ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

JavaWeb实战:从零构建图书管理系统,深入理解Servlet与JDBC

JavaWeb实战:从零构建图书管理系统,深入理解Servlet与JDBC

1. 为什么一个“简单”的图书管理系统值得认真做

很多人看到“图书管理系统”这几个字,第一反应是“这不就是学生作业吗”。我带过不少刚入行的开发者,也帮朋友看过不少简历项目,发现一个挺有意思的现象:真正能把一个图书管理系统讲清楚的人,往往比那些张口就是“高并发秒杀”“分布式架构”的人更容易拿到offer。原因很简单,图书管理系统虽然业务不复杂,但它把JavaWeb开发里最核心的那条链路完整地串了一遍——从数据库表设计、JDBC操作、Servlet请求处理,到JSP页面渲染、会话管理、表单校验,一个都不少。

这个项目适合谁?如果你是刚学完Java基础、正在啃Servlet和JSP的初学者,它能帮你把零散的知识点串成一条线;如果你是有一定经验但没完整做过CRUD项目的开发者,它能让你重新审视那些被框架封装掉的底层细节。我个人的看法是,在Spring Boot和MyBatis大行其道的今天,用原生JavaWeb做一遍图书管理系统,反而能让你更清楚“框架到底帮你做了什么”。

这篇文章不会只给你一堆代码让你复制粘贴。我会从表结构设计开始,一步步拆解每个环节背后的思考逻辑,包括为什么这么设计、参数怎么定、哪些地方容易踩坑。你跟着走一遍,应该能独立做出一个能跑、能看、能讲清楚的项目。

2. 项目整体设计与技术选型思路

2.1 功能边界怎么划:别一上来就想做“大而全”

我见过太多人做图书管理系统,一开始雄心勃勃,要加借阅、预约、罚款、推荐、评论、权限分级……结果做到一半就烂尾了。我的建议是,第一版只做三个核心模块:图书管理、用户管理、借阅管理。图书管理负责增删改查,用户管理负责登录注册和基本信息维护,借阅管理负责借书还书和借阅记录查询。这三个模块覆盖了CRUD的全部形态,也涉及了多表关联查询,足够你理解JavaWeb的核心流程。

为什么不做权限分级?因为一旦引入角色概念,你就得设计角色表、权限表、角色权限关联表,还要在每次请求时做拦截判断。这些内容不是不能做,而是会分散你对核心链路的注意力。等你把基础版本跑通了,再往上加角色控制,那时候你已经有足够的体感去理解为什么需要拦截器、为什么需要过滤器。

2.2 技术栈选择:原生Servlet+JSP+JDBC,还是上框架

这个问题我被问过很多次。我的回答很直接:如果你是为了学习JavaWeb的底层原理,就用原生Servlet+JSP+JDBC;如果你是为了快速出活,那就直接上Spring Boot。但既然标题写的是“JavaWeb实现”,我们这里就老老实实用原生技术栈。

具体选型如下:

技术组件选择理由
前端页面JSP + JSTL与Servlet天然集成,不需要额外学习模板引擎
后端控制Servlet 3.1+理解请求响应模型的最佳入口
数据库MySQL 5.7/8.0最通用的关系型数据库,资料多
数据访问原生JDBC + 手写DAO看清SQL执行的全过程,不被ORM遮蔽
连接池Druid或HikariCP避免每次请求都新建连接,提升性能
构建工具Maven依赖管理标准化,方便导入IDE
服务器Tomcat 8.5/9.0Servlet容器的事实标准

有人会问,为什么不用JSP+Servlet+JavaBean的经典MVC?其实我们用的就是MVC,只是把JavaBean拆成了实体类和DAO两层。实体类负责承载数据,DAO负责数据库操作,Servlet负责流程控制,JSP负责展示。这个分层看起来简单,但它是理解Spring MVC的前置知识。

2.3 分层架构设计:每层只做自己该做的事

