news 2026/10/17 11:51:38

基于Servlet+JSP的MVC购物车完整实现实战解析

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
基于Servlet+JSP的MVC购物车完整实现实战解析

接到一个练手需求:用MVC架构实现一个简单的购物车。这个命题在培训班和面试准备里出现频率相当高,看着平平无奇,可真正要做到分层清晰、代码干净,其实挺能检验基本功。这篇文章就拿一个基于Servlet + JSP的完整实例,带着把购物车从需求分析、模型设计、控制器转发到页面渲染一步一步落起来,全程代码可直接复制,注释和坑都标好了。适合刚学完Java Web想找一个综合练手项目的人,也适合那些明明会写Servlet却经常搞不清Controller、Model、View边界到底画在哪的开发同学。

1. 从需求到骨架:先拆解购物车,再谈MVC

1.1 购物车需求没你想的那么简单

很多人拿到“购物车”第一反应是:不就是add、delete、列表嘛。真上手才发现,需求背后藏着一堆细节决策。比如用户点击“加入购物车”时,页面要传什么参数?商品重复添加时是新建条目还是累加数量?用户改数量时要不要校验上限?删除某项后要不要回收库存?这些节点没有产品经理帮你拍板的时候,就得自己先把功能清单列出来。

一个最小可用的购物车,至少要覆盖这些动作:

功能涉及页面需要参数
加入购物车商品列表/商品详情productId、productName、price、quantity
查看购物车购物车页面无
修改某项数量购物车页面action=update、productId、quantity
删除某一项购物车页面action=remove、productId
清空购物车购物车页面action=clear
统计总件数、总金额购物车页面无

这里要特别提醒一个容易被新手忽略的设计点:同一个商品连续添加两次,到底算两个条目还是同一个条目数量叠加?正常电商都是叠加。这个决策直接决定了模型层的数据结构——如果你用List存每个条目,每次添加都要线性遍历找同款商品;如果用Map以productId为key,就能直接命中。后面代码部分我会展开讲,这里先记住“需要幂等合并”这个约束。

1.2 为什么非要用MVC,而不是直接堆JSP

有人可能问,我一个购物车用两个JSP页面,页面里嵌套Java代码,不也能跑吗?恰恰是这种“能跑就行”的写法,会埋下后面所有维护麻烦的种子。想象一下:业务逻辑写在JSP里,意味着看到一个页面你要同时理解HTML结构、Java判断、SQL拼接;改一行样式可能碰坏一段业务代码;测试时没有浏览器根本没法验证逻辑;两个人并行开发一个页面,冲突能打到怀疑人生。

MVC的本质是把一次用户请求拆成三个角色:Model负责数据和业务规则,View负责展示,Controller负责接收请求、调度Model、选择View。我常用的类比是餐厅:顾客(浏览器)点菜,服务生(Controller)记录菜单并传达给后厨,后厨(Model)备菜炒菜,菜品由服务生端回给顾客(View)。如果顾客直接跑进后厨自己炒菜,那就是把业务逻辑写在JSP里的原始状态。

用MVC后最直观的好处有三点。第一,代码可测试性上来,Model层的加减商品逻辑用纯Java单元测试就能验证,不需要启动Tomcat。第二,多人协作边界清晰,一个人写Java类,一个人调页面,互相不阻塞。第三,未来换技术栈时,比如把JSP换成Thymeleaf甚至前后端分离的JSON接口,Controller和Model基本不用动,只换View层。对于练手项目来说,这三条足够说服你老老实实按MVC来写。

2. 方案选型与目录结构:购物车数据到底放哪里

2.1 三种存储方案对比:Session、Cookie、数据库

购物车数据的特点是多变、临时、与用户会话强相关,但也有跨会话持久化的需求。初学者最容易纠结的就是数据应该存在哪里。我把几种常见方案拉出来对比一遍。

