每年到这个时间点,都有学弟学妹跑来问我同一句话:“JavaWeb方向的毕业设计,到底选什么题目稳一点?”我的回答一直没变过:图书管理系统。这个题看起来普通,甚至有点老气,但它背后覆盖的东西,恰好是JavaWeb课程里最难啃、也最能出成绩的那几块硬骨头。JSP动态页面、Servlet处理请求、JDBC操作MySQL、Session会话、Filter拦截器、分页查询、事务回滚,这些知识点在这一个系统里全部能串起来。我把这几年带学生做这个题目时整理的环境搭建、数据库设计、核心代码、部署发布和踩坑过程完整写了一遍,给正在做或者准备做这个题目的同学一个可以直接参照的样板。
1. 毕业设计选题为什么偏偏是它:JavaWeb技术栈的“最小完整闭环”
1.1 一个图书管理系统能覆盖哪些高分考点
很多同学挑毕设题目的时候有个误区,总觉得题目越新越好,越复杂越好。实际上本科毕业设计的评分重点从来就不是功能多少,而是你对基础知识的掌握程度、对工程流程的理解,以及答辩时能不能把“为什么这么做”讲清楚。图书管理系统最大的优势,恰恰是它的业务足够简单、足够贴近日常生活,但技术上又足够完整。
在这个系统里,你会接触到以下这些必考内容:JSP实现页面动态展示,Servlet接收请求并跳转,JavaBean封装数据,JDBC完成数据库增删改查,Session判断登录状态,Filter统一拦截未登录请求,PreparedStatement预编译防SQL注入,分页查询避免一次性加载数据过多,Connection事务保证借书还书的一致性。你把这条链路上每个环节都亲手写一遍,比看十遍网课都管用。我经常跟学生说一句话:毕业设计做得再花哨,答辩只问三十分钟;但如果你能把这条调用链从浏览器到数据库来回讲清楚,老师基本不会为难你。
1.2 项目规模的“做加法”与“做减法”:先定边界再动手
这个题目最大的坑不是不会写,而是不知道做多大。我见过有人上来就加了图书封面上传、Excel批量导入、邮件逾期提醒,结果写了一周还在登录页面转圈;也见过有人只做了图书增删改查就交差,答辩的时候老师问“借书之后库存为什么没变”,直接愣住。
我的建议是先搭一个基线版本,功能上只保留四件事:管理员登录、图书管理(增删改查+分页)、读者管理(增删改查)、借阅还书(含库存扣减和归还恢复)。把这四件事跑通,你的主体框架就基本完整了,至少能拿一个中等偏上的分数。然后再根据自己的时间和精力,挑一个亮点功能做加法,比如验证码登录、逾期自动计算、统计图表、批量导入。注意,加一个亮点就够,不要贪多。这个取舍逻辑我在后面答辩那章还会细讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境搭建实录:用IDEA 2023从零创建一个JavaWeb项目并接入Tomcat
2.1 关键技术选型:版本搭配别踩“新版本大坑”
现在的JavaWeb环境比几年前好配太多了,但版本搭配还是有点门道。就2023年之后的情况来看,我的推荐组合是:JDK 1.8(或者JDK 11,毕业设计用1.8最稳)、IDEA 2023.2以上、Tomcat 8.5.97或Tomcat 9.0.x、MySQL 5.7或8.0、MySQL Connector/J 8.0.x。
| 组件 | 推荐版本 | 说明 |
|---|---|---|
| JDK | 1.8 或 11 | 兼容性最好,网上资料最多,不要盲目用JDK 17+ |
| IDEA | 2023.2+ | 新版界面创建Web项目路径略有变化 |
| Tomcat | 8.5.97 或 9.0.x | 对传统JavaWeb项目兼容性最好 |
| MySQL | 5.7 或 8.0 | 8.0需使用cj驱动并配置时区 |
| JDBC驱动 | mysql-connector-java 8.0.x | 必须放到WEB-INF/lib下 |
这里特别提醒:如果用MySQL 8,数据库驱动类名必须是com.mysql.cj.jdbc.Driver,连接URL里建议加上serverTimezone=Asia/Shanghai,不然连接时大概率报时区错误。我在实际指导中碰到不少同学用了JDK 17甚至JDK 21,然后发现老版本的Tomcat不兼容,又去折腾环境变量,纯属给自己加戏。JavaWeb毕业设计用的是基础API,JDK 8完全够用,而且网上绝大部分资料都是基于这个版本写的,碰到问题搜答案也方便。
2.2 用IDEA 2023创建项目的两种路径:Maven骨架和手工Web模块
在IDEA 2023里创建JavaWeb项目,我常用两种方式,各有各的使用场景,你根据自己的情况选一种。
第一种是Maven方式。新建项目时选择Maven Archetype,在archetype列表里找到org.apache.maven.archetypes:maven-archetype-webapp,然后填好GroupId和ArtifactId。这种方式会自动生成src/main/java、src/main/resources、src/main/webapp的目录结构,还会生成pom.xml,后续在pom.xml里加依赖很方便。缺点是第一次下载archetype模板可能比较慢,而且自动生成的web.xml是2.3老版本,需要手动改成3.1或4.0版本,否则Servlet注解用不了。
第二种是纯手工方式。新建一个普通Java项目,然后右键项目选择Add Framework Support,勾选Web Application,IDEA会自动补上web目录。这种方式更贴近我早期做项目的习惯,每一步都能自己控制,适合想搞清楚JavaWeb项目到底长什么样的同学。我自己带毕设时一般推荐这种,因为它不会引入Maven那套构建概念,对于只想完成课设的人来说更直接。
无论哪种方式,最后都要在WEB-INF下创建lib目录,把mysql-connector-java.jar拖进去,然后右键Add as Library。这一步经常被忽略,很多人写代码的时候不报错,一运行就报ClassNotFoundException,十有八九是jar包没有放进WEB-INF/lib。
2.3 Tomcat配置与“JSP修改不生效”的初次交锋
项目结构创建好之后,在IDEA里配置Tomcat。打开Run/Debug Configurations,新增一个Tomcat Server Local,在Deployment标签页点加号,选择Artifact,类型选war exploded,Application context建议设成/bookmanage。很多同学会在这里纠结:Application context到底是干嘛的?简单说,它就是访问这个项目的根路径,你填了/bookmanage,启动后访问地址就是http://localhost:8080/bookmanage/xxx。
配置完Tomcat之后,我强烈建议你在Run配置里把Open browser关掉,改成自己手动访问,这样能避开很多浏览器缓存带来的错觉。另外,IDEA的Tomcat配置里有几个更新选项,On frame deactivation选择Update resources,这样IDE失去焦点时会自动把改动过的静态资源和JSP同步到Tomcat,稍微能缓解一点“改了不生效”的问题。至于这个问题真正的原理和完整排查办法,我在第六章专门写了一大段,这里先不展开。
3. 数据库设计:五张表支撑起整个借阅流程
3.1 从业务需求梳理出实体与关系
数据表设计是JavaWeb毕设里最容易被同学应付过去、但答辩时又最容易被追着问的部分。图书管理系统的核心业务其实就三件事:登录系统、管理图书、借书还书。围绕这三件事,实体并不复杂。最基本的需要有管理员、图书、读者、借阅记录这四个实体。如果你还想支持“同一本书有多本副本”这种语义,t_book表里的库存字段就能解决,不需要给每本书单独建记录。所以最终我建议用四张表加一张可选的联系表,即可覆盖全部业务。
管理员和图书之间没有复杂关系,管理员是操作者;读者和图书是多对多关系,一个读者可以借多本书,一本书在不同时间可以被不同读者借走。这个多对多关系不能直接落在两张表上,必须拆成借阅记录表。这一点是数据库设计答辩时的经典考点,下面我结合SQL来说。
3.2 核心建表SQL与字段设计说明
以下是我在实际项目中用的建表语句(MySQL 8.0,字符集utf8mb4):
sql复制CREATE DATABASE IF NOT EXISTS bookdb DEFAULT CHARACTER SET utf8mb4;
USE bookdb;
CREATE TABLE t_admin (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE COMMENT '登录账号',
password VARCHAR(100) NOT NULL COMMENT '登录密码',
realname VARCHAR(50) DEFAULT '' COMMENT '真实姓名',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB COMMENT='管理员表';
CREATE TABLE t_book (
id INT PRIMARY KEY AUTO_INCREMENT,
bookname VARCHAR(100) NOT NULL COMMENT '书名',
isbn VARCHAR(20) DEFAULT '' COMMENT 'ISBN编号',
author VARCHAR(50) DEFAULT '' COMMENT '作者',
publisher VARCHAR(100) DEFAULT '' COMMENT '出版社',
category VARCHAR(50) DEFAULT '' COMMENT '分类',
price DECIMAL(6,2) DEFAULT 0 COMMENT '定价',
stock INT NOT NULL DEFAULT 0 COMMENT '当前可借库存',
total INT NOT NULL DEFAULT 0 COMMENT '总藏书量',
location VARCHAR(50) DEFAULT '' COMMENT '馆藏位置',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB COMMENT='图书表';
CREATE TABLE t_reader (
id INT PRIMARY KEY AUTO_INCREMENT,
username VARCHAR(50) NOT NULL UNIQUE,
password VARCHAR(100) NOT NULL,
realname VARCHAR(50) NOT NULL,
card_no VARCHAR(20) NOT NULL UNIQUE COMMENT '借阅证号',
phone VARCHAR(20) DEFAULT '',
email VARCHAR(50) DEFAULT '',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP
) ENGINE=InnoDB COMMENT='读者表';
CREATE TABLE t_borrow (
id INT PRIMARY KEY AUTO_INCREMENT,
book_id INT NOT NULL,
reader_id INT NOT NULL,
borrow_time DATETIME NOT NULL COMMENT '借出时间',
due_time DATETIME NOT NULL COMMENT '应还时间',
return_time DATETIME DEFAULT NULL COMMENT '实际归还时间',
status TINYINT NOT NULL DEFAULT 0 COMMENT '0在借 1已还',
renew_count INT NOT NULL DEFAULT 0 COMMENT '续借次数',
CONSTRAINT fk_borrow_book FOREIGN KEY (book_id) REFERENCES t_book(id),
CONSTRAINT fk_borrow_reader FOREIGN KEY (reader_id) REFERENCES t_reader(id)
) ENGINE=InnoDB COMMENT='借阅记录表';
这里有几个字段名需要你特别理解,答辩的时候老师喜欢问。为什么有stock又有total?stock是当前可借数量,total是馆藏总量。借出一本,stock减一;归还一本,stock加一,total永远不变。这样就能直观知道有多少本在读者手里,等于total - stock。为什么status用TINYINT不用VARCHAR?因为状态只有0和1两种值,用数字存占空间小、判断效率高,代码里用常量或者枚举去对应,比存储中文乱糟糟的字符串好维护。
3.3 借阅记录表为什么是系统的心脏
很多同学做借还功能的时候,喜欢简单粗暴地在t_book表里加一个字段叫“是否被借出”,然后借了就置为1。这个设计在只有一本书的时候没问题,但一旦系统里有十本相同的《Java核心技术》,这个字段就完全不知道怎么处理了。正确的做法是把借这个“动作”本身记录成一条独立的数据,也就是t_borrow表。
借阅记录表最重要的价值是可以回答三个问题:某本书现在在谁手里、某个人借了几本书、有没有超期没还。依靠t_borrow的book_id、reader_id、due_time和return_time四个字段就能全部查出来。举一个例子,要查“当前所有超期未还的记录”,SQL会是:
sql复制SELECT b.*, bk.bookname, rd.realname
FROM t_borrow b
JOIN t_book bk ON b.book_id = bk.id
JOIN t_reader rd ON b.reader_id = rd.id
WHERE b.status = 0 AND b.due_time < NOW();
这条SQL里面涉及了多表关联、条件过滤、时间函数,答辩的时候如果老师让你“现场说说怎么查出超期图书”,你能完整写出来,是很加分的。所以借阅记录表的内容一定要吃透,它是整个系统数据流的核心。
4. 核心代码实现:从DBUtil到借书还书的完整调用链
4.1 最基础的DBUtil:JDBC连接与资源释放
代码结构上,我建议按包名分层:com.bookmanage.bean放实体类,com.bookmanage.dao放数据访问类,com.bookmanage.servlet放Servlet,com.bookmanage.filter放过滤器,com.bookmanage.util放工具类。不管项目大小,这种分层都能让你和答辩老师一眼看懂项目结构。这也是JavaWeb分层的经典做法,Model层用Bean承载数据,DAO层负责和数据库打交道,Servlet作为Controller,JSP作为View。
写JDBC的第一步是准备一个DBUtil。别看它简单,很多人栽在资源没有关闭,导致MySQL连接数爆掉,多跑几遍就卡死。我习惯把连接的创建和资源释放都写好:
java复制package com.bookmanage.util;
import java.sql.*;
public class DBUtil {
private static final String URL = "jdbc:mysql://localhost:3306/bookdb?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai";
private static final String USERNAME = "root";
private static final String PASSWORD = "你自己的密码";
static {
try {
Class.forName("com.mysql.cj.jdbc.Driver");
} catch (ClassNotFoundException e) {
e.printStackTrace();
}
}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USERNAME, PASSWORD);
}
public static void close(ResultSet rs, PreparedStatement ps, Connection conn) {
if (rs != null) {
try { rs.close(); } catch (SQLException ignored) {}
}
if (ps != null) {
try { ps.close(); } catch (SQLException ignored) {}
}
if (conn != null) {
try { conn.close(); } catch (SQLException ignored) {}
}
}
}
如果你的项目里不想把这些连接参数写死在类里,可以用一个jdbc.properties文件放URL、用户名、密码,再用java.util.Properties读取。这个小细节在答辩时提一句“把配置和代码分离”,效果会很好。
4.2 DAO层:分页查询与库存判断的核心SQL
DAO层是查数据库的地方,这里两个方法值得琢磨。第一个是图书分页查询。列表页不可能把几千本书一次性查出来,分页是JavaWeb必考功能。我的实现思路是先查总数count(*),再计算总页数,然后用LIMIT offset, pageSize取当前页数据:
java复制public List<Book> findByPage(int pageNum, int pageSize) {
List<Book> list = new ArrayList<>();
String sql = "SELECT id, bookname, isbn, author, publisher, category, price, stock, total, location FROM t_book ORDER BY id DESC LIMIT ?, ?";
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("bookname"));
book.setIsbn(rs.getString("isbn"));
book.setAuthor(rs.getString("author"));
book.setPublisher(rs.getString("publisher"));
book.setCategory(rs.getString("category"));
book.setPrice(rs.getBigDecimal("price"));
book.setStock(rs.getInt("stock"));
book.setTotal(rs.getInt("total"));
book.setLocation(rs.getString("location"));
list.add(book);
}
}
} catch (SQLException e) {
e.printStackTrace();
}
return list;
}
public int count() {
String sql = "SELECT COUNT(*) FROM t_book";
try (Connection conn = DBUtil.getConnection();
PreparedStatement ps = conn.prepareStatement(sql);
ResultSet rs = ps.executeQuery()) {
if (rs.next()) {
return rs.getInt(1);
}
} catch (SQLException e) {
e.printStackTrace();
}
return 0;
}
第二个关键是库存判断。借书之前必须先查t_book的stock,大于0才能继续。这里要注意并发问题:两个人同时借同一本书的最后一本,理论上只能有一个成功。解决方式是在事务里用SELECT ... FOR UPDATE锁行,这个后面4.5节会讲。
4.3 Servlet控制层:借书还书用同一个入口还是分开写
Servlet这一层的写法比较自由,但我的建议是“一个业务一个方法,通过参数区分操作”会让代码显得很乱,不如拆两个Servlet:BorrowServlet和ReturnServlet,也可以一个HandleServlet里用action参数。对于毕设规模,我推荐后者,例如在BorrowServlet里加一个action参数,值为borrow时执行借书,值为return时执行还书。这样做的好处是Controller类少,逻辑直观,但坏处是代码稍微有一点长。如果你觉得难维护,拆开成两个Servlet也完全没问题,关键是让老师看出来你有“接口设计”的意识。
一个典型的借书Servlet核心逻辑是:从页面取得bookId和readerId,调用BorrowDao的borrow方法,根据返回结果跳转到不同页面。注意,页面跳转建议用重定向而不是forward,特别是新增、修改、删除之后,不然用户一刷新浏览器就会重复提交上次的请求,把同一本书借两次。这个坑我在实际项目里见过太多次了,刷新页面导致借阅记录重复,数据一团乱。
4.4 登录拦截Filter:一个Filter挡住所有未登录请求
登录功能如果只在每个Servlet里判断Session,代码会重复且容易遗漏,最好的方式是用Filter统一拦截。Filter就是JavaWeb里的“关卡”概念,请求到达Servlet之前会先经过它。写一个LoginFilter,判断Session里有没有登录用户,没有就重定向到登录页;同时放行登录接口本身、登录页、以及CSS/JS/图片这类静态资源:
java复制package com.bookmanage.filter;
import javax.servlet.*;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.*;
import java.io.IOException;
@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();
String ctx = req.getContextPath();
if (uri.equals(ctx + "/login.jsp")
|| uri.equals(ctx + "/login")
|| uri.endsWith(".css")
|| uri.endsWith(".js")
|| uri.endsWith(".jpg")
|| uri.endsWith(".png")) {
chain.doFilter(request, response);
return;
}
HttpSession session = req.getSession(false);
if (session != null && session.getAttribute("loginUser") != null) {
chain.doFilter(request, response);
} else {
resp.sendRedirect(ctx + "/login.jsp");
}
}
}
这里有个细节:req.getSession(false)和req.getSession()是不一样的。getSession(false)在没有Session时返回null,不会强行创建Session;而getSession()没有就新建。在Filter里用false更严谨,避免每个静态资源请求都创建一个无意义的Session。
4.5 事务处理:借书操作为什么必须包裹在一个事务里
借书这个操作并不是只有一条SQL,它至少包含两步:往t_borrow插入借阅记录,同时把t_book的stock减一。如果两条SQL中间程序抛异常,第一条执行了第二条没执行,就会导致库存没变但借阅记录有了,数据不一致。解决办法就是让两步操作在同一个数据库事务里执行:要么全部成功,要么全部回滚。
Java里通过Connection的setAutoCommit(false)开启手动事务,commit提交,rollback回滚。下面是借书的核心方法:
java复制public boolean borrow(int bookId, int readerId) {
Connection conn = null;
try {
conn = DBUtil.getConnection();
conn.setAutoCommit(false);
String checkSql = "SELECT stock FROM t_book WHERE id = ? FOR UPDATE";
int stock;
try (PreparedStatement ps = conn.prepareStatement(checkSql)) {
ps.setInt(1, bookId);
try (ResultSet rs = ps.executeQuery()) {
if (!rs.next() || rs.getInt("stock") <= 0) {
conn.rollback();
return false;
}
stock = rs.getInt("stock");
}
}
String updateSql = "UPDATE t_book SET stock = ? WHERE id = ?";
try (PreparedStatement ps = conn.prepareStatement(updateSql)) {
ps.setInt(1, stock - 1);
ps.setInt(2, bookId);
ps.executeUpdate();
}
String insertSql = "INSERT INTO t_borrow(book_id, reader_id, borrow_time, due_time, status) VALUES(?, ?, NOW(), DATE_ADD(NOW(), INTERVAL 30 DAY), 0)";
try (PreparedStatement ps = conn.prepareStatement(insertSql)) {
ps.setInt(1, bookId);
ps.setInt(2, readerId);
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);
conn.close();
} catch (SQLException e) {
e.printStackTrace();
}
}
}
}
这里用FOR UPDATE锁住图书行,是为了防止并发下两个请求同时读到stock为1,然后都走完流程,把库存扣成负数。这在单机毕设里很难触发,但答辩老师非常爱问这个问题,你能讲出“锁行+事务”这两个词,就已经超过大多数人了。
5. 部署发布:把项目从IDEA搬到独立Tomcat与Windows服务器
5.1 打war包的两种方式和war包结构
项目开发完,最终交付需要部署到独立Tomcat上,这时候要给项目打war包。war包本质上是一个zip压缩包,里面装着整个web应用的目录结构。打war包有两种常用方式。
第一,如果你用Maven创建的项目,在IDEA右侧Maven面板双击package,就能在target目录下生成war包。第二,如果你用的是手工创建的Web项目,需要在Project Structure里手动添加Artifact,类型选择Web Application: Archive,然后点击Build菜单里的Build Artifacts。无论哪种方式,打完后你都可以用解压软件打开war看一眼,里面应该有WEB-INF目录、JSP页面、静态资源等。如果WEB-INF里没有lib目录,或者lib里没有mysql驱动jar包,部署后一定会出问题。
5.2 独立Tomcat部署与上下文路径的理解
拿到war包后,部署就变得非常简单:把war文件复制到Tomcat安装目录下的webapps文件夹里,然后启动Tomcat。Tomcat启动时会自动解压war包,并把文件夹名作为上下文路径。比如你把bookmanage.war放进webapps,那么访问地址就是http://localhost:8080/bookmanage/。
这里我特别强调一下上下文路径的问题。开发时IDEA里配置的Application context是/bookmanage,部署后访问路径也带bookmanage,这是最容易出404的地方。在JSP页面里引用CSS、JS、跳转链接时,建议统一用${pageContext.request.contextPath}作为前缀,这样不管最后部署的上下文路径是bookmanage还是其他,页面都能自动匹配,不会因为路径写死而报错。
5.3 想让网站用80端口访问,改一处配置就行
Tomcat默认端口是8080,访问的时候必须带端口号。如果想把端口去了,直接访问http://你的域名/,可以修改conf目录下的server.xml。找到Connector那一行,把port="8080"改成port="80"即可:
xml复制<Connector port="80" protocol="HTTP/1.1"
connectionTimeout="20000"
redirectPort="8443" />
改完端口后,需要在服务器防火墙里放行80端口,重启Tomcat。很多同学改了端口却访问不了,大部分原因是防火墙没放行,而不是Tomcat配置有误,这个排查思路值得记住。
另外,如果你的服务器上已经装了Apache或者其他Web服务器占用了80端口,最简单的办法就是让Tomcat用8080,再让Apache通过反向代理把请求转发到Tomcat。这个方案在Windows服务器上也可以实现,但配置量会大一些。对于毕业设计,改Tomcat端口就够了,不推荐在部署阶段给自己增加额外复杂度。
6. 踩坑实录:JSP改不生效、中文乱码、404的完整排查链路
6.1 排查思路比答案更重要:问题分层的通用方法
我带过的学生里,几乎所有人在项目运行阶段都会碰到三个问题:JSP改了不生效、中文乱码、访问404。这三类问题的共性是:它们都不是某个单一原因导致的,而是浏览器、服务器、项目、数据库几个层面都可能出问题。所以我先给一个通用的排查思路:从浏览器端往服务器端反向走,先排除缓存,再确认浏览器真的请求到了服务器,然后看服务器返回了什么内容,最后看Tomcat的catalina.out日志。这个思路比死记任何单个答案都重要,因为换了一个环境,同样的问题可能是完全不同的原因。
6.2 JSP改不生效的四种原因与验证手段
JSP改了不生效,90%的情况下跑不出这四个原因。第一个是浏览器缓存,尤其是用了Chrome的“缓存禁用”没打开,每次访问的都是本地缓存里的旧页面。验证方式很简单:在地址栏后手动加一个?t=123这种随机的查询参数,或者按Ctrl+F5强制刷新,如果新内容出现了,就是缓存问题。
第二个是IDEA没有把修改同步到Tomcat。使用war exploded方式部署时,IDEA有时不会自动同步JSP。解决办法是运行配置里把On frame deactivation设置为Update resources,然后手动点击那个Update按钮或者按Ctrl+F10。第三个是Tomcat的JSP编译缓存没清,删除Tomcat目录下work/Catalina/localhost/bookmanage整个文件夹,重启Tomcat,强制重新编译。第四个属于“你以为改了,其实改错位置了”,特别是多人共用项目或者目录结构混乱时,修改的是src/main/webapp下的JSP,IDEA部署用的却是另一个web目录下的副本。遇到这种问题不要慌,在文件管理器里搜索文件名,看看项目里到底有几个同名JSP文件。
6.3 中文乱码:一次把所有编码位全部配齐
中文乱码是JavaWeb老生常谈的问题,它牵涉四个编码点:数据库连接URL、数据库表字符集、Tomcat连接器、页面字符集。任何一个不一致,就会出现“数据库里是正确的中文,页面上显示问号”或者“页面上正常,一查数据库全是乱码”。
我建议按顺序检查一遍。第一,MySQL建库时明确CHARSET=utf8mb4,别依赖默认。第二,连接URL带上useUnicode=true&characterEncoding=utf8。第三,每个JSP页面顶部声明contentType="text/html; charset=UTF-8"和pageEncoding="UTF-8"。第四,如果是POST提交,要么加一个CharacterEncodingFilter统一设置request.setCharacterEncoding("UTF-8"),要么在每个Servlet里写。第五,如果是GET参数乱码,在Tomcat的server.xml里给Connector加URIEncoding="UTF-8"。把这些位置配齐后,乱码基本会消失。注意字符编码解决原则是“从头到尾都用同一种编码”,不要中途混用GBK和UTF-8。
6.4 部署后404:八成是上下文路径和Artifacts没看明白
本地IDEA里运行好好的,部署到独立Tomcat就404,这是项目答辩前最让人崩溃的情况。据我观察,原因基本集中在两个地方。一是war包结构不对,解压后WEB-INF/classes目录下应该有编译好的class文件和属性文件,WEB-INF/lib目录下应该有所有依赖jar包。少了classes,通常是因为开发时没执行编译,或者Artifacts配置时没有把编译输出加进去。二是访问路径不对,war包叫book.war,你却用http://localhost:8080/bookmanage去访问,那必然404,因为上下文路径是book不是bookmanage。URL里的大小写也要注意,Tomcat对路径大小写敏感,BookManage和bookmanage是两个完全不同的东西。
遇到404,最快的排查方式是看Tomcat的logs目录下localhost.***.log日志,服务器会把所有请求记录在里面,包括404时实际请求的路径,一对比就很清楚是路径写错还是资源没部署。
7. 答辩准备与项目进阶:老师会问什么,亮点怎么加
7.1 高频答辩问题清单与标准回答思路
答辩环节经常问的问题其实非常集中,这里列几个我见过的高频问题,以及我建议的回答思路。
| 高频问题 | 推荐回答思路 |
|---|---|
| 为什么不用SSM或者Spring Boot? | 按课程设计要求使用JSP+Servlet+JDBC,是为了更清晰地理解HTTP请求处理、会话管理、数据库操作这些底层过程,之后再上框架会更扎实 |
| 数据库为什么这样设计? | 按三范式设计,消除重复数据,借阅记录单独建表用来解除读者和图书的多对多关系 |
| 怎么防止SQL注入? | 使用PreparedStatement预编译,禁止拼接SQL字符串 |
| 分页是怎么实现的? | 先count查总记录数,再计算总页数,用LIMIT offset,pageSize取当前页数据 |
| 借书和还书时库存怎么保持一致? | 用Connection事务,借书时开启手动事务,提交前校验库存并锁定该行,失败则回滚 |
只要你能把这些问题的逻辑用自己的话讲清楚,而不是背答案,基本上就稳了。
7.2 低成本高观感的进阶功能:分页、验证码、密码加密
想从六十份里脱颖而出,不需要真的做很多功能,挑一两个小点做扎实就够。我个人最推荐三个“性价比”功能。第一个是登录验证码,一张图片上生成几个随机字符,用Session存答案,登录时校验。这个功能工作量不大,但能让系统看起来完整很多。第二个是密码加密存储,把明文密码用MD5加盐或者至少SHA-256哈希后再存数据库,答辩的时候说一句“即使数据库泄露,也不会直接暴露密码”,格局就打开了。第三个是借阅超期计算,在借阅列表页通过due_time和当前时间比较,超期记录用红色标注。注意这个计算不能只存一个状态字段,因为超期是随时间动态发生的,应该在前端展示时动态判断。
7.3 从JSP+Servlet到Maven+MyBatis的升级路线
如果你的时间还剩两周以上,可以考虑给项目引入Maven和MyBatis。Maven负责依赖管理和构建,MyBatis替代手写JDBC,通过Mapper接口和XML完成SQL映射,能省掉大量样板代码。这个升级路线不用推倒重来,把原来的JDBC工具类替换成SqlSessionFactory,把DAO的实现类改成Mapper接口,Controller和JSP层基本不动。等你的项目跑通这套升级,后面再学Spring、SpringMVC、SpringBoot的时候,就会觉得很多概念是相通的。
我自己带过的毕业设计里,凡是愿意把Filter和事务这两个点真正吃透的同学,答辩成绩普遍都不差。这倒不是玄学,是因为这两个点恰好避开了“背代码”式的学习,逼着你真的去理解JavaWeb的请求链条和数据一致性。希望这份经验能帮你少走一点弯路,也祝你答辩顺利。
