JSP+MVC+MySQL实战:从零构建图书购物网站

📅 发布时间:2026/10/9 12:32:03
JSP+MVC+MySQL实战:从零构建图书购物网站
简介这是一套基于JSP与MVC设计模式、以MySQL为数据库的网上图书购物系统源码面向Java Web初学者、进阶学习者以及需要完成毕设、课程设计或大作业的学生帮助其理解分层架构与购物流程的实现思路。压缩包共76个文件约47.8MB包含14个jsp页面、12个java源文件与12个class编译文件另有css样式、xml配置、sql数据库脚本及项目展示视频等覆盖前端页面、控制层与数据层的主要模块。资源附有数据库文件与README说明可对照MVC三层结构梳理图书浏览、购物车、订单等核心功能的代码组织方式并借助演示视频快速了解系统运行效果。目前已有136人学习下载适合作为参考资料在具备一定Java Web基础的前提下自行调试、修改与扩展功能。1. 从零手搓图书购物网站JSPMVCMySQL 这套老架构为什么还值得做很多人第一次接触 Java Web都是从一套「网上图书购物系统」开始的。它看起来平平无奇图书列表、购物车、下单、后台管理功能就这些。但真正动手写一遍你会发现这套系统把 JSP、MVC 分层、MySQL 增删改查、会话管理、事务处理全串起来了是一个能让你把课本知识落到实处的完整闭环。我见过太多人学完 Servlet 和 JDBC 之后不知道下一步做什么其实缺的就是这样一个能把前后端和数据库打通的实战项目。图书购物网站的好处在于业务逻辑足够清晰——图书有分类、有库存、有价格用户有登录、有购物车、有订单每一块都能对应到具体的表结构和代码实现。你不需要花时间理解复杂的业务规则可以把精力集中在 MVC 模式怎么落地、JSP 页面怎么组织、MySQL 表怎么设计这些真正重要的地方。这篇文章会从架构选型讲到具体实现再到部署踩坑把一套能跑起来的图书购物系统拆开揉碎讲清楚。2. 图书购物系统的 MVC 分层JSP 到底该写多少 Java 代码2.1 为什么不用纯 JSP 写到底新手最容易犯的错误是把所有逻辑都塞进 JSP 页面里。一个book_list.jsp里既有数据库连接代码又有循环遍历结果集还夹杂着 HTML 标签改一个查询条件要翻遍整个文件。这种写法在页面少的时候还能忍一旦功能超过十个维护成本就会指数级上升。MVC 模式的核心思路是把「数据从哪来」「数据怎么处理」「数据怎么展示」三件事分开。在 JSP 技术栈里Model 层用 JavaBean 和 DAO 类来承载数据和数据库操作Controller 层用 Servlet 来接收请求、调用业务逻辑、决定跳转方向View 层就是 JSP 页面只负责把传过来的数据渲染成 HTML。这样分工之后JSP 里几乎不出现Class.forName和Connection这些字眼页面代码干净得像纯静态模板。具体到图书购物系统Model 层至少需要这几个 JavaBeanBook图书实体、User用户实体、CartItem购物车条目、Order订单实体。DAO 层对应BookDao、UserDao、OrderDao每个 DAO 类里封装增删改查方法。Controller 层按功能模块划分 Servlet比如BookListServlet负责查询图书列表AddToCartServlet处理加入购物车LoginServlet处理登录验证。View 层就是book_list.jsp、cart.jsp、login.jsp这些页面它们通过 request 作用域拿到数据用 JSTL 标签库遍历输出。2.2 一个 Servlet 的完整请求处理流程以图书列表页为例用户访问/bookList这个 URLTomcat 根据web.xml或注解配置把请求交给BookListServlet。Servlet 的doGet方法里先调用BookDao.findAll()拿到ListBook然后request.setAttribute(bookList, list)最后request.getRequestDispatcher(/book_list.jsp).forward(request, response)。JSP 页面里用c:forEach items${bookList} varbook遍历输出。整个链路清晰得可以画成一条直线。// BookListServlet.java WebServlet(/bookList) public class BookListServlet extends HttpServlet { protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 设置编码防止中文乱码 request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); BookDao bookDao new BookDao(); ListBook bookList bookDao.findAll(); // 查询所有图书 // 将数据存入 request 作用域供 JSP 页面使用 request.setAttribute(bookList, bookList); // 转发到图书列表页面 request.getRequestDispatcher(/book_list.jsp).forward(request, response); } }这段代码的关键点有三个第一WebServlet(/bookList)注解代替了web.xml配置Servlet 3.0 之后推荐这种写法第二request.setCharacterEncoding(UTF-8)必须放在获取任何参数之前否则 POST 请求里的中文会变成乱码第三用forward而不是redirect因为 forward 是服务器内部跳转request 作用域里的数据不会丢失redirect 会导致浏览器重新发起请求bookList属性就没了。2.3 DAO 层怎么写才不算「裸写 JDBC」很多教程里的 DAO 就是直接拼 SQL 字符串然后用Statement执行。这种写法有两个致命问题SQL 注入风险和连接泄漏。图书购物系统里用户搜索图书时如果输入 OR 11用Statement拼接的查询会把整张表都查出来。正确做法是全部使用PreparedStatement参数用?占位然后setString、setInt逐个赋值。// BookDao.java 中的搜索方法 public ListBook searchByKeyword(String keyword) { ListBook list new ArrayList(); String sql SELECT * FROM book WHERE title LIKE ? OR author LIKE ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { // 前后加 % 实现模糊匹配 ps.setString(1, % keyword %); ps.setString(2, % keyword %); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Book book new Book(); book.setId(rs.getInt(id)); book.setTitle(rs.getString(title)); book.setAuthor(rs.getString(author)); book.setPrice(rs.getBigDecimal(price)); book.setStock(rs.getInt(stock)); list.add(book); } } } catch (SQLException e) { e.printStackTrace(); } return list; }这里用了 try-with-resources 语法Connection、PreparedStatement、ResultSet都会在代码块结束时自动关闭不需要手动写finally里的close()。DBUtil是一个工具类内部维护数据库连接参数可以用简单的DriverManager实现也可以换成 Druid 或 HikariCP 连接池。对于课程设计级别的项目DriverManager就够了但要注意每次获取连接都会新建一个物理连接并发量大了会拖垮 MySQL。如果想让项目看起来更专业引入 Druid 连接池只需要改DBUtil一个类上层 DAO 代码完全不用动。3. MySQL 表结构设计与购物车、订单的核心逻辑3.1 图书购物系统需要哪几张表一个能跑通完整流程的图书购物系统至少需要五张表user用户、book图书、category分类、cart购物车、orders订单和order_item订单明细。user表存用户名、密码、邮箱、角色普通用户/管理员。book表存书名、作者、出版社、ISBN、价格、库存、封面图片路径、分类 ID。category表存分类名称。cart表存用户 ID、图书 ID、数量。orders表存订单号、用户 ID、下单时间、总金额、收货地址、订单状态。order_item表存订单 ID、图书 ID、购买数量、购买时的单价。这里有一个容易忽略的细节order_item里的单价必须单独存一份不能只靠book表的当前价格。因为图书价格会变如果订单明细只关联图书 ID用户三个月后查看历史订单看到的是涨价后的价格这显然不合理。同样图书被下架删除后订单明细里的图书信息也不能跟着消失所以order_item里最好冗余存一份书名和单价。-- 图书表建表语句 CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, author VARCHAR(100), publisher VARCHAR(100), isbn VARCHAR(20), price DECIMAL(10,2) NOT NULL DEFAULT 0.00, stock INT NOT NULL DEFAULT 0, cover_img VARCHAR(255), category_id INT, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;ENGINEInnoDB是必须的因为后面下单要用事务MyISAM 不支持事务。CHARSETutf8mb4而不是utf8因为utf8在 MySQL 里是残缺的存不了 emoji 和部分生僻字utf8mb4才是真正的 UTF-8。price用DECIMAL(10,2)而不是FLOAT浮点数在金额计算时会出现0.1 0.2 0.30000000000000004这种玄学问题DECIMAL是精确十进制适合金额字段。3.2 购物车用 Session 还是存数据库这是图书购物系统设计时第一个要做的选择。用 Session 存购物车优点是实现简单不需要建表读写速度快。缺点是用户换浏览器或清 Cookie 后购物车就丢了而且服务器重启 Session 也会失效。用数据库存购物车优点是持久化用户在任何设备登录都能看到购物车。缺点是每次增删改查都要访问数据库性能稍差。我的建议是课程设计或小型项目用 Session 就够了因为实现简单代码量少答辩时也好解释。但如果想做得更完整建议用数据库存购物车同时在用户未登录时用 Cookie 暂存登录后合并到数据库。这样既保证了用户体验又体现了对持久化的理解。// 加入购物车的 Servlet 核心逻辑Session 方案 protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { int bookId Integer.parseInt(request.getParameter(bookId)); // 从 Session 获取当前购物车如果没有则新建 MapInteger, Integer cart (MapInteger, Integer) request.getSession().getAttribute(cart); if (cart null) { cart new HashMap(); } // 如果已存在则数量加一否则新增条目 cart.put(bookId, cart.getOrDefault(bookId, 0) 1); request.getSession().setAttribute(cart, cart); response.sendRedirect(cart.jsp); }这段代码用MapInteger, Integer存购物车key 是图书 IDvalue 是数量。getOrDefault是 Java 8 的方法如果 key 不存在就返回默认值 0然后加一。注意最后用sendRedirect而不是forward因为加入购物车是一个「操作」而不是「查询」重定向可以防止用户刷新页面时重复提交。3.3 下单时的事务处理与库存扣减下单是整个系统里唯一必须用事务的地方。用户点击「提交订单」后系统要做三件事在orders表插入一条订单记录在order_item表插入多条明细记录在book表扣减对应图书的库存。这三步必须全部成功或全部失败否则会出现「订单生成了但库存没扣」或者「库存扣了但订单没生成」的脏数据。// OrderService.java 中的下单方法 public boolean createOrder(int userId, MapInteger, Integer cart, String address) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 计算总金额并插入订单主表 BigDecimal total BigDecimal.ZERO; for (Map.EntryInteger, Integer entry : cart.entrySet()) { Book book bookDao.findById(entry.getKey()); total total.add(book.getPrice().multiply(new BigDecimal(entry.getValue()))); } int orderId orderDao.insertOrder(conn, userId, total, address); // 2. 插入订单明细并扣减库存 for (Map.EntryInteger, Integer entry : cart.entrySet()) { int bookId entry.getKey(); int quantity entry.getValue(); Book book bookDao.findById(bookId); // 检查库存是否充足 if (book.getStock() quantity) { conn.rollback(); return false; } orderDao.insertOrderItem(conn, orderId, bookId, quantity, book.getPrice()); bookDao.reduceStock(conn, bookId, quantity); } conn.commit(); // 全部成功提交事务 return true; } catch (SQLException e) { if (conn ! null) { try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这段代码的关键在于所有 DAO 方法都接收同一个Connection参数而不是各自去DBUtil.getConnection()。因为事务是绑定在连接上的如果每个 DAO 方法都新建连接事务就失效了。conn.setAutoCommit(false)开启事务后所有 SQL 操作都在同一个事务里最后conn.commit()一次性提交。如果中途任何一步失败conn.rollback()会撤销所有已执行的操作。库存检查放在扣减之前如果库存不足直接回滚并返回 false前端提示用户「库存不足」。4. 图书购物系统部署与联调的避坑记录4.1 中文乱码的三层排查现象图书列表页显示正常但搜索「Java编程」时返回空结果或者后台添加图书后书名变成问号。原因中文乱码可能出现在三个地方——JSP 页面编码、Servlet 请求编码、MySQL 连接编码。任何一层没设对都会导致乱码。解决第一每个 JSP 页面顶部加% page contentTypetext/html;charsetUTF-8 languagejava %。第二Servlet 的doGet和doPost第一行加request.setCharacterEncoding(UTF-8)注意doGet的编码设置对 URL 参数无效需要在 Tomcat 的server.xml里给 Connector 加URIEncodingUTF-8。第三JDBC 连接 URL 加?useUnicodetruecharacterEncodingutf8MySQL 8 驱动还要加serverTimezoneAsia/Shanghai否则会报时区错误。4.2 数据库连接池耗尽导致页面卡死现象本地测试一切正常部署到服务器后运行一段时间所有页面都打不开重启 Tomcat 后恢复过一会儿又卡死。原因每次数据库操作都新建Connection用完没有关闭连接数不断累积最终 MySQL 达到max_connections上限新请求全部阻塞。解决把所有Connection的获取和关闭统一收口到DBUtil用 try-with-resources 确保自动关闭。如果项目并发量稍大引入 Druid 连接池在druid.properties里配置maxActive20、maxWait3000连接用完归还池子而不是物理关闭。另外在 MySQL 端执行SHOW PROCESSLIST可以看到当前所有连接如果发现大量Sleep状态的连接说明代码里有泄漏。4.3 订单重复提交的三种拦截方式现象用户点击「提交订单」后网络卡顿用户以为没成功又点了一次结果生成两笔一模一样的订单。原因HTTP 请求是幂等的但下单操作不是。每次请求都会执行一次插入没有任何去重机制。解决第一种方式前端提交后立即禁用按钮用 JavaScript 把按钮设为disabled。第二种方式在 Session 里存一个 token下单时校验 token 是否存在校验通过后立即删除。第三种方式在orders表加唯一索引比如user_id create_time组合唯一重复插入会抛异常。三种方式可以叠加使用前端拦截体验最好后端 token 校验最可靠。4.4 JSP 页面引入 CSS 和图片 404现象JSP 页面能打开但样式全丢图片显示裂图浏览器控制台报 404。原因JSP 页面通过forward跳转后浏览器地址栏还是 Servlet 的路径比如/bookList此时页面里写的相对路径css/style.css会被解析成/css/style.css而实际文件在/WEB-INF/同级目录下。解决在 JSP 页面顶部用% String path request.getContextPath(); %获取项目上下文路径所有静态资源引用都写成%path%/css/style.css和%path%/images/logo.png。或者用 JSTL 的c:url value/css/style.css/标签效果一样但更简洁。如果项目部署在 Tomcat 的根目录下path为空字符串这种写法也能兼容。5. 用过滤器统一字符编码和登录拦截的进阶技巧5.1 一个 Filter 解决全站编码问题前面在每个 Servlet 里写request.setCharacterEncoding(UTF-8)是重复劳动而且容易漏。更好的做法是写一个CharacterEncodingFilter在web.xml或注解里配置拦截所有请求统一设置编码。WebFilter(/*) public class CharacterEncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; request.setCharacterEncoding(UTF-8); response.setContentType(text/html;charsetUTF-8); chain.doFilter(request, response); // 放行交给下一个过滤器或 Servlet } }WebFilter(/*)表示拦截所有请求包括静态资源。chain.doFilter是放行操作如果不调用请求就会被拦截在这里页面永远打不开。这个过滤器要放在所有过滤器的最前面确保编码设置在其他逻辑之前生效。5.2 登录拦截器与白名单配置图书购物系统里购物车、下单、个人中心这些页面必须登录才能访问。用 Filter 实现登录校验同时配置白名单让登录页、注册页、图书列表页不需要登录也能访问。WebFilter(/user/*) public class LoginFilter implements Filter { // 不需要登录的路径 private static final String[] WHITE_LIST {/login.jsp, /register.jsp, /login, /bookList}; public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); String path uri.substring(request.getContextPath().length()); // 白名单直接放行 for (String white : WHITE_LIST) { if (path.startsWith(white)) { chain.doFilter(request, response); return; } } // 检查 Session 中是否有登录用户 Object user request.getSession().getAttribute(user); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); } else { chain.doFilter(request, response); } } }这里把拦截路径设为/user/*意味着所有/user/开头的请求都会经过这个过滤器。白名单用startsWith匹配/bookList虽然不在/user/下但为了保险也加进去。判断登录状态靠 Session 里的user属性登录成功后session.setAttribute(user, user)退出时session.invalidate()或removeAttribute(user)。5.3 用 JSTL 和 EL 表达式简化 JSPJSP 里写% for(Book book : list) { %这种脚本片段已经过时了现在推荐用 JSTL 标签库配合 EL 表达式。EL 表达式${book.title}会自动调用book.getTitle()不需要 import 任何类。JSTL 的c:forEach替代 for 循环c:if替代 if 判断fmt:formatDate格式化日期fmt:formatNumber格式化金额。% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % table c:forEach items${bookList} varbook varStatusstatus tr td${status.index 1}/td td${book.title}/td td${book.author}/td tdfmt:formatNumber value${book.price} pattern0.00//td td c:choose c:when test${book.stock 0}有货/c:when c:otherwise缺货/c:otherwise /c:choose /td /tr /c:forEach /tablevarStatusstatus提供了循环状态status.index从 0 开始status.count从 1 开始。c:choose相当于 switch-case比多个c:if更清晰。fmt:formatNumber的pattern属性可以自定义格式0.00表示人民币符号加两位小数。用 JSTL 之后JSP 页面里几乎看不到 Java 代码前端人员也能看懂和修改。5.4 分页查询的 SQL 写法与页码计算图书列表数据多了必须分页否则一次查几千条会把内存撑爆。MySQL 分页用LIMIT offset, sizeoffset是起始行号size是每页条数。假设每页 10 条第 1 页是LIMIT 0, 10第 2 页是LIMIT 10, 10第 n 页是LIMIT (n-1)*10, 10。public ListBook findByPage(int pageNum, int pageSize) { ListBook list new ArrayList(); String sql SELECT * FROM book ORDER BY id DESC LIMIT ?, ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, (pageNum - 1) * pageSize); // 起始行 ps.setInt(2, pageSize); // 每页条数 try (ResultSet rs ps.executeQuery()) { while (rs.next()) { // 封装 Book 对象代码同上 } } } catch (SQLException e) { e.printStackTrace(); } return list; }总页数需要单独查一次SELECT COUNT(*) FROM book然后totalPages (totalCount pageSize - 1) / pageSize。这个公式用整数除法向上取整比如 21 条数据每页 10 条(219)/10 3页。前端传页码时要做边界校验如果pageNum 1设为 1如果pageNum totalPages设为totalPages防止 SQL 报错或返回空列表。5.5 图书封面图片上传的路径处理后台添加图书时需要上传封面图片。JSP 的form要加enctypemultipart/form-dataServlet 端用MultipartConfig注解开启文件上传支持然后通过request.getPart(cover)获取文件。WebServlet(/admin/addBook) MultipartConfig(maxFileSize 5 * 1024 * 1024) // 最大 5MB public class AddBookServlet extends HttpServlet { protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { String title request.getParameter(title); Part coverPart request.getPart(cover); String fileName System.currentTimeMillis() _ coverPart.getSubmittedFileName(); // 保存到项目部署目录下的 upload 文件夹 String savePath getServletContext().getRealPath(/upload); File uploadDir new File(savePath); if (!uploadDir.exists()) uploadDir.mkdirs(); coverPart.write(savePath File.separator fileName); // 数据库里存相对路径 String coverImg upload/ fileName; // 调用 BookDao 插入图书coverImg 作为参数 } }getSubmittedFileName()获取原始文件名前面拼时间戳防止重名覆盖。getRealPath(/upload)拿到的是项目在 Tomcat 里的实际部署路径注意这个路径在重新部署 war 包后会被清空生产环境应该存到项目目录之外的固定位置。数据库里只存相对路径upload/xxx.jpgJSP 页面用img src${pageContext.request.contextPath}/${book.coverImg}显示这样项目迁移时不用改数据。5.6 用 EXPLAIN 检查慢查询图书搜索功能如果数据量大了会变慢用EXPLAIN命令可以看 MySQL 有没有走索引。在book表的title和author字段上建索引EXPLAIN SELECT * FROM book WHERE title LIKE %Java%会显示typeALL全表扫描因为LIKE以%开头无法使用索引。解决办法是把搜索改成全文索引或者用LIKE Java%前缀匹配。如果业务允许还可以引入 Elasticsearch 做全文检索但那是另一个话题了。对于课程设计级别的项目几千条数据全表扫描完全能接受不需要过度优化。-- 在 title 和 author 上建普通索引 CREATE INDEX idx_book_title ON book(title); CREATE INDEX idx_book_author ON book(author); -- 查看查询执行计划 EXPLAIN SELECT * FROM book WHERE title LIKE Java%;EXPLAIN结果里重点看type列ALL是全表扫描ref或range是走了索引index是全索引扫描。rows列是预估扫描行数越小越好。Extra列出现Using filesort说明有额外排序出现Using temporary说明用了临时表这两个都值得关注。索引不是越多越好每个索引都会增加插入和更新的开销一般只在 WHERE、ORDER BY、JOIN 涉及的字段上建索引。5.7 项目打包与 Tomcat 部署的注意事项IDEA 里用 Maven 打包pom.xml里packagingwar/packaging执行mvn clean package生成 war 文件丢到 Tomcat 的webapps目录下启动 Tomcat 会自动解压部署。注意 MySQL 驱动包的版本要和 MySQL 服务器版本匹配MySQL 8 用mysql-connector-java-8.0.xMySQL 5.7 用mysql-connector-java-5.1.x。驱动类名也不同MySQL 8 是com.mysql.cj.jdbc.DriverMySQL 5.7 是com.mysql.jdbc.Driver。连接 URL 里 MySQL 8 必须加serverTimezone否则启动就报错。我自己的习惯是本地开发用 Tomcat 插件跑mvn tomcat7:run或mvn tomcat9:run不用手动配 Tomcat。部署到服务器时用scp把 war 包传上去然后systemctl restart tomcat。日志看logs/catalina.out数据库连接问题、Servlet 初始化异常都在里面。每次改完代码重新打包部署记得清一下浏览器缓存JSP 编译后的 class 文件在 Tomcat 的work目录下有时候改了 JSP 不生效就是缓存问题删掉work目录重启即可。希望这些踩坑经验能帮你少走弯路。本文还有配套的精品资源点击获取