存储位置存活周期优点缺点适用场景
Session用户会话期间,默认约30分钟服务端存储安全、上手简单、天然区分用户内存占用随用户量增长、重启丢失、分布式环境要额外共享方案未登录状态的临时购物车、练手项目首选
Cookie可设定几小时到几年不占服务端内存、跨重启保留存在客户端可被篡改、容量小、敏感信息不适合匿名用户的“记住我”、轻量数据
数据库持久化可永久保存、可跨设备同步、便于做订单分析需要建表、需要关联用户、读写开销更大登录用户购物车、完整电商系统

做“简单的购物车”,我的建议是先用Session。理由不复杂:购物车的本质是“当前浏览器会话内的一堆临时条目”,Session按浏览器隔离,天生合适。而且不涉及用户登录体系,省掉建表、关联用户、合并逻辑这些额外负担。等以后做到登录功能,再演进成“用户表 + 购物车表 + 商品表”的持久化方案也不迟。设计的时候可以在Cart类上先预留一个跨会话合并的思路,但现阶段别过度设计。

2.2 MVC三层的职责划分与一次请求的完整流转

选定了Session存放购物车,接下来要明确三层各自管什么。Controller层只做三件事:拿参数、调模型、跳视图。它不该知道购物车内部怎么存条目,也不该直接拼接HTML。Model层负责购物车自身逻辑:加商品、合并数量、算总价。View层只做展示,从Session里取出购物车对象,遍历打印,一点业务都不能有。

一次“加入购物车”请求的完整流转是这样的:

  1. 用户在商品页点击链接,请求发送到CartServlet,地址类似/cart?action=add&productId=1&productName=卫衣&price=199.00&quantity=1。
  2. Tomcat根据注解或web.xml的映射找到对应的Servlet,调用doGet方法。
  3. Controller从request中读取所有参数,从session中取出Cart对象,没有则新建并放入session。
  4. Controller调用cart.add(...),把参数交给Model层的Cart处理。
  5. add方法内部完成Map的合并、数量累加逻辑。
  6. Controller通过sendRedirect("cart.jsp")把请求重定向到视图。
  7. cart.jsp从sessionScope中取出cart,用JSTL循环输出表格,展示总价。

这个流程里有个细节值得琢磨:为什么是重定向而不是请求转发?我在3.2节会详细解释,简单说就是防刷新重复提交。

2.3 项目骨架与技术栈准备

环境方面我用的是一套非常常见轻量的组合:JDK 8+、Tomcat 8.5+、IDEA。技术栈刻意停留在Servlet + JSP + JSTL,不引入Spring这类重型框架。原因是MVC这个概念最初就是从Servlet/JSP这类原生技术中提炼出来的,把原生Servlet跑通,才能真正理解框架帮你做了什么事。以后再碰Spring MVC,你会发现其实只是把Controller层的样板代码自动化了。

项目目录结构按MVC分层组织如下:

src/main/java/com/demo/cart ├── model │ ├── Cart.java │ └── CartItem.java └── web └── CartServlet.java src/main/webapp ├── cart.jsp └── product.jsp

webapp根目录放页面文件,Java代码按model和web分包,这个结构就算没有框架,维护者也一眼能看出哪层对应哪层。如果你用Maven创建项目,记得在pom.xml里加入Servlet API和JSTL的依赖,注意Servlet API的scope要设为provided,因为Tomcat本身已经带了实现,打war包时不要重复打包。

3. 核心代码实操:手写Model、Controller、View三层

3.1 模型层:CartItem和Cart的设计与参数说明

模型层是整个购物车的灵魂,设计得好不好直接决定后续Controller和View的复杂度。

先看单个条目对象CartItem。它承载一行购物车记录的所有信息。

