☰
JavaWeb简易购物车实战:基于Session的内存购物车实现与避坑指南
2026/10/11 4:21:32 网站建设 项目流程

简介:这是一套基于JavaWeb技术实现的简易购物车系统完整源码,适合Java初学者及希望巩固Web开发基础的中级开发者。代码围绕Servlet与JSP、Session会话管理、JDBC数据库交互、MVC设计模式、JSTL与EL表达式等核心知识点展开,覆盖商品展示、加入购物车、修改数量、删除商品及订单处理等典型业务流程,并附有开发文档与项目安装说明,能帮助读者理清从页面请求到数据持久化的完整链路。压缩包共54个文件,包含14个Java源文件、14个编译后的class文件、6个JSP页面、数据库相关配置及properties、xml等辅助资源,整体大小约1.58MB,目录结构清晰,适合直接导入Eclipse或IntelliJ IDEA配合Tomcat运行学习。已有4125人浏览学习。通过研读源码和笔记,可快速掌握JavaWeb项目从环境搭建、数据库设计到功能实现的常见思路,对提升实际项目开发能力很有帮助。

1. 什么是 javaWeb 简易购物车:它不是一张表,而是会话里的一块内存

很多人第一次接触 javaWeb 购物车时,会下意识地想把「购物车」设计成数据库中的一张表:加购就是 insert,改数量就是 update,删除就是 delete。这个思路不算错,但对一个网页端的简易购物车来说,它把问题想复杂了。真正的购物车,在绝大多数教学项目和基础实践中,只是一个放在 HttpSession 里的内存结构。用户点一下「加入购物车」,服务端往 Session 里塞一个 HashMap;用户清空购物车,服务端把这个 Session 属性删掉。整个过程不碰数据库,也不落盘。

这么设计的第一层原因是简单:不需要额外建表、不需要处理事务、不需要考虑未登录用户怎么关联购物车数据,一个会话对象全搞定了。第二层原因是贴合 HTTP 的无状态模型——购物车本质是「某一次浏览过程里的临时意图」,Session 天生就是为这种临时状态设计的。给刚入门 JavaWeb 的人做一个可以复现、能看清请求流转路径的项目,用 Session 存购物车,比引入 Redis 或数据库表都直观得多。

这篇笔记面向的读者很明确:正在做 JavaWeb 课设或入门练手的人、需要给现有管理系统补一个购物车模块的人、被教科书里各种「MVC 三层架构」绕晕的初学者。我会先用最朴素的方式讲清楚购物车应该长什么样,然后给出一个能直接跑起来的最小工程,再把加购、改数量、删商品这些操作的代码逐段拆开,最后列几个我实际踩过的坑。这套方案代码量不大,但该有的请求、转发、重定向、会话、JDBC 读写都齐了,值得照着做一遍。

2. 技术选型与职责拆解:为什么 JSP + Servlet + JDBC 就够

2.1 购物车为什么是内存结构而不是数据库表

购物车里的数据有一个鲜明特点:生命周期极短的临时性。用户打开浏览器访问一次购物网站,往购物车里加了几件商品,然后关闭页面走人,这条数据就不再有任何意义了。如果把它落进数据库,你不仅要为每个匿名访客建一张购物车明细表,还要在会话过期时写定时任务去清理孤儿数据,否则表会越攒越脏。为一个简易项目引入这种复杂度,不划算。

常见的做法是:用 HttpSession 的 setAttribute 方法存放一个 Map<商品Id, 数量>。同一个商品再次加购时,只要数量加 1 而不是重新插入。这个 Map 的生命周期由容器管理,默认超时时间一般是 30 分钟,用户关掉浏览器后 Session 也随之失效。要展示购物车里的商品详情(名称、单价、小计),再通过商品 Id 去查一遍商品表即可。也就是说,购物车只存 Id 和数量两个字段,商品的其他信息仍然以数据库为准。

为什么不是 Cookie?Cookie 也存在浏览器端,看似也能存购物车,但 Cookie 的容量限制只有 4KB 左右,而一个购物车里动辄几十件商品的 Id 列表很容易撑爆这个上限。更重要的是 Cookie 里的数据是明文的,用户可以随意篡改。Session 把数据留在服务端,客户端只拿一个看不见内容物的会话 ID,安全性和容量都更合适。简易购物车用 Session,是成本和安全性之间的平衡点,不是没考虑过其他方案。

