简介:基于Java Web三层架构的图书管理系统,是一套可直接运行的课程设计/毕业设计项目,特别适合正在完成大作业或初学Web开发的读者。压缩包内共141个文件,包含核心业务Java源码、编译后的class文件、JSP视图页面、XML项目配置、SQL数据库脚本,以及CSS和JavaScript等前端资源,整体体积仅5.12MB,目录结构清晰,有助于对照理解表示层、业务层与数据访问层之间的调用关系。系统实现了图书信息维护、读者管理、登录验证等常用功能,通过JDBC完成数据访问,JSP负责页面展示,Servlet处理业务请求;配套数据库脚本可直接导入,按项目说明完成配置即可无修改运行,能帮助读者掌握从数据表设计到界面交互的完整Web项目流程。目前已有100人学习下载,作为高分必过的课程设计方案,完整度和可运行性较有保障,既适合日常练习,也能直接用于答辩展示。
1. 基于JDBC+JSP+Servlet的图书管理系统是什么:一个能跑完Java Web全流程的实战项目
如果你正在学Java Web,多半会遇到这样一个坎:理论都看过了,但一次用户点击到底怎么流转到数据库、再弹回页面,脑子里还是没有完整画面。用Spring Boot做项目,很多中间过程被框架藏了起来;而基于JDBC+JSP+Servlet的图书管理系统,逼着你手动处理加载驱动、建立连接、拼SQL、写Servlet、渲染JSP每一步。这份源码压缩包里通常就是完整的Java源码、数据库建表脚本和项目说明,解压配置后丢进Tomcat就能跑通。它适合准备毕业设计、补Servlet/JSP基本功、或者想搞清楚Web容器底层机制的开发者。把它当成一个会说话的黑匣子来拆,比背一百道面试题都管用。
2. 三层架构与环境准备:动手写代码前需要想明白的三个核心点
2.1 为什么是JDBC+JSP+Servlet:这套组合的学习价值在哪里
JSP负责展示、Servlet负责控制、JDBC负责数据访问,这就是Java Web最经典的MVC分层。JSP里直接写Java脚本的时代早就过去了,现在主流做法是JSP配合EL表达式和JSTL标签渲染页面,Servlet只做请求接收和数据传递,JDBC则是Java操作关系型数据库的最底层规范,MyBatis和Hibernate这类ORM框架底层都绕不开它。
用这个组合亲手搭一个图书管理系统,你能收获三样硬功夫。第一,理解一次HTTP请求从浏览器到Servlet再到DAO的完整链路,知道request、response、session各自在哪个环节生效。第二,学会手动管理数据库连接资源,亲眼看到连接不关闭会导致什么后果,这是用框架的人很难产生的体感。第三,把请求转发和重定向的区别用在实际业务里,而不是只在面试题里见过。
为什么不用Spring Boot?不是不能用,而是它把Tomcat、DispatcherServlet、数据源全部通过自动配置藏了起来,初学者遇到报错经常不知道去哪查。我的原话是:先把这个手工版跑通,再回去看Spring Boot,你会发现自己看框架日志时明显不慌了。生产项目确实很少再直接用JSP,但这个项目作为理解Web本质的载体,价值恰恰在“笨”。
提示:这个项目对JDK、Tomcat、MySQL的版本搭配比较敏感,后面部署章节会专门讲。环境贴近我给的组合,能少很多玄学问题。
2.2 开发环境与工程目录:从零搭出可运行的Web工程
我一般推荐的环境组合是:JDK 8或11,Tomcat 8.5或9,MySQL 5.7或8.0,开发工具用Eclipse IDE for Enterprise Java and Web Developers,或者IntelliJ IDEA Ultimate。新手最容易卡在第一步:用了只支持Java SE的Eclipse版本,根本建不了Dynamic Web Project,装一堆插件还是报错。
下面是一个标准Maven工程的目录结构,zip包里的源码一般也是这个样子:
book-manager/ ├── pom.xml # Maven配置,非Maven工程可忽略 ├── src/main/java │ ├── com/book/bean # 实体类:Book, User, BorrowRecord │ ├── com/book/dao # 数据访问层:BookDao, UserDao │ ├── com/book/servlet # 控制器:LoginServlet, BookServlet, BorrowServlet │ ├── com/book/util # 工具类:DBUtil, StringUtil │ └── com/book/filter # 过滤器:EncodingFilter ├── src/main/webapp │ ├── login.jsp # 登录页 │ ├── book_list.jsp # 图书列表页 │ ├── book_add.jsp # 新增图书页 │ ├── WEB-INF │ │ ├── web.xml # Web部署描述文件 │ │ ├── lib # 非Maven工程放mysql驱动jar的位置 │ │ └── classes # 编译后的class文件输出目录 └── src/main/resources └── db.properties # 数据库连接配置这个目录结构有几个关键决策点。第一,bean实体类负责承载数据库字段,让Servlet和JSP之间不用散装Map传数据,代码可读性高一个档次。第二,DAO把SQL全部收拢,Servlet不直接碰JDBC代码,这是分层最需要守住的底线,否则后期加需求时改动会到处开花。第三,数据库连接参数放在db.properties而不是写死在代码里,换库、换密码、换端口时只改配置文件,不用重新编译。
数据库驱动版本要和MySQL版本对得上。MySQL 5.7可以用5.1.x或8.0.x驱动,但连接串写法不同;MySQL 8.0以上建议用8.0.x驱动,并且必须带serverTimezone参数。先记一个结论,后面代码里我会再展开:MySQL 8.0的连接串是jdbc:mysql://localhost:3306/book_db?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false,少了serverTimezone,启动必报时区异常。
2.3 web.xml与Tomcat部署的最小配置:先让页面能访问再说
很多人代码写了半天下不了地,问题出在web.xml上。下面是兼容Tomcat 8.5和9的精简配置:
<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_3_1.xsd" version="3.1"> <display-name>BookManager</display-name> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> <filter> <filter-name>EncodingFilter</filter-name> <filter-class>com.book.filter.EncodingFilter</filter-class> </filter> <filter-mapping> <filter-name>EncodingFilter</filter-name> <url-pattern>/*</url-pattern> </filter-mapping> </web-app>web.xml要点有三个。welcome-file决定你访问项目根路径时先加载哪个页面,拼写必须和实际文件名一致。filter-mapping里的/*表示所有请求都先经过过滤器,统一编码的EncodingFilter就配在这里。如果你用Servlet注解@WebServlet,这里可以不写servlet配置,但新手我更建议先用web.xml写一遍,能直观看到URL和Servlet类的绑定关系。
Tomcat部署有两条路。第一条,把项目打成war包扔进Tomcat的webapps目录,启动后自动解压,适合部署到服务器。第二条,在IDE里配置Tomcat Server,把项目挂上去启动,适合日常调试,改完代码热部署快,还能实时看Tomcat控制台日志。排查问题全靠那些日志,我一般用第二种。
环境配好后,启动Tomcat,浏览器访问http://localhost:8080/book-manager/,能看到login.jsp页面,说明基础链路通了。如果404,按三个顺序查:项目是否部署成功、访问路径是否带对了context path、welcome-file文件名有没有拼错。把这个当成第一个里程碑,不要急着追求完整功能。
3. 数据库设计与JDBC数据访问层:让图书的增删改查先跑起来
3.1 表结构设计:四张核心表与建表SQL脚本
图书管理系统最少需要四张表:用户表、图书表、借阅记录表、分类表。有些项目会加管理员表,但更常见的做法是在用户表里加一个role字段区分管理员和普通读者,少一张表就少一份关联查询的麻烦。下面是一套常用的建表脚本:
CREATE DATABASE IF NOT EXISTS book_db DEFAULT CHARSET utf8mb4; USE book_db; CREATE TABLE `user` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL, `real_name` VARCHAR(50), `role` TINYINT DEFAULT 0 COMMENT '0-普通读者 1-管理员', `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `category` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `name` VARCHAR(50) NOT NULL ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `book` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `book_name` VARCHAR(100) NOT NULL, `author` VARCHAR(50), `publisher` VARCHAR(100), `price` DECIMAL(10,2), `stock` INT DEFAULT 1 COMMENT '库存数量', `category_id` INT, `create_time` DATETIME DEFAULT CURRENT_TIMESTAMP, CONSTRAINT fk_book_category FOREIGN KEY (category_id) REFERENCES category(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `borrow_record` ( `id` INT PRIMARY KEY AUTO_INCREMENT, `user_id` INT NOT NULL, `book_id` INT NOT NULL, `borrow_date` DATE NOT NULL, `return_date` DATE, `status` TINYINT DEFAULT 0 COMMENT '0-借出 1-已还 2-逾期', CONSTRAINT fk_borrow_user FOREIGN KEY (user_id) REFERENCES user(id), CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES book(id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;建表这里有一个特别容易踩的坑:user是MySQL的保留字,直接CREATE TABLE user会报语法错误。要么像上面一样用反引号包起来,要么干脆把表名改成users。zip里的项目说明如果提到导入脚本出错,十有八九就是这类保留字问题。
字段设计上还有几点要注意。库存stock和借阅状态status是联动的:借书要先判断stock大于0,再执行UPDATE扣减,再插入借阅记录,这三个动作必须放在同一个事务里,不然高并发下库存会变成负数。price用DECIMAL(10,2)而不用FLOAT/DOUBLE,因为浮点数的精度误差在金额计算里没法容忍。create_time用DATETIME DEFAULT CURRENT_TIMESTAMP,插入记录时不用手动填。外键约束能保证数据完整性,代价是删除分类时会受限制,如果你的项目说明里故意不建外键,那是在给“删除分类时如何处理历史图书”留余地,不算设计缺陷。
3.2 JDBC工具类与DAO:连接、查询、增删改查的代码模板
JDBC访问数据库的标准套路是五步:加载驱动、获取连接、创建Statement/PreparedStatement、执行SQL、处理结果集。先给一个DBUtil工具类的写法:
package com.book.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; public class DBUtil { private static final String DRIVER = "com.mysql.cj.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/book_db" + "?useUnicode=true&characterEncoding=utf8" + "&serverTimezone=Asia/Shanghai&useSSL=false"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, Statement stmt, ResultSet rs) { if (rs != null) { try { rs.close(); } catch (SQLException ignored) {} } if (stmt != null) { try { stmt.close(); } catch (SQLException ignored) {} } if (conn != null) { try { conn.close(); } catch (SQLException ignored) {} } } }DBUtil里有三个关键参数要记住。驱动类名:MySQL 8.0以上用com.mysql.cj.jdbc.Driver,MySQL 5.7及更早用com.mysql.jdbc.Driver,写错了直接报ClassNotFoundException。连接串:useUnicode=true和characterEncoding=utf8负责中文,serverTimezone=Asia/Shanghai解决MySQL 8.0的时区报错,useSSL=false关掉加密连接省去证书警告。账号密码硬写在代码里只是演示,后面第6章会说怎么挪到配置文件里。
再一个DAO方法,按书名做模糊查询:
package com.book.dao; import com.book.bean.Book; import com.book.util.DBUtil; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.List; public class BookDao { public List<Book> searchBooks(String keyword) { List<Book> list = new ArrayList<>(); String sql = "SELECT id, book_name, author, publisher, price, stock " + "FROM book WHERE book_name LIKE ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setString(1, "%" + keyword + "%"); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book book = new Book(); book.setId(rs.getInt("id")); book.setBookName(rs.getString("book_name")); book.setAuthor(rs.getString("author")); book.setPublisher(rs.getString("publisher")); book.setPrice(rs.getBigDecimal("price")); book.setStock(rs.getInt("stock")); list.add(book); } } } catch (SQLException e) { e.printStackTrace(); } return list; } }这段代码要重点看三个细节。第一,SQL用?占位符而不是字符串拼接,这是防SQL注入的最基本动作,也是JDBC里最该形成的肌肉记忆。第二,try-with-resources是Java 7以后的推荐写法,括号里声明的Connection和PreparedStatement会自动关闭,比手动在finally里写close更不容易漏。第三,rs.getXxx("列名")里的列名必须和SELECT里的字段别名一致,比如SELECT COUNT(*)时要用rs.getInt(1)或者给COUNT起个别名,否则报列名不存在。
如果你拿到手的项目说明里,DBUtil是每个方法里都new一个Connection,性能其实很浪费。常见做法是配合一个连接池,或者至少在工具类里做单例。但单例只是缓解,真正的方案是连接池,我在第6章展开。
3.3 借书还书的事务边界:库存和借阅记录必须同步更新
图书管理系统里最典型的事务场景是借书。判断库存、扣减库存、插入借阅记录,三个动作必须同时成功或同时失败。下面是事务版本的借书DAO方法:
public boolean borrowBook(int userId, int bookId) { String checkSql = "SELECT stock FROM book WHERE id = ? FOR UPDATE"; String updateSql = "UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0"; String insertSql = "INSERT INTO borrow_record " + "(user_id, book_id, borrow_date, status) VALUES (?, ?, CURDATE(), 0)"; Connection conn = null; try { conn = DBUtil.getConnection(); conn.setAutoCommit(false); // 关闭自动提交,开启事务 // 1. 锁定该图书的行,防止并发借书 try (PreparedStatement ps = conn.prepareStatement(checkSql)) { ps.setInt(1, bookId); try (ResultSet rs = ps.executeQuery()) { if (!rs.next() || rs.getInt("stock") <= 0) { return false; // 库存不足,直接回滚返回 } } } // 2. 扣减库存 try (PreparedStatement ps = conn.prepareStatement(updateSql)) { ps.setInt(1, bookId); int rows = ps.executeUpdate(); if (rows == 0) return false; } // 3. 插入借阅记录 try (PreparedStatement ps = conn.prepareStatement(insertSql)) { ps.setInt(1, userId); ps.setInt(2, bookId); ps.executeUpdate(); } 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); } catch (SQLException e) { e.printStackTrace(); } try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这段代码里有三个容易出问题的地方。setAutoCommit(false)之后如果忘了调commit(),程序结束时所有未提交操作都会被回滚,你会以为数据库“丢”了数据,其实是事务没提交。SELECT ... FOR UPDATE是InnoDB的行级锁,可以让两个用户同时借同一本书时变成排队执行,避免库存被超额扣减。finally里把连接状态还原成true再关闭,这是为了将来接连接池时,连接归还后不会带着残留事务状态给下一个人用。
如果你不想写这么复杂,有一个更短的实现:把“检查库存”和“扣减库存”合并成一条UPDATE book SET stock = stock - 1 WHERE id = ? AND stock > 0,通过受影响行数判断是否成功。这个写法性能更好,但无法区分“书不存在”和“库存不足”两种错误。我留独立查询是觉得图书管理系统的并发量根本不到拼性能的程度,可读性和可扩展性更重要。
4. Servlet控制器与JSP页面:从请求到响应的完整链路
4.1 Servlet路由设计:一个动作一个Servlet还是统一入口
Servlet层的组织方式有两种主流风格。第一种是一个功能一个Servlet,LoginServlet只管登录,BookServlet管图书列表和增删改,BorrowServlet管借书和还书;第二种是统一入口,一个Servlet根据action参数的取值分发到不同方法。新手我推荐第一种,每个Servlet的代码短,逻辑集中,出了问题能立刻定位到文件。等代码量大了再重构成统一入口也不迟。
下面是一个图书列表Servlet的写法,重点看doGet和doPost的分工:
package com.book.servlet; import com.book.bean.Book; import com.book.dao.BookDao; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; @WebServlet("/book/list") public class BookListServlet extends HttpServlet { private BookDao bookDao = new BookDao(); @Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String pageStr = req.getParameter("currentPage"); String keyword = req.getParameter("keyword"); int currentPage = 1; if (pageStr != null && !pageStr.isEmpty()) { currentPage = Integer.parseInt(pageStr); } // 按关键字查,这里先不分页,直接查全部 List<Book> books = bookDao.searchBooks(keyword == null ? "" : keyword); req.setAttribute("bookList", books); req.setAttribute("currentPage", currentPage); // 服务端转发,浏览器地址栏不变 req.getRequestDispatcher("/book_list.jsp").forward(req, resp); } @Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String name = req.getParameter("bookName"); String author = req.getParameter("author"); // 省略参数校验和数据装配 // bookDao.insertBook(...); // 写操作完成后重定向,防止刷新重复提交 resp.sendRedirect(req.getContextPath() + "/book/list"); } }Servlet有三个核心知识点。第一,通常约定doGet负责查询数据往页面塞,doPost负责接收表单做写操作。Servlet本身不强制,但按这个习惯来,代码分工清楚,别人接手也容易懂。第二,forward和sendRedirect的区别是重点:forward是服务端跳转,地址栏不变,request里的attribute在JSP里能直接取到;sendRedirect是让浏览器再发一次请求,地址栏会变,request数据带不过去,只能通过URL参数或session。列表查询用forward,新增或删除成功后必须用sendRedirect,不然用户一刷新就重复提交。第三,@WebServlet的路径必须以/开头,否则Tomcat启动直接报InvalidServletMappingException。
4.2 JSP页面与EL表达式:列表展示和表单回显的落地写法
JSP在这一层里只做渲染,不要写复杂业务。下面是一个图书列表页面的标准写法:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <head><title>图书列表</title></head> <body> <h2>图书列表</h2> <form action="${pageContext.request.contextPath}/book/list" method="get"> 关键字:<input type="text" name="keyword" value="${param.keyword}"/> <input type="submit" value="搜索"/> </form> <table border="1"> <tr> <th>编号</th><th>书名</th><th>作者</th><th>出版社</th> <th>价格</th><th>库存</th><th>操作</th> </tr> <c:forEach items="${bookList}" var="book"> <tr> <td>${book.id}</td> <td>${book.bookName}</td> <td>${book.author}</td> <td>${book.publisher}</td> <td>${book.price}</td> <td>${book.stock}</td> <td> <a href="${pageContext.request.contextPath}/book/edit?id=${book.id}">编辑</a> <a href="${pageContext.request.contextPath}/book/delete?id=${book.id}" onclick="return confirm('确定删除吗?')">删除</a> </td> </tr> </c:forEach> </table> </body> </html>JSP页面有三个高频出错点。第一,page指令的contentType必须同时带charset=UTF-8,只写text/html的话中文必乱。第二,c:forEach的items属性名${bookList}必须和Servlet里req.setAttribute("bookList", ...)的名字完全一致,差一个字母页面就会渲染出一个空的循环体。第三,所有action和链接都要拼上${pageContext.request.contextPath},这样项目就算改了部署名字,链接也不会404。
表单回显错误信息也是常见需求。Servlet里req.setAttribute("errorMsg", "...")然后forward到JSP,JSP里用<c:if test="${not empty errorMsg}">输出。注意forward才有attribute可用,sendRedirect之后request里的数据就没了,很多人在这里疑惑为什么取不到值。另外,JSP页面里尽量别写<% %>脚本片段,EL表达式和JSTL能覆盖绝大多数场景,脚本片段会把Java异常和HTML结构混在一起,调试起来极痛苦。
4.3 分页实现:LIMIT写法与翻页参数的状态维护
图书数量一多,一次性查全部渲染对数据库和页面都不友好。常见做法是SQL分页,用LIMIT加偏移量:
public List<Book> findBooksByPage(int currentPage, int pageSize) { String sql = "SELECT id, book_name, author, publisher, price, stock " + "FROM book ORDER BY id LIMIT ?, ?"; List<Book> list = new ArrayList<>(); int offset = (currentPage - 1) * pageSize; try (Connection conn = DBUtil.getConnection(); PreparedStatement ps = conn.prepareStatement(sql)) { ps.setInt(1, offset); ps.setInt(2, pageSize); try (ResultSet rs = ps.executeQuery()) { while (rs.next()) { Book book = new Book(); // 属性装配省略 list.add(book); } } } catch (SQLException e) { e.printStackTrace(); } return list; }分页要注意三点。第一,LIMIT的第一个参数是偏移量,公式是(currentPage-1)pageSize,页码从1开始,偏移量从0开始,第一次翻页就把公式算错的人不少。第二,总页数要单独查SELECT COUNT(),用(total + pageSize - 1) / pageSize算出总页数,然后把它和当前页、关键字一起放进request,JSP里才能渲染出正确的上一页和下一页。第三,搜索关键字在翻页时必须一直挂在页码链接上,比如/book/list?currentPage=2&keyword=Java,否则第二页就没有搜索条件了。这个坑在我见过的新手里出现频率极高。
如果你的项目数据量很小,也可以一次查出全部数据,在内存里用List.subList切页。实现简单,但数据量上来后查询时间和内存占用都会翻倍,连接池也撑不住。生产环境老老实实走SQL分页。
5. 部署避坑与常见问题排查:新手最容易翻车的五个环节
这个项目说难不难,但把它从“代码能编译”变成“打开浏览器能跑通”,中间隔着大量环境细节。我带新手时的经验是,八成问题集中在这五类里:驱动类找不到、路径映射不对、中文乱码、连接泄漏、重复提交。下面按现象到原因的写法逐一拆开,每一条都是实际调试中遇到过的。
5.1 跑不起来:ClassNotFoundException、404和路径映射
第一条坑是驱动ClassNotFoundException。现象:Tomcat启动后第一次访问数据库相关页面,控制台直接抛java.lang.ClassNotFoundException: com.mysql.jdbc.Driver,页面500,其他页面正常。原因有三种:驱动jar没进WEB-INF/lib;MySQL 8.0还在写旧驱动类名;或者用Maven构建时依赖scope写成provided导致运行时缺失。解决:查看部署后项目下的WEB-INF/lib目录,确认mysql-connector-java.jar物理存在;看一眼pom.xml里的scope;然后把DBUtil里的DRIVER常量改成com.mysql.cj.jdbc.Driver。改完要重新部署、不要只刷新页面,Tomcat的classes目录缓存经常坑人。
第二条坑是404与InvalidServletMappingException。现象:浏览器访问http://localhost:8080/book-manager/book/list直接404,或者Tomcat启动时报错,提示Servlet映射无效。原因:@WebServlet注解路径少了开头的/,或者两个Servlet的url-pattern都是/book,启动时映射冲突;还有可能是welcome-file指向的login.jsp和实际文件名不一致。解决:看Tomcat启动时控制台打印的“Mapping servlet: ... from ...”,确认自己写的路径和扫描结果一致;再检查URL里context path是book-manager还是ROOT,部署名不同,访问前缀就不同。如果改了配置还是老样子,把Tomcat的work目录整个删掉重启,能解决大部分“改了代码不生效”的假象。还有一点,IDE热部署有时不会自动重载web.xml,手动restart一下最稳。
5.2 数据正确性:乱码、连接耗尽与重复提交
第三条坑是中文乱码。现象:页面显示的数据库中文全是问号,或者表单提交到数据库后变成??,更隐蔽的是只有部分中文乱码,说明来源不止一个。原因:乱码是三层问题——JSP页面没有声明charset=UTF-8,或者JDBC连接串缺了characterEncoding=utf8,或者MySQL的库表字符集不是utf8mb4。解决这一条要三层一起查:JSP文件头加<%@ page contentType="text/html;charset=UTF-8" %>;db.properties里的URL加上useUnicode=true&characterEncoding=utf8;建表语句用DEFAULT CHARSET=utf8mb4。再加一个EncodingFilter,在doFilter里对request和response统一设置UTF-8,用Filter包住所有请求路径,这样新加的Servlet不会漏掉编码设置。这个组合拳打下来,乱码基本绝迹。注意Tomcat的GET请求编码由server.xml里的URIEncoding决定,Tomcat 8.0以后默认就是UTF-8,如果用了老版本Tomcat,需要手动改。
第四条坑是连接耗尽。现象:系统跑一段时间后,某些页面随机报Connection is not available,或者MySQL端提示Too many connections,重启Tomcat后恢复,过一阵又出现。原因:某条代码路径上Connection没有关闭,最常见的是查询过程中抛了SQLException,提前return或catch住就直接跳过了finally里的close。如果是连接池,表现就是连接被借走不还,池子慢慢被占满。解决:不要相信人眼检查,直接全局搜索new Connection和DBUtil.getConnection的调用点,把所有获取连接的操作改成try-with-resources写法;手动写的close一定放在finally里,并且所有return路径都不能绕过finally。更好的做法是换连接池并打开泄漏检测,Druid有removeAbandoned和logAbandoned参数,泄漏连接会自动回收并打日志。我当时第一次排查时就是靠这个参数定位到具体方法的,血泪经验。
第五条坑是刷新页面导致重复提交。现象:新增图书成功后,用户按一下F5,数据库里多了一条完全相同的记录;借书成功后刷新,借阅记录多了一条,库存也扣了两次。原因:Servlet在写操作完成后用了forward而不是sendRedirect,浏览器地址栏保留POST地址,刷新就是再提交一次;表单没有做任何防重处理。解决:写操作完成后必定调用resp.sendRedirect(req.getContextPath() + "/book/list"),把数据新增变成一次全新的GET请求。查询操作继续用forward,两种跳转各司其职。如果连重定向都防不住少数极端情况,可以生成一个表单token放进session,提交时比对后立即失效,但这在图书管理系统里属于过度设计,重定向已经能覆盖绝大多数场景。
6. 从能跑到能用:验证主流程与三个进阶加固方向
一个图书管理系统交付前,我习惯按“登录→搜索→借书→还书→管理端增删改”这条主线完整走一遍。用干净的MySQL库导入项目说明里的SQL脚本,启动Tomcat后逐项验证:普通用户能登录;按书名或作者能搜到结果;借书成功后库存减一;还书后库存加一;管理员能新增、编辑、删除图书;所有页面的中文都没有乱码。每一项背后都对应一个DAO方法和一条SQL,出问题时不用猜,直接看Tomcat控制台日志,定位到具体方法再断点。
进阶加固有三个方向。第一是换连接池,把DriverManager换成Druid或HikariCP。图书管理系统并发不高,Druid配个初始化5、最大20的连接数足够,还能在监控页面看到连接使用情况,第5章的连接耗尽问题在监控里一眼就能看出来。第二是安全加固,登录密码不要明文存库,至少用SHA-256加盐哈希;SQL统一走PreparedStatement;JSP模板里对用户输入做HTML转义防XSS。第三是补日志,第5章排查连接泄漏时没有日志几乎无从下手,加一个Slf4j+Log4j2,在登录、借书、还书等关键操作打点,线上问题和本地复现的差距就在日志里。
最后说一个我的实际教训:我最早做这个项目时,资源关闭代码图省事写在查询结果的下一行,结果处理过程中抛异常时连接永远不会关。后来线上频繁报连接池耗尽,只能一条条代码去审计,从那天起我所有资源获取都只用try-with-resources。这个项目虽然看着简单,但把它跑通、写好、加固完,Java Web的底层功课就补齐了一大半。希望帮到你。
本文还有配套的精品资源,点击获取