package com.demo.cart.model; public class CartItem { private String productId; private String productName; private double price; private int quantity; public CartItem() { } public CartItem(String productId, String productName, double price, int quantity) { this.productId = productId; this.productName = productName; this.price = price; this.quantity = quantity; } public double getSubtotal() { return this.price * this.quantity; } // getter / setter 省略 }

这里注意几个设计点。productId用String而不是int,因为很多电商系统的商品ID可能带前缀字符,String兼容性更强。getSubtotal是一个计算属性,没有对应的成员变量,好处是整个购物车里只有一处计算小计的代码,避免视图层各自算导致结果不一致。价格先用double是为了演示,但4.3节我会指出double在金额计算里的坑,真做订单系统要换成以“分”为单位的整数。

接着看购物车容器Cart。核心是内部存储用Map而不是List。

package com.demo.cart.model; import java.io.Serializable; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; public class Cart implements Serializable { private Map<String, CartItem> items = new HashMap<>(); public void add(String productId, String productName, double price, int quantity) { if (items.containsKey(productId)) { CartItem item = items.get(productId); item.setQuantity(item.getQuantity() + quantity); } else { items.put(productId, new CartItem(productId, productName, price, quantity)); } } public void updateQuantity(String productId, int quantity) { CartItem item = items.get(productId); if (item != null && quantity > 0) { item.setQuantity(quantity); } } public void remove(String productId) { items.remove(productId); } public void clear() { items.clear(); } public List<CartItem> getItems() { return new ArrayList<>(items.values()); } public int getTotalCount() { int total = 0; for (CartItem item : items.values()) { total += item.getQuantity(); } return total; } public double getTotalPrice() { double total = 0.0; for (CartItem item : items.values()) { total += item.getSubtotal(); } return total; } }

用Map最直接的好处是“按productId查找”从O(n)变成O(1)。add方法里先containsKey判断,存在就累加数量,不存在才新建条目,这正对应1.1节提到的“重复添加商品要合并数量”的需求约束。updateQuantity里有个防御判断:quantity小于等于0时忽略,避免前端传0或负数把数量改坏。getItems返回一个新的ArrayList包裹的value视图,而不是直接返回内部Map,这是封装性原则——不让外面的代码直接操作内部HashMap的键值对结构。

还有一个容易被忽略的细节:Cart实现了Serializable。因为Session可能被Tomcat持久化到磁盘,存放在Session里的对象最好都能序列化,否则极端情况下会抛NotSerializableException。这行代码属于写的时候没啥感觉、出线上问题才后悔的类型,先加上不亏。

3.2 控制器层:CartServlet的参数解析与逻辑分派

Controller是MVC里唯一和HTTP打交道的部分。我的习惯是只用一个Servlet处理购物车所有操作,通过action参数区分动作,这样路由规则简单集中,也方便新人理解“一个入口调度所有逻辑”的分层思想。

package com.demo.cart.web; import com.demo.cart.model.Cart; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpSession; import java.io.IOException; @WebServlet("/cart") public class CartServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { doPost(request, response); } @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String action = request.getParameter("action"); HttpSession session = request.getSession(); Cart cart = (Cart) session.getAttribute("cart"); if (cart == null) { cart = new Cart(); session.setAttribute("cart", cart); } if ("add".equals(action)) { String productId = request.getParameter("productId"); String productName = request.getParameter("productName"); double price = Double.parseDouble(request.getParameter("price")); int quantity = Integer.parseInt(request.getParameter("quantity")); cart.add(productId, productName, price, quantity); } else if ("update".equals(action)) { String productId = request.getParameter("productId"); int quantity = Integer.parseInt(request.getParameter("quantity")); cart.updateQuantity(productId, quantity); } else if ("remove".equals(action)) { String productId = request.getParameter("productId"); cart.remove(productId); } else if ("clear".equals(action)) { cart.clear(); } response.sendRedirect(request.getContextPath() + "/cart.jsp"); } }

这个Servlet有个看起来很不起眼但极其关键的写法:doGet里直接调用doPost。因为前端添加商品用的是链接,修改数量用的是表单,一个GET一个POST,如果只在doPost里写逻辑,点链接就会405。合并处理让GET和POST走同一条业务路径,是偷懒,也是常见的务实写法。

