ARTICLE DETAIL

资讯详情

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

Java Web图书管理系统实战:Servlet/JSP与JDBC全流程解析

Java Web图书管理系统实战:Servlet/JSP与JDBC全流程解析 1. 为什么我仍推荐用半老不新的技术栈来入门图书管理系统如果你在技术社区里搜图书管理系统跳出来的结果一半是 PHP 写的另一半是 Java Web 写的。而 Java Web 那一半里又有相当一部分是十几年前就在传的 SSM 框架项目近两年则被 Spring Boot 快速 CRUD 碾压。那为什么我还要坚持用 Servlet JSP JDBC 这条老路来讲一个图书管理系统直接说结论因为这个项目真正的价值不在图书上而在帮你补齐 Java Web 最底层的那张认知地图——HTTP 请求进来之后容器做了什么Servlet 是怎么被实例化并调用的JSP 页面为什么第一次访问特别慢JDBC 的 Connection 到底该什么时候关。这些内容在 Spring Boot 里都会被框架藏得严严实实你在 IDEA 里点一个 Run 就能跑起来可一旦部署到真实服务器遇到 404、500、ClassNotFoundException你甚至连从哪里下手排查都不知道。图书管理系统恰恰是能把这张地图走通的最小可运行项目。另一个现实原因是Java Web 方向的面试尤其是校招和初级岗位几乎必考Servlet 生命周期JSP 内置对象Tomcat 目录结构这一类基础题。你用 Spring Boot 做一百个新项目也替代不了亲手配置一次 web.xml、亲眼看一下 Tomcat work 目录里由 JSP 编译生成的 java 文件所带来的直观理解。这个积累过程没有捷径。这篇文章我会按一套完整可落地的脉络来走先讲清楚技术选型的理由和数据库设计然后从登录到借还书把核心功能逐个实现再把 Tomcat 部署后 JSP 的编译机制单独拆开讲最后专门用一节来排查我在实际开发中踩过的高频坑。整篇东西不会让你五秒钟跑完一个 Demo 就结束而是让你做完之后能对一个 Java Web 项目从代码到运行这件事建立起完整的直觉。适合看这篇文章的读者有两类一是刚开始学 Java Web、想在 Servlet/JSP 阶段找一个完整项目练手的人二是已经会用 Spring Boot 写 CRUD但始终觉得底层不太踏实、想回头补课的人。两类读者我分别会照顾到遇到基础概念我会解释清楚遇到值得深入的点我也会展开讲。2. 数据库设计先行图书管理系统的表和关系该怎么定很多人一上来就写代码写到最后发现要么字段不够用要么表关系设计得没法支持借阅记录查询。我建议所有人在动手写第一行 Java 代码之前先花一个小时把数据库设计定下来。这不是流程上的仪式感而是因为 Java Web 项目的开发效率很大程度上取决于你提前想清楚了数据怎么存、怎么查。后面所有 Servlet 里的查询逻辑都是在为你的表结构服务。2.1 三张核心表图书表、读者表、借阅记录表一个标准图书管理系统最小集通常是三张表图书表、读者表、借阅记录表。管理员相关的字段我建议直接合并到读者表里用一个 role 字段区分别单独建一张管理员表否则登录逻辑要写两套纯粹增加无谓的复杂度。图书表的核心字段我建议这样设计字段名类型说明idINT 自增主键图书唯一标识book_nameVARCHAR(100)书名authorVARCHAR(50)作者publisherVARCHAR(100)出版社isbnVARCHAR(20)国际标准书号建议加唯一索引total_countINT馆藏总量available_countINT当前可借数量create_timeDATETIME入库时间其中最关键的设计在于把馆藏总量和当前可借数量分开而不是只存一个数量。这个设计看似简单却给后续的借书和还书业务省了大量麻烦。借书时只对 available_count 做减一操作还书时加一完全不需要去统计借阅记录表里的记录条数。如果只存一个字段你还得先查这个读者是不是已经借过这本书再决定能不能借非常容易在并发场景下出错。读者表更简单id、username、password、real_name、phone、role。password 我推荐至少用 MD5 加盐存储虽然 MD5 在安全领域已经被认为不够强但对于教学项目的定位它足够演示不能明文存密码这个意识。角色字段用 role INT0 表示管理员1 表示普通读者。借阅记录表是整个系统的业务核心设计时要把借书和还书两条操作都落在这张表上字段名类型说明idINT 自增主键记录编号book_idINT关联图书表reader_idINT关联读者表borrow_timeDATETIME借书时间due_timeDATETIME应还时间return_timeDATETIME实际归还时间NULL 表示未还statusTINYINT0 借出中1 已归还2 逾期这里 status 字段看起来很冗余因为通过 return_time 是否为 NULL 也能判断是否归还。但实际开发中这个字段极大的简化了首页我的借阅列表的显示逻辑。如果直接用 return_time 判断逾期每显示一条记录都要做一次当前时间和截止时间的比较写出来的 SQL 和 Java 代码都会绕。有了 status 字段你只需要在还书操作时更新一次状态即可。这是典型的用适度冗余换取查询效率的设计思路在实际业务系统里非常常见。2.2 初始化 SQL 脚本与数据库连接配置建表完成后强烈建议把初始化 SQL 存成init.sql文件放在项目根目录的 sql 文件夹下。我第一次做这个项目的时候偷懒直接在建表工具里执行完就关了窗口后来要换一台电脑继续开发表结构只能靠记忆重建光是字段名就对错了三个。以下是建表脚本的核心部分CREATE DATABASE IF NOT EXISTS library_system DEFAULT CHARACTER SET utf8mb4; USE library_system; CREATE TABLE book ( id INT AUTO_INCREMENT PRIMARY KEY, book_name VARCHAR(100) NOT NULL, author VARCHAR(50), publisher VARCHAR(100), isbn VARCHAR(20) UNIQUE, total_count INT DEFAULT 0, available_count INT DEFAULT 0, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE reader ( id INT AUTO_INCREMENT PRIMARY KEY, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, real_name VARCHAR(50), phone VARCHAR(20), role TINYINT DEFAULT 1 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE borrow_record ( id INT AUTO_INCREMENT PRIMARY KEY, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, due_time DATETIME, return_time DATETIME, status TINYINT DEFAULT 0, FOREIGN KEY (book_id) REFERENCES book(id), FOREIGN KEY (reader_id) REFERENCES reader(id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;注意这里我用的是 utf8mb4 而不是 utf8这样中文和生僻字都能正常存储也避免了 MySQL 8.0 版本中 utf8 相关的一系列兼容问题。字符集的问题在后面的中文乱码那节还会专门提到。数据库连接部分我用最基础的 JDBC 方式不引入 MyBatis 等 ORM 框架。先在src/main/resources下建一个db.properties文件jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/library_system?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8mb4 jdbc.usernameroot jdbc.passwordyour_password这里面最容易被忽略的是serverTimezoneAsia/Shanghai参数。MySQL 8.0 以上版本如果没有配置时区连接时会直接报The server time zone value Öйú±ê׼ʱ¼ä is unrecognized的错。我第一次遇到这个报错时一头雾水其实翻译过来的意思就是服务端时区无法被 JDBC 驱动识别。JDBC 驱动我推荐使用com.mysql.cj.jdbc.Driver这是 MySQL Connector/J 8.0 以上的新驱动类名。老教程里写的com.mysql.jdbc.Driver在新版本中虽然还能用但会打印弃用警告。如果你用的是 Maven 管理依赖在 pom.xml 里加这一段就行dependency groupIdmysql/groupId artifactIdmysql-connector-java/artifactId version8.0.33/version /dependency3. 项目骨架搭建Maven 结构、分层思想与 web.xml 的关键配置环境准备完毕接下来搭建项目骨架。这一步我不建议手动右键 New Project 然后一个个建包而是直接用 Maven 创建一个 webapp 项目结构清晰后续引入依赖也方便。创建好的标准目录结构应该是这样的library-system/ ├── pom.xml ├── src │ ├── main │ │ ├── java │ │ │ └── com/library │ │ │ ├── entity // 实体类Book, Reader, BorrowRecord │ │ │ ├── dao // 数据访问层BookDao, ReaderDao, BorrowDao │ │ │ ├── service // 业务逻辑层BookService, BorrowService │ │ │ ├── servlet // 控制层LoginServlet, BookServlet... │ │ │ └── util // 工具类DBUtil │ │ ├── resources │ │ │ └── db.properties │ │ └── webapp │ │ ├── WEB-INF │ │ │ └── web.xml │ │ ├── index.jsp │ │ ├── login.jsp │ │ └── book │ │ ├── list.jsp │ │ └── edit.jsp │ └── test └── sql └── init.sql3.1 分层架构为什么不能把所有代码都怼进 Servlet很多初学者会问一个问题项目也不大为什么非要分成 entity、dao、service、servlet 这么多层把数据库查询代码直接写在 Servlet 里不是更快吗没错对于一个只有二十几个接口的小项目不分层确实更快代码量看起来还更少。但图书管理系统不是一个做完就扔的项目你会不断给它加功能——加导出、加还书提醒、加逾期计算。一旦加了几个月需求全部代码堆在 Servlet 里的后果就是一个 LoginServlet.java 从 200 行膨胀到 2000 行改一个借书逻辑要在一个 500 行的方法里找半天而且根本没有办法写单元测试因为逻辑全和 HttpServletRequest 耦合在一起。分层的本质是把变化隔离起来。Servlet 这一层只负责接收请求、解析参数、调 service、决定跳转到哪个 JSP。service 层负责业务规则的编写比如借书时要检查库存、检查读者是否已经借了同一本书、检查读者是否有逾期未还记录。dao 层只做最朴素的数据库增删改查不会写任何业务判断。这样任何一个层的代码发生变化其他层的调用关系都不会被破坏。3.2 写一个线程安全的 DBUtil 工具类数据库连接管理是最容易被新手写崩的部分。我见过不少人把 Connection 声明成类的成员变量结果多个用户并发访问时一个用户关闭了连接另一个用户正在执行查询就会收到No operations allowed after connection closed异常。正确的做法是每次操作都从连接池获取一个全新连接用完后立即归还。我这里用 Druid 连接池它比 DBCP 和 C3P0 更轻监控功能也更方便。package com.library.util; import com.alibaba.druid.pool.DruidDataSourceFactory; import javax.sql.DataSource; import java.io.InputStream; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; import java.util.Properties; public class DBUtil { private static DataSource dataSource; static { try { InputStream in DBUtil.class.getClassLoader() .getResourceAsStream(db.properties); Properties props new Properties(); props.load(in); dataSource DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { e.printStackTrace(); throw new ExceptionInInitializerError(数据库连接池初始化失败); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } public static void close(ResultSet rs, Statement stmt, Connection conn) { if (rs ! null) { try { rs.close(); } catch (SQLException e) { e.printStackTrace(); } } if (stmt ! null) { try { stmt.close(); } catch (SQLException e) { e.printStackTrace(); } } if (conn ! null) { try { conn.close(); } catch (SQLException e) { e.printStackTrace(); } } } }这个工具类的关键点有两个。第一DataSource 是线程安全的可以作为一个全局单例被所有 DAO 共享。第二每次从 dataSource.getConnection() 拿到的连接在用完后调用 close() 时并不是真正关闭物理连接而是归还到连接池里。这种机制让连接的创建和销毁成本被大幅摊平项目从单机演示量级到几百人的并发访问量级都不会倒。3.3 web.xml 配置与启动验证web.xml 是 Java Web 应用的核心配置文件。在现代 Servlet 3.0 规范里虽然可以通过注解 WebServlet 免配置但理解 web.xml 的配置逻辑仍然很重要因为很多旧项目、公司老系统还在用它。我用注解方式做 Servlet 映射但 web.xml 里保留了欢迎页和会话超时的配置web-app xmlnshttp://xmlns.jcp.org/xml/ns/javaee xmlns:xsihttp://www.w3.org/2001/XMLSchema-instance xsi:schemaLocationhttp://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd version4.0 display-nameLibrary Management System/display-name welcome-file-list welcome-fileindex.jsp/welcome-file /welcome-file-list session-config session-timeout30/session-timeout /session-config /web-app把项目配置到 Tomcat 这一步很多人在 IDEA 里点了一下三角号看到浏览器弹出来就以为成了。但如果你用的是 Tomcat 9 以下的版本需要确认 Java 版本匹配Tomcat 10 及以上版本则要注意包名变化——javax.servlet 变成了 jakarta.servlet这两个 API 是不兼容的。我第一次用 Tomcat 10 跑老项目时一堆关于 HttpServlet 的 ClassNotFoundException 扑面而来查了半天才发现是命名空间变了。实际上我建议做这个项目时用 Tomcat 9.0 JDK 8 或 11 的组合这是最稳的搭配。如果你用更高版本的 JDK注意 JDK 17 对反射和模块系统的限制也可能带来一些奇怪的坑。4. 核心功能实现从登录鉴权到图书借还的完整链路骨架搭好之后接下来实现具体的功能模块。这一节我会把每个功能的实现思路和关键代码都过一遍而不是只给一个空泛的编写 XX 功能。4.1 登录功能与 Session 会话管理登录是系统的大门也是第一个需要认真处理的业务功能。登录逻辑做成这样用户在 login.jsp 表单中输入用户名和密码POST 请求发送到 LoginServletServlet 调用 ReaderDao 按用户名查出一条记录再用 Base64 加盐方式比对密码。我这里用 BCrypt 的简化版 MD5 加盐来演示真实项目建议直接用 Spring Security 的 BCryptPasswordEncoder。关键代码逻辑如下package com.library.servlet; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; WebServlet(/login) public class LoginServlet extends HttpServlet { Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String username req.getParameter(username); String password req.getParameter(password); ReaderDao readerDao new ReaderDao(); Reader reader readerDao.findByUsernameAndPassword(username, password); if (reader null) { req.setAttribute(error, 用户名或密码错误); req.getRequestDispatcher(/login.jsp).forward(req, resp); } else { HttpSession session req.getSession(); session.setAttribute(currentUser, reader); // 管理员跳转图书管理页读者跳转借阅页 if (reader.getRole() 0) { resp.sendRedirect(req.getContextPath() /book/list); } else { resp.sendRedirect(req.getContextPath() /book/list); } } } }这里有几个细节值得展开。第一密码验证时最好不要在 DAO 层直接拼username AND password的 SQL而应该先按用户名查出记录拿到密码哈希后再在 Java 层比对。这样做的重要原因不是怕 SQL 注入——PreparedStatement 已经防住了一部分——而是为了不暴露用户名不存在和密码错误的差异避免攻击者枚举用户名。当然对于学习项目用一条 SQL 同时查用户名和密码也没什么问题我只是建议你从现在开始就养成好习惯。第二登录成功后把 Reader 对象放进 Session后续所有页面要判断当前用户是否已登录只需要检查 session.getAttribute(currentUser) 是否为空。我最开始做这个项目时在每个 Servlet 里都手动写一遍Reader currentUser (Reader) req.getSession().getAttribute(currentUser); if (currentUser null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; }写了几个类之后实在忍不了了抽了一个 BaseServlet 或者直接在 web.xml 里配一个 Filter 来解决。这里我强烈建议你直接用 Filter 统一做登录鉴权因为它不侵入业务代码。代码也很简单WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest req (HttpServletRequest) request; HttpServletResponse resp (HttpServletResponse) response; String uri req.getRequestURI(); // 放行登录页、静态资源、登录接口 if (uri.endsWith(login.jsp) || uri.endsWith(/login) || uri.contains(/static/) || uri.endsWith(index.jsp)) { chain.doFilter(request, response); return; } HttpSession session req.getSession(false); if (session null || session.getAttribute(currentUser) null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } chain.doFilter(request, response); } }注意一个细节我在判断 Session 时用的是req.getSession(false)而不是req.getSession()。区别在于如果当前请求没有关联的 SessiongetSession(true) 会创建一个全新的空 Session而 getSession(false) 返回 null。用了带参数 true 的写法每次未登录用户访问任何资源都会生成一堆无用 Session既浪费内存还会导致 Filter 判断永远无法生效——因为 Session 永远不为 null。4.2 图书列表的分页查询为什么要用 LIMIT 而不是全量加载图书列表是最常见的查询功能但如果一次性把所有图书加载到 JSP 页面数据量一上来页面就卡死。分页是每个 Java Web 开发者都要掌握的基本功。分页的核心是两件事查总数和查当前页数据。总数用SELECT COUNT(*) FROM book当前页数据用SELECT * FROM book ORDER BY id LIMIT offset, pageSize。这里 offset 的计算公式是(currentPage - 1) * pageSize。我在 BookDao 里的分页查询方法是这样的public ListBook findPage(int pageNum, int pageSize) { String sql SELECT * FROM book ORDER BY create_time DESC LIMIT ?, ?; ListBook list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, (pageNum - 1) * pageSize); ps.setInt(2, pageSize); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Book book new Book(); book.setId(rs.getInt(id)); book.setBookName(rs.getString(book_name)); // 其他字段省略 list.add(book); } } } catch (SQLException e) { e.printStackTrace(); } return list; }这里用了 try-with-resources 语法Connection、PreparedStatement、ResultSet 都能自动关闭省去了 finally 里那一堆判断非空的样板代码。虽然看起来只是语法层面的简化但实际项目中因为漏关连接导致的连接池耗尽问题我见过太多次了。能用 try-with-resources 的地方就不要手写 close。分页参数从页面传递的时候需要注意一个校验问题。用户手抖把页码改成 0 或者负数SQL 执行LIMIT -5, 10会直接报错。所以 Servlet 里拿到 pageNum 后一定要做边界处理int pageNum 1; String pageNumStr req.getParameter(pageNum); if (pageNumStr ! null !pageNumStr.isEmpty()) { try { pageNum Integer.parseInt(pageNumStr); } catch (NumberFormatException e) { pageNum 1; } } if (pageNum 1) { pageNum 1; }这个看似简单的地方恰恰是我见过出 bug 最多的地方。用户直接在浏览器地址栏把?pageNumabc传给服务器如果不在 Servlet 层做容错NumberFormatException 会让整个页面 500。做一个规范的项目永远不要信任用户的任何输入。4.3 借书与还书事务处理是成败关键借书和还书是这个项目里第一个涉及事务的功能。借一本书要做两步操作向 borrow_record 表插入一条记录同时把 book 表的 available_count 减一。这两步必须同时成功或同时失败否则会出现记录显示已借出但库存没减或者库存减了但查不到借阅记录的数据不一致。在 JDBC 里事务控制就是三行代码的事Connection conn null; try { conn DBUtil.getConnection(); // 关闭自动提交开启事务 conn.setAutoCommit(false); // 第一步插入借阅记录 insertBorrowRecord(conn, bookId, readerId); // 第二步更新图书库存 updateAvailableCount(conn, bookId, -1); // 都成功了才提交 conn.commit(); } catch (SQLException e) { if (conn ! null) { try { // 任何一步失败回滚全部操作 conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } } e.printStackTrace(); } finally { if (conn ! null) { try { conn.setAutoCommit(true); conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }注意这里我把 DAO 的方法都设计成了接收 Connection 参数的版本而不是让每个 DAO 内部自己去获取连接。因为事务要求两个数据操作在同一个连接上执行如果每个 DAO 方法各自DBUtil.getConnection()那就会变成两个独立事务commit 和 rollback 根本没办法统一。这是我最初做这个项目时最容易踩的一个逻辑坑。我把 insertBorrowRecord 和 updateAvailableCount 写成了两个独立的 DAO 方法每个方法内部都自动获取连接结果在 service 层怎么控制事务都不生效。直到我打印出两次调用的连接对象地址才发现根本不是同一个连接。这个问题的解决方案就是你上面看到的样子把连接作为参数传给 DAO 方法。事务功能做完后你可以做一个简单的验证在借书的 Service 里故意抛一个异常看数据库里的库存是否保持不变。如果事务控制没问题数据是完整的如果事务没生效你就会发现 borrow_record 表多了一条记录但库存没变。这种验证方式比瞪着眼睛读代码要高效得多。5. 部署到 Tomcat 之后JSP 到底编译成了什么这一节我来解决很多人都会困惑的问题JSP 页面在浏览器里运行得好好的但 IDE 里根本没有对应的 Java 类那它到底是怎么被执行的热搜词里那句web项目配置tomcat后查看jsp编译后的java类问的正是这件事。5.1 Jasper 容器的工作原理JSP 之所以能工作根本原因在于 Tomcat 内置了一个叫 Jasper 的 JSP 编译器。当一个 JSP 文件第一次被请求时Tomcat 会经历一个完整的流水线把引入的 taglib 指令、page 指令解析成 Servlet 的元信息。把 JSP 中的静态 HTML 内容转成 Servlet 中out.write()的字符串参数。把 JSP 表达式% %的内容转成 Java 表达式语句。把 JSP Scriptlet% %里的原文直接嵌入生成的 Java 方法中。生成一个继承自 HttpJspBase 的 Java 类。调用 javac 编译器把这个 Java 类编译成 class 文件。由 Tomcat 的 ClassLoader 加载这个类实例化成 Servlet 并执行。也就是说每一个 JSP 文件经过编译后本质上就是一个 Servlet。你在 JSP 里写的 HTML 标签、EL 表达式、JSTL 标签最终都会被转换成 Java 代码。这也是为什么 JSP 第一次访问时会明显慢一拍——因为第一次访问需要完成上述全流程编译第二次开始就直接加载已经编译好的 class 文件了。5.2 去 Tomcat 的 work 目录找源码查看编译后 Java 类的具体位置非常简单。在你配置的 Tomcat 安装目录下有一个 work 目录。以 Tomcat 9 为例结构通常是apache-tomcat-9.0.x/ ├── bin/ ├── conf/ ├── lib/ ├── logs/ ├── temp/ ├── webapps/ └── work/ └── Catalina └── localhost └── library_system你的应用名 └── org └── apache └── jsp ├── login_jsp.java ├── login_jsp.class ├── index_jsp.java └── book └── list_jsp.java打开login_jsp.java你会看到一个类的骨架public final class login_jsp extends org.apache.jasper.runtime.HttpJspBase implements org.apache.jasper.runtime.JspSourceDependent { public void _jspService(HttpServletRequest request, HttpServletResponse response) throws java.io.IOException, ServletException { response.setContentType(text/html;charsetUTF-8); // JSP 中的静态内容从这里开始 out.write(\r\n); out.write(html\r\n); out.write(head\r\n); out.write(\tmeta charset\UTF-8\\r\n); out.write(\ttitle登录/title\r\n); // ... } }看到这段代码你以前困惑的很多问题就豁然开朗了。为什么 JSP 里可以直接使用 request、response、session、out 这些内置对象因为 _jspService 方法的参数里已经声明了 request 和 response方法内部又通过 _jspx_page_context 初始化了 session、out、application 等变量。这些就是 JSP 九大内置对象的来源。为什么 JSP 里修改了 HTML 代码必须重启 Tomcat 才能生效因为 Tomcat 对于 JSP 文件有一个修改时间戳检测机制正常情况下你不用重启也能看到更新——但如果你改了 JSP 的 page 指令、引入了新的 Java 类或者改了 taglib就有可能需要清掉 work 目录下对应的编译产物才能正常更新。这也是改了 JSP 没生效问题的一个常见原因。5.3 查看编译产物能帮你解决什么问题实际操作中查看编译后的 Java 类最大的价值在于排查两类问题。第一类是 JSP 语法错误。比如你写了一个标签忘记闭合Jasper 编译时报出的错误信息可能比较隐晦直接看生成的 Java 代码你会发现它卡在某个位置的 out.write 处。第二类是 JSP 与 Servlet 版本不匹配的问题。Tomcat 10 使用 jakarta.servlet如果你在 JSP 里用了一个旧版的 taglib 依赖编译阶段就会失败。去 work 目录看编译日志和生成的源码能直接定位到到底是哪个标签解析不出来。我已经养成一个习惯凡是 JSP 相关的问题第一步先看 work 目录下的对应 java 文件有没有生成。如果 java 文件没生成说明编译阶段就挂了去看 catalina.out 日志如果 java 文件生成了但页面还是报错说明运行时异常去查具体抛出的异常栈。这个排查路径几乎能覆盖 90% 的 JSP 问题。6. 部署与维护中的高频坑我从这个项目里踩过的五类问题这一节我会把做 Java Web 项目时遇到的高频坑整理出来这些问题几乎每个初学 Tomcat JSP 的人都会遇到区别只在于踩的顺序不同。6.1 404 还是 500先分清楚错误原因再动手浏览器白屏或者报错时很多新手第一反应是到处乱查代码。实际上 HTTP 状态码已经告诉了你问题的大致方向状态码含义常见原因404资源不存在请求路径写错、Servlet 未映射、部署名不一致500服务器内部错误Java 代码抛异常、JSP 编译失败、数据库连接失败405请求方法不支持表单 POST 提交到了只实现了 doGet 的 Servlet403禁止访问WEB-INF 目录下资源被直接访问、Filter 拦截排查 404 时先看浏览器地址栏里的 URL 和你 WebServlet 里的配置是否一致。很多人项目部署名是 library_system访问路径却是/book/list少了上下文路径就 404。如果你在 IDEA 里配置了 Tomcat 的 Application context 为/路径就直接从根开始这隐藏了很多部署名相关的坑一旦脱离 IDEA 手动部署到外部 Tomcat 就立刻暴露。排查 500 时重点看 IntelliJ IDEA 的 Console 面板里 Tomcat 的日志输出异常类型和行号都会打印出来。不要只盯着浏览器上的错误页面看Tomcat 默认的错误页非常模糊。6.2 中文乱码的三处根源中文乱码是 Java Web 里的经典问题根子在于字符到底用什么编码从一端传到另一端。乱码的产生几乎逃不开以下三个位置第一JSP 页面本身。页面顶端必须有% page contentTypetext/html;charsetUTF-8 languagejava %同时meta charsetUTF-8也不能少。这两个标签控制的是浏览器以什么编码解析页面内容。第二Servlet 接收参数。POST 请求通过req.setCharacterEncoding(UTF-8)来解决这行代码必须在读取任何参数之前调用。GET 请求则需要修改 Tomcat 的 conf/server.xml在 Connector 里添加URIEncodingUTF-8属性。第三数据库连接。JDBC URL 里的 characterEncoding 参数必须设置为 UTF-8 或者 utf8mb4同时数据库表本身的 charset 也要一致。这三层的编码必须统一任何一层不一致就会在某一个环节出现乱码。6.3 JDBC 驱动类找不到为什么项目里明明有 jar 包这是一个出现频率极高的错误java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver。有些新手很困惑Maven 依赖里明明有 mysql-connector-java编译也通过了但运行时就是找不到类。原因在于 Web 应用的类加载机制。Tomcat 的 ClassLoader 是层级结构Web 应用的 lib 目录下的 jar 包由 WebappClassLoader 加载。如果你在 IDEA 里只是把 jar 包加到了 Project Structure 的 Libraries 里但没有把它部署到 Tomcat 的 WEB-INF/lib 目录下Tomcat 运行时根本看不到它。检查方法很简单看 IDEA 里 Artifacts 配置中输出目录的 WEB-INF/lib 下有没有 mysql-connector 的 jar 包。用 Maven 构建时只要依赖 scope 是默认的 compilewar 包里会带上如果你用的是手动添加 jar 的方式需要把它拷到 WEB-INF/lib 文件夹下。这个坑我见过太多次几乎每个从IDEA 项目切换到手动部署到 Tomcat的人都会遇到。6.4 Servlet 里输出 JSON 和跳转 JSP 的区别做图书管理系统的 Ajax 接口时会遇到一个问题Servlet 到底该返回什么给浏览器。传统的做法是转发到 JSP 页面JSP 负责渲染 HTML而比较现代的做法是 Servlet 直接返回 JSON 数据前端 JavaScript 渲染页面。resp.setContentType(application/json;charsetUTF-8); PrintWriter out resp.getWriter(); out.write({\code\:0,\msg\:\success\,\data\:[...]}); out.flush();两种方式的适用场景不同。返回 JSP 适合服务端渲染的页面表单提交后跳转到列表页这种用户体验最直接返回 JSON 适合局部刷新和前后端分离的场景。做这个项目时我建议你至少实现一个返回 JSON 的接口比如校验图书编号是否已存在这样你能直观感受到两种响应方式在代码组织和前端处理上的差异。关键点是注意设置 ContentType很多人在返回 JSON 时忘了指定application/json浏览器会把返回答复用 HTML 的默认规则解析中文就会出现乱码。Content-Type 里的 charset 同样要加上。6.5 修改了 JSP 却不生效先清 work 目录Tomcat 对 JSP 的修改检测通常很灵敏但以下情况它确实会失灵服务器时间和服务器的系统时间不一致时间戳检测失效JSP 使用了第三方 taglib资源变更没有触发编译修改了 web.xml 但没有重启 Tomcat排查方法就是清理 Tomcat 的 work 目录把里面的编译缓存全部删掉重启服务后 Jasper 会全部重新编译。这招解决不了的问题如果还有再考虑是不是 class 文件缓存的问题。我个人的习惯是在开发环境用 IDEA 的 Tomcat 集成时经常手动删掉C:\Users\用户名\AppData\Local\JetBrains\下的 Tomcat 配置缓存这不是什么高深的操作但确实能解决一大部分改了代码不生效的怪问题。7. 项目做完之后如何验证和扩展项目能在浏览器里跑通并不意味着这个项目就完成了。我建议你按下面的检查清单过一遍确认自己做的不是表面工程登录失败时页面是否提示错误信息而不仅只是跳回去未登录用户直接访问 /book/list是否会被 Filter 挡回登录页借书时库存为 0是否有明确的异常提示同一本书借出后是否能在借阅记录里看到状态还书后状态是否变更删除图书时如果这本书有未归还的借阅记录系统是怎么处理的密码在数据库里是不是明文第六个问题尤其重要。很多项目在删除图书时没有处理外键约束直接 delete 导致外键冲突异常或者破坏了借阅记录数据的完整性。我当时处理的方式是逻辑删除给 book 表加一个 status 字段0 表示正常1 表示下架这样既保留了历史借阅记录也不影响列表的正常显示。这个思路在实际业务系统里非常常见值得你领会。做完基础功能后我强烈建议你再扩展两个功能它们能让你对 Java Web 有更深的理解。第一个是图书搜索。给列表页加一个按书名模糊搜索的输入框SQL 用WHERE book_name LIKE ?注意 LIKE 的参数要拼接成%关键字%同时要注意防止 % 或者 _ 这些通配符被用户恶意输入。JDBC 里new StringBuilder(%).append(keyword).append(%)处理一下就行。第二个是借阅统计报表。做一个简单的统计页用一条聚合 SQL 查出最受欢迎的几本书按借阅次数降序排列SELECT b.book_name, COUNT(br.id) AS borrow_count FROM borrow_record br JOIN book b ON br.book_id b.id GROUP BY b.id ORDER BY borrow_count DESC LIMIT 10;这个功能会让你首次接触到 JOIN 查询和 GROUP BY 聚合也能体会到表结构设计时冗余字段和关联字段的意义。我还想单独说一个很多人会忽略的问题代码里不要提交数据库密码。db.properties文件里存的是你的真实数据库账号和密码如果整个项目上传到公开的代码托管平台这等于把系统大门钥匙交了出去。我的做法是把db.properties.example作为模板提交内容里的密码用占位符代替然后本地复制一份改名为db.properties填入真实密码。同时在 .gitignore 文件里加入db.properties。这个习惯越早养成越好因为等代码真的泄露再补救麻烦就大了。8. 最后一件事为什么我建议你亲手敲一遍而不是复制粘贴这篇文章里所有代码我都建议你亲手敲一遍。不是我藏着掖着不给你完整源码而是这个项目如果全程复制粘贴做完之后你的收获会打一个很大的折扣。原因很简单写代码的过程才是建立错误记忆的过程。你亲手敲一遍会在某个 DAO 方法里少写一个分号或者把 ResultSet 的 getInt(id) 写成了 getInt(book_id)然后被迫去理解异常信息去对照列名。这个过程虽然痛苦但恰恰是你在积累排查能力。我招人面试时从来不关心候选人做过多少个图书管理系统而是关心他在那个项目里卡过多久、怎么解决的。解决问题的过程才是项目经验。如果你打算用这个项目作为求职简历里的项目经历有一点必须提醒你不要写实现了图书的增删改查而要写清楚你解决了什么问题、采用了什么方案。比如通过连接池管理数据库连接解决了并发请求下连接耗尽问题通过 Filter 统一处理登录鉴权避免了重复代码借阅功能使用手动事务控制保证数据一致性。面试官想听的从来不是你做什么而是你怎么做、为什么这么做。我当初做这个项目的时候也和你一样在 Tomcat 部署、Servlet 路径、JSP 编译、数据库连接这些问题上反复折腾过。现在回头看那些折腾恰恰是最值得的部分。把这个项目完整做完你对 Java Web 的理解会比那些一上来就 Spring Boot 一条龙的人扎实很多。接下来你可以继续往两个方向走要么进入 SSM/Spring Boot 的框架学习把这个系统的 DAO 层换成 MyBatis体会框架带来的生产力提升要么深入 Tomcat 源码理解连接器、容器、类加载器的完整体系。不管选哪个这套图书管理系统的底子都会成为你继续前进的垫脚石。
返回列表