☰
从零手搓JavaWeb图书订阅管理系统:Servlet+JSP+JDBC完整实战
2026/10/8 4:38:29 网站建设 项目流程

简介:这是一套面向JavaWeb初学者与课程设计需求的图书订阅管理系统源码,基于Servlet、JSP与JSTL构建,配合MySQL数据库和Tomcat服务器运行,适合用来练习用户登录注册、图书浏览、购物车结算与后台图书增删改查等典型业务场景。压缩包共76个文件,约5.16MB,其中37个java文件承载Servlet与业务逻辑,20个jsp页面负责前台展示与后台管理界面,另有9个jar依赖包、1个sql初始化脚本及properties、xml等配置资源,目录按src、web、pages等模块划分,结构清晰便于对照学习。目前已有677人学习下载。读者可从中获得一套可直接部署运行的完整项目,理解MVC分层思路、数据库表结构设计、JDBC连接配置以及分页检索、安全过滤、错误处理等实现细节,适合作为课程作业、毕业设计参考或JavaWeb入门练手项目。

1. 从零手搓一个 JavaWeb 图书订阅管理系统:为什么它是练手项目里最值得做的一个

很多同学学完 Servlet 和 JSP 之后,最大的困惑不是"不会写代码",而是"不知道一个完整的 JavaWeb 项目长什么样"。课本上的例子要么是单个 Servlet 打印 Hello World,要么是零散的 CRUD 片段,拼在一起根本跑不起来。图书订阅管理系统恰好卡在一个黄金位置上:业务逻辑足够简单(图书、用户、订阅记录三张核心表),但涉及的技术面又足够全(登录鉴权、分页查询、表单校验、数据库事务、前后端交互),是检验 JavaWeb 基本功最合适的载体。

这篇文章不讲空泛的理论,而是从数据库建表开始,一步步把环境配置、后端 Servlet、JSP 页面、分页逻辑、订阅状态流转全部串起来,最后给出几个我在实际带人做这个项目时反复踩过的坑。适合已经学过 Java 基础、Servlet、JDBC 和 JSP,但还没有独立完成过一个完整 Web 项目的同学。跟着走一遍,你能得到一个能跑、能演示、能写进简历的系统,而不是一堆散落的代码片段。

2. 环境与数据库:IDEA 跑 JavaWeb 项目最容易翻车的三个配置点

2.1 为什么用 Maven + Tomcat 而不是手动导 jar 包

新手做 JavaWeb 项目最常见的做法是:下载一堆 jar 包,手动丢进WEB-INF/lib目录,然后在 IDEA 里配置 Artifact。这种做法在只有两三个依赖时勉强能用,但图书订阅管理系统至少需要 MySQL 驱动、JSTL、Servlet API 这几类依赖,手动管理很快就会乱。更麻烦的是,一旦换一台电脑或者换一个 IDEA 版本,Artifact 配置经常丢失,项目直接跑不起来。

用 Maven 管理依赖的好处是:所有 jar 包的坐标写在pom.xml里,换环境只需要重新mvn install一次。配合 IDEA 的 Maven 面板,依赖冲突也能直观看到。我一般会推荐用 Maven 的war打包方式,这样 IDEA 能自动识别 Web 项目结构,少配很多 Artifact。

<!-- pom.xml 核心依赖,版本按你本地 Tomcat 和 JDK 调整 --> <dependencies> <!-- Servlet API:scope 必须是 provided,因为 Tomcat 自带 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <!-- JSTL:JSP 页面里做循环和判断用,别忘了它 --> <dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency> <!-- MySQL 驱动:8.x 版本驱动类名和 URL 参数都变了 --> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency> </dependencies>

这里的关键参数是scope。Servlet API 必须设为provided,因为 Tomcat 运行时自带这个包,如果你打成compile,部署时会因为类冲突报ClassCastException或者NoSuchMethodError。MySQL 驱动则必须是compile,因为它需要被打进 war 包。JSTL 容易被忽略,很多同学写完 JSP 发现<c:forEach>标签不生效,排查半天才发现是没引 JSTL 依赖。

2.2 数据库建表:三张表撑起整个订阅业务