还有一个实操上的好处:调试直观。你用 Servlet 处理加购请求时,直接打印 session.getAttribute("cart") 就能看到当前购物车的内容,不用连数据库执行 select。对于还没把调试工具链玩熟的初学者,这个「黑匣子」能被直接看到内部状态,排查问题的成本低得多。

2.2 职责拆解:把 JSP、Servlet、JDBC 各放对位置

一个可以交差的简易购物车项目,至少要分出四层职责,但每层都很薄。第一层是 JSP 页面,负责展示商品列表和购物车内容;第二层是 Servlet,负责接收请求、调用业务方法、决定跳转方向;第三层是一个购物车相关的 JavaBean(CartItem 和 Cart),封装购物车的存储结构;第四层是商品数据的 JDBC 访问代码,负责把商品表里的记录查出来。

不少人会把 JDBC 代码直接写进 Servlet 的 doGet 方法里,这种做法在小项目里能跑,但会让你后面改数据库连接配置时到处找代码。我一般会单独建一个类,哪怕这个类只有一个静态方法,也要和 Servlet 分开。同理,JSP 页面里不要出现 Java 代码片段。用 EL 表达式读购物车数据,用 JSTL 的 c:forEach 遍历商品列表,这两样东西是 JavaWeb 入门阶段必须顺手学会的——它们能让你把页面代码控制在二十行以内。

关于版本选择,Servlet 可以用 3.1 或 4.0,JSP 用 2.3 以上,JDK 用 8 或 11,Tomcat 用 9.x。这些版本都是经过多年验证的稳定组合,网上能找到大量兼容性说明。不要用 Servlet 5.0 以上的版本,因为从 5.0 开始采用了 Jakarta EE 命名空间,与老教材里的 javax.servlet 包名不兼容,会让照着旧教程写代码的初学者在第一关就翻车。

# 这里给一个最小目录结构,之后照着建就不会乱 src/main/java ├── com.demo.entity # CartItem、Cart、Product ├── com.demo.dao # 商品表访问 ├── com.demo.web # CartServlet、GoodsServlet src/main/webapp ├── WEB-INF/web.xml # Servlet 映射 ├── goodsList.jsp # 商品列表页 └── cart.jsp # 购物车页面

建目录的时候,包名尽量不要用默认包,否则 Servlet 注解扫描和 web.xml 配置都可能出问题。用公司域名反写或者干脆用 com.demo 这种形似域名反写的结构,是最稳妥的选择。

3. 最小可运行工程:目录、依赖与数据库初始化

3.1 用 Maven 搭好骨架:pom.xml 里的依赖清单

我会用 Maven 来管理这个项目。原因不是它多高级,而是它能帮你锁定依赖版本,避免手动往 WEB-INF/lib 里拷 jar 包时漏掉某一个、运行期才报 ClassNotFoundException。对于一个入门项目,pom.xml 只需包含 servlet-api、jsp-api 和 mysql-connector-java 三样,其中前两个还要显式声明 provided scope,因为 Tomcat 容器本身自带这两个库,打包时不需要重复打入。

<dependencies> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>javax.servlet.jsp</groupId> <artifactId>javax.servlet.jsp-api</artifactId> <version>2.3.3</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>

第二个依赖记得补上 jstl 和 taglibs,因为页面循环要用 c:forEach,没有 JSTL 你只能退回 Java 代码片段,页面会变得很难读。版本号不用追新,能稳定工作的老版本反而更省心。mysql-connector 用 8.0.x 配合 MySQL 5.7 或 8.0 都能正常连接,驱动类全名是 com.mysql.cj.jdbc.Driver,这个写法在 8.x 里已经是标配。

<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>

Maven 配好后,在项目根目录执行 mvn package,确认能打出 war 包。这一步通过,说明依赖引入没问题,后续任何报错都不太可能与缺 jar 有关了。

3.2 商品表与初始化数据:购物车得有东西可加

购物车本身不存商品明细,但页面得能从商品表查出可加购的物品。我一般只建一张 goods 表,包含 id、name、price、stock 四个字段。price 用 decimal(10,2) 而不是 float,避免浮点精度在结算小计时出现 0.30000000000000004 这种尴尬。

CREATE TABLE goods ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 10 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; INSERT INTO goods (name, price, stock) VALUES ('机械键盘', 199.00, 50); INSERT INTO goods (name, price, stock) VALUES ('游戏鼠标', 89.00, 80); INSERT INTO goods (name, price, stock) VALUES ('显示器支架', 129.00, 30);

