news 2026/10/28 9:13:38

JavaWeb图书馆管理系统源码:JSP+Servlet+JDBC借阅实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
JavaWeb图书馆管理系统源码:JSP+Servlet+JDBC借阅实战

简介:JavaWeb图书馆管理系统源码(MySQL版)面向高校课程设计、毕业设计及Java Web初学者,围绕图书信息、读者账户、借阅归还和管理员权限等业务提供较完整的学习样例,可用于理解Servlet、JSP、JavaBean与MySQL的整合开发。压缩包共209个文件,约1.91MB,以51个JSP页面、32个Java源文件及对应class、MySQL数据文件(myd/frm/myi)、9个jar依赖为主,并含SQL建库脚本、tld标签库、properties配置和少量图片、JS、CSS等静态资源,目录按实体、DAO、表单与页面组织。已有359人学习,在同类课程设计素材中具备一定参考度。源码可见Book、Reader、Borrow、Manager等实体及DAO结构,也包含借阅表单、读者类型等类,便于梳理MVC分层、数据库表主外键关联、用户认证与授权思路、登录鉴权、借阅流程与Servlet/JSP交互,并可对照学习请求转发、JSTL/EL、异常处理和Tomcat部署,适合作为课程作业、毕业设计原型或Java Web综合练习的起点。

1. 从一份"能跑就先赢一半"的源码说起

接手一个图书借阅相关的业务需求时,最耗时间的从来不是写页面,而是把"书、人、借还记录"这三者的关系理清楚。这套 javaWeb 图书馆管理系统源码 MySQL 版本,正好把这条主线走完整了:图书入库、读者登记、借书还书、逾期罚金、后台统计,一个都没落下。技术栈是 java 里最朴实的那一套,JSP 加 Servlet 加 JDBC,没有花哨的框架,好处是拿到手解压、导入、改两行数据库配置就能看见登录页。它适合两类人:一类是想找个完整 CRUD 项目练手的学生,另一类是要在三天内交出一个能演示的借阅模块的开发者。后面几章我会按目录结构、数据库、核心代码、部署排查、二次开发的顺序一层层拆开,把能抄的部分都写成可复制的代码块。

2. 目录结构与技术栈:JSP+Servlet+JDBC 这套组合到底怎么分层

先把包解开看一眼目录,这类项目的骨架几乎都是同一个模子刻出来的,认清了结构,后面找任何一处逻辑都不会迷路。判断一个 JavaWeb 项目值不值得改,我第一眼看的就是它有没有把"实体、数据访问、业务、控制"这四层分开。分开了,你加个字段只需要动三四个文件;没分开,JSP 页面里直接写 SQL,改一个条件能让你翻遍整个工程。

2.1 一份典型的工程目录长什么样

解开压缩包后在 IDE 里导入,看到的目录大概长这样(不同版本命名略有出入,但分层是一致的):

library-system/ ├── src/ │ ├── com/library/bean/ 实体类:Book、Reader、BorrowRecord、Admin │ ├── com/library/dao/ 接口:BookDao、ReaderDao、BorrowDao │ ├── com/library/dao/impl/ 实现:BookDaoImpl、BorrowDaoImpl │ ├── com/library/service/ 业务:BookService、BorrowService │ ├── com/library/servlet/ 控制:LoginServlet、BookServlet、BorrowServlet │ ├── com/library/filter/ AuthFilter、EncodingFilter │ └── com/library/util/ DBUtil、PageBean、DateUtil ├── WebRoot/ (也可能是 web/ 或 WebContent/) │ ├── WEB-INF/ │ │ ├── web.xml Servlet 与 Filter 的注册中心 │ │ └── lib/ mysql-connector-java-x.x.x.jar 等依赖 │ ├── jsp/ book_list.jsp、reader_edit.jsp、borrow_form.jsp │ ├── css/ js/ images/ │ └── index.jsp 入口,通常 redirect 到登录页 └── database/ └── library.sql 建库建表 + 初始管理员账号

这个结构里最值得关注的是util/DBUtil,它一般是整个项目唯一持有数据库连接的地方,所有 DAO 都从它拿Connection。我在改这类项目时有个习惯:先把 DBUtil 从头到尾读一遍,看它是每次 new 一个连接,还是维持了一个简易连接池。前者在演示环境够用,几十个人同时借书就会把 MySQL 的连接数顶满,这个坑在第 5 章会细讲。

