ARTICLE · INTELLIGENCE

战地情报 · 详情页

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

JavaWeb简易购物车实战:Servlet+JSP+JDBC完整实现与避坑指南

JavaWeb简易购物车实战:Servlet+JSP+JDBC完整实现与避坑指南

简介:这份JavaWeb简易购物车源代码面向正在学习JavaWeb开发、希望打通完整业务流程的初学者与进阶者,围绕在线购物车这一典型场景,串联Servlet与JSP、HTTP请求处理、Session会话管理、JDBC数据库交互、MVC分层设计以及JSTL与EL表达式等核心知识点,帮助读者把零散概念落到可运行的项目中。压缩包共54个文件,约1.58MB,以14个java源文件与对应class文件为主体,辅以6个jsp页面、xml与properties配置、jar依赖及css样式等,并附开发文档、项目安装说明与数据库笔记,便于对照理解各功能实现逻辑。目前已有4130人学习下载。读者可从中获得一套结构完整的购物车示例代码、数据库设计与关键代码讲解笔记,以及环境配置与部署运行的说明,适合作为课程设计参考或自学练手素材,用于理解商品增删改查、购物车金额统计与异常处理等实现思路。

1. 从一份“能跑起来的购物车”说起

很多同学学完 Servlet 和 JSP,教材上的例子能看懂,一到自己动手就卡壳:商品列表怎么渲染、加购数量怎么累加、结算时怎么防止重复提交,全是没写过就想不到的细节。这份 javaWeb 简易购物车源代码,正好补上这段空白——它用最朴素的 Servlet + JSP + JDBC 组合,把“浏览商品、加入购物车、修改数量、删除条目、模拟下单”这条主链路完整走了一遍,没有 Spring 全家桶,也没有前端框架,打开 IDE 就能顺着请求一路读到底。适合刚学完 JavaWeb 基础、想找一个能跑通、能改动的 java购物车 练手项目的开发者,也适合需要给课程设计找个干净起点的同学。它不追求功能多,追求的是每一层都看得见、改得动。

2. 项目骨架与技术选型:为什么是 Servlet + JSP + JDBC

2.1 技术栈拆解与选型逻辑

这个项目的技术栈非常克制,基本就是 JavaWeb 的“老三样”:

层次技术作用
表现层JSP + JSTL渲染商品列表、购物车页面
控制层Servlet接收请求、调度业务、转发视图
业务层普通 Java 类(Service)购物车增删改查逻辑
数据层JDBC + MySQL商品与订单持久化
会话HttpSession保存当前用户的购物车

选这套组合的原因很直接:学习成本低、依赖少、调试路径短。用 Spring MVC 当然更“工程化”,但对刚接触 Web 的开发者来说,多一层容器、多一层注解,反而把“一个 HTTP 请求到底怎么变成页面”这件事遮住了。这里每个请求从web.xml或注解映射进 Servlet,到 Service 处理,再到 JSP 输出,链路是裸露的,出问题一眼能定位。

常见做法是把购物车数据放在 Session 里而不是每次查库,这个项目也是这么处理的——购物车本质是“临时状态”,没必要每加一件商品就写一次数据库,结算时才落订单表。这个取舍在后面改造章节还会展开。

2.2 目录结构长什么样

一个典型的 Maven Web 工程结构大致如下(不同 IDE 生成会略有差异,但核心目录一致):

shopping-cart/ ├── pom.xml ├── src/ │ ├── main/ │ │ ├── java/ │ │ │ └── com/example/cart/ │ │ │ ├── controller/ # Servlet 层 │ │ │ │ ├── ProductServlet.java │ │ │ │ └── CartServlet.java │ │ │ ├── service/ # 业务逻辑 │ │ │ │ ├── ProductService.java │ │ │ │ └── CartService.java │ │ │ ├── dao/ # 数据访问 │ │ │ │ ├── ProductDao.java │ │ │ │ └── OrderDao.java │ │ │ ├── model/ # 实体类 │ │ │ │ ├── Product.java │ │ │ │ ├── CartItem.java │ │ │ │ └── Order.java │ │ │ └── util/ │ │ │ └── DBUtil.java # 数据库连接工具 │ │ ├── resources/ │ │ │ └── db.properties # 数据库配置 │ │ └── webapp/ │ │ ├── WEB-INF/ │ │ │ └── web.xml │ │ ├── index.jsp # 商品列表页 │ │ ├── cart.jsp # 购物车页 │ │ └── order-success.jsp # 下单成功页 └── sql/ └── init.sql # 建表与初始数据