字符集用 utf8mb4 而不是 utf8,是一个血泪经验:utf8 在 MySQL 里实际是 utf8mb3,存不了 emoji,也存不了部分生僻汉字。你写个「😄」进商品名,旧字符集直接报错。建表时把字符集定了,以后少一堆麻烦。

数据库连接信息按惯例写在 src/main/resources 下的 jdbc.properties 里,然后写一个简单的 DBUtil 类读取配置、获取连接。连接串里必须带上 useUnicode=true&characterEncoding=utf8,否则中文参数往数据库里写会乱码。时区参数 serverTimezone=Asia/Shanghai 在 MySQL 8.x 下是必须的,不加会报时区错误。

Class.forName("com.mysql.cj.jdbc.Driver"); String url = "jdbc:mysql://localhost:3306/shopdb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai"; conn = DriverManager.getConnection(url, "root", "你的密码");

注意 Class.forName 在连接池或较新驱动里已经不是必写项,但对初学者来说保留更稳。不需要理解它背后的 SPI 机制,知道这行代码是触发驱动类加载即可。

4. 购物车核心逻辑的实现:加入、改数量、删除

4.1 定义 CartItem 和 Cart:会话里存什么

购物车里每一行是一条 CartItem,至少要有商品 id、商品名称、单价、加购数量四个属性,再加一个 getSubtotal() 方法计算小计。商品名称和单价是在加购时从数据库查出来的,这样页面展示购物车时不必再查一次数据库,性能好一些,代码也简单。

public class CartItem { private int id; // 商品 id private String name; // 商品名称 private double price; // 单价 private int count; // 加购数量 public double getSubtotal() { return price * count; } // 构造方法、getter/setter 省略 }

Cart 类不直接操纵 Session,它只负责持有一个 Map<Integer, CartItem>。加购时判断 map 里有没有同一个 id,有就把 count 加 1,没有就 new 一个 CartItem 塞进去。把业务放在这个类里,Servlet 就只做参数解析和跳转两件事,后期如果想换成 Redis 存储或其他方式,只需要替换 Cart 的实现,控制层可以不动。

public class Cart { private Map<Integer, CartItem> items = new LinkedHashMap<>(); public void addToCart(CartItem item) { CartItem exist = items.get(item.getId()); if (exist != null) { exist.setCount(exist.getCount() + 1); } else { items.put(item.getId(), item); } } public List<CartItem> list() { return new ArrayList<>(items.values()); } // remove、clear 方法类似,不再赘述 }

用 LinkedHashMap 而不是 HashMap 是有意为之:它保证遍历顺序和插入顺序一致,购物车页面显示的顺序就和用户加购的顺序一致了。这个小细节很多人不注意,但页面效果差异明显。

4.2 加购请求的完整链路:Servlet 与 JSP 之间的配合

加购操作的前端只是一个链接或者一个按钮,点击后携带商品 id 请求 CartServlet。Servlet 根据 action 参数分发处理:add 是加购,update 是改数量,delete 是删一项,clear 是清空。最后统一重定向回商品列表页或购物车页,而不是转发到 JSP。

@WebServlet("/cart") public class CartServlet extends HttpServlet { protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); HttpSession session = req.getSession(); Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); } if ("add".equals(action)) { int id = Integer.parseInt(req.getParameter("id")); Product p = ProductDao.findById(id); CartItem item = new CartItem(p.getId(), p.getName(), p.getPrice(), 1); cart.addToCart(item); } else if ("delete".equals(action)) { cart.remove(Integer.parseInt(req.getParameter("id"))); } resp.sendRedirect(req.getContextPath() + "/goods"); } }

这里要解释为什么用重定向而不是转发:转发是服务端内部跳转,地址栏不变;重定向是浏览器重新发起一次请求,地址栏会变成目标地址。如果加购后用转发回到商品列表页,用户按 F5 刷新时会再次提交上一次的加购请求,商品数量会被反复叠加。重定向正是解决「刷新导致重复提交」的最朴素方案。

ProductDao.findById 里的 JDBC 代码老生常谈:建立连接、写 SQL、填参数、执行查询、封装结果集、关资源。唯一要强调的是 finally 里逐个关闭 ResultSet、Statement、Connection,并且要按这个顺序关,否则连接不释放,多刷新几次页面就会出现连接超时。