WebRoot这个名字本身就说明它是 Eclipse Dynamic Web Project 的产物。如果你用 IDEA 打开,需要把它标记成 Web 资源目录,或者在 Project Structure 里把 Web Resource Directory 指到WebRoot,否则 Tomcat 起来后访问任何 JSP 都是 404,人还没开始调代码就先卡在环境上。

2.2 为什么这套老技术栈反而适合二次开发

很多人一看没有 Spring Boot 就皱眉,觉得这是十年前的产物。但放到"三天内交付一个可演示的借阅模块"这个场景里,它的优势非常明显:没有 Maven 依赖树要下载,WEB-INF/lib下扔几个 jar 就能编译;没有自动配置的黑匣子,请求从哪来到哪去,顺着web.xml一条条看下去就清楚了;改一个查询条件,只需要动 DAO 里的一行 SQL,不用管注解扫描和 Bean 注入。

它采用的是典型的 MVC 变形,也有人叫"JSP+JavaBean+Servlet"模式。JSP 只负责渲染,Servlet 收参数做校验,Service 里管业务规则和事务,DAO 里只有 SQL。真正写起来你会发现,业务规则其实就那么几条:一个读者同时最多借几本、借期多少天、逾期每天罚多少钱、库存为 0 能不能借。这些规则如果散落在 JSP 的if里,后面加一条"教师可借 10 本"的需求就得改十几个页面;集中在 Service 里,改一个常量就够了。

我一般会在动手改之前先做一件事:把所有硬编码的业务数字(借阅上限、借期天数、罚金单价)从各个 Servlet 里搜出来,统一挪到一个Constants类里。这一步花不了二十分钟,但后面调参数的时候能省掉大量重新编译的时间。这套源码里如果这些数字是散落的,建议你第一件事就做这个整理。

2.3 从 web.xml 到 DAO:一次请求到底走了哪几步

这类项目的web.xml是理解全局的钥匙,两个 Filter 加一组 Servlet 映射基本就是全部内容:

<!-- 编码过滤器必须排在鉴权过滤器前面,否则中文参数在鉴权阶段就已经乱了 --> <filter> <filter-name>EncodingFilter</filter-name> <filter-class>com.library.filter.EncodingFilter</filter-class> <init-param> <param-name>encoding</param-name> <param-value>UTF-8</param-value> </init-param> </filter> <filter-mapping> <filter-name>EncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <filter> <filter-name>AuthFilter</filter-name> <filter-class>com.library.filter.AuthFilter</filter-class> </filter> <filter-mapping> <filter-name>AuthFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> <!-- 用一个 Servlet 扛下整个图书模块,靠 action 参数分发,能少写十几个类 --> <servlet> <servlet-name>BookServlet</servlet-name> <servlet-class>com.library.servlet.BookServlet</servlet-class> </servlet> <servlet-mapping> <servlet-name>BookServlet</servlet-name> <url-pattern>/book</url-pattern> </servlet-mapping>

