ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Java Web购物车源码解析:Servlet+JSP+JDBC轻量级MVC实现

Java Web购物车源码解析:Servlet+JSP+JDBC轻量级MVC实现

简介:面向JavaWeb初学者的购物车系统完整源码包,基于Servlet/JSP+Session+JDBC实现,涵盖商品添加删除、数量更新、金额统计等核心交互,同时附有数据库脚本与详细开发笔记,适合课设练习与框架前基础巩固。源码演示了如何通过Session保存购物车数据,利用JDBC操作MySQL商品表,并在JSP中借助EL与JSTL渲染商品列表,配有异常处理与部署说明。包内共54个文件,含14个Java源文件、14个编译后class文件、6个JSP页面、数据库及配置类XML和Properties文件等,整体仅1.58MB,轻量易读。已有4125人学习下载。资料包还包含项目安装说明、Eclipse工程目录结构,从环境配置到功能逻辑均有交代,可帮助读者理解MVC分层与购物车状态管理过程,适合系统走通一个完整业务流程。

1. 一个 Java Web 简易购物车源码拆开看:它能干什么、适合谁

搜 java 购物车源码的时候,十有八九会撞上一堆 Spring Boot 全家桶项目,光是配 Maven 镜像、等依赖下载就能把耐心磨没。这份源码反着来,走的是 Java Web 最经典的 Servlet + JSP + JDBC 路线,一个 Tomcat 加一个 MySQL 就能跑,不掺任何框架。它适合两类人:一类是刚学完 Java Web 基础、正愁课设或者面试项目的人,能完整看到注册登录、商品列表、加购、改数量、删除、结算下单这条链路是怎么串起来的;另一类是想快速搭一个可演示原型、不想被框架束缚的开发者。代码量不大,但把 Java Web 的核心知识点都串起来了。

2. 架构与技术栈:Servlet + JSP + JDBC 的轻量 MVC 长什么样

2.1 技术选型:为什么用这套「老组合」

先别急着觉得 Servlet 过时。我把这套源码的依赖清单拉出来看过,运行时只有一个mysql-connector-javaJAR 包,剩下的全是 JDK 自带的 Servlet 和 JSP API。跟 Spring Boot 方案比,优势非常直接:

对比维度本项目方案常见 Spring Boot 方案
依赖数量1 个数据库驱动 JARSpring Boot 全家桶 + 大量第三方包
运行环境外置 Tomcat + MySQL内嵌 Tomcat,依赖 Maven 拉包
学习价值JDBC 手动管理、Session、Servlet 生命周期全部暴露大部分逻辑被框架封装成黑匣子
上手成本导入后直接部署 war首次构建要等网络拉取依赖

分层的思路是典型的轻量 MVC:JSP 负责渲染,Servlet 负责接收请求和跳转,DAO 层用 JDBC 操作数据库。没有 Service 层抽象,订单金额计算这类逻辑直接写在 Servlet 里,对入门项目来说反而更直白,想拆出 Service 层也是后面改造的事。

2.2 目录结构:一个标准 Dynamic Web Project 的自我修养

这套源码的目录结构规规矩矩,就是 Eclipse 里 Dynamic Web Project 的标准形态:

src/com/demo/shop/ ├── entity/ # 实体类 │ ├── User.java │ ├── Product.java │ ├── CartItem.java │ └── OrderItem.java ├── dao/ # 数据访问层 │ ├── UserDao.java │ └── ProductDao.java ├── servlet/ # 控制层 │ ├── RegisterServlet.java │ ├── LoginServlet.java │ ├── ProductListServlet.java │ ├── CartServlet.java │ └── OrderServlet.java ├── util/ │ └── DBUtil.java # JDBC 连接工具类 └── filter/ └── LoginFilter.java # 登录拦截 WebContent/ ├── index.jsp # 商品列表页 ├── login.jsp ├── register.jsp ├── cart.jsp # 购物车页面 ├── order.jsp # 订单结果页 ├── css/style.css └── WEB-INF/ ├── web.xml └── lib/mysql-connector-java.jar

职责边界很清晰:entity里四个类对应数据库表和购物车条目,dao只负责 SQL 和结果集映射,servlet只做参数接收、业务编排、页面跳转。唯一的辅助类是DBUtil,把获取连接、关闭资源的重复代码收拢到一起。web.xml里配置了 Servlet 映射和欢迎页,没配 Spring、没配 MyBatis,说明这项目没有任何框架侵入。

2.3 数据库设计:两张业务表撑起整个购物流程

数据库脚本里核心表有三张,用户表、商品表、订单主表加订单明细表,主表和明细表是一对多关系。看建表语句就能大概猜到业务边界:

CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(32) NOT NULL COMMENT '存的是 MD5 值', nickname VARCHAR(50) DEFAULT '' ); CREATE TABLE t_product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(100) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, description VARCHAR(255) DEFAULT '' ); CREATE TABLE t_shop_order ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT NOW(), status TINYINT DEFAULT 0 COMMENT '0-未支付 1-已支付' ); CREATE TABLE t_order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, product_name VARCHAR(100), price DECIMAL(10,2), quantity INT );

注意t_order_item里冗余了product_name和price,这是电商系统里订单快照的常见做法——订单生成后商品改名或调价,历史订单的记录不受影响。密码字段存的是 MD5,不是明文,这类细节在简易 Demo 里属于加分项。

3. 核心模块拆解:一个商品从浏览到结算的完整链路

3.1 商品列表:一个 Servlet 如何把数据送到 JSP

用户访问首页看到商品列表,链路是从ProductListServlet开始的。它做的事情极其简单:调用 DAO 查出所有商品,放进 request 域,转发给index.jsp。看一下ProductDao的查询实现:

public List<Product> findAll() { List<Product> list = new ArrayList<>(); String sql = "SELECT id, name, price, stock, description FROM t_product"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql); ResultSet rs = ps.executeQuery()) { while (rs.next()) { Product p = new Product(); p.setId(rs.getInt("id")); p.setName(rs.getString("name")); p.setPrice(rs.getBigDecimal("price")); p.setStock(rs.getInt("stock")); p.setDescription(rs.getString("description")); list.add(p); } } catch (Exception e) { e.printStackTrace(); } return list; }

这里用了 try-with-resources,Connection、PreparedStatement、ResultSet三个资源在 try 块结束后自动关闭,不会出现连接泄漏。JDBC 里最容易被新手忽略的就是资源关闭,这个写法值得直接抄。查询出的List<Product>返回给ProductListServlet.doGet后:

protected void doGet(HttpServletRequest request, HttpServletResponse response) { ProductDao dao = new ProductDao(); List<Product> products = dao.findAll(); request.setAttribute("products", products); request.getRequestDispatcher("/index.jsp").forward(request, response); }

request.setAttribute("products", products)里的 key 是products,index.jsp里用 JSTL 的<c:forEach items="${products}">去取,key 必须对上,改一处另一处不跟就会渲染出空列表。这套源码没有做分页,商品多的时候全查出来性能会明显下滑,我把它放在第 5 章改造方案里讲。

3.2 购物车存取:Session 里怎么存、怎么改

购物车是本项目的重头戏,实现方案是把购物车整个塞进HttpSession。session.setAttribute("cart", cart)之后,只要会话不失效,用户在任意页面都能拿回同一个购物车。购物车结构用的是Map<Integer, CartItem>,key 是商品 ID,value 是购物车条目。核心的添加逻辑在CartServlet里:

protected void doPost(HttpServletRequest request, HttpServletResponse response) throws IOException { request.setCharacterEncoding("UTF-8"); String action = request.getParameter("action"); String productIdStr = request.getParameter("productId"); HttpSession session = request.getSession(); Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new HashMap<>(); } if ("add".equals(action)) { int productId = Integer.parseInt(productIdStr); CartItem item = cart.get(productId); if (item == null) { // 第一次加入,查一次库拿到商品信息 Product p = new ProductDao().findById(productId); item = new CartItem(p.getId(), p.getName(), p.getPrice(), 1); cart.put(productId, item); } else { // 已经加过,只改数量,不重复查库 item.setQuantity(item.getQuantity() + 1); } } else if ("update".equals(action)) { int productId = Integer.parseInt(productIdStr); int quantity = Integer.parseInt(request.getParameter("quantity")); CartItem item = cart.get(productId); if (item != null) { item.setQuantity(quantity); } } else if ("delete".equals(action)) { int productId = Integer.parseInt(productIdStr); cart.remove(productId); } session.setAttribute("cart", cart); response.sendRedirect("cart.jsp"); }

这段代码把三种操作收在一个 Servlet 里,靠action参数区分,是这类简易项目最常见的写法。几个细节值得注意:第一次加购才查数据库,之后只改内存里的数量,这是 Session 购物车效率的关键;update是给购物车页面里数量输入框用的,用户改完数字提交表单,quantity参数从请求里取;delete直接map.remove,一行搞定,但别忘了把改完的cart重新setAttribute回去,否则下次请求拿到的还是旧对象。

CartItem这个实体在设计上和Product不是一回事,它只有productId、name、price、quantity四个字段。price在这里是加购那一刻的商品单价快照,而不是拿着 productId 每次实时去查,这样购物车页面渲染时不需要再碰数据库,但也会带来安全隐患——用户如果手动改购物车页面里的价格字段提交,下单金额就失真了。这个问题在结算模块里有对应处理。

3.3 结算与订单:购物车到订单表的落地

结算模块是整套代码里唯一需要事务的地方。OrderServlet要做两件事:往t_shop_order插入一条订单主记录,拿到自增订单号后再往t_order_item插入 N 条明细。任何一个环节失败,都要把前面插入的数据全部回滚,否则会出现「订单主表有记录、明细却是空的」这种脏数据。核心代码:

Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 String insertOrder = "INSERT INTO t_shop_order (user_id, total_price, create_time, status) VALUES (?, ?, NOW(), 0)"; PreparedStatement ps1 = conn.prepareStatement(insertOrder, Statement.RETURN_GENERATED_KEYS); ps1.setInt(1, userId); ps1.setBigDecimal(2, totalPrice); ps1.executeUpdate(); ResultSet keys = ps1.getGeneratedKeys(); int orderId = 0; if (keys.next()) { orderId = keys.getInt(1); } String insertItem = "INSERT INTO t_order_item (order_id, product_id, product_name, price, quantity) VALUES (?, ?, ?, ?, ?)"; PreparedStatement ps2 = conn.prepareStatement(insertItem); for (CartItem item : cart.values()) { ps2.setInt(1, orderId); ps2.setInt(2, item.getProductId()); ps2.setString(3, item.getName()); // 注意:取的是数据库里的最新价格,不是购物车里的快照价 ps2.setBigDecimal(4, item.getPrice()); ps2.setInt(5, item.getQuantity()); ps2.addBatch(); } ps2.executeBatch(); conn.commit(); // 全部成功,提交 session.removeAttribute("cart"); // 下单成功清空购物车 } catch (Exception e) { if (conn != null) { conn.rollback(); // 任何一步失败,全部回滚 } e.printStackTrace(); }

这里有个血泪经验:购物车里的price是加购时的快照,用户在购物车页面把价格改成 0.01 再提交,如果直接拿快照价入库,订单金额就被人为篡改了。合格的做法是结算时重新按productId回表查最新价格再写入明细,防御金额被串改。RETURN_GENERATED_KEYS是为了拿自增主键,executeBatch()批量执行明细插入,数据量大时比单条 executeUpdate 快,这里虽然数据量小,但写法规范。

这套流程没有做库存扣减,t_product.stock字段摆在那但没被结算逻辑使用,属于明显的改造切入点,后面细说。

4. 部署运行与避坑盘查:从零初始化到常见翻车现场

4.1 环境准备与配置修改

跑这套源码需要的环境是 JDK 8 及以上、Tomcat 8.5 或 9、MySQL 5.7 或 8.0。项目只有一个配置文件需要改,就是src下的db.properties:

jdbc.driver=com.mysql.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/shop?useUnicode=true&characterEncoding=UTF-8 jdbc.username=root jdbc.password=123456

如果你用的是 MySQL 8.0,驱动类要换成com.mysql.cj.jdbc.Driver,URL 里还要追加useSSL=false&serverTimezone=Asia/Shanghai,不然启动时会报 SSL 连接警告和时区错误。这个细节是高频翻车点,在第 4 章末尾单独拎出来讲。jdbc.password改成你自己本机 MySQL 的密码,数据库名默认是shop,也可以改成别的,但要保持和初始化脚本里的库名一致。

4.2 数据库初始化:建库、建表、造数据

源码里通常带着一个shop.sql脚本,用命令行导入最快:

mysql -uroot -p < shop.sql

脚本内容除了建表语句,还会插入几条测试商品数据,比如几个手机、耳机之类的,方便登录后直接看到商品列表。导入之后可以顺手验证一下:

mysql -uroot -p -e "USE shop; SHOW TABLES; SELECT * FROM t_product;"

能看到t_user、t_product、t_shop_order、t_order_item四张表,商品表里有数据,说明初始化成功了。如果导入时报错,大概率是 MySQL 版本语法兼容问题,MySQL 8 里DEFAULT NOW()这种写法是没问题的,老版本如果报错就把DEFAULT去掉,由程序插入时指定时间。

4.3 部署到 Tomcat 并跑通第一条链路

用 IDEA 的话,导入项目后选 Web Application 类型,配好 Tomcat,直接把项目以 war exploded 方式部署。访问路径取决于上下文名,默认是http://localhost:8080/shop/,如果上下文名改成了别的,访问路径跟着变。

跑通链路按这个顺序验证:先注册一个账号,密码会以 MD5 形式落库,然后登录跳回首页;在商品列表里点「加入购物车」,右上角的购物车条目数要变成 1;进cart.jsp修改数量、再点删除,看数据是否正确变化;最后点「结算」,跳到订单成功页,这时候去数据库里查t_shop_order和t_order_item,能看到主表和明细表都有数据,说明一条完整链路已经走通。

有一个容易被忽略的细节:LoginFilter拦截了所有/cart/*和/order/*的请求,未登录访问这些路径会被强制弹回login.jsp。测试的时候如果出现「一直让我登录」的现象,先确认下是不是忘记登录,或者 Session 被清掉了。

4.4 避坑清单:5 个高频翻车现场

  1. 现象:Tomcat 启动后访问首页,控制台报ClassNotFoundException: com.mysql.jdbc.Driver。原因是驱动 JAR 没进WEB-INF/lib,或者 MySQL 8 的驱动包用的是新类名com.mysql.cj.jdbc.Driver。解决:确认mysql-connector-java.jar确实存在于WebContent/WEB-INF/lib下;MySQL 8 就把db.properties里驱动改成新类名,URL 加useSSL=false&serverTimezone=Asia/Shanghai。

  2. 现象:部署 MySQL 8 时,启动不报错,但第一次查询就抛异常,提示The server time zone value '�й���ʱ��' is unrecognized。原因是 MySQL 8 对时区校验更严格,连接串里没指定时区。解决:URL 末尾追加serverTimezone=Asia/Shanghai,这是老版本不需要、新版本必须加的参数,属于典型的运行环境玄学。

  3. 现象:注册或登录提交中文用户名,插入数据库后变成??。原因是请求编码和数据库编码不一致。解决:第一,db.properties里 URL 加useUnicode=true&characterEncoding=UTF-8;第二,数据库和表建的时候用utf8mb4;第三,处理 POST 请求的 Servlet 里doPost第一行写request.setCharacterEncoding("UTF-8")。三个地方全对齐,中文乱码才彻底消失。

  4. 现象:登录后加了几个商品进购物车,重启 Tomcat 再访问cart.jsp,购物车是空的。原因:Session 默认存在 JVM 内存里,Tomcat 一重启,所有会话数据全部丢失。这个现象不是 bug,是 Session 方案的固有特性。解决:如果只是演示可以接受;要跨重启保留,就得把购物车落库或迁到 Redis,第 5 章有对应方案。

  5. 现象:项目跑一阵子后,页面开始报Too many connections,MySQL 连接数被耗尽。原因:DAO 里查询后没有关闭Connection,或者 try-with-resources 只关了结果集而连接没释放。解决:检查所有 DAO 方法,确保连接关闭;用DBUtil统一管理,finally块或 try-with-resources 里关闭Connection。我在拆源码时习惯先全局搜一下new Connection,看关闭逻辑是否成对,这一招能筛掉大半有连接泄漏嫌疑的 Demo。

5. 二次开发改造思路:三处值得动手的地方

5.1 商品列表加分页和模糊搜索

现在的ProductDao.findAll()是一把梭全查出来,商品过百之后首页加载就开始变慢。改成分页很简单,给 DAO 加一个findByPage(int pageNum, int pageSize),SQL 改成LIMIT ?, ?,第一个参数是偏移量(pageNum - 1) * pageSize,第二个是每页条数。再配一个count()方法查总记录数,页面上渲染页码。搜索框可以在ProductListServlet里接收keyword参数,SQL 加WHERE name LIKE ?,注意%通配符要拼在参数值里而不是 SQL 里,防止 SQL 注入。

5.2 给受限接口加统一登录校验

现有LoginFilter只拦了购物车和订单路径,如果新加了「我的订单」页面,容易忘记加拦截配置。我一般会把拦截规则收敛到web.xml里统一定义:把需要登录的 URL 前缀列成字符串数组,过滤器里做startsWith判断。改造时还可以加一层角色判断,比如管理员访问/admin/*拦截后检查session.getAttribute("role"),这样可以一个过滤器支持多级权限,比在每个 Servlet 里复制粘贴登录校验代码好维护得多。

5.3 购物车从 Session 迁到 Redis

Session 购物车最大的毛病是重启即丢,而且多台 Tomcat 做负载均衡时 Session 不共享,用户在这台机器加了购物车,下个请求被转发到另一台机器就空了。迁到 Redis 的方案是按用户维度存购物车,key 设计成cart:userId,value 用 JSON 序列化后的购物车条目列表;用户未登录时购物车可以先挂在临时 key 上,登录后合并。改造时CartServlet里的增删改逻辑不变,只是把session.getAttribute("cart")换成redis.get("cart:" + userId),序列化工具用 fastjson 或 Jackson 都行。迁移的收益不只是数据不丢,结算模块还能顺手把 Redis 里的购物车读出来做库存预扣。

说回这份源码本身。我拿到手的第一件事不是看业务代码,而是先把DBUtil、web.xml、db.properties、所有 DAO 的关闭逻辑过一遍,这套跑通了再谈加功能。从那以后我每次拆 Servlet 购物车项目都强制走这个习惯:先跑通链路,再查资源释放,最后才动业务代码。这份源码刚好是你要找的 java 购物车 Demo,按第 4 章的流程完整跑一遍,跑通了再动手改,你会比直接读代码收获大得多。希望帮到你。

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

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进