public static Product findById(int id) { String sql = "SELECT id, name, price, stock FROM goods WHERE id=?"; // PreparedStatement 预编译,防 SQL 注入 // 结果封装成 Product 对象返回 }

在页面端,遍历购物车只需这样一段 EL + JSTL:

<c:forEach items="${sessionScope.cart.list()}" var="item"> <tr> <td>${item.name}</td> <td>${item.price}</td> <td> <a href="cart?action=update&id=${item.id}&count=1">+</a> ${item.count} <a href="cart?action=update&id=${item.id}&count=-1">-</a> </td> <td>${item.subtotal}</td> <td><a href="cart?action=delete&id=${item.id}">删除</a></td> </tr> </c:forEach>

需要留意的是 sessionScope 的写法,只有加上它才能明确告诉 EL 去 Session 里取 cart,而不是去请求域或页面域里找。这个小细节如果漏掉,页面上的购物车一直是空的,排查时容易一头雾水。改数量操作不用单独加一个文本框,直接在现有数字旁边放加减两个链接,通过 count 参数传 ±1,后端拿到后修改数量,这样就避免了表单提交要处理的空值问题。

5. 五个高频坑:Session 丢失与乱码排查记录

5.1 商品数量莫名其妙叠加,刷新一次涨一次

现象:加入一件商品后点浏览器刷新,购物车里这件商品的数量每次 +1,无法通过刷新让页面停留在原状。

原因:加购请求用的是 doGet,浏览器地址栏的请求 URL 不会因为页面跳转而改变,刷新时浏览器原样重发上一次请求。如果加购后是转发到列表页,这个请求会被反复提交,每次都在原数量上加 1。另外,页面里如果用了 这种方式直接提交 GET 请求,刷新重放的几率也高。

解决:把加购处理结束后的响应改为重定向,即 resp.sendRedirect() 返回列表页。重定向会让地址栏变成列表页的 URL,刷新时提交的是列表页的查询请求,而不是加购请求。对于数量变化类操作,更稳妥的是改用 POST 提交,并且处理完后仍然重定向,双保险。我习惯的规则是:所有写操作一律 POST + 重定向,读操作才用 GET + 转发。

5.2 加购时商品名变成 ?或乱码

现象:使用 Tomcat 8 及以下版本运行项目时,请求参数里的中文商品名在购物车里显示为一堆问号或乱码。

原因:GET 请求的中文参数默认按 ISO-8859-1 解码,超过这个字符集范围的字符全部变成问号。Tomcat 8 及以上版本默认 URI 编码是 UTF-8,问题不大,但很多教材配套的教学环境还在用 Tomcat 7。

解决:在请求到达 Servlet 之前先指定请求编码,或者在 Tomcat 的 server.xml 的 Connector 配置里加上 URIEncoding="UTF-8"。代码层面最省事的做法是写一个 Filter,在 doFilter 开头设置 req.setCharacterEncoding("UTF-8"),然后 chain.doFilter 放行。注意设置编码动作必须发生在读取任何参数之前,否则已解码的参数无法回头修正。同时,JSP 页面顶部写 pageEncoding=“UTF-8”,数据库连接串里带 characterEncoding=utf8,三层一起到位才算是彻底解决。

public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) { req.setCharacterEncoding("UTF-8"); resp.setCharacterEncoding("UTF-8"); chain.doFilter(req, resp); } }

5.3 同一个商品加购后数量没有 +1,反而出现两行

现象:往购物车里加一件商品两次,购物车页面出现两行同名的商品,而不是一行数量为 2。

原因:CartItem 里的 equals 和 hashCode 没有重写,导致 Map<Integer, CartItem> 的 key 是商品 id(Integer 包装类型)时不存在重复问题,但如果你把 Cart 实现里的 key 换成了 CartItem 对象,且没有重写 equals,那么每次 new 的 CartItem 都是不同对象,永远不能命中已有的项。初学者最容易在这里写错,把 key 类型和实际类型混在一起。

解决:严格保持 Map 的 key 类型为 Integer 商品 id,判断是否存在时用 id 作为查找依据,而不是把 CartItem 本身当作 key。如果你的设计里一定要用对象做 key,那么必须重写 equals 和 hashCode,且只比较商品 id。经验之谈:能用基本类型包装类做 key 就不要用对象,少写代码就是少出错。

5.4 页面能打开,但购物车数据显示不完整,点删除报空指针

现象:列表页能正常显示商品,但进入购物车页后部分字段空白;点击删除某个商品时后台抛 NullPointerException,页面白屏。