我见过不少初学者把所有逻辑都写在Servlet里,一个doPost方法几百行,SQL语句、业务判断、页面跳转全混在一起。这种代码能跑,但没法维护,也没法跟别人讲清楚。正确的做法是分层:

  • 实体层(entity):只定义属性和getter/setter,对应数据库表结构。
  • DAO层(dao):只负责数据库的增删改查,每个方法对应一条或一组SQL。
  • Service层(service):负责业务逻辑,比如借书时判断库存是否充足、用户是否已借过同一本书。
  • 控制层(servlet):负责接收请求参数、调用Service、设置请求属性、转发到JSP。
  • 视图层(jsp):只负责展示数据,不写Java逻辑代码(用JSTL和EL表达式)。

这个分层的好处是,当你需要换数据库时,只需要改DAO层;当你需要改页面时,只需要改JSP;当你需要加业务规则时,只需要改Service。每层的职责边界清晰,调试的时候也能快速定位问题出在哪一层。

3. 数据库设计与核心表结构拆解

3.1 三张核心表:图书、用户、借阅记录

表结构设计是整个项目的地基。地基没打好,后面写代码会越写越别扭。我们先看三张核心表:

图书表(book)

字段名类型约束说明
idINT主键,自增图书唯一标识
isbnVARCHAR(20)唯一,非空国际标准书号
titleVARCHAR(100)非空书名
authorVARCHAR(50)非空作者
publisherVARCHAR(100)可空出版社
priceDECIMAL(10,2)可空价格
stockINT非空,默认0库存数量
categoryVARCHAR(50)可空分类
create_timeDATETIME默认当前时间入库时间

用户表(user)

字段名类型约束说明
idINT主键,自增用户唯一标识
usernameVARCHAR(50)唯一,非空登录名
passwordVARCHAR(100)非空密码(存哈希值)
real_nameVARCHAR(50)可空真实姓名
phoneVARCHAR(20)可空联系电话
roleTINYINT默认00普通用户,1管理员
create_timeDATETIME默认当前时间注册时间

借阅记录表(borrow_record)

字段名类型约束说明
idINT主键,自增记录唯一标识
user_idINT外键,非空借阅人
book_idINT外键,非空借阅图书
borrow_timeDATETIME非空借出时间
return_timeDATETIME可空归还时间
statusTINYINT默认00借出中,1已归还

3.2 字段设计的几个关键决策

为什么ISBN要加唯一约束?因为同一本书可能有多个副本,但ISBN是唯一的。如果你不加唯一约束,同一本书可能被重复录入多次,导致库存数据混乱。当然,如果你要支持多副本,可以把ISBN设为普通索引,用另一个字段区分副本编号。但第一版我建议简化处理,一个ISBN对应一条记录,库存用stock字段表示。

为什么密码字段要设100长度?因为如果你用SHA-256加盐哈希,输出的十六进制字符串是64位,加上盐值可能更长。如果你直接存明文密码,20长度就够了,但那是严重的安全隐患。我在实际项目中见过有人用MD5存密码,虽然比明文好一点,但MD5已经被证明不安全了。建议用SHA-256加随机盐,盐值单独存一个字段。

为什么借阅记录表要单独建,而不是在图书表里加个借阅人字段?因为一本书可以被多个人先后借阅,一个人也可以借多本书,这是典型的多对多关系。用中间表记录每次借阅行为,不仅能保留历史记录,还能方便地查询“某本书被借过几次”“某个人借过哪些书”。

3.3 索引与性能考量

在图书表上,我建议给title和author字段加普通索引,因为搜索功能通常会按书名或作者模糊查询。注意,模糊查询用LIKE '%关键词%'时,前置百分号会导致索引失效,但数据量不大的情况下影响有限。如果数据量上万,可以考虑用全文索引或者搜索引擎。

借阅记录表上,给user_id和book_id分别加索引,因为查询借阅记录时通常会按用户或按图书过滤。另外,status字段也可以加索引,因为查询“当前借出未还”的记录时用得上。

注意:外键约束在开发环境能帮你保证数据一致性,但在高并发场景下可能成为性能瓶颈。第一版建议加上外键,方便理解表之间的关系;如果后续要优化性能,可以考虑去掉外键,在应用层保证一致性。

4. 核心功能模块的实操实现

4.1 数据库连接与DAO层封装

