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 JDBC2.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 渲染 ListProduct products productService.listAll(); req.setAttribute(products, products); req.getRequestDispatcher(/index.jsp).forward(req, resp); } }这里用forward而不是sendRedirect是因为要把products这个 request 域属性带到 JSP。如果改成重定向属性就丢了页面会渲染成空列表——这是新手排查“页面没数据”时第一个要看的点。ProductService.listAll()内部调用ProductDaoDAO 里就是一条SELECT * FROM product用PreparedStatement执行后逐行映射成Product对象。JSP 侧用 JSTL 的forEach遍历c:forEach varp items${products} div classproduct h3${p.name}/h3 p价格${p.price}/p a hrefcart?actionaddid${p.id}加入购物车/a /div /c:forEach${p.price}直接输出金额格式化可以在后端用BigDecimal处理也可以在 JSP 用fmt:formatNumber项目里通常选后者视图层负责展示格式。3.2 加入购物车Session 里的 Map 结构购物车的核心数据结构是MapInteger, CartItemkey 是商品 IDvalue 是购物车条目商品信息 数量。这样加购时判断“是否已存在”是 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 取出购物车没有就新建 MapInteger, CartItem cart (MapInteger, 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这是 PRGPost-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(MapInteger, 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-apiprovided 作用域、jstl、mysql-connector-java。如果项目用的是javax.*包名就别配 Tomcat 10因为 Tomcat 10 起包名换成了jakarta.*Servlet 类会直接加载失败——这是最容易踩的版本坑。数据库配置放在db.propertiesjdbc.urljdbc:mysql://localhost:3306/shopping_cart?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 jdbc.usernameroot jdbc.passwordyour_password jdbc.drivercom.mysql.cj.jdbc.DriverserverTimezone不配的话MySQL 8 驱动可能报时区异常characterEncodingutf8不配中文商品名容易变问号。DBUtil里用静态块加载驱动、用DriverManager拿连接简单项目够用连接池可以后续再加。4.2 建库、导数据、启动步骤按顺序走在 MySQL 里执行sql/init.sql建库建表并插入几条测试商品修改db.properties里的账号密码命令行执行mvn clean package生成target/*.war把 war 丢进 Tomcat 的webapps目录启动 Tomcat浏览器访问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 页面没声明pageEncodingUTF-8、请求体没设request.setCharacterEncoding(UTF-8)、数据库连接串缺characterEncodingutf8。解决方式是三处一起补JSP 顶部加% page contentTypetext/html;charsetUTF-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都强制先把上面那条回归路径跑一遍、记下基线结果再动第一行代码。希望这个习惯也能帮到你。本文还有配套的精品资源点击获取