这个分层不是摆设。controller只负责取参数、调 Service、选视图;service管业务规则(比如数量不能为负、库存校验);dao只管 SQL。新手最容易犯的错是把 SQL 直接写进 Servlet,页面一多就变成一锅粥,这个结构正好给出一个可模仿的边界。

2.3 数据库表设计

项目至少需要两张核心表:商品表和订单表。购物车本身不落库,存在 Session 里。建表脚本大致如下:

-- 商品表 CREATE TABLE 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(500) ); -- 订单表 CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT, total_price DECIMAL(10,2) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 订单明细表 CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, product_id INT NOT NULL, quantity INT NOT NULL, price DECIMAL(10,2) NOT NULL );

price用DECIMAL而不是FLOAT,是金额字段的基本纪律,浮点误差在结算时会被放大。order_item单独存一份下单时的价格快照,是因为商品价格后续可能变动,订单必须保留成交时的价格,这个细节很多简易项目会漏掉,后面改造时值得留意。

3. 核心链路拆解:一个加购请求是怎么走完的

3.1 商品列表的渲染

首页index.jsp通过一个 Servlet 拿到商品列表再转发渲染。控制层代码大致是这样:

@WebServlet("/products") public class ProductServlet extends HttpServlet { private final ProductService productService = new ProductService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 查询全部商品,交给 JSP 渲染 List<Product> products = productService.listAll(); req.setAttribute("products", products); req.getRequestDispatcher("/index.jsp").forward(req, resp); } }

这里用forward而不是sendRedirect,是因为要把products这个 request 域属性带到 JSP。如果改成重定向,属性就丢了,页面会渲染成空列表——这是新手排查“页面没数据”时第一个要看的点。ProductService.listAll()内部调用ProductDao,DAO 里就是一条SELECT * FROM product,用PreparedStatement执行后逐行映射成Product对象。

JSP 侧用 JSTL 的forEach遍历:

<c:forEach var="p" items="${products}"> <div class="product"> <h3>${p.name}</h3> <p>价格:${p.price}</p> <a href="cart?action=add&id=${p.id}">加入购物车</a> </div> </c:forEach>

${p.price}直接输出,金额格式化可以在后端用BigDecimal处理,也可以在 JSP 用fmt:formatNumber,项目里通常选后者,视图层负责展示格式。

3.2 加入购物车:Session 里的 Map 结构

购物车的核心数据结构是Map<Integer, CartItem>,key 是商品 ID,value 是购物车条目(商品信息 + 数量)。这样加购时判断“是否已存在”是 O(1),比用 List 遍历查找干净得多。

