资讯动态

JSP+Servlet 购物车:Session 会话管理与 Tomcat 部署

发布时间:2026/10/28 4:45:25 来源:尧图企业网站定制
简介这份「购物车案例【简单版】」资源面向正在学习 Java Web 的初学者与课程实训学生用一个可运行的迷你电商购物车项目串起从页面展示到数据持久化的完整链路帮助读者理解 JSP 与 Servlet 如何协作、Session 如何保存用户购物状态。压缩包共 62 个文件约 916KB包含 13 个 java 源文件与 26 个编译后的 class另有 6 个 jsp 页面、5 个 xml 配置、4 个 jar 依赖及少量 js、html 静态资源覆盖实体类、DAO、Service、Servlet 与工具类等典型分层目录结构便于按模块阅读源码。目前已有 670 人学习下载。读者可从中获得商品列表与购物车页面的实现范例、添加与删除商品的请求处理流程、基于 Session 的购物车数据维护思路以及 JSP、Servlet 与 Tomcat 协同工作的整合示例适合作为手写练手项目或课程设计的起步模板并据此扩展数据库持久化与安全防护等知识点。1. 一个 rar 包里塞着的 JSP 购物车原型为什么老项目里它仍然管用打开这个「购物车案例【简单版】.rar」里面没有微服务、没有前后端分离就是一摞 jsp 页面加几个 servlet、一个 web.xml再配一套最朴素的 html 静态页。很多人第一反应是「这玩意儿过时了」。但真到要讲清楚 session 会话、要在一个 40 分钟的实验课里跑通「加购 → 改数量 → 删除 → 结算」全链路时这套东西的性价比高得吓人。它解决的核心问题很具体用最少的代码把 HTTP 无状态这件事「藏」起来让购物车数据在多次请求之间活下来。适合谁在校做 JSP/Servlet 课程设计的人、需要快速搭一个带会话状态的 Java Web 小 demo 的开发者、以及想搞明白 Tomcat 容器里 session 到底怎么存的人。整个包跑起来只需要 JDK Tomcat不依赖任何构建工具。2. 环境搭起来Tomcat 与项目的目录映射先把「能跑」这件事落地再谈代码。这个案例属于典型的非 Maven 老式 Web 工程目录结构直接决定它能不能被容器认出来。目录结构放错、编译产物路径放错是新手第一道坎比代码逻辑本身更容易翻车。2.1 依赖与版本选择JDK 与 Tomcat 的匹配案例里的 JSP 用的是老式脚本片段% %Servlet 映射写在 web.xml 里而不是注解最稳妥的组合是 JDK 8 加 Tomcat 8.5 或 Tomcat 9。用 Tomcat 10 及以上会踩 javax 包名迁移的坑——Tomcat 10 起把javax.servlet.*换成了jakarta.servlet.*源码里所有 import 都要改新手别在这个环节消耗时间。组件推荐版本说明JDK8JSP 编译与 Servlet API 兼容性最稳Tomcat8.5.x / 9.0.x使用 javax.servlet 包命名空间Servlet API随 Tomcat 自带不需要额外下载 jar浏览器任意现代浏览器需允许 Cookie否则 session 失效提示把 Tomcat 目录和解压后的项目目录分开存放别把项目塞进 Tomcat 的 webapps 里又用 IDE 部署两套路径同时生效会出现「改了代码没反应」的玄学。安装动作很直接解压 Tomcat确认bin/startup.batWindows或bin/startup.shLinux/macOS可执行启动后访问http://localhost:8080能看到默认页环境就算通了。这一步不通后面全是白费。2.2 把 rar 解成可部署的 web 工程结构解压后你会看到类似如下的结构重点是WEB-INF的位置和classes目录。部署到 Tomcat 时容器只认WEB-INF/classes和WEB-INF/lib源码放哪儿都行但编译后的 class 必须落在 classes 下。# 假设解压到 ~/work/cart-demo cd ~/work/cart-demo # 查看解压后的结构关键看 WEB-INF 在哪一层 find . -maxdepth 3 -type d | sort # 手工编译 servlet 源码到 WEB-INF/classes # 需要把 Tomcat 的 servlet-api.jar 加进 classpath javac -encoding UTF-8 \ -cp $CATALINA_HOME/lib/servlet-api.jar \ -d WEB-INF/classes \ src/com/demo/cart/*.java上面三条命令分别做三件事先确认目录层级避免WEB-INF被多套了一层文件夹再用javac手工编译-encoding UTF-8是必须的Windows 默认 GBK源码里有中文商品名时会编译出乱码-cp指向 Tomcat 自带的 servlet-api不引这个包编译器会报「找不到符号 HttpServlet」。-d指定输出目录class 文件的包路径会自动按com/demo/cart生成这一点不用手建子目录。如果你的 IDE 是 Eclipse 老版本或 IntelliJ 社区版也可以直接把整个文件夹当 Web 项目导入指定 Tomcat 作为服务器让它代管编译。但手工编译这条路径更透明——出问题时你能立刻判断是编译没过、类没加载还是映射没匹配上。2.3 用 web.xml 固定请求入口老工程不带注解所有 URL 靠 web.xml 声明。这是一份最小可用的映射配置把加购、修改、删除、查看四个动作分派到不同 servlet。?xml version1.0 encodingUTF-8? web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee version3.1 display-namecart-demo/display-name !-- 加购接收商品 id 与数量 -- servlet servlet-nameAddCartServlet/servlet-name servlet-classcom.demo.cart.AddCartServlet/servlet-class /servlet servlet-mapping servlet-nameAddCartServlet/servlet-name url-pattern/cart/add/url-pattern /servlet-mapping !-- 列表页把 session 里的购物车渲染出来 -- servlet servlet-nameCartListServlet/servlet-name servlet-classcom.demo.cart.CartListServlet/servlet-class /servlet servlet-mapping servlet-nameCartListServlet/servlet-name url-pattern/cart/list/url-pattern /servlet-mapping welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list /web-appurl-pattern建议用层级路径而不是*.do这种后缀通配层级路径在日志里一眼能看出是哪个模块。welcome-file指向首页省得每次手敲全路径。改完 web.xml 必须重启容器它不支持热加载——这一点和 JSP 页面不同JSP 改了直接刷新浏览器就生效很多人栽在这两者的差异上。3. session 承载购物车数据结构与增删改的完整实现这一章是案例的技术核心。购物车之所以能跨请求存在靠的就是HttpSession。理解 session 的键值存取形态比抄代码重要得多。3.1 为什么选 session 而不是 Cookie 或数据库三种存法各有位置Cookie 存在浏览器端容量小单域名约 4KB、明文可见、可被篡改放商品 id 列表勉强够放价格绝对不行数据库存购物车最正规但要建表、要用户登录态关联对一个简单版案例属于重型武器session 存在服务端内存容量只受 JVM 堆限制天然和一次会话绑定用户关掉浏览器会话过期数据自动回收不用写清理逻辑。所以这个案例的选型逻辑是数据量小、生命周期短、不需要跨设备同步session 是最省事的那一层。代价也要说清楚——session 存在单台 Tomcat 的内存里服务重启即丢多实例部署时不共享。真实电商项目里购物车一定落库或落 Redissession 只放登录凭证。这个边界心里有数就不算被简单版耽误。存储结构用MapString, Integerkey 是商品 idvalue 是数量。用 Map 而不是 List核心原因是「加购同一件商品要合并数量」Map 的getput天然完成合并List 还得遍历比对。下面是加购的核心逻辑package com.demo.cart; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; import java.util.LinkedHashMap; import java.util.Map; public class AddCartServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException { // 1. 统一编码必须在取参数之前设置 req.setCharacterEncoding(UTF-8); resp.setContentType(text/html;charsetUTF-8); String productId req.getParameter(pid); // 前端可能不传数量默认加 1 int qty parseIntOrDefault(req.getParameter(qty), 1); // 2. 取 session参数 true 表示不存在则新建 HttpSession session req.getSession(true); // 3. 从 session 取购物车首次访问为空则初始化 SuppressWarnings(unchecked) MapString, Integer cart (MapString, Integer) session.getAttribute(cart); if (cart null) { // LinkedHashMap 保证遍历顺序等于加购顺序 cart new LinkedHashMap(); session.setAttribute(cart, cart); } // 4. 合并数量已存在则累加 cart.merge(productId, qty, Integer::sum); resp.sendRedirect(req.getContextPath() /cart/list); } private int parseIntOrDefault(String raw, int def) { if (raw null || raw.trim().isEmpty()) return def; try { int v Integer.parseInt(raw.trim()); return v 0 ? v : def; // 负数或 0 视为非法 } catch (NumberFormatException e) { return def; } } }逐段说明setCharacterEncoding必须写在任何getParameter之前顺序反了参数已经按默认编码解出来了再设也补不回来这是编码问题最常见的翻车点。getSession(true)里的 true 表示「没有就建」改成 false 则在无会话时返回 null后面直接空指针。cart.merge(key, value, Integer::sum)是 Java 8 的写法含义是「key 不存在就放入 value存在就把旧值和新值按 sum 合并」比手写 if-else 判断containsKey干净得多。最后的sendRedirect用重定向而不是转发是为了避免用户按 F5 重复提交加购——重定向后浏览器地址栏是列表页刷新只会重新查列表不会重复加。这个细节值得单独记一笔是血泪经验。getContextPath()拼在路径前面保证项目部署在非根路径比如/cartdemo时跳转依然正确。硬编码/cart/list在根路径部署下能跑换个部署方式就 404。3.2 购物车列表页的渲染与金额计算列表页要把 Map 里的 id 和数量翻译成商品名、单价、小计。简单版通常把商品信息写死在一个静态 Map 里这里沿用这个做法重点是渲染逻辑。package com.demo.cart; import javax.servlet.http.*; import java.io.IOException; import java.io.PrintWriter; import java.util.LinkedHashMap; import java.util.Map; public class CartListServlet extends HttpServlet { // 模拟商品库id - [名称, 单价] private static final MapString, Object[] PRODUCTS new LinkedHashMap(); static { PRODUCTS.put(p1001, new Object[]{机械键盘, 299.00}); PRODUCTS.put(p1002, new Object[]{无线鼠标, 89.50}); PRODUCTS.put(p1003, new Object[]{显示器支架, 159.00}); } Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType(text/html;charsetUTF-8); HttpSession session req.getSession(false); MapString, Integer cart session null ? null : (MapString, Integer) session.getAttribute(cart); PrintWriter out resp.getWriter(); out.println(table border1 cellpadding6); out.println(trth商品/thth单价/thth数量/th th小计/thth操作/th/tr); double total 0.0; if (cart ! null !cart.isEmpty()) { for (Map.EntryString, Integer e : cart.entrySet()) { Object[] p PRODUCTS.get(e.getKey()); if (p null) continue; // 商品下架则跳过 double price (Double) p[1]; int qty e.getValue(); double sub price * qty; total sub; out.printf(trtd%s/tdtd%.2f/td tda hrefupdate?pid%sqty%d-/a %d a hrefupdate?pid%sqty%d/a/td td%.2f/td tda hrefremove?pid%s删除/a/td/tr%n, p[0], price, e.getKey(), qty - 1, qty, e.getKey(), qty 1, sub, e.getKey()); } } else { out.println(trtd colspan5购物车是空的/td/tr); } out.printf(trtd colspan4合计/tdtd%.2f/td/tr%n, total); out.println(/table); } }关键点在getSession(false)列表页只读不建用户从没加过购就访问列表时不该凭空生成一个 session省一点内存也省一次 Set-Cookie 往返。循环里对PRODUCTS.get做了 null 判断模拟「商品下架但购物车里还有记录」的场景不判空会直接空指针导致整页白屏。金额用 double 累加在演示里够用但真实场景涉及分账、优惠券时必须换成BigDecimal并指定舍入模式double 的二进制精度问题会在多件商品叠加后冒出0.30000000000000004这种尾巴。这里把数量加减直接拼成 GET 链接是为了让案例结构简单实际项目里修改数量应该走 POST 并做 CSRF 校验。update和remove两个 servlet 的逻辑是同一套路取 session 中的 Map改或删然后重定向回列表。改数量时特别注意要处理qty 0的情况语义上应该等同于删除否则购物车里会出现数量 0 或负数的脏数据。3.3 JSP 与 Servlet 的分工边界案例里同时有 JSP 和 Servlet很多新手分不清该写哪个。判断标准很简单Servlet 管流程和数据JSP 管展示。加购、改数量、删条目这些「动作」放 Servlet页面骨架、循环输出表格行这些「外观」放 JSP。上面为了看得清楚我把 HTML 直接 print 在 Servlet 里了实际工程更常见的做法是 Servlet 把 cart 放进 request 域再forward到 JSP由 JSP 用 JSTL 的c:forEach渲染。这样做的好处是前端改样式不用碰 Java 代码重新编译。JSP 里写 Java 脚本片段% %虽然老在教学案例里反而直观能看清「取属性 → 判空 → 循环」的完整过程但生产环境里脚本片段早就该退场了。分工定清楚还有个隐性收益调 bug 时能快速定位。页面显示不对先看 JSP 有没有拿到 request 域的数据数据没拿到再回去查 Servlet 有没有正确 setAttribute。这条排查路径比在一个几百行的 JSP 里同时找逻辑和样式要快得多。4. 会话与参数的避坑清单五个真踩过的坑4.1 中文参数变问号编码设置顺序错了现象加购页提交中文商品名列表页显示一串问号或乱码数量价格都正常。原因request.setCharacterEncoding(UTF-8)没写或写在了getParameter之后。Tomcat 8 起 GET 请求默认按 UTF-8 解析但 POST 请求体默认按 ISO-8859-1 解一旦参数已经被解析再设编码无效。还有一种情况是 JSP 页面顶部没写% page contentTypetext/html;charsetUTF-8 %浏览器按错误编码提交。解决Servlet 里第一行就调setCharacterEncodingJSP 页头声明 contentType 和 pageEncoding 双编码如果还不行检查 Tomcat 的conf/server.xml里 Connector 有没有配URIEncodingUTF-8。排查顺序按「页面声明 → 请求设置 → 容器配置」走别跳步。4.2 刷新一次购物车翻倍转发代替了重定向现象点「加入购物车」后按 F5数量每次都再加一遍越刷越多。原因加购用的是forward或直接输出页面浏览器地址栏还停在 POST 的 URL 上刷新即重发上一次 POST 请求业务逻辑再执行一次。这是 PRGPost-Redirect-Get模式缺失的典型症状。解决所有写操作完成后一律sendRedirect到结果页让浏览器发起新的 GET。改完后刷新只会重新查列表不再触发加购。这是我觉得这个案例里最值得记住的一条比记住几个 API 名字有用。4.3 换台电脑购物车就空了session 的作用域本质现象同一浏览器里好好的换浏览器或换设备登录购物车内容完全不同。原因session 靠JSESSIONID这个 Cookie 关联Cookie 存在浏览器里且不跨设备。换浏览器等于换了一个会话服务端当然找不到原来的 Map。这不是 bug是设计使然。解决接受它或者上真正的登录 数据库购物车。演示时如果要跨请求保持确保浏览器没禁用 Cookie否则 Tomcat 会退化成 URL 重写;jsessionidxxx地址栏会变得很难看而且链接里漏带就会丢会话。4.4 重启 Tomcat 数据全丢内存态的正常代价现象调试时改完代码重启容器之前加的商品全部消失。原因session 默认存内存Tomcat 停止时内存对象随之销毁。Tomcat 支持把 session 序列化到work目录下的SESSIONS.ser但要求存进去的对象全部实现SerializableLinkedHashMap里的 String 和 Integer 都满足所以理论上能持久化默认配置下并不保证。解决演示阶段把这个当成预期行为即可需要跨重启保留就改存文件或数据库。别在答辩前十分钟才发现数据丢了那会很尴尬。4.5 直接访问列表页报 500空指针没兜住现象从没加过任何商品直接在地址栏敲/cart/list页面报 500。原因getSession(false)返回 null 后直接调getAttribute空指针。或者 session 存在但cart属性从未设置。解决按 3.2 的写法做双层判空session 为空和 cart 为空分别走「购物车是空的」分支。养成习惯凡是getAttribute拿到的对象第一件事就是判 null这条在 Servlet 里没有例外。5. 从能跑到能验证三个压箱底的调试手法案例能跑起来只是开始能从现象反推原因才算真掌握。我一般会按下面这套顺序做验证每次接手类似的会话型小项目都走一遍省下大量猜的时间。第一个手法是直接观察 session 内容。加一个临时诊断 Servlet把 session 里的所有属性和创建时间打出来比在业务代码里到处 print 高效public class SessionDumpServlet extends HttpServlet { Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException { resp.setContentType(text/plain;charsetUTF-8); HttpSession s req.getSession(false); PrintWriter out resp.getWriter(); if (s null) { out.println(no session); return; } out.println(id s.getId()); out.println(created new java.util.Date(s.getCreationTime())); out.println(maxInactive s.getMaxInactiveInterval() s); java.util.EnumerationString names s.getAttributeNames(); while (names.hasMoreElements()) { String n names.nextElement(); out.println(n s.getAttribute(n)); } } }maxInactiveInterval默认是 1800 秒半小时不动就过期。调试「过一会儿购物车自己空了」这类问题时把这个值和实际间隔对一下立刻能判断是不是超时导致。getId拿去和浏览器 Cookie 里的JSESSIONID比对能确认请求到底有没有带上同一个会话。第二个手法是看 Tomcat 的 access log 而不是只看控制台。logs/localhost_access_log里记录了每次请求的方法、路径、状态码和耗时。加购请求如果是 302 且后跟一个 200 的 GET说明 PRG 生效了如果只看到一个 POST 200说明还是转发。这张表能在一分钟里确认请求链路对不对比读代码快。第三个手法是关闭 Cookie 做降级测试。浏览器设置里禁用 Cookie 后重跑加购流程观察 URL 是否出现;jsessionid。这个测试能暴露所有硬编码链接、以及那些忘记encodeURL的地方。案例里如果链接全是写死的禁用 Cookie 后购物车必然失效——这不是要你修它而是要你知道边界在哪什么条件下这套方案会塌。进阶一点可以把购物车从 session 里抽出来定义一个CartService接口内存实现只是其中一种后面接数据库或缓存时只换实现不动 Servlet。简单版没必要这么做但结构上留个口子答辩被问「怎么扩展」时能接得住。另外提醒一句cart.merge、getAttribute强转这些地方在编译期是看不到类型错误的泛型擦除全靠运行时类型正确所以SuppressWarnings(unchecked)慎用加之前先确认存进去的确实是那个类型。从那以后我每次做会话相关的 demo都会先把诊断 Servlet 挂上去先看一眼 session 里到底有什么再动业务代码——十次有八次问题就出在那一眼里而不是我以为的复杂逻辑。希望帮到你。本文还有配套的精品资源点击获取

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