图书订阅管理系统的核心业务可以抽象成三个实体:图书、用户、订阅记录。图书表存书的基本信息,用户表存账号密码,订阅记录表是中间表,记录"谁订阅了哪本书、什么时候订阅的、当前状态是什么"。这三张表的设计直接决定了后面 Servlet 里 SQL 怎么写。

-- 图书表:status 字段控制图书是否可订阅 CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, author VARCHAR(50), stock INT DEFAULT 0, -- 库存,订阅时减一 status TINYINT DEFAULT 1, -- 1 可订阅,0 已下架 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 用户表:role 区分普通用户和管理员 CREATE TABLE user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) UNIQUE NOT NULL, password VARCHAR(100) NOT NULL, role TINYINT DEFAULT 0, -- 0 普通用户,1 管理员 create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 订阅记录表:user_id 和 book_id 联合索引,加速查询 CREATE TABLE subscribe ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, status TINYINT DEFAULT 0, -- 0 待审核,1 已通过,2 已归还 subscribe_time DATETIME DEFAULT CURRENT_TIMESTAMP, INDEX idx_user_book (user_id, book_id) );

stock字段是订阅业务的关键。每次用户订阅成功,库存减一;归还时库存加一。这里必须用数据库事务保证"扣库存"和"插订阅记录"要么都成功要么都失败,否则会出现库存扣了但订阅记录没写进去的脏数据。subscribe表的status字段用 0/1/2 表示待审核、已通过、已归还,比用字符串存储更省空间,查询也更快。

2.3 IDEA 里 Tomcat 配置:三个必改的地方

在 IDEA 里配置 Tomcat 时,有三个地方新手最容易漏。第一是Deployment选项卡里必须添加war exploded而不是war,否则每次改 JSP 都要重新打包,开发效率极低。第二是Application context建议设成/book,这样访问路径就是http://localhost:8080/book/,比默认的一长串 artifact 名清爽。第三是On Update action和On frame deactivation都设成Update classes and resources,这样改完 Java 代码和 JSP 不用手动重启 Tomcat。

提示:如果你的 IDEA 是社区版,没有 Tomcat 集成选项,可以装 Smart Tomcat 插件,配置逻辑和旗舰版基本一致,只是界面位置不同。

3. 后端 Servlet 与 JDBC:把订阅流程拆成可复用的四层

3.1 从 JDBC 工具类开始,别在每个 Servlet 里重复写连接代码

很多同学写 Servlet 时习惯在每个doGet或doPost里直接DriverManager.getConnection,结果代码里到处都是重复的连接字符串和异常处理。图书订阅管理系统至少有登录、图书列表、订阅、归还、审核五个功能,如果每个都写一遍连接代码,后期改数据库密码会改到崩溃。正确的做法是抽一个DBUtil工具类,统一管理连接获取和释放。

// DBUtil.java:统一数据库连接工具 public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/book_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USER = "root"; private static final String PASSWORD = "your_password"; static { try { Class.forName("com.mysql.cj.jdbc.Driver"); // MySQL 8.x 驱动类名 } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL 驱动加载失败", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 释放资源,顺序不能错:ResultSet -> Statement -> Connection public static void close(Connection conn, Statement stmt, ResultSet rs) { 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) {} } }

URL 里的serverTimezone=Asia/Shanghai是 MySQL 8.x 必须加的,不加会报时区错误。useSSL=false在本地开发时加上,避免控制台一堆 SSL 警告。characterEncoding=utf8保证中文不乱码。这三个参数少一个都可能让你排查半天。

3.2 订阅功能的事务处理:一个典型的"扣库存 + 插记录"场景

订阅图书这个动作,表面上是用户点一下按钮,背后至少要做三件事:检查库存是否大于零、扣减库存、插入订阅记录。这三步必须在一个事务里完成,否则并发情况下会出现超卖。下面是一个典型的订阅 Servlet 实现。