原因:这是典型的用户换了个浏览器或者清了 Cookie 导致 Session 丢失。Session 是靠 Cookie 里的 JSESSIONID 维持的,浏览器禁止 Cookie 后每次请求都是新的会话,cart 属性自然为 null。另一个常见情况是先直接打开了购物车页,还没通过列表页加购过任何商品,此时 session 里根本没有 cart 这个属性。

解决:在购物车页面的 Servlet 里做空值兜底,取出 cart 为 null 时就 new 一个空的 Cart 放入 session,而不是直接拿 null 去调方法。页面端为了优雅,可以在购物车为空时提示「还没有商品,去逛逛」,而不是渲染一个空表格。这段逻辑两三行就能写完,但能挡住大部分线上白屏。

Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); }

5.5 修改数量时输入了负数或 0,购物车里出现离谱数据

现象:用户通过加减链接操作数量,理论上每次只传 ±1,但如果有人手工修改 URL 参数,传 count=-999,购物车里的数量就变成了负数。

原因:后端缺少兜底校验,拿到参数后直接运算。简易项目通常不面对恶意攻击,但一个「不小心手滑」的普通用户也能制造同样的脏数据。负数的购物车数量会导致购物车总计出现负数,展示上非常难看,计算结算金额时还会引发逻辑混乱。

解决:在后端更新数量之后、保存之前做一次最小值校验,数量小于 1 时直接删除这一项,或者重置为 1。数量大于库存时按库存上限截断。这套校验逻辑放在 Cart 类的 updateCount 方法里,保证任何入口进来的数据都会被兜住。类似的兜底同样适用于商品 id 参数,Integer.parseInt 前先做一次正则匹配或 try-catch,避免非数字参数直接让 Servlet 抛 NumberFormatException。

6. 从「能跑」到「能交差」的进阶调整

到此,一个基于 Session 存储、JSP + Servlet 展示、JDBC 读商品表的简易购物车已经完整跑通了。如果做完这个还觉得不过瘾,或者这是你的课程设计需要「加亮点」,下面几个方向是我会优先考虑的,按性价比从高到低排列。

第一个值得改的是把商品详情也纳入购物车展示。当前方案在加购时把商品名和单价存进了 CartItem,这有个隐患:后台改了商品价格,购物车里的旧价不会自动更新。进阶做法是购物车只存 id 和数量,每次渲染购物车页时用 id 批量查一次商品表,详情实时对齐。代价是每次打开购物车多一次查询,但换来的是价格数据的准确性。这一步改动不大,但能把「购物车是临时视图」这个理念贯彻到位。

第二个方向是加上数量校验与库存联动。在 updateCount 方法里查一下当前商品的库存,加购数量超过库存时弹出提示,不允许继续添加。这样即使没人恶意攻击,也能挡住误操作,页面体验感提升明显。库存扣减可以放在「结算」动作里做,如果没有做结算功能,至少要做到下单时二次校验,防止超卖——虽然简易项目通常不需要真上锁,但提前把库存校验逻辑埋好,后续接支付宝或微信支付时会省很多事。

第三个方向是引入 Cookie 持久化未登录用户的购物车。做法是:用户未登录时,把购物车编码为 JSON 字符串写入 Cookie,登录后将 Cookie 中的内容合并进账户的购物车数据。这个功能在真实电商里是标配,但实现起来涉及 JSON 序列化、Cookie 编解码、合并策略,量不小。我的建议是:如果做的是课设,这个方向属于加分项但投入产出比一般,把 Session 版本打磨到极致已经足够;如果是在公司里做企业级项目,这几乎是硬需求。

我在经历模拟项目X的时候,一开始也想着把所有状态都放进数据库,后来发现会话过期时数据库里堆满了没人认领的临时记录,清洗麻烦不说,还拖慢查询。把购物车放 Session 后又踩过刷新重复提交的坑,被 QA 同学反复反馈「加一次变两次」。「写操作后一律重定向」这个习惯,就是那次被磨出来的。回头来看,做简易购物车最有价值的收获并不是学会了几个标签和 API,而是真正理解了 HTTP 无状态和 Session 之间的关系。

如果你照着上面的代码跑通了,建议你试着改一个功能来验证自己是否真理解:把「加入购物车」改成「商品详情页内选择数量后加入」,看看参数传递和数量叠加逻辑会出现哪些新问题。这个改动不复杂,但能考验你对整个请求链路的掌握程度。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询