
简介这是一份面向JavaWeb初学者的简易购物车项目完整源码基于Servlet、JSP、Session、JDBC及MVC模式实现商品浏览、加入购物车、修改数量、删除商品等常见功能适合正在学习JavaWeb核心知识、准备课程设计或希望理解完整业务逻辑的开发者参考。压缩包共54个文件以Java源文件、JSP页面、class编译文件为主同时包含数据库脚本、XML配置、properties配置、JSTL相关支持及项目导入所需文件整体仅1.58MB解压即可开始学习。资源附带了配套笔记与开发文档逐模块讲解了HTTP请求处理、Session状态保持、数据库增删改查、JSTL与EL表达式简化页面以及异常和SQL注入等安全注意事项还提供目录结构和部署说明便于快速在Tomcat中运行。目前已有4125人学习下载无论是入门巩固还是项目实战借鉴都能从中获得清晰的实现思路。1. javaWeb简易购物车源代码能做什么先跑通会话再谈数据库刚学完 Servlet 和 JSP很多人第一个想动手的完整案例不是博客就是购物车。购物车这个题目好在它小却能把你被迫摸一遍会话跟踪、数据库 CRUD、表单提交、重定向这些 JavaWeb 基本功。市面上常见的 javaWeb 简易购物车源代码核心套路就是商品表放 MySQL购物车数据放 Session用 Servlet 做入口JSP 做展示配合 Tomcat 就能跑起来。它能解决的是“让用户在没有登录的前提下也能选商品、改数量、看总价”这件事适合课程设计、毕业设计前练手也适合拿来理解一个完整 javaweb 项目完整案例 mysql 的目录是什么样。新手拿到这类源代码第一件事不是急着跑而是先读懂存储在 Session 里的购物车到底是怎么被“记下来”的。HTTP 本来是无状态的用户每次请求都像第一次来购物车能跨页面记住靠的就是 Session 里的一个对象。下面我会按数据模型、核心逻辑、运行配置、踩坑记录这个顺序把这个方案从头到尾讲清楚代码可以直接照着敲。2. 数据模型与项目结构三张表和一个 Map 把购物车立起来2.1 为什么用 Session 直接存购物车而不是一开始就建表购物车本质上是一个“临时订单”存哪里决定了项目的复杂度。很多新手的第一个反应是把购物车设计成数据库表但那样需要引入用户表、会话标识、CRUD 逻辑甚至要考虑登录和匿名购物的关联复杂度一下子翻倍。简易购物车最常见做法是把购物车对象直接放进 HttpSession用浏览器那次会话当作购物车的生命周期。原理是用户第一次访问时Tomcat 会在服务端创建一个 Session并通过 Cookie 把 JSESSIONID 写回浏览器后续请求带上这个 ID服务端就能找到对应的 Session 对象。购物车就是 Session 里的一个属性比如一个名叫 cart 的对象。这样做的好处是用户不需要登录就能加购各用户的购物车天然隔离代码量也很小。边界也很清楚Session 存在服务端内存里服务器一重启就没了用户清了 Cookie 也就丢了购物车。如果你的场景是用户登录后换一台设备购物车还能同步那就必须改成数据库方案建 user_cart 表和 user_cart_item 表把购物车持久化到 MySQL。但既然是“简易”购物车先从 Session 方案起步是最合理的。等 Session 方案跑通了再在登录逻辑里做合并把 Session 购物车倒进数据库这样代码演进有条理。2.2 建表 SQL 与实体类设计购物车项必须带上数量和单价Session 购物车即使不需要购物车表商品表还是要有的。最简单的一张商品表至少包含商品主键、名称、价格、库存、图片路径、描述。下面这是我常用的建表脚本字符集建议直接用 utf8mb4不然中文描述很容易出问题。CREATE DATABASE shop DEFAULT CHARACTER SET utf8mb4; CREATE TABLE product ( id INT PRIMARY KEY AUTO_INCREMENT, name VARCHAR(50) NOT NULL, price DECIMAL(10,2) NOT NULL, stock INT NOT NULL DEFAULT 0, image VARCHAR(255) DEFAULT , description VARCHAR(500) DEFAULT );AUTO_INCREMENT 让商品 id 自增price 用 DECIMAL(10,2) 而不是 FLOAT是为了避免浮点数导致金额计算出现 0.300000004 这种离谱结果。stock 字段在结算时要参与乐观锁扣减后面避坑那一章会专门讲。image 和 description 都可以先留空不影响购物车主流程。购物车实体类我习惯建两个CartItem 表示一条购物车记录Cart 表示整个购物车。CartItem 必须包含商品 id、名称、单价、数量和小计特别是“数量”不能省否则没法处理同一种商品加购多次的情况。public class CartItem { private int productId; private String name; private double price; private int quantity; public CartItem(int productId, String name, double price, int quantity) { this.productId productId; this.name name; this.price price; this.quantity quantity; } public double getSubtotal() { return price * quantity; } // getter/setter 省略实际开发要补全 }代码里的 price 来源要特别说明加购时从商品表查出来塞进 CartItem这个价格只是给 JSP 页面展示用的。真正下单结算时必须重新查数据库里的最新价格覆盖它否则管理员一改价购物车里的金额就和实际下单金额对不上。至于为什么不在每次显示购物车时都查一次数据库原因很简单简易项目用 Session 缓存可以减少一次查询代价就是价格可能滞后。Cart 类的核心是一个 Map以商品 id 为 keyCartItem 为 value。用 Map 而不是 List是因为加购时判断“这个商品在不在购物车里”只需要一次 containKey时间为 O(1)省去遍历。import java.util.*; public class Cart { private MapInteger, CartItem items new HashMap(); public void addItem(CartItem item) { if (items.containsKey(item.getProductId())) { CartItem old items.get(item.getProductId()); old.setQuantity(old.getQuantity() item.getQuantity()); } else { items.put(item.getProductId(), item); } } public void removeItem(int productId) { items.remove(productId); } public void updateQuantity(int productId, int quantity) { CartItem item items.get(productId); if (item ! null) { item.setQuantity(quantity); } } public ListCartItem getItems() { return new ArrayList(items.values()); } public double getTotalPrice() { return items.values().stream() .mapToDouble(CartItem::getSubtotal).sum(); } }addItem 方法里最关键的就是 containsKey 分支同一个商品再次加购时不新增一条记录而是在原记录上叠加数量。更新数量时如果传入 0 或负数调用方应该先 removeItem避免购物车出现数量为 0 的脏数据。2.3 目录结构怎么放MVC 分层不是架出来的是摆出来的很多课程设计源码最大的问题不是没分层而是把所有 Servlet 堆在一个包、所有 JSP 堆在根目录。拿到一个 javaWeb 简易购物车源代码你先看目录结构基本就能判断这代码值不值得继续读。我一般按下面这样组织 Maven 项目src/main/java/com/example/shop ├── entity # CartItem, Cart, Product, Order ├── dao # ProductDao, OrderDao用 JDBC 操作 MySQL ├── service # OrderService负责结算事务 ├── web # CartServlet, OrderServlet, ProductServlet src/main/webapp ├── static/iconfont # 图标库字体文件 ├── WEB-INF/jsp # 购物车页、商品列表页、结算页面 ├── index.jsp └── WEB-INF/web.xmlServlet 只做三件事接收参数、调用 service、决定跳转还是重定向。DAO 层只管 SQL返回值给上层。JSP 尽量只用 EL 表达式和 JSTL 展示数据不要在 JSP 里写大段 Java 脚本片段。这样分开之后拿到源码的人第一眼就能知道哪里改页面、哪里改逻辑、哪里改 SQL。JSP 文件放在 WEB-INF 下是我的习惯因为 WEB-INF 下的资源不能被浏览器直接访问必须通过 Servlet forward 出来可以防止用户绕过流程直接打开购物车页面。如果项目里没有用 MVC 框架Servlet 里用 request.getRequestDispatcher(/WEB-INF/jsp/cart.jsp).forward(req, resp) 就能把数据带过去。这里的“MVC 分层不是架出来的”意思是不用引入 Spring MVC光靠 Servlet 和 JSP 的物理目录划分就能把职责拆开。3. 购物车核心逻辑实现加购、改数量、结算三个动作写进一个 Servlet3.1 加入购物车先查商品再塞 Session最后重定向购物车最常见的入口是商品列表页的“加入购物车”按钮。点击后把商品 id 通过 POST 提交到 CartServletServlet 先判断 Session 里有没有 cart没有就 new 一个然后根据 id 查商品查到了再组装 CartItem 放进去。下面是核心代码action 参数用来区分本次请求是加购还是更新数量。WebServlet(/cart) public class CartServlet extends HttpServlet { private ProductDao productDao new ProductDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String action req.getParameter(action); if (add.equals(action)) { addItem(req, resp); } else if (update.equals(action)) { updateQuantity(req, resp); } } private void addItem(HttpServletRequest req, HttpServletResponse resp) throws IOException { HttpSession session req.getSession(); Cart cart (Cart) session.getAttribute(cart); if (cart null) { cart new Cart(); session.setAttribute(cart, cart); } int productId Integer.parseInt(req.getParameter(productId)); int quantity 1; if (req.getParameter(quantity) ! null) { quantity Integer.parseInt(req.getParameter(quantity)); } Product product productDao.findById(productId); if (product ! null product.getStock() quantity) { cart.addItem(new CartItem( product.getId(), product.getName(), product.getPrice(), quantity)); } resp.sendRedirect(req.getContextPath() /WEB-INF/jsp/cart.jsp); } }doPost 开头先 setCharacterEncoding(UTF-8)是为了处理表单提交过来的中文参数。action 这个参数是整个 Servlet 的分流开关前端每个表单里放一个 hidden 字段名字就叫 action提交时告诉服务端这次要做什么。加购逻辑里有两个防御点商品查出来是 null 就什么都不加库存不够也不加。这里对库存只是预防性判断真正要能拦住并发超卖得靠后面结算时的 SQL 扣减。最后用 sendRedirect 而不是 forward是因为加购后浏览器地址栏会变成/cart用户按 F5 刷新时只重发一个 GET 请求不会重复提交表单把同一种商品再加一遍。这是典型的 Post/Redirect/Get 模式简易项目也值得一上来就用。3.2 修改数量与单条删除用 action 参数让一个 Servlet 吃下所有操作购物车页面要支持用户改数量、删除某一行如果每个操作单独写一个 Servlet目录会很难看。常见做法是继续复用 CartServlet通过 action 参数区分。更新数量的逻辑里有个容易忽略的细节数量小于等于 0 时不能直接更新而应该调用 removeItem 把它删掉否则购物车列表会冒出行数量为 0 的商品。private void updateQuantity(HttpServletRequest req, HttpServletResponse resp) throws IOException { Cart cart (Cart) req.getSession().getAttribute(cart); if (cart null) { resp.sendRedirect(req.getContextPath() /cart.jsp); return; } int productId Integer.parseInt(req.getParameter(productId)); int quantity Integer.parseInt(req.getParameter(quantity)); if (quantity 0) { cart.removeItem(productId); } else { cart.updateQuantity(productId, quantity); } resp.sendRedirect(req.getContextPath() /cart.jsp); }这里先取 Session 里的 cart如果为空说明会话过期或者用户没加购过任何商品直接重定向到购物车页面不要再继续往下走。前端提交数量时我建议用表单里带数字输入框的方式input 的 name 设为 quantity这样用户点“更新数量”按钮触发提交。避免用 checkbox 数组是因为删除多条和更新多条混合在一起代码会变得难读。Cart 里的 updateQuantity 方法只负责改数字不负责判断数量上限。如果需要限制用户一次最多买 99 件可以在 Servlet 里加一个 if (quantity 99) 的校验也可以在 JSP 的 input 上写 max99。我给新手一般建议两边都做JSP 限制操作手感Servlet 限制真正的数据边界。前端校验可以被绕过所以 Servlet 里那层才是关键。3.3 结算与库存校验钱算对了不代表能下单购物车页面显示总价很容易遍历 Cart.getItems() 累加 getSubtotal() 就行。但真正点击“去结算”时不能直接拿 Session 里的商品价格和库存去生成订单因为 Session 里的数据是用户加购那一刻的缓存可能已经过期。我见过很多项目在这里翻车管理员在后台改了价格老顾客结算时购物车页面显示旧价格提交订单却按新价格扣款用户投诉金额对不上。先写一个结算前校验的框架重点是重查数据库并扣库存public Order createOrder(Cart cart, int userId) { ListCartItem items cart.getItems(); double total 0; for (CartItem item : items) { Product product productDao.findById(item.getProductId()); if (product null) { throw new RuntimeException(商品已下架); } if (product.getStock() item.getQuantity()) { throw new RuntimeException(商品[ product.getName() ]库存不足); } // 以数据库最新价格为准购物车里的价格只用于展示 item.setPrice(product.getPrice()); // 扣库存这条 SQL 是防超卖的关键 int rows productDao.deductStock(item.getProductId(), item.getQuantity()); if (rows 0) { throw new RuntimeException(商品[ product.getName() ]已被抢完); } total product.getPrice() * item.getQuantity(); } return orderDao.insert(userId, total, items); }这里我用 item.setPrice(product.getPrice()) 做了两件事一是让购物车页面显示的价格在结算后自动刷新二是把最新价格传给订单明细保存订单表里存的金额才算真正“价格快照”。deductStock 这条 SQL 是关键它会返回影响行数行数是 0 就说明库存已经被别人扣完了这个方案比先 select 再 update 安全得多。对应的 SQL 放在 DAO 里UPDATE product SET stock stock - ? WHERE id ? AND stock ?减库存和生成订单必须放在同一个数据库事务里否则会出现订单创建成功但库存没扣或者库存扣了订单却没落库的尴尬局面。简易项目用 JDBC 的话可以手动 setAutoCommit(false)全部执行成功后 commit任何一个中间步骤抛异常就 rollback。4. idea运行javaweb项目配置与前端资源把源码在本地跑起来4.1 Tomcat 运行时配置war exploded 才是调试的正确姿势拿到源代码之后大多数人卡在第一步不知道在 IDEA 里怎么把项目跑起来。首先确认这是一个 Maven 项目还是普通 Web 项目Maven 项目导入后要等依赖下载完普通项目直接在 Project Structure 里把 src/main/java 标成 Sources把 webapp 加入 Web Facet。配置 Tomcat 的路径是 Run - Edit Configurations - 左上角加号 - Tomcat Server - Local。注意这里不要选 TomEE。然后在 Deployment 标签页点加号选择 Artifact如果项目构建正确会出现两个选项war 和 war exploded。我强烈建议选 war exploded因为 exploded 是解压后的目录IDEA 可以直接把 class 和 JSP 的变更热加载进去不需要每次改一点就重新打包 war。Deployment 里要能看到 :war exploded Server 标签页URL 留空或写 http://localhost:8080/ On frame deactivation建议选 Update resources配置完启动后如果访问返回 404先检查两步第一Artifact 是否在 Deployment 里没添加自然没法访问第二项目里有没有 Servlet 注解或者 web.xml。如果用 WebServlet 注解Servlet 3.0 会自动扫描不需要 web.xml但如果项目结构是老式的必须在 web.xml 里注册 Servlet。4.2 连接 MySQL驱动、连接串、编码三个坑一次排掉购物车项目的数据库连接最常用 JDBC。新建的 MySQL 如果版本是 8.x驱动类名要写 com.mysql.cj.jdbc.Driver不是老教程里的 com.mysql.jdbc.Driver。驱动 jar 包放哪也有地方讲究如果你用 Maven在 pom.xml 里加 mysql-connector-j 依赖如果你是手动导 jar必须放到 src/main/webapp/WEB-INF/lib 目录下Tomcat 才会把它加载进应用运行时。连接串里的编码参数和时区参数缺一不可jdbc:mysql://localhost:3306/shop?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseUnicodetrue 配合 characterEncodingutf8是为了让中文从 Java 到 MySQL 不变成问号。serverTimezoneAsia/Shanghai 是为了解决 MySQL 8.x 默认时区在美国导致连接时报 CST 的错误。老项目如果报 SSL 警告可以在连接串后面加 useSSLfalse 临时压掉但生产环境不建议关闭。JDBC 工具类我一般这样写把连接信息放到 db.propertiespublic class DBUtil { private static String url; private static String username; private static String password; static { Properties props new Properties(); try (InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(db.properties)) { props.load(in); Class.forName(props.getProperty(jdbc.driver)); url props.getProperty(jdbc.url); username props.getProperty(jdbc.username); password props.getProperty(jdbc.password); } catch (Exception e) { throw new RuntimeException(数据库配置加载失败, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(url, username, password); } }注意 static 块里的 Class.forName 是为了让驱动注册到 DriverManager有些教程说 MySQL 8 可以省略但我建议保留省得别人换驱动版本时踩坑。db.properties 文件要放在 src/main/resources 下不要放在 webapp 下否则会被打包进 WEB-INF/classes 之外的地方。4.3 引入 iconfont 图标库给“一键加入购物车”按钮配个图标商品列表页的加购按钮默认文字版“加入购物车”其实够用但很多人想把按钮做得好看一点于是会用到 iconfont 这类图标库。在 iconfont 网站选好购物车图标后直接把下载的压缩包解压到 src/main/webapp/static/iconfont 目录然后在 JSP 里引入 css 就可以。link relstylesheet href${pageContext.request.contextPath}/static/iconfont/iconfont.css button typesubmit classbtn-add i classiconfont icon-gouwuche/i span一键加入购物车/span /button这里有一个常见的坑iconfont 下载包里的字体文件.ttf, .woff必须和 iconfont.css 保持同一目录比如都在 static/iconfont 下。如果你只把 css 复制走了没带字体文件图标会显示成方框。另一个坑是按钮的外层不能嵌套也就是说不允许 form 套 form如果你有两个表单一个操作 actionadd一个操作 actionupdate要把它们用不同 form 包起来或者用 button 的 formaction 属性指向不同地址不能简单地把两个按钮塞进同一个表单。引入图标之后按钮点击时提交的请求依然是 POSThidden 字段 actionadd 照旧图标只是视觉效果。字体图标本身是一个字符点击它等同于点击按钮内的其他区域不需要额外写 onclick 事件。5. javaWeb购物车避坑指南Session消失、中文乱码、并发超卖5.1 Session 会话失效购物车为什么“神秘消失”现象用户早上加购了几件商品下午打开页面发现购物车空了而且没有任何错误提示看起来就像一个魔幻 Bug。 原因Tomcat 的 Session 默认过期时间是 30 分钟无访问。如果用户加购后关掉浏览器、或者两天后再打开JSESSIONID 这个 Cookie 已经丢失服务端也早把 Session 清掉了。服务器重启同样会导致所有内存 Session 清空。 解决明确“简易购物车”的生命周期就是浏览器会话登录、结算、刷新都要考虑到 Session 可能为空。更好的办法是用户登录后把购物车持久化到数据库Session 只在用户未登录时作为临时载体这个方向我会在最后一章讲合并逻辑。至少要在购物车页面给一句提示“购物车仅在本浏览器会话内有效”避免用户误以为商品已经被下单。5.2 中文乱码JSP、Servlet、MySQL 三处要同时设 UTF-8现象购物车页面里商品名称显示成“???”或者数据库查出来的中文变乱码。 原因这是一个链路上的问题任何一环漏了都会乱。最常见的是 Servlet doPost 里没有 request.setCharacterEncoding(UTF-8)其次是 JSP 页面虽然写了 pageEncoding但浏览器端的 Content-Type 不匹配再就是 MySQL 表或连接串没有使用 utf8mb4。 解决按顺序检查三处。Servlet 里在读取任何参数前调用 req.setCharacterEncoding(UTF-8)JSP 第一行写成 % page contentTypetext/html;charsetUTF-8 pageEncodingUTF-8 %MySQL 建库时用 DEFAULT CHARACTER SET utf8mb4。如果这三处都对依然乱码再用数据库客户端查看表结构确认字段的 collation 不是 latin1。5.3 并发超卖一个 update 语句把库存扣安全现象商品库存只剩 1 件A 和 B 两个人同时打开结算页都提交了订单最终系统发出两笔订单库存显示 -1。 原因代码先从 product 表 select stock检查到库存大于 0然后再 update 库存。两个请求都读到库存为 1都认为可以下单导致超卖。这是典型的“检查与执行之间出现时间差”。 解决不要用 select 判断直接执行 3.3 里那条 UPDATE product SET stock stock - ? WHERE id ? AND stock ?数据库行锁会保证同一时间只有一个请求能扣成功。影响行数为 0 的那个请求说明库存被别人抢完了直接抛异常回滚订单。如果你的项目已经用了 Spring可以把这条 SQL 放在事务注解里用 JDBC 则需要手动控制 commit/rollback。5.4 重复提交Post/Redirect/Get 三件套给购物车兜底现象用户手快连点了两次“提交订单”结果生成两笔一模一样的订单或者 F5 刷新购物车页面浏览器弹窗“是否重新发送表单”点击确认后商品被重复加购。 原因表单用 POST 提交后如果 Servlet 处理完直接 forward 到成功页面浏览器的当前请求还是 POST。用户刷新时浏览器会重复提交上一次数据Servlet 又执行了一次于是造成重复。 解决加购、更新数量、结算提交成功之后一律 sendRedirect而不是 forward。重定向会让浏览器发起一个新的 GET 请求地址栏也变成购物车页面地址这样无论怎么刷新都不会把原来的 POST 请求重放一次。这条经验对后端工程师来说是基本功但课程设计里能看到正确做法的代码真的不多所以值得单独记一笔。6. 两个值得加进源码的进阶技巧匿名购物车合并与价格快照如果你的购物车项目打算从“课程设计”往“能给别人演示的产品”走一步我建议除了跑通流程之外再加上两个小功能登录后的购物车合并和订单价格快照。匿名购物车合并的逻辑是用户未登录时加购的商品放在 Session 里登录成功后不能直接把 Session 清空否则用户一登录购物车就没了。常见做法是登录成功后先取出 Session 里的 cart遍历每一件商品把它们 merge 进数据库购物车表再把数据库购物车装进 Session 覆盖原来的匿名车。合并时要注意数量求和而不是覆盖。价格快照这件事我在 3.3 里强调过订单明细里面保存的 price 必须是下单那一刻从数据库查出来的最新价格不能直接用 CartItem 里缓存的旧价格。很多源码偷懒不更新 item 价格导致用户购物车显示 100订单却按 120 结算。真正的商品价格是变动的而订单历史是不能变的所以“快照”要落在订单表里。我自己的血泪经验是一开始为了省事结算逻辑直接遍历 Session 里的 CartItem 求总和连续部署了几次也没出问题。直到有一天运营在后台改了商品价格一批老订单金额全部对不上测试直接发了一个“金额计算错误”的 Bug 单。从那以后我对购物车里的价格一律只当“展示缓存”结算必查数据库。如果你手里的源码也是这样趁还没翻车赶紧改掉。这个方向值得投入的原因很简单购物车几乎覆盖了 JavaWeb 最核心的会话、状态、事务、前后端交互跑通它后面学 Spring MVC、Spring Boot 时很多概念你都会有“原来如此”的感觉。希望帮到你。本文还有配套的精品资源点击获取