简介:这份JavaWeb图书馆管理系统源码面向高校课程设计、毕业设计和JavaWeb入门练手者,采用Servlet、JSP、JavaBean与MySQL实现图书信息、读者账户、借阅归还及管理员权限等业务,可用于理解MVC分层和数据库表关联。压缩包共209个文件,约1.91MB,含51个JSP页面、32个Java源文件及编译类、9个JAR依赖、MySQL数据文件与SQL脚本,并有properties配置、TLD标签库、JS/CSS和图片等资源;其中JSP承担页面展示,Java源文件对应业务与数据访问,SQL脚本便于初始化数据库。已有359人学习,适合需要可运行、可拆解、可二次开发案例的读者。源码中的DAO、实体类、表单对象与借阅流程相互配合,便于梳理登录认证、权限控制、请求转发、异常处理及CRUD链路,也能按模块替换界面或扩展功能,对理解Servlet接收请求、调用业务层再转发JSP渲染的流程较有帮助。
1. 拿到一个能跑的图书馆系统,比从零写一百行 CRUD 更值
很多人对「图书借还」这个业务的印象还停留在课程作业:几张表、几个 JSP、一个 Servlet 转来转去。真上手维护一个学校或企业内部的小型图书室,你会发现难点不在增删改查,而在「借书那一刻库存和借阅记录要不要一起动」「读者超期了怎么算」「管理员和普通用户看到的菜单为什么不一样」。这份 javaWeb 图书馆管理系统源码(MySQL 版本)正好卡在这个位置上:解压出来就是一套完整的 JavaWeb 工程,图书、图书分类、读者、借阅、管理员几条主线都铺好了,前端用 JSP 渲染,后端走 Servlet + JDBC 直连 MySQL,没有 Spring 那层自动装配的封装,读代码不用先猜框架在做什么。适合两类人:一是实训阶段需要一个能演示完整业务闭环的工程作为底座,二是工作里要快速搭个内部小系统的开发者。它能不能用得顺手,取决于环境、库表、调用链这三件事有没有对齐。
2. 环境对齐:JDK、Tomcat、MySQL 的版本组合与工程导入的四步
原生 JavaWeb 工程最容易让人在第一步就放弃,不是代码写得差,而是版本组合错一个,整个工程满屏红色报错,连启动都启动不了。这一章把版本选择、目录识别、建库导脚本、连接配置这四件事按顺序讲清楚,照着做基本能跑起来。
2.1 为什么纯 Servlet 工程对版本比对框架工程更敏感
框架工程通常自带一套依赖管理,版本冲突在编译期就被约束住了。而一份 JSP + Servlet 的源码包,编译期靠servlet-api.jar,运行期靠容器提供实现,两边的命名空间必须对上。JDK 跨过 8 之后,javax.servlet整体迁移到jakarta.servlet,包名一改,工程里所有import javax.servlet.http.HttpServlet;全部失效,这不是改一两行能救的。所以第一步不是急着往 IDE 里拖,而是先定版本组合。
我一般的固定搭配是 JDK 8 + Tomcat 8.5 或 9 + MySQL 5.7 或 8.0,理由是这套组合的兼容窗口最宽:javax命名空间在 Tomcat 9 及以前都还在,jstl、mysql-connector-java这些老 jar 也都是按这个环境编译的。如果你本地已经装了 JDK 17,不建议硬上,装一个 8 的 JDK 并单独给 IDE 指定 SDK 更省事。
| 组件 | 建议版本 | 判断依据 | 越界的直接后果 |
|---|---|---|---|
| JDK | 8 | 源码里出现javax.servlet.* | 编译报class file has wrong version或 import 全红 |
| Tomcat | 8.5 / 9 | 只认javax命名空间 | Tomcat 10 及以上,所有 Servlet import 失效 |
| MySQL | 5.7 / 8.0 | 建表脚本里的存储引擎与字符集 | 8.0 的库灌 5.7 脚本一般没事,反向容易报语法错 |
| JDBC 驱动 | 与 MySQL 大版本对齐 | WEB-INF/lib下的mysql-connector-java-*.jar | 驱动旧了连不上 8.0,驱动新了报时区或公钥错误 |
这张表不是让你死记,而是让你在报错时有个对照方向。比如启动时抛NoClassDefFoundError: javax/servlet/http/HttpServlet,八成是 Tomcat 版本和 JDK 版本对不上;如果抛的是com.mysql.jdbc.Driver找不到,那就是驱动 jar 没进WEB-INF/lib或者类名写的是老写法而驱动是 8.x 的新包。
2.2 解压后先看三个文件,判断工程类型
压缩包解开,别急着导进 IDE。先看目录结构和三个关键位置,五分钟能判断出这个工程是什么技术形态,后面所有操作都基于这个判断。
# 1. 看整体结构,确认是不是标准 Web 工程 ls -la library/ # 期望看到 src/、WebRoot/ 或 web/、数据库脚本 .sql 文件 # 2. 看 web.xml 是否存在,Servlet 是不是在这里注册的 find . -name "web.xml" -path "*/WEB-INF/*" # 3. 看 lib 目录里有哪些 jar,直接决定技术栈 ls -la WebRoot/WEB-INF/lib/三个文件看下来的判断逻辑是这样:如果WEB-INF/web.xml里塞满了<servlet>和<servlet-mapping>,说明是原生 Servlet 分发,代码里大概率有一个BaseServlet或者action参数分发器;如果lib下出现spring-webmvc-*.jar、mybatis-*.jar,那这套源码其实是 SSM 换了个名字流通,配置文件要去找applicationContext.xml;如果lib下只有mysql-connector-java、jstl、standard这几个,那就是最纯粹的 JSP + Servlet + JDBC,也是最容易读懂、最容易改的一种。第三种情况的源码包,价值反而更高,因为它没有隐藏的约定。
schema或sql目录下的.sql文件是第二个必看的东西。打开扫一眼CREATE TABLE的数量和字段,你能立刻知道这套系统的业务边界到底到哪:有没有罚金计算、有没有预约、有没有管理员分级。心里有数之后再动手建库,比先跑起来再回头补要省很多时间。
2.3 建库与灌脚本:字符集这一步别省
数据库建错字符集,后面中文乱码能折腾你一整天,而且现象是间歇性的——有的页面正常,有的页面出错,排查起来完全没有方向。所以我一般把建库语句和灌脚本分开写,建库时就把字符集钉死。
# 建库时显式指定字符集,别依赖 MySQL 默认值 mysql -u root -p -e "CREATE DATABASE library_db DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;" # 灌入脚本,注意指定 default-character-set,否则脚本里的中文注释可能变问号 mysql -u root -p --default-character-set=utf8mb4 library_db < library_db.sql # 灌完立刻验证:表数量和字符集 mysql -u root -p -e "USE library_db; SHOW TABLES; SHOW CREATE TABLE book\G" | head -40--default-character-set=utf8mb4这个参数很多人会漏。如果脚本文件本身是 UTF-8 编码,而客户端默认按 latin1 解析,导入后的中文会变成一串问号,而且这个错误在导入当时不报,等页面查出来才发现,回滚重导的成本很高。utf8mb4相比utf8多支持 emoji 和部分生僻字,图书名里出现特殊符号时不会截断。
灌完脚本后有两个动作值得马上做。一是SHOW CREATE TABLE看几张主表的字符集是不是都跟着库走了,偶尔会有脚本里硬写了CHARSET=latin1的表,那种表单独改掉。二是SELECT COUNT(*)看初始数据量,如果图书表只有三五行示例数据,说明这是一份纯演示工程,交付前你得自己准备数据,别指望它带真实书库。
2.4 连接配置的四处必改,以及 URL 里那几个参数
源码包里的数据库连接信息通常散落在两三个地方,只改一处是最常见的翻车点。老工程常见的分布是:src/db.properties或jdbc.properties一份,DBUtil.java里可能硬编码一份,有的还会在web.xml里配一个<context-param>。最稳妥的做法是全项目搜关键词,一次改干净。
# db.properties 修改示例 driver=com.mysql.cj.jdbc.Driver url=jdbc:mysql://127.0.0.1:3306/library_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false&allowPublicKeyRetrieval=true username=root password=你的密码# 改之前先全项目扫一遍,确认没有漏网之鱼 grep -rn "jdbc:mysql\|com.mysql.jdbc.Driver\|3306" src/ WebRoot/WEB-INF/ 2>/dev/nullURL 后面这几个参数不是可选项。useUnicode=true&characterEncoding=utf8决定驱动层怎么编码字符串,缺了它在某些 MySQL 版本上会出现写入正常、读出乱码的诡异现象。serverTimezone=Asia/Shanghai是 MySQL 8.0 的硬要求,缺了会抛The server time zone value '' is unrecognized,因为 8.0 的驱动不再猜服务器时区。useSSL=false是为了避免本地环境没有证书时的告警刷屏。allowPublicKeyRetrieval=true对应 8.0 默认的caching_sha2_password认证插件,缺了会报Public Key Retrieval is not allowed。
驱动类名要跟 jar 版本对上:mysql-connector-java5.x 用com.mysql.jdbc.Driver,8.x 用com.mysql.cj.jdbc.Driver。8.x 的驱动其实也认老类名,只是会打一行 deprecated 警告,但如果 jar 是 5.x 而你写了新类名,那就是直接ClassNotFoundException。另外驱动 jar 一定要放在WEB-INF/lib下而不是只加到 IDE 的构建路径里,否则 IDE 里能跑、导成 war 部署就报找不到驱动,这个坑我在第一次部署时踩得很结实。
3. 库表拆解:五张核心表的关系、索引与三个数据隐患
图书馆系统的业务复杂度几乎全压在数据库上,代码本身反而简单。这一章从表关系讲到字段设计,再落到三个实际运行中一定会遇到的数据问题。
3.1 五张核心表的关系,以及外键该不该建
一份典型的图书馆系统库表大概是这么几条线:book存书目,booktype存分类,reader存读者,borrow存借阅记录,admin存后台账号。关系很清晰:book.type_id指向booktype.id,borrow.book_id指向book,borrow.reader_id指向reader。
问题出在外键上。这类源码包十有八九是不建物理外键的,理由也说得过去:导入脚本时不用管表的创建顺序,删数据时不用先删子表,性能上少一层约束检查。代价是脏数据没人拦——删掉一本书,borrow表里指向它的记录就变成孤儿,查借阅历史时JOIN出来一堆NULL,页面上显示空白书名。
我的处理方式分场景:如果是自己内部用、数据量不大,补上外键并设置ON DELETE RESTRICT,删书时直接报错,逼着先处理借阅记录,这是最省心的做法;如果是要长期跑、数据量会上来,就保持不建外键,但在 Service 层把「删书前检查是否有未归还记录」写死,同时给borrow.book_id建索引,保证JOIN不慢。两种都能用,怕的是既没外键也没业务校验,那数据烂掉只是时间问题。
3.2 建表脚本里我必改的几个字段
原始脚本能用,但有几个字段类型经不起实际使用。下面是我改过之后的简化版,重点看类型和索引。
-- 书目表:库存字段和 ISBN 唯一索引是关键 CREATE TABLE `book` ( `id` INT NOT NULL AUTO_INCREMENT, `book_name` VARCHAR(120) NOT NULL COMMENT '书名', `author` VARCHAR(60) DEFAULT NULL, `isbn` VARCHAR(20) DEFAULT NULL, `type_id` INT NOT NULL COMMENT '分类ID', `price` DECIMAL(10,2) DEFAULT '0.00' COMMENT '定价,别用float', `stock` INT NOT NULL DEFAULT 0 COMMENT '可借库存', `total` INT NOT NULL DEFAULT 0 COMMENT '馆藏总数', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1在架 0下架', PRIMARY KEY (`id`), UNIQUE KEY `uk_isbn` (`isbn`), KEY `idx_type` (`type_id`), KEY `idx_name` (`book_name`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; -- 借阅表:状态位和联合索引决定查询性能 CREATE TABLE `borrow` ( `id` BIGINT NOT NULL AUTO_INCREMENT, `book_id` INT NOT NULL, `reader_id` INT NOT NULL, `borrow_date` DATE NOT NULL, `due_date` DATE NOT NULL COMMENT '应还日期', `return_date` DATE DEFAULT NULL COMMENT '实际归还,未还为NULL', `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0借出 1已还 2超期', PRIMARY KEY (`id`), KEY `idx_reader_status` (`reader_id`, `status`), KEY `idx_book` (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;三处改动值得说明。第一,price用DECIMAL(10,2)而不是FLOAT,浮点数在金额上是老生常谈的坑,累加之后会出现0.30000000000000004这种值,罚款计算直接失真。第二,borrow表加了(reader_id, status)联合索引,因为「我的借阅」这个页面的查询条件是读者 ID 加状态过滤,这是全系统调用频率最高的查询,没索引时数据量一过万就明显卡。第三,return_date允许为NULL表示未归还,比用一个'0000-00-00'的假日期要干净得多,后者在 JDBC 里读出来会直接抛异常。
stock(可借库存)和total(馆藏总数)这两个字段分开存,是我从实际使用里总结出来的。只存一个stock的话,书全部借出后你就分不清这本书馆里到底有几本、是全借走了还是根本没采购。分开存之后,total - stock就是当前在借数量,对账非常方便。
3.3 三个一定会遇到的数据隐患
库存扣减的竞态问题。最常见的错误写法是先SELECT stock FROM book WHERE id = ?,在 Java 里判断stock > 0然后UPDATE book SET stock = stock - 1。单机单用户测试永远没问题,两个人同时点借书就超借了。正确的写法是把判断压进 SQL,让数据库的锁来保证原子性:
-- 一条语句完成"有库存才扣减",影响行数为 0 就是库存不足 UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0;执行后看executeUpdate()的返回值,等于 1 表示扣减成功,等于 0 表示库存已经没了,直接给前端返回「该书已借完」。这个写法不需要显式加锁,InnoDB 的行锁会处理好。
日期类型用 VARCHAR 存。有些老脚本图省事把borrow_date写成VARCHAR(20),页面上看着没问题,一旦做超期统计就崩了:字符串比较'2024-9-1' > '2024-10-1'的结果是 true,因为逐字符比到第二位9大于1。超期名单会算错,而且是那种「看起来合理但就是不对」的错。统一改成DATE或DATETIME,格式化交给 Java 的SimpleDateFormat或前端的toLocaleDateString。
没有逻辑删除字段。直接DELETE的后果是统计报表永远对不上账:去年的借阅总量今年再查,因为书被删了,JOIN不上,数字少了一截。给book和reader加一个is_deleted TINYINT DEFAULT 0,所有查询带上WHERE is_deleted = 0,删除只做UPDATE。代价是所有 SQL 都要多一个条件,收益是历史数据永远可追溯。这个改动越早做越好,等数据攒起来再补,历史记录的is_deleted全是NULL,判断逻辑又要多一层。
4. 调用链还原:从借书按钮到 SQL 执行,中间发生了什么
读懂调用链是改这套源码的前提。这一章用「借书」这一条完整链路串起 Servlet、Service、DAO 三层,再把分页和事务这两个高频需求落到代码上。
4.1 一条借书请求的完整路径
JSP 页面上的「借阅」按钮提交到哪个 Servlet、带什么参数,决定了你改功能时该动哪个文件。典型的分发方式是所有图书相关操作都打到同一个 Servlet,用action参数区分具体方法。
// BookServlet.java 片段:按 action 分发,这是老工程最常见的写法 protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { // 必须在取参数之前设置,否则 POST 中文参数乱码 req.setCharacterEncoding("UTF-8"); String action = req.getParameter("action"); if ("borrow".equals(action)) { int bookId = Integer.parseInt(req.getParameter("bookId")); int readerId = (Integer) req.getSession().getAttribute("readerId"); boolean ok = borrowService.borrow(bookId, readerId); // 事务在这一层 // 借完重定向到列表页,避免刷新重复提交 resp.sendRedirect(req.getContextPath() + "/book?action=list&msg=" + (ok ? "ok" : "fail")); } }这段代码里有三个容易改错的地方。setCharacterEncoding必须放在第一行getParameter之前,放后面等于没设。readerId从 session 取而不是从请求参数取,否则用户改一下 URL 就能替别人借书。借书成功后用sendRedirect而不是getRequestDispatcher().forward(),因为转发的话浏览器地址栏还停在提交页,用户按 F5 会重复提交一次借书请求,库存白扣。
4.2 手写 JDBC 的三个固定套路
没有 MyBatis 的工程,DAO 层全是模板代码,但有三个位置必须写对,写错了就是连接泄漏或者注入漏洞。
// DBUtil.java:连接获取,配置从 properties 读,别硬编码 public class DBUtil { private static final Properties P = new Properties(); static { try (InputStream in = DBUtil.class.getClassLoader() .getResourceAsStream("db.properties")) { P.load(in); // 配置和代码分离,换环境只改 properties } catch (Exception e) { throw new ExceptionInInitializerError("数据库配置加载失败"); } } public static Connection getConn() throws SQLException { return DriverManager.getConnection( P.getProperty("url"), P.getProperty("username"), P.getProperty("password")); } }// BookDao.java:查询用 try-with-resources,参数一律走占位符 public List<Book> findByPage(int page, int size, String keyword) { String sql = "SELECT id, book_name, author, stock FROM book " + "WHERE is_deleted = 0 AND book_name LIKE ? ORDER BY id DESC LIMIT ?, ?"; List<Book> list = new ArrayList<>(); try (Connection conn = DBUtil.getConn(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); // 占位符防注入,别拼字符串 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.setBookName(rs.getString("book_name")); b.setStock(rs.getInt("stock")); list.add(b); } } } catch (SQLException e) { throw new RuntimeException("分页查询失败: " + e.getMessage(), e); } return list; }try-with-resources把Connection、PreparedStatement、ResultSet三个都包进去,顺序是后开的先关,这个语法从 JDK 7 就有,没有理由不用。老工程里大量finally { rs.close(); ps.close(); conn.close(); }的写法,只要中间任何一个close抛异常,后面的就关不掉,连接池很快就会耗尽,表现出来就是跑一段时间后整个系统卡死、日志里全是Too many connections。这类问题重启能缓解,但根因不修就会反复出现。
LIKE查询用ps.setString(1, "%" + keyword + "%")把通配符拼在参数值里,而不是拼在 SQL 字符串里,这样既保留了模糊匹配能力,又不会因为用户输入' OR '1'='1而把整张表拖出来。
4.3 分页的两条 SQL 与页码越界
分页必须有两条 SQL:一条查总数算总页数,一条查当前页数据。只写后者的后果是前端分页器渲染不出来,因为你不知道一共多少页。
// 总数查询:和列表查询保持同样的 WHERE 条件,否则页数对不上 public int countByKeyword(String keyword) { String sql = "SELECT COUNT(*) FROM book WHERE is_deleted = 0 AND book_name LIKE ?"; try (Connection conn = DBUtil.getConn(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); try (ResultSet rs = ps.executeQuery()) { return rs.next() ? rs.getInt(1) : 0; } } catch (SQLException e) { return 0; } }后端接收页码时要防两个边界:page小于 1 时强制改成 1,page超过总页数时返回空列表或者直接跳到最后一页,而不是让LIMIT的偏移量算成一个巨大的数。(page - 1) * size在page很大时会整数溢出变成负数,MySQL 直接报语法错误。另外size一定要设上限,不设的话有人手动传size=100000,一次查询把整个表拖进内存,小服务器当场就顶不住了。
4.4 借书事务:扣库存和插记录必须绑在一起
借书这个动作在数据库上包含两条写操作:book.stock减一,borrow插一条记录。两条必须同生共死,否则会出现「库存扣了但没借阅记录」或者反过来「有记录但库存没动」。手写 JDBC 时事务的关键是这两条 SQL 共用一个Connection。
// BorrowService.java:同一个连接内完成扣库存 + 插记录 public boolean borrow(int bookId, int readerId) { Connection conn = null; try { conn = DBUtil.getConn(); conn.setAutoCommit(false); // 关闭自动提交,事务开始 // 1. 原子扣库存,返回 0 说明没库存了 try (PreparedStatement ps = conn.prepareStatement( "UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0")) { ps.setInt(1, bookId); if (ps.executeUpdate() == 0) { conn.rollback(); // 库存不足,回滚并返回 return false; } } // 2. 插入借阅记录,应还日期默认借出后 30 天 try (PreparedStatement ps = conn.prepareStatement( "INSERT INTO borrow(book_id, reader_id, borrow_date, due_date, status) " + "VALUES (?, ?, CURDATE(), DATE_ADD(CURDATE(), INTERVAL 30 DAY), 0)")) { ps.setInt(1, bookId); ps.setInt(2, readerId); ps.executeUpdate(); } conn.commit(); // 两步都成功才提交 return true; } catch (SQLException e) { try { if (conn != null) conn.rollback(); } catch (SQLException ignore) {} throw new RuntimeException("借书失败", e); } finally { try { if (conn != null) { conn.setAutoCommit(true); conn.close(); } } catch (SQLException ignore) {} } }setAutoCommit(false)之后如果忘记commit,数据不会落库,而且连接归还到池里时事务状态是脏的,会影响下一次使用。finally里把autoCommit恢复成 true 再关闭,是给连接池复用的一个保险。这套写法比框架里一个@Transactional啰嗦得多,但你能清楚看到事务边界在哪,改逻辑时心里有底。
5. 避坑排查:五条踩过的坑,按现象、原因、解决拆开
下面这五条是我在这类工程上反复遇到的,每一条都写清楚现象长什么样、根因在哪、怎么一次性解决。看的时候建议对照自己的报错信息找。
5.1 启动阶段的两个致命报错
坑一:java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver
现象是 Tomcat 启动日志里刷出这一行,随后所有涉及数据库的页面全部 500。原因是驱动 jar 只加到了 IDE 的构建路径(比如 Eclipse 的 Build Path 或者 IDEA 的 Libraries),没有复制到WEB-INF/lib下。IDE 内嵌运行时能找到那个 jar,一旦部署到独立的 Tomcat 就找不到了。
解决方式很直接:把mysql-connector-java-x.x.x.jar复制一份到WebRoot/WEB-INF/lib/,然后在 IDE 的项目结构里刷新并重新构建。验证方法是解压打好的 war 包,确认WEB-INF/lib/下确实有那个 jar。这个坑之所以高频,是因为它符合「本地能跑、部署就崩」的典型特征,很多人会误以为是服务器环境问题,方向一错就是半天。
坑二:The server time zone value '' is unrecognized
现象是应用能启动,但第一次访问数据库就抛异常,堆栈里能看到serverTimezone。原因是 MySQL 8.0 的驱动不再自动推断服务器时区,URL 里必须显式指定。同时如果 MySQL 服务端本身没配时区表,Asia/Shanghai也可能解析失败。
解决分两层:连接 URL 加上serverTimezone=Asia/Shanghai,这是必须的;如果加了还报错,就在 MySQL 命令行执行SET GLOBAL time_zone = '+8:00';并写进my.ini的[mysqld]段,用偏移量代替时区名,绕开时区表缺失的问题。两个都做完,这个报错基本不会再出现。
5.2 页面与请求阶段的三个高频故障
坑三:CSS 和 JS 全部 404,页面裸奔
现象是 JSP 能打开,但样式全丢,浏览器控制台一片红,请求的路径是/css/style.css而不是/library/css/style.css。原因是 JSP 里写的是绝对路径/css/style.css,而应用部署在 Tomcat 的/library上下文下。项目从 IDE 内嵌运行切到独立 Tomcat,上下文路径变了,写死的绝对路径就全断了。
正确的写法是加${pageContext.request.contextPath}:
<link rel="stylesheet" href="${pageContext.request.contextPath}/css/style.css"> <script src="${pageContext.request.contextPath}/js/jquery.min.js"></script>同一份代码在 IDE 里上下文是/,在 Tomcat 里是/war包名,加了这个前缀两边都能跑。改的时候全项目搜href="/和src="/,一次性替换干净,别只改当前报错的那个页面。
坑四:中文乱码,而且只在某些页面出现
现象是新增图书时书名输入「数据结构」,列表页显示成「????」或者「æ•°æ®ç»“æž„」。原因有三层,任何一层没对上都会乱:请求解码(request.setCharacterEncoding("UTF-8")要在取参数前)、响应编码(response.setContentType("text/html;charset=UTF-8"))、数据库连接(URL 里的characterEncoding=utf8)以及表和字段本身的字符集。四层里数据库那层最容易漏,因为连接串里写了characterEncoding不等于表就是 utf8mb4。
排查顺序是从外到内:先看浏览器返回的响应头Content-Type有没有charset=UTF-8,再看SHOW CREATE TABLE book的字符集,最后看连接串。三层都确认过还没解决,就在 Servlet 里打印一次new String(req.getParameter("bookName").getBytes("ISO-8859-1"), "UTF-8")的中间结果,能立刻定位是哪一层转错了。这个调试手法虽然土,但比盲改配置快得多。
坑五:借书时提示成功,但库存变成负数
现象是并发测试时几个人同时借同一本书,最后stock显示 -1 甚至 -2。原因是经典的先查后改:Java 里SELECT出库存判断大于 0,再单独发UPDATE,两个请求都通过了判断,然后各自减一。
解决就是第 3 章里那条UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0,把判断和修改压到一条语句里,用数据库的行锁保证原子性。改完之后还要在 Service 层处理executeUpdate()返回 0 的情况,给前端明确的「已借完」提示,而不是静默失败。顺带一提,如果业务上允许多人同时借同一本书(馆藏有多本),这个写法依然成立,因为它只扣实际存在的库存。
6. 二次开发的三个落点:拦截、统计与上线自检
能跑起来只是起点,这套源码真正的价值在于它足够简单,你想加什么都能在一个下午里加进去。下面三个改动是我每次拿到这类工程都会做的,篇幅不长但都是实打实能用的。
6.1 用 Filter 补一道登录拦截
老工程常见的问题是每个 Servlet 里都写一遍if (session.getAttribute("admin") == null) { 跳登录页 },漏写一个页面就裸奔。用一个 Filter 统一收口,web.xml里配一次,全站生效。
// LoginFilter.java:白名单放行,其余一律校验 session public class LoginFilter implements Filter { private static final Set<String> WHITE = Set.of("/login.jsp", "/login", "/css", "/js"); public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req = (HttpServletRequest) request; HttpServletResponse resp = (HttpServletResponse) response; String path = req.getRequestURI().substring(req.getContextPath().length()); // 白名单(登录页、静态资源)直接放行 for (String w : WHITE) { if (path.startsWith(w)) { chain.doFilter(request, response); return; } } if (req.getSession().getAttribute("admin") == null) { resp.sendRedirect(req.getContextPath() + "/login.jsp"); // 未登录踢回登录页 return; } chain.doFilter(request, response); } }<!-- web.xml 中注册,注意 url-pattern 覆盖全站 --> <filter> <filter-name>LoginFilter</filter-name> <filter-class>com.example.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>LoginFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping>白名单里一定要带上静态资源目录,否则登录页自己的 CSS 会被拦掉,页面上出现「明明没登录却能看到一个没样式的登录框」的怪现象。另外这个 Filter 只做了「有没有登录」的判断,如果要做管理员和普通读者的权限区分,就在 session 里存一个role字段,在 Filter 里按path前缀再判一次,/admin/*只放行管理员,逻辑加十行就够。
6.2 一条聚合 SQL 顶掉一个报表模块
「借阅排行」和「超期提醒」这两个需求,很多人第一反应是写 Java 循环,把借阅记录全查出来在内存里分组统计。数据量一大就是灾难。一条 SQL 就能解决:
-- 借阅排行 TOP10:按书分组计数,走 idx_book 索引 SELECT b.id, b.book_name, COUNT(*) AS times FROM borrow br JOIN book b ON br.book_id = b.id WHERE br.borrow_date >= DATE_SUB(CURDATE(), INTERVAL 90 DAY) GROUP BY b.id, b.book_name ORDER BY times DESC LIMIT 10; -- 超期未还名单:DATE 类型直接比较,别用字符串 SELECT br.id, r.reader_name, b.book_name, br.due_date, DATEDIFF(CURDATE(), br.due_date) AS overdue_days FROM borrow br JOIN reader r ON br.reader_id = r.id JOIN book b ON br.book_id = b.id WHERE br.status = 0 AND br.due_date < CURDATE() ORDER BY overdue_days DESC;第一条里的时间窗口用DATE_SUB(CURDATE(), INTERVAL 90 DAY)而不是写死日期,这样报表永远看最近三个月,不用每月改代码。第二条依赖borrow.status和due_date两个条件,如果你按第 3 章的建议建了(reader_id, status)联合索引,这里的status = 0能吃到索引的左前缀,但due_date的过滤还是要回表,数据量大的话可以再补一个(status, due_date)的联合索引。
把这两条 SQL 的结果直接塞进request.setAttribute,JSP 里用 JSTL 的<c:forEach>渲染成表格,一个报表页面二十分钟能做完,比在 Java 里折腾集合分组靠谱得多。
6.3 上线前的一份自检表
这套工程交付前我都会走一遍下面这张表,五分鐘能过完,但能挡掉八成的低级故障。
| 检查项 | 判定方法 | 不合格的典型表现 |
|---|---|---|
| 驱动 jar 在 war 内 | 解压 war 看WEB-INF/lib | 独立 Tomcat 启动即 500 |
| 连接串含时区参数 | grepserverTimezone | 首次访问数据库抛异常 |
| 库表字符集为 utf8mb4 | SHOW CREATE TABLE | 中文变问号,且只在部分页面 |
| 静态资源用 contextPath 前缀 | grephref="/ | 部署后页面无样式 |
| 库存扣减为单条 UPDATE | 查 Service 层代码 | 并发下库存变负数 |
| 事务方法共用 Connection | 查getConn()调用次数 | 库存扣了但没借阅记录 |
| 分页 size 有上限 | 查参数校验 | 一次请求拖垮内存 |
这张表的价值不在于条目多全,而在于每一条都对应一个具体的、我真实遇到过的故障。把它当成上线前的固定动作,比出问题之后再回头翻日志要省太多时间。
从那以后我每次拿到这类 JavaWeb 源码包,都会先把版本组合钉死、把库表字符集和索引改掉、再顺着一条借书链路把 Servlet 到 SQL 走一遍,最后过一遍自检表——这三步做完,剩下的才是业务功能的增删。跳过前面直接改功能,后面花在排查环境上的时间一定比省下来的多。希望帮到你。
本文还有配套的精品资源,点击获取