原生JDBC最烦人的就是每次都要写一堆try-catch-finally来关闭连接。我的做法是写一个工具类,把连接获取和释放封装起来。如果你用Druid连接池,配置如下:

# druid.properties driverClassName=com.mysql.cj.jdbc.Driver url=jdbc:mysql://localhost:3306/library?useSSL=false&serverTimezone=Asia/Shanghai&characterEncoding=utf8 username=root password=your_password initialSize=5 maxActive=20 maxWait=3000

工具类的核心方法就两个:getConnection()和close()。getConnection()从连接池拿连接,close()把连接还回去。注意,这里的close不是真的关闭物理连接,而是归还到池里。

DAO层的写法我推荐用泛型基类+具体实现的方式。基类里封装通用的增删改查方法,具体DAO继承基类并实现特定查询。比如:

public abstract class BaseDao<T> { protected Connection getConn() throws SQLException { return DruidUtil.getConnection(); } protected void close(Connection conn, Statement stmt, ResultSet rs) { // 依次关闭rs、stmt、conn } // 通用更新方法 protected int update(String sql, Object... params) { // 执行INSERT、UPDATE、DELETE } // 通用查询方法 protected List<T> query(String sql, RowMapper<T> mapper, Object... params) { // 执行SELECT并映射结果 } }

RowMapper是一个函数式接口,负责把ResultSet的一行映射成一个对象。这样写的好处是,每个具体DAO只需要关注SQL和映射逻辑,不用重复写连接管理代码。

4.2 图书管理模块:增删改查的完整实现

图书管理的核心是CRUD。我们以“添加图书”为例,走一遍完整流程。

前端页面(addBook.jsp):一个表单,包含ISBN、书名、作者、出版社、价格、库存、分类等字段。表单提交到/book?action=add。

Servlet层(BookServlet):在doPost方法中,根据action参数判断操作类型。如果是add,就读取请求参数,封装成Book对象,调用Service层的addBook方法。

protected void doPost(HttpServletRequest req, HttpServletResponse resp) { String action = req.getParameter("action"); if ("add".equals(action)) { Book book = new Book(); book.setIsbn(req.getParameter("isbn")); book.setTitle(req.getParameter("title")); book.setAuthor(req.getParameter("author")); // ... 其他字段 boolean success = bookService.addBook(book); if (success) { resp.sendRedirect(req.getContextPath() + "/book?action=list"); } else { req.setAttribute("msg", "添加失败,ISBN可能已存在"); req.getRequestDispatcher("/addBook.jsp").forward(req, resp); } } }

Service层(BookService):先检查ISBN是否已存在,如果存在就返回false;否则调用DAO插入。

DAO层(BookDao):执行INSERT语句。

public boolean insert(Book book) { String sql = "INSERT INTO book(isbn, title, author, publisher, price, stock, category) VALUES(?,?,?,?,?,?,?)"; return update(sql, book.getIsbn(), book.getTitle(), book.getAuthor(), book.getPublisher(), book.getPrice(), book.getStock(), book.getCategory()) > 0; }

这个流程看起来简单,但有几个细节容易出错。第一,价格字段用Double.parseDouble解析时,如果用户输入空字符串会抛异常,需要做空值判断。第二,库存字段用Integer.parseInt解析时,如果输入非数字也会抛异常。第三,中文乱码问题,需要在Servlet中设置req.setCharacterEncoding("UTF-8"),或者在Tomcat的server.xml中配置URIEncoding。

实操心得:我建议在Service层做参数校验,而不是在Servlet层。因为Servlet层只负责接收参数,业务规则应该集中在Service层。比如“库存不能为负数”“价格不能小于0”这些规则,放在Service层更合适。

4.3 用户登录与Session管理

登录功能的核心是验证用户名和密码,然后把用户信息存入Session。这里有一个关键决策:密码怎么存?我强烈建议不要存明文。正确的做法是:

  1. 用户注册时,生成一个随机盐值(比如16位随机字符串)。
  2. 把盐值和密码拼接,用SHA-256计算哈希值。
  3. 把盐值和哈希值分别存入数据库。

登录时,先根据用户名查出盐值和哈希值,然后用同样的方式计算输入密码的哈希值,比对是否一致。

public static String sha256(String password, String salt) { String combined = password + salt; MessageDigest md = MessageDigest.getInstance("SHA-256"); byte[] digest = md.digest(combined.getBytes(StandardCharsets.UTF_8)); return bytesToHex(digest); }

Session管理方面,登录成功后把User对象存入req.getSession().setAttribute("user", user)。然后在需要登录才能访问的页面或Servlet中,检查Session中是否有user对象。如果没有,就重定向到登录页。

这里有一个常见的坑:Session超时时间。默认是30分钟,可以在web.xml中配置。如果用户正在填写表单,Session突然过期,提交时会跳转到登录页,之前填的数据就丢了。我的做法是在Session快过期时给用户一个提示,或者用AJAX定时刷新Session。

4.4 借书还书:事务处理的典型场景

借书操作涉及两个数据库操作:在借阅记录表中插入一条记录,同时更新图书表中的库存。这两个操作必须在一个事务中完成,否则可能出现“记录插入了但库存没减”或者“库存减了但记录没插入”的情况。

原生JDBC的事务控制流程如下:

Connection conn = null; try { conn = DruidUtil.getConnection(); conn.setAutoCommit(false); // 开启事务 // 操作1:插入借阅记录 borrowDao.insert(conn, record); // 操作2:更新库存 bookDao.decreaseStock(conn, bookId); conn.commit(); // 提交事务 } catch (Exception e) { if (conn != null) { conn.rollback(); // 回滚事务 } throw new RuntimeException("借书失败", e); } finally { if (conn != null) { conn.setAutoCommit(true); conn.close(); } }

注意,这里的所有DAO方法都必须接收同一个Connection对象,不能各自去连接池拿新连接。否则事务控制会失效。这是初学者最容易犯的错误之一。

还书操作类似,只是把插入记录改成更新记录的return_time和status,同时增加库存。

注意:借书前要检查库存是否大于0,还要检查该用户是否已经借了同一本书且未归还。这两个检查必须在事务中完成,否则并发情况下可能出现超借。

5. 常见问题与排查技巧实录

5.1 中文乱码:三种场景三种解法

中文乱码是JavaWeb开发中最常见的问题,没有之一。它可能出现在三个地方:

场景一:表单提交的中文参数乱码。如果是POST请求,在读取参数前设置req.setCharacterEncoding("UTF-8")即可。如果是GET请求,Tomcat 8之后默认URI编码是UTF-8,一般不会乱码;如果是Tomcat 7或更早版本,需要在server.xml的Connector标签中加URIEncoding="UTF-8"。

场景二:响应输出的中文乱码。在Servlet中设置resp.setContentType("text/html;charset=UTF-8")。如果是JSP页面,在页面顶部加<%@ page contentType="text/html;charset=UTF-8" language="java" %>。

场景三:数据库存储的中文乱码。检查数据库和表的字符集是否为utf8mb4,检查JDBC连接URL中是否加了characterEncoding=utf8。如果还不行,检查MySQL的my.ini或my.cnf中的character-set-server配置。

5.2 连接池配置不当导致的性能问题

我见过有人用Druid连接池,但配置的maxActive只有5,结果并发稍微高一点就报“获取连接超时”。连接池的大小怎么定?一个经验公式是:maxActive = CPU核数 * 2 + 磁盘数。对于个人项目,设20就够了。但要注意,maxActive不是越大越好,因为每个连接都会占用数据库的资源。

另外,maxWait参数控制获取连接的超时时间,默认是-1(无限等待)。我建议设为3000毫秒,避免请求线程被无限阻塞。还有validationQuery参数,用来检测连接是否有效,MySQL可以设为SELECT 1。

5.3 SQL注入与预编译语句

这个问题我必须单独拿出来讲。我见过不少初学者用字符串拼接的方式构造SQL:

// 错误示范 String sql = "SELECT * FROM user WHERE username='" + username + "' AND password='" + password + "'";

这种写法存在严重的SQL注入风险。如果用户在密码框输入' OR '1'='1,就能绕过登录验证。正确的做法是使用PreparedStatement:

String sql = "SELECT * FROM user WHERE username=? AND password=?"; PreparedStatement ps = conn.prepareStatement(sql); ps.setString(1, username); ps.setString(2, passwordHash);

PreparedStatement不仅防注入,还能预编译SQL,提升执行效率。这是必须养成的习惯。

5.4 常见问题速查表

问题现象可能原因排查方向
页面404URL路径写错、Servlet映射未配置检查web.xml或@WebServlet注解
页面500空指针、SQL语法错误、类型转换异常看Tomcat日志中的异常堆栈
数据库连接失败驱动类名写错、URL错误、账号密码错误检查druid.properties配置
中文乱码编码设置不一致检查请求、响应、数据库三处编码
事务不回滚多个DAO用了不同Connection确保事务中所有操作共用同一个Connection
Session失效超时时间太短、浏览器禁用Cookie检查web.xml中的session-timeout配置

实操心得:调试JavaWeb项目时,我习惯在关键位置打日志,而不是只靠System.out.println。用日志框架(如Log4j或SLF4J)可以控制日志级别,方便在生产环境关闭调试日志。另外,Tomcat的localhost.log和catalina.log是排查问题的第一手资料,遇到异常先看这两个文件。

6. 项目扩展方向与个人经验分享

6.1 从原生JavaWeb到框架的平滑过渡

当你把这个项目做完之后,可以尝试用Spring Boot重写一遍。你会发现,原来需要手动配置的数据库连接、事务管理、请求映射,Spring Boot都帮你自动配置好了。原来需要手写的DAO层,MyBatis或JPA帮你生成了。但如果你没有经历过原生JavaWeb的“痛苦”,你可能不会真正理解这些框架的价值。

我个人的体会是,先做一遍原生版本,再用框架做一遍,你对“框架解决了什么问题”的理解会深刻得多。比如,你会明白为什么需要依赖注入——因为手动new对象会导致层与层之间耦合太紧;你会明白为什么需要AOP——因为事务控制、日志记录这些横切关注点散落在各处很难维护。

6.2 可以继续深挖的几个方向

如果你还有余力,可以考虑以下几个扩展方向:

方向一:分页查询。当图书数量很多时,一次性查出所有数据会导致页面加载缓慢。分页查询的核心是LIMIT offset, size,配合COUNT(*)查询总记录数。你可以自己写一个PageBean类来封装分页数据。

方向二:文件上传。给图书添加封面图片,涉及文件上传和存储。Servlet 3.0之后可以用@MultipartConfig注解简化文件上传处理。

方向三:拦截器与权限控制。用Filter或Interceptor实现登录拦截,未登录用户不能访问借阅页面。管理员和普通用户看到的功能不同。

方向四:前端交互优化。用AJAX实现无刷新查询和删除,提升用户体验。可以用原生XMLHttpRequest,也可以用jQuery的$.ajax。

6.3 我在实际开发中踩过的坑

最后分享几个我实际踩过的坑,希望能帮你省点时间。

坑一:忘记设置请求编码。我在Servlet中读取中文参数时,经常忘记先调用req.setCharacterEncoding("UTF-8"),结果拿到的全是乱码。后来我养成了一个习惯,在doPost方法的第一行就设置编码。

坑二:连接池连接泄漏。有一次我写了一个DAO方法,在异常分支里忘记关闭连接,结果跑了一段时间后连接池耗尽,所有请求都超时。后来我改用try-with-resources语法,确保连接一定会被关闭。

坑三:事务中混用了不同连接。我在做借书功能时,Service层开启了事务,但DAO层内部又去连接池拿了新连接,导致事务失效。排查了半天才发现问题。记住,事务中的所有数据库操作必须共用同一个Connection。

坑四:JSP中写了太多Java代码。我一开始图方便,在JSP里直接写<% %>脚本段查数据库,结果页面又慢又难维护。后来改用Servlet+JSP的模式,JSP只负责展示,逻辑全部放在Servlet和Service中。

这个项目虽然叫“简单”的图书管理系统,但如果你认真做一遍,把每个环节的原理都搞清楚,收获绝对不比做一个“复杂”的分布式项目少。关键不在于项目大小,而在于你是否真正理解了每一行代码背后的逻辑。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进