再解释一下为什么用sendRedirect而不是forward。假设你是用forward转发到cart.jsp,浏览器地址栏会停留在/cart?action=add&productId=1,这时候用户按F5刷新,浏览器会重新发送这个GET请求,等同于又执行一次add,商品数量直接翻倍。用sendRedirect发出302响应,浏览器跳到新的地址/cart.jsp,刷新时只是重新请求页面,不会重复改动数据。这个小习惯属于“踩过一次坑就再也不想第二次”的关键经验。

3.3 视图层:用JSTL渲染购物车列表

视图层我直接用cart.jsp,从sessionScope里取出购物车数据渲染。这里强烈建议用JSTL标签而不是JSP脚本片段,脚本片段写在页面里,页面上中下全是<% %>,可读性极差,也不符合MVC里View只管展示的定位。

<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <html> <head> <title>购物车</title> </head> <body> <h3>我的购物车</h3> <c:choose> <c:when test="${empty sessionScope.cart.items}"> <p>购物车还是空的,去挑选几件喜欢的商品吧。</p> </c:when> <c:otherwise> <table border="1" cellpadding="6" cellspacing="0"> <tr> <th>商品名</th> <th>单价</th> <th>数量</th> <th>小计</th> <th>操作</th> </tr> <c:forEach items="${sessionScope.cart.items}" var="item"> <tr> <td>${item.productName}</td> <td>${item.price}</td> <td> <form action="${pageContext.request.contextPath}/cart" method="post"> <input type="hidden" name="action" value="update"> <input type="hidden" name="productId" value="${item.productId}"> <input type="text" name="quantity" value="${item.quantity}" size="2"> <button type="submit">更新</button> </form> </td> <td>${item.subtotal}</td> <td><a href="${pageContext.request.contextPath}/cart?action=remove&productId=${item.productId}">删除</a></td> </tr> </c:forEach> </table> <p>总件数:${sessionScope.cart.totalCount},总金额:<fmt:formatNumber value="${sessionScope.cart.totalPrice}" pattern="#0.00"/> 元</p> <a href="${pageContext.request.contextPath}/cart?action=clear">清空购物车</a> </c:otherwise> </c:choose> <p><a href="product.jsp">继续购物</a></p> </body> </html>

页面里有个细节:购物车为空时用c:choose判断并展示空状态,而不是直接输出一个空表格。这个空状态非常影响体验,很多新手只写循环不写空判断,结果购物车没商品时显示一排表头加一行空行,看着就很业余。

JSTL的EL表达式能从sessionScope里一路点出cart.items,遍历Map的values因为getItems返回的是List,所以c:forEach拿到的是CartItem对象,页面直接点item.productName、item.subtotal即可,完全不用写Java代码。fmt:formatNumber的pattern控制金额显示小数点后两位,这也是View层该做的事——格式化展示,不能让业务层掺和。

4. 功能细节与边界处理:数量修改、重复提交、价格精度

4.1 数量修改的两种交互方式

购物车页面里数量修改我采用了最简单可靠的方案:每个数量输入框包一个form,点“更新”按钮就提交表单。这个方案不需要任何JavaScript就能工作,逻辑直接、方便新人理解。缺点是每次点更新整个页面刷新,体验有一点点跳。

如果你希望页面不跳转,可以做一层渐进增强:给数量输入框绑定change事件,用fetch把action、productId、quantity提交到同一个CartServlet,然后局部更新小计和总价。核心逻辑不用变,Controller依然是接收action=update,只改View层的交互方式。这里我建议初学者先把表单方案跑通,再考虑异步,一步到位容易把人绕晕。

4.2 防止刷新后重复提交的常用处理

3.2节已经提过用重定向避免刷新重复添加,这是PRG模式(Post/Redirect/Get)在购物车场景里最典型的表现。除了PRG,还有更严格的防重手段:给表单加一个隐藏token,首次加载购物车页面时把token存入Session,提交时比对Session里的值,匹配则执行并作废旧token,不匹配则视为重复提交直接忽略。

