
简介这是一份基于 JavaWeb 与 Servlet 的购物车系统项目面向初学 Servlet/Tomcat 的开发者演示了从商品展示页点击“添加到购物车”经 Servlet 记录选购信息与数量再跳转到另一个 Servlet 用表格呈现购物结果的完整流程。压缩包共 25 个文件约 491KB包含 3 个 Java 源文件与对应 class 编译产物、1 个商品展示 html、8 张商品图片以及 xml、project、prefs 等 Eclipse 工程配置目录结构清晰便于直接导入查看。已有 638 人学习浏览。参考该项目可以快速理解 HttpServlet 请求处理、超链接跳转传参、Session/请求对象保存数据、表格动态输出等核心写法也能借鉴页面与 Servlet 的分工方式搭建自己的购物车 Demo。1. 一个购物车系统为什么偏偏要用 javaWeb servlet 再写一遍很多人一上来就用 Spring Boot 写购物车但 javaWeb servlet 这套老技术栈仍是理解 Web 应用底层的捷径。购物车是典型的有状态业务加购、改数量、删除、结算多个请求要共享同一份数据HTTP 本身却是无状态的。用 servlet 手写一遍你才看得清 Session、JSP、JDBC 怎么协作。这个项目的价值不在能跑而在做选择的过程数据放 Session 还是 Cookie价格用 double 还是 BigDecimal并发点加购会不会丢数量。跟着走完一遍对 javaWeb 完整链路的体感比看十遍笔记都深。下面这套方案按能跑通、结构简单、方便扩展来写商品表和订单表用 MySQL购物车本体存在 HttpSession 里最后给出向 Redis 迁移的过渡姿势。2. 先把 Servlet 的角色定死请求入口、会话载体与购物车容器2.1 购物车不是一个对象而是三个 Servlet 的分工购物车系统的请求链路其实很短用户打开商品列表页点某个商品上的加入购物车跳转到购物车页改数量或删除最后模拟下单。按这条链路我一般把系统拆成三个 Servlet职责边界非常清楚Servlet映射 URL职责GoodsListServlet/goods查询商品表渲染商品列表页CartServlet/cart用 action 参数分发 add/update/remove/clear/show 五个动作OrderServlet/order读取购物车生成订单记录CartServlet 是整条链路的中枢。这里用 action 参数做分发而不是直接建五个 Servlet 类是在简单这个约束下最省事的方案等动作多到方法超过五个再拆出去也不迟。商品列表和购物车分两个 Servlet 的原因很简单一个面向商品库一个面向用户会话改商品查询不会影响购物车逻辑。做 servlet 开发生命周期是绕不开的基础认知容器在第一次请求到达时加载并实例化 Servletinit() 只执行一次之后的每次请求都由容器分配一个新线程调用 service()再按请求方法分发到 doGet 或 doPost。这意味着 Servlet 实例是被多线程共享的。如果你把购物车 Map 直接放在 Servlet 的成员变量里两个用户同时加购就会互相踩踏这就是为什么购物车数据必须放在 Session 里而不是 Servlet 字段里。理解这个生命周期后面的并发问题基本都能自己推断出来。2.2 购物车放 Session 还是 Cookie选型理由与最小代码HttpSession 是购物车最顺手的容器。理由有三第一Session 本质是服务端内存里的一个 Map以 JSESSIONID 做标识key 是属性名value 是任意可序列化对象正好放下购物车第二数据在服务端用户只能拿到一个不透明的会话 ID改不了内部的商品和价格第三API 足够直白setAttribute 存getAttribute 取。相比之下Cookie 虽然也能存但要每次请求在浏览器和服务端之间搬运而且用户可以打开开发者工具直接改 Cookie 值把 19.9 改成 0.1购物车就变成免费超市了。所以购物车这类数据我一般只用 Session 装Cookie 只留给记住登录名这种非敏感场景。先看最小读取逻辑HttpSession session request.getSession(); MapInteger, Integer cart (MapInteger, Integer) session.getAttribute(cart); if (cart null) { cart new HashMap(); session.setAttribute(cart, cart); }这段逻辑里request.getSession() 是有则取无则建不需要传 true。从 Session 里拿出来的属性要强转因为 getAttribute 返回的是 Object。判空后新建 HashMap 并立即放回 Session这一步不能省否则后面加购时 cart 为 null直接 NPE。Map 的 key 存商品 idvalue 存数量完整购物车就是这张 Map简单直观。2.3 IDEA 运行 javaWeb 项目配置Tomcat 版本、部署路径与最小 Servlet环境配置是最容易劝退新手的一关。我常用的组合是 IDEA Tomcat 9 Java 8原因很现实Tomcat 9 用的是 javax.servlet 命名空间网上绝大部分教程和你手里的 servlet 笔记都是这套写法拿 Tomcat 10 跑会直接编译报错因为 Tomcat 10 把包名改成了 jakarta.servlet。如果你非要上 Tomcat 10所有 import 要跟着改名等于给入门阶段加难度。配置步骤按这个顺序走IDEA 新建普通 Java 项目右键模块 → Add Framework Support → 勾选 Web Application生成 web/WEB-INF 和 web.xml。下载 Tomcat 9.0 解压Run → Edit Configurations → 左上角 → Tomcat Server → LocalApplication server 指向解压目录。Deployment 标签页点 → Artifact → 选 war explodedApplication context 改成 /cart-demo。给模块加依赖Project Structure → Libraries → → 选 Tomcat 目录下 lib/servlet-api.jar。启动 Tomcat浏览器访问 http://localhost:8080/cart-demo/cart 看结果。用 vscode 写 servlet 的人也常问环境怎么配。思路其实一样装 Tomcat 插件把编译好的 class 手动放进 WEB-INF/classes整个项目丢进 webapps。只是没有 IDEA 的 Artifact 自动同步改一次代码要重启一次容器调试效率低不少。入门阶段我建议先用 IDEA 跑通再谈编辑器自由。写一个最小 Servlet 验证环境WebServlet(/cart) public class CartServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); resp.getWriter().write(h2cart servlet is running/h2); } }WebServlet 是 Servlet 3.0 之后的注解式映射写了它就不用在 web.xml 里再配 servlet-mapping。两个 setXxx 是编码和响应类型先记着写后面第 5 章专门说乱码问题。访问 /cart-demo/cart 看到那句英文说明整个链路已经通了。提示如果访问 404先看 IDEA 启动日志里打印的 Actual context path 是什么再拼上注解里的 /cart 才是完整访问路径。3. 从 MySQL 表设计到商品查询购物车的地基不牢后面全是坑3.1 三张表建好购物车才谈得上数据落库购物车本身存在 Session 里但商品、订单、订单明细必须落库。最简单的 mysql 方案是三张表goods 商品表、orders 订单表、order_item 订单明细表。购物车表在简单阶段不建Session 就是购物车表等做到 Redis 迁移那一步再补一张存储结构也不迟。CREATE TABLE goods ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, image_url VARCHAR(255) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT, total_amount DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0 COMMENT 0待支付 1已支付, created_at DATETIME NOT NULL ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, goods_id INT NOT NULL, goods_name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, quantity INT NOT NULL, KEY idx_order (order_id), CONSTRAINT fk_order FOREIGN KEY (order_id) REFERENCES orders(id), CONSTRAINT fk_goods FOREIGN KEY (goods_id) REFERENCES goods(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; INSERT INTO goods (name, price, stock) VALUES (机械键盘, 399.00, 50), (USB鼠标, 79.90, 120), (27寸显示器, 1299.00, 30);两个设计点要特别说明。第一金额一律用 DECIMAL(10,2)不是 double这是数据库层面的精度底线第 5 章会看到 double 累加出来的恐怖数字。第二订单明细表冗余了 goods_name 和 price这是刻意的下单那一刻的商品名称和价格是快照商品表以后改名改价都不应该影响历史订单。外键在简单项目里建议加上能帮你理解数据一致性生产环境嫌锁表可以不建但你得知道自己在取舍什么。3.2 用 JDBC 查商品PreparedStatement、try-with-resources 与连接参数商品查询用原生 JDBC 就足够不需要 ORM。最常犯的错是把 Connection 手动关在 finally 里代码又长又容易漏。Java 7 之后的 try-with-resources 会自动按逆序关闭ResultSet 先关、Connection 最后关写起来干净得多。public ListGoods listAll() { String sql SELECT id, name, price, stock FROM goods; ListGoods list new ArrayList(); String url jdbc:mysql://localhost:3306/cart_db ?useSSLfalsecharacterEncodingUTF-8serverTimezoneAsia/Shanghai; try (Connection conn DriverManager.getConnection(url, root, 123456); PreparedStatement ps conn.prepareStatement(sql); ResultSet rs ps.executeQuery()) { while (rs.next()) { Goods g new Goods(); g.setId(rs.getInt(id)); g.setName(rs.getString(name)); g.setPrice(rs.getBigDecimal(price)); g.setStock(rs.getInt(stock)); list.add(g); } } catch (SQLException e) { e.printStackTrace(); } return list; }URL 里三个参数缺一不可useSSLfalse 去掉 MySQL 8 的 SSL 握手告警characterEncodingUTF-8 保证中文写入读取一致serverTimezoneAsia/Shanghai 解决 MySQL 8 默认时区导致日期差 8 小时的问题。MySQL Connector/J 8.0 之后不再需要 Class.forName(com.mysql.cj.jdbc.Driver) 手动加载驱动驱动类会自动注册。simple 项目里 e.printStackTrace() 够用但正式环境还是换成日志框架不然线上定位问题只能靠翻控制台。3.3 商品列表到加购请求表单传 id价格必须后端重查商品列表页用 JSP 渲染每个商品后面跟一个加购表单。这里有一个新手经常做错的动作把价格也放在 hidden 字段里传给后端想着下单时直接用。这是把价格篡改的入口直接递给用户开发者工具一改0.1 元下单不是玩笑。c:forEach vargoods items${goodsList} div span${goods.name}/span span${goods.price}/span form actioncart methodpost input typehidden nameaction valueadd input typehidden namegoodsId value${goods.id} input typenumber namequantity value1 min1 max${goods.stock} button typesubmit加入购物车/button /form /div /c:forEach表单里只传 action、goodsId、quantity 三个字段。action 用来让 CartServlet 区分操作类型goodsId 是加购目标quantity 由用户输入。价格只做展示写在 span 里不参与表单提交。后端收到请求后必须用 goodsId 重新查一次商品表拿到真实价格再算金额。这就是前端传参只是意图后端查库才是事实的典型场景购物车和订单系统都该守着这条线。4. 加购、改数量、删除与清空购物车四个动作的 Servlet 实现4.1 加购先查 Session再决定新建购物车还是合并数量加购是购物车最核心的动作逻辑只有三步从 Session 拿到购物车 Map没有就新建拿商品 id 和数量把数量合并进 Map放回 Session。合并这一步我推荐 Map.merge一句代码搞定有则累加无则新增省掉 containsKey 判断。WebServlet(/cart) public class CartServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String action req.getParameter(action); if (add.equals(action)) { addToCart(req); } else if (update.equals(action)) { updateQuantity(req); } else if (remove.equals(action)) { removeItem(req); } else if (clear.equals(action)) { clearCart(req); } resp.sendRedirect(req.getContextPath() /cart?actionshow); } private void addToCart(HttpServletRequest req) { int goodsId Integer.parseInt(req.getParameter(goodsId)); int quantity Integer.parseInt(req.getParameter(quantity)); HttpSession session req.getSession(); MapInteger, Integer cart (MapInteger, Integer) session.getAttribute(cart); if (cart null) { cart new HashMap(); } cart.merge(goodsId, quantity, Integer::sum); session.setAttribute(cart, cart); } }doPost 末尾的 resp.sendRedirect 是 PRG 模式Post 提交后重定向到 GET 请求刷新页面时不会重复提交上一次的购买动作。很多新手少了这一步用户加购后按 F5购物车数量翻倍这就是刷新翻车的根源。Integer::sum 是合并函数的方法引用等价于 (old, add) - old add语义是同商品数量相加。注意 Integer.parseInt 在参数缺省时会抛 NumberFormatException第 5 章会讲怎么校验这里先记着。4.2 改数量前端传字符串后端按整数严校验购物车页面上每个商品都带一个数量输入框用户改完点更新。前端 number 输入框的 min、max 只能防普通用户挡不住手动构造请求的调用方所以后端必须重新校验一遍。校验的核心是把字符串转成合法整数非法则忽略越界则收敛。private void updateQuantity(HttpServletRequest req) { int goodsId Integer.parseInt(req.getParameter(goodsId)); String qtyStr req.getParameter(quantity); if (qtyStr null || qtyStr.isEmpty()) { return; } int quantity; try { quantity Integer.parseInt(qtyStr); } catch (NumberFormatException e) { return; } if (quantity 1) { quantity 1; } if (quantity 999) { quantity 999; } HttpSession session req.getSession(); MapInteger, Integer cart (MapInteger, Integer) session.getAttribute(cart); if (cart ! null cart.containsKey(goodsId)) { cart.put(goodsId, quantity); } }先判 null 和空串再 try-catch 捕获数字格式异常最后做上下限收敛。小于 1 的收敛到 1大于 999 的收敛到 999这是防刷参数的上限。我的处理是静默忽略非法值并直接 return不中断用户操作如果要更严谨可以在 return 前记录一条 warn 日志。最后一步必须同时满足 cart 不为空且包含该商品才 put否则会出现购物车里没有这个商品却更新成功的诡异状态。这里校验代码虽然繁琐但每一个分支都有实际触发的场景不是防御性编程的空架子。4.3 删除与清空remove 单个商品与移除整个购物车属性边界不一样删除和清空很容易写成同一个逻辑但二者边界完全不同。删除单个商品是 Map.remove(goodsId)购物车属性还在其他商品不受影响清空是 session.removeAttribute(cart)整个购物车属性被移除下次加购时 getAttribute 返回 null再走新建流程。private void removeItem(HttpServletRequest req) { int goodsId Integer.parseInt(req.getParameter(goodsId)); HttpSession session req.getSession(); MapInteger, Integer cart (MapInteger, Integer) session.getAttribute(cart); if (cart ! null) { cart.remove(goodsId); } } private void clearCart(HttpServletRequest req) { HttpSession session req.getSession(); session.removeAttribute(cart); }clearCart 有个容易踩的坑千万不要为了清空购物车而调用 session.invalidate()。invalidate 会销毁整个会话用户登录态、其它临时属性全部清零等于把登录状态和购物车一锅端。清空购物车只是移除 cart 这一个属性其他属性原封不动这是 removeAttribute 和 invalidate 的本质区别。删除单个商品后如果购物车 Map 变成空 Map页面要显示购物车为空而不是一个只有表头的空表格这个判断写在 JSP 里即可。4.4 购物车页面JSP 遍历 Session 里的 Map金额展示与计算分离购物车页需要把 Map 里的 key 还原成商品信息。Session 里只有 goodsId 和数量商品名、价格、小计必须在显示前查一次数据库组成一个 goodsMap 放进 request 作用域JSP 里靠下标访问。遍历用 JSTL 的 c:forEach而不是 JSP 里的 % % scriptlet否则页面里全是 Java 片段读起来像一团乱麻。c:forEach varitem items${cart} tr td${goodsMap[item.key].name}/td td${goodsMap[item.key].price}/td td form actioncart methodpost styledisplay:inline; input typehidden nameaction valueupdate input typehidden namegoodsId value${item.key} input typenumber namequantity value${item.value} min1 max999 button typesubmit更新/button /form /td td${goodsMap[item.key].price * item.value}/td tda hrefcart?actionremovegoodsId${item.key}删除/a/td /tr /c:forEach核心是 ${cart} 和 ${goodsMap} 两个作用域对象cart 取自 SessiongoodsMap 取自 request。goodsMap[item.key] 等价于按商品 id 从 Map 中取商品对象是 EL 对 Map 的标准访问方式。价格乘以数量这一行EL 里 BigDecimal 和 Integer 相乘结果是 BigDecimal展示不会丢精度。但这只是展示层的小计下单结算的总金额必须在 Java 后端重新用 BigDecimal 累加绝不能把页面上算好的值直接提交背后原因还是那条任何来自前端的数据都不可信。5. 购物车系统的常见问题与避坑排查5 个翻车现场5.1 商品加进购物车刷新或重启后没了现象购物车加好后重启 Tomcat或者过一会儿再刷新购物车是空的。原因Tomcat 默认 Session 过期时间是 30 分钟到点自动销毁重启容器直接清空内存里的所有 Session如果用户关掉浏览器JSESSIONID 这个 Cookie 也就没了服务端有 Session 也对不上号。解决分清临时购物车和持久化购物车。简单阶段接受 Session 丢失是特性不是 bug想延长会话在 web.xml 里加配置session-config session-timeout1440/session-timeout /session-config单位是分钟1440 即一天。超时时间调长会让服务端内存里堆积大量无用 Session所以只适合开发环境。真正的跨会话持久化要么把购物车落库要么用第 6 章的 Redis 方案别指望靠调超时时间扛线上。5.2 商品名变成问号和乱码现象商品名机械键盘显示成????往 MySQL 写入时报 Incorrect string value。原因一次请求要经过浏览器 → Tomcat → JDBC → MySQL 四层任何一层的编码不是 UTF-8中文就断链。最常见的是把 req.setCharacterEncoding(UTF-8) 写在读取参数之后或者干脆没写POST 请求的中文参数到后端已经乱掉。解决四个位置逐一检查。POST 请求在 doPost 第一行就调 req.setCharacterEncoding(UTF-8)页面响应用 resp.setContentType(text/html;charsetUTF-8)JDBC URL 带 characterEncodingUTF-8数据库表用 utf8mb4。GET 请求的中文参数要走 Tomcat 的 server.xml给 Connector 加 URIEncodingUTF-8。这个坑之所以玄学是因为经常四个位置修好三个最后一个漏了症状还和前三个没修时一样。5.3 结算金额算出 59.699999999999996现象3 件 19.90 元的商品总价算出来是 59.699999999999996。原因double 在二进制里无法精确表示 0.1每一次乘法加法都在累积误差。用 double 做金额翻车只是时间问题。解决金额计算的唯一正解是 BigDecimal。数据库用 DECIMAL(10,2)Java 端用 BigDecimal构造参数必须传字符串而不能传 doubleBigDecimal total new BigDecimal(0.00); for (CartItem item : items) { BigDecimal price new BigDecimal(item.getPrice().toString()); BigDecimal subtotal price.multiply(BigDecimal.valueOf(item.getQuantity())); total total.add(subtotal); }两个细节new BigDecimal(19.9) 这种方式会得到 19.900000000...所以要先用 toString 把 double 转成字符串再构造数量是整数用 BigDecimal.valueOf 转。展示层 EL 的乘法只负责页面显示后端结算永远走这段 Java 代码这条规则从购物车延伸到订单、支付全程适用。5.4 Tomcat 能启动但访问项目页面 404现象Tomcat 启动成功浏览器打开 http://localhost:8080/ 能看到 Tomcat 首页访问自己的页面却 404。原因部署路径和访问路径没对上。IDEA 里 Application context 如果配的是 /cart-demo实际访问必须带这个前缀如果忘了部署 Artifact请求根本没进你的项目如果用 web.xml 配 servlet-mappingURL 写错也会 404。解决先看启动日志里的 Actual context path确认项目实际挂在哪个路径下。再检查 Run Configuration → DeploymentArtifact 选 war explodedApplication context 写 /cart-demo。访问路径 Application context 拼 Servlet 映射。用 WebServlet 注解可以少查一层 web.xml 的映射配置出问题维度更少。顺序排查这三层404 基本十分钟内解决。5.5 快速点两次加购数量只加了 1现象用户快速双击加入购物车购物车里同一个商品数量应该是 2实际却是 1。原因容器为每个请求分配独立线程Servlet 是被共享的Session 里的购物车 Map 也是共享对象。两个请求同时执行 cart.merge后写的覆盖先写的等于丢了一次加购。解决在并发量不高的场景把 HashMap 换成 ConcurrentHashMap 就能扛住单实例内的并发写入cart new ConcurrentHashMap();再配合 merge 的原子性同商品累加不会互相覆盖。但要注意这只是在单实例内有效多实例部署时 Session 本身不共享用户两次请求打到不同节点购物车照样分裂。到这一步要做的不是继续锁 Session而是把购物车状态外置落到 Redis 这类共享存储里这就是下一章的内容。6. 给购物车加一剂后悔药把 Session 里的购物车迁到 RedisSession 购物车有个绕不开的痛点容器重启就全没了用户隔天回来购物车空空如也。想让购物车记住用户一周最平滑的过渡是引入 Redis用 Jedis 做客户端把会话里的 Map 整体搬到 Redis 的 hash 结构里副作用是把状态从内存挪到了外部存储Session 过期不再等于购物车消失。public class CartStore { private static Jedis jedis new Jedis(localhost, 6379); public static void save(String sessionId, MapInteger, Integer cart) { MapString, String data new HashMap(); cart.forEach((k, v) - data.put(String.valueOf(k), String.valueOf(v))); jedis.hset(cart: sessionId, data); jedis.expire(cart: sessionId, 7 * 24 * 3600); } public static MapInteger, Integer load(String sessionId) { MapString, String data jedis.hgetAll(cart: sessionId); MapInteger, Integer cart new HashMap(); data.forEach((k, v) - cart.put(Integer.parseInt(k), Integer.parseInt(v))); return cart; } }每次加购后调用 CartStore.save(request.getSession().getId(), cart)页面展示前调用 load 读回来。key 用 sessionId 区分不同用户的购物车expire 设置 7 天过期等于给了用户七天后悔药。验证方法很简单重启 Tomcat用同一个浏览器再访问购物车页数据还在换一个浏览器访问是空的——符合预期因为 sessionId 变了。我最早做购物车时直接把整个 Map 序列化成 JSON 塞进字符串读回来还要处理反序列化异常后来发现 Redis 的 hash 结构天然匹配商品 id 到数量的映射才反应过来选错存储结构比写错代码更致命。这个习惯后来被我带到所有会话状态设计里先想清楚数据形态适不适合这个存储再动手写代码。希望帮到你。本文还有配套的精品资源点击获取