@WebServlet("/cart") public class CartServlet extends HttpServlet { private final ProductService productService = new ProductService(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String action = req.getParameter("action"); HttpSession session = req.getSession(); // 从 Session 取出购物车,没有就新建 Map<Integer, CartItem> cart = (Map<Integer, CartItem>) session.getAttribute("cart"); if (cart == null) { cart = new LinkedHashMap<>(); session.setAttribute("cart", cart); } if ("add".equals(action)) { int productId = Integer.parseInt(req.getParameter("id")); CartItem item = cart.get(productId); if (item == null) { Product p = productService.findById(productId); cart.put(productId, new CartItem(p, 1)); } else { item.setQuantity(item.getQuantity() + 1); } } else if ("remove".equals(action)) { int productId = Integer.parseInt(req.getParameter("id")); cart.remove(productId); } else if ("update".equals(action)) { int productId = Integer.parseInt(req.getParameter("id")); int qty = Integer.parseInt(req.getParameter("qty")); CartItem item = cart.get(productId); if (item != null && qty > 0) { item.setQuantity(qty); } } resp.sendRedirect(req.getContextPath() + "/cart.jsp"); } }

几个关键点值得说清楚。第一,用LinkedHashMap而不是HashMap,是为了让购物车条目按加入顺序展示,用户体验上更自然。第二,加购后走sendRedirect而不是forward,这是 PRG(Post-Redirect-Get)模式的简化版——刷新页面不会重复加购,避免“刷新一次多一件”的经典翻车。第三,qty > 0的校验放在这里,但更严谨的做法是同时在 Service 层再校验一次,因为 Servlet 可能被多个入口调用。

CartItem这个类很轻:

public class CartItem { private Product product; private int quantity; public BigDecimal getSubtotal() { return product.getPrice().multiply(BigDecimal.valueOf(quantity)); } // getter / setter 省略 }

getSubtotal()用BigDecimal.multiply计算小计,避免在 JSP 里做浮点乘法。购物车总价则是遍历cart.values()累加,同样用BigDecimal.add。

3.3 结算下单:事务与库存扣减

结算时把 Session 里的购物车转成订单,这一步涉及多表写入,必须放在一个事务里:

public class OrderService { public int checkout(Map<Integer, CartItem> cart, int userId) throws SQLException { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 BigDecimal total = BigDecimal.ZERO; for (CartItem item : cart.values()) { total = total.add(item.getSubtotal()); } // 1. 写订单主表 int orderId = OrderDao.insertOrder(conn, userId, total); // 2. 写订单明细,同时扣库存 for (CartItem item : cart.values()) { OrderDao.insertOrderItem(conn, orderId, item); int affected = ProductDao.decreaseStock( conn, item.getProduct().getId(), item.getQuantity()); if (affected == 0) { throw new SQLException("库存不足:" + item.getProduct().getName()); } } conn.commit(); return orderId; } catch (SQLException e) { if (conn != null) conn.rollback(); throw e; } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } } }

decreaseStock的 SQL 要带条件,这是防超卖的关键:

UPDATE product SET stock = stock - ? WHERE id = ? AND stock >= ?

如果affected == 0,说明库存不够,抛异常触发回滚,整个订单作废。这个“条件更新 + 检查影响行数”的写法,比先查库存再更新要可靠,因为中间没有时间窗口。下单成功后清空 Session 里的购物车,重定向到成功页。

4. 跑起来:环境准备与启动步骤

4.1 环境与依赖清单

跑这个项目需要:JDK 8 或以上、Maven 3.6+、MySQL 5.7/8.0、一个 Servlet 容器(Tomcat 8.5/9.0 最稳)。pom.xml里核心依赖就三块:javax.servlet-api(provided 作用域)、jstl、mysql-connector-java。如果项目用的是javax.*包名,就别配 Tomcat 10,因为 Tomcat 10 起包名换成了jakarta.*,Servlet 类会直接加载失败——这是最容易踩的版本坑。

数据库配置放在db.properties:

jdbc.url=jdbc:mysql://localhost:3306/shopping_cart?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 jdbc.username=root jdbc.password=your_password jdbc.driver=com.mysql.cj.jdbc.Driver

serverTimezone不配的话,MySQL 8 驱动可能报时区异常;characterEncoding=utf8不配,中文商品名容易变问号。DBUtil里用静态块加载驱动、用DriverManager拿连接,简单项目够用,连接池可以后续再加。

4.2 建库、导数据、启动

步骤按顺序走:

  1. 在 MySQL 里执行sql/init.sql,建库建表并插入几条测试商品;
  2. 修改db.properties里的账号密码;
  3. 命令行执行mvn clean package,生成target/*.war;
  4. 把 war 丢进 Tomcat 的webapps目录,启动 Tomcat;
  5. 浏览器访问http://localhost:8080/shopping-cart/products。

如果不想打 war,在 IDE 里配一个 Tomcat Server,把项目加进去直接 Run 也行,改代码热部署更快。启动后先访问商品列表,确认数据能出来,再点加购,看购物车页数量是否正确,最后走一遍结算,检查orders和order_item两张表有没有数据、product.stock有没有减少。这条验证路径走通,说明环境没问题。

提示:如果页面 404,先看访问路径里的 context path 是否和 war 包名一致;如果 500 且堆栈指向ClassNotFoundException,多半是 JDBC 驱动没打进WEB-INF/lib,检查 Maven 依赖作用域。

5. 避坑与排查:几个真实会卡住人的地方

5.1 中文乱码:三个位置都要管

现象是商品名或表单提交的中文变成乱码。原因通常出在三处:JSP 页面没声明pageEncoding="UTF-8"、请求体没设request.setCharacterEncoding("UTF-8")、数据库连接串缺characterEncoding=utf8。解决方式是三处一起补:JSP 顶部加<%@ page contentType="text/html;charset=UTF-8" %>,在读取参数前调用setCharacterEncoding,连接串补上编码参数。只改一处往往还是乱,因为乱码可能发生在任意一环。

5.2 刷新重复加购

现象是加购后按 F5,购物车数量又加一。原因是加购走的是 GET 请求且直接 forward 回页面,浏览器刷新会重发上一次请求。解决办法就是前面代码里的sendRedirect,让加购请求结束后重定向到购物车页,刷新只会重新 GET 购物车页,不会重复触发加购逻辑。更规范的做法是加购用 POST,但简易项目里 GET + 重定向已经能挡住大部分误操作。

5.3 Session 丢失导致购物车清空

现象是重启 Tomcat 后购物车空了,或者换个浏览器就看不到之前的购物车。这不是 bug,是 Session 的机制:默认存在内存里,重启即失效;Session 靠 Cookie 里的 JSESSIONID 关联,换浏览器自然对不上。如果确实需要持久化购物车,得把Map序列化存库或存 Redis,这属于改造范畴。排查时先确认是不是重启或换端导致的,别急着怀疑代码。

5.4 库存扣成负数

现象是并发下单后stock出现负值。原因是用了“先查再减”的写法,两个请求同时查到库存为 1,都判断够,然后都执行减法。解决方式就是 3.3 里的条件更新WHERE stock >= ?加影响行数判断,把判断和扣减合成一条原子 SQL。这个坑在单机测试时几乎复现不了,一上并发就暴露,属于典型的“测试环境好好的,上线就出事”。

5.5 Tomcat 10 启动报类找不到

现象是javax.servlet相关类全部ClassNotFoundException。原因是 Tomcat 10 把 Servlet API 迁到了jakarta.*命名空间,而项目依赖还是javax.*。解决方式二选一:换 Tomcat 9 及以下,或者把依赖和 import 全部迁到jakarta.*。新手建议直接选 Tomcat 9,省去一轮迁移。

6. 二次开发:从“能跑”到“能改”的几个落点

拿到这份代码,真正有价值的是在它上面做增量。第一个改造点是购物车持久化:把Map从 Session 挪到数据库,建一张cart_item表,按user_id存,这样换设备、重启都不丢。改造时注意加购逻辑要从“读 Session”改成“先查库再 upsert”,并发下用唯一索引(user_id, product_id)兜底。

第二个改造点是引入连接池。现在DBUtil每次DriverManager.getConnection,请求一多就慢。换成 HikariCP 只需要改DBUtil一个类,对外接口不变,这是分层带来的好处。配置大致是:

private static final HikariDataSource DS = new HikariDataSource(); static { DS.setJdbcUrl(PropUtil.get("jdbc.url")); DS.setUsername(PropUtil.get("jdbc.username")); DS.setPassword(PropUtil.get("jdbc.password")); DS.setMaximumPoolSize(10); } public static Connection getConnection() throws SQLException { return DS.getConnection(); }

第三个改造点是给 Servlet 加一层统一的字符编码 Filter 和登录拦截 Filter,把散落在各个 Servlet 里的重复代码收拢。Filter 的doFilter里先setCharacterEncoding,再判断登录态,未登录跳登录页,已登录放行。这样新增页面时不用每个都记得写编码设置。

验证改造是否成功,我一般会走一条固定回归路径:清空数据库 → 导初始数据 → 加购三件不同商品 → 改其中一件数量 → 删一件 → 结算 → 查订单表和库存表 → 重启容器再走一遍。这条路径能覆盖 Session、事务、库存、重定向几个关键点。

说个自己的教训:早些年改这类项目,我总喜欢先动 JSP 样式,结果业务逻辑没验证就改视图,出了问题分不清是样式还是数据。从那以后我每次接手一个能跑的 Demo,都强制先把上面那条回归路径跑一遍、记下基线结果,再动第一行代码。希望这个习惯也能帮到你。

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

RELATED READING

延伸阅读

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