对于“简单购物车”,PRG已经能覆盖绝大多数刷新、回退造成的重复操作。token方案一般用在支付、下单这类不允许二次提交的场景,关于token的坑也很有意思——如果你把token存Session里,用户开两个页面同时提交,后提交的会因为token被前面作废而失败,需要引导用户刷新页面重新获取token。这个具体业务自己权衡,通常下单页加token,购物车这种中间过程PRG就够。

4.3 金额计算的精度陷阱与正确姿势

用double算金额是购物车项目最容易埋雷的地方。跑一下这段代码就明白了:

System.out.println(0.1 + 0.2);

输出结果是0.30000000000000004,而不是0.3。这是因为计算机用二进制表达十进制小数时,0.1这类数字无法精确表示,只能存近似值。购物车单件商品看似没事,一旦叠加多件、多次计算,误差会累积。金额展示和计算出现“差一分钱”的问题,排查起来非常折磨。

推荐的做法是:内部所有金额计算用以“分”为单位的整数,比如199.00元存成19900。展示的时候再除以100转成元,并用fmt:formatNumber格式化。改造模型层也不复杂,CartItem里加一个int priceInCents字段,subtotal返回priceInCents * quantity,页面显示时自己除以100。如果嫌换算麻烦,也可以用BigDecimal,但有两个强制约定:必须用字符串构造new BigDecimal("0.1"),绝不能new BigDecimal(0.1);保证够日常精度时整数法最稳妥。

5. 常见问题与排查实录:掉Session、覆盖数据、中文乱码

5.1 刷新页面购物车就空了,Session为什么没保住

第一个高频问题:页面一刷新或者关掉浏览器再打开,购物车清空了。这是最正常的现象——Session默认活在服务端内存里,浏览器关闭后JSESSIONID失效,下一次访问Tomcat会生成新的会话。开发时还经常遇到另一种情况:IDEA里改了Java代码触发热部署,整个Context重启,内存里的Session跟着全丢,购物车瞬间变空。这两种都算不上bug,但如果生产环境出现“登录状态一会儿就掉”,就要检查Session配置。

默认Tomcat会话超时是30分钟,如果你希望购物车保持更久,可以在web.xml里配置:

<session-config> <session-timeout>60</session-timeout> </session-config>

另外要注意,Session依赖浏览器Cookie保存JSESSIONID,如果用户禁用Cookie,每次请求都是新会话。常规解决办法是用URL重写,但URL重写会把session标识暴露在地址栏,有安全隐患,一般只做兜底方案。排查这种问题时,先看一眼DevTools的Network面板,确认请求带没带JSESSIONID Cookie,基本就能定位一半问题。

5.2 添加同一商品后旧数据被覆盖

第二个经典问题:购物车里同一商品加了好几次,数量永远是最后一次提交的数量,而不是累加。我见过很多同学卡在这个bug上,代码长这样:

// 错误写法 CartItem item = new CartItem(productId, productName, price, quantity); items.put(productId, item);

第一次加入productId=1的商品后Map里是 {1: 卫衣x1},第二次再加时put方法直接把key为1的旧值顶掉了,变成 {1: 卫衣x1}——数量不是2,还是1。原因是Map的put天然是覆盖语义,你要么先get判断,要么像3.1节的写法那样containsKey判断后累加。这类问题的排查思路也很固定:在add方法里加一行日志,把productId和quantity打出来,一看就知道是逻辑没进合并分支。

5.3 中文乱码从哪里来,如何一次性解决

购物车涉及商品名、页面展示、表单提交,中文乱码的坑几乎一定会遇到。乱码分来源,要分别处理。

POST请求的乱码:在doPost最前面加request.setCharacterEncoding("UTF-8"),而且必须在第一次读取request参数之前设置,放晚了没用。

