拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

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

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. 从一份课程设计说起:JSP+MVC+MySQL 的图书购物系统到底能跑多远

很多人第一次接触 JavaWeb,就是从「网上图书购物系统」开始的。它几乎是高校课程设计和毕设选题里出现频率最高的题目之一,原因很实在:业务闭环完整,有用户、有商品、有购物车、有订单,能同时练到 JSP、Servlet、MVC 分层和 MySQL 建表。但真正动手时,问题就来了——网上搜到的代码要么是单个 JSP 里塞满 Java 代码的「远古写法」,要么是直接上 Spring MVC 的重型方案,中间那层「用原生 JSP+Servlet 手写 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/setter;dao 里每个方法对应一条或一组 SQL;servlet 里不出现 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 ) ENGINE=InnoDB; 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 ) ENGINE=InnoDB; 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) ) ENGINE=InnoDB; 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) ) ENGINE=InnoDB;

几个参数要说明。字符集用 utf8mb4 而不是 utf8,因为图书标题里可能出现生僻字或 emoji,utf8 三字节存不下。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 里写driverClassName=com.mysql.cj.jdbc.Driver、url=jdbc:mysql://localhost:3306/bookstore?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8、username=root、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 注入的问题,图书标题里带单引号(比如《O'Reilly 动物书》)就会直接把语句拼断。

public class BookDao { public List<Book> 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 ?, ?"; List<Book> 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=小说&page=1,Servlet 从 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 { List<Book> 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 传个page=abc时不应该让整个页面 500,回退到第一页是更稳的做法。总页数用(total + size - 1) / size向上取整,别用total / size,否则 9 条数据每页 8 条会算成 1 页,第 9 条永远看不到。转发用 forward 不用 redirect,因为 request 域里的数据 redirect 会丢。

4.2 JSP 视图只做展示:JSTL 循环和 EL 表达式

JSP 里禁止出现<% %>脚本片段,全部用 JSTL 标签和 EL 表达式。图书列表页的核心就是循环渲染:

<%@ page contentType="text/html;charset=UTF-8" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %> <html> <body> <h2>${category} - 图书列表</h2> <c:forEach var="book" items="${books}"> <div class="book-card"> <img src="${pageContext.request.contextPath}/images/${book.coverImg}" alt="封面"/> <h3>${book.title}</h3> <p>作者:${book.author}</p> <p>价格:<fmt:formatNumber value="${book.price}" pattern="0.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 class="sold-out">已售罄</span> </c:otherwise> </c:choose> </div> </c:forEach> <div class="pager"> <c:forEach begin="1" end="${totalPage}" var="p"> <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, List<CartItem> 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)之后所有操作共用同一个 Connection,reduceStock 接收的正是这个 conn。Statement.RETURN_GENERATED_KEYS用来拿刚插入订单的自增 id,比插完再SELECT LAST_INSERT_ID()更可靠,因为后者在并发下可能拿到别人的 id。任何一步抛异常都走 rollback,finally 里恢复自动提交再归还连接,否则连接池里的连接会带着未提交事务被下一个请求复用,出现数据错乱。

5.2 避坑清单:五个真实踩过的坑

坑一:中文乱码,列表页全是问号。现象是图书标题存进数据库变成???。原因是请求和响应编码没统一。解决:在 Servlet 的 doPost 第一行加req.setCharacterEncoding("UTF-8"),响应加resp.setContentType("text/html;charset=UTF-8"),同时确认数据库和表的字符集是 utf8mb4,JDBC url 带characterEncoding=utf8。三处缺一处都会乱码。

坑二:JSP 页面报The absolute uri ... cannot be resolved。现象是 JSTL 标签不生效,页面直接报 500。原因是 WEB-INF/lib 下缺 jstl.jar 和 standard.jar,或者 Tomcat 10 用了 jakarta 命名空间而项目还是 javax。解决:Tomcat 9 及以下用 javax 版 jstl,Tomcat 10 及以上必须换成 jakarta 版,两者不能混用,这是升级容器时最常见的翻车点。

坑三:连接池报Communications link failure。现象是跑一段时间后所有数据库操作失败。原因是 MySQL 默认 8 小时空闲断开连接,而连接池里的连接还以为是活的。解决:url 加autoReconnect=true,或者配置连接池的validationQuery=SELECT 1和testWhileIdle=true,让池子定期探活。生产环境更推荐后者。

坑四:订单金额对不上,差几分钱。现象是购物车合计和订单 total_price 有微小差异。原因是用了 double 做金额运算。解决:全程用 BigDecimal,multiply和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 的 Mapper,BookListServlet 对应 Spring MVC 的 Controller,forward 到 JSP 对应视图解析器。等你把这套跑通再去看 Spring MVC,会发现它只是把web.xml里的 Servlet 映射换成了注解,把DBUtil换成了框架管理的 DataSource,把forward换成了ModelAndView。分层思想一模一样,只是少写了样板代码。

我自己的习惯是,每做完一个这样的教学项目,都会把「哪些代码是重复的、哪些是框架能帮我省的」列一遍。当年第一次写图书购物系统时,我在每个 Servlet 里都手写了一遍编码设置和登录判断,后来加了过滤器才发现前面写的全是无用功。这个「先手写一遍再想怎么抽象」的过程,比直接抄框架代码学到的多得多。希望帮到你。

本文还有配套的精品资源,点击获取

返回列表