简介:购物车案例【简单版】.rar 面向刚接触 Java Web 的开发者与课程学习者,围绕 JSP、Servlet、Tomcat、HTML 与 Session 搭建一个可运行的简易购物车示例,帮助理解商品展示、加入购物车、删除条目及会话状态保持等基础流程。压缩包共 62 个文件、约 916KB,包含 13 个 Java 源码、6 个 JSP 页面、26 个编译后 class、5 个 XML 配置以及 4 个依赖 jar,并配有 HTML/JS 页面与少量工程元数据,覆盖实体、DAO、Service、Servlet 等分层模块,适合对照源码梳理请求处理与数据流转。目前已有 671 人学习。读者可从中获得一套结构清晰的入门级项目样本,用来练习 JSP 与 Servlet 协作、Session 购物车更新、数据库访问分层和 Tomcat 部署调试思路,也可作为课堂实验或小型练习的参考底稿。
1. 先把话说透:这份 JSP 购物车 Demo 能跑通什么、不能解决什么
jsp + java + tomcat + html + session 这五个词几乎覆盖了一个 Java Web 入门工程能碰到的基础组件,但真正串成一个能跑通的购物车,还是有不少人会卡在环境版本、session 作用域、JSTL 依赖这三件事上。这份《购物车案例【简单版】.rar》把商品列表、加入购物车、改数量、删除、金额汇总五个动作串成一个最小闭环,代码量不大,却把 JSP 当视图、Servlet 当控制器、session 当购物车容器这三层分工交代得比较清楚。它不连数据库、不引框架、不做分布式缓存,适合刚学完 Servlet 想找一份能跑通完整例子的入门者,也适合需要一份结构清晰课堂案例的讲师,以及想回头补齐 session 细节的开发者。真正想把它落到能演示的状态,关键不在代码本身,而在环境对齐和 session 边界这两个地方。
2. 环境对齐:JDK + Tomcat + Servlet 版本错一位就翻车
2.1 一张版本矩阵,先把依赖选型锁死
拿到压缩包之后第一件事不是解压,而是先确认自己机器上装的是哪个版本的 JDK 和 Tomcat。JSP 这套东西的版本耦合非常硬:Tomcat 8.5 对应 Servlet 3.1,Tomcat 9 对应 Servlet 4.0,两者都还在用javax.servlet.*包名;而 Tomcat 10 之后整个迁到了jakarta.servlet.*,包名一改,老代码里所有import javax.servlet.*全部红叉。这份 Demo 是按javax.servlet体系写的,所以部署前要么用 Tomcat 8.5/9,要么就得把每个 Servlet 的 import 改成jakarta.*。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | JDK 8 或 JDK 11 | Tomcat 8.5 与 9 都能跑,11 是长期支持版本里最稳的 |
| Tomcat | 9.0.x | 仍是javax.servlet,社区资料最多,遇到问题好搜 |
| Servlet API | 4.0 | Tomcat 9 自带,不需要单独下载 jar |
| JSP API | 2.3 | 同上,Tomcat 自带 |
| JSTL | 1.2 | 需要手动放进WEB-INF/lib,后面会讲 |
| 数据库 | 无 | 这份 Demo 用 session 存购物车,不连库 |
新手最容易踩的坑就是去官网下载 Tomcat 时默认拉最新版,然后发现明明代码没错,<%标签全部报错、import javax.servlet.*全部找不到。原因不是代码写错,而是 Tomcat 10 之后容器本身就换了包名。行业里比较稳妥的做法是:学习、复现、老项目维护,一律锁 Tomcat 9 这一条线,等对 Servlet 规范熟了再往jakarta迁。
JSTL 那部分还要补一句。JSTL 1.2 从 Java EE 8 之后就不再随容器分发,Tomcat 只提供 Servlet 和 JSP 的 API,不提供 JSTL 的实现。所以c:forEach、fmt:formatNumber这些标签要想生效,就必须把jstl-1.2.jar手动放进WEB-INF/lib目录。这一步漏了,症状就是 JSP 页面上「标签原样输出」,或者报ClassNotFoundException: javax.servlet.jsp.jstl.core.Config。
2.2 目录结构、打包与部署链路
压缩包解压之后,一个能让 IDE 正常识别的、最经典的 JSP 工程目录长这样:
ShoppingCartDemo/ ├── src/ │ └── com/example/cart/ │ ├── model/ # 数据模型 │ ├── repository/ # 模拟商品数据 │ ├── servlet/ # 控制器 │ └── util/ ├── WebContent/ │ ├── WEB-INF/ │ │ ├── web.xml # 部署描述符 │ │ ├── lib/ │ │ │ └── jstl-1.2.jar │ │ └── classes/ # 编译产物 │ ├── index.jsp # 商品列表 │ ├── cart.jsp # 购物车清单 │ └── checkout.jsp # 结算页 └── README.md用命令行打包 WAR 的方式是这样:
# 先编译 Java 源文件 javac -encoding UTF-8 -cp "/opt/tomcat9/lib/servlet-api.jar" \ -d WebContent/WEB-INF/classes \ $(find src -name "*.java") # 打包成 WAR cd WebContent jar -cvf ../shoppingcart.war . # 部署到 Tomcat cp ../shoppingcart.war /opt/tomcat9/webapps/ # 启动 Tomcat /opt/tomcat9/bin/startup.shjavac那条命令里三个关键参数:-encoding UTF-8保证中文源码不被按 GBK 编译出乱码;-cp把 Tomcat 自带的servlet-api.jar挂到 classpath,否则编译期就找不到HttpServlet;-d把.class直接输出到WEB-INF/classes,省去手动拷贝。jar -cvf那条注意是「先 cd 到 WebContent 再打包」,因为 WAR 内部的根目录必须是WEB-INF/、index.jsp这些内容本身,而不是外面那层工程目录,多打一层ShoppingCartDemo/进去,访问就会 404。
web.xml只有几行,但真正发挥作用的就是<welcome-file>和<session-config>这两处:
<?xml version="1.0" encoding="UTF-8"?> <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_4_0.xsd" version="4.0"> <display-name>ShoppingCartDemo</display-name> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> <session-config> <session-timeout>30</session-timeout> </session-config> </web-app><session-timeout>单位是分钟,30 是 Tomcat 的默认值。购物车 Demo 里把它显式写出来有两个好处:一是读工程的人一眼能看到会话时长的设定;二是在调 session 过期的时候可以直接改这里,不用去动全局配置。
2.3 启动验证和几个一眼能看出的环境症状
部署之后访问http://localhost:8080/shoppingcart/应该直接进index.jsp。这一步如果 404,先别急着看代码,按顺序排查三件事:webapps/shoppingcart/目录是否真的存在(有时候 WAR 复制失败);Tomcat 的conf/server.xml默认端口有没有被别的进程占用;浏览器地址有没有带错 context path。
500 错误的排查入口只有一处:看logs/catalina.out和logs/localhost.<日期>.log。前者是控制台输出汇总,后者是每个 context 的错误栈。JSP 编译期的异常一般只会出现在localhost.<日期>.log里,catalina.out里只有一句Servlet.service() for servlet [jsp] threw exception,不看localhost日志很难定位。
还有一种很隐蔽的情况:代码编译通过、WAR 也部署了,但访问时所有 CSS、图片全部 404。原因基本是把静态资源和 JSP 放到了同一个目录,访问路径却按相对路径写的。正确做法是 JSP 里所有资源引用统一加${pageContext.request.contextPath},比如<link rel="stylesheet" href="${pageContext.request.contextPath}/css/main.css">,这样不管部署成什么 context path 都能找到资源。
3. session 购物车的设计:Map 结构 + 三支 Servlet 撑起整条链路
3.1 session、request、cookie、数据库四者的取舍
购物车数据放哪里,是这个 Demo 第一个要讲清楚的设计决定。session 本质上是服务端维护的一张表:key 是浏览器携带的JSESSIONID,value 是一个HttpSession对象,浏览器第一次访问时容器自动Set-Cookie下发一个 ID,后续每次请求都靠这个 ID 关联回同一份数据。所以它天然满足「同一个用户在一个会话里购物车是连续」的需求,同时因为是服务端存储,用户就算在浏览器里改 cookie 也拿不到别人的数据。
| 存储位置 | 生命周期 | 跨设备同步 | 容量上限 | 适配场景 |
|---|---|---|---|---|
| session | 会话级别,默认 30 分钟 | 否 | 内存,几百条无压力 | 临时购物车、教学 Demo |
| cookie | 浏览器控制 | 否 | 单域约 4KB | 记住用户名、偏好设置 |
| request | 一次请求 | 否 | 无 | 页面内传参 |
| 数据库/Redis | 手动或 TTL 控制 | 是 | 磁盘或内存 | 生产环境购物车 |
行业里成熟电商的购物车通常是「session 存未登录临时车 + 登录后合并到数据库」的双写结构,这份 Demo 只保留前半段,也就是 session 那一层。好处是代码路径短,坏处是用户一关浏览器购物车就没了,多设备也看不到同一份数据。演示、教学、小规模内网工具完全够用,但要上生产就得考虑后面补一层持久化。
理解 session 生命周期还有一个容易翻车的点:它是由容器主动回收的,不是浏览器关掉就立刻销毁。默认 30 分钟内只要还有请求过来,session 就会被续期;反之 30 分钟没有任何请求,容器就把它从内存里清掉。开发阶段经常遇到「刚加的购物车没刷新几次就空了」,十有八九是把<session-timeout>改成了离谱的小值,或者在调试工具里手动清了 JSESSIONID。
3.2 Product 与 CartItem 两个数据模型
数据模型只用了两个类,但字段设计有几个别踩的点。Product是商品的定义,CartItem是「购物车里的一行」,它持有Product的引用加一个数量。这样设计的好处是价格只在Product里维护一份,购物车里不复制价格,避免结算和商品列表价格对不上的经典问题。
package com.example.cart.model; import java.math.BigDecimal; public class Product { private final String id; private final String name; private final BigDecimal price; private final String imageUrl; public Product(String id, String name, BigDecimal price, String imageUrl) { this.id = id; this.name = name; this.price = price; this.imageUrl = imageUrl; } public String getId() { return id; } public String getName() { return name; } public BigDecimal getPrice() { return price; } public String getImageUrl() { return imageUrl; } }这里price用的是BigDecimal而不是double。这是血泪经验:double在金额上做加法会出现0.1 + 0.2 = 0.30000000000000004这种经典误差,购物车里数量一多、单价一带小数,结算金额就会飘几分钱。BigDecimal虽然写起来啰嗦一点,但金额对得上账。构造器里传new BigDecimal("399.00")时一定用字符串,别用new BigDecimal(399.00),后者一进去就已经是double精度损失了。
CartItem的写法:
package com.example.cart.model; import java.math.BigDecimal; public class CartItem { private final Product product; private int quantity; public CartItem(Product product, int quantity) { this.product = product; this.quantity = quantity; } public Product getProduct() { return product; } public int getQuantity() { return quantity; } public void setQuantity(int quantity) { // 下限 1,上限 99,超出范围直接夹紧 this.quantity = Math.max(1, Math.min(99, quantity)); } // 单行小计 public BigDecimal getSubtotal() { return product.getPrice().multiply(BigDecimal.valueOf(quantity)); } }setQuantity里做了夹紧处理,这个细节很多人不写,导致前端传个-1进来直接算出负数小计。前端<input type="number" min="1">只能约束合法浏览器,抓包改参数照样能绕过,所以服务端必须兜底。quantity上限 99 是常见电商的做法,防止某一行数量过大多到金额溢出视图。getSubtotal用price.multiply(count),BigDecimal的方法返回新对象,不改变原值,所以多行累加的时候要另外写一个汇总逻辑,不能指望subtotal自己累。
3.3 三支 Servlet:加入、修改数量、删除
整个 Demo 的控制器部分就是三支 Servlet,路径规划成/cart/add、/cart/update、/cart/remove,围绕一份Map<String, CartItem>做增删改。这里最大的设计取舍是:用Map还是用List。用List的话每次改数量都得遍历,查找复杂度 O(n),而且同一个商品可能被加两次变成两行,用户在购物车里看到重复条目会懵;用Map以商品 id 为 key,天然保证同一商品只有一行,改数量直接get命中。
AddToCartServlet是最能体现这套结构的:
package com.example.cart.servlet; import com.example.cart.model.CartItem; import com.example.cart.model.Product; import com.example.cart.repository.ProductRepository; 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; import java.util.LinkedHashMap; import java.util.Map; @WebServlet("/cart/add") public class AddToCartServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 1. 统一 UTF-8,防止商品名、参数中文变问号 req.setCharacterEncoding("UTF-8"); String productId = req.getParameter("productId"); int qty = parseQty(req.getParameter("qty")); // 2. 取 session,没有就创建一个 HttpSession session = req.getSession(); // 3. 取购物车容器,没有就初始化成有序 Map @SuppressWarnings("unchecked") Map<String, CartItem> cart = (Map<String, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new LinkedHashMap<>(); session.setAttribute("cart", cart); } // 4. 已在购物车里则累加数量,否则新加一行 if (cart.containsKey(productId)) { CartItem item = cart.get(productId); item.setQuantity(item.getQuantity() + qty); } else { Product p = ProductRepository.findById(productId); if (p != null) { cart.put(productId, new CartItem(p, qty)); } } // 5. PRG 模式:POST 后重定向到 GET,刷新页面不会重复提交 resp.sendRedirect(req.getContextPath() + "/cart.jsp"); } private int parseQty(String raw) { if (raw == null || raw.isEmpty()) return 1; try { return Math.max(1, Integer.parseInt(raw)); } catch (NumberFormatException e) { return 1; } } }五段逻辑各管一件事,每一段都有个坑位。
第 1 段setCharacterEncoding("UTF-8")只对 POST body 生效,GET 请求的参数编码要走server.xml里的URIEncoding,这是很多人以为「我设了 UTF-8 怎么还有乱码」的根源。第 2 段req.getSession()默认会新建,如果这一行写成getSession(false),第一次访问时就是null,后面session.setAttribute直接 NPE。第 3 段用了LinkedHashMap而不是HashMap,因为购物车清单展示的时候通常按加入顺序排,HashMap的迭代顺序不稳定,用户刷新一下商品顺序就跳了会显得很诡异。第 4 段别忘了cart.containsKey之后修改的是 item 内部的 quantity,Map本身没换 key,所以不需要再put一次;新手常见错误是每次都用新CartItem覆盖,导致之前加的丢了。第 5 段用sendRedirect而不是forward,这就是 PRG 模式(Post-Redirect-Get),浏览器刷新的时候是重新 GET,不会重新 POST 一遍,否则用户每刷新一次购物车就多一份,那才是真翻车。
改数量和删除两支 Servlet 结构一样,只是操作不同:
@WebServlet("/cart/update") public class UpdateQuantityServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); String productId = req.getParameter("productId"); int qty = Integer.parseInt(req.getParameter("qty")); HttpSession session = req.getSession(); @SuppressWarnings("unchecked") Map<String, CartItem> cart = (Map<String, CartItem>) session.getAttribute("cart"); if (cart != null && cart.containsKey(productId)) { cart.get(productId).setQuantity(qty); // setQuantity 内部会夹紧 1-99 } resp.sendRedirect(req.getContextPath() + "/cart.jsp"); } }RemoveFromCartServlet就一句cart.remove(productId),没什么可讲的,但其中的一个细节值得说:删除一侧没有做「购物车为空时移除 session 上的 cart 属性」的处理。加不加都行,加了之后 JSP 里cart可能是null,页面上就得用<c:if test="${not empty cart}">包一层,不加则至少保证 JSP 渲染逻辑简单。这份 Demo 选的是后者,牺牲一点点内存换取更简单的视图逻辑,教学项目够用,生产环境会在用户登出时显式session.invalidate()清干净。
4. JSP + EL + JSTL 渲染:从清单到金额结算的几个关键写法
4.1 商品列表页:一次 forEach 遍历模拟数据
index.jsp干的事情很简单:从ProductRepository里取全部商品,用<c:forEach>遍历成一张卡片网格。写法上最容易错的地方是「没引 JSTL 就写c:forEach」和「引了 JSTL 却忘了加 taglib 指令」。两件事都要做,不能只为了一件事。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <!DOCTYPE html> <html> <head> <meta charset="UTF-8"> <title>商品列表</title> </head> <body> <h1>商品列表</h1> <div class="grid"> <c:forEach var="p" items="${applicationScope.productList}"> <div class="card"> <img src="${pageContext.request.contextPath}${p.imageUrl}" alt="${p.name}"/> <h3>${p.name}</h3> <p class="price">¥<fmt:formatNumber value="${p.price}" pattern="0.00"/></p> <form action="${pageContext.request.contextPath}/cart/add" method="post"> <input type="hidden" name="productId" value="${p.id}"/> <input type="number" name="qty" value="1" min="1" max="99"/> <button type="submit">加入购物车</button> </form> </div> </c:forEach> </div> </body> </html>productList的来源有两个常见做法:一是写一个ServletContextListener在启动时setAttribute;二是在某个Servlet里getServletContext().setAttribute。前者是更干净的实践,我一般会选前者,因为商品数据属于应用级别,生命周期跟应用一致,而不是跟某次请求绑定。
<fmt:formatNumber value="${p.price}" pattern="0.00"/>是金额展示的标准写法,0.00保证永远两位小数、哪怕价格是399也补成399.00;写成#.##的话399会显示成399.,因为#表示「有就显示没有就省略」。这类细节在演示里不算致命,但在客户面前蹦出来会显得很不专业。
4.2 购物车清单页:Map 的遍历与金额累加
cart.jsp是整份 Demo 里代码最集中的一页。核心难点是「session里那个Map<String, CartItem>怎么在 JSP 里遍历」。JSTL 的<c:forEach>遍历 Map 时,var拿到的是Map.Entry,商品 id 用${entry.key}拿,购物车行对象用${entry.value}拿,行对象里再往下一层是product和quantity。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <!DOCTYPE html> <html> <head><meta charset="UTF-8"><title>购物车</title></head> <body> <h1>购物车</h1> <c:choose> <c:when test="${empty sessionScope.cart}"> <p>购物车还是空的,去<a href="${pageContext.request.contextPath}/index.jsp">挑几件</a>吧。</p> </c:when> <c:otherwise> <table> <thead> <tr><th>商品</th><th>单价</th><th>数量</th><th>小计</th><th>操作</th></tr> </thead> <tbody> <c:set var="total" value="0"/> <c:forEach var="entry" items="${sessionScope.cart}"> <tr> <td>${entry.value.product.name}</td> <td>¥<fmt:formatNumber value="${entry.value.product.price}" pattern="0.00"/></td> <td> <form action="${pageContext.request.contextPath}/cart/update" method="post"> <input type="hidden" name="productId" value="${entry.key}"/> <input type="number" name="qty" value="${entry.value.quantity}" min="1" max="99"/> <button type="submit">更新</button> </form> </td> <td>¥<fmt:formatNumber value="${entry.value.subtotal}" pattern="0.00"/></td> <td> <form action="${pageContext.request.contextPath}/cart/remove" method="post"> <input type="hidden" name="productId" value="${entry.key}"/> <button type="submit">删除</button> </form> </td> </tr> <c:set var="total" value="${total + entry.value.subtotal}"/> </c:forEach> <tr class="total"> <td colspan="3">合计</td> <td colspan="2">¥<fmt:formatNumber value="${total}" pattern="0.00"/></td> </tr> </tbody> </table> </c:otherwise> </c:choose> </body> </html><c:set var="total" value="0"/>那里看起来只是声明一个变量,但实际是把total放进了 page 作用域,后面${total + entry.value.subtotal}每次都会顺着 page、request、session、application 的顺序去找,找到的是 page 里那个BigDecimal。这里能算对,是因为 EL 里的+遇到BigDecimal会走BigDecimal.add()。这套写法在 EL 2.2 及以上稳定,更老的容器里会退化成隐式double转换,小数金额可能又会飘,所以 JDK 8 + Tomcat 9 这个组合是真正的底线。
把「更新数量」和「删除」都写成 POST 表单而不是<a href>链接,不是什么约定俗成,而是很实在的安全考虑:GET 请求可以被预加载、被浏览器缓存、被爬虫扫到,一条 GET 的删除链接藏在邮件、聊天工具里,用户点一下购物车就少一行。POST 至少能避免这类误触发。
4.3 结算页:把「展示总价」和「真正下单」分开
checkout.jsp在 Demo 里只做一件事:展示订单摘要。但从学习角度它值得单独拎出来,因为它把「展示」和「换算」做了明确的分离:页面上所有金额都是「元」为单位、两位小数展示;但代码里真实计算全走BigDecimal的multiply和add,中间没有一次double。如果直接在 JSP 里写${total * 1}或者${total}让 EL 自己推,BigDecimal和Long混算时会静默丢失精度,最后一位小数就开始飘。所以checkout.jsp里所有金额一律用<fmt:formatNumber>输出,EL 里不做任何数值运算,只做取值。
<c:if test="${not empty sessionScope.cart}"> <h2>订单摘要</h2> <p>商品种类:<c:out value="${fn:length(sessionScope.cart)}"/> 种</p> <p>合计金额: <fmt:formatNumber value="${sessionScope.cartTotal}" pattern="¥#,##0.00"/> </p> </c:if>这里的cartTotal是在cart.jsp或某个Servlet里提前算好放进去的,不放在 JSP 里现算。原因很简单:JSP 里做业务计算,一旦页面结构变了、变量名改了,计算逻辑就跟着藏起来,后面维护会很难找。行业里比较稳的习惯是「所有金额在 Servlet 里算好,JSP 只负责格式化展示」,checkout.jsp就是这套习惯的落地。fn:length是functions标签库里的方法,用之前要把<%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %>加上,不然整页报fn cannot be resolved。
5. 避坑与常见问题排查:五个真实踩过的坑
5.1 中文乱码:表单里商品名变成问号
现象:index.jsp页面显示正常,但提交到cart.jsp之后,商品名全部变成???或者「机械»」这种乱码。
原因:中文乱码在 JSP 里有三个不同的来源,很容易修了一处另一处还乱。第一处是 JSP 文件自身的pageEncoding,跟contentType里的charset不是一回事,前者决定 Java 源怎么读这个文件,后者决定响应头怎么写;第二处是 POST 请求的 body 编码,靠request.setCharacterEncoding("UTF-8")管;第三处是 GET 请求的 URL 参数编码,这条没法用 API 改,只能改conf/server.xml里的Connector配置。
解决:JSP 首行统一写全<%@ page contentType="text/html;charset=UTF-8" language="java" pageEncoding="UTF-8" %>;Servlet 的第一行固定req.setCharacterEncoding("UTF-8");server.xml的Connector加上URIEncoding="UTF-8"。三处一起动,才能把所有场景盖住。
5.2 购物车突然变空:session 被悄悄回收了
现象:加了几件商品,去泡了杯咖啡回来刷新,购物车空了,但浏览器里JSESSIONIDCookie 还在。
原因:HttpSession的生命周期是由容器管的,默认 30 分钟没请求就回收。但如果 web 应用部署在 Tomcat 上,且 WAR 包被重新部署(webapps目录下同名 WAR 被覆盖),Tomcat 默认会把所有 session 序列化再反序列化,很多对象如果没有实现Serializable就会直接丢,用户就会看到「改了代码一刷新购物车就空了」。这份 Demo 里Product和CartItem默认没实现Serializable,热部署时就会触发。
解决:长期做法是让Product、CartItem都实现java.io.Serializable,并加serialVersionUID;短期做法是开发期间尽量别在 session 里有数据的时候重新部署,或者直接从 IDE 里用重启代替覆盖。生产环境则会把购物车沉到 Redis,靠 TTL 管理过期,不依赖容器的 session 机制。
5.3 启动时ClassNotFoundException: javax.servlet.jsp.jstl.core.Config
现象:WAR 部署成功,访问index.jsp直接 500,日志里是java.lang.ClassNotFoundException: javax.servlet.jsp.jstl.core.Config。
原因:容器只提供 Servlet 和 JSP 的 API,不提供 JSTL 的实现类。JSTL 从 Java EE 8 之后就不再随容器分发了,Tomcat 9 装好之后lib/目录下是没有jstl相关 jar 的,必须把jstl-1.2.jar放进WEB-INF/lib。
解决:去 Maven 仓库下载jstl-1.2.jar,放到WebContent/WEB-INF/lib/下。如果工程是用 Maven 管的,pom.xml里加上javax.servlet:jstl:1.2依赖,打包时就会自动带进去。还有一点要注意:jstl-1.2.jar已经包含了 API 和实现,不需要再单独加standard.jar,老资料里那个「两个 jar 都要加」的说法是 JSTL 1.1 时代的遗留,别照搬。
5.4 Tomcat 10 上所有 JSP 报 404 或 500
现象:代码一点没改,换个机器上一部署,index.jsp直接 404,或者 500 报The absolute uri: http://java.sun.com/jsp/jstl/core cannot be resolved。
原因:Tomcat 10 之后整个 Servlet 规范迁到了jakarta.servlet.*包名下,JSTL 也换了坐标jakarta.servlet.jsp.jstl:jakarta.servlet.jsp.jstl-api,老的java.sun.com命名空间在 Tomcat 10 上根本找不到匹配的 TLD。这跟代码质量无关,纯粹是版本不匹配。
解决:要么把 Tomcat 换回 9.0.x(推荐,学习场景没必要折腾新版),要么把源码里所有javax.servlet.*改成jakarta.servlet.*、jstl-1.2.jar换成 Jakarta 版坐标、JSP 里 JSTL 的 URI 从http://java.sun.com/jsp/jstl/core改成jakarta.tags.core。三件事一起改,改一半照样报错。
5.5 金额显示0.30000000000000004
现象:两件商品分别是 0.1 元和 0.2 元,购物车合计显示成0.30000000000000004。
原因:EL 表达式里做+运算时,如果参与运算的两边没有一方是BigDecimal,容器会把它们转成double再算,0.1 + 0.2的精度误差就原样暴露出来。很多 Demo 一开始用double存价格,就是因为写完看不见问题,等单价出现小数才爆发。
解决:模型层所有的金额字段一律用BigDecimal,并且Product构造时用new BigDecimal("399.00")这种字符串重载,不要用double的重载;JSP 里<c:set var="total" value="0"/>初始值写成BigDecimal.ZERO对应的字符串"0"也行,但累加时至少要保证加数是BigDecimal。最稳妥的做法是汇总逻辑全在 Servlet 里算,JSP 只<fmt:formatNumber>输出结果,这样 EL 里根本不做数值运算,也就没有了精度问题。
6. 把 session 购物车往上推一步:三个值得动手验证的扩展点
会话购物车跑通之后,真正值得花时间验证的其实不是「再加一个页面」,而是这三个点,每一个都能测出一个具体边界。
第一个扩展点是「session 不是本机内存」,用一个反向代理或双击两个浏览器窗口测一下就能看到:同一个浏览器会话里的购物车是共享的,另一个浏览器实例(或者无痕模式)打开是空的。这验证了 session 的「按 JSESSIONID 隔离」的设计。如果再起一个 Tomcat 实例做横向扩容,不做 session 共享的话,用户第二次请求打到另一台机器上,购物车就凭空消失了,这也就是为什么生产环境的购物车最终都得落到 Redis。
第二个扩展点是「并发修改同一行」。打开两个标签页同时加同一件商品,两个请求先后到达AddToCartServlet,cart.containsKey都返回 true,然后都去item.setQuantity(item.getQuantity() + qty),最终数量可能是加两次的正确值,也可能因为int读改写不是原子操作丢一次。要严格保证正确性,Map得换成ConcurrentHashMap或者干脆在synchronized块里改;但这只是演示,int在原生的 demo 里概率不高,一旦上升到「多台机器同时加购物车」的层面,同步方案直接换成 Redis 的HINCRBY才是出路。
第三个扩展点是「价格快照」。现在的CartItem持有Product的引用,如果商品价格在运营后台被改了,用户加入购物车之后看到的小计也会跟着变,这跟电商真实的「下单时锁价」逻辑不一样。正确做法是在CartItem加一个unitPrice字段,加入购物车那一刻就把价格复制一份进去,结算时用小计按快照算,商品当前价只用于「提示价格已变」的展示。这一点很多教学 Demo 都不写,因为一写就要多一个字段、多一次价格同步判断,但它是区分「玩具购物车」和「能上生产的购物车」最明显的一条线。
我自己那份最早版本的购物车 Demo 里就是直接引用Product,结果有一次演示时运营把演示商品价格从 399 改成 299,所有已经加进购物车的用户小计全部跟着变,现场那叫一个尴尬。从那以后我每次做购物车类功能都先把「快照价」三个字写在模型设计的第一行,不管是不是演示项目。希望这份拆解能帮到你,少走几步弯路。
本文还有配套的精品资源,点击获取