图书管理系统大概是JavaWeb领域最"烂大街"的项目了,但我带新人入行这么多年,每次有人问我"Java语法学完了该做什么",我给出的答案永远是它。别看这个项目简单,从JDBC到Servlet再到JSP,整个JavaWeb的经典链路它一条不落全走了一遍。这篇文章不搞虚的,直接把我平时带人做这个项目的完整过程摊开讲,从环境搭建到数据库设计,从每一层代码怎么写到最后怎么部署,包括我自己踩过的坑、学员常犯的错,全写在这里。你要是零基础,跟着这篇从头敲一遍,不敢说精通Java,但至少能独立写出一个像样的管理系统,也够你拿去应付课程设计或者面试里的项目提问了。
1. 项目整体设计与技术选型
1.1 为什么偏偏是图书管理项目
图书管理项目在Java学习路线里的地位,差不多等同于烹饪课里的番茄炒蛋。它不大不小,刚好能覆盖JavaWeb开发的完整链路:前端页面怎么写、后端接口怎么接、数据库怎么设计、数据怎么流动。你要是一上来就上手电商系统,光是商品SKU、订单状态机、支付回调就能把人绕晕;要是只写控制台练习,又永远碰不到真实开发里的HTTP、会话这些东西,学完语法等于没学。
一个标准的图书管理项目包含的功能通常就这么几块:图书的新增、删除、修改、查询,读者的借书还书,用户的登录注册,管理员和普通读者的权限区分。听着简单,但每一块展开都有值得琢磨的点。就拿删书来说,表面是dao层里一行delete语句的事,实际你得考虑这本书被借走了怎么办,是禁止删除还是标记下架?这就是业务规则,是用户没写进需求但你必须替他想清楚的地方。
这个项目真正训练的是"分层思维"。控制层只负责接收参数和跳转页面,业务层只负责规则判断,数据层只负责跟数据库打交道。我在实际带教中发现,很多自学的同学写代码不分层,Servlet里直接拼SQL,功能是能跑,但加一个需求就要重构一次。图书管理项目因为规模小,恰好可以让你毫无压力地把三层架构练熟,等以后做Spring Boot项目时,这种分层设计的肌肉记忆会帮你大忙。
1.2 技术选型:老技术为什么依然能打
技术选型这里我多说几句,因为很多人一上来就纠结要不要学最新框架。我推荐的组合是:JDK 8 + Tomcat 9 + JSP + Servlet + JDBC + MySQL。这套组合你翻现在的招聘要求可能觉得过时,但对零基础学员来说,它恰恰是最好的教学载体。
为什么?因为这套技术栈没有任何框架封装遮住你的视线。JSP能让你看到页面模板怎么嵌Java代码,Servlet能让你亲眼看到HTTP请求怎么被接收、响应怎么写回去,JDBC能让你手写数据库连接、手写预编译SQL。这些底层机制你亲手摸过一遍之后,再学Spring Boot、MyBatis、Spring Data JPA这些封装好的轮子,理解速度完全不一样——你知道它们替你干了什么,也知道出问题该去哪一层排查。
也有一种选择是直接上Spring Boot + MyBatis,很多培训班这么做,理由是对接就业。但我不建议零基础直接跳过去。假如你连Servlet都没写过,遇到Spring MVC的DispatcherServlet就觉得是魔法,遇到MyBatis的Mapper代理也觉得是魔法,整个学习过程全在背注解和配置,底层一问三不知。技术可以升级,路线不建议跳级。我遇到过不止一个学员,Spring Boot项目能跑,但让他解释一下请求从浏览器发出到数据库返回结果之间发生了什么,完全说不清楚。这种状态去面试,项目一深问就露馅。
| 技术方案 | 学习成本 | 底层可见性 | 就业匹配度 | 推荐人群 |
|---|---|---|---|---|
| JSP+Servlet+JDBC | 低 | 高,所有环节手写 | 中,偏课程设计与基础岗 | 零基础入门,先吃透原理 |
| Spring Boot+MyBatis | 中高 | 低,框架封装多 | 高,主流企业技术栈 | 已有JavaWeb基础,准备就业 |
1.3 功能模块与角色权限拆解
动手写代码前,先把功能边界画清楚。我习惯让学员画一张简单的模块图:系统分成前台和后台两大部分,后台是管理员用的,管图书和借还;前台是读者用的,查书和借书。不要一上来就想着做花哨的功能,核心就四件事:图书管理、读者管理、借阅管理、登录认证。
权限这块是很多新手容易忽略的。图书管理项目虽然小,但权限设计完全可以做得规范和合理。管理员能对图书做增删改查,能查看所有借阅记录,能处理还书;普通读者只能查询图书、借书、还书、查看自己的借阅历史。这个约束在哪一层实现?我要求学员必须在Servlet里做登录校验,通过Session判断当前用户角色,再决定是否放行。放页面里用隐藏按钮去控制权限是错的,因为接口暴露了,绕过前端直接请求URL照样能执行操作。这一点理解透了,你以后做任何系统的权限控制都有基础。
功能清单列出来大概是这样的:管理员登录、图书信息维护(增删改查)、读者信息维护(增删改查)、图书借阅、图书归还、借阅记录查询、超期判断。够了吗?对于入门来说完全够了。你要是想加功能,后期可以加图书分类、模糊搜索、分页、借阅排行榜,这些都是同一个套路,学会了第一个,后面都是复制粘贴加改动。
2. 环境准备与数据库设计
2.1 开发环境搭建与常见坑
先说环境。零基础同学最容易在环境配置上耗尽热情,这里我给出一套我实测最省事的组合:JDK 8、MySQL 5.7或者8.0、Tomcat 9、IDEA社区版、Maven 3.6。JDK 8虽然老,但它跟Tomcat 9的兼容性最稳,而且市面上大量教程、代码示例都是按JDK 8写的,你跟着做不会遇到莫名其妙的版本差异。
JDK装好后一定要在命令行里验证一下。打开cmd,输入java -version,能正确输出版本号才算装好。很多同学装完就在IDEA里写代码,结果IDEA用的是自带JDK或者配错了路径,后面启动Tomcat报各种错误。环境变量里JAVA_HOME和PATH这两个必须配,JAVA_HOME指向JDK安装根目录,PATH里加上%JAVA_HOME%\bin,这是基础操作但每年都有一堆人卡在这。
MySQL装的时候有两个地方我特别提醒:一个是字符集,安装时或建库时务必指定utf8mb4,不然后面存中文全是问号;另一个是端口,默认3306即可,别随意改动,改了你连接字符串也得跟着改,新手容易忘记。Tomcat下载解压版就行,解压后目录里有个bin文件夹,startup.bat是启动脚本,conf/server.xml里可以改端口,默认8080。端口被占用是最常见的启动失败原因,后面我会专门讲排查方法。
IDEA里创建一个普通的Maven项目,用maven-archetype-webapp模板,这个模板会帮你生成标准的Web项目结构。IDEA社区版不带Tomcat集成插件,这个问题不大,我一般让学员用IDEA里配置Tomcat的方式,或者直接打WAR包扔到Tomcat的webapps目录里启动,两种方式都要会,尤其后者更接近真实部署。
2.2 三张核心表的设计
数据库设计是整个项目的根基,表结构建错了,后面所有代码都要推倒重来。图书管理项目最少需要三张表:图书表、用户表(读者)、借阅记录表。有些教程喜欢把用户和读者分开建表,我觉得入门阶段没必要,一个用户表加一个角色字段就能表达清楚。
图书表字段这样设计:book_id主键自增,book_name书名,author作者,publisher出版社,price价格,stock库存,category分类。这里我着重说stock——库存存在图书表里而不是每次现算,这是一个常见的设计取舍。借书时库存减一,还书时加一,看起来有并发风险,但入门项目完全够用,而且查询效率高。你要是用"每次查询借阅记录再统计剩余数量"的做法,逻辑上更严谨,但SQL复杂得多,对新手不友好。
用户表:user_id主键,username唯一,password,real_name真实姓名,role角色字段,管理员写1,普通读者写0。密码存储这里我必须强调:真实项目绝不能存明文,至少要用MD5加盐或者BCrypt哈希。但入门阶段如果直接上加密,很多人会混淆"加密"和"业务逻辑"的学习重点。我的建议是:先存明文把功能跑通,然后作为一个进阶任务,自己研究怎么改成MD5存储,这样既不耽误主线,又能真实练到安全问题。
借阅记录表:borrow_id主键,book_id外键关联图书表,user_id外键关联用户表,borrow_time借书时间,return_time还书时间。这张表就是整个系统的核心业务表,一次借书操作插入一条记录,还书时更新return_time。你要查询某本书被谁借了、某个读者借了几本书、哪些书超期没还,全部靠这张表出数据。
实际在建表时我会让学员手写SQL,不要用Navicat的图形界面点出来,原因很简单:面试里让你手写建表语句是常事,而且建表SQL写熟了,后面写复杂的联表查询才不怵。下面是完整的建表脚本,可以直接复制执行:
CREATE DATABASE IF NOT EXISTS library_db DEFAULT CHARACTER SET utf8mb4; USE library_db; CREATE TABLE `book` ( `book_id` INT NOT NULL AUTO_INCREMENT, `book_name` VARCHAR(100) NOT NULL, `author` VARCHAR(50) DEFAULT NULL, `publisher` VARCHAR(100) DEFAULT NULL, `price` DECIMAL(10,2) DEFAULT NULL, `stock` INT NOT NULL DEFAULT 1, `category` VARCHAR(50) DEFAULT NULL, PRIMARY KEY (`book_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `user` ( `user_id` INT NOT NULL AUTO_INCREMENT, `username` VARCHAR(50) NOT NULL UNIQUE, `password` VARCHAR(100) NOT NULL, `real_name` VARCHAR(50) DEFAULT NULL, `role` TINYINT NOT NULL DEFAULT 0 COMMENT '1=admin, 0=reader', PRIMARY KEY (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4; CREATE TABLE `borrow_record` ( `borrow_id` INT NOT NULL AUTO_INCREMENT, `book_id` INT NOT NULL, `user_id` INT NOT NULL, `borrow_time` DATETIME DEFAULT CURRENT_TIMESTAMP, `return_time` DATETIME DEFAULT NULL, PRIMARY KEY (`borrow_id`), KEY `idx_book_id` (`book_id`), KEY `idx_user_id` (`user_id`), CONSTRAINT `fk_borrow_book` FOREIGN KEY (`book_id`) REFERENCES `book` (`book_id`), CONSTRAINT `fk_borrow_user` FOREIGN KEY (`user_id`) REFERENCES `user` (`user_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里有个细节:user表我加了UNIQUE约束在username上,这是为了防止注册重复用户名。外键我也真实建了,但必须提醒你,有些教程为了省事不建外键,靠代码逻辑维护关联——零基础阶段我建议建上,因为外键能帮你理解表间关系,等以后做高并发项目再考虑为了性能去掉外键。索引这块,borrow_record表上我给book_id和user_id建了普通索引,因为查询借阅记录时这两个字段是高频搜索条件,没索引的话数据量一大就会全表扫描,体验差异很明显。
2.3 项目目录结构与分层约定
有了表,接下来规划代码目录。我见过太多新手把所有类堆在一个包里的写法,这里我直接给出一个我平时带人用的标准结构,每个包干什么写在注释里:
src/main/java ├── com.library.entity # 实体类:Book, User, BorrowRecord ├── com.library.util # 工具类:DBUtil 数据库连接 ├── com.library.dao # 数据访问层:BookDao, UserDao, BorrowDao ├── com.library.dao.impl # DAO实现类(接口与实现分离) ├── com.library.service # 业务层接口 ├── com.library.service.impl # 业务层实现 └── com.library.servlet # Controller层:BookServlet, UserServlet, BorrowServlet为什么要接口和实现分开?这是很多新手不理解的地方。你现在可能觉得"接口里写个方法签名,实现类里写逻辑,这不是脱裤子放屁吗"。我直接说答案:为了解耦和扩展。今天你用的是JDBC,明天要是换成MyBatis,业务层调用的还是BookDao接口,你只需要把实现类换掉,业务层一行代码不用改。面试的时候HR问"你用过哪些设计模式",你可以说"项目里通过接口编程实现了依赖倒置",这就比干巴巴背定义有说服力得多。
分层之间的调用规则这么定:JSP页面发起请求到Servlet,Servlet调Service接口,ServiceImpl里面写业务逻辑、调DAO接口,DAOImpl直接操作数据库。每一层只认相邻的下一层,禁止Servlet跨层直接调DAO。这个规则坚持住,你的代码结构就永远是清爽的。我自己检查学员代码时,第一件事就是看有没有Servlet直接new DAO去查数据,发现一次就要求重构一次,练的就是这个习惯。
3. 核心功能实现与代码解析
3.1 实体类与JDBC连接工具
先把实体类写了。实体类的作用是让Java代码里的对象跟数据库表记录一一对应,这是ORM思想的雏形。Book实体长这样:
public class Book { private Integer bookId; private String bookName; private String author; private String publisher; private BigDecimal price; private Integer stock; private String category; public Book() {} // 每个字段的 getter 和 setter 省略,IDE一键生成 }注意我用的是包装类型Integer而不是基本类型int。这是有讲究的:数据库字段可能为NULL,基本类型int接不住空值,会直接抛NullPointerException,包装类型可以优雅地表达"没有值"这个状态。这个细节很多工作两三年的开发都不一定注意,你从这个项目开始养成习惯,以后踩坑就少。
接下来是最容易被轻视但最重要的DBUtil。很多新手喜欢在DAO里每次DriverManager.getConnection,用完不关连接,这是错误的。正确的做法是写一个工具类把连接的创建和关闭收拢在一起,同时规定必须在finally块里释放资源:
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/library_db?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8"; private static final String USERNAME = "root"; private static final String PASSWORD = "123456"; 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, 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(); } } } }连接串里的参数每一个都有用。useSSL=false是关掉SSL警告,本地开发不加这句话控制台会刷一堆红字,虽然不影响运行但很烦人。serverTimezone=Asia/Shanghai是解决MySQL 8.0的时区报错,不加会提示时区无法识别。characterEncoding=utf8是保证传输给数据库的SQL里中文不乱码。这三个参数是MySQL 8.0下的标准配置,我建议你直接抄,后面真遇到问题再逐个研究,省时间。
Class.forName("com.mysql.cj.jdbc.Driver")这一行是注册驱动。很多教程让你"照抄就行",但我想解释清楚:JDBC规范里DriverManager会通过SPI机制自动加载驱动,所以这一行在现代MySQL驱动下其实可以省略。但写上它有个好处——如果驱动JAR包没引入或者版本不对,这行会立刻抛ClassNotFoundException,让你第一时间知道是依赖问题而不是代码问题。所以我建议保留,它是个很好的"烟雾报警器"。
3.2 DAO层的CRUD实操
DAO层是整个项目代码量最大的部分,也是锤炼SQL功力的地方。我先说一个原则,这个原则值得你用荧光笔划出来:永远使用PreparedStatement,永远不要用Statement拼接SQL字符串。
// 错误示范:SQL拼接,存在注入风险 String sql = "SELECT * FROM book WHERE book_name = '" + name + "'"; // 正确写法:预编译,参数用?占位 String sql = "SELECT * FROM book WHERE book_name = ?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, name); ResultSet rs = ps.executeQuery();为什么拼接有风险?因为用户输入的内容会被当成SQL代码执行。假设查询条件输入' OR 1=1 --,拼接后的SQL变成WHERE book_name = '' OR 1=1 -- ',恒真条件会把整张表全查出来。这就是SQL注入。PreparedStatement用预编译机制把参数值跟SQL结构分离,用户输入永远是数据而不是代码,从根上杜绝了这个问题。你以后做任何项目、用任何数据库访问框架,这个安全意识都是一脉相承的。
我以BookDaoImpl为例,展示两个最有代表性的方法。先说查询所有图书:
@Override public List<Book> findAll() { List<Book> list = new ArrayList<>(); String sql = "SELECT book_id, book_name, author, publisher, price, stock, category FROM book"; Connection conn = null; PreparedStatement ps = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); ps = conn.prepareStatement(sql); rs = ps.executeQuery(); while (rs.next()) { Book book = new Book(); book.setBookId(rs.getInt("book_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")); book.setCategory(rs.getString("category")); list.add(book); } } catch (SQLException e) { e.printStackTrace(); } finally { DBUtil.close(rs, ps, conn); } return list; }再说新增图书,这里有个关键点:自增主键怎么拿回来。很多时候你新增完一本书,马上要用这本书的book_id去插入借阅记录,所以得在插入后把自增ID取出来:
@Override public int insert(Book book) { String sql = "INSERT INTO book(book_name, author, publisher, price, stock, category) VALUES(?,?,?,?,?,?)"; Connection conn = null; PreparedStatement ps = null; try { conn = DBUtil.getConnection(); // 第二个参数 RETURN_GENERATED_KEYS 是关键 ps = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS); ps.setString(1, book.getBookName()); ps.setString(2, book.getAuthor()); ps.setString(3, book.getPublisher()); ps.setBigDecimal(4, book.getPrice()); ps.setInt(5, book.getStock()); ps.setString(6, book.getCategory()); ps.executeUpdate(); ResultSet keys = ps.getGeneratedKeys(); if (keys.next()) { return keys.getInt(1); } return 0; } catch (SQLException e) { e.printStackTrace(); return 0; } finally { DBUtil.close(null, ps, conn); } }prepareStatement的第二个参数RETURN_GENERATED_KEYS是告诉数据库"插入完成后把生成的主键还给Java端"。这个技巧在以后用MyBatis时也有对应的体现,就是useGeneratedKeys属性,原理一模一样。你现在把这个机制理解了,后面学框架那是一通百通。
3.3 Service业务层与Servlet控制层
DAO层写完,Service层重点写"规则"。还书这个场景是练Service最好的例子:还书时先查借阅记录是否存在、是否已经还过;如果借了多本书要逐本处理;还书成功后更新库存和return_time。这些跨多张表、带判断条件的逻辑如果在Servlet里写,代码会又长又乱,放进Service才是正路。
public class BorrowServiceImpl implements BorrowService { private BorrowDao borrowDao = new BorrowDaoImpl(); private BookDao bookDao = new BookDaoImpl(); @Override public boolean borrowBook(int userId, int bookId) { // 先检查库存 Book book = bookDao.findById(bookId); if (book == null || book.getStock() <= 0) { return false; } // 再检查该读者是否已经借了这本书没还 BorrowRecord exists = borrowDao.findActiveByUserAndBook(userId, bookId); if (exists != null) { return false; } // 扣库存 + 插入借阅记录,这里真实项目要加事务,见下文 bookDao.decreaseStock(bookId); borrowDao.insert(userId, bookId); return true; } }你仔细看这个逻辑,它就是真实业务开发的缩影。这里头还有一个事务问题我必须专门讲:decreaseStock和insert是两个独立的数据库操作,如果insert失败了但decreaseStock成功了,库存扣了但没借书记录,数据就不一致了。解决办法是让这两个操作处于同一个数据库事务里,要么全成功要么全回滚。
JDBC里实现事务很简单:在操作前conn.setAutoCommit(false),操作完conn.commit(),出异常conn.rollback()。但我上面这个BorrowServiceImpl里,DAO层每次getConnection拿的都是新连接,两个操作天然不在一个事务里。入门阶段怎么解决?我的要求是:把这两个逻辑合并到一个DAO方法borrowTransaction里,用同一个Connection完成两个操作。写起来不优雅,但目的是让你理解事务的本质——"多个数据库操作要么一起成功、要么一起失败"。理解了这个,你学Spring的@Transactional注解就是水到渠成的事。
Servlet层是MVC里的Controller,它的职责就是:取参数、调Service、做页面跳转。一个经典的处理流程如下:
@WebServlet("/book") public class BookServlet extends HttpServlet { private BookService bookService = new BookServiceImpl(); @Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String op = req.getParameter("op"); if (op == null) op = "list"; switch (op) { case "list": listBooks(req, resp); break; case "toEdit": toEdit(req, resp); break; case "add": addBook(req, resp); break; case "update": updateBook(req, resp); break; case "delete": deleteBook(req, resp); break; default: listBooks(req, resp); } } // 各方法实现省略 }我习惯重写service方法,在它里面做个简单的请求分发,用op参数区分不同操作。这其实就是Front Controller的前身,等你以后学了Spring MVC,会发现@RequestMapping就是干这件事的,只不过Spring把分发逻辑做成了框架。你在Servlet里手写过一遍,再回看Spring MVC的HandlerMapping、HandlerAdapter,会非常有亲切感。
跳转方式这里还有一个高频考点:request.getRequestDispatcher("xxx.jsp").forward()和resp.sendRedirect("xxx")有什么区别,什么时候用哪个。我的规则很简单:执行完查询需要带数据到页面上展示的,用forward转发,因为request域里的参数和数据在转发后还能用;执行完增删改之后,一定要用redirect重定向,否则用户刷新页面表单会重复提交,导致重复插入数据。这个"表单重复提交"问题,是图书管理项目里最容易被新手撞出来的坑之一,后面我详细说。
3.4 前端页面与JSP交互
JSP页面是这个项目里最"看得见"的部分。零基础学员做页面最容易犯的错是追求CSS美观,花三天调样式,把后端逻辑晾在一边。我的建议是:能用Bootstrap就用Bootstrap,把精力放在JSP跟Servlet的数据交互上。页面丑一点没关系,逻辑通了才是本事。
JSP的核心语法就几个:<% %>脚本片段、<%= %>输出表达式、JSTL标签库。我强烈建议你从一开始就学习使用JSTL和EL表达式,而不是在JSP里嵌大段<% %>Java代码。道理很直白:JSP是视图层,职责是展示数据,你要是把Java逻辑全塞进去,就违背了分层原则,并且页面会混乱到无法维护。
在pom.xml中引入JSTL依赖:
<dependency> <groupId>javax.servlet</groupId> <artifactId>jstl</artifactId> <version>1.2</version> </dependency>然后在JSP页面顶部声明:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fmt" uri="http://java.sun.com/jsp/jstl/fmt" %>图书列表页用c:forEach循环渲染整张表:
<table class="table table-bordered"> <thead> <tr> <th>编号</th> <th>书名</th> <th>作者</th> <th>出版社</th> <th>价格</th> <th>库存</th> <th>操作</th> </tr> </thead> <tbody> <c:forEach items="${bookList}" var="book"> <tr> <td>${book.bookId}</td> <td>${book.bookName}</td> <td>${book.author}</td> <td>${book.publisher}</td> <td><fmt:formatNumber value="${book.price}" type="currency"/></td> <td>${book.stock}</td> <td> <a href="book?op=toEdit&bookId=${book.bookId}">编辑</a> <a href="book?op=delete&bookId=${book.bookId}" onclick="return confirm('确定删除吗?')">删除</a> </td> </tr> </c:forEach> </tbody> </table>注意借书/还书页面的信息提示。我一般要求学员在Session或request域里放一个message属性,操作成功或失败后由Servlet设置消息并转发到对应页面,页面里用c:if判断有没有消息再展示。这样用户体验会好很多,也让学员明白"页面和后台怎么通过域对象通信"。
登录功能是权限控制的核心前提。登录Servlet里验证用户名密码后,把userId和role存进Session,然后过滤器检查每个请求的Session状态。过滤器Filter是这个项目里很容易被忽略但极其重要的一环。写一个LoginFilter,拦截所有/admin/*路径,Session里没有user对象就重定向到登录页,这是最基础的认证方式。你以后学Spring Security、Shiro,第一课讲的还是这个套路。
4. 常见问题排查与项目部署
4.1 环境与启动阶段的典型问题
环境问题占据了零基础学员至少三成的报错时间,我把高频问题列个速查表,全是实战里反复出现的。
| 现象 | 原因 | 解决方案 |
|---|---|---|
| Tomcat启动一闪而过 | JAVA_HOME没配或配错 | 命令行检查java -version,重新配置环境变量 |
| 端口8080被占用 | 别的服务占了端口 | 改Tomcat的server.xml端口,或找到占用端口的进程结束它 |
| 404错误 | 访问路径跟Servlet注解不匹配 | 核对@WebServlet("")里的URL和页面请求地址完全一致 |
| ClassNotFoundException: cj.jdbc.Driver | MySQL驱动JAR没引入 | pom.xml添加mysql-connector-java依赖,检查依赖是否下载成功 |
| 控制台中文乱码 | 页面或连接串没指定编码 | JSP加contentType=UTF-8,连接串加characterEncoding=utf8 |
端口占用这个问题我展开讲讲排查方法。在命令行执行netstat -ano | findstr 8080,能看到占用8080端口的进程PID,再去任务管理器里找到对应进程结束掉。如果那是个系统进程或者你不敢动,就直接改Tomcat端口:编辑conf/server.xml里Connector标签的port属性,改成8081,重启Tomcat就行。很多人卡在这还以为是代码问题,浪费大量时间,其实先检查环境才是最快的。
JDK和Tomcat版本不匹配也容易出问题。Tomcat 9对应JDK 8及以上,Tomcat 10对应JDK 11及以上,并且Tomcat 10把包名从javax.servlet改成了jakarta.servlet,你要是用Tomcat 10跑旧教程的代码,编译直接报错找不到包。我之前就犯过这个错,给学员配了最新的Tomcat 10,结果教材里的Servlet全编译不过,折腾半天才反应过来是版本问题。所以零基础阶段我统一用Tomcat 9 + JDK 8,最稳。
4.2 数据库连接与SQL执行问题
数据库相关的报错,九成以上集中在驱动找不到、时区错误、SQL语法错误这三类。驱动找不到就是上一节说的依赖问题,时区错误在连接串里加serverTimezone=Asia/Shanghai解决。SQL语法错误则需要细心调试,我建议学员先把SQL语句在Navicat或命令行里单独执行一遍,确认无误再贴回代码。用PreparedStatement时还要注意参数占位符的数量和setXxx的顺序必须一致,多一个少一个都会在executeQuery时报参数索引无效。
还有一个特别隐蔽的问题:MySQL 8.0默认使用caching_sha2_password认证插件,而某些老版本的驱动不支持这个认证方式,连接时会报Public Key Retrieval is not allowed。解决方案是在连接串加allowPublicKeyRetrieval=true。这个参数我第一次遇到时也查了很久,因为提示信息完全没有指向这个原因。你如果也遇到这个报错,直接加上参数就行。
资源的关闭顺序也是要养成的好习惯。ResultSet、PreparedStatement、Connection这三者必须逆序关闭,先关结果集再关语句最后关连接。只使用我在DBUtil里提供的方法,可以帮你少写很多代码。有的同学图省事,Connection不关,等连接池把连接耗尽时,SQL执行就卡住或者报连接数超限。本地开发可能感觉不明显,但一旦部署到服务器上,那是分分钟把数据库搞挂的节奏。
4.3 中文乱码问题的根源与根治
中文乱码是JavaWeb新手必遇的坑,而且往往不止遇到一次。我总结一下,乱码就三个来源:请求参数乱码、响应输出乱码、数据库存取乱码。
请求参数乱码分两种请求方式。POST请求在读取参数之前执行req.setCharacterEncoding("UTF-8")就能解决,注意必须在第一次读取参数前设置,放在Servlet方法的第一行最保险。GET请求的参数是拼在URL里的,Tomcat 8及以上默认用UTF-8解码,基本不出问题,但如果遇到乱码,需要改Tomcat的conf/server.xml里Connector加URIEncoding="UTF-8"属性。
响应输出乱码的解决办法是JSP页面顶部写pageEncoding="UTF-8"和contentType="text/html;charset=UTF-8",Servlet里写resp.setContentType("text/html;charset=UTF-8")。数据库存取乱码,建库时指定utf8mb4、连接串加characterEncoding=utf8、表字段也用utf8mb4字符集,三者配合才能保证中文从浏览器到数据库再回来的完整链路不乱码。
我见过一个特别典型的场景:页面输入中文,新增到数据库里是正常的????问号,查出来也是问号。很多人第一反应是改代码,但真正的原因就是建库时没指定字符集,数据库默认用了latin1。所以建库语句我从一开始就让你加上DEFAULT CHARACTER SET utf8mb4,就是为了从源头把这个坑堵死。
4.4 部署打包与Spring Boot升级路线
项目开发完,总要把WAR包丢到Tomcat里跑起来才算真正结束。IDEA里Build Artifact,选择war exploded,会输出WAR包到target目录。把WAR包复制到Tomcat的webapps目录,重启Tomcat,它会自动解压部署。访问地址就是http://localhost:8080/项目名/。这一步做完,你才算完成了从"代码能跑"到"部署能跑"的跨越,也更能理解真实项目里运维同学在做什么。
我建议你有余力的话,把这个项目用Spring Boot重写一遍,作为进阶挑战。改造思路很清晰:用Spring Boot的Web Starter替代Tomcat嵌入式服务器,用Spring MVC替代手写Servlet,用MyBatis替代JDBC,用Thymeleaf或Vue替代JSP。业务逻辑你完全不用大改,BookService接口基本能直接搬过去,这正好能让你验证"分层设计的好处"——当框架换了,业务层代码还能保留大部分。这个升级过程做完,你对JavaWeb的理解就完整了。
最后再分享一个小技巧。很多学员问我"做完了图书管理项目,下一步做什么"。我的建议是不要急着开新项目,而是拿着这个已经跑通的项目做三件事:第一,把所有SQL改成预编译并解释为什么;第二,给密码加盐做MD5存储;第三,模拟一个并发借同一本书的场景,观察数据会不会出错。这三件事做完,你对这个项目理解深度会翻一倍,面试聊起来也更有底气。别贪多,把一个项目吃透,比同时刷十个半吊子项目强太多。