GET请求的乱码:Tomcat 8.5默认URI编码已经改成UTF-8,一般不会乱。但如果你部署到老版本Tomcat,或者手动改过配置文件,就可能出现UTF-8请求参数乱码,需要检查server.xml里Connector的URIEncoding是否设置。

响应和页面的乱码:JSP页面顶部要同时写contentType="text/html;charset=UTF-8",最好再加pageEncoding="UTF-8";Servlet如果直接输出内容,要设置response.setContentType("text/html;charset=UTF-8")。响应的编码和请求的编码经常被并在一起说,其实一个管输入一个管输出,两个都要管。

还有个容易被忽略的是数据库连接字串的编码设置,等购物车演进成持久化方案后,JDBC连接URL要带useUnicode=true&characterEncoding=UTF-8,否则入库直接变问号。

结尾

我自己当年就是拿这个项目练手把Servlet摸透的。当时最大的体会是:搞懂MVC不能靠背概念,靠的是亲手把一次请求从浏览器走到Controller、再落到Model、最后渲染回页面的完整链路走一遍。现在再回头看这个购物车,有个小技巧想分享给你:开发阶段把Cart对象加了什么、删了什么、操作后购物车里还剩几条,都用日志打印出来,配合Session调试,数据流动的整个过程就非常直观,定位bug的速度能快好几倍。如果后续想继续扩展,可以把Session购物车升级成数据库表,也可以加登录后合并匿名购物车的逻辑,但不管加什么,记住一点——保持分层清晰,别让代码越改越乱。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 11:51:28

Day6编程练习复盘:滑动窗口、快慢指针与层序遍历实战

第六天。说实话&#xff0c;能连续刷题六天&#xff0c;比我预想的要难不少。第一天打开题库&#xff0c;连题目描述里的术语都要反复查&#xff1b;到了今天&#xff0c;至少看到中等难度的数组题、链表题、二叉树题&#xff0c;心里已经能快速判断“这题大概考什么套路”。这…

作者头像 李华
网站建设 2026/10/17 11:50:58

微服务商城多角色登录注册实战:JWT+网关统一鉴权方案

要是一个商城系统&#xff0c;用户进来看不到商品不重要&#xff0c;第一道门就是注册和登录。单机项目里用Session存一下、写个过滤器拦一下就完了&#xff1b;可一旦拆成微服务&#xff0c;问题立马变味儿&#xff1a;三个角色账号、多个服务独立部署、状态怎么共享、商家入驻…

作者头像 李华
网站建设 2026/10/17 11:50:24

vue-draggable实战:三级树形结构拖拽排序方案全解

拖拽排序在后台管理项目里几乎是个躲不掉的活儿&#xff0c;尤其是三级甚至更多层级的菜单树、分类树、组织架构树&#xff0c;手写一套稳定的拖拽逻辑&#xff0c;远比想象中麻烦。早期我图省事&#xff0c;写过一版原生事件加逐层判断的排序功能&#xff0c;结果边界情况一堆…

作者头像 李华
网站建设 2026/10/17 11:49:56

AI原生团队搭建指南:10人如何干出40人的活

前阵子跟一位做电商代运营的朋友吃饭&#xff0c;他提了个让我印象特别深的问题&#xff1a;团队最鼎盛的时候四十多号人&#xff0c;业务没怎么涨&#xff0c;人却越来越不够用&#xff0c;每天光协调和审批就能耗掉半天。后来他狠下心裁到十二个人&#xff0c;营收反而比之前…

作者头像 李华
网站建设 2026/10/17 11:49:38

STM32F407上FreeRTOS与LwIP的tcpecho实战:从环境搭建到性能调优

简介&#xff1a;这份资源面向STM32嵌入式开发学习者与以太网应用开发者&#xff0c;聚焦STM32F407平台下FreeRTOS与LwIP协议栈的整合实践。工程基于CubeMX生成原始框架&#xff0c;外接LAN8742A PHY芯片&#xff0c;通过添加任务线程完成网络协议栈移植&#xff0c;最终实现pi…

作者头像 李华