几个参数的含义值得说清楚。<url-pattern>/*</url-pattern>表示拦截所有请求,包括静态资源,所以 AuthFilter 内部一定要放行login.jsp、css/、js/这些路径,否则登录页自己都被拦住了,浏览器上就是无限重定向。<init-param>里的 encoding 通过filterConfig.getParameter("encoding")读出来,好处是改编码不用改代码,坏处是很多人忘了在init()里真正调用request.setCharacterEncoding()。

完整的链路是:浏览器请求/book?action=list→ EncodingFilter 设置编码 → AuthFilter 检查 session 里有没有登录用户 → BookServlet 的doGet里根据action分发到list()方法 → 调BookService.findAll(page)→ 调BookDaoImpl.findByPage()拼 SQL 查库 → 结果塞进request.setAttribute→forward到jsp/book_list.jsp渲染。这条链路你只要在 BookServlet 的doGet第一行打个断点,再点一次页面上的"图书列表",整条路径就全在眼前了。

3. 数据库设计:三张主表撑起借阅业务

图书馆系统的表可以很多,也可以很少。这套源码的核心其实就三张:图书表、读者表、借阅记录表。剩下的分类表、管理员表、罚金流水表都是围绕它们做扩写。看一个 JavaWeb 项目的数据库设计水平,我一般先看借阅记录表有没有把"应还日期"和"实还日期"分开存,分开存了,逾期计算才有依据;只存一个借出时间,后面做续借和罚金就只能靠估算。

3.1 建表 SQL 与字段取舍

把database/library.sql导入 MySQL 之后,核心三张表大致是这样:

CREATE TABLE `book` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `isbn` VARCHAR(20) NOT NULL UNIQUE COMMENT '国际标准书号,做唯一约束防止重复入库', `title` VARCHAR(120) NOT NULL, `author` VARCHAR(60), `category_id` INT DEFAULT 1, `total` INT NOT NULL DEFAULT 1 COMMENT '馆藏总量', `stock` INT NOT NULL DEFAULT 1 COMMENT '当前可借数量', `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 上架 0 下架', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `reader` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `card_no` VARCHAR(20) NOT NULL UNIQUE COMMENT '借书证号,登录账号', `name` VARCHAR(40) NOT NULL, `password` VARCHAR(64) NOT NULL COMMENT '早期版本多为明文或 MD5,改造时换 BCrypt', `type` TINYINT NOT NULL DEFAULT 1 COMMENT '1 学生 2 教师', `phone` VARCHAR(20), `status` TINYINT NOT NULL DEFAULT 1 COMMENT '1 正常 0 冻结', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `borrow_record` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `book_id` INT NOT NULL, `reader_id` INT NOT NULL, `borrow_date` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, `due_date` DATETIME NOT NULL COMMENT '应还日期,借出时算好写入,后续不再改', `return_date` DATETIME DEFAULT NULL COMMENT '为 NULL 表示还没还', `fine` DECIMAL(8,2) NOT NULL DEFAULT 0.00, `status` TINYINT NOT NULL DEFAULT 0 COMMENT '0 借出中 1 已归还 2 逾期 3 丢失', KEY `idx_reader_status` (`reader_id`, `status`), KEY `idx_book` (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字段的取舍有两个地方值得单独说。book表同时存total和stock,属于典型的冗余设计——总量减去可借数就是已借出数,理论上能算出来,为什么要存两个?因为借书时的并发扣减必须靠UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0这一条原子语句来完成,如果每次都要先SELECT COUNT(*)再从借阅记录里反推,两次查询之间就有窗口,两个人同时点"借阅"会把同一本书借出去两次。这个坑我在别的地方真踩过,后面 3.3 会讲完整做法。

另一个是due_date单独落库。很多人图省事,用borrow_date + 30 天在查询时现算逾期,短期没问题,一旦支持"教师借 60 天、学生借 30 天",或者允许续借一次延长 30 天,现算的逻辑就会散在好几个地方,改起来非常容易漏。借出那一刻把应还日期定死,续借时只更新这一个字段,后面所有逾期判断都只看这一列,逻辑就收敛了。

索引方面,idx_reader_status是给"查某读者当前未还的书"用的,idx_book是给"查这本书的借阅历史"用的。这两个查询在读者端和管理端都会高频出现,表数据超过几万行之后没有索引会明显卡顿。

3.2 借阅状态机:一本书从在架到归还经历了什么

状态字段是这类系统里最容易写乱的地方。我见过一版源码,status同时被用来表示"书在不在架"和"这条记录还不还",结果一个读者把书弄丢了,管理员把书下架,借阅记录的 status 也跟着变,历史数据全乱了。正确的做法是两个状态分开:book.status描述书本身能不能借,borrow_record.status描述这一笔借阅走到了哪一步。

借阅记录的四态流转是这样的:新插入时status=0(借出中),到了应还日期还没还就转2(逾期),归还时转1(已归还)并写return_date,书丢失则转3且库存不再回补。这里有个现实的工程问题——谁把0改成2?最朴素的做法是不落库,列表查询时用due_date < NOW() AND return_date IS NULL动态判断,页面上显示"逾期"就行。这样做的缺点是按状态筛选的 SQL 会变得很长。

我更倾向于折中:查询时动态判断用于展示,另外用一个每天凌晨跑一次的定时任务批量刷状态。没有 Quartz 依赖的项目,用 Servlet 的init()起一个ScheduledExecutorService就够了,代码量不到二十行,也不引入任何新依赖。这套源码如果没带定时任务,二次开发时补上这一段是很划算的。

3.3 库存扣减的并发问题:一条 SQL 挡住超借

这段是我认为最需要抄走的部分。假设两个人几乎同时点了同一本书的"借阅",你的代码如果是这样的:

// 错误示范:先查再改,中间有时间窗口 Book book = bookDao.findById(bookId); if (book.getStock() > 0) { bookDao.updateStock(bookId, book.getStock() - 1); borrowDao.insert(bookId, readerId); }

两个线程都能读到stock = 1,都判断通过,最后库存被扣成-1,一本书借给了两个人。正确做法是把判断题交给数据库:

-- 库存扣减:条件 UPDATE 是原子的,返回影响行数 0 就代表没抢到 UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0;

Java 侧只判断返回值:

public boolean decreaseStock(int bookId) throws SQLException { String sql = "UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0"; try (PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, bookId); // 影响行数为 1 说明抢到了,为 0 说明库存已被别人扣光或者书已下架 return ps.executeUpdate() == 1; } }

参数只有一个bookId。返回值语义要记牢:true才是扣减成功,false表示这本书此刻不可借,业务层必须直接回滚而不是继续往下写借阅记录。这一步和后面的INSERT必须在同一个事务里,否则扣了库存却没写记录,或者写了记录却没扣库存,都会让数据对不上账。

4. 核心代码拆解:登录鉴权与借还书的完整链路

业务代码里真正需要精读的只有两块:一是登录和权限过滤,它决定了 anybody 能不能进后台;二是借书还书,它涉及事务、并发和金额计算。其余的增删改查都是同一个套路,看懂一个 Servlet 剩下全是复制粘贴。

4.1 登录校验与 AuthFilter:别让后台裸奔

登录 Servlet 做的事情很固定:取参数、查用户、比对密码、写 session、跳转。

@WebServlet("/login") public class LoginServlet extends HttpServlet { private final ReaderService readerService = new ReaderService(); @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String cardNo = req.getParameter("cardNo"); String password = req.getParameter("password"); String role = req.getParameter("role"); // reader / admin if (cardNo == null || cardNo.trim().isEmpty() || password == null) { req.setAttribute("msg", "借书证号和密码不能为空"); req.getRequestDispatcher("/jsp/login.jsp").forward(req, resp); return; } Object user = "admin".equals(role) ? adminService.login(cardNo, password) : readerService.login(cardNo.trim(), password); if (user == null) { // 不区分"账号不存在"和"密码错误",避免被撞库探测 req.setAttribute("msg", "账号或密码不正确"); req.getRequestDispatcher("/jsp/login.jsp").forward(req, resp); return; } // session 里同时存用户对象和角色,过滤器靠这两个键做判断 req.getSession().setAttribute("loginUser", user); req.getSession().setAttribute("role", role); resp.sendRedirect(req.getContextPath() + "/book?action=list"); } }

几个参数要注意:req.getContextPath()拿到的是项目部署名,比如/library,拼在重定向路径前面,项目改名部署也不会 404,这是新手最容易忽略的一句。session 的 key 用loginUser而不是user,是因为过滤器里也要用同一个字符串,两边不一致就会出现"登录成功但一直被踢回登录页"的玄学现象。

过滤器这边,核心逻辑是白名单加未登录跳转:

public class AuthFilter implements Filter { // 不需要登录就能访问的路径,登录页、静态资源、登录接口本身 private static final String[] WHITE_LIST = {"/jsp/login.jsp", "/login", "/css/", "/js/", "/images/"}; @Override 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 white : WHITE_LIST) { if (path.startsWith(white)) { chain.doFilter(request, response); return; } } if (req.getSession().getAttribute("loginUser") == null) { resp.sendRedirect(req.getContextPath() + "/jsp/login.jsp"); return; } chain.doFilter(request, response); // 放行,请求继续往后走 } }

getRequestURI()返回的是带项目名的完整路径,减掉getContextPath()才是过滤器内部能比较的相对路径,这一步不做,白名单里写/jsp/login.jsp永远匹配不上(实际拿到的是/library/jsp/login.jsp),结果就是登录页也被拦,浏览器报"重定向次数过多"。这个坑几乎每套这类源码都有人踩。

4.2 借书:一次事务里的三个动作

借书是整个系统里唯一必须开事务的操作,因为它要同时改两张表。顺序是:校验借阅上限 → 扣库存 → 写借阅记录。

public class BorrowService { private static final int MAX_BORROW_STUDENT = 5; // 学生同时最多借 5 本 private static final int LOAN_DAYS = 30; // 借期 30 天 public Result borrow(int bookId, int readerId, int readerType) { Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关掉自动提交,开启事务 BorrowDao borrowDao = new BorrowDao(conn); BookDao bookDao = new BookDao(conn); int limit = (readerType == 2) ? 10 : MAX_BORROW_STUDENT; if (borrowDao.countUnreturned(readerId) >= limit) { conn.rollback(); return Result.fail("已达借阅上限,请先归还部分图书"); } // 条件更新,库存不足时返回 false,直接结束 if (!bookDao.decreaseStock(bookId)) { conn.rollback(); return Result.fail("这本书已被借完"); } Date dueDate = DateUtil.plusDays(new Date(), LOAN_DAYS); borrowDao.insert(bookId, readerId, dueDate); conn.commit(); // 三步全部成功才落库 return Result.ok("借阅成功,请于 " + DateUtil.format(dueDate) + " 前归还"); } catch (Exception e) { DBUtil.rollbackQuietly(conn); // 异常时回滚,避免半截数据 throw new RuntimeException("借书失败", e); } finally { DBUtil.close(conn); // 归还连接,连接池模式下必须 } } }

参数说明:bookId是图书主键,readerId是读者主键,readerType决定借阅上限(1 学生、2 教师)。setAutoCommit(false)是整段代码的关键,不写这一句,三条 SQL 各自提交,扣了库存但插入失败就会留下"书少了但没借出去"的脏数据,对账时非常难查。rollbackQuietly是个工具方法,内部吞掉回滚本身的异常,防止原始错误被覆盖。

还有一个细节:MAX_BORROW和LOAN_DAYS这两个常量应该从配置文件读,而不是硬编码。教师和学生上限不同这一点,很多源码版本干脆没做,二次开发时改这里是最容易出效果的一处。

4.3 还书与逾期罚金:金额用 BigDecimal,别用 double

还书要干三件事:更新记录的归还时间和状态、回补库存、算罚金。罚金计算是最容易写出精度问题的地方。

public class FineCalculator { // 每天罚 0.20 元,用 BigDecimal 避免 0.1 + 0.2 这类浮点误差 private static final BigDecimal FINE_PER_DAY = new BigDecimal("0.20"); public static BigDecimal calc(Date dueDate, Date returnDate) { long diffMs = returnDate.getTime() - dueDate.getTime(); if (diffMs <= 0) { return BigDecimal.ZERO; // 提前还或按期还,不罚 } // 向上取整:逾期 1 小时也算一天,和线下图书馆的规则保持一致 long days = (long) Math.ceil(diffMs / (1000.0 * 60 * 60 * 24)); return FINE_PER_DAY.multiply(BigDecimal.valueOf(days)); } }

Math.ceil这一句是业务规则,不是技术细节:有的馆按"逾期不足一天不罚",有的按"超过一分钟就算一天",脚本里必须和实际规章对齐,否则上线后读者会来争论。BigDecimal的乘法要用multiply,而且构造时传字符串"0.20"而不是0.20,后者还是会带进浮点误差,这是 Java 里关于金额的血泪经验。

归还的 SQL 也要带状态条件,防止重复提交导致库存被回补两次:

-- 只有 status = 0 或 2 的记录才允许归还,影响行数为 0 说明这笔记录已经还过了 UPDATE borrow_record SET return_date = NOW(), status = 1, fine = ? WHERE id = ? AND status IN (0, 2);

WHERE后面的状态条件就是幂等保护。用户手抖点了两次"归还",第二次影响行数是 0,库存就不会多加一本。这类幂等设计在任何涉及数量增减的系统里都值得抄。

4.4 分页查询与模糊搜索:PageBean 的固定套路

列表页一律分页,这是基本要求。这套源码里分页通常封装成一个PageBean,字段就那几个:当前页、每页条数、总记录数、数据列表。

public PageBean<Book> findByPage(String keyword, int page, int pageSize) throws SQLException { PageBean<Book> pb = new PageBean<>(); StringBuilder where = new StringBuilder(" WHERE status = 1 "); List<Object> params = new ArrayList<>(); if (keyword != null && !keyword.trim().isEmpty()) { // 书名或作者任一匹配,注意两边的 % 拼在参数里,不要拼进 SQL 字符串 where.append(" AND (title LIKE ? OR author LIKE ?) "); String like = "%" + keyword.trim() + "%"; params.add(like); params.add(like); } // 先查总数,用来算总页数 String countSql = "SELECT COUNT(*) FROM book" + where; int total = queryCount(countSql, params); pb.setTotal(total); pb.setPage(page); // LIMIT 的偏移量 = (当前页 - 1) * 每页条数 String dataSql = "SELECT * FROM book" + where + " ORDER BY id DESC LIMIT ?, ?"; params.add((page - 1) * pageSize); params.add(pageSize); pb.setList(queryList(dataSql, params)); return pb; }

参数含义:keyword允许为空,为空就退化成全量分页;page从 1 开始,前端传 0 或负数时要在 Servlet 里兜底成 1,否则LIMIT -5, 10会直接语法报错。%拼在 Java 的字符串里而不是 SQL 里,是为了让PreparedStatement能正确转义,同时也顺手挡住了注入。ORDER BY id DESC保证新入库的书排前面,这条不加,翻页时会出现同一本书在两页都出现的情况,属于典型的"看起来能用但用户会抱怨"的问题。

5. 部署跑通与常见问题排查:Tomcat 起不来、登录 500、中文乱码

代码看懂了不等于能跑起来。这套东西的部署步骤不多,但每一步都有常见的翻车点。我按实际顺序走一遍,然后把最耗时间的几个问题单独列出来。

5.1 六步把项目跑起来

# 1. 建库并导入结构和初始数据 mysql -uroot -p -e "CREATE DATABASE library DEFAULT CHARSET utf8mb4;" mysql -uroot -p library < database/library.sql # 2. 确认数据库里已经有初始管理员账号 mysql -uroot -p library -e "SELECT id, card_no, type FROM reader LIMIT 5;"

第三步改配置文件。这类项目通常把连接信息放在src/jdbc.properties或者直接写在DBUtil里:

jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://127.0.0.1:3306/library?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false jdbc.username=root jdbc.password=你的密码

四个参数里最容易出问题的是serverTimezone。MySQL 8 的驱动要求显式指定时区,不写会抛The server time zone value is unrecognized,看着像连接失败,其实是时区解析失败。另外useSSL=false是本地开发关掉 SSL 握手,省掉一堆警告。

第四步放驱动 jar 到WEB-INF/lib,注意 MySQL 8 用mysql-connector-java-8.x.x.jar,MySQL 5.7 用5.1.x也能跑,但驱动类名不同:8.x 是com.mysql.cj.jdbc.Driver,5.x 是com.mysql.jdbc.Driver,写错就是ClassNotFoundException。

第五步配 Tomcat,把项目部署上去,端口默认 8080。第六步浏览器访问http://localhost:8080/library/jsp/login.jsp,用library.sql里带的初始账号登录。如果首页是index.jsp,直接访问http://localhost:8080/library/也行,它一般会 redirect 到登录页。

5.2 五条踩坑记录:现象、原因、解决

第一条:访问任何页面都是 404,Tomcat 日志里也没有异常。

现象是浏览器白页加 404,控制台干干净净。原因通常是项目的访问路径不对,或者WebRoot没被识别成 Web 资源目录,导致 class 文件根本没编译到WEB-INF/classes。解决方式是先在 Tomcat 的webapps目录下确认有没有生成对应的项目文件夹,再检查 IDE 的 Deployment Assembly 里src有没有映射到WEB-INF/classes。IDEA 里的具体路径是 Project Structure → Artifacts → 看 Output Layout 里有没有WEB-INF/classes。

第二条:点登录直接 500,堆栈里是NullPointerException指向DBUtil.getConnection()。

现象是页面报 500,日志第一行是空指针。原因八成是Class.forName("com.mysql.cj.jdbc.Driver")抛了ClassNotFoundException被吞掉,DriverManager拿不到驱动返回 null。解决方式是先确认 jar 真的在WEB-INF/lib里(不是项目根目录的 lib),再确认驱动类名和 MySQL 版本匹配,最后把Class.forName的异常打出来而不是e.printStackTrace()完事。

第三条:中文全是问号,图书名存进去变成???。

这个要分三层查,顺序不能乱。第一层是数据库和表的字符集,SHOW CREATE TABLE book看是不是utf8mb4;第二层是 JDBC URL 有没有带useUnicode=true&characterEncoding=utf8;第三层是代码里有没有在request.getParameter之前调用setCharacterEncoding。三层里任意一层没对上都会乱码,而只改其中一层往往看起来"好了一点"但没根治,这是最折磨人的地方。我的做法是三层一次性全查一遍,别猜。

第四条:登录成功后又跳回登录页,反复循环。

现象是输对密码,页面闪一下回到登录框,浏览器提示重定向次数过多。原因有两个常见版本:一是 LoginServlet 里 session 的 key 写的是user,而 AuthFilter 里判断的是loginUser;二是 AuthFilter 的白名单没放行/jsp/login.jsp,登录页本身被拦。排查方法是在过滤器的doFilter第一行打印path和 session 里的所有属性名,一眼就能看出对不上。

第五条:借书提示成功,但刷新列表库存没变。

现象是前端弹了"借阅成功",数据库里borrow_record有新增记录,book.stock却没减少。原因基本是 DAO 里各自拿了独立的 Connection,setAutoCommit(false)只对当前连接生效,另一条 SQL 在别的连接上自动提交了。解决方式是让 Service 层创建 Connection 并透传给所有 DAO 构造函数,绝不允许 DAO 内部自己DBUtil.getConnection()。这个改法在第 4.2 的代码里已经体现了,new BorrowDao(conn)就是把连接传进去。

6. 二次开发:从"能演示"到"能交付"的几步改造

把这套系统改造成能真正交付的东西,我一般按优先级排三件事:先补安全和健壮性,再加业务功能,最后才考虑技术栈升级。顺序反过来做,很容易在换了框架之后发现业务逻辑本来就是错的。

第一步是把密码存储换掉。源码里如果是明文或者简单 MD5,改成 BCrypt 只需要加一个 jar,登录处BCrypt.checkpw(raw, hashed),注册和改密处BCrypt.hashpw(raw, BCrypt.gensalt())。数据库字段长度要留到 60 以上,很多人改完发现存进去被截断,登录永远失败,就是因为password VARCHAR(32)没扩。

第二步是把DBUtil换成连接池。手写连接最大的问题不是性能,是连接泄漏——某个 DAO 忘了 close,跑一天之后 MySQL 报Too many connections,整个系统挂掉。换成 Druid 或 HikariCP 之后,连接归还由池子兜底,配合try-with-resources基本不会再漏。改造点集中在DBUtil.getConnection()一个方法里,其他代码一行不用动,这也是当初分层带来的好处。

第三步是加业务字段。最常见的需求是"续借"和"预约"。续借只需要在borrow_record上加一个renew_count字段,Service 里判断小于 1 就允许把due_date往后推 30 天,同时校验这本书没有别人在预约。预约要新建一张reservation表,还书时触发"通知下一个预约者",这一步如果没有消息推送渠道,先在管理端做一个待处理列表就够了,不用一上来就搞邮件短信。

第四步才是技术栈。如果确实要迁到 Spring Boot,我的建议是不要重写,而是按模块切:先把 DAO 层换成 MyBatis 的 Mapper 接口,SQL 原样搬过去;再把 Servlet 换成@RestController,参数从request.getParameter换成方法入参;最后 JSP 换成静态页加接口调用。每切完一个模块就跑一遍原有的功能,保证行为一致。整包重写听着痛快,实际上业务规则全在那些if里,重写等于重新踩一遍坑。

验证方法我习惯用最笨的:准备三个账号(学生、教师、管理员)、五本书(其中一本库存为 1)、两条逾期记录,然后按借书 → 超限借书 → 还书 → 逾期还书 → 重复还书这个顺序点一遍,每一步都去数据库里核对stock和status。这套流程跑通,基本能过滤掉九成的问题。

提示:改造过程中每完成一步就提交一次版本,尤其是改DBUtil和事务相关代码时。这类改动出问题往往不是立刻报错,而是跑一段时间后数据对不上,没有回退点会非常被动。

从那以后我每次接手这类"能跑但不敢上线"的源码项目,都强制自己先做一遍上面那个五人五书的验证流程,再动任何一行代码。看着慢,实际上省掉了后面反复排查数据不一致的时间。希望帮到你。

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/28 9:13:00

汽车电子底层软件中的数学:从ADC标定到PID与CRC

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/28 9:12:26

YOLOv5训练DNF识别模型:自动脚本目标检测避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/28 9:12:09

工控数据类型与值范围详解:PLC、Modbus 实战避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/28 9:11:35

BMS电池管理系统国产AFE芯片选型与替代实操指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/28 9:09:55

工业视觉测量精度实战:标定误差与光照漂移的工程化解法

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/10/28 9:09:11

STM32F1驱动DHT11实战:单总线时序与GPIO模式切换详解

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华