简介:购物车案例【简单版】压缩包面向Java Web入门与课程实训读者,围绕JSP、Servlet、Tomcat、HTML与Session构建一个可运行的简易购物车示例,帮助理解商品展示、加入与删除购物车及用户状态保持等基础流程。包内共62个文件,约916KB,以编译后的class、Java源码、JSP页面、XML配置、jar依赖及少量HTML、JS资源为主,分别对应业务分层、页面展示、容器配置与前端交互,目录按entity、dao、service、servlet等模块组织,便于对照阅读。已有671人学习,适合作为JavaWeb综合练习或课程设计参考,可从中梳理Servlet接收请求、Session存储购物车、数据库持久化商品与购物项的实现思路,并了解简单分层项目的组织方式。
1. 从一次「刷新就清空」的翻车说起:这个 JSP 购物车案例在练什么
改完cart.jsp的表格样式,顺手按了 F5,购物车里刚加的三件商品全没了。这是我第一次接触购物车案例时遇到的坑,排查半天才发现问题不在页面,而是 Servlet 里每次请求都重新new了一个HashMap覆盖掉 session 里那份。这个 JSP 购物车案例简单版,练的就是这条容易翻车的链路:HTML 负责把商品摆出来,Java Servlet 接住表单提交,Tomcat 当容器,最关键的购物车状态挂在 Session 上,靠浏览器带回的 JSESSIONID 认人。
它适合刚学完 Servlet 生命周期、能写个 Hello World 却还没真正理解「会话状态」的人。整个工程不到十个文件,没有数据库、没有框架、没有连接池,你能在一小时内把「加购—查看—改数量—清空」这条闭环跑通,然后把每一行代码拆开看它为什么这么写。也正因为它足够小,后面想加数据库、加登录、加库存校验时,改动点在哪一目了然。
2. 工程骨架拆解:WebContent 下这几个文件各自管什么
2.1 目录结构与文件职责清单
这类简单版案例通常是 Eclipse Dynamic Web Project 的形态,.rar解压出来就是一个能直接导入的工程。目录结构大致如下,我把每个文件的作用标在注释里:
cart-demo/ ├── src/com/example/cart/ │ ├── model/Product.java # 商品模型:id、名称、单价 │ ├── model/CartItem.java # 购物车行项:商品 + 数量 + 小计 │ └── web/CartServlet.java # 唯一控制器,处理 add/update/remove/clear ├── WebContent/ │ ├── WEB-INF/ │ │ ├── web.xml # Servlet 映射、欢迎页 │ │ └── lib/ # jstl.jar、standard.jar 放这里 │ ├── index.jsp # 商品列表页,每个商品一个加购表单 │ ├── cart.jsp # 购物车页,遍历 sessionScope.cart 渲染 │ └── css/style.css # 表格、按钮样式,纯静态 └── README.mdsrc和WebContent的分离是这套老工程的核心约定:src下的.java编译后必须落到WebContent/WEB-INF/classes才生效,WEB-INF下的东西浏览器直接访问不到,只能由服务端转发,这是 Servlet 规范的保护机制。lib目录里的 jar 会被容器自动加载到应用类路径,所以 JSTL 的包丢在这里而不是项目根目录。
| 文件 | 类型 | 是否可直接访问 | 职责 |
|---|---|---|---|
| index.jsp | JSP | 是 | 展示商品列表与加购按钮 |
| cart.jsp | JSP | 是 | 展示购物车、数量表单、合计 |
| CartServlet.java | Java | 否 | 所有写操作的唯一入口 |
| web.xml | XML | 否 | 映射/cart,配置欢迎页 |
| style.css | CSS | 是 | 样式,无业务逻辑 |
表格里能看出来,整个项目只有CartServlet一个控制器,所有动作靠一个action参数分发。这是教学案例的典型取舍:牺牲扩展性换可读性,等你要加订单、加支付时,再按action拆成多个 Servlet 或引入前端控制器。
2.2 JDK、Tomcat 与工程导入的三步落地
环境这块没有玄学,但版本对不上确实会卡人。这个案例的代码用的是javax.servlet.*包名,所以 Tomcat 只能选 9 及以下,JDK 用 8 或 11 都行。Tomcat 10 之后包名整体迁到jakarta.servlet.*,直接部署会报ClassNotFoundException,这是第一个高频翻车点。
导入工程后按三步走。第一步确认 Build Path 里的 JRE 和 Server Runtime 都挂上了,Eclipse 里右键工程 → Properties → Java Build Path → Libraries,把 Tomcat 的 Runtime 加进去,这样HttpServlet才能编译通过。第二步把工程加到 Tomcat 实例里,右键 Servers 视图里的 Tomcat → Add and Remove,把项目挪到右侧。第三步启动,控制台出现Server startup in xxx ms就说明容器起来了,访问http://localhost:8080/cart-demo/即可。
如果你不想开 IDE,纯命令行也能跑,编译时注意 servlet-api 只参与编译、不能打进WEB-INF/lib,否则和容器自带的包冲突:
# 编译:-cp 指向容器的 servlet-api.jar,-d 指定输出到 classes javac -encoding UTF-8 \ -cp "$CATALINA_HOME/lib/servlet-api.jar" \ -d WebContent/WEB-INF/classes \ $(find src -name "*.java") # 打包:进到 WebContent 上层,把整个目录打成 war jar -cvf cart-demo.war -C WebContent . # 部署:丢进 webapps,Tomcat 会自动解压 cp cart-demo.war "$CATALINA_HOME/webapps/"参数上重点看两个:-encoding UTF-8不加的话,源码里的中文商品名编译后就是乱码;-cp后面必须是容器 lib 下的 servlet-api,用 Maven 仓库里那份也行,但版本别和 Tomcat 差太远。打包时-C WebContent .的意思是「切换到 WebContent 目录,把当前目录所有内容打进 war 根」,这样 war 解压后index.jsp在根路径,而不是多套一层WebContent目录导致 404。
部署完如果页面白屏但控制台没报错,先看webapps下有没有解压出对应目录,再看访问路径的 context path 是不是等于 war 包名。改过 war 名的话,路径要跟着改。
2.3 web.xml 里那两个必须配对的节点
老工程用web.xml做映射,servlet-name和servlet-mapping里的名字必须完全一致,这是最容易写错又最难从报错里看出来的一处:
<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1"> <!-- 声明 Servlet,name 是内部标识,随便起但要和下面一致 --> <servlet> <servlet-name>cartServlet</servlet-name> <servlet-class>com.example.cart.web.CartServlet</servlet-class> </servlet> <!-- 映射:url-pattern 决定浏览器用什么地址访问 --> <servlet-mapping> <servlet-name>cartServlet</servlet-name> <url-pattern>/cart</url-pattern> </servlet-mapping> <!-- 访问根路径时默认打开哪个页面 --> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> </web-app>servlet-class写的是全限定类名,少一层包名就是ClassNotFoundException。url-pattern写成/cart表示精确匹配,写成*.do是扩展名匹配,/会拦截所有请求包括静态资源,新手别乱用。welcome-file只影响目录访问的默认页,不改变 Servlet 映射。
提示:如果你给
CartServlet加了@WebServlet("/cart")注解,同时web.xml里又配了一遍,两者会冲突。简单版建议二选一,跟着源码走,别自己加。
3. 加入购物车链路:从 index.jsp 的表单到 Session 里的一行 Map
3.1 商品数据从哪来:硬编码 List 与 Product 模型
简单版没有数据库,商品是写死在内存里的。Product模型只有三个字段,配合一个全参构造:
package com.example.cart.model; public class Product { private String id; private String name; private double price; // 简单版用 double,精度问题见第 5 章 public Product(String id, String name, double price) { this.id = id; this.name = name; this.price = price; } public String getId() { return id; } public String getName() { return name; } public double getPrice() { return price; } }字段用String做 id 而不是自增 int,是因为后面购物车用Map<String, CartItem>存,key 直接用 id 最省事,不用额外维护索引。price用double是这个案例的原始写法,看着直观,但合计金额会冒出0.30000000000000004这种结果,第五章会讲怎么换掉它。
商品目录放在 Servlet 里作为静态常量,避免每次请求重新构造:
private static final List<Product> CATALOG = Arrays.asList( new Product("p01", "机械键盘", 299.00), new Product("p02", "无线鼠标", 89.00), new Product("p03", "显示器支架", 159.00) );Arrays.asList返回的是固定长度列表,只能读不能增删,正好符合「商品目录不在这改」的语义。想加商品直接在这里补一行即可,不用动任何页面。
3.2 CartItem 为什么要把 Product 包一层
很多人第一版会写成Map<String, Integer>,key 是商品 id,value 是数量。能跑,但页面渲染时你得再按 id 去商品列表里查一遍名称和单价,查一次还好,循环里查就是 O(n²)。
CartItem把商品对象和数量绑在一起,顺便把小计算出来:
package com.example.cart.model; public class CartItem { private 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) { this.quantity = quantity; } // 小计,页面直接 ${item.subtotal} 取 public double getSubtotal() { return product.getPrice() * quantity; } }这里用「包装」而不是「继承」,是因为CartItem和Product不是 is-a 关系,一个商品可以对应多个购物车行项(不同用户、不同会话)。getSubtotal写成只读方法而不是存字段,避免数量改了忘记同步小计。JSP 里 EL 表达式访问${item.subtotal}时,容器会自动找getSubtotal(),这是 JavaBean 命名规范带来的便利。
3.3 CartServlet 的 action 分支与转发逻辑
整个项目的写操作都压在doPost里,靠action参数分流。下面这段是核心,我加了注释:
@Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding("UTF-8"); // 必须在取参数之前 String action = req.getParameter("action"); HttpSession session = req.getSession(); // 没有会话就新建一个 Map<String, CartItem> cart = getOrCreateCart(session); if ("add".equals(action)) { String id = req.getParameter("id"); Product p = findById(id); if (p != null) { CartItem item = cart.get(id); if (item == null) { cart.put(id, new CartItem(p, 1)); // 首次加购 } else { item.setQuantity(item.getQuantity() + 1); } } } else if ("update".equals(action)) { String id = req.getParameter("id"); int qty = Integer.parseInt(req.getParameter("qty")); CartItem item = cart.get(id); if (item != null) { if (qty <= 0) { cart.remove(id); // 数量归零等于删除 } else { item.setQuantity(qty); } } } else if ("remove".equals(action)) { cart.remove(req.getParameter("id")); // 不存在也不报错 } else if ("clear".equals(action)) { cart.clear(); } // PRG 模式:处理完重定向,刷新页面不会重复提交 resp.sendRedirect(req.getContextPath() + "/cart.jsp"); } @SuppressWarnings("unchecked") private Map<String, CartItem> getOrCreateCart(HttpSession session) { Map<String, CartItem> cart = (Map<String, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new LinkedHashMap<>(); // 保序,页面顺序不跳 session.setAttribute("cart", cart); } return cart; }逐点说。setCharacterEncoding必须放在任何getParameter之前,否则容器已经按默认编码解析完了,再设也没用。getSession()不带参数等价于getSession(true),没有会话就创建,购物车场景正需要这个行为;如果只想读不想建,用getSession(false)并判空。
getOrCreateCart是整个链路的关键,它保证了同一个会话里始终是同一个 Map 实例。写成LinkedHashMap而不是HashMap是因为 HashMap 的遍历顺序不稳定,用户加了三件商品,刷新一下顺序变了,体验上很奇怪,这是血泪经验。
最后一行sendRedirect而不是forward,是为了让浏览器地址栏停在购物车页并重新发起 GET,用户按 F5 时重复提交的是 GET 请求,不会把商品又加一遍。代价是多一次请求,但这个场景完全值得。
4. Session 会话管理:JSESSIONID 是怎么把购物车绑到你浏览器上的
4.1 一次请求里 Session 的创建、读取与销毁
HTTP 是无状态的,服务端认不出两次请求来自同一个人。Session 的做法是:第一次调用getSession()时容器生成一个随机 ID,创建一个HttpSession对象存在内存里,同时通过响应头Set-Cookie: JSESSIONID=xxx把这个 ID 发给浏览器。之后浏览器每次请求自动带上这个 Cookie,容器按 ID 找回对应的 Session 对象。
// 第一次请求:容器创建 Session,分配 JSESSIONID HttpSession session = req.getSession(); // 存数据,key 建议用常量,避免各处手写字符串写错 session.setAttribute("cart", cart); // 读数据,注意强转 Object obj = session.getAttribute("cart"); // 只读场景:不创建新会话,拿不到就是 null HttpSession existing = req.getSession(false); // 用户退出或清空:让当前会话立即失效 session.invalidate(); // 调整超时,单位秒,默认 30 分钟 session.setMaxInactiveInterval(60 * 60);setAttribute的 key 在整个应用内共享命名空间,两个模块都用"cart"就会互相覆盖,所以真实项目里我一般定义成public static final String CART_KEY = "cart"放在常量类里。invalidate()之后原来的 JSESSIONID 作废,但浏览器里的 Cookie 要等下次响应才会被清掉,所以退出后立刻访问其他页面,容器会新建一个 Session,购物车自然是空的。
这里还有个容易忽略的点:Session 存在容器内存里,Tomcat 重启就全没了;多台 Tomcat 做集群时,A 机器创建的 Session 在 B 机器上读不到。简单版不涉及这些,但你要是打算把它改成能上线的版本,这是第一个要解决的问题,第五章会展开。
4.2 cart.jsp 的渲染与合计金额计算
页面用 JSTL 遍历sessionScope.cart,比 scriptlet 干净很多:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <table> <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> <!-- 改数量:提交到 /cart,action=update --> <form action="${pageContext.request.contextPath}/cart" method="post"> <input type="hidden" name="action" value="update"> <input type="hidden" name="id" value="${entry.key}"> <input type="number" name="qty" value="${entry.value.quantity}" min="0"> <button type="submit">更新</button> </form> </td> <td><fmt:formatNumber value="${entry.value.subtotal}" pattern="0.00"/></td> <td> <form action="${pageContext.request.contextPath}/cart" method="post"> <input type="hidden" name="action" value="remove"> <input type="hidden" name="id" value="${entry.key}"> <button type="submit">删除</button> </form> </td> </tr> </c:forEach> </table>${sessionScope.cart}直接取 session 里名为cart的属性,遍历 Map 时entry.key是商品 id,entry.value是CartItem,所以${entry.value.product.name}会依次调用getProduct().getName()。fmt:formatNumber的pattern="0.00"保证金额显示两位小数,不加的话299.0看着别扭。
合计金额有两种写法。页面内累加简单直接:
<c:set var="total" value="0" /> <c:forEach var="entry" items="${sessionScope.cart}"> <c:set var="total" value="${total + entry.value.subtotal}" /> </c:forEach> <p>合计:<fmt:formatNumber value="${total}" pattern="0.00"/></p>另一种是把计算挪到 Java 里,JSP 只负责展示,页面里写成${cartTotal}。我倾向后者,因为金额计算迟早要换BigDecimal,散在 JSP 里改起来到处找。${pageContext.request.contextPath}拿到的是应用上下文路径,比如/cart-demo,表单 action 前面带上它,工程改名也不用动页面。
4.3 数量增减与删除的幂等处理
购物车这类操作最好做到「重复执行结果一致」。加购做了加一,天然不幂等,但删除和更新可以做得干净:
删除时直接cart.remove(id),key 不存在返回 null,不会抛异常,用户连点两次删除也不会报错。更新时先判item != null,再判数量是否小于等于 0,归零就走删除逻辑,这样即使用户手动把数量输入框改成 0 或者负数,购物车也不会留一行-3的诡异记录。
输入框上加了min="0",但这是浏览器层面的约束,构造请求绕过它太容易了。服务端必须再校验一次,Integer.parseInt遇到非数字会抛NumberFormatException,稳妥写法是包一层判断:
int qty; try { qty = Integer.parseInt(req.getParameter("qty")); } catch (NumberFormatException e) { qty = 1; // 参数被改坏时给个安全默认值 }这些细节不影响「能跑」,但决定了别人拿着你的代码改的时候会不会踩坑。简单版案例里往往省略了这些校验,你自己补上,才算真的理解这条链路。
5. 踩坑排查:五个让简单版购物车跑不起来的典型故障
5.1 部署与环境类故障
现象一:访问/cart一直 404,控制台没有任何异常。原因通常是web.xml里servlet-name和servlet-mapping的名字不一致,或者servlet-class的包名写错。容器启动时会校验类是否存在,但映射名对不上时不会报错,只是这个 URL 没有被任何 Servlet 接住。 解决是把两个servlet-name复制粘贴保证一模一样,再确认访问地址带上了正确的 context path。工程叫cart-demo就访问http://localhost:8080/cart-demo/cart,少一段都是 404。
现象二:启动报java.lang.ClassNotFoundException: javax.servlet.http.HttpServlet。十有八九是把 Tomcat 换成了 10 或 11。从 Tomcat 10 开始,Servlet API 的包名从javax.servlet迁到了jakarta.servlet,老代码编译时找的是javax,运行时容器里只有jakarta,自然找不到。 解决办法二选一:换回 Tomcat 9,或者把源码里所有javax.servlet改成jakarta.servlet并重新编译。别想着两个包混用,容器不会给你这个机会。
现象三:JSP 页面原样输出<c:forEach ...>标签,没有被解析。说明 JSTL 没生效。可能是WEB-INF/lib下缺jstl.jar和standard.jar(JSTL 1.2 之后合并成一个jstl-1.2.jar),也可能是web.xml的 version 太低,比如声明成 2.3,EL 表达式默认不启用。 解决是补齐 jar 包并确认web.xml声明为 3.0 及以上,页面顶上taglib的 uri 用http://java.sun.com/jsp/jstl/core。还有个隐蔽情况:jar 放进去了但 IDE 没重新部署,WEB-INF/lib里没有同步过去,clean 一下重新发布即可。
5.2 代码与编码类故障
现象四:加入购物车的商品名在页面显示成问号或方块。编码链路有三段:JSP 文件本身的保存编码、页面pageEncoding、请求参数解析编码。任何一段不是 UTF-8 都会坏。 解决是 JSP 首行写contentType="text/html;charset=UTF-8" pageEncoding="UTF-8",Servlet 里取参数前调用req.setCharacterEncoding("UTF-8"),响应侧resp.setContentType("text/html;charset=UTF-8")。三段齐了,中文就正常了。顺手检查一下 IDE 的文件编码设置,这个黑匣子一旦设错,后面全是玄学问题。
现象五:合计金额显示0.30000000000000004这种鬼数字。这是double的二进制浮点表示问题,不是代码写错了。0.1 + 0.2在 double 下就不等于0.3,金额计算用 double 迟早翻车。 解决是把Product.price和CartItem.subtotal换成BigDecimal,加法用add()、乘法用multiply(new BigDecimal(quantity)),最后setScale(2, RoundingMode.HALF_UP)保留两位。如果不想大改,也可以全程用「分」做整数运算,展示时再除以 100,简单版改造里这是性价比最高的一个改动。
6. 二次开发改造:把这辆「玩具车」推到能用的一条路径
这个案例最大的价值是骨架清晰,所以改造不用推倒重来,按优先级一条条加就行。下面这张表是我一般建议的顺序,从影响面小的开始:
| 改造项 | 改动文件 | 难点 | 验证方式 |
|---|---|---|---|
| 金额换 BigDecimal | Product、CartItem、cart.jsp | 所有运算点都要换 API | 加三件 0.1 元商品,合计应为 0.30 |
| 商品数据搬进数据库 | 新增 ProductDao、CartServlet | JDBC 连接与异常处理 | 改数据库价格,刷新页面同步变化 |
| 登录后合并购物车 | 新增 LoginServlet、CartService | 匿名车与用户车的合并策略 | 未登录加两件,登录后数量仍为 2 |
| JSP 去 scriptlet | 所有 jsp | EL 与 JSTL 熟练度 | 页面源码里搜不到<% |
| 统一编码过滤器 | 新增 CharacterEncodingFilter | 过滤器顺序 | 中文商品名全链路无乱码 |
拿「金额换 BigDecimal」举例,Product 的构造参数从double改成String,内部new BigDecimal(price),CartItem 的小计改成:
public BigDecimal getSubtotal() { // 单价是 BigDecimal,数量是 int,先转再乘,避免精度丢失 return product.getPrice().multiply(new BigDecimal(quantity)); }注意构造BigDecimal时一定用String入参,用double入参会把浮点误差原样带进来,等于白改。
再往上走一步是「登录后合并购物车」,这里的策略选择比代码更值得想。常见做法是登录成功时取出匿名 Session 里的cart,和用户库里已有的购物车按商品 id 求并集,数量相加,然后session.setAttribute覆盖回去。合并只做一次,之后所有读写都走用户维度,避免同一个购物车出现两份数据源。
我更习惯的做法是把购物车操作全部收进一个CartService,Servlet 只负责取参数、调服务、重定向。这样等你要加库存校验、加优惠券、加限购逻辑时,改动都在服务层,控制器几乎不用动。简单版的CartServlet里业务和参数解析混在一起,短期看着省事,加第三个功能就开始难受了。
验证方法也很朴素:每改一项,先跑一遍「加购—改数量—删除—清空」四步,再看 Tomcat 日志有没有异常,最后用浏览器开发者工具确认JSESSIONID这个 Cookie 在多次请求之间没变。这三点对上了,说明会话这条线还是通的。
从那以后我每次碰 Session 相关的代码,都会先把「Map 什么时候创建、什么时候覆盖」在纸上画一遍再动手,因为这类 bug 不报错,只是数据悄悄丢了,排查起来特别费时间。希望这些记录能帮你少走一遍同样的路,把这份案例跑起来之后,别停在能跑,挑一个改造项动手试试,理解会完全不一样。
本文还有配套的精品资源,点击获取