之前接了个课程设计辅导,对方直接甩给我一个标题:“史上功能最全Web购物车实现(基于jsp+java+servlet+dao)包括后台图书管理、实现数据库分页等功能”。这标题看起来像是一个学习项目,但听完需求才发现,这其实是一整套Java Web单体应用的综合练习:前台要有商品浏览、购物车操作、模拟结算,后台要有图书管理、图片上传、模糊搜索,还要在列表页玩出数据库分页的花样。
这种项目在初学者眼里是“期末作业天花板”,在面试场景里又是常被拿来聊的经典模型。很多同学能把“往购物车里塞一本书”的流程跑通,但一旦涉及后台管理、分页、会话隔离、连接池管理,就会暴露出大量问题。这篇文章我就以这套模拟项目X为蓝本,从数据库设计、DAO封装、Servlet控制、购物车会话操作、后台分页管理到实际排坑,完整拆一遍实现思路。适合正在做Java Web课程设计的人,也适合想用购物车项目系统整理Servlet/JSP/DAO知识点的同学参考。
1. 购物车项目到底“全”在哪里:先从标题拆出功能矩阵
1.1 “功能最全”不是功能堆砌,而是三层结构都要完整
很多人一看到“史上功能最全”就觉得是夸张,但用来做课程设计或面试作品,这句话其实有它的道理。它不是在说购物车本身能玩出多少花样,而是指整个项目覆盖了Java Web开发里最核心的三层结构:表现层、控制层、数据访问层。表现层用JSP承担页面渲染,控制层用Servlet接收请求并控制页面跳转,数据访问层通过DAO完成对数据库的增删改查。购物车只是把这三层串起来的那条业务线。
这套模拟项目X的业务线主要有两条。一条是前台面向普通用户的浏览与购物链路:分页查看图书列表、查看图书详情、登录、加入购物车、修改购物车数量、删除单项、清空购物车、查看总金额。另一条是后台面向管理员的图书管理链路:管理员登录校验、图书列表、新增图书、编辑图书、删除图书、封面上传、按书名或分类模糊搜索。这两条链路通过同一套图书表关联起来,后台改了数据,前台列表和购物车会跟着变。
标题里特别点出的“数据库分页”,其实是后台图书列表用的能力,但前台图书分页也应该实现,否则后台能用、前台不能用,会显得项目很割裂。我一并做了,前后台共用同一个分页Bean和同一个DAO查询方法。所以项目的“全”,指的是前台交易链路、后台管理链路、通用分页机制、会话与权限控制这几块都有自己的落地,而不是简单堆了一堆按钮。
1.2 适合谁拿去练手,以及练完能沉淀什么
如果你是刚学完JavaWeb基础、准备做课程设计的同学,这套项目至少能帮你把如下知识点串成一条线:JDBC操作MySQL、PreparedStatement防注入、DAO层的结果集映射、连接池配置、Servlet请求分发、Session购物车状态、Filter权限拦截、EL表达式与JSTL标签、文件上传、分页SQL写法。这些都是面试Java后端岗位时的高频问题,也是工作中很容易碰到的日常开发场景。
如果你是准备求职、想要一个项目写进简历的同学,我不建议你停在这个“前后台+分页”的版本。更合理的做法是先把这个版本完整跑通,然后在此基础上做减法或加法。减法指的是把JSP页面里的Java脚本彻底清干净,全部换成EL和JSTL,提升代码可读性;加法指的是引入Maven、把数据库连接改成Druid连接池、加入订单表和库存扣减逻辑、把分页改成Ajax异步加载。这样这个“购物车”项目就能从课程设计水平往工程化水平靠拢。
1.3 项目目录结构与模块划分参考
我这里采用的包结构是这样的,大家可以作为参考:com.demo.entity放实体类Book、User、CartItem、PageBean;com.demo.dao放BookDAO、UserDAO,以及BaseDAO这个通用父类;com.demo.service放业务逻辑,比如BookService、CartService;com.demo.servlet放BookServlet、CartServlet、UserServlet、AdminServlet;com.demo.filter放登录过滤器;com.demo.util放DBUtil、UploadUtil等工具类。JSP页面则在WebContent下按前台和后台分成front和admin两个目录。
这种分法不花哨,但很实用。它把“谁负责取数据”“谁负责算业务”“谁负责展示”分得清清楚楚,后续扩展订单功能时,只需仿照BookDAO再加一个OrderDAO,业务逻辑放进OrderService,页面再加几个JSP,整体不会乱。
2. 从数据库第一行建表开始:图书、用户与购物车数据模型怎么设计才不返工
2.1 三张核心表的字段取舍
做Web项目,我习惯先定数据库表结构,因为表结构直接决定了DAO层怎么写、前端页面要展示哪些字段。这套模拟项目X里,最基础的是图书表(book)、用户表(user)、购物车表(cart_item)。很多同学做购物车时不建购物车表,把购物车整个放到Session里,这在前台免登录场景下是可以的,后面我会详细说。但如果要做“后台管理”和“用户体系”,建议还是把购物车表建出来,一方面体现数据建模能力,另一方面为以后做“未登录收藏、登录后合并购物车”的功能留扩展点。
图书表我推荐的字段结构是:id、title(书名)、author(作者)、price(定价)、stock(库存)、cover(封面图路径)、category(分类)、publisher(出版社)、description(简介)、create_time(创建时间)。这里有三点容易踩坑。第一,价格字段必须用DECIMAL(10,2),千万不能用float或double,否则金额会出现0.1+0.2不等于0.3这类浮点误差;第二,库存字段用INT,并建议在查询列表时加一个stock > 0条件,这样“已售罄”的图书不会出现在前台;第三,封面路径不要存完整网址或绝对路径,只存相对路径比如upload/xxx.jpg,页面渲染时动态拼接项目上下文路径,方便项目整体迁移。
用户表字段是:id、username、password、nickname、role、create_time。这里的角色字段用来区分普通用户和管理员,普通用户浏览前台、购买图书,管理员进入后台管理图书。密码字段需要强调一下:明文存储是绝对不允许的,哪怕只是练手项目,也至少要用MD5加固定盐或者SHA-256做一次散列,至少要在简历和文档里体现出你有这个安全意识。实际项目中还会加动态盐、加次数迭代,但课程设计阶段做到固定盐哈希已经算过关了。
2.2 购物车表建不建,怎么建
购物车表按“用户+图书”维度去重存储,建议字段为:id、user_id、book_id、quantity、add_time,并给user_id + book_id加唯一索引。这样同一个用户重复添加同一本图书时,可以走ON DUPLICATE KEY UPDATE quantity = quantity + 1,或者先在DAO里查一次,再决定update还是insert。这个设计在面试里非常容易聊出亮点,因为大多数人只会往Session里塞Map,很少会想到把购物车落库。
我的建议是两者结合:用户未登录时,购物车数据放Session,保证游客也能加购;用户登录后,把Session里的购物车合并到数据库,以数据库购物车为准。这套模拟项目X因为篇幅和篇幅限制,前台购物车主流程还是以Session为主,但数据库表已经提前建好,关键代码里也留了CartDAO的空实现,方便后续扩展。你要是想做得更完整,可以把登录成功后的购物车合并逻辑补上。
2.3 建表SQL与初始化数据
下面是基本可用的建表SQL,MySQL环境下直接执行即可。如果你是SQL Server或Oracle,只需把自增主键的写法对应替换。
CREATE DATABASE IF NOT EXISTS shop_db DEFAULT CHARSET utf8mb4; USE shop_db; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(255) NOT NULL, author VARCHAR(100), price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 100, cover VARCHAR(255), category VARCHAR(50), publisher VARCHAR(100), description TEXT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, nickname VARCHAR(50), role VARCHAR(20) DEFAULT 'user', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE cart_item ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, add_time DATETIME DEFAULT CURRENT_TIMESTAMP, UNIQUE KEY uk_user_book (user_id, book_id) ); INSERT INTO book (title, author, price, stock, cover, category, publisher, description) VALUES ('Java编程思想', '某经典作者', 89.90, 200, 'upload/java.jpg', '编程', '某出版社', 'Java进阶必读'), ('深入理解计算机系统', '某作者团队', 128.00, 150, 'upload/csapp.jpg', '计算机', '某出版社', '系统底层经典'), ('数据库系统概论', '某数据库作者', 45.00, 300, 'upload/db.jpg', '数据库', '某出版社', '数据库入门教材');这段SQL里加了个小细节:DEFAULT CHARSET utf8mb4而不是utf8。原因很简单,utf8mb4是完整的UTF-8实现,能存下一些生僻字和特殊符号,避免中文乱码的隐性坑。
3. 分层不是应付面试:JSP、Servlet、DAO的调用链与职责边界
3.1 一次图书列表请求,在代码里到底怎么走
我见过太多JSP项目,页面里直接写JDBC连接、直接查库、直接拼HTML,逻辑确实能跑,但后续修改一次就要动一次页面,维护成本极高。好的做法是严格分层,让请求走一条清晰链路。以“前台第一页图书列表”为例,完整的调用顺序是:浏览器访问BookServlet?action=list&page=1,Servlet接收请求并解析action和page参数,调用BookService或直接调用BookDAO查询出PageBean对象,把PageBean放到request作用域,再转发到front/book_list.jsp。JSP页面用EL和JSTL取出PageBean里的list和分页信息,渲染成HTML,浏览器展示。
这套链路里最容易写歪的地方是参数解析。比如page参数用户可能传abc,也可能传-1,如果不做容错处理,Integer.parseInt会直接抛异常。我的做法是写一个内部小工具方法,把字符串转int,转换失败就返回默认值1,同时如果页码小于1就重置为1,大于总页数就重置为总页数。这样用户在地址栏乱改参数,页面也不会白屏。
3.2 DAO层只做数据读写,别把业务塞进来
DAO层的核心是“一行数据映射成一个对象”。JDBC查询返回的是ResultSet,使用起来不直观,所以DAO里的职责就是执行SQL、遍历ResultSet、把每一行封装成Book对象,然后返回List<Book>或单个Book。这个过程是重复劳动,但也是最有必要写好的一环。
我常用的封装方法是抽取一个Book getBookFromResultSet(ResultSet rs)私有方法,或者用一个小心的工具类,把rs.getInt("id")、rs.getString("title")、rs.getBigDecimal("price")都集中到一个地方。这样不管是按ID查询、分页查询还是模糊搜索,都能复用同一套映射逻辑,不会出现“这个页面少了个字段、那个页面类型转换异常”的问题。
连接管理方面,我建议用连接池而不是每次手动DriverManager.getConnection。手动获取连接在并发场景下会反复创建和销毁数据库连接,性能很差。模拟项目X里用的是DBCP连接池,配置文件写在db.properties里,连接池参数按学生机配置调整:初始连接数5、最大连接数20、最大等待时间3000毫秒。实际练习时,Druid也可以,配置方式大同小异。
3.3 Servlet层是“交通警察”:转发还是重定向要想清楚
Servlet比较容易翻车的地方,是不清楚“请求转发”和“重定向”该用哪个。简单的判断标准是:如果这次请求结束后页面需要带数据,比如图书列表、购物车详情、后台图书管理列表,就用request.getRequestDispatcher("xxx.jsp").forward(request, response);如果是表单提交后想防止刷新重复提交,比如添加图书成功后跳回列表页,就用response.sendRedirect("BookServlet?action=list")。转发是同一个请求内带着request作用域的数据到目标页面,重定向是浏览器重新发起一次全新请求,request里的数据自然就丢了。
这套逻辑在购物车里尤其重要。用户点击“加入购物车”,如果Servlet处理完后转发到购物车页面,那用户按F5刷新时浏览器会重新提交一次“加入购物车”请求,导致同一本图书被加了两次。正确做法是“加购成功后重定向到购物车展示URL,购物车展示这个动作才用转发”。这也是我刚才提到的RedirectAfterPost模式,在表单提交场景里属于基础设施级别的技巧。
4. 购物车会话玩法的四种操作:加购、改数量、删除、清空的完整实现
4.1 Session里的购物车到底是个什么结构
购物车最核心的载体是Session对象。Session是跟着用户会话走的,每个浏览器对应独立的一份,天然隔离,不需要考虑两个人互相污染数据。购物车在Session里的结构,我推荐用HashMap<Integer, CartItem>,key是bookId,value是CartItem对象。CartItem里除了持有Book对象,再单独存一个quantity数量字段。
为什么用Map而不是List?因为Map通过bookId判断“这本书在不在购物车里”的代价是O(1),而用List就得一遍遍遍历才能知道里面有没有这本书。Map在执行“加购时数量累加”“修改数量时直接set”这两类高频操作时非常顺手。CartItem这个实体类属于典型的POJO,需要实现Serializable接口,因为Tomcat在做Session钝化或集群Session同步时,如果类没实现序列化接口,会抛出NotSerializableException。这个细节很多人没注意,直到部署到集群环境才被坑。
4.2 加购与数量修改的代码骨架
加购流程的Servlet核心逻辑大致如下:
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String action = request.getParameter("action"); if ("add".equals(action)) { int bookId = Integer.parseInt(request.getParameter("bookId")); int quantity = Integer.parseInt(request.getParameter("quantity")); if (quantity < 1) { quantity = 1; } HttpSession session = request.getSession(); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); session.setAttribute("cart", cart); } Book book = bookDAO.findById(bookId); if (book == null) { response.sendRedirect("front/error.jsp?msg=book_not_found"); return; } CartItem item = cart.get(bookId); if (item == null) { cart.put(bookId, new CartItem(book, quantity)); } else { item.setQuantity(item.getQuantity() + quantity); } response.sendRedirect("CartServlet?action=view"); } }这段代码要额外处理一个边界:库存上限。如果用户加了5本,实际库存只有3本,服务端要么提示“库存不足”,要么把数量封顶到3。我建议在加购和修改数量两处都校验一下quantity <= book.stock,否则管理员把库存调小之后,购物车里还可能挂着一个超卖数量,结算时会非常尴尬。
修改数量的操作和加购类似,前端可以把操作类型拆成increase和decrease,或者直接传一个最终数量值。暴力但稳妥的方案是传最终数量值,服务端做边界判断后item.setQuantity(quantity)。如果传的是“加减一”的增量,就要注意快速点击时请求并发,可能出现旧值覆盖新值的脏写。课程设计阶段用最终数量值方案就好,代码简单,逻辑清晰。
4.3 删除、清空与金额计算的边界处理
删除单项就是cart.remove(bookId),然后判断一下如果cart为空就把Session里的“cart”属性也移除,避免留下一个空Map造成前端显示异常。清空购物车更简单,直接session.removeAttribute("cart")。这两个操作几乎没有技术难度,真正需要费心的是金额计算和空购物车展示。
金额计算我强烈建议用BigDecimal,而不是把单价和数量转成int去乘再除以100。虽然以分为单位用整数可以规避浮点误差,但JSP页面渲染、用户输入、价格存储全都是以“元”为单位,来回换单位容易出错。直接new BigDecimal(price).multiply(new BigDecimal(quantity))虽然多写几个单词,但可读性高,也不会出现精度问题。最后在JSP里用fmt:formatNumber标签保留两位小数输出,界面干净整洁。
另一个边界是“图书下架”。如果管理员在后台把某本图书删除或把库存改为0,用户Session里还残留着这本图书,点击结算或修改数量时会查不到Book对象。我的策略是:每次渲染购物车页面时,遍历cart中的每一项,根据bookId重新查库,查不到的就直接从Map里移除,并在页面上提示“部分商品已失效”。这样能保证购物车数据和库存状态尽量一致。
5. 数据库分页是标题里最值钱的部分:物理分页SQL与PageBean封装
5.1 分页三要素与LIMIT参数计算
数据库分页是这个项目里最值得展开讲的知识点,也是面试官最爱追问的“为什么不用内存分页”。先说什么叫物理分页:数据库每页只返回需要的记录条数,比如每页10条,第二页就通过LIMIT 10 OFFSET 10只拿第11到20条。执行两次SQL,一次查总数,一次查当前页数据。与之相对的内存分页是一次性把所有数据SELECT *查出,然后JVM里截取需要的部分,这在小数据量时看不出问题,但图书表一旦有几十万条,内存和响应时间都会崩。
分页需要三个基本参数:当前页currentPage、页大小pageSize、总记录数totalCount。totalCount来自SELECT COUNT(*) FROM book,总页数totalPage的计算公式是(totalCount + pageSize - 1) / pageSize。当前页的数据查询SQL是SELECT * FROM book ORDER BY id DESC LIMIT ?, ?,第一个问号是offset,第二个是limit。offset的计算公式是(currentPage - 1) * pageSize,注意不是currentPage * pageSize,第二页的offset是10而不是20,这是初学最容易写错的点。
5.2 能不能只查一次就拿到分页数据
MySQL的LIMIT本身就带起始位置和条数,所以查询当前页数据时天然物理分页。有些同学会问:能不能用LIMIT ? OFFSET ?代替两个问号?语法上可以,但用PreparedStatement的动态参数绑定其实都一样的,我习惯写成LIMIT ?, ?,第一个参数是offset,第二个是size,顺序千万别反。
还有一个小优化点:当数据量很大的时候,深分页LIMIT 100000, 10会因为数据库要扫描并丢掉前10万行而变慢。更优的写法是利用主键做条件,先查出上一页最后一条记录的ID,再WHERE id < 上一页最大ID ORDER BY id DESC LIMIT 10,这样数据库能走主键索引。但这种键集分页在不改代码结构的前提下不容易在通用PageBean里实现,所以我一般建议课程设计阶段用标准LIMIT offset, size即可,但要在文档里写出这个优化思路,面试时会加分。
5.3 PageBean对象与前置条件校验
PageBean是分页功能的核心载体,我建议做成泛型类,比如PageBean<T>,不管图书列表还是用户列表都能复用。字段包括:List<T> list存放当前页数据,int currentPage当前页,int pageSize页大小,int totalCount总记录数,int totalPage总页数,再提供一个boolean isHasNext判断是否有下一页。JSP页面渲染分页导航时,有了这些字段就足够拼出上一页、下一页、页码列表。
DAO层的方法签名建议写成:
public List<Book> findPage(int offset, int pageSize) throws SQLException;然后在Service里计算offset:
int offset = (currentPage - 1) * pageSize;这样DAO不关心业务层怎么计算页码,参数干净,后续想加缓存或改查询条件也不受影响。Service层还需要在查询总数之后校验当前页越界,比如删除最后一页的最后一条数据后,当前页可能超出总页数,这时候要把currentPage重置为totalPage,再重新查询一次。
5.4 分页和模糊搜索同时存在的SQL组织方式
标题里的“后台图书管理”通常离不开搜索,搜索和分页叠在一起才是完整需求。搜索条件下,列表查询SQL和总数查询SQL都必须带同样的WHERE条件,否则页数对不上。查询SQL形如:
SELECT * FROM book WHERE title LIKE ? [AND category = ?] ORDER BY id DESC LIMIT ?, ?;总数SQL形如:
SELECT COUNT(*) FROM book WHERE title LIKE ? [AND category = ?];这里需要注意PreparedStatement的参数索引:如果先拼接了搜索条件,再追加分页参数,参数顺序必须和SQL里的问号顺序完全一致。常见Bug是WHERE子句里有如果分类为空就不拼,结果参数个数和问号数对不上,执行时直接报Parameter index out of range。我建议统一用一个List<Object> params累积参数,最后再转成数组传给PreparedStatement的参数设值循环,这样最稳妥。
模糊搜索还有一个细节:LIKE ?的参数要手动拼上%,比如%Java%,而不是在SQL里写LIKE '%?%'。后者会把?当成字符串的一部分,查出来的结果是空。
6. 后台图书管理实战:登录拦截、图片上传、模糊搜索一起落地
6.1 Filter拦截:后台页面不能裸奔
后台管理页面必须具备权限控制,否则任何人都可以直接输入admin/book_list.jsp看到管理界面。靠每个Servlet里写一遍“检查session里有没有loginUser”是能实现,但重复代码太多,而且JSP如果被直接访问,Servlet根本不会经过。所以正确姿势是写一个Filter,把后台访问请求统一拦下来做登录判断。
我建议Filter按“路径前缀”做拦截,比如/admin/*和/AdminServlet。Filter核心逻辑并不复杂:从Session里取管理员信息,存在就放行,不存在就重定向到登录页。由于后台管理员的登录页本身不能拦截,还要在Filter里加一个白名单判断,把login.jsp、loginServlet放行。Web.xml里配置Filter映射时注意顺序,多个Filter时会按配置顺序依次执行,权限Filter尽量排在编码Filter后面,否则乱码问题还没处理就先被拦截跳转,排查起来很痛苦。
6.2 图片上传:Tomcat下用getPart还是Commons FileUpload
图书封面是后台图书管理的标配字段。传统做法是用Apache Commons FileUpload组件解析multipart表单,但对Servlet 3.0以上版本来说,直接用request.getPart("cover")更简单,前提是Servlet类上要标注@MultipartConfig注解,同时在JSP表单里设置enctype="multipart/form-data",这两个条件缺一个都拿不到文件内容。
保存文件的思路是这样:先拿到上传文件名,但一定不要直接用原始文件名保存,因为不同用户可能会传同名文件,会互相覆盖。我用UUID.randomUUID().toString().replace("-", "")拼上原文件扩展名,生成唯一文件名,然后把文件写到WebContent/upload目录下。数据库里存的是upload/abc123.jpg这种相对路径。页面上渲染时再拼接request.getContextPath(),得到完整的/shop/upload/abc123.jpg路径。这个拼接如果漏了,直接写相对路径,在部署到带ContextPath的服务器时会面临图片404的困扰。
还有文件大小限制问题,@MultipartConfig(maxFileSize = 1024 * 1024 * 2)限制单文件2MB,后台捕获IllegalStateException后返回友好提示,避免用户传一个10MB的图片直接让服务器响应异常。
6.3 图书新增、编辑、删除的后台操作细节
新增图书的表单和编辑图书的表单可以共用一套JSP页面,只是提交目标不同。新增走AdminBookServlet?action=insert,编辑走AdminBookServlet?action=update,页面通过EL表达式判断传入的book对象是否为空,为空则是新增,不为空则回填字段。回填的时候注意checked状态和下拉框选中项,要用JSTL的c:if去动态输出,常见错误是只在编辑页写死默认选项,导致新增和编辑行为不一致。
删除图书时,除了执行DELETE FROM book WHERE id=?,还要考虑要不要删封面图片文件。如果封面文件确实上传到了本地磁盘,可以先查一次book记录拿到cover路径,再执行删除SQL,最后用java.io.File删除文件。这里要小心路径穿越:删除文件名不能直接拼接用户传入的相对路径,而应该从数据库里取出的路径来删,防止有人构造../../xxx路径。不过课程设计阶段只要做到“路径来自数据库”就已经算有安全意识到了。
7. 中文乱码、搜索分页丢失、购物车快照过期:真正恶心的坑都在这里
7.1 中文乱码的三个传播节点
JSP + Servlet + MySQL这套组合里,中文乱码通常不是某个单一节点造成的,而是一串节点层层累积的结果。第一个节点是请求参数编码:POST请求参数要调用request.setCharacterEncoding("UTF-8"),且必须在拿到任何参数之前设置;GET请求的URL参数编码取决于服务器连接器配置,Tomcat 8以上默认是UTF-8,老版Tomcat则需要在server.xml里加URIEncoding="UTF-8",否则地址栏传中文搜索词必然乱码。第二个节点是响应编码:response.setContentType("text/html;charset=UTF-8")要写在最前面,同时JSP页面头部的pageEncoding和contentType里的charset也要一致。第三个节点是数据库连接URL,必须在JDBC连接串里加useUnicode=true&characterEncoding=UTF-8,否则数据库客户端和服务器端编码不一致,写入的中文会变成问号。
排查这段流程有一个实用技巧:先把页面上显示的中文和数据库里存的中文对比,如果在页面输入时就已经乱码,问题在请求参数编码;如果页面显示正常、数据库里是乱码,问题在连接URL;如果数据库正常、页面乱码,问题在响应编码。按这个思路排查,比随机改配置高效得多。
7.2 分页导航一点就把搜索条件弄丢了
这个坑在图书管理后台特别常见:用户在第一页搜索“Java”得到结果,点击第二页,结果却变成了全部图书,因为分页导航里的链接只写了BookServlet?action=list&page=2,完全没有带上搜索关键词。要修复这个bug,必须在拼接分页链接时把当前搜索条件原样带上。
我建议在Servlet接收参数时,把搜索条件同时放入request作用域,比如keyword、category。然后在JSP的分页导航里拼链接时,用JSTL做一次URL参数拼接,链接变成BookServlet?action=list&page=2&keyword=${param.keyword}&category=${param.category}。这里有个额外提示:如果keyword中含中文或特殊字符,最好做一次URL编码,否则部分服务器解析会异常。这种情况下可以用fn:replace或者维护一个专门输出当前搜索条件的MVC辅助方法,保证URL合法。
7.3 购物车里的价格快照和图书下架问题
设计购物车时,为了减少查库压力,很多人会直接把完整Book对象塞进Session,页面直接取cartItem.book.price展示。这样有个隐患:管理员在后台改了图书价格或库存后,用户Session里存的还是旧数据,页面照旧显示旧价格,结账时可能会按旧价成交,也可以按旧库存判断,这都属于业务逻辑漏洞。
我的处理方案是:Session里只存bookId和quantity,购物车页面每次渲染前,由CartService根据bookId集合批量重新查库,组装出最新的CartItem列表。这样价格、书名、封面永远以数据库为准,购物车里绝不放完整的可变业务数据。代价是多一次查询,但这个项目规模完全可以接受。配合前面第4.3节提到的失效商品清理逻辑,购物车模块就只依赖当前数据库状态,不容易出现脏数据。
这个方法也引出一个面试常见题:Session和数据库购物车如何选择。Session的优点是免登录、无数据库压力、实现简单,缺点是用户换浏览器或清Cookie就丢失;数据库的优点是跨设备同步、可持续存储,缺点是每次访问都要查库,同时未登录用户无法关联用户ID。所以现网电商大多用Redis做临时购物车,登录后合并到数据库,但那是后话了。
7.4 表单重复提交与连接释放
表单重复提交导致数据重复,是Web初学者耳熟能详的坑。比如后台新增图书时,用户点击“保存”后长时间没有响应,于是再点一次,结果数据库里插入了两条一模一样的图书。处理方式就是我前面提到的“POST后重定向”,请求处理完成之后不再转发到JSP页面,而是重定向到图书列表Servlet,浏览器地址栏变成列表URL,用户再刷新也只是刷新列表,不会重新提交POST数据。
连接释放的问题则藏得更深。DAO层在finally块里只关闭了ResultSet和Statement,但连接是从连接池借出来的,如果没有关闭,连接并不会回到池中,而是被占用。高并发下一旦连接池被耗尽,整个应用会卡在等待连接上。正确做法是:从连接池拿连接时不管是否发生异常,finally里都要调用conn.close(),这里close的真实逻辑是归还连接而不是物理断开。使用DBCP连接池时,还可以开启removeAbandoned=true和logAbandoned=true,帮助排查连接泄漏。
8. 从课程设计到工程化:下一步还能怎么升级
这套购物车骨架跑通之后,如果想把它从“期末作业”升级成“能写进简历的作品”,我建议优先做三件事。第一件事是把原生的JDBC DAO迁移到MyBatis或Spring JDBC Template,配置上Druid连接池,让数据访问层更直观也更易维护。第二件事是给图书表加订单表和订单明细表,同时把库存扣减做成事务,防止并发下单出现超卖。第三件事是引入Maven统一管理依赖,把JSP里的脚本彻底清理干净,并把分页改成Ajax局部刷新,减少整页跳转的呆板感。
我个人在带这个模拟项目X时的体会是,购物车虽小,但它把Web开发的各个关键节点都串了一遍。很多同学卡住的不是技术难点,而是“不知道下一步该做什么”。如果你现在正写这个项目,先把后台图书管理和分页这两个硬骨头啃下来,再回头去优化购物车的交互,整个项目的完成度就会有明显提升。最后分享一个小技巧:开发时尽量把日志打印完整,尤其是SQL参数和异常堆栈,购物车这类状态型项目,大部分诡异bug都是“看不出数据在哪里丢的”导致的。
希望这篇拆解能帮到正在做Java Web课程设计或者准备面试项目的你。有问题欢迎在评论区交流,我会挑有代表性的问题继续展开。