简介:这套基于JSP、Servlet与Tomcat的电子图书管理系统设计源码,融合Bootstrap前端样式,面向Java初学者、课程设计及小组实训,完整演示了从页面展示到后端请求处理的Web项目开发流程。系统涵盖图书列表、图书详情、用户注册、登录验证、图书搜索与借阅管理等核心功能,可帮助理解JSP动态页面生成、Servlet请求分发以及Tomcat容器部署的协作方式。压缩包约2.77MB,共50个文件,包括22个Java源文件负责业务逻辑,11个XML配置定义项目结构与运行环境,8个JSP页面实现前端交互,另有CSS、JS、图标、依赖jar包、MySQL连接器配置及说明文档,目录结构清晰,便于定位功能模块。目前已有123人浏览学习,说明其作为课程设计参考方案有一定认可度。源码中webapp与src分层明确,readme.txt、IDE工程文件、.gitignore等配置齐全,支持直接导入开发环境调试;对于需要完成课程设计答辩或希望掌握JSP+Servlet经典协作模式的开发者,这份源码提供了从目录结构到功能实现的完整参考,也可作为扩展二次开发的底稿。
1. JSP+Servlet+Tomcat做电子图书管理系统:为什么这套老技术栈还在被反复使用
JSP+Servlet+Tomcat的组合乍一看像是“过时”的配置,但在教学课题、毕业设计、中小企业内部系统里,它依然是出现频率最高的技术栈之一。电子图书管理系统恰好是这个组合的典型代表:实体关系清晰、业务流程固定(图书录入、借阅、归还、查询),不需要复杂的微服务架构,一个Tomcat实例加一个MySQL数据库就能撑起完整业务。这套系统解决的是让读者和管理员通过浏览器完成图书的增删改查与借还操作,适合新手理解Web开发的请求-响应模型,也适合老手快速搭建能跑的内部工具。本文按“功能设计 → 数据库 → 代码分层 → 部署运行 → 排错”的顺序,把这个系统的源码怎么设计、怎么落地讲清楚。
2. 电子图书管理系统的功能拆解与数据库设计:从借阅流程反推表结构
2.1 最小的功能闭环:两类角色、六个页面、四个操作
任何一个电子图书管理系统,无论叫什么名字,核心都离不开管理员和普通读者两类角色。管理员负责图书的上架、下架、修改信息和查看所有借阅记录;读者负责检索图书、查看详情、发起借阅和归还。所谓“最小可用系统”,至少要有六个页面:登录页、图书列表页、图书详情页、图书录入/编辑页、借阅记录页、用户管理页。别小看这六个页面,它们已经把Web开发里最常见的表单提交、列表渲染、跳转传参、会话管理全部覆盖了。
我在设计这类系统时通常会先画一张“借阅流程图”,而不是急着建表。借书这个动作,看起来只是在一个表里插一条记录,但它牵扯到图书库存是否充足、读者是否已经借了这本书、逾期未还的图书怎么处理。把流程画清楚,表结构自然就出来了。这里给出一个最精简的流程闭环:登录 → 检索图书 → 查看详情 → 点击借阅(若库存大于0且无逾期记录则成功)→ 到期归还 → 管理员在后台看到归还记录并更新库存。
2.2 四张表撑起整个业务:users、books、categories、borrow_records
基于上面的流程,数据库最少需要四张表。users表存管理员和读者的账号信息,通过role字段区分身份;books表存图书的标题、作者、ISBN、分类、库存量和封面路径;categories表独立出来是为了避免分类名在books里重复出现,也是给后续做分类筛选留的接口;borrow_records表记录每一次借还动作,是整张业务网的中心。
下面的SQL建表语句是精简版,字段命名我习惯用下划线风格,和Java实体类的驼峰命名在写JDBC手工映射时好对应。字符集统一用utf8mb4,别问为什么不用utf8,问就是utf8在MySQL里存不了生僻字和emoji——图书标题里真有可能出现特殊字符。
CREATE DATABASE IF NOT EXISTS elib DEFAULT CHARSET utf8mb4; USE elib; CREATE TABLE users ( user_id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(64) NOT NULL, -- 存MD5或SHA-256摘要,不存明文 role TINYINT NOT NULL DEFAULT 0, -- 0=读者 1=管理员 phone VARCHAR(20), status TINYINT DEFAULT 1 -- 1=正常 0=禁用 ); CREATE TABLE categories ( cate_id INT PRIMARY KEY AUTO_INCREMENT, cate_name VARCHAR(50) NOT NULL ); CREATE TABLE books ( book_id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(200) NOT NULL, author VARCHAR(100), isbn VARCHAR(30), cate_id INT, total INT DEFAULT 0, -- 总库存 remaining INT DEFAULT 0, -- 当前可借数量 cover_path VARCHAR(255), -- 封面图片的URL create_time DATETIME, FOREIGN KEY (cate_id) REFERENCES categories(cate_id) ); CREATE TABLE borrow_records ( record_id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, borrow_time DATETIME NOT NULL, due_time DATETIME NOT NULL, return_time DATETIME NULL, -- NULL表示未还 status TINYINT DEFAULT 0, -- 0=借出 1=已还 2=逾期 FOREIGN KEY (user_id) REFERENCES users(user_id), FOREIGN KEY (book_id) REFERENCES books(book_id) );这个表设计的几个关键点说一下。password字段存的是摘要而不是明文,这是基本的安全底线,很多课程设计里直接存明文,放在内网还行,但凡暴露到公网就是事故。remaining字段是冗余字段,它可以从total减去借出数量算出来,但实际项目里建议保留,因为借阅时做“remaining > 0”判断只用查一次表,不需要走聚合运算,这一点在高并发下差距明显。borrow_records的status用数字而不是字符串,是为了后续写统计SQL时用GROUP BY status直接出报表,不需要写一堆CASE WHEN。
2.3 用SQL先跑通核心业务:借书、还书、超期统计
表建好之后,先别急着写Java代码,用SQL把三个核心业务跑通,后面的Dao层写起来就是复制粘贴。借书操作在业务上分两步:先检查读者有无逾期记录和该书是否有库存,再插入借阅记录并扣减库存。在JDBC中这两步要用事务包起来,防止插入记录成功但扣库存失败。还书操作则是更新borrow_records表并回补库存。
-- 借书:检查读者是否有逾期未还记录 SELECT COUNT(*) FROM borrow_records WHERE user_id = ? AND status = 2; -- 借书:检查图书剩余库存 SELECT remaining FROM books WHERE book_id = ?; -- 借书成功:插入借阅记录,借期默认30天 INSERT INTO borrow_records(user_id, book_id, borrow_time, due_time, status) VALUES(?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), 0); -- 借书成功:扣减库存 UPDATE books SET remaining = remaining - 1 WHERE book_id = ?; -- 还书:更新记录 UPDATE borrow_records SET return_time = NOW(), status = 1 WHERE record_id = ? AND user_id = ?; -- 还书:回补库存 UPDATE books SET remaining = remaining + 1 WHERE book_id = ?;这两组SQL逻辑上不对称:借书要先查后写,还书是直接写。所以借书这个动作在Service层必须用Connection.setAutoCommit(false)开启事务,等两条SQL都执行成功再commit,任何一步异常就rollback。我见过不少翻车案例就是漏了事务,借阅记录有了但库存没扣,第二个人来借同一本书时系统显示还有库存,实际上书已经不在架上了。
超期统计的SQL可以留给定时任务来跑,UPDATE borrow_records SET status=2 WHERE return_time IS NULL AND due_time < NOW(),MySQL的DATETIME比较直接走字典序,这一步不需要额外处理时区。定时任务在Servlet项目里可以用ServletContextListener配合ScheduledExecutorService实现,也可以用操作系统的crontab直接调一个独立的Java类,后者对新手更友好。
2.4 查询索引与封面图路径:模糊搜索的性能边界和图片404的常见解法
图书列表页最常见的操作是按标题模糊搜索和按分类筛选。模糊搜索在MySQL里就是WHERE title LIKE '%关键词%',这个写法走不了索引,数据量小的时候没问题,一旦图书表超过几万条,每一次列表加载都是全表扫描,页面就会明显变卡。我一般的处理是:产品上让用户输入前缀而不是任意片段,SQL写成WHERE title LIKE '关键词%',这样books表在title上建的普通索引能派上用场;如果必须支持任意片段搜索,那就引入全文索引或搜索引擎,而不是在LIKE模糊匹配上硬耗。
索引这边,borrow_records表的查询条件几乎都是user_id加status,所以建一个联合索引idx_user_status(user_id, status)就够了。books表的isbn字段有唯一性要求,直接建唯一索引。外键字段cate_id在MySQL里会自动建索引,不需要额外声明。索引不是越多越好,写操作多的表每多一个索引就要多维护一棵B+树,电子图书管理系统这种读多写少的场景,两三张核心表各建两到三个关键索引就够了,剩下的交给慢查询日志去发现。
封面图的存储也是一个常见的设计分叉点。这个系统的源码里一般有两种做法:把图片二进制存进books表的一个BLOB字段,或者把图片文件上传到服务器某个目录,数据库只存相对路径。我的建议是走路径方案。BLOB方案在数据量上来之后会让表体积严重膨胀,而且图片读取要经过JDBC的流处理,每次列表页展示封面都查一次大字段,响应时间会急剧恶化。路径方案只需要在项目里规定一个/uploads/covers/目录,文件名用book_id加时间戳重命名,JSP页面里用<img src="${pageContext.request.contextPath}${book.coverPath}">渲染,简单且稳定。
这个经验也顺带解释了一个常见疑惑:JSP图片如何对坐标定位、封面展示不出来的问题,多半不是前端写法的问题,而是路径写死了绝对路径、没有加pageContext.request.contextPath,导致部署到不同上下文路径下图片全部失效。改法就是把所有带/uploads或/jsp前缀的链接都改成EL表达式拼上下文路径。
3. 用JSP+Servlet搭出MVC骨架:控制器、视图与数据访问如何分工
3.1 项目目录结构:从src到webapp,每一层放什么
拿到一套JSP+Servlet的电子图书管理系统源码,先别急着点运行,先看目录结构是否干净——这个动作决定你后面调试要花多少时间。MVC不是纸上谈兵,具体到项目里就是目录结构的约束:
src/main/java/com/example/elib/ ├── entity/ -- 对应数据库表的实体类(User, Book, Category, BorrowRecord) ├── dao/ -- 数据访问层,每个实体一个Dao接口和实现类 ├── service/ -- 业务逻辑层,借书/还书/事务控制在这里 ├── controller/ -- Servlet控制层,处理请求并转发到JSP └── filter/ -- 过滤器,登录校验、字符编码设置 src/main/webapp/ ├── jsp/ -- 页面文件:login.jsp, bookList.jsp, borrowRecord.jsp ├── uploads/ -- 上传的图书封面 └── WEB-INF/ ├── web.xml -- Servlet映射、过滤器注册、欢迎页 └── lib/ -- 项目依赖jar包(或走Maven管理)很多人第一次写JSP项目会把业务逻辑全塞进Servlet里,一个servlet几百行,跑是能跑,改需求时就是灾难。Service层存在的意义不只是多包一层,而是把借书这种需要跨表操作的事务边界划清楚。Dao层只负责单表增删改查,Service层负责组合多个Dao调用并管理事务,Controller层只做参数接收、调用Service、选择跳转页面。这条线一旦乱了,后面排查一个“借书失败”可能要翻三个文件,而且是横向翻,不是纵向翻。
3.2 Servlet控制层:请求来了之后怎么走
登录请求是最典型的例子。用户在login.jsp里提交用户名和密码,表单的action指向一个映射为/login的Servlet。用注解代替web.xml映射是这几年的主流做法,代码量少也不会漏:
@WebServlet(name = "LoginServlet", urlPatterns = "/login") public class LoginServlet extends HttpServlet { private UserService userService = new UserService(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); User user = userService.login(username, password); if (user != null) { HttpSession session = request.getSession(); session.setAttribute("loginUser", user); response.sendRedirect(request.getContextPath() + "/book/list"); } else { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("/jsp/login.jsp").forward(request, response); } } }这段代码有三个容易忽略的细节。request.setCharacterEncoding("UTF-8")必须放在getParameter之前,放在后面就不生效,这是新手最常踩的坑之一。登录成功后用sendRedirect重定向而不是forward转发,目的是避免用户按F5刷新时重复提交表单——刷新会重放上一次POST请求,导致重复登录或重复借书。request.getContextPath()是动态获取部署路径,当系统部署在/elib这样的上下文时,它返回/elib,所有链接和重定向都必须拼上它,写死绝对路径部署到别的机器上必出错。
3.3 JSP视图层:EL表达式与JSTL如何减少页面里的Java代码
JSP页面最常见的坏味道是<% if (...) { %>满天飞。写起来确实快,但修bug时满屏找那个没有闭合的尖括号,心态很容易崩。正规做法是引入JSTL标签库和EL表达式,页面里只做展示和循环,不写业务判断。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <html> <body> <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.remaining}</td> <td> <a href="${pageContext.request.contextPath}/book/detail?bookId=${book.bookId}">详情</a> </td> </tr> </c:forEach> </table> </body> </html>${bookList}是从Servlet里通过request.setAttribute("bookList", list)传过来的。EL表达式会根据book的类型自动调用getter方法,所以实体类必须为每个属性提供标准的getXxx()方法,IDEA生成的getter方法名是getBookId(),EL中写${book.bookId}即可对应上。
这里有个细节:如果实体类属性用基本类型int,而数据库某条记录的对应字段是NULL,JDBC读取时就会抛NullPointerException,EL渲染也会出问题。所以数据库字段允许为空的属性在实体类中要用包装类型Integer,性能和内存的差距在项目体量下可以忽略,但空值处理的坑是实实在在的。
3.4 数据访问层:JDBC封装与连接池选型
数据访问层是这套源码里最容易被写成流水账的部分。初学者常见的写法是每个Dao方法里都写一遍Class.forName("com.mysql.jdbc.Driver")、DriverManager.getConnection(...),然后try-catch-finally关连接。这样写功能没错,但每开一个连接就是一次TCP握手和MySQL鉴权,在高并发下连接数会迅速打满数据库。
我在这类项目里一般会引入一个简单的连接池,Druid或HikariCP都行,配置差别不大。下面演示用Druid在项目启动时从db.properties读取配置并创建连接池:
# src/main/resources/db.properties jdbc.driver=com.mysql.cj.jdbc.Driver jdbc.url=jdbc:mysql://localhost:3306/elib?useUnicode=true&characterEncoding=utf8mb4&useSSL=false&serverTimezone=Asia/Shanghai jdbc.username=root jdbc.password=123456 jdbc.initialSize=5 jdbc.maxActive=20public class DBUtil { private static DruidDataSource dataSource; static { try { Properties props = new Properties(); props.load(DBUtil.class.getClassLoader().getResourceAsStream("db.properties")); dataSource = (DruidDataSource) DruidDataSourceFactory.createDataSource(props); } catch (Exception e) { throw new ExceptionInInitializerError("数据库初始化失败: " + e.getMessage()); } } public static Connection getConnection() throws SQLException { return dataSource.getConnection(); } }注意serverTimezone=Asia/Shanghai这个参数。MySQL 8.x的JDBC驱动对时区敏感,不设置会报The server time zone value 'Öйú±ê׼ʱ¼ä' is unrecognized,这是一类让人摸不着头脑却高频出现的错误。URL里的characterEncoding=utf8mb4要和useUnicode=true配合使用,单独写characterEncoding=UTF-8在JDBC URL里是无效的。驱动类用com.mysql.cj.jdbc.Driver,这是MySQL Connector/J 8.x的类名,老代码里常见的com.mysql.jdbc.Driver在8.x里已被移除。
3.5 Service层事务与业务校验:把Dao调用组合成完整动作
Dao层里insert、update是原子操作,但真实业务往往是多个原子操作的组合。以借书为例,完整业务动作是校验读者状态、校验图书库存、插入借阅记录、扣减库存。这四个步骤任何一个失败,前面的操作都要回滚。在Servlet和Dao之间加Service层,就是为了给这一组操作划定事务边界。
public class BookService { private BookDao bookDao = new BookDao(); private BorrowRecordDao borrowRecordDao = new BorrowRecordDao(); public boolean borrowBook(int userId, int bookId) throws SQLException { Connection conn = DBUtil.getConnection(); conn.setAutoCommit(false); try { if (bookDao.getRemaining(conn, bookId) <= 0) { return false; } if (borrowRecordDao.hasOverdue(conn, userId)) { return false; } borrowRecordDao.insert(conn, userId, bookId); bookDao.decreaseRemaining(conn, bookId); conn.commit(); return true; } catch (SQLException e) { conn.rollback(); throw e; } finally { conn.setAutoCommit(true); conn.close(); } } }这里有个关键点:Dao的所有方法都要接收Connection conn作为参数,而不是每次在Dao内部自己获取新连接。如果Dao内部各自获取连接,Service层的事务控制就失效了——三个Dao用了三个不同的连接,commit和rollback都管不到对方。这也是JSP+Servlet项目进化到一定阶段后,很多人转向Spring的根本原因:Spring声明式事务把连接传递和事务边界的控制全部封装掉了。但理解这个手动传connection的过程,比直接学Spring事务更有价值。
提示:如果项目里用了上面的
DBUtil,记得在finally里把连接归还给连接池而不是关闭。Druid的close()方法本质上就是归还连接,池内连接不会真正断开,只有超过maxActive或空闲超时时才会被物理关闭。
4. Tomcat部署与运行:从源码到浏览器里能访问,需要跨过哪些坎
4.1 环境准备:JDK、Tomcat、IDEA的版本选型与配置
JSP+Servlet这套技术栈对版本敏感,一组能跑的版本搭配能省掉大量排查时间。JDK 8对应Tomcat 8.5/9,JDK 11对应Tomcat 9/10,JDK 17对应Tomcat 10.1。版本之间不是随便配的,Tomcat 10把Servlet API的包名从javax.servlet改成了jakarta.servlet,如果用了Tomcat 10而代码里还写着import javax.servlet.http.HttpServlet,编译直接报错。在这个项目里我默认使用JDK 8 + Tomcat 8.5 + MySQL 8.x的组合,这套组合最稳,网上能找到的JSP老代码也基本都是这个环境。
IDEA里部署Tomcat有几种方式,最常见的是在Run Configuration里新增Tomcat Server > Local然后指定Tomcat目录。如果你用的IDEA版本较新,可能会遇到“本地部署Tomcat 9没找到Tomcat Server”的情况——这不是IDEA的bug,而是新版IDEA把Tomcat集成位置改了,或者类型选错了。一种做法是检查Plugins里Tomcat插件是否被禁用,另一种做法是跳过IDE部署,用Add Local Server手动指定CATALINA_HOME。
更可靠的方式反而是用Maven插件。tomcat7-maven-plugin虽然名字带7,但在Tomcat 8.5和9上照样能跑,配置只有几行:
<plugin> <groupId>org.apache.tomcat.maven</groupId> <artifactId>tomcat7-maven-plugin</artifactId> <version>2.2</version> <configuration> <port>8080</port> <path>/elib</path> <uriEncoding>UTF-8</uriEncoding> </configuration> </plugin>配置好后在IDEA右侧Maven面板启动tomcat7:run,自动把webapp目录发布到内嵌Tomcat上,不需要手动复制WAR文件。uriEncoding必须设为UTF-8,否则路径里的汉字参数在GET请求中会乱码。顺带说一句,如果你在tomcat下载页看到Tomcat 8.5和Tomcat 9两个版本,选8.5就行,9是过渡版,10以后就是jakarta包名的天下了。
4.2 把源码打包成WAR并部署:适合交付和移植的方案
Maven插件的内嵌方式适合开发,交付给别人时还是要一个标准的WAR包。在pom.xml里将打包方式设置为<packaging>war</packaging>,执行mvn clean package,target目录下会生成elib.war。把它复制到Tomcat的webapps目录下,启动Tomcat时会自动解压部署。此时访问地址是http://localhost:8080/elib/,这里的/elib是WAR包文件名去掉.war后缀所得,也就是上下文路径。
这里经常有一个混淆点:直接访问http://localhost:8080/会看到Tomcat默认首页,而不是你的系统,因为默认上下文/对应的是Tomcat自带的ROOT应用。想让系统直接挂在根路径,把WAR文件改名为ROOT.war再部署即可。注意ROOT.war部署后,原ROOT目录会被覆盖,旧文件要提前备份。
部署到Linux服务器时,不要直接用root用户启动Tomcat。安全习惯是新建普通用户,把Tomcat目录和webapps的属主改给这个用户。启动用bin/startup.sh,但它把日志重定向到了logs/catalina.out,排查启动失败时记得用bin/startup.sh && tail -f logs/catalina.out实时看日志。如果启动后进程秒退,多半是JAVA_HOME没配或者8080端口被占,下一章细说。
4.3 配置文件详解:web.xml、数据源与字符编码的拦路虎
JSP+Servlet早期是每个Servlet都在web.xml里写一段映射,现在用注解省掉了一大半,但web.xml里仍有三件事必须保留:过滤器声明、欢迎页、会话超时设置。
<web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" version="3.1"> <filter> <filter-name>encodingFilter</filter-name> <filter-class>com.example.elib.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>loginFilter</filter-name> <filter-class>com.example.elib.filter.LoginFilter</filter-class> </filter> <filter-mapping> <filter-name>loginFilter</filter-name> <url-pattern>/book/*</url-pattern> <url-pattern>/borrow/*</url-pattern> </filter-mapping> <welcome-file-list> <welcome-file>index.jsp</welcome-file> </welcome-file-list> <session-config> <session-timeout>30</session-timeout> </session-config> </web-app>编码过滤器是所有过滤器里最先执行的,request.setCharacterEncoding("UTF-8")必须在Servlet读取参数之前执行,所以它的url-pattern要配成/*覆盖所有路径。登录过滤器作用于/book/*和/borrow/*两个路径段,未登录用户访问这些路径时直接重定向到/login。两个过滤器的顺序很讲究:编码过滤必须在登录过滤之前,因为登录过滤器内部也要读取请求参数。
字符编码是个跨层的坑,只设置JSP页面的pageEncoding是不够的。JSP页面编译时的编码、浏览器提交表单时的编码、Servlet读取参数的编码、数据库连接URL里的编码,一个不一致就乱码。经验做法是全链路统一UTF-8:JSP的pageEncoding="UTF-8"、Servlet里的request.setCharacterEncoding("UTF-8")和response.setContentType("text/html;charset=UTF-8")、JDBC URL里的characterEncoding=utf8mb4、MySQL表的字符集。五层缺一层,数据在某个中间环节就可能坏掉。
注意:修改了JSP文件后,Tomcat的工作目录
work/Catalina/localhost/下会缓存编译后的class文件,有时改了代码但界面没变化,就是缓存没清掉,需要手动清理该目录再重启。
5. 源码调试与常见问题排查:JSP+Servlet项目5个高频踩坑实录
这一章是我做类似项目时血泪经验的汇总。每一条都是“现象 → 原因 → 解决”的结构,你对照自己的报错信息按图索骥即可。
5.1 Tomcat启动失败:端口占用与JAVA_HOME未设置
现象:执行startup.sh或catalina.bat run后,控制台输出一大堆异常,核心错误是Port 8080 required by Tomcat v8.5 Server at localhost is already in use,或者干脆提示Cannot find 'JAVA_HOME'。
原因:8080端口被其他进程占用,通常是之前残留的Tomcat实例、IDEA内部运行的其他应用,或者某些开发工具的默认端口;JAVA_HOME没设置则是Tomcat的启动脚本找不到JVM安装路径,这在Windows上尤其常见。
解决:端口冲突用netstat -ano | findstr 8080(Windows)或lsof -i:8080(Linux/Mac)找到占用PID,杀进程,或改conf/server.xml里的<Connector port="8080">换到8081等空闲端口。JAVA_HOME在系统环境变量里指向JDK安装目录,注意不含bin子目录。推荐在命令行里用catalina.bat run而不是startup.bat——run模式把日志打到前台,排错效率高得多,startup.bat的日志被重定向到文件里,出了问题经常看不到。
5.2 中文乱码:JSP页面、Servlet响应还是数据库,依次排查
现象:页面原本该显示“电子图书管理”的地方变成乱码,有的是???,有的是æ°å¸,有的是方框。不同形态的乱码指向不同环节。
原因:???通常是JDBC URL里没指定characterEncoding,数据从数据库取出来就被截断;æ°å¸是典型的UTF-8字节被错误解码再显示,说明某一层的字符集声明和解码不一致;方框一般是页面本身缺少编码声明。这是一个跨层链路,必须逐层排查而不是盯着一个文件改。
解决:我排查的固定顺序是:先看数据库表字符集,SHOW CREATE TABLE books;确认是utf8mb4;再看JDBC URL里有没有characterEncoding=utf8mb4;再看Servlet里有没有调用request.setCharacterEncoding和response.setContentType;最后看JSP文件的pageEncoding。如果数据已经在库里存成乱码,不要试图写一条UPDATE把乱码“修”回来——字节已经被破坏,编码转换不可逆。这时候只能删掉重录,这也是为什么我总说编码问题是全项目最该提前定死的规范,没有后悔药可吃。
5.3 404/500错误:路径映射写错与Class目录结构问题
现象:启动正常、页面能打开,但点击某个链接后出现HTTP Status 404或HTTP Status 500。404常伴随The requested resource is not available,500则可能是java.lang.NullPointerException或ClassNotFoundException。
原因:404多数时候不是Servlet没写,而是@WebServlet的urlPatterns路径和前端表单action路径不一致。比如Servlet映射成/book/detail,JSP里却写成了book/detail(少了前导斜杠),或者漏掉了${pageContext.request.contextPath}。500中的ClassNotFoundException通常指向class文件没被编译进WEB-INF/classes,IDEA里常见于构建输出路径配置错误或增量编译缓存未刷新。
解决:先用浏览器开发者工具看Network里实际请求的URL,对比它和Servlet映射是否一致。少前导斜杠的请求路径会被当成相对路径,导致上下文段重复叠加。ClassNotFound则去target/classes或Tomcat的work目录看对应的.class文件是否存在,不存在就mvn clean compile重新构建,再不行就File > Invalidate Caches清理IDEA缓存。这一步看似玄学,但IDEA的增量编译在JSP项目上确实经常翻车。
5.4 MySQL驱动加载失败:ClassNotFound还是连接超时
现象:启动Tomcat时控制台报java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver,或者部署后第一次访问数据库时报Cannot create PoolableConnectionFactory。
原因:驱动jar包没放进WEB-INF/lib目录。在IDEA里开发时,依赖管理是构建工具干的,部署到Tomcat时必须打进WEB-INF/lib。Maven项目里MySQL依赖如果scope是provided,驱动就不会被带进WAR包;手工引进的jar包没被IDEA标记为随构建输出也会丢。
解决:确认pom.xml里mysql驱动的scope是compile(默认值),执行mvn clean package后检查WAR包内WEB-INF/lib下有没有mysql-connector-java-8.x.jar。纯手工项目则把jar包复制进webapp/WEB-INF/lib/,同时确认IDEA的Project Structure里这个jar是Compile状态而不是Provided。驱动类名也注意:MySQL 8.x必须用com.mysql.cj.jdbc.Driver,老代码里的com.mysql.jdbc.Driver在8.x驱动里已被删除,写错会直接抛ClassNotFoundException。
5.5 Session会话失效:跳转到登录页还是数据丢失
现象:用户在图书详情页停留一段时间后,点击“借阅”被踢回登录页;或者登录状态还在,但页面上的用户名列显示为null。
原因:Tomcat默认session超时时间是30分钟,web.xml里的session-config如果配置了session-timeout则以此为准。被踢回登录页多半是会话过期后登录过滤器拦截,这是预期行为。用户名显示null则是另一个问题:登录时把User对象以loginUser为键存进session,但后续某个Servlet里用了session.getAttribute("username"),键名对不上,取到的自然是null。
解决:如果产品要求长会话,调大session-timeout;更合理的做法是让用户在界面上看到剩余有效时间并主动刷新。键名问题则是约定问题:所有会话键名统一写在一个常量类里,例如public static final String SESSION_USER = "loginUser";,任何人都不许硬编码字符串。我在这个项目里吃过一次亏,登录存的是loginUser,收拾据列表的Servlet读的却是user,调试了半天才发现是键名不一致。从那以后,涉及session键名的代码全部用常量类,一劳永逸。
6. 进阶:用Filter把权限校验收口到一个类里,并按验证清单交付
6.1 一个LoginFilter处理登录校验和角色权限分级
把登录判断散落在各个Servlet里是最容易出乱的写法。我在项目里通常只写一个LoginFilter,统一处理三件事:白名单路径直接放行,未登录用户重定向到登录页,已登录但角色不符的用户拒绝访问管理路径。
@WebFilter(filterName = "LoginFilter", urlPatterns = "/*") public class LoginFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) resp; HttpSession session = request.getSession(false); String path = request.getRequestURI().substring(request.getContextPath().length()); User loginUser = (session == null) ? null : (User) session.getAttribute("loginUser"); if (isPublicPath(path) || loginUser != null) { chain.doFilter(req, resp); return; } if (path.startsWith("/admin") && loginUser.getRole() != 1) { response.sendError(HttpServletResponse.SC_FORBIDDEN); return; } response.sendRedirect(request.getContextPath() + "/login"); } private boolean isPublicPath(String path) { return path.equals("/login") || path.startsWith("/jsp/login") || path.startsWith("/css") || path.startsWith("/js") || path.startsWith("/uploads"); } }getSession(false)在无会话时返回null而不是新建会话,避免给每个静态资源请求都创建一个无用session。白名单里把静态资源和登录页放行,剩下的路径全部走登录校验。角色判断放在登录判断之后,避免未登录用户先收到403而不是被带去登录页。
6.2 用一套功能验证清单判断系统能不能交付
系统能不能交付,不要靠“感觉没问题”,要按清单逐项验证。我一般会列这样一张表:
| 验证项 | 前置条件 | 预期结果 |
|---|---|---|
| 管理员登录 | 数据库存在role=1账号 | 登录成功跳转图书管理页 |
| 读者登录 | 数据库存在role=0账号 | 登录成功跳转图书列表页 |
| 新增图书 | 管理员登录 | 提交后列表页出现新书,库存正确 |
| 借阅 | 读者登录、库存>0、无逾期 | 借阅记录插入、库存减一 |
| 逾期借阅 | 读者存在status=2记录 | 借阅被拒绝并提示原因 |
| 归还 | 存在借出记录 | 记录状态变为已还、库存加一 |
| 未登录访问/borrow | 无session | 被重定向到登录页 |
这套清单不长,但覆盖了核心闭环和边界条件。测试时我习惯用两个浏览器窗口,一个登录管理员,一个登录读者,模拟真实并发操作,比在同一个浏览器里反复退出登录要省事得多。
我后来养成的习惯是:写JSP+Servlet项目,把请求路径、session键名、编码声明这三类字符串统一收口到常量类或配置里,不再让它们在代码里散落。这套电子图书管理系统值不值得做,如果在学习期,它能把Web开发从请求到数据库的完整链路走一遍,比只看框架源码的印象深得多;如果是在做内部工具,它能用最小的维护成本稳定跑很多年。希望帮到你。
本文还有配套的精品资源,点击获取