ARTICLE DETAIL

资讯详情

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

Java Web网上书店系统:Servlet+JSP+JDBC全链路与事务优化

Java Web网上书店系统:Servlet+JSP+JDBC全链路与事务优化 简介这是一份Java Web网上书店后台管理系统课程设计文档面向计算机科学与技术等专业学生及Java Web初学者用于完成基于B/S架构的管理系统课设任务。文档围绕网上书店场景涉及JSP、Servlet、JavaBean、MySQL与Eclipse、Tomcat开发环境包含需求分析、系统介绍、数据库表设计、模块结构图、主要功能流程图并覆盖注册登录、购物车、后台登录、用户管理、图书管理等功能模块数据库部分给出userdetail表中用户名、密码、权限、注册时间、登录次数以及books表中图书编码、书名、出版社ID、价格、库存、简介等字段设计并附有部分页面代码实现。资源包仅含1个doc文件约845KB结构完整文档按系统介绍、数据库表结构、模块结构图、功能流程图及心得体会等顺序编排便于直接查阅或改编为课程设计报告。目前已有548人学习下载适合需要参考完整课程设计思路、数据库设计、功能模块划分与实验报告写法的读者也可作为Java Web综合实践项目的排错与扩展参考。1. 小型网上书店系统的技术选型与整体认知课程设计里出现频率很高的一类题目就是网上书店系统看着简单真动手会发现它把 Java Web 的核心链路几乎全串了一遍数据库建模、DAO 封装、Servlet 请求分发、Session 状态管理、JSP 页面渲染、事务控制、分页查询。小型网上书店系统的业务边界通常限定在图书检索浏览、加入购物车、下单结算、订单查询这四条主线上不做支付对接、不做物流跟踪。技术栈按课程设计最常见、资料最全的组合来选就是 JDK 8 及以上、Servlet 3.1 规范、Tomcat 8.5 或 9、MySQL 5.7 或 8.0、JDBC 直连不走 ORM 框架。这么做的好处是每一层都看得见答辩时老师随手翻一处代码就能问到底层 SQL 怎么写不存在框架帮你把逻辑藏起来的情况。适合刚学完 Java SE 和数据库、需要一次性把前后端串起来的中高年级学生也适合想拿一个完整小项目复习 Servlet 生命周期的开发者。2. 网上书店系统的数据库表设计与 DAO 层 JDBC 封装答辩里最容易被追问的不是页面长什么样而是表建得对不对、SQL 写在哪里。网上书店系统要落地的数据关系并不复杂但涉及用户、图书、交易三种类型的数据如果不提前把表结构定清楚写到购物车和订单模块时就会反复改表。常见做法是先画 ER 图再写建表 SQL最后按表逐个写 DAO。这个顺序不能反过来否则你很容易在 DAO 里写一堆临时拼出来的字段后面改一处字段名就要改三四个文件。2.1 用户、图书、订单三张核心表的字段设计先看核心表结构的字段规划建表时把主键、索引和约束一次定好后面 DAO 层就能按固定字段来写。表名核心字段用途说明userid, username, password, email, create_time存储注册用户password 存 MD5 加盐值bookid, isbn, title, author, publisher, price, stock, category_id, cover_url图书主数据stock 直接放这里categoryid, name, sort_order图书分类用于前台筛选ordersid, order_no, user_id, total_amount, status, create_time订单主表记录一笔交易的整体信息order_itemid, order_id, book_id, quantity, unit_price订单明细记录成交时的单价重点说两个设计决策。第一stock库存字段直接放在book表上不单独拉一张库存表因为小型网上书店系统不存在多仓库出库场景拆表反而增加 join 成本事务控制也更麻烦。第二订单明细里必须冗余记录unit_price成交单价不能只存book_id然后 join 回图书表取价格否则图书调价后历史订单金额就跟着变了。order_no用一个带时间戳和随机数的字符串生成不要用自增 id 当订单号暴露给前端。图书表的title字段加普通索引用于关键词检索时的模糊匹配。2.2 用 JDBC 工具类封装连接与 DAO 基类课程设计不引入连接池框架也能跑但连接必须统一管理不能在每个 DAO 方法里各写各的DriverManager.getConnection()。public class DBUtil { // 数据库地址、用户名、密码按实际环境修改 private static final String URL jdbc:mysql://localhost:3306/bookstore ?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; static { try { // MySQL 8 使用 com.mysql.cj.jdbc.Driver Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new RuntimeException(MySQL 驱动加载失败, e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { // 逆序关闭先关结果集再关语句最后关连接 try { if (rs ! null) rs.close(); } catch (SQLException ignored) {} try { if (ps ! null) ps.close(); } catch (SQLException ignored) {} try { if (conn ! null) conn.close(); } catch (SQLException ignored) {} } }JDBC 工具类解决的是连接从哪来的问题DAO 基类解决的是增删改查模板代码重复的问题。常见做法是定义一个泛型基类把update和query两个方法固定下来子类只需要传 SQL 和参数。public abstract class BaseDao { // 通用更新返回受影响行数 protected int update(String sql, Object... params) throws SQLException { try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { // 参数从 1 开始绑定注意不是 0 for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } return ps.executeUpdate(); } } // 通用查询由子类实现 RowMapper 完成结果集到对象的映射 protected T ListT query(String sql, RowMapperT mapper, Object... params) throws SQLException { ListT list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { for (int i 0; i params.length; i) { ps.setObject(i 1, params[i]); } try (ResultSet rs ps.executeQuery()) { while (rs.next()) { list.add(mapper.map(rs)); } } } return list; } }RowMapper是一个只有一个方法的接口子类用 lambda 或匿名类实现它把ResultSet当前行转成 Java 对象。这种写法比在每个 DAO 里重复写while(rs.next())循环要干净得多答辩时也容易讲清楚封装思路。update方法里使用 try-with-resources 自动关连接和语句但query里手动多套了一层ResultSet的关闭因为 try-with-resources 的关闭顺序同样是从内到外这样更稳妥。注意Class.forName在 Servlet 容器里只加载一次写在静态代码块中可以利用类加载机制保证驱动一定在第一次getConnection之前完成注册。2.3 图书实体类与三层架构的职责边界实体类和数据表一一对应字段名用驼峰对应下划线DAO 层负责映射。实体类里不写业务逻辑只放属性、getter/setter 和构造方法。三层架构的职责边界在课程设计里必须说清楚Servlet 负责接收参数和转发页面Service 层负责组装业务逻辑和事务DAO 层只做单表 SQL 操作。最常见的错误是把事务写在 DAO 里导致 Service 无法控制多个 DAO 调用在同一个事务中完成。public class Book { private Integer id; private String isbn; private String title; private String author; private String publisher; private BigDecimal price; // 金额用 BigDecimal不用 double private Integer stock; private Integer categoryId; private String coverUrl; // 省略 getter/setterIDEA 快捷键 AltInsert 生成 }BookDao继承BaseDao在类里定义一个ROW_MAPPER常量实现RowMapperBook把所有字段逐一从ResultSet里取出并塞进新对象。后面按关键词分页查书的findByPage和按 id 查单本书的findById都复用这个映射器。3. Servlet 与 JSP 实现图书浏览检索与购物车链路前端页面和 Servlet 的配合是课程设计里代码量最大的部分。图书列表要支持分页和关键词检索购物车要支持增删改查页面要能实时反映会话状态。这一层的关键不是写多少页面而是请求参数的校验和会话数据结构的选型。3.1 BookListServlet 处理分页查询与关键词检索列表页的核心是分页参数解析和关键词拼接。如果用字符串拼接 SQLkeyword参数必须做空值和转义处理否则要么语法报错要么被注入。WebServlet(/book/list) public class BookListServlet extends HttpServlet { private final BookDao bookDao new BookDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 当前页码默认 1每页条数默认 8 int page parseIntOrDefault(req.getParameter(page), 1); int size parseIntOrDefault(req.getParameter(size), 8); String keyword req.getParameter(keyword); // 空字符串统一按 null 处理DAO 层用 if 判断是否拼接 LIKE 条件 if (keyword ! null keyword.trim().isEmpty()) { keyword null; } ListBook books bookDao.findByPage(keyword, (page - 1) * size, size); int total bookDao.countByKeyword(keyword); req.setAttribute(books, books); req.setAttribute(total, total); req.setAttribute(page, page); // 向上取整计算总页数避免最后一页丢失 req.setAttribute(totalPages, (total size - 1) / size); req.setAttribute(keyword, keyword); req.getRequestDispatcher(/WEB-INF/jsp/book_list.jsp).forward(req, resp); } private int parseIntOrDefault(String value, int def) { try { return Integer.parseInt(value); } catch (NumberFormatException e) { return def; } } }page参数是用户可控的如果不做数字校验传pageabc会直接抛异常导致 500。(page - 1) * size就是 SQL 里LIMIT的第一个参数偏移量。totalPages的计算一定要用向上取整公式(total size - 1) / size用整除会丢掉最后一页不满的数据。关键词检索走LIKE %keyword%但只在 DAO 方法内拼接%号Servlet 只传原始关键词职责分开。3.2 HttpSession 实现购物车增删改查购物车的存储方案直接决定后面的代码结构课程设计里三种方案的对比如下。方案存储位置优点缺点适用场景Session 购物车服务器内存实现简单与用户会话天然绑定分布式部署不共享服务重启丢失课程设计、单机部署Cookie 购物车客户端浏览器不占服务器内存容量受限用户可篡改未登录用户临时存储数据库购物车数据库表持久化可跨设备访问每次操作都读写数据库登录用户的正式系统课程设计一律推荐 Session 购物车用LinkedHashMapInteger, CartItem来存key 是图书 idvalue 包含图书对象和数量。// 加入购物车先取会话里的购物车没有就新建 SuppressWarnings(unchecked) MapInteger, CartItem cart (MapInteger, CartItem) req.getSession().getAttribute(cart); if (cart null) { cart new LinkedHashMap(); // LinkedHashMap 保证按加入顺序展示 req.getSession().setAttribute(cart, cart); } int bookId Integer.parseInt(req.getParameter(bookId)); CartItem item cart.get(bookId); if (item null) { Book book bookDao.findById(bookId); if (book null || book.getStock() 0) { resp.sendRedirect(req.getContextPath() /book/list?msgstock_empty); return; } cart.put(bookId, new CartItem(book, 1)); } else { // 数量不能超过库存 if (item.getQuantity() 1 item.getBook().getStock()) { item.setQuantity(item.getQuantity() 1); } } resp.sendRedirect(req.getContextPath() /cart/list);用LinkedHashMap而不是HashMap的原因很实际用户在购物车页面看到的顺序应该和加入顺序一致HashMap的迭代顺序不稳定会出现商品在购物车里“跳位置”的现象。加入购物车时校验库存避免把已经售罄的书加进去下单时再校验一次。修改数量和删除商品只需从Map里取出CartItem改数量或remove(bookId)再重定向回购物车列表页即可。3.3 JSP 与 JSTL 渲染图书列表和购物车页面JSP 页面禁止写 Java 脚本片段所有逻辑在 Servlet 里完成。页面只做展示和循环。% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % % taglib prefixfmt urihttp://java.sun.com/jsp/jstl/fmt % div classbook-grid c:forEach items${books} varbook div classbook-card img src${pageContext.request.contextPath}/static/cover/${book.coverUrl} alt${book.title} h3${book.title}/h3 p作者${book.author}/p p classpricefmt: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:otherwisespan classsold-out无货/span/c:otherwise /c:choose /div /c:forEach /divfmt:formatNumber用于金额格式化把BigDecimal显示成两位小数加千分位。c:choose根据库存决定显示“加入购物车”还是“无货”。pageContext.request.contextPath保证上下文路径变化时链接不失效。分页导航条放在列表下方用c:if控制上一页/下一页按钮的显示状态当前页码加粗高亮。提示JSP 放在/WEB-INF/jsp/目录下浏览器不能直接访问只能通过 Servlet 转发这是 MVC 规范的基本要求。4. 订单结算模块的 JDBC 事务与库存扣减并发控制下单是整个系统里唯一需要同时操作多张表的业务。订单主表、订单明细表、图书库存表三者必须在同一个事务里完成任何一步失败都要整体回滚。这一章把事务控制和并发场景分开讲因为课程设计里能跑通事务是一回事能不能扛住并发是另一回事。4.1 订单主表与明细表的拆分设计订单拆成主表和明细表是电商类系统的标准做法。主表记录订单号、用户、总金额、状态明细表记录每一本书的 id、数量、成交单价。状态值含义触发条件可流转到0待支付用户提交订单1 或 -11已支付支付成功模拟2 或 -12已发货管理员操作33已完成用户确认收货终态-1已取消用户取消或超时终态订单号生成规则用yyyyMMddHHmmss加 4 位随机数保证同一秒内不会重复。总金额在插入订单前由购物车里的单价乘以数量累加得出存到orders.total_amount字段。状态字段用整数而不是字符串便于后续按状态做索引查询。明细表的外键order_id关联主表级联删除不设因为订单数据不应该被物理删除。4.2 手动事务控制实现下单与扣库存的原子操作JDBC 默认是自动提交模式每条 SQL 执行完立刻生效。下单需要手动把autoCommit设为false在所有操作成功后再显式commit出错时rollback。public boolean createOrder(Order order, ListOrderItem items) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交开启事务 // 第一步插入订单主表并取回自增主键 String orderSql INSERT INTO orders(order_no, user_id, total_amount, status, create_time) VALUES(?, ?, ?, 0, NOW()); PreparedStatement ps1 conn.prepareStatement(orderSql, Statement.RETURN_GENERATED_KEYS); ps1.setString(1, order.getOrderNo()); ps1.setInt(2, order.getUserId()); ps1.setBigDecimal(3, order.getTotalAmount()); ps1.executeUpdate(); ResultSet rs ps1.getGeneratedKeys(); int orderId 0; if (rs.next()) orderId rs.getInt(1); // 第二步逐条扣减库存并写入订单明细 String stockSql UPDATE book SET stock stock - ? WHERE id ? AND stock ?; String itemSql INSERT INTO order_item(order_id, book_id, quantity, unit_price) VALUES(?, ?, ?, ?); PreparedStatement ps2 conn.prepareStatement(stockSql); PreparedStatement ps3 conn.prepareStatement(itemSql); for (OrderItem item : items) { ps2.setInt(1, item.getQuantity()); ps2.setInt(2, item.getBookId()); ps2.setInt(3, item.getQuantity()); // 库存不足时 affected 为 0 int affected ps2.executeUpdate(); if (affected 0) { throw new RuntimeException(库存不足bookId item.getBookId()); } ps3.setInt(1, orderId); ps3.setInt(2, item.getBookId()); ps3.setInt(3, item.getQuantity()); ps3.setBigDecimal(4, item.getUnitPrice()); ps3.executeUpdate(); } conn.commit(); // 全部成功提交事务 return true; } catch (Exception e) { // 任何一步异常都回滚保证不出现半截订单 if (conn ! null) { try { conn.rollback(); } catch (SQLException ignored) {} } return false; } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ignored) {} } } }关键点在UPDATE book SET stock stock - ? WHERE id ? AND stock ?这条 SQL 上。把库存判断条件写进WHERE里由数据库保证原子性而不是先SELECT查库存再判断。任何一条明细扣减失败都抛异常触发回滚已插入的订单主表和之前的明细全部撤销。finally里恢复autoCommit并关闭连接防止连接归还到连接池后仍是手动提交模式。4.3 并发场景下的库存超卖与乐观锁方案上面的事务写法能解决“插入失败回滚”的问题但解决不了两个用户同时下单最后一本书的并发问题。比如库存只剩 1 本两个线程几乎同时执行UPDATEMySQL 的行锁会让它们串行执行第一个扣减成功第二个affected为 0 抛异常回滚——这个逻辑本身是对的。真正需要额外处理的是购物车层面的读取和下单之间的时间差用户 A 看到有货加入购物车下单时可能已经没货了。-- 乐观锁写法在 book 表加 version 字段扣减时带上版本号 UPDATE book SET stock stock - ?, version version 1 WHERE id ? AND stock ? AND version ?;乐观锁适合并发冲突不高的课程设计场景每次下单前从购物车项里取出图书的 version 值更新时比对。如果返回 0说明版本已被别人改过提示用户刷新重试。悲观锁用SELECT ... FOR UPDATE也行但会阻塞其他读操作课程设计里不推荐答辩时说清楚两种方案的取舍就够了。注意无论用乐观锁还是悲观锁购物车中存储的图书快照价格、库存在每次进入结算页时都要重新从数据库读取不能直接用 Session 里的旧数据。5. 小型网上书店系统的分页查询优化与部署排错前面四章把功能链路跑通了这一章收在两个具体技巧上一个是从数据量增长后才会暴露的分页性能问题另一个是部署到 Tomcat 后最常见的几类报错。这两块内容在答辩时属于加分项因为大部分课程设计只保证功能可用不会主动考虑查询效率和线上排查。5.1 MySQL 深分页的 SQL 优化方案课程设计的数据量通常只有几百条LIMIT怎么写性能都一样。但答辩时老师可能会问“数据量大了怎么优化”这里准备一个说法。-- 普通分页偏移量越大MySQL 需要扫描并丢弃的行越多 SELECT * FROM book ORDER BY id LIMIT 10000, 10; -- 延迟关联优化先用覆盖索引取主键再用主键回表取完整行 SELECT b.* FROM book b INNER JOIN (SELECT id FROM book ORDER BY id LIMIT 10000, 10) t ON b.id t.id; -- 游标法记住上一页最后一条记录的 id下一页按 id lastId 查询 SELECT * FROM book WHERE id 10000 ORDER BY id LIMIT 10;延迟关联的核心思路是让子查询只扫描id这一列走覆盖索引拿到 10 个 id 后再回表取整行数据。游标法适合“下一页”这种顺序翻页的场景不适合跳页。课程设计里如果用了 MyBatis 的分页插件也要能说出底层生成的就是LIMIT offset, size这种 SQL以及为什么深分页会慢。图书列表页只查询book表id是主键延迟关联优化生效的前提是ORDER BY id走主键索引如果按create_time排序需要给create_time加索引后再对比执行计划。5.2 部署验证清单与常见报错排查代码写完只是第一步把项目扔进 Tomcat 的webapps目录后能否正常访问、中文是否乱码、数据库能否连通都需要逐项验证。检查项验证方式常见问题数据库连通启动后查看控制台日志Communications link failure检查 MySQL 是否启动、端口是否对驱动加载确认WEB-INF/lib下有驱动 jarClassNotFoundException驱动包缺失或版本不对中文编码页面输入中文搜索看是否乱码请求解码用request.setCharacterEncoding(UTF-8)数据库连接串带characterEncodingutf8Servlet 映射直接访问对应 URL404 通常是WebServlet路径写错或web.xml版本不匹配静态资源访问 CSS/JS 是否 200路径要带contextPath否则部署到非 ROOT 上下文会 404中文乱码是最常踩的坑。GET 请求的中文参数在 Tomcat 8 以后默认按UTF-8解码一般没问题POST 请求必须在读取参数之前调用request.setCharacterEncoding(UTF-8)写在doPost方法的第一行。数据库连接串里characterEncodingutf8和serverTimezone一个都不能少少了前者存进去是问号少了后者启动就报时区异常。排错的基本顺序是先看 Tomcat 控制台的堆栈信息定位到具体行再看浏览器 Network 面板的请求响应最后单独用 JDBC 小程序连一次数据库确认连接串本身没问题。把这个顺序固化成习惯答辩演示时遇到报错也不至于卡壳。本文还有配套的精品资源点击获取
返回列表