Servlet+JSP+MySQL实现购物车系统:JavaWeb入门项目全解析 简介一套基于 javaWeb、Servlet 与 Tomcat 的购物车系统实现面向 Java Web 初学者和课程实践场景适合用来理解 Servlet 如何接收请求、处理业务并完成页面跳转。项目以商品展示为入口点击“添加到购物车”超链接后由 Servlet 记录所选商品信息与选购数量再转发到另一个 Servlet 以表格形式展示购物车内容完整覆盖从页面交互到服务端处理的常见流程也便于在此基础上扩展登录、订单等模块。压缩包共 25 个文件大小约 491KB包含 Java 源文件、class 编译文件、XML 配置、HTML 商品页、JPG 运行截图以及 Eclipse 工程元数据目录结构保留 src、build、WebContent 等模块便于直接对照学习和复查。运行截图可直观展示页面效果与结果表格辅助排查商品信息、数量记录等问题工程配置信息完整用 Eclipse 打开后即可较快进入实战练习。已有 638 人学习既可用于 Servlet 入门作业也可作为购物车功能原型的参考实现。1. 为什么简单的购物车系统是JavaWeb入门最绕不开的项目很多人在刚学完Servlet和JSP之后都会遇到一个尴尬的处境语法都认识但不知道怎么拼成一个能跑的Web应用。用户注册、博客、后台管理这类项目虽然经典但要么涉及文件上传、富文本、权限这些容易把人劝退的附加功能要么业务逻辑弱到只能练CRUD。而购物车系统恰好卡在中间——它是以javawebservlet为主线的教学项目里难度最合适、覆盖面最全的一个要连MySQL、要写DAO、要用Session管理会话、要处理表单提交和重定向、还要做金额计算。我见过不少初学者在这个项目上第一次真正理解“HTTP是无状态的”这句话是什么意思。这篇文章我按自己做过的一套方案给你拆开讲清楚从环境搭建到Servlet处理购物车业务再到五六个高频翻车点最后补两个实用的小优化。你跟着走一遍相当于把JavaWeb的核心链路完整跑通了。2. 用IDEA Maven搭出最小可运行的JavaWeb骨架2.1 开发环境与Tomcat版本选型在动手写代码前先把版本问题定下来这一条能省掉你后面很多折腾。常见做法是JDK 1.8 Tomcat 8.5 或 Tomcat 9 Servlet 3.1/4.0这套组合在IDEA里跑javaweb项目最成熟网上能搜到的问题记录最多。注意一个关键点如果用了Tomcat 10或更高版本Servlet的包名从 javax.servlet 变成了 jakarta.servlet你搜索资料时看到的旧教程代码基本都要改名才能跑对初学者非常不友好。我的建议是直接用Tomcat 8.5或9跟着javax的代码走踩坑少、资料多、配起来顺。IDEA里运行javaweb项目配置这一步最大的坑在于很多人不理解IDEA的“配置Tomcat”和“部署war包”是什么关系。你只需要记住IDEA里跑Web项目本质上是把项目打成war包然后丢给Tomcat加载。所以配置分两步——第一步在Run/Debug Configurations里新建一个Tomcat Server指定本地Tomcat路径第二步在Deployment选项卡里把当前项目的war包加进去Application context填/cart。这样启动Tomcat后访问地址就是http://localhost:8080/cart/。2.2 创建工程与pom.xml最小依赖用Maven建工程而不是直接建普通JavaWeb项目。Maven帮你管依赖后面要加MySQL驱动、Jackson、JSTL这些包时只需要往pom.xml里黏一段即可不用再手动去下载jar包放到WEB-INF/lib目录下。如果你的IDEA新建项目时没有Maven选项选“Maven Archetype”里的maven-archetype-webapp模板即可。project xmlnshttp://maven.apache.org/POM/4.0.0 xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd modelVersion4.0.0/modelVersion groupIdcom.example/groupId artifactIdcart-demo/artifactId version1.0-SNAPSHOT/version packagingwar/packaging properties maven.compiler.source1.8/maven.compiler.source maven.compiler.target1.8/maven.compiler.target /properties dependencies !-- servlet 官方API -- dependency groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency !-- JSP API -- dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency !-- JSTL 标签库页面里遍历购物车用 -- dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency !-- MySQL 驱动 -- dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.25/version /dependency /dependencies /project这里最值得说的是javax.servlet-api的scope必须是provided。理由很简单Tomcat自己已经带了Servlet实现你打包时如果再把这个jar包塞进去运行时就会和Tomcat自带类冲突典型症状是启动后各种NoSuchMethodError或ClassCastException。这个坑在初学时非常常见不少人跑来问为什么自己用Maven建的Servlet项目一运行就报错八成是这个scope写错了。2.3 项目目录结构与Tomcat部署建好工程后目录结构长这样才算标准。src/main/java放Java源码src/main/webapp放JSP、CSS、图片等静态资源。web.xml在src/main/webapp/WEB-INF/web.xml下这是Servlet的注册中心。我习惯在web.xml里加上一段session-config把Session超时时间设置为30分钟防止调试时Session一会儿就没了web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-namecart-demo/display-name session-config session-timeout30/session-timeout /session-config welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list /web-app上面这段配置的含义是用户30分钟内不操作Session自动失效购物车也随之清空。如果不写session-timeout默认是30分钟但很多容器默认值不一致显式写出来更可靠。welcome-file指定访问项目根路径时默认打开index.jsp。3. 数据表与购物车模型把“什么是购物车”先想清楚3.1 MySQL建表与种子数据购物车本身不需要建表因为它是典型的会话级数据用户在逛的时候把商品临时放进购物车下单之后这些数据就该清空。真正要落库的是商品表。我用图书举例因为字段好理解、数据好造。建表SQL如下CREATE DATABASE IF NOT EXISTS cart_db DEFAULT CHARACTER SET utf8mb4; USE cart_db; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, author VARCHAR(50), price DECIMAL(10,2), sales INT DEFAULT 0, img VARCHAR(255) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO book (name, author, price, sales, img) VALUES (Java核心技术卷I, Cay S. Horstmann, 119.00, 3200, img/java-core.jpg), (MySQL必知必会, Ben Forta, 49.00, 5600, img/mysql.jpg), (深入理解Java虚拟机, 周志明, 89.00, 2800, img/jvm.jpg), (Spring实战第6版, Craig Walls, 99.00, 1800, img/spring.jpg);注意字段price用DECIMAL(10,2)而不是FLOAT或DOUBLE。购物车里的金额计算将来要精确到分用浮点类型会出现0.10.20.30000000000000004这种经典问题。数据库层面先按精确小数存代码层面后面第6章还会再用一次BigDecimal双保险。字符集用utf8mb4能存下emoji和生僻字避免中文乱码。3.2 实体类与DAO层用JDBC连接数据库实体类对应book表的行数据。注意价格字段类型是BigDecimal不要用double。package com.example.entity; import java.math.BigDecimal; public class Book { private Integer id; private String name; private String author; private BigDecimal price; private Integer sales; private String img; // 无参构造、getter/setter 省略IDE自动生成 Override public String toString() { return Book{id id , name name , price price }; } }然后写一个JdbcUtils工具类封装数据库连接的创建和关闭。这个工具类在项目里所有DAO中复用避免每个DAO都写一遍Class.forName和DriverManager.getConnection。JDBC连接MySQL数据库是javaweb项目完整案例中的必考内容但大多数人第一次写都会忘记处理时区问题。package com.example.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class JdbcUtils { private static final String URL jdbc:mysql://localhost:3306/cart_db?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(MySQL驱动加载失败检查pom依赖); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(AutoCloseable... resources) { for (AutoCloseable r : resources) { if (r ! null) { try { r.close(); } catch (Exception ignored) { } } } } }URL里serverTimezoneAsia/Shanghai是MySQL 8.0的硬性要求不加上去会报时区错误。characterEncodingutf8确保读写中文不乱码。useSSLfalse是本地开发时的配置正式环境建议用true并配置证书。密码直接写在代码里是开发环境的做法真实项目里应该放到配置文件里用环境变量注入。DAO层写BookDao提供两个方法查所有商品和按id查单个商品。查询之后要立刻关闭Connection、PreparedStatement、ResultSetJavaWeb的经典血泪经验就是数据库连接资源不释放搞到后面连接池满了整个应用卡死。package com.example.dao; import com.example.entity.Book; import com.example.util.JdbcUtils; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.util.ArrayList; import java.util.List; public class BookDao { public ListBook findAll() { String sql SELECT id, name, author, price, sales, img FROM book; ListBook list new ArrayList(); Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn JdbcUtils.getConnection(); ps conn.prepareStatement(sql); rs ps.executeQuery(); while (rs.next()) { list.add(mapRow(rs)); } } catch (Exception e) { e.printStackTrace(); } finally { JdbcUtils.close(rs, ps, conn); } return list; } public Book findById(Integer id) { String sql SELECT id, name, author, price, sales, img FROM book WHERE id?; Connection conn null; PreparedStatement ps null; ResultSet rs null; try { conn JdbcUtils.getConnection(); ps conn.prepareStatement(sql); ps.setInt(1, id); rs ps.executeQuery(); if (rs.next()) { return mapRow(rs); } } catch (Exception e) { e.printStackTrace(); } finally { JdbcUtils.close(rs, ps, conn); } return null; } private Book mapRow(ResultSet rs) throws Exception { Book book new Book(); book.setId(rs.getInt(id)); book.setName(rs.getString(name)); book.setAuthor(rs.getString(author)); book.setPrice(rs.getBigDecimal(price)); book.setSales(rs.getInt(sales)); book.setImg(rs.getString(img)); return book; } }mapRow这个方法是我个人的习惯把ResultSet到实体类的映射单独抽出来两个查询方法都能复用它。如果不抽findAll和findById里会有两段几乎相同的rs.getXxx代码看多了容易眼花。另外e.printStackTrace()在开发阶段可以用真上线了应该换成日志框架记录。3.3 购物车的数据结构为什么选MapString, CartItem现在到了这个项目最核心、也最值得想清楚的地方购物车在Java代码里用什么数据结构很多初学者第一反应是用ArrayList存Book对象。但仔细想想会发现问题用户往购物车加了3次《Java核心技术卷I》如果直接用List里面会有3个相同的Book对象显示的时候很啰嗦改数量时要遍历列表逐条更新删一条还要考虑删哪条。更合理的方案是MapString, CartItemkey是商品idvalue是“商品 数量”的组合对象。这样同一个商品只占一条记录加购时直接更新数量就行。package com.example.entity; import java.math.BigDecimal; public class CartItem { private Book book; private Integer quantity; public CartItem(Book book, Integer quantity) { this.book book; this.quantity quantity; } public Book getBook() { return book; } public void setBook(Book book) { this.book book; } public Integer getQuantity() { return quantity; } public void setQuantity(Integer quantity) { this.quantity quantity; } public BigDecimal getSubtotal() { return book.getPrice().multiply(new BigDecimal(quantity)); } }getSubtotal()返回条目小计。用BigDecimal的multiply方法做乘法而不是book.getPrice() * quantity。price是BigDecimalquantity是IntegerJava不会自动把两者相乘强行用乘号会直接编译报错这反而逼着你用正确的方式。购物车本身也是一个类package com.example.entity; import java.math.BigDecimal; import java.util.LinkedHashMap; import java.util.Map; public class Cart { private MapInteger, CartItem items new LinkedHashMap(); public void add(Book book, Integer quantity) { CartItem item items.get(book.getId()); if (item null) { items.put(book.getId(), new CartItem(book, quantity)); } else { item.setQuantity(item.getQuantity() quantity); } } public void remove(Integer bookId) { items.remove(bookId); } public void updateQuantity(Integer bookId, Integer quantity) { CartItem item items.get(bookId); if (item ! null) { item.setQuantity(quantity); } } public MapInteger, CartItem getItems() { return items; } public BigDecimal getTotalPrice() { BigDecimal total BigDecimal.ZERO; for (CartItem item : items.values()) { total total.add(item.getSubtotal()); } return total; } public boolean isEmpty() { return items.isEmpty(); } public int getTotalCount() { int count 0; for (CartItem item : items.values()) { count item.getQuantity(); } return count; } public void clear() { items.clear(); } }我选LinkedHashMap而不是HashMap原因是LinkedHashMap能保持插入顺序。用户先加了一本书A再加一本书B显示购物车时A就在B前面这符合日常购物网站的体验。如果换HashMap条目顺序会乱掉页面上商品顺序飘忽不定用户会觉得你代码有bug。getTotalPrice和getTotalCount都是遍历后累加这里用的是total.add(...)而不是total total.add(...)注意BigDecimal是不可变对象add方法返回新对象所以两种写法都可以但前一种如果忘了接收返回值total永远是0这种隐藏问题排查起来很费劲。3.4 Session在购物车里的角色购物车对象存哪里答案是HttpSession。为什么不是request或applicationrequest是一次请求结束就没了用户把商品加到购物车然后刷新页面这个动作会产生两次请求购物车放request里一次刷新就丢光。application是整个应用全局共享用户A加的东西用户B能看到这不叫购物车这叫公共仓库。Session是“每个用户独占、默认30分钟不销毁”的存储空间完美匹配购物车“用户私有的、短期有效的数据”这个特征。加购Servlet里的核心动作是HttpSession session req.getSession(); Cart cart (Cart) session.getAttribute(cart); if (cart null) { cart new Cart(); session.setAttribute(cart, cart); }这段代码在项目里会重复出现。我的习惯是抽一个静态方法放在Cart类里或单独的CartUtil里但初学者阶段先写两遍能加深对“从Session里拿到对象、如果为空就新建并放回去”这个模式的理解。后面如果实现了第6章的Cookie恢复功能购物车为空时还要先尝试从Cookie重建购物车。4. 从加购到结算Servlet处理购物车核心业务流程4.1 AddCartServlet接收参数、更新Session第一个要写的Servlet是加购入口。前端商品列表页每个商品旁边都有个“加入购物车”按钮点击后提交GET或POST请求到/cart?actionaddid1。这里用GET请求比较符合链接跳转的习惯但因为要修改服务器数据规范上更推荐POST。初学者理解起来有点绕简单记住读数据用GET改数据用POST。加购是改数据我们用POST。package com.example.servlet; import com.example.dao.BookDao; import com.example.entity.Book; import com.example.entity.Cart; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; WebServlet(/addCart) public class AddCartServlet extends HttpServlet { private final BookDao bookDao new BookDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); String idParam req.getParameter(id); String qtyParam req.getParameter(quantity); if (idParam null || idParam.isEmpty()) { resp.sendRedirect(req.getContextPath() /productList); return; } Integer bookId Integer.parseInt(idParam); int quantity 1; if (qtyParam ! null !qtyParam.isEmpty()) { quantity Integer.parseInt(qtyParam); // 这里可能抛NumberFormatException第5章会讲 } Book book bookDao.findById(bookId); if (book null) { resp.sendRedirect(req.getContextPath() /productList); return; } HttpSession session req.getSession(); Cart cart (Cart) session.getAttribute(cart); if (cart null) { cart new Cart(); session.setAttribute(cart, cart); } cart.add(book, quantity); resp.sendRedirect(req.getContextPath() /cart/list); } }这个Servlet把所有流程串起来了解析参数 → 查数据库 → 操作Session购物车 → 重定向。一眼能看出JavaWeb的典型Request-Response模型。特别注意req.setCharacterEncoding(UTF-8)放在读取参数之前它解决的是POST请求参数中文乱码问题写错了位置就没有效果。而resp.setContentType(text/html;charsetUTF-8)这一行在这个Servlet里其实多余因为最后没有向response里写任何内容只是重定向。写上是保险习惯防着后面改成转发到JSP时忘了设编码。4.2 CartListServlet把购物车展示到JSP加购完成后重定向到购物车列表页。这里为什么重定向而不直接用req.getRequestDispatcher(/cartList.jsp).forward(req, resp)关键原因是重定向会发起第二次完整的请求浏览器地址栏变成/cart/list用户按F5刷新时执行的是“查询购物车列表”的操作而不是“再次加购”的操作。如果用forward用户刷新时浏览器会重新提交上一次的POST请求购物车里同一件商品被反复添加这就是“刷新页面导致重复加购”问题的根源。package com.example.servlet; import com.example.entity.Cart; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; WebServlet(/cart/list) public class CartListServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { HttpSession session req.getSession(); Cart cart (Cart) session.getAttribute(cart); if (cart null) { cart new Cart(); session.setAttribute(cart, cart); } req.setAttribute(cart, cart); req.getRequestDispatcher(/WEB-INF/cartList.jsp).forward(req, resp); } }JSP放在/WEB-INF/目录下外部无法直接通过URL访问必须经过Servlet转发。这就给页面加了一道访问控制虽然现在没有权限需求但养成这个习惯以后开发后台管理类功能时能少踩很多坑。JSP中遍历购物车用JSTL的c:forEach% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % html head title我的购物车/title /head body h2我的购物车/h2 c:choose c:when test${empty cart.items} p购物车是空的去挑几本书吧/p /c:when c:otherwise table border1 cellpadding8 tr th书名/thth单价/thth数量/thth小计/thth操作/th /tr c:forEach items${cart.items.values()} varitem tr td${item.book.name}/td td¥fmt:formatNumber value${item.book.price} pattern0.00//td td form action${pageContext.request.contextPath}/cart/update methodpost input typehidden nameid value${item.book.id} input typenumber namequantity value${item.quantity} min1 stylewidth:60px button typesubmit修改/button /form /td td¥fmt:formatNumber value${item.subtotal} pattern0.00//td td a href${pageContext.request.contextPath}/cart/remove?id${item.book.id}删除/a /td /tr /c:forEach /table p strong商品总数${cart.totalCount} 件/strongbr strong总金额¥fmt:formatNumber value${cart.totalPrice} pattern0.00//strong /p a href${pageContext.request.contextPath}/productList继续购物/a /c:otherwise /c:choose /body /html${cart.items.values()}是EL表达式调用Map的values()方法返回所有CartItem集合。这里有一个初学者容易绕弯的点我们定义Cart里的items是MapMap.values()返回的是Collectionc:forEach可以直接遍历Collection。不要写成${cart.items}然后期望它遍历Map的键那是遍历key而不是value。fmt:formatNumber用来格式化金额避免BigDecimal显示成一长串。4.3 Update与Remove数量修改和删除条目修改数量和删除条目是购物车另外两个基础操作。它们的Servlet逻辑很薄核心调用Cart类里已有的方法。package com.example.servlet; import com.example.entity.Cart; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; WebServlet(/cart/update) public class UpdateCartServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); Integer bookId Integer.parseInt(req.getParameter(id)); Integer quantity Integer.parseInt(req.getParameter(quantity)); HttpSession session req.getSession(); Cart cart (Cart) session.getAttribute(cart); if (cart null) { resp.sendRedirect(req.getContextPath() /cart/list); return; } if (quantity 0) { cart.remove(bookId); } else { cart.updateQuantity(bookId, quantity); } resp.sendRedirect(req.getContextPath() /cart/list); } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 限制只能用POST请求用GET访问直接重定向 resp.sendRedirect(req.getContextPath() /cart/list); } }删除Servlet同理只是参数里没有quantity直接用cart.remove(bookId)。这里有个细节数量改成0或负数时我做了个保护逻辑当quantity 0时直接删除该条目而不是真的把数量设置为0或负数。否则页面上会出现一个数量为0的商品行总金额算出来不对用户一头雾水。4.4 转发与重定向怎么选这是Servlet学习中一个绕不开的经典问题购物车项目里正好两个场景都有。操作类请求加购、改数量、删除之后用重定向sendRedirect原因是防止用户刷新时重复提交展示类请求查购物车列表、查商品列表用转发forward原因是转发在服务端完成不用多一次网络往返而且request域里set的attribute能直接传给JSP。这个选择标准请直接背下来面试时也常考。另外一个容易犯的错误是重定向的路径写法。resp.sendRedirect(/cart/list)和resp.sendRedirect(req.getContextPath() /cart/list)是完全不同的效果。前者是绝对路径浏览器会跳到http://localhost:8080/cart/list但如果你的应用部署在/cart这个context下实际访问地址是http://localhost:8080/cart/cart/list直接404。后者先拼出应用上下文路径再拼业务路径这才正确。同理转发时req.getRequestDispatcher(/WEB-INF/cartList.jsp)这个路径是相对于应用根目录的与context无关不需要拼接。5. 购物车项目常见问题与避坑清单5.1 POST请求和GET请求都出现中文乱码现象商品名称在JSP页面上显示成“????”或乱码串。原因分两种。POST请求Servlet里没有在主处理逻辑前调用req.setCharacterEncoding(UTF-8)Tomcat默认按ISO-8859-1解析请求体。GET请求更隐蔽Tomcat 8以下版本默认对URL中的参数按ISO-8859-1解码request.setCharacterEncoding对GET参数无效需要在Tomcat的conf/server.xml里给Connector配置URIEncodingUTF-8。解决POST的场景直接在Servlet开头加一行req.setCharacterEncoding(UTF-8)。GET的场景在Tomcat安装目录的server.xml里修改ConnectorConnector port8080 protocolHTTP/1.1 connectionTimeout20000 redirectPort8443 URIEncodingUTF-8 /用IDEA内置Tomcat时server.xml可以在IDEA的Run配置中找到Tomcat Server的Server Settings区域有个“Open configuration file”的按钮。另外页面本身也要设置contentTypetext/html;charsetUTF-8这四层数据库、应用代码、Tomcat、浏览器页面全都要统一少一层都可能出问题。5.2 Maven依赖的servlet-api和Tomcat自带类冲突现象项目一启动报各种NoSuchMethodError、ClassCastException或者明明代码里HttpServlet是能编译过的运行时就报“类找不到”或“方法找不到”。原因pom.xml里servlet-api的scope没设为provided导致打的war包里包含了servlet-api.jar而Tomcat的lib目录里也有一份相同类名但不同来源的实现两个jar包在类加载时冲突Tomcat先加载了自己的那份然后war里的那份要么不能生效要么覆盖出错。解决把servlet-api和jsp-api的scope改成providedMaven打包时这两个jar包不会进入WEB-INF/lib运行时完全依赖Tomcat自己提供。这个坑特别典型的地方在于本地IDEA里跑得好好的因为IDE运行时不打包war一旦部署到独立的Tomcat、或者按war exploded部署到Tomcat启动立刻翻车。5.3 购物车页面用forward操作后也用了forward导致二重提交现象用户加购后如果服务端用的是req.getRequestDispatcher(/cartList.jsp).forward(req, resp)而不是重定向用户按F5刷新页面浏览器会重新提交上一次的POST请求购物车里同一个商品数量暴涨。原因forward只是服务端内部跳转浏览器不知道这次请求已经被“处理完毕”它还在等待同一个URL的响应。如果处理逻辑是“加购”那么每次刷新都会执行一次“加购”。解决所有修改服务端数据的操作完成后一律使用sendRedirect跳转到“查询展示”的URL上。这是REST规范里的PRG模式Post-Redirect-Get能彻底杜绝表单重复提交。5.4 数据库连接失败ClassNotFoundException或通信链路异常现象JDBC连接MySQL时报ClassNotFoundException: com.mysql.jdbc.Driver或Communications link failure。原因分别对应两个问题。第一个是MySQL驱动版本不兼容MySQL 8.x需要com.mysql.cj.jdbc.DriverMySQL 5.7及以下用com.mysql.jdbc.Driver。第二个是服务没起或URL不对MySQL没启动或者URL里的端口、库名写错。解决MySQL 8.0对应改URL和驱动名同时确认pom.xml里mysql-connector-java的版本不是5.x。连接失败时先手工用命令行mysql -u root -p测试能否连接排除数据库本身未启动的问题再回来看代码。5.5 Session里的购物车对象类型转换异常现象(Cart) session.getAttribute(cart)这一行抛ClassCastException: java.util.HashMap cannot be cast to com.example.entity.Cart。原因同一台Tomcat上的不同应用如果都用默认的JSESSIONID且旧Session没失效可能形成脏数据更常见的原因是这个名字被其他地方的代码放了一个HashMap进Session。解决换个不那么通用的attribute名比如session.setAttribute(SHOPPING_CART_OBJECT, cart)避免与框架或项目其他代码冲突。同时把Cart类加上static final long serialVersionUID 1LHttpSession在Tomcat重启时会持久化到磁盘要求里面的对象可序列化不写serialVersionUID在特定容器重启场景下会报序列化异常。6. 让购物车系统再往前走两步Cookie恢复与金额精度6.1 用Cookie记下购物车内容下次访问自动恢复Session的痛点是关闭浏览器就没了购物车跟着一起消失。电商网站的做法是把购物车数据存数据库但Servlet入门项目里我们可以用Cookie存一个简化版只存商品id和数量格式如bookId:quantity|bookId:quantity。用户下次打开网站时先尝试解析Cookie恢复购物车。这个功能能让你这项目的完整度明显高一个档次。实现分两段。第一段在AddCartServlet每次操作完购物车后把购物车序列化写入Cookie// 在重定向前同步Cookie StringBuilder sb new StringBuilder(); for (CartItem item : cart.getItems().values()) { if (sb.length() 0) sb.append(|); sb.append(item.getBook().getId()).append(:).append(item.getQuantity()); } Cookie cookie new Cookie(cartCookie, URLEncoder.encode(sb.toString(), UTF-8)); cookie.setMaxAge(30 * 24 * 3600); // 30天有效期 cookie.setPath(req.getContextPath()); resp.addCookie(cookie);第二段在获取Session购物车之前优先检查CookieCookie[] cookies req.getCookies(); Cart cart null; if (cookies ! null) { for (Cookie c : cookies) { if (cartCookie.equals(c.getName())) { String value URLDecoder.decode(c.getValue(), UTF-8); cart new Cart(); String[] entries value.split(\\|); for (String entry : entries) { String[] parts entry.split(:); int bookId Integer.parseInt(parts[0]); int quantity Integer.parseInt(parts[1]); bookDao.findById(bookId).ifPresent(book - cart.add(book, quantity)); } break; } } } if (cart ! null) { session.setAttribute(cart, cart); }Cookie方案有三条边界要记牢。一是只适合存少量数据Cookie总体积上限4KB大量书籍会溢出溢出了就干脆别追求完整恢复提示用户重新加购。二是Cookie明文可篡改千万不要存价格、账号、密码这类敏感信息只存商品id和数量。三是如果数据库里商品表和图片删了恢复时bookDao查询会拿到null代码里要加空值跳过。这个方案在真实项目里不会用但作为学习项目它帮助你理解Cookie和Session的配合关系这比功能本身更重要。6.2 金额计算统一BigDecimal别在页面上做加减乘除前文数据库用了DECIMAL实体类用了BigDecimal购物车运算也是BigDecimal。但有一种常见写法是在JSP里直接计算总价比如${item.book.price * item.quantity}这种写法在EL表达式里会把BigDecimal强制转成Double计算两个Double相乘精度丢失金额对不上。正确做法是所有金额运算都在Cart类的getTotalPrice和CartItem的getSubtotal里完成JSP只负责显示。显示时用fmt:formatNumber保留两位小数。另外一个常被忽略的细节BigDecimal构造时请不要传double比如new BigDecimal(0.1)得到的值不是0.1而是0.1000000000000000055511151231257827。正确姿势是new BigDecimal(0.1)或BigDecimal.valueOf(0.1)。项目里实体类从ResultSet的getBigDecimal方法取出来的值本身是安全的但如果你在代码里手写金额字面量一定用字符串构造。6.3 手工验收清单跑完这些用例你的购物车才算合格把系统跑起来以后别急着说“做完了”。按下面这个清单过一遍任何一条不对劲都说明还有隐性问题。用例操作步骤预期结果首次加购商品页点“加入购物车”跳转到购物车页显示1件商品重复加购再点同一个商品的“加入购物车”商品条目不增加数量变为2修改数量数量框改成5点修改小计和总金额同步更新删除条目点删除商品消失总金额扣减空购物车全部删除后刷新购物车页显示“购物车是空的”提示刷新防重加购后按F5购物车内容不重复增加中文显示商品名含中文后走完加购流程从查询到展示全流程无乱码非法参数手动访问/cart/update?id1quantityabc页面友好提示不抛500错误金额精度单价0.1的商品买3个小计显示0.30不是0.30000000000000004会话过期等待30分钟后再操作购物车清空重新加购正常部署启动换一台上线Tomcat部署war包不报依赖冲突功能正常这个清单里“非法参数”那条尤其值得补上。项目里现在Integer.parseInt直接裸奔遇到不是数字的输入就抛NumberFormatException。真实项目里要加一层try-catch或者用正则先校验\\d。这个属于健壮性设计新手阶段可以不用但要知道这是个todo。最后说一下我个人的做法我会在完成基本功能后保留一个“继续购物”的回跳链接并记录上次浏览位置。这其实用很简单的请求参数就能做到但体验会好很多。这个项目做完后你顺手还会理解URL设计、Servlet生命周期、DAO模式这些JavaWeb的底层骨架知识——它们比“购物车”本身重要得多。后续想进阶就从纯Servlet向Spring MVC迁移你会发现Controller层、Service层划分的思路其实还是一样只是框架帮你省了那些重复的模板代码。希望帮到你。本文还有配套的精品资源点击获取