// SubscribeServlet.java:处理用户订阅请求 protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { int userId = (int) req.getSession().getAttribute("userId"); int bookId = Integer.parseInt(req.getParameter("bookId")); Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 1. 检查库存并锁定行,防止并发超卖 String checkSql = "SELECT stock FROM book WHERE id = ? AND status = 1 FOR UPDATE"; PreparedStatement checkStmt = conn.prepareStatement(checkSql); checkStmt.setInt(1, bookId); ResultSet rs = checkStmt.executeQuery(); if (!rs.next() || rs.getInt("stock") <= 0) { conn.rollback(); resp.getWriter().write("{\"code\":0,\"msg\":\"库存不足\"}"); return; } // 2. 扣减库存 String updateSql = "UPDATE book SET stock = stock - 1 WHERE id = ?"; PreparedStatement updateStmt = conn.prepareStatement(updateSql); updateStmt.setInt(1, bookId); updateStmt.executeUpdate(); // 3. 插入订阅记录 String insertSql = "INSERT INTO subscribe(user_id, book_id, status) VALUES(?, ?, 0)"; PreparedStatement insertStmt = conn.prepareStatement(insertSql); insertStmt.setInt(1, userId); insertStmt.setInt(2, bookId); insertStmt.executeUpdate(); conn.commit(); // 三步都成功才提交 resp.getWriter().write("{\"code\":1,\"msg\":\"订阅成功\"}"); } catch (Exception e) { try { if (conn != null) conn.rollback(); } catch (SQLException ignored) {} resp.getWriter().write("{\"code\":0,\"msg\":\"系统异常\"}"); } finally { DBUtil.close(conn, null, null); } }

FOR UPDATE是这里的关键。它会在查询时锁定这一行,其他并发请求必须等当前事务提交后才能读到最新库存。没有这个锁,两个用户同时订阅最后一本书,可能都查到库存为 1,然后都扣减成功,库存变成 -1。setAutoCommit(false)开启事务后,任何一步失败都要rollback,否则前面的操作不会撤销。

3.3 分页查询:用 LIMIT 而不是在 Java 里 subList

图书列表页通常需要分页显示,每页 10 条。新手容易犯的错误是:一次性查出所有图书,然后在 Java 里用list.subList()截取当前页。数据量小的时候没问题,一旦图书上千本,内存和查询效率都会出问题。正确做法是用 SQL 的LIMIT在数据库层面分页。

// BookListServlet.java:分页查询图书 int page = Integer.parseInt(req.getParameter("page") == null ? "1" : req.getParameter("page")); int pageSize = 10; int offset = (page - 1) * pageSize; String sql = "SELECT * FROM book WHERE status = 1 ORDER BY id DESC LIMIT ? OFFSET ?"; PreparedStatement stmt = conn.prepareStatement(sql); stmt.setInt(1, pageSize); stmt.setInt(2, offset); ResultSet rs = stmt.executeQuery(); // 同时查总数,用于计算总页数 String countSql = "SELECT COUNT(*) FROM book WHERE status = 1"; // ... 把 list 和 totalPage 放进 request,转发到 JSP

LIMIT ? OFFSET ?里两个参数分别是每页条数和偏移量。第 1 页 offset 是 0,第 2 页是 10,以此类推。总页数用COUNT(*)除以pageSize向上取整。把list和totalPage放进request后转发到 JSP,JSP 里用<c:forEach>渲染列表,用<c:if>控制上一页/下一页按钮的显示。

4. JSP 页面与前后端交互:让数据在浏览器里正确显示

4.1 JSTL 循环渲染图书列表,别再用 Java 脚本片段

