ARTICLE DETAIL

建站实战干货

来自一线的建站与推广经验沉淀,每一条都经过真实交付验证。

JSP+Servlet+MVC+MySQL 图书购物系统完整实战:从建库到下单

2026/10/1 17:33:55 拓冰建站 浏览量
JSP+Servlet+MVC+MySQL 图书购物系统完整实战:从建库到下单 简介这是一套基于JSP与MVC设计模式、以MySQL为数据库的网上图书购物系统源码面向Java Web初学者与进阶学习者可作为毕业设计、课程设计、大作业或工程实训的参考项目。压缩包共76个文件约47.8MB包含14个jsp页面、12个java源文件及对应class文件、1个sql数据库脚本另有css样式、xml配置、图片素材与项目展示视频覆盖前端页面、控制层与数据层等完整结构。资源已积累136人学习下载具备一定参考价值。读者可从中了解MVC分层组织方式、JSP与Servlet协作流程、数据库表结构设计及购物车、订单等典型模块的实现思路并借助展示视频快速把握项目整体运行效果。需注意代码仅作参考资料需具备一定基础自行调试与修改不能直接照搬用于定制需求。1. 从一份课程设计说起JSPMVCMySQL 的图书购物系统到底能跑多远很多人第一次接触 JavaWeb就是从「网上图书购物系统」开始的。它几乎是高校课程设计和毕设选题里出现频率最高的题目之一原因很实在业务闭环完整有用户、有商品、有购物车、有订单能同时练到 JSP、Servlet、MVC 分层和 MySQL 建表。但真正动手时问题就来了——网上搜到的代码要么是单个 JSP 里塞满 Java 代码的「远古写法」要么是直接上 Spring MVC 的重型方案中间那层「用原生 JSPServlet 手写 MVC」的路线反而讲得少。这篇要讲的就是这条中间路线不引入 Spring、不引入 MyBatis用最朴素的 Servlet 做控制器、JSP 做视图、JavaBean 做模型MySQL 做持久化把图书购物网站从建库建表一路做到能下单。它适合两类人一是正在做基于 JSP 的毕设选题、需要一套能讲清楚分层逻辑的完整案例二是想搞明白 MVC 三层架构在没有框架兜底时到底怎么落地的人。热词里那些「jsp 入门」「javaweb 项目完整案例 mysql」「mvc 三层架构」的诉求基本都能在这条线上找到答案。需要先摆正预期这套方案不是生产级电商它是教学级和中小型内部系统级的实现。它的价值在于让你彻底看清一次请求从浏览器到数据库再回到页面的完整链路而不是让你去对标淘宝。把这条链路走通后面再学 Spring MVC 或 ASP.NET Core MVC你会发现分层思想是相通的只是换了个壳。2. 先把 MVC 三层架构和数据库设计立住再谈写代码2.1 为什么图书购物系统必须分层而不是全塞进 JSP早期 JSP 的写法是在页面顶部写% ... %脚本片段直接连数据库、查数据、拼 HTML。这种写法在只有一两个页面时看着快但图书购物系统至少有登录、图书列表、图书详情、购物车、下单、订单查询六七个页面一旦全塞进 JSP会出现三个致命问题数据库连接代码重复十几遍、改一个字段要翻遍所有页面、页面里混着业务逻辑根本没法调试。MVC 三层架构的解法是把职责切开。模型层Model用 JavaBean 承载数据比如 Book、User、Order 这些实体类再加一层 DAO 负责和 MySQL 打交道视图层View只负责展示用 JSP 加 JSTL 标签不写 Java 逻辑控制层Controller用 Servlet 接收请求、调用 DAO、把结果放进 request 域、转发给 JSP。这样改一个查询只需要动 DAO改一个页面样式只需要动 JSP互不干扰。具体到目录结构常见做法是这样组织的src/ com.bookstore.entity/ Book.java User.java Order.java CartItem.java com.bookstore.dao/ BookDao.java UserDao.java OrderDao.java com.bookstore.util/ DBUtil.java com.bookstore.servlet/ BookListServlet.java LoginServlet.java CartServlet.java WebContent/ WEB-INF/web.xml WEB-INF/lib/ mysql-connector-java.jar jstl.jar standard.jar book_list.jsp book_detail.jsp cart.jsp login.jsp这个结构里entity 是纯数据载体只有属性和 getter/setterdao 里每个方法对应一条或一组 SQLservlet 里不出现 SQL 语句jsp 里不出现import java.sql.*。判断分层有没有做对就看这四条有没有被打破。2.2 MySQL 建库建表图书、用户、订单、购物车四张核心表数据库设计决定了后面 DAO 好不好写。图书购物系统最少需要四张表图书表、用户表、订单主表、订单明细表。购物车如果做持久化就再加一张如果只存在 Session 里就可以省掉。下面这套建表语句是我一般会用的版本字段类型和约束都按实际查询需求定过CREATE DATABASE bookstore DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE bookstore; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, author VARCHAR(100), price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0, cover_img VARCHAR(255), category VARCHAR(50), create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, phone VARCHAR(20), address VARCHAR(255), reg_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB; CREATE TABLE orders ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, total_price DECIMAL(10,2) NOT NULL, status TINYINT NOT NULL DEFAULT 0, order_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (user_id) REFERENCES user(id) ) ENGINEInnoDB; CREATE TABLE order_item ( id INT PRIMARY KEY AUTO_INCREMENT, order_id INT NOT NULL, book_id INT NOT NULL, quantity INT NOT NULL DEFAULT 1, price DECIMAL(10,2) NOT NULL, FOREIGN KEY (order_id) REFERENCES orders(id), FOREIGN KEY (book_id) REFERENCES book(id) ) ENGINEInnoDB;几个参数要说明。字符集用 utf8mb4 而不是 utf8因为图书标题里可能出现生僻字或 emojiutf8 三字节存不下。price 用 DECIMAL(10,2) 而不是 FLOAT金额计算不能有浮点误差。stock 默认值设 0配合热词里「mysql 设置默认值为 0」的场景下单时要先判断库存再扣减。orders 表名用复数因为 order 是 MySQL 保留字直接叫 order 会报语法错误这是新手最容易翻车的地方之一。status 用 TINYINT 表示订单状态0 待付款、1 已付款、2 已发货、3 已完成比存字符串省空间也好索引。建完表建议立刻建两个索引CREATE INDEX idx_book_category ON book(category);和CREATE INDEX idx_order_user ON orders(user_id);。图书列表按分类筛选、订单按用户查这两个索引能明显减少扫描行数。数据量小的时候感觉不出来但这是习惯问题热词里「mysql 创建索引」的诉求就落在这里。3. 用 DBUtil 和 DAO 把 MySQL 连接管起来3.1 数据库连接池别每次请求都 DriverManager.getConnection最原始的写法是每个 DAO 方法里Class.forName(com.mysql.cj.jdbc.Driver)再DriverManager.getConnection(...)用完 close。这在图书列表页每刷新一次就建一次连接几十个并发就能把 MySQL 的连接数打满报Too many connections。正确做法是用连接池常见的是 Druid 或 C3P0教学项目里用 Druid 足够。连接池的核心参数有三个必须调初始连接数、最大活跃连接数、最大等待时间。初始连接数设 5最大活跃设 20等待时间设 3000 毫秒。等待时间不能设太大否则连接耗尽时请求会一直挂着用户看到的是页面转圈而不是报错排查起来很痛苦。public class DBUtil { private static DataSource dataSource; static { try { Properties props new Properties(); props.load(DBUtil.class.getClassLoader() .getResourceAsStream(druid.properties)); dataSource DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError(连接池初始化失败: e.getMessage()); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(Connection conn, Statement stmt, ResultSet rs) { // 注意关闭顺序ResultSet - Statement - Connection // 连接池的 close 是归还连接不是真正关闭 try { if (rs ! null) rs.close(); } catch (SQLException ignored) {} try { if (stmt ! null) stmt.close(); } catch (SQLException ignored) {} try { if (conn ! null) conn.close(); } catch (SQLException ignored) {} } }druid.properties 里写driverClassNamecom.mysql.cj.jdbc.Driver、urljdbc:mysql://localhost:3306/bookstore?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8、usernameroot、password你的密码。这里有个高频坑MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver不是老的com.mysql.jdbc.Driver写错会报Loading class ... is deprecated然后连接失败。另外 url 里必须带serverTimezone否则插入时间字段会报时区错误热词里「mysql ssl 连接错误」很多时候就是 useSSL 和时区参数没配对导致的。3.2 BookDao 的增删改查PreparedStatement 和参数绑定DAO 层每个方法只做一件事SQL 用 PreparedStatement 预编译绝不用字符串拼接。拼接 SQL 不只是 SQL 注入的问题图书标题里带单引号比如《OReilly 动物书》就会直接把语句拼断。public class BookDao { public ListBook findByCategory(String category, int page, int size) throws SQLException { String sql SELECT id,title,author,price,stock,cover_img,category FROM book WHERE category ? LIMIT ?, ?; ListBook list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, category); ps.setInt(2, (page - 1) * size); // 起始偏移量 ps.setInt(3, size); // 每页条数 try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Book b new Book(); b.setId(rs.getInt(id)); b.setTitle(rs.getString(title)); b.setPrice(rs.getBigDecimal(price)); b.setStock(rs.getInt(stock)); list.add(b); } } } return list; } public int reduceStock(Connection conn, int bookId, int qty) throws SQLException { // 扣库存必须带 stock ? 条件防止超卖 String sql UPDATE book SET stock stock - ? WHERE id ? AND stock ?; try (PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, qty); ps.setInt(2, bookId); ps.setInt(3, qty); return ps.executeUpdate(); // 返回 0 表示库存不足 } } }分页的LIMIT ?, ?两个参数分别是偏移量和条数偏移量是(page-1)*size这个公式写错会导致第一页重复或漏数据。reduceStock 特意接收外部传入的 Connection是为了和订单插入放在同一个事务里这个设计在下一章下单流程会用到。返回受影响行数调用方判断是否为 0 就能知道库存够不够比先查再改少一次数据库往返也避免了并发下的竞态。4. Servlet 控制器和 JSP 视图怎么配合4.1 一个 Servlet 处理图书列表请求转发与分页参数Servlet 的职责是收参数、调 DAO、存数据、转发。以图书列表为例URL 设计成/book/list?category小说page1Servlet 从 request 拿 category 和 page调 BookDao 查数据把 list 和总页数放进 request然后request.getRequestDispatcher(/book_list.jsp).forward(request, response)。WebServlet(/book/list) public class BookListServlet extends HttpServlet { private BookDao bookDao new BookDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String category req.getParameter(category); if (category null || category.trim().isEmpty()) { category 全部; } int page 1; try { page Integer.parseInt(req.getParameter(page)); } catch (NumberFormatException ignored) { // 参数非法时回退到第一页不要抛 500 } int size 8; try { ListBook books bookDao.findByCategory(category, page, size); int total bookDao.countByCategory(category); req.setAttribute(books, books); req.setAttribute(currentPage, page); req.setAttribute(totalPage, (total size - 1) / size); req.setAttribute(category, category); req.getRequestDispatcher(/book_list.jsp).forward(req, resp); } catch (SQLException e) { req.setAttribute(error, 图书加载失败请稍后重试); req.getRequestDispatcher(/error.jsp).forward(req, resp); } } }这里有两个细节值得说。page 参数解析用 try-catch 兜住用户手动改 URL 传个pageabc时不应该让整个页面 500回退到第一页是更稳的做法。总页数用(total size - 1) / size向上取整别用total / size否则 9 条数据每页 8 条会算成 1 页第 9 条永远看不到。转发用 forward 不用 redirect因为 request 域里的数据 redirect 会丢。4.2 JSP 视图只做展示JSTL 循环和 EL 表达式JSP 里禁止出现% %脚本片段全部用 JSTL 标签和 EL 表达式。图书列表页的核心就是循环渲染% page contentTypetext/html;charsetUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % html body h2${category} - 图书列表/h2 c:forEach varbook items${books} div classbook-card img src${pageContext.request.contextPath}/images/${book.coverImg} alt封面/ h3${book.title}/h3 p作者${book.author}/p p价格fmt:formatNumber value${book.price} pattern0.00/ 元/p c:choose c:when test${book.stock 0} a href${pageContext.request.contextPath}/cart/add?bookId${book.id}加入购物车/a /c:when c:otherwise span classsold-out已售罄/span /c:otherwise /c:choose /div /c:forEach div classpager c:forEach begin1 end${totalPage} varp a href${pageContext.request.contextPath}/book/list?category${category}page${p} class${p currentPage ? active : }${p}/a /c:forEach /div /body /html${pageContext.request.contextPath}是拿应用上下文路径项目部署成/bookstore时链接才会正确直接写/cart/add在非根路径部署下会 404这是本地跑通、部署到服务器就挂的经典原因。价格用 fmt 标签格式化别直接${book.price}否则 12.5 会显示成 12.5 而不是 12.50。库存判断用 c:choose售罄的按钮不渲染成可点链接比点了再报错体验好。5. 下单流程和那些让我翻过车的坑5.1 购物车到订单一个事务里完成扣库存和写订单下单是整个系统里唯一必须用事务的地方。流程是从 Session 拿购物车 → 校验库存 → 插入 orders 主表拿到自增 id → 循环插入 order_item → 扣减 book 库存 → 清空购物车。这几步要么全成功要么全回滚否则会出现订单写了但库存没扣或者库存扣了订单没写。public int createOrder(int userId, ListCartItem items) throws SQLException { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 BigDecimal total BigDecimal.ZERO; for (CartItem it : items) { total total.add(it.getPrice().multiply(new BigDecimal(it.getQuantity()))); } // 1. 插入订单主表拿回自增主键 String orderSql INSERT INTO orders(user_id,total_price,status) VALUES(?,?,0); int orderId; try (PreparedStatement ps conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS)) { ps.setInt(1, userId); ps.setBigDecimal(2, total); ps.executeUpdate(); try (ResultSet rs ps.getGeneratedKeys()) { rs.next(); orderId rs.getInt(1); } } // 2. 逐条插入明细并扣库存 BookDao bookDao new BookDao(); for (CartItem it : items) { if (bookDao.reduceStock(conn, it.getBookId(), it.getQuantity()) 0) { throw new SQLException(库存不足: it.getTitle()); } String itemSql INSERT INTO order_item(order_id,book_id,quantity,price) VALUES(?,?,?,?); try (PreparedStatement ps conn.prepareStatement(itemSql)) { ps.setInt(1, orderId); ps.setInt(2, it.getBookId()); ps.setInt(3, it.getQuantity()); ps.setBigDecimal(4, it.getPrice()); ps.executeUpdate(); } } conn.commit(); return orderId; } catch (SQLException e) { if (conn ! null) conn.rollback(); throw e; } finally { if (conn ! null) { conn.setAutoCommit(true); conn.close(); } } }关键点是setAutoCommit(false)之后所有操作共用同一个 ConnectionreduceStock 接收的正是这个 conn。Statement.RETURN_GENERATED_KEYS用来拿刚插入订单的自增 id比插完再SELECT LAST_INSERT_ID()更可靠因为后者在并发下可能拿到别人的 id。任何一步抛异常都走 rollbackfinally 里恢复自动提交再归还连接否则连接池里的连接会带着未提交事务被下一个请求复用出现数据错乱。5.2 避坑清单五个真实踩过的坑坑一中文乱码列表页全是问号。现象是图书标题存进数据库变成???。原因是请求和响应编码没统一。解决在 Servlet 的 doPost 第一行加req.setCharacterEncoding(UTF-8)响应加resp.setContentType(text/html;charsetUTF-8)同时确认数据库和表的字符集是 utf8mb4JDBC url 带characterEncodingutf8。三处缺一处都会乱码。坑二JSP 页面报The absolute uri ... cannot be resolved。现象是 JSTL 标签不生效页面直接报 500。原因是 WEB-INF/lib 下缺 jstl.jar 和 standard.jar或者 Tomcat 10 用了 jakarta 命名空间而项目还是 javax。解决Tomcat 9 及以下用 javax 版 jstlTomcat 10 及以上必须换成 jakarta 版两者不能混用这是升级容器时最常见的翻车点。坑三连接池报Communications link failure。现象是跑一段时间后所有数据库操作失败。原因是 MySQL 默认 8 小时空闲断开连接而连接池里的连接还以为是活的。解决url 加autoReconnecttrue或者配置连接池的validationQuerySELECT 1和testWhileIdletrue让池子定期探活。生产环境更推荐后者。坑四订单金额对不上差几分钱。现象是购物车合计和订单 total_price 有微小差异。原因是用了 double 做金额运算。解决全程用 BigDecimalmultiply和add都用 BigDecimal 方法数据库字段用 DECIMAL。double 的二进制表示存不下 0.1 这种十进制小数累加必然有误差。坑五部署到服务器后图片和 CSS 全 404。现象是本地正常打成 war 包部署后静态资源加载失败。原因是 JSP 里写了绝对路径/images/xx.jpg而应用上下文不是根路径。解决所有静态资源引用前加${pageContext.request.contextPath}或者用c:url标签生成。热词里「传统 jsp 项目打包 war」的坑基本都集中在这里。6. 把这套系统跑稳之后我建议你做的三件事第一件事是加一层过滤器统一处理编码和登录校验。现在每个 Servlet 都写一遍setCharacterEncoding很啰嗦写一个CharacterEncodingFilter实现Filter接口在doFilter里统一设置web.xml 里配/*拦截。登录校验同理把需要登录的路径购物车、下单、订单查询在过滤器里判断 Session 里有没有 user没有就重定向到登录页。这样业务 Servlet 里就不用重复写权限判断了。第二件事是给 DAO 加单元测试。不用上完整的测试框架用 JUnit 加一个测试库就能跑。重点测三个方法分页查询的边界第一页、最后一页、超出范围、扣库存的并发两个线程同时扣同一本书、订单事务的回滚故意让明细插入失败验证订单主表没留下脏数据。这三个测过系统的基本正确性就有底了。第三件事是理解这套手写 MVC 和框架的对应关系。你现在写的 BookDao 对应 MyBatis 的 MapperBookListServlet 对应 Spring MVC 的 Controllerforward 到 JSP 对应视图解析器。等你把这套跑通再去看 Spring MVC会发现它只是把web.xml里的 Servlet 映射换成了注解把DBUtil换成了框架管理的 DataSource把forward换成了ModelAndView。分层思想一模一样只是少写了样板代码。我自己的习惯是每做完一个这样的教学项目都会把「哪些代码是重复的、哪些是框架能帮我省的」列一遍。当年第一次写图书购物系统时我在每个 Servlet 里都手写了一遍编码设置和登录判断后来加了过滤器才发现前面写的全是无用功。这个「先手写一遍再想怎么抽象」的过程比直接抄框架代码学到的多得多。希望帮到你。本文还有配套的精品资源点击获取