简介:这份压缩包是一套基于 JSP、Servlet、Tomcat 与 Session 的简易购物车实战案例,面向刚接触 Java Web 开发、希望打通“页面—控制层—业务层—数据层”链路的初学者与课程设计者。项目围绕商品列表展示、加入购物车、查看与删除购物车条目等基础流程组织,涉及实体建模、DAO 与服务层拆分、Servlet 请求处理、JSP 页面渲染以及 Session 状态保持,能帮助读者理解多页面间购物数据一致性的实现方式。包内共 62 个文件,以 26 个 class 编译产物、13 个 Java 源码、6 个 JSP 页面、5 个 XML 配置和 4 个依赖 jar 为主,另有少量 HTML、JavaScript 与静态页面文件,压缩包约 916KB,体积轻便,适合本地部署调试。目前已有 671 人学习或下载。读者可据此梳理 Java Web 分层结构、请求响应流程和 Session 存取逻辑,并作为课程实验、入门练习或小型项目脚手架参考。
1. 从一份 .rar 说起:这个购物车案例到底能干什么
很多人学 JSP 都卡在同一个坎上:语法背得挺熟,一到“购物车”这种要跨页面记住状态的东西就无从下手——点“加入”之后,下一页怎么知道刚才加的是哪件商品?刷新一下为什么数量翻倍了?这个【简单版】购物车案例正好切在这个痛点上。它不接数据库、不上框架,纯 JSP + JavaBean + Tomcat,靠 Session 把“商品浏览、加入购物车、改数量、删除、清空、算合计”这条链路完整走一遍,解压丢进 webapps 就能看到页面。适合刚啃完 Servlet 生命周期、想搞明白 HttpSession 到底怎么用的人,也适合需要一份能当场演示的骨架代码再去改实验的人。它最大的价值不是功能多,而是每一处状态变化你都能在代码里找到对应的一行。
2. 环境与目录:从解压到 Tomcat 里出现第一个页面
2.1 先把运行环境钉死:JDK、Tomcat 与端口
这类 JSP 工程对环境的容忍度其实不低,但有三个东西必须对得上,否则后面全是玄学问题。第一是 JDK 版本,JSP 页面里的脚本片段和 JavaBean 都是编译执行的,JDK 8 起步最稳,装 17 也能跑,但要确认 Tomcat 认的是同一个 JDK。第二是 Tomcat 的大版本,这一点尤其要留神:Tomcat 9 及以前用的是javax.servlet包名,Tomcat 10 之后换成了jakarta.servlet,老工程整包扔进 Tomcat 10,页面能打开,一点“加入购物车”就 404 或者抛ClassNotFoundException。第三是端口,8080 被占是新手最常见的第一个翻车点。
# 1. 确认 JDK 版本,输出应为 1.8 及以上 java -version javac -version # 2. 确认 Tomcat 版本,决定用 javax 还是 jakarta 包名 # 路径按自己解压位置改,这里假设解压在 /opt 下 /opt/apache-tomcat-9.0.x/bin/version.sh # 3. 检查 8080 是否被占,Linux / macOS 用这条 lsof -i :8080 # Windows 用这条 netstat -ano | findstr :8080这三条命令看起来像例行公事,实际上是排障的分水岭。java -version和javac -version版本不一致,说明机器上装了多个 JDK,JAVA_HOME指错了,编译出来的 class 版本比运行环境高,报的是UnsupportedClassVersionError,跟购物车逻辑八竿子打不着,但会让你怀疑代码写错了。端口检查同理,8080 被占时 Tomcat 启动日志里会有一行Address already in use,但控制台刷得快,很多人只看到最后一句“启动失败”,就开始翻 web.xml。
提示:如果只想改端口,动
conf/server.xml里的<Connector port="8080"那一行就行,改成 8081 之后记得访问地址也一起改,别在浏览器里继续刷 8080。
另外提一句CATALINA_HOME和CATALINA_BASE。单实例部署时两者一般指向同一个目录,设不设都能跑;如果你后面想在同一台机器上起两个 Tomcat 做对比实验,这两个变量就要分开设置,否则第二个实例会把第一个的日志和部署目录覆盖掉。简单版案例用不到这么复杂,但早点养成设JAVA_HOME的习惯,能省掉后面很多“为什么命令找不到”的追问。
2.2 拆开压缩包看到的目录结构
解压之后看到的应该是一个标准 Java Web 工程的样子,源码和页面分开,配置文件藏在WEB-INF下。这套结构不是随便摆的,WEB-INF目录对外不可直接访问,是放类文件和配置的地方,浏览器输 URL 是打不开的,这一点新手经常误以为文件丢了。
cart-demo/ ├── src/ # Java 源码,需要编译成 class │ └── com/example/cart/ │ ├── Goods.java # 商品实体 │ ├── GoodsRepo.java # 商品目录,简单版写死在内存里 │ ├── CartItem.java # 购物车行:商品 + 数量 │ └── Cart.java # 购物车本体,会话里存的就是它 ├── web/ # 部署到 Tomcat 的根目录 │ ├── WEB-INF/ │ │ ├── web.xml # 部署描述符,Servlet 3.0+ 可省 │ │ ├── classes/ # 编译后的 .class 放这里 │ │ └── lib/ # 第三方 jar,比如 jstl.jar │ ├── index.jsp # 入口,重定向到商品列表 │ ├── list.jsp # 商品列表页 │ ├── cart.jsp # 购物车页 │ ├── add.jsp # 加购处理(简单版直接用 JSP 干这活) │ ├── update.jsp # 改数量 / 删除 │ └── css/style.css └── README.md这张目录表要记住两个位置关系。一是classes下必须是包路径,com/example/cart/Cart.class这样一层层摆好,如果直接把 class 平铺在classes下,页面 import 得到的是ClassNotFoundException,而且报错信息里写的是“找不到类”,你会反复确认类名拼写,其实问题在目录层级。二是lib下的 jar 只对当前工程生效,不放进WEB-INF/lib而是丢到 Tomcat 的公共 lib,就会变成全局污染,换台机器又跑不起来。
用 Eclipse 或 IDEA 打开时,这些目录会被识别成普通目录而不是源码根目录,手动右键标记一下src为 Sources Root、web为 Web Resource Directory,IDE 的编译输出路径指向web/WEB-INF/classes,后面改代码才不用手工javac。如果完全不打算用 IDE,那就老老实实命令行编译,下面这段能直接用。
# 进入源码目录,把 class 直接编到 WEB-INF/classes 下 # -encoding UTF-8 不能省,源码里有中文商品名,不指定会乱码 cd cart-demo mkdir -p web/WEB-INF/classes javac -encoding UTF-8 \ -cp "/opt/apache-tomcat-9.0.x/lib/servlet-api.jar" \ -d web/WEB-INF/classes \ src/com/example/cart/*.java-cp那一行指的是 Servlet API 的位置,因为源码里如果引用了HttpServletRequest这类接口,编译时必须有对应的 jar。-d指定输出目录,javac 会自动按 package 建子目录,所以不用手工mkdir com/example/cart。这一步过了,WEB-INF/classes下就能看到.class文件,说明编译链路通了,剩下的都是运行期问题。
2.3 第一次跑起来:部署与访问路径
部署有两种常见做法,看你是想反复改还是只想先看一眼效果。只看效果就用目录方式,把整个web目录拷进 Tomcat 的webapps下并改名;要反复改就用conf/server.xml加一个<Context>指向本地目录,改完 JSP 不用重新拷。
# 方式一:整目录拷贝,最省事,但每次改完要重新拷 cp -r cart-demo/web /opt/apache-tomcat-9.0.x/webapps/cart # 启动 Tomcat /opt/apache-tomcat-9.0.x/bin/startup.sh # 方式二:映射本地目录,改完 JSP 刷新即生效 # 在 conf/server.xml 的 <Host> 节点里加一行: # <Context path="/cart" docBase="/绝对路径/cart-demo/web" reloadable="true" />两种方式访问地址都是http://localhost:8080/cart/list.jsp。注意list.jsp而不是list,因为简单版没配welcome-file也没写 Servlet 映射,路径必须带扩展名。这是第一个容易懵的地方:网上教程里写http://localhost:8080/工程名/,那是配置了欢迎页的情况,直接照抄会得到 404。
启动之后翻一下logs/catalina.out,正常的话能看到Server startup in xxx ms,以及 JSP 首次访问时的编译信息。JSP 是懒编译的,第一次打开页面会慢一两秒,这是 Jasper 在把它翻译成 Servlet 再编译,不是卡住了。如果页面出现 500,先看日志末尾的root cause,那里才是真正的问题,前面的Servlet.service() for servlet [jsp] threw exception只是包装。
3. 会话状态的落点:Session 里的购物车数据结构怎么设计
3.1 为什么简单版选 Session 而不是数据库
购物车这个需求,本质是“在一段时间内记住用户选了哪些商品”。可选方案有三类:存数据库、存 Cookie、存 Session。简单版选了 Session,理由是它最贴合这个场景的语义——购物车是临时状态,用户没登录,关掉浏览器就该清掉,存数据库要建表、要生成匿名用户标识、要处理过期清理,为了演示一个交互流程引入这么多东西,反而把主线遮住了。
Cookie 也能存,但容量只有 4KB 左右,商品一多就塞不下,而且内容在客户端可以被随意篡改,价格和数量全暴露。Session 的数据在服务端,客户端只拿到一个JSESSIONID的会话标识,改也改不出花来。代价是服务端要占内存,单机没问题,一旦上集群就得考虑会话共享,这是后面二次开发时要处理的事,简单版先不背这个包袱。
还有一层现实考虑:Session 的读写时机特别适合用来讲清楚“请求—响应”这条链路。加购、改数量、删除,每个动作都是一次独立的 HTTP 请求,服务端默认不记得上一次请求干了什么,全靠 Session 这个“服务端的抽屉”把购物车对象存进去、下次再取出来。把这条链路走通一遍,后面学 Spring Session、学 Redis 存会话,都是同一套思路换个存放介质。
3.2 三个 JavaBean 的职责划分
数据结构设计得好不好,直接决定了后面页面代码有多干净。这里用三个类:Goods是商品本身,CartItem是购物车里的一行,Cart是整个购物车。关键设计有两条:一是不把金额存成字段,用方法算;二是用 Map 而不是 List 存行,靠商品 id 天然去重。
package com.example.cart; import java.io.Serializable; // 商品:只读数据,会话里存的是它的引用,所以必须可序列化 public class Goods implements Serializable { private static final long serialVersionUID = 1L; private final int id; private final String name; private final double price; private final String imgUrl; public Goods(int id, String name, double price, String imgUrl) { this.id = id; this.name = name; this.price = price; this.imgUrl = imgUrl; } public int getId() { return id; } public String getName() { return name; } public double getPrice(){ return price; } public String getImgUrl(){ return imgUrl; } }商品类设计成不可变对象,构造完就不给 setter。这不是炫技,而是防止页面里某个脚本片段顺手改了价格,改完还没人发现。implements Serializable这行是必须的,因为 Tomcat 在重启或者内存吃紧的时候会把会话序列化到磁盘,对象不可序列化就会抛NotSerializableException,表现是“重启后购物车全空了”,这个坑后面第 5 章还会细说。
package com.example.cart; import java.io.Serializable; // 购物车行:商品 + 数量,小计不存字段,每次现算 public class CartItem implements Serializable { private static final long serialVersionUID = 1L; private Goods goods; private int count; public CartItem(Goods goods, int count) { this.goods = goods; this.count = count; } public Goods getGoods() { return goods; } public void setGoods(Goods g){ this.goods = g; } public int getCount() { return count; } public void setCount(int c) { this.count = c; } // 小计:页面上写 ${item.subtotal} 就会调到这个方法 public double getSubtotal() { return goods.getPrice() * count; } }getSubtotal()不落字段是有意为之。如果小计和数量各存一份,改数量的时候忘了同步小计,就会出现“数量是 3、小计还是 1 件的钱”这种前后不一致,而且很难一眼看出来。让所有派生值都从基础字段现算,就只有一个数据源,改不动。这个习惯放到真实项目里同样成立,能算出来的东西就别存。
package com.example.cart; import java.io.Serializable; import java.util.ArrayList; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; public class Cart implements Serializable { private static final long serialVersionUID = 1L; // key = 商品 id,value = 购物车行 // 用 LinkedHashMap 是为了保持加购顺序,展示时不会跳来跳去 private Map<Integer, CartItem> items = new LinkedHashMap<>(); public void add(Goods goods, int count) { if (goods == null || count <= 0) return; CartItem item = items.get(goods.getId()); if (item == null) { items.put(goods.getId(), new CartItem(goods, count)); } else { item.setCount(item.getCount() + count); // 已存在则累加 } } public void update(int goodsId, int count) { if (!items.containsKey(goodsId)) return; if (count <= 0) { items.remove(goodsId); return; } // 减到 0 就是删 if (count > 99) count = 99; // 上限保护 items.get(goodsId).setCount(count); } public void remove(int goodsId) { items.remove(goodsId); } public void clear() { items.clear(); } public List<CartItem> getItems() { return new ArrayList<>(items.values()); } public int getTotalCount() { int total = 0; for (CartItem i : items.values()) total += i.getCount(); return total; } public double getTotalPrice() { double total = 0; for (CartItem i : items.values()) total += i.getSubtotal(); return total; } }getItems()返回的是一个新 List,不是内部 Map 的视图。这一点在 JSP 里体现不出来,但如果页面在遍历过程中又调用了remove(),直接返回视图会抛ConcurrentModificationException。拷贝一份虽然多占一点点内存,但避免了并发修改的坑。getTotalCount()和getTotalPrice()名字起得直白,是因为它们会直接出现在 EL 表达式里,叫sum()之类的在页面上不好对应。
商品数据源用一个静态目录顶上,GoodsRepo里写死几条数据,改造时把它换成 DAO 就是接数据库的入口。
package com.example.cart; import java.util.Arrays; import java.util.List; // 商品目录:简单版写在内存里,二次开发时换成查库即可 public final class GoodsRepo { private static final List<Goods> ALL = Arrays.asList( new Goods(1001, "机械键盘", 299.00, "img/kb.jpg"), new Goods(1002, "无线鼠标", 89.00, "img/mouse.jpg"), new Goods(1003, "显示器支架", 159.00, "img/stand.jpg") ); public static List<Goods> findAll() { return ALL; } public static Goods findById(int id) { for (Goods g : ALL) { if (g.getId() == id) return g; } return null; // 找不到返回 null,调用方必须判空 } }findById返回 null 这个约定很关键,后面加购逻辑里每一次都要判空。真实项目里用 DAO 查不到记录也是返回 null 或者空 Optional,处理思路一样。
3.3 存进去、取出来:Session 属性的读写时机
Session 的用法就三条语句:getSession()拿会话、getAttribute("cart")取车、setAttribute("cart", cart)放回去。真正需要想清楚的是“什么时候创建”和“什么时候销毁”。
// 取车的统一写法,几乎每个处理页面开头都是这三行 Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); // 第一次访问时创建并存入 }第一次访问时 Session 里没有cart属性,getAttribute返回 null,此时新建一辆车放回去。之后所有请求拿到的都是同一个对象引用,因为 Session 在服务端是同一个会话,只要JSESSIONID不变,取出来的就是同一个Cart。这里有个容易误会的点:不需要每次操作完都setAttribute一遍,对象引用没变,改的是对象内部状态,Session 里存的自然就是改过的。只有新建对象时才需要 set。
会话什么时候没?三种情况:调session.invalidate()主动清、超过web.xml里配的session-timeout分钟数没动静、Tomcat 重启。简单版里“清空购物车”一般实现成cart.clear()而不是session.invalidate(),因为后者会把整个会话干掉,如果以后加了登录信息也一起没了,属于误伤。
<!-- web/WEB-INF/web.xml 里控制会话超时,单位是分钟 --> <session-config> <session-timeout>30</session-timeout> </session-config>30 分钟是常见默认值,意味着用户把页面挂那儿半小时不操作,回来点加购会发现车空了。演示的时候这个行为会让人以为代码有 bug,实际是超时正常生效。如果要做“记住我”那种效果,就得往 Cookie 里写持久化标识,那是另一个话题了。
4. 核心链路拆解:加购、改数量、删除、结算四段代码
4.1 商品列表页与加购入口
列表页干的事很简单:把GoodsRepo.findAll()的结果渲染成一行行卡片,每行带一个“加入购物车”的链接。简单版把加购写成 GET 链接,参数直接拼在 URL 上,这不是最佳实践,但足够直观,能让人一眼看清“点一下到底传了什么过去”。
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ page import="com.example.cart.*" %> <%@ page import="java.util.List" %> <!DOCTYPE html> <html> <head><meta charset="UTF-8"><title>商品列表</title></head> <body> <% List<Goods> goodsList = GoodsRepo.findAll(); // 从会话里取车的总件数,用于右上角角标;没车就显示 0 Cart cart = (Cart) session.getAttribute("cart"); int badge = (cart == null) ? 0 : cart.getTotalCount(); %> <p>购物车里有 <%= badge %> 件商品,<a href="cart.jsp">去看看</a></p> <% for (Goods g : goodsList) { %> <div class="card"> <h3><%= g.getName() %></h3> <p>¥<%= g.getPrice() %></p> <!-- 加购入口:id 决定加哪件,count 决定加几件 --> <a href="add.jsp?id=<%= g.getId() %>&count=1">加入购物车</a> </div> <% } %> </body> </html>页面上<%= %>是输出表达式,<% %>是脚本片段。这种写法现在看确实老派,但好处是所有逻辑都摊在明面上,不用去猜标签背后做了什么。badge那个变量每次请求都重新算,因为列表页也可能被刷新,不能缓存。
注意contentType="text/html;charset=UTF-8"和pageEncoding="UTF-8"两个都要写。前者管响应头,告诉浏览器用什么编码解析;后者管 JSP 源文件本身的编码,决定 Jasper 读文件时怎么解码。只写一个,中文商品名照样乱码。
4.2 加购逻辑:同商品合并与数量累加
add.jsp是整个案例里最关键的一段,它包含了参数接收、容错、会话读写、重定向四个动作。把这段看懂了,剩下的改数量、删除都是同一个套路。
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ page import="com.example.cart.*" %> <% int id = 0; int count = 1; // 1. 接收参数,任何一步失败都退回默认值,不让页面 500 try { id = Integer.parseInt(request.getParameter("id")); } catch (NumberFormatException e) { id = 0; } try { String c = request.getParameter("count"); if (c != null) count = Integer.parseInt(c); } catch (NumberFormatException e) { count = 1; } // 2. 查商品,查不到直接回列表,不做无意义操作 Goods goods = GoodsRepo.findById(id); if (goods != null) { // 3. 从 Session 取车,第一次访问时新建 Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); } cart.add(goods, count); } // 4. 重定向,而不是转发:避免刷新页面重复加购 response.sendRedirect("list.jsp"); %>第 1 步的try-catch不是可有可无的装饰。request.getParameter("id")返回的是字符串,如果用户手工把 URL 改成?id=abc,直接parseInt就抛异常,页面显示 500。简单的容错能让这个案例在被乱改参数时不至于崩掉。
第 4 步的sendRedirect是重点。如果这里用forward,浏览器地址栏还停在add.jsp,用户按 F5 会再发一次加购请求,数量直接翻倍——这是这类案例最经典的 bug,很多人第一次跑就遇到,还以为是自己代码写错了。重定向让浏览器重新发一个 GET 到list.jsp,刷新也只是刷新列表,不会重复提交。这个模式叫 PRG(Post-Redirect-Get),改成 POST 表单提交之后同样适用。
数量合并的逻辑在Cart.add()里:先按 id 查 Map 里有没有,有就setCount(旧 + 新),没有才put新行。所以连着点五次“加入”,购物车里是一行数量 5,不是五行数量 1。这个行为符合直觉,也是面试里经常被问的一个点。
4.3 修改数量与删除:参数校验和空车处理
改数量和删除可以合成一个页面处理,靠参数区分;也可以拆成两个。简单版一般是update.jsp管数量、remove.jsp管删除,逻辑都是“取车 → 判断车是否存在 → 调用方法 → 重定向回购物车页”。
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ page import="com.example.cart.*" %> <% int id = 0, count = 1; try { id = Integer.parseInt(request.getParameter("id")); } catch (Exception e) {} try { String c = request.getParameter("count"); if (c != null) count = Integer.parseInt(c); } catch (Exception e) {} Cart cart = (Cart) session.getAttribute("cart"); if (cart != null) { // 这个方法是幂等的:id 不在车里就什么都不做 // count <= 0 时内部转成删除,所以减号按钮可以一直点 cart.update(id, count); } response.sendRedirect("cart.jsp"); %>cart.update()内部做了三件事值得单独说。第一,containsKey判断商品在不在车里,不在就直接返回,防止有人伪造 URL 往里塞不存在的 id。第二,count <= 0转成删除,这样购物车页面的减号按钮不需要判断“这是不是最后一件”,一直点下去自然会消失,交互上更顺。第三,超过 99 截断,防止有人把 URL 改成count=999999,页面上出现一个天文数字。
删除页面更简单,只有 id 一个参数:
<%@ page contentType="text/html;charset=UTF-8" pageEncoding="UTF-8" %> <%@ page import="com.example.cart.*" %> <% int id = 0; try { id = Integer.parseInt(request.getParameter("id")); } catch (Exception e) {} Cart cart = (Cart) session.getAttribute("cart"); if (cart != null) cart.remove(id); response.sendRedirect("cart.jsp"); %>这两个页面都有一句if (cart != null)。看起来多余,实际很必要:用户直接访问update.jsp时 Session 里可能还没有车,不判空就是 NullPointerException。这类空指针在演示时不一定触发,但换成别人来点就会出问题,属于“自己测永远过、交给别人必翻车”的典型。
4.4 结算页与合计计算
购物车页是整个案例的门面,要用 JSTL 把 List 渲染成表格,并且在车空的时候给出提示,而不是显示一个空表格。
<%@ 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" %> <!DOCTYPE html> <html> <head><meta charset="UTF-8"><title>购物车</title></head> <body> <h2>我的购物车</h2> <c:choose> <%-- 车不存在或行数为 0 都走这个分支 --%> <c:when test="${empty sessionScope.cart.items}"> <p>购物车还是空的,<a href="list.jsp">去逛逛</a></p> </c:when> <c:otherwise> <table border="1" cellpadding="6"> <tr><th>商品</th><th>单价</th><th>数量</th><th>小计</th><th>操作</th></tr> <c:forEach var="item" items="${sessionScope.cart.items}"> <tr> <td>${item.goods.name}</td> <td><fmt:formatNumber value="${item.goods.price}" pattern="0.00"/></td> <td> <%-- 减号:数量减到 0 时 update 内部会转成删除 --%> <a href="update.jsp?id=${item.goods.id}&count=${item.count - 1}">-</a> ${item.count} <a href="update.jsp?id=${item.goods.id}&count=${item.count + 1}">+</a> </td> <td><fmt:formatNumber value="${item.subtotal}" pattern="0.00"/></td> <td><a href="remove.jsp?id=${item.goods.id}">删除</a></td> </tr> </c:forEach> </table> <p> 共 ${sessionScope.cart.totalCount} 件, 合计:<fmt:formatNumber value="${sessionScope.cart.totalPrice}" pattern="0.00"/> 元 </p> <p> <a href="clear.jsp">清空购物车</a> | <a href="list.jsp">继续购物</a> </p> </c:otherwise> </c:choose> </body> </html>${sessionScope.cart.items}会自动调用getItems(),${item.subtotal}调用getSubtotal(),EL 的规则是把属性名首字母大写加get前缀去找方法,找不到再试is前缀。所以 JavaBean 的 getter 命名必须规范,写成getsubtotal()就取不到值,页面上显示空白,也不报错,排查起来很费劲。
<fmt:formatNumber pattern="0.00"/>是为了解决double直接输出的问题。299.0 * 3在页面上可能显示成897.0,也可能是897.0000000001这种,不格式化的话小数点位数随缘。金额用double本身就有精度隐患,简单版先靠格式化遮住,正经项目应该换BigDecimal。
清空页面就一行cart.clear()加重定向,没什么好说的。所有处理页面写完后,整个链路就闭合了:列表 → 加购 → 购物车 → 改数量/删除 → 结算/清空。每一步都是独立的 HTTP 请求,靠 Session 串起来,这就是这个案例最想让人看懂的东西。
5. 避坑与排查:跑这个购物车最容易翻车的五处
5.1 现象:中文全变问号或乱码
现象很直观,商品名、按钮文字显示成????或者一堆方块。原因是编码在三处各管一段:pageEncoding管 JSP 源文件读取、contentType管响应头、request.setCharacterEncoding管请求体。只改其中一处,另外两处照旧。解决方式是把三处对齐到 UTF-8,GET 请求还要在server.xml的 Connector 上加URIEncoding="UTF-8",否则 URL 里的中文参数在 Tomcat 8 之后虽然默认按 UTF-8 解,但老配置里写死了 ISO-8859-1 就会出问题。
<!-- conf/server.xml:让 GET 请求的 URL 参数按 UTF-8 解码 --> <Connector port="8080" protocol="HTTP/1.1" connectionTimeout="20000" URIEncoding="UTF-8" redirectPort="8443" />5.2 现象:部署到高版本 Tomcat 后所有 Servlet 404
页面能打开,一点加购就 404,日志里是java.lang.ClassNotFoundException: javax.servlet.http.HttpServlet。原因是工程编译时依赖的是javax.servlet包,而 Tomcat 10 之后统一换成了jakarta.servlet,包名对不上,容器根本加载不了这个类。解决要么把工程降到 Tomcat 9 跑,要么把源码里所有javax.servlet改成jakarta.servlet并换用新的 API jar 重新编译。这一步没有捷径,改完还要清一遍WEB-INF/classes里的旧 class,不然新旧混在一起更难查。
5.3 现象:重启 Tomcat 后购物车清空,日志里有 NotSerializableException
用户加了几件商品,服务重启一次回来车空了。原因是 Session 的持久化机制会把会话对象序列化到work目录,Cart、CartItem、Goods任何一个没实现Serializable,序列化就失败,会话被丢弃。解决办法是三个类都加上implements Serializable和显式serialVersionUID。serialVersionUID别省,不然以后给类加个字段,反序列化时版本号对不上,老会话照样失效。
5.4 现象:手工改 URL 参数,页面直接 500
把add.jsp?id=1改成id=abc,或者count=-5,页面白屏报 500。原因是Integer.parseInt遇到非数字直接抛NumberFormatException,代码里没有兜底。解决是在所有接收参数的地方加try-catch并给默认值,同时对数量做范围校验。这类问题在自己点的时候不会出现,一交给别人演示必踩,属于典型的“演示翻车点”。
5.5 现象:<c:forEach>不生效,标签原样出现在页面上
页面源代码里能看到<c:forEach>这串字符,说明标签压根没被解析。两个原因:一是缺jstl.jar,WEB-INF/lib下没有这个包;二是web.xml的版本太老,比如还写着 2.3,容器默认不解析 EL。解决是先确认 lib 下有 jstl 和 standard 两个 jar,再把web.xml的web-app版本升到 3.0 以上,或者干脆删掉web.xml(Servlet 3.0 之后可以注解配置,JSP 也能跑)。
6. 二次开发:把它改成能接数据库、能前后端分离的样子
这份代码拿来练手够了,真要往项目上靠,我一般按三步改。第一步把GoodsRepo抽成接口,加一个 JDBC 实现,findAll()和findById()改成查表,其余代码一行不动,因为调用方只依赖静态方法签名。第二步把.jsp里的脚本片段搬到 Servlet,JSP 只负责渲染,加购流程从 GET 链接改成 POST 表单,顺便把 PRG 模式补完整。第三步才是拆前后端:加一个/api/cart/*的 JSON 接口,页面用 fetch 调,购物车角标局部刷新,不再整页跳转。
| 改造点 | 简单版做法 | 落地做法 |
|---|---|---|
| 商品数据 | 内存 List 写死 | DAO 查库,加缓存 |
| 状态存放 | 单机 Session | Session + Redis,支持多实例 |
| 金额计算 | double 加格式化 | BigDecimal,单位统一用分 |
| 加购入口 | GET 链接 | POST 表单 + 防重复提交 |
| 页面渲染 | JSP 脚本片段 | Servlet 出 JSON,前端渲染 |
接口那段大概长这样,@WebServlet注解省掉 web.xml 配置,返回体只带角标数量,前端拿到就能更新:
@WebServlet("/api/cart/add") public class AddToCartServlet extends HttpServlet { @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { req.setCharacterEncoding("UTF-8"); resp.setContentType("application/json;charset=UTF-8"); int goodsId = Integer.parseInt(req.getParameter("goodsId")); int count = Integer.parseInt(req.getParameter("count")); // 会话里没有车就建一辆,和 JSP 版本逻辑一致 Cart cart = (Cart) req.getSession().getAttribute("cart"); if (cart == null) { cart = new Cart(); req.getSession().setAttribute("cart", cart); } cart.add(GoodsRepo.findById(goodsId), count); // 只回总件数,前端据此刷新右上角角标 resp.getWriter().write("{\"totalCount\":" + cart.getTotalCount() + "}"); } }改完记得验证:会话超时后接口是否还返回正常结构、商品 id 不存在时是否返回 404 而不是 500、并发点两次加购数量是不是只加一次。这三条我每次都要跑一遍。吃过一次亏之后,我改任何涉及会话状态的代码,都会先手工把JSESSIONID清掉再走一遍全流程,因为“第一次访问时车不存在”这个分支,正常点按钮是永远测不到的。希望帮到你。
本文还有配套的精品资源,点击获取