JSP 里写<% for(...) { %>这种脚本片段是十年前的做法,现在应该用 JSTL 和 EL 表达式。好处是页面里没有 Java 代码,前端同学也能看懂,而且 JSTL 标签会自动处理 null 值,不会因为某个字段为空就报 500。

<!-- book_list.jsp:用 JSTL 渲染图书列表 --> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <table> <tr><th>书名</th><th>作者</th><th>库存</th><th>操作</th></tr> <c:forEach items="${bookList}" var="book"> <tr> <td>${book.title}</td> <td>${book.author}</td> <td>${book.stock}</td> <td> <c:choose> <c:when test="${book.stock > 0}"> <a href="subscribe?bookId=${book.id}">订阅</a> </c:when> <c:otherwise> <span style="color:gray">已借完</span> </c:otherwise> </c:choose> </td> </tr> </c:forEach> </table>

<c:forEach>的items属性对应request里的bookList,var是循环变量名。<c:choose>相当于 Java 的if-else,根据库存是否大于零决定显示"订阅"链接还是"已借完"。EL 表达式${book.title}会自动调用getTitle()方法,前提是你的实体类有标准的 getter。

4.2 登录拦截:用 Filter 而不是在每个 Servlet 里判断 Session

图书订阅管理系统里,除了登录页和注册页,其他页面都需要登录才能访问。如果在每个 Servlet 开头都写一遍if (session.getAttribute("userId") == null),代码会非常冗余。用 Filter 统一拦截是更优雅的做法。

// AuthFilter.java:登录拦截过滤器 @WebFilter("/*") // 拦截所有请求 public class AuthFilter implements Filter { // 白名单:不需要登录就能访问的路径 private static final String[] WHITE_LIST = {"/login.jsp", "/login", "/register.jsp", "/register"}; public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; String path = request.getRequestURI().substring(request.getContextPath().length()); // 白名单直接放行 for (String white : WHITE_LIST) { if (path.startsWith(white)) { chain.doFilter(req, resp); return; } } // 检查 Session Object userId = request.getSession().getAttribute("userId"); if (userId == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); } else { chain.doFilter(req, resp); } } }

@WebFilter("/*")表示拦截所有请求。白名单里的路径直接放行,其他路径检查 Session 里有没有userId。没有就重定向到登录页。注意request.getRequestURI()拿到的是包含 contextPath 的完整路径,所以要substring掉 contextPath 再比较。

4.3 订阅状态流转:从待审核到已归还的完整闭环

订阅记录有三个状态:0 待审核、1 已通过、2 已归还。管理员在后台审核通过后,状态从 0 变 1;用户归还后,状态从 1 变 2,同时库存加一。这个状态流转必须用 SQL 的WHERE status = ?做条件更新,防止重复操作。

// ReturnServlet.java:用户归还图书 String sql = "UPDATE subscribe SET status = 2 WHERE id = ? AND user_id = ? AND status = 1"; PreparedStatement stmt = conn.prepareStatement(sql); stmt.setInt(1, subscribeId); stmt.setInt(2, userId); int rows = stmt.executeUpdate(); if (rows > 0) { // 只有更新成功才加库存,保证幂等 String stockSql = "UPDATE book SET stock = stock + 1 WHERE id = ?"; // ... 执行加库存 }

WHERE status = 1这个条件很关键。如果用户重复点击归还按钮,第二次执行时status已经是 2,rows会是 0,不会重复加库存。这就是幂等设计,在订阅类系统里非常重要。

5. 避坑与排查:图书订阅系统里最容易翻车的五个地方

5.1 中文乱码:POST 和 GET 要分开处理

现象:表单提交后,数据库里存的中文书名变成????,或者页面显示乱码。

原因:POST 请求的乱码是因为请求体默认用 ISO-8859-1 解码,GET 请求的乱码是因为 Tomcat 的URIEncoding默认不是 UTF-8。两者处理方式不同。

解决:POST 在 Filter 或 Servlet 里加request.setCharacterEncoding("UTF-8"),且必须在读取任何参数之前调用。GET 请求需要改 Tomcat 的server.xml,在 Connector 标签里加URIEncoding="UTF-8"。数据库连接 URL 里也要加characterEncoding=utf8。三处都改到位,乱码基本就消失了。

5.2 数据库连接泄漏:Tomcat 跑一会儿就报 Too many connections

现象:项目刚启动正常,运行一段时间后所有数据库操作都失败,报Too many connections。

原因:每次getConnection()之后没有在finally里关闭连接,或者关闭顺序不对导致连接没真正释放。Servlet 是并发执行的,每个请求都泄漏一个连接,很快就会耗尽 MySQL 的最大连接数。

解决:所有数据库操作必须用try-finally包裹,在finally里调用DBUtil.close()。关闭顺序必须是ResultSet -> Statement -> Connection,反过来关会导致前面的资源无法释放。更彻底的做法是引入连接池(如 Druid 或 HikariCP),但练手项目用工具类统一关闭也能解决问题。

5.3 JSP 页面报 404:路径里多了或少了 contextPath

现象:登录成功后跳转图书列表,浏览器报 404,地址栏显示http://localhost:8080/book_list.jsp。

原因:跳转时用了绝对路径/book_list.jsp,但项目的 contextPath 是/book,正确路径应该是/book/book_list.jsp。JSP 里的链接和表单 action 如果写死绝对路径,很容易漏掉 contextPath。

解决:所有 JSP 里的链接和表单 action 都用${pageContext.request.contextPath}/xxx拼接。Servlet 里的重定向用response.sendRedirect(request.getContextPath() + "/xxx")。这样无论 contextPath 怎么改,路径都不会错。

5.4 订阅后库存没变:事务没提交或自动提交没关

现象:用户订阅成功,订阅记录也插入了,但图书库存还是原来的数字。

原因:conn.setAutoCommit(false)之后忘记conn.commit(),或者异常被 catch 了但没有 rollback,导致事务一直挂着。还有一种情况是扣库存和插记录用了两个不同的 Connection,事务根本不生效。

解决:确保扣库存和插记录用的是同一个 Connection,并且在所有操作成功后调用commit(),异常时调用rollback()。可以在finally里把autoCommit恢复为 true,避免连接归还到连接池时状态不对。

5.5 分页最后一页数据重复:offset 计算错误

现象:翻到最后一页时,出现了上一页已经显示过的数据。

原因:offset = (page - 1) * pageSize这个公式本身没错,但如果前端传的page超过了总页数,offset 会超出总记录数,MySQL 会返回空结果。另一种情况是排序字段不唯一,比如按status排序,相同 status 的记录在两次查询中顺序可能不同,导致分页数据错乱。

解决:后端要对page做边界校验,超过总页数就取最后一页。排序字段一定要用唯一字段,比如id DESC,不要用可能重复的字段排序。如果必须用非唯一字段排序,可以加一个id作为第二排序字段。

6. 进阶技巧:用连接池和统一响应格式把项目提升一个档次

练手项目跑通之后,如果你想让它更接近生产级代码,有两个方向可以立刻动手改。第一个是引入数据库连接池。前面DBUtil每次DriverManager.getConnection都会新建一个物理连接,开销很大。换成 Druid 或 HikariCP 之后,连接从池子里拿,用完还回去,性能提升非常明显。以 Druid 为例,只需要加一个依赖,写一个DruidDataSource的配置类,把DBUtil.getConnection()改成从dataSource.getConnection()获取即可,其他代码几乎不用动。

第二个是统一接口响应格式。现在每个 Servlet 返回的 JSON 格式都不一样,有的返回{"code":1},有的返回{"success":true},前端处理起来很麻烦。可以定义一个Result类,统一用code、msg、data三个字段,所有 Servlet 都返回这个格式。配合一个简单的 JSON 工具类(比如 Jackson 或 Gson),代码会清爽很多。

// Result.java:统一响应格式 public class Result<T> { private int code; // 1 成功,0 失败 private String msg; private T data; public static <T> Result<T> success(T data) { Result<T> r = new Result<>(); r.code = 1; r.msg = "success"; r.data = data; return r; } public static <T> Result<T> fail(String msg) { Result<T> r = new Result<>(); r.code = 0; r.msg = msg; return r; } // getter/setter 省略 }

改完之后,Servlet 里只需要resp.getWriter().write(new Gson().toJson(Result.success(bookList))),前端拿到code判断成功失败,拿到data渲染页面。这个习惯一旦养成,以后做任何 JavaWeb 项目都会受益。

最后说一个我自己的习惯:每次写完一个功能,不要急着写下一个,先手动把整个流程走一遍——注册、登录、订阅、审核、归还,每一步都点一下,看看数据库里的数据对不对。这个项目我前后带人做过十几遍,凡是最后能跑起来的,都是那些愿意在每一步停下来验证的人。希望帮到你。

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

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询