简介:这套JavaWeb简易购物车源代码面向初学Web开发的开发者,围绕在线购物车场景,系统展示了Servlet与JSP分工、HTTP请求处理、Session保存购物车状态、JDBC操作MySQL等核心技术,可帮助读者打通从页面展示到数据持久化的完整业务流程。压缩包共54个文件,以java、class、jsp源码为主,辅以xml配置、properties属性文件、jar依赖包和项目说明文档,整体仅1.58MB,结构紧凑,便于导入Eclipse或IDEA后快速运行和拆解。资源附带笔记资料、数据库设计说明及项目安装说明,对商品表、用户表、购物车表等库结构及关键代码均有讲解。已有4125人浏览学习,适合想要结合实战理解MVC设计模式、JSTL/EL表达式及会话管理的初学者,也适合作为课程设计或毕业设计的参考模板。
1. javaWeb简易购物车源代码:从下载到跑通的三个关键问题
这份 javaWeb简易购物车源代码,是我见过最适合用来理解 java购物车 状态管理的最小闭环工程。它不像 Spring Boot 商城那样追求功能全,而是老老实实基于 Servlet + JSP + MySQL 跑通“商品展示 → 加入购物车 → 修改数量 → 删除 → 模拟结算”整条链路。折腾一晚上,你能同时弄懂 HTTP 请求参数、Session 生命周期、JSP 页面渲染和 JDBC 查询这四件事是怎么串起来的。适合刚学完 Java 基础想做第一个 Web 项目的学生,也适合需要在内部工具里快速集成一个购物车模块的从业者。先别急着改代码,把环境匹配好,后面的复现速度会快很多。
2. 技术栈与工程结构:为什么选 Servlet+JSP+MySQL,以及购物车状态到底存哪儿
2.1 为什么不用 Spring Boot:先看清 Session 的边界
这份源代码的技术选型相当老派:Java 8、Servlet 3.1、JSP、JSTL、MySQL、Maven、Tomcat 8.5。如果放到今天,很多团队会直接选择 Spring Boot + Thymeleaf,但这份资源的定位不是“生产级商城”,而是让学习者看清购物车状态的边界。购物车的本质是什么?是一份临时的、与用户会话绑定的状态。用户把商品装进购物车,在没有登录、没有下单之前,这份数据并不需要落到数据库,保存在 Session 里就够了。Spring Boot 的 @SessionAttributes 虽然可以做同样的事,但它把 Session 内对象的创建和序列化包装在框架内部,一旦出现“为什么我的购物车偶尔丢失”这类问题,初学者很难定位到 HttpSession 的生命周期。
我实际拆过的几个项目里,凡是直接用 Spring Boot 起步的新人,遇到 Session 失效、Cookie 路径、会话并发等问题时,往往会陷入“框架暗坑”而不是“业务逻辑坑”。用原生的 request.getSession() 去解决购物车,你能直观看到:Session 由容器创建,保存在服务器内存,默认通过 JSESSIONID Cookie 维持。这个原理搞懂之后,再看任何框架的 Session 封装都会轻松。
| 对比项 | Servlet+JSP 购物车 | Spring Boot 购物车 |
|---|---|---|
| 状态存储 | HttpSession + 手动 Cart 对象 | @SessionAttributes 或 Redis |
| 请求处理 | @WebServlet 手动分发 action | @RestController + @PostMapping |
| 页面渲染 | JSP + JSTL 服务端渲染 | Thymeleaf 或前后端分离 |
| 学习成本 | 低,能看到底层 | 中,框架概念多 |
| 适合场景 | 入门、小型 Demo | 生产级全栈项目 |
这个表格不是否定 Spring Boot,而是说明:当你只有“理解购物车状态管理”这一个目标时,Servlet+JSP 反而是最短路径。
2.2 源码目录与文件职责:每个类在哪一环起作用
拿到源代码后,第一件事是看目录结构。一个标准的 Maven 结构一般如下:
src/main/java/com/example/cart/ pojo/ Product.java CartItem.java Cart.java dao/ ProductDao.java servlet/ ProductListServlet.java ProductDetailServlet.java CartServlet.java filter/ EncodingFilter.java util/ DBUtil.java src/main/resources/ db.properties src/main/webapp/ index.jsp product-list.jsp product-detail.jsp cart.jsp WEB-INF/ web.xml这个结构对应购物车链路的四个环:
- Product:商品实体的 POJO,对应数据库 product 表字段。
- CartItem:购物车里的“一行”,持有 Product 和 quantity。
- Cart:购物车本体,持有 List ,对外提供 add、update、remove、clear、getTotal 方法。
- ProductDao:数据库访问层,负责查询商品列表和按 id 查单个商品。
- CartServlet:所有购物车动作的入口,用 action 参数区分操作。
- EncodingFilter:解决 POST 请求和响应输出的中文编码。
需要特别指出的是 web.xml 的存在。如果你看到的是 WebServlet 注解而不是 web.xml 配置,也正常,但 web.xml 里通常还会配置 Session 超时时间。例如:
<session-config> <session-timeout>30</session-timeout> </session-config>session-timeout 单位是分钟,默认 30 分钟。这个值决定了用户购物车在闲置多久后会被清空。简易购物车 Demo 里它只是配置,但在实际项目中,你要根据业务调整。有些网站把购物车 Session 超时设成 7 天,这会导致服务器内存占用明显上升,常见做法是定期清理无效会话。
2.3 数据库初始化:一张 product 表为什么够用
购物车数据不需要建表,但商品数据必须来自数据库。这也是“简易”的由来。初始化脚本通常包含建库、建表、插入测试数据三步:
CREATE DATABASE IF NOT EXISTS cart_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE cart_db; DROP TABLE IF EXISTS product; CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(64) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 100, description VARCHAR(255), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); INSERT INTO product (name, price, stock, description) VALUES ('Java核心技术 卷I', 79.50, 50, '经典Java入门书籍'), ('Spring实战 第6版', 99.00, 30, 'Spring框架实践'), ('机械键盘', 199.00, 200, '茶轴有线机械键盘'), ('无线鼠标', 49.90, 150, '静音办公鼠标');建表时,price 用 DECIMAL(10,2) 而不是 float 或 double。这是刻意为之:浮点型在计算 0.1+0.2 时会得到 0.30000000000000004,金额计算一旦出现这种误差,在订单结算时会触发严重问题。DECIMAL(10,2) 表示总共 10 位、小数 2 位,可以存储到 99999999.99,足够绝大多数 Demo。
连接参数集中在 src/main/resources/db.properties:
db.driver=com.mysql.cj.jdbc.Driver db.url=jdbc:mysql://localhost:3306/cart_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 db.username=root db.password=123456这份配置在 MySQL 8.x 下是标准写法。如果你用 MySQL 5.7,driver 可以保留 com.mysql.cj.jdbc.Driver,但 serverTimezone 参数不是必需的。password 要改成自己环境里真实密码,不要用占位符跑。在 MySQL 8.x 下,如果驱动不是 com.mysql.cj.jdbc.Driver,启动会报 ClassNotFoundException。这也是最常见的环境坑之一。
这里还要补一句:为什么不用 Redis?因为购物车教学资源的目标是让你理解“状态放在会话里”,而不是让你一开始就引入分布式缓存。等做到第六章的订单持久化时,你会发现 Session 购物车和数据库订单正好形成“临时状态 → 持久状态”的完整映射,这是处理一切状态类需求的基本模型。
3. 本地复现:从环境匹配到 Tomcat 部署的完整命令链路
3.1 环境版本匹配:先统一 JDK、Maven、Tomcat 和 MySQL
拆这个资源的第一晚,我先装好一套固定版本环境,避免“代码没问题、环境互相打架”。推荐组合是:
- JDK 1.8(最低 8u201)
- Maven 3.6.x
- Tomcat 8.5.x(不要用 9 或 10)
- MySQL 5.7 或 8.0.x
Tomcat 版本很关键。Tomcat 9 之后 JSP 解析器有调整,部分旧语法在 EL 表达式中的行为会变。Tomcat 10 把包名从 javax.servlet 改成了 jakarta.servlet,传统 Servlet 代码直接运行会提示 ClassNotFoundException。如果真的要用新版本,必须把 src 里的 import javax.servlet.* 全部改成 jakarta.servlet.*,这等于重写代码。所以不要升级。
JDK 版本也不要盲目选 17。这套代码用的 Servlet 3.1 和 JSTL 1.2 在 JDK 8 上最稳。如果你本机同时装了多个 JDK,在运行 Maven 前先确认 JAVA_HOME:
export JAVA_HOME=/path/to/jdk1.8.0_202 export PATH=$JAVA_HOME/bin:$PATH java -version执行后应该看到 openjdk version "1.8.0_202"。如果出现 jdk 17,后面 Maven 编译时可能上报 java.lang.reflect.InaccessibleObjectException,原因不是代码错,而是高版本 JDK 模块系统限制了反射访问。解法不是加 opens 参数,而是换回 JDK 8。
3.2 数据库导入与 db.properties 修改:参数逐个说明
把初始化 SQL 保存为 init.sql 并导入:
mysql -uroot -p < init.sql输入密码后,用命令确认:
mysql -uroot -p -e "use cart_db; show tables; select * from product;"能看到 product 表和四条数据,数据库部分就完成了。接下来改 db.properties。这里每个参数的含义:
| 参数名 | 示例值 | 作用 | 坑点 |
|---|---|---|---|
| db.driver | com.mysql.cj.jdbc.Driver | 告诉 Java 用哪个 JDBC 驱动 | MySQL 5.7 用 com.mysql.jdbc.Driver 也可,8.x 必须用 cj |
| db.url | jdbc:mysql://localhost:3306/cart_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 | 连接地址 | 不写 serverTimezone 在 8.x 会报时区错误 |
| db.username | root | 数据库账号 | 不要带空格 |
| db.password | 123456 | 数据库密码 | 密码中含 & 时注意 Properties 转义 |
改完配置,可以单独写一个小类测试连接,但更省事的是直接启动 Tomcat 看页面。如果报错,参考第五章的第 2 个坑。
3.3 Maven 打包与两种部署方式:war 复制 vs 嵌入式 Tomcat
在项目根目录(有 pom.xml 的地方)执行:
mvn clean package -DskipTestsclean 的意义是删除旧 target,package 会编译、打 war 包,-DskipTests 跳过测试阶段。如果有测试类且没有写好,打包会失败,跳过测试可以加快验证。
看到 BUILD SUCCESS 后,target 目录下会有 cart.war。部署方式有两种。
方式一是复制到独立 Tomcat 的 webapps:
cp target/cart.war $CATALINA_HOME/webapps/ $CATALINA_HOME/bin/startup.shTomcat 启动时会自动解压 war。注意如果之前部署过,需要先把旧的 cart 目录和 war 删除,否则可能出现类文件残留,最典型的表现是改了代码不生效。方式二是开发时用插件:
mvn tomcat7:run这个命令会用嵌入式 Tomcat 7 运行,支持热部署。但注意,插件名是 tomcat7,实际上 Servlet 规范是 3.0,和独立 Tomcat 8.5 的 3.1 略有差异。我一般会用方式一做正式验证,用插件做日常调试。如果你发现插件运行时正常,独立 Tomcat 里却报错,优先检查 web.xml 头部的 schema 版本:
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1">tomcat7 插件对这些 schema 解析比较宽容,但独立 Tomcat 8.5 会严格按照 3.1 校验。如果你的 web.xml 写的是 4.0,独立 Tomcat 8.5 会直接拒绝启动。
3.4 启动后链路验证:从商品列表到购物车页面
启动完成后访问:
http://localhost:8080/cart/index.jsp如果 Tomcat 端口没改,就是这个地址。看到四条商品数据,打开开发者工具,先点击“加入购物车”,观察 Network:
- 请求 URL 应该是 /cart/cart?action=add&productId=1&quantity=1,或类似路径。
- 请求后返回状态码 302,Location 指向 cart.jsp。
- cart.jsp 页面里应该能看到刚才加的商品、数量和 79.50 的小计。
如果页面里的数量文本框每次修改后都能正确更新,说明第 4 章要拆解的 CartServlet 参数链路是通的。如果出现商品列表空白,先看 Tomcat 的 catalina.out 日志。常见错误是:
java.sql.SQLException: No suitable driver found这句话的意思是驱动没加载。原因要么是 db.driver 写错,要么是 MySQL 驱动 jar 没有打进 war。查看 war 里的 lib 目录:
jar tf target/cart.war | grep mysql如果输出为空,pom.xml 缺 MySQL 依赖。补充依赖:
<dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.29</version> </dependency>version 要和本地 MySQL 版本匹配,8.0.29 对应 MySQL 8.x,5.7 也可以用它。注意 artifactId 是 mysql-connector-java,不是 mysql-connector-j。这个细节能避免白白浪费一个晚上。
到这里,你的环境已经能把项目跑起来。下一章拆代码,重点看 Session 里的购物车到底怎么被操作。
4. 购物车核心代码拆解:Session 存储、动作分发、金额计算与扩展点
4.1 模型层:CartItem 和 Cart 的增删改查实现
购物车的单元是 CartItem,它是一行购物车记录,至少包含商品对象和数量两个字段。一个常见的实现:
public class CartItem { private Product product; private int quantity; public CartItem(Product product, int quantity) { this.product = product; this.quantity = quantity; } public double getSubtotal() { return product.getPrice() * quantity; } public Product getProduct() { return product; } public int getQuantity() { return quantity; } }getSubtotal 直接算这一行的小计。为什么要单独装一个 CartItem 而不是直接用 Map<Product, Integer>?因为 JSP 页面迭代时,用 Map.Entry 也可以,但 EL 表达式 $item.value 不如 $item.quantity 直观,而且 Map 的遍历顺序不可控。CartItem 是更符合业务语义的模型。
Cart 类封装整个购物车状态,核心逻辑如下:
public class Cart { private List<CartItem> items = new ArrayList<>(); public void addCartItem(Product product, int quantity) { if (product == null || quantity <= 0) return; for (CartItem item : items) { if (item.getProduct().getId() == product.getId()) { item.setQuantity(item.getQuantity() + quantity); return; } } items.add(new CartItem(product, quantity)); } public void updateQuantity(int productId, int quantity) { for (CartItem item : items) { if (item.getProduct().getId() == productId) { if (quantity <= 0) { items.remove(item); } else { item.setQuantity(quantity); } return; } } } public void deleteCartItem(int productId) { items.removeIf(item -> item.getProduct().getId() == productId); } public double getTotalPrice() { double total = 0; for (CartItem item : items) { total += item.getSubtotal(); } return total; } }addCartItem 里有一个很重要的细节:判断 productId 相等用的是 int 的==,而不是 equals。因为 Product.id 在数据库中是 int 类型,通过 getter 返回时是 int 基本类型,==没问题。但如果某个版本把 id 改成 Integer,两个超过 127 的 Integer 对象用==比较会返回 false,导致重复商品永远不累加。预防手段是改成item.getProduct().getId().intValue() == product.getId().intValue(),或者干脆用 Objects.equals。这类问题属于血泪经验,你在老项目里很容易碰到。
updateQuantity 里把 quantity <= 0 当成删除,是刻意的设计。因为前端输入框允许用户清空,清空后 value 值为 0,这时大多数用户期望的并不是报错,而是这个商品从购物车消失。如果你非要报错,反而会得到更多客服投诉。
4.2 Servlet 层:action 参数分发与四个边界参数说明
CartServlet 是购物车动作的入口,代码结构如下:
@WebServlet("/cart") public class CartServlet extends HttpServlet { @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); HttpSession session = req.getSession(); // 从 Session 里取出购物车,如果第一次访问就初始化 Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); } String action = req.getParameter("action"); ProductDao dao = new ProductDao(); if ("add".equals(action)) { int productId = Integer.parseInt(req.getParameter("productId")); int quantity = Integer.parseInt(req.getParameter("quantity")); Product product = dao.findById(productId); cart.addCartItem(product, quantity); } else if ("update".equals(action)) { int productId = Integer.parseInt(req.getParameter("productId")); int quantity = Integer.parseInt(req.getParameter("quantity")); cart.updateQuantity(productId, quantity); } else if ("remove".equals(action)) { int productId = Integer.parseInt(req.getParameter("productId")); cart.deleteCartItem(productId); } else if ("clear".equals(action)) { cart.clear(); } resp.sendRedirect(req.getContextPath() + "/cart.jsp"); } }几个容易被忽略的参数边界:
第一,Integer.parseInt 没有异常保护。如果用户传?quantity=abc,这行代码会直接抛 NumberFormatException。第五章会讲修正方式。第二,resp.sendRedirect 用的路径带req.getContextPath(),这样在部署路径是 /cart 时重定向到 /cart/cart.jsp,而不是 /cart.jsp。如果 JSP 在 webapp 根目录且工程名是 ROOT,直接写 "cart.jsp" 也行,但带 contextPath 更通用。第三,action 分发用了 if/else,如果 action 为空,整个过程什么都不做,直接跳回购物车页,这不会报错但会给人一种“点击无效”的错觉。第四,ProductDao 每次 new 一次,浪费连接,但作为 Demo 无所谓;进阶后用连接池替换即可。
4.3 数据访问层:PreparedStatement 与 JDBC 连接管理
ProductDao 里最核心的方法是 findById 和 findAll。常见实现:
public class ProductDao { public Product findById(int id) { String sql = "SELECT id, name, price, stock, description FROM product WHERE id = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, id); try (ResultSet rs = ps.executeQuery()) { if (rs.next()) { Product p = new Product(); p.setId(rs.getInt("id")); p.setName(rs.getString("name")); p.setPrice(rs.getDouble("price")); p.setStock(rs.getInt("stock")); p.setDescription(rs.getString("description")); return p; } } } catch (SQLException e) { e.printStackTrace(); } return null; } }PreparedStatement 的?占位符是必须的,不要直接拼字符串。原因有两个:第一,防止 SQL 注入,如果用户把 productId 传成1 OR 1=1,拼接 SQL 会把整个表变成查询结果;第二,?让 JDBC 驱动帮你处理参数类型,setInt 之后数据库知道它是数字。常见的反面教材是把 price 或者 id 用字符串拼接,导致 MySQL 隐式转换,性能不好且容易出错。
DBUtil 一般长这样:
public class DBUtil { private static Properties props = new Properties(); static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { props.load(in); Class.forName(props.getProperty("db.driver")); } catch (Exception e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(props.getProperty("db.url"), props.getProperty("db.username"), props.getProperty("db.password")); } }注意 Class.forName 在 JDBC 4.0 之后不是必须的,因为驱动会自动注册,但保留它可以让 DBUtil 初始化时立刻暴露 driver 名字写错的问题。getResourceAsStream 读取的是 classpath 下的文件,如果 db.properties 放在 src/main/resources 下,打包后会自动进入 classes 根目录,这里能读到。如果 db.properties 放错位置,运行时会出现 NullPointerException,因为 in 为 null。这也是一个很常见的坑。
4.4 JSP 层:EL 表达式、JSTL 循环和金额格式化
购物车页面 cart.jsp 的循环部分:
<c:forEach var="item" items="${cart.items}"> <tr> <td>${item.product.name}</td> <td> <input type="number" value="${item.quantity}" min="1" onchange="window.location='${pageContext.request.contextPath}/cart?action=update&productId=${item.product.id}&quantity='+this.value" /> </td> <td>¥<fmt:formatNumber value="${item.subtotal}" pattern="0.00"/></td> <td><a href="${pageContext.request.contextPath}/cart?action=remove&productId=${item.product.id}">删除</a></td> </tr> </c:forEach>EL 表达式${cart.items}会调用 Cart 的 getItems() 方法,返回 List 。${item.subtotal}会调用 CartItem 的 getSubtotal()。这是 Java 规范里的一个玄学点:EL 的属性名不是找字段,而是找 getter。如果方法叫 getMoneyTotal(),EL 要写moneyTotal,不是subtotal。所以 CartItem 的 getter 命名必须和 EL 表达式一致。
fmt:formatNumber 在这里有实际意义:double 类型在累加时可能产生 0.30000000000000004 这样的尾差,格式化后保证显示正常。但注意,格式化只是解决展示层,如果金额用于计算,比如后面生成订单,不应该用格式化后的字符串,而应该用 BigDecimal 重新算。这也是升级订单功能时要记住的。
另一个细节是 onchange 直接在 input 上生成 URL 跳转,当用户点击浏览器后退时,input 值可能已经改变但购物车没变,导致页面回显异常。更好的做法是用 fetch 提交。但作为简易 Demo,这条链路逻辑最直白,便于讲清参数传递。
4.5 扩展点:从 Demo 往真实购物车过渡的三个方向
拆完代码后你会发现,购物车本身并不复杂,真正的复杂度都在边界:
方向一是持久化。Session 购物车在浏览器关闭后就没法恢复。如果要做真实商城,需要在下单时把购物车快照进订单表,并清空购物车。第六章会演示这个。
方向二是库存校验。当前 product.stock 字段没有参与任何判断,用户可以把数量改成 99999,结算后库存变成负数。真实业务要在 add 和 update 时检查数据库 stock,而不是信任 Session 中的数量。
方向三是并发安全。Session 是一个用户在会话内访问的,同一个用户的并发请求可能会有两个线程同时修改同一个 Cart 对象。乐观做法是给 Cart 的修改方法加 synchronized 或者使用 CopyOnWriteArrayList。简易 Demo 不需要,但如果在 Tomcat 集群环境,Session 还要考虑迁移或集中存储。
这些扩展点加起来,就是一张从“能跑”到“能上线”的完整路线图。
5. 避坑与排查:新手最容易翻车的五个运行错误
5.1 中文乱码:商品名显示问号或后台取值变乱码
现象: 浏览器页面商品名称显示为问号,或者 JSP 传入的中文参数在 Servlet 里取出来变成乱码。
原因: 链路中三个环节有一个没统一编码。MySQL 表字符集用了 latin1,连接 URL 没带 characterEncoding=utf8,或者 Servlet/JSP 没有设置 request 编码。
解决: 首先确认建库 SQL 里带的是 utf8mb4,不是 utf8。其次 db.properties 的 url 末尾加 characterEncoding=utf8。然后在所有 Servlet 入口最前面调用 req.setCharacterEncoding("UTF-8"),在 JSP 顶部写 pageEncoding="UTF-8"。如果用了 Filter,建议 Filter 的 doFilter 里同时设置 response.setCharacterEncoding("UTF-8"),因为 JSP 响应字节编码也是常见乱码源。
5.2 数据库连不上:Access denied 或 Communications link failure
现象: 启动 Tomcat 后,点商品列表页出现 500,控制台堆栈包含 Access denied for user 'root'@'localhost' 或 Communications link failure。
原因: Access denied 是密码或用户名不对;Communications link failure 通常是 MySQL 端口没开,或 URL 里 host 写错,比如把 localhost 写成了 127.0.0.1 但 MySQL 只监听 ::1 的 IPv6。
解决: 先用 mysql -uroot -p 在命令行确认能连上。再确认 db.properties 用户名密码一致。如果用的是 MySQL 8,驱动必须换成 com.mysql.cj.jdbc.Driver,而且 url 里要加 serverTimezone=Asia/Shanghai。如果 MySQL 端口不是 3306,则 url 改成相应端口。
5.3 URL 参数被篡改:数量负数或非数字导致 500
现象: 用户把参数 ?quantity=-1 或 ?quantity=abc 输入地址栏,页面直接抛出 NumberFormatException。
原因: CartServlet 里直接 Integer.parseInt(req.getParameter()),没有做异常捕获,也没有业务校验。这是非常常见的 Demo 级错误。
解决: 在解析前用正则判断:
String qtyStr = req.getParameter("quantity"); int quantity = 0; if (qtyStr != null && qtyStr.matches("\\d{1,9}")) { quantity = Integer.parseInt(qtyStr); }如果 quantity 是 0 或非法输入,直接删除该商品或忽略操作,不要让后台报 500。补上这层后,购物车才算真正健康。
5.4 Session 为 null 导致 add 操作空指针
现象: 用户直接访问 /cart?action=add&productId=1,按道理应该加购,却报 NullPointerException at CartServlet。
原因: 有些版本代码里用 session.getAttribute("cart") 返回 null 后没有初始化,直接调用 null.addCartItem。
解决: 在 action 分发前统一初始化:
Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); }这段逻辑最好放在 service 方法最前面,而不是每个 action 里重复判断。顺便把初始化放在 filter 里也是可行方案,比如一个默认的 session 监听器。
5.5 页面跳转后拿不到购物车:相对路径与部署路径不匹配
现象: 在商品详情页点“加入购物车”之后,重定向到了一个不存在的 /product-detail/cart.jsp,或者返回后 cart.jsp 显示空列表。
原因: 路径问题。CartServlet 的 @WebServlet("/cart") 映射到根路径,但商品详情页若在 /product-detail.jsp,页面里的相对链接 cart?action=add 会被浏览器解析成 /product-detail/cart,Servlet 无法匹配。
解决: 页面里的所有跳转都使用以 ${pageContext.request.contextPath} 开头的绝对路径:
<a href="${pageContext.request.contextPath}/cart?action=add&productId=${item.id}">加入购物车</a>注意 JSTL 的 curl 标签也能自动加 contextPath:
<a href="<c:url value='/cart?action=add&productId=${item.id}' />">加入购物车</a>c:url 会自动处理 contextPath 和特殊字符转义,是 JSP 页面里最稳妥的链接构造方式。
6. 进阶验证:把 Session 购物车持久化成订单,并用 curl 完成回归
6.1 建订单表与订单明细表
购物车最薄弱的环节是“关浏览器就没了”。要把这份资源从玩具升级成可演示的半成品,第一步是加两张持久化表:
CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, total_amount DECIMAL(10,2) NOT NULL, created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE order_items ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL );orders 表存订单总额,order_items 存购物车快照。为什么要快照?因为用户下单后商品价格可能调整,订单明细里的 price 必须保留下单那一刻的价格,而不是每次都去商品表反查。
6.2 在 CartServlet 里加 checkout 动作
在 action 分发链里增加一个分支:
} else if ("checkout".equals(action)) { // 先把金额算出来,再写入订单表 double total = cart.getTotalPrice(); // 这里执行 INSERT INTO orders 和 INSERT INTO order_items cart.clear(); session.setAttribute("orderTotal", total); resp.sendRedirect(req.getContextPath() + "/order-success.jsp"); }注意顺序:先算总额,再写订单,最后清空 Session 购物车。两个 INSERT 应该放在同一个数据库事务里,避免出现“订单总表写入成功、明细失败”的脏数据。Demo 里可以用 Connection.setAutoCommit(false) 包裹。
6.3 用 curl 模拟一次完整购买链路并断言
浏览器手工点击容易漏掉细节,更可靠的回归方式是 curl 加 Cookie 保持会话:
curl -c cookies.txt "http://localhost:8080/cart/cart?action=add&productId=1&quantity=2" curl -b cookies.txt "http://localhost:8080/cart/cart?action=checkout"第一次请求把商品加进 Session,第二次请求触发结算。结算完成后,用 SQL 验证:
SELECT id, total_amount FROM orders; SELECT order_id, product_id, quantity, price FROM order_items;预期的第一条记录 total_amount = 159.00,对应 79.50 乘以 2。因为 orders 表用的是 DECIMAL,直接等于 159.00,不会出现 158.99999999999997。这就是状态从 Session 到数据库的一次完整迁移。
从那以后,我每拆一个 Web 资源,都会把 curl 链路和数据库断言写进验证清单,而不是只打开浏览器看一眼页面。这样做,角色从用户变成了检查者,很多问题在半小时内就会暴露。希望帮到你。
本文还有配套的精品资源,点击获取