基于Servlet+JSP的MVC购物车完整实现实战解析
接到一个练手需求用MVC架构实现一个简单的购物车。这个命题在培训班和面试准备里出现频率相当高看着平平无奇可真正要做到分层清晰、代码干净其实挺能检验基本功。这篇文章就拿一个基于Servlet JSP的完整实例带着把购物车从需求分析、模型设计、控制器转发到页面渲染一步一步落起来全程代码可直接复制注释和坑都标好了。适合刚学完Java Web想找一个综合练手项目的人也适合那些明明会写Servlet却经常搞不清Controller、Model、View边界到底画在哪的开发同学。1. 从需求到骨架先拆解购物车再谈MVC1.1 购物车需求没你想的那么简单很多人拿到“购物车”第一反应是不就是add、delete、列表嘛。真上手才发现需求背后藏着一堆细节决策。比如用户点击“加入购物车”时页面要传什么参数商品重复添加时是新建条目还是累加数量用户改数量时要不要校验上限删除某项后要不要回收库存这些节点没有产品经理帮你拍板的时候就得自己先把功能清单列出来。一个最小可用的购物车至少要覆盖这些动作功能涉及页面需要参数加入购物车商品列表/商品详情productId、productName、price、quantity查看购物车购物车页面无修改某项数量购物车页面actionupdate、productId、quantity删除某一项购物车页面actionremove、productId清空购物车购物车页面actionclear统计总件数、总金额购物车页面无这里要特别提醒一个容易被新手忽略的设计点同一个商品连续添加两次到底算两个条目还是同一个条目数量叠加正常电商都是叠加。这个决策直接决定了模型层的数据结构——如果你用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里取出购物车对象遍历打印一点业务都不能有。一次“加入购物车”请求的完整流转是这样的用户在商品页点击链接请求发送到CartServlet地址类似/cart?actionaddproductId1productName卫衣price199.00quantity1。Tomcat根据注解或web.xml的映射找到对应的Servlet调用doGet方法。Controller从request中读取所有参数从session中取出Cart对象没有则新建并放入session。Controller调用cart.add(...)把参数交给Model层的Cart处理。add方法内部完成Map的合并、数量累加逻辑。Controller通过sendRedirect(cart.jsp)把请求重定向到视图。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.jspwebapp根目录放页面文件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 MapString, 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 ListCartItem 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?actionaddproductId1这时候用户按F5刷新浏览器会重新发送这个GET请求等同于又执行一次add商品数量直接翻倍。用sendRedirect发出302响应浏览器跳到新的地址/cart.jsp刷新时只是重新请求页面不会重复改动数据。这个小习惯属于“踩过一次坑就再也不想第二次”的关键经验。3.3 视图层用JSTL渲染购物车列表视图层我直接用cart.jsp从sessionScope里取出购物车数据渲染。这里强烈建议用JSTL标签而不是JSP脚本片段脚本片段写在页面里页面上中下全是% %可读性极差也不符合MVC里View只管展示的定位。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://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 border1 cellpadding6 cellspacing0 tr th商品名/th th单价/th th数量/th th小计/th th操作/th /tr c:forEach items${sessionScope.cart.items} varitem tr td${item.productName}/td td${item.price}/td td form action${pageContext.request.contextPath}/cart methodpost input typehidden nameaction valueupdate input typehidden nameproductId value${item.productId} input typetext namequantity value${item.quantity} size2 button typesubmit更新/button /form /td td${item.subtotal}/td tda href${pageContext.request.contextPath}/cart?actionremoveproductId${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?actionclear清空购物车/a /c:otherwise /c:choose pa hrefproduct.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依然是接收actionupdate只改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-timeout60/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);第一次加入productId1的商品后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页面顶部要同时写contentTypetext/html;charsetUTF-8最好再加pageEncodingUTF-8Servlet如果直接输出内容要设置response.setContentType(text/html;charsetUTF-8)。响应的编码和请求的编码经常被并在一起说其实一个管输入一个管输出两个都要管。还有个容易被忽略的是数据库连接字串的编码设置等购物车演进成持久化方案后JDBC连接URL要带useUnicodetruecharacterEncodingUTF-8否则入库直接变问号。结尾我自己当年就是拿这个项目练手把Servlet摸透的。当时最大的体会是搞懂MVC不能靠背概念靠的是亲手把一次请求从浏览器走到Controller、再落到Model、最后渲染回页面的完整链路走一遍。现在再回头看这个购物车有个小技巧想分享给你开发阶段把Cart对象加了什么、删了什么、操作后购物车里还剩几条都用日志打印出来配合Session调试数据流动的整个过程就非常直观定位bug的速度能快好几倍。如果后续想继续扩展可以把Session购物车升级成数据库表也可以加登录后合并匿名购物车的逻辑但不管加什么记住一点——保持分层清晰别让代码越改越乱。