
简介这是一套基于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 和数量两个字段商品的其他信息仍然以数据库为准。为什么不是 CookieCookie 也存在浏览器端看似也能存购物车但 Cookie 的容量限制只有 4KB 左右而一个购物车里动辄几十件商品的 Id 列表很容易撑爆这个上限。更重要的是 Cookie 里的数据是明文的用户可以随意篡改。Session 把数据留在服务端客户端只拿一个看不见内容物的会话 ID安全性和容量都更合适。简易购物车用 Session是成本和安全性之间的平衡点不是没考虑过其他方案。还有一个实操上的好处调试直观。你用 Servlet 处理加购请求时直接打印 session.getAttribute(cart) 就能看到当前购物车的内容不用连数据库执行 select。对于还没把调试工具链玩熟的初学者这个「黑匣子」能被直接看到内部状态排查问题的成本低得多。2.2 职责拆解把 JSP、Servlet、JDBC 各放对位置一个可以交差的简易购物车项目至少要分出四层职责但每层都很薄。第一层是 JSP 页面负责展示商品列表和购物车内容第二层是 Servlet负责接收请求、调用业务方法、决定跳转方向第三层是一个购物车相关的 JavaBeanCartItem 和 Cart封装购物车的存储结构第四层是商品数据的 JDBC 访问代码负责把商品表里的记录查出来。不少人会把 JDBC 代码直接写进 Servlet 的 doGet 方法里这种做法在小项目里能跑但会让你后面改数据库连接配置时到处找代码。我一般会单独建一个类哪怕这个类只有一个静态方法也要和 Servlet 分开。同理JSP 页面里不要出现 Java 代码片段。用 EL 表达式读购物车数据用 JSTL 的 c:forEach 遍历商品列表这两样东西是 JavaWeb 入门阶段必须顺手学会的——它们能让你把页面代码控制在二十行以内。关于版本选择Servlet 可以用 3.1 或 4.0JSP 用 2.3 以上JDK 用 8 或 11Tomcat 用 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 groupIdjavax.servlet/groupId artifactIdjavax.servlet-api/artifactId version4.0.1/version scopeprovided/scope /dependency dependency groupIdjavax.servlet.jsp/groupId artifactIdjavax.servlet.jsp-api/artifactId version2.3.3/version scopeprovided/scope /dependency dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.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 groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependencyMaven 配好后在项目根目录执行 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 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; 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 类读取配置、获取连接。连接串里必须带上 useUnicodetruecharacterEncodingutf8否则中文参数往数据库里写会乱码。时区参数 serverTimezoneAsia/Shanghai 在 MySQL 8.x 下是必须的不加会报时区错误。Class.forName(com.mysql.cj.jdbc.Driver); String url jdbc:mysql://localhost:3306/shopdb?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/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它只负责持有一个 MapInteger, CartItem。加购时判断 map 里有没有同一个 id有就把 count 加 1没有就 new 一个 CartItem 塞进去。把业务放在这个类里Servlet 就只做参数解析和跳转两件事后期如果想换成 Redis 存储或其他方式只需要替换 Cart 的实现控制层可以不动。public class Cart { private MapInteger, 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 ListCartItem 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 JSTLc:forEach items${sessionScope.cart.list()} varitem tr td${item.name}/td td${item.price}/td td a hrefcart?actionupdateid${item.id}count1/a ${item.count} a hrefcart?actionupdateid${item.id}count-1-/a /td td${item.subtotal}/td tda hrefcart?actiondeleteid${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 配置里加上 URIEncodingUTF-8。代码层面最省事的做法是写一个 Filter在 doFilter 开头设置 req.setCharacterEncoding(UTF-8)然后 chain.doFilter 放行。注意设置编码动作必须发生在读取任何参数之前否则已解码的参数无法回头修正。同时JSP 页面顶部写 pageEncoding“UTF-8”数据库连接串里带 characterEncodingutf8三层一起到位才算是彻底解决。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 没有重写导致 MapInteger, CartItem 的 key 是商品 idInteger 包装类型时不存在重复问题但如果你把 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 之间的关系。如果你照着上面的代码跑通了建议你试着改一个功能来验证自己是否真理解把「加入购物车」改成「商品详情页内选择数量后加入」看看参数传递和数量叠加逻辑会出现哪些新问题。这个改动不复杂但能考验你对整个请求链路的掌握程度。希望帮到你。本文还有配套的精品资源点击获取