JavaWeb实战:从零构建图书管理系统,深入理解Servlet与JDBC
发布时间:2026/10/18 6:23:34 锦皓数字建站

1. 为什么一个“简单”的图书管理系统值得认真做很多人看到“图书管理系统”这几个字第一反应是“这不就是学生作业吗”。我带过不少刚入行的开发者也帮朋友看过不少简历项目发现一个挺有意思的现象真正能把一个图书管理系统讲清楚的人往往比那些张口就是“高并发秒杀”“分布式架构”的人更容易拿到offer。原因很简单图书管理系统虽然业务不复杂但它把JavaWeb开发里最核心的那条链路完整地串了一遍——从数据库表设计、JDBC操作、Servlet请求处理到JSP页面渲染、会话管理、表单校验一个都不少。这个项目适合谁如果你是刚学完Java基础、正在啃Servlet和JSP的初学者它能帮你把零散的知识点串成一条线如果你是有一定经验但没完整做过CRUD项目的开发者它能让你重新审视那些被框架封装掉的底层细节。我个人的看法是在Spring Boot和MyBatis大行其道的今天用原生JavaWeb做一遍图书管理系统反而能让你更清楚“框架到底帮你做了什么”。这篇文章不会只给你一堆代码让你复制粘贴。我会从表结构设计开始一步步拆解每个环节背后的思考逻辑包括为什么这么设计、参数怎么定、哪些地方容易踩坑。你跟着走一遍应该能独立做出一个能跑、能看、能讲清楚的项目。2. 项目整体设计与技术选型思路2.1 功能边界怎么划别一上来就想做“大而全”我见过太多人做图书管理系统一开始雄心勃勃要加借阅、预约、罚款、推荐、评论、权限分级……结果做到一半就烂尾了。我的建议是第一版只做三个核心模块图书管理、用户管理、借阅管理。图书管理负责增删改查用户管理负责登录注册和基本信息维护借阅管理负责借书还书和借阅记录查询。这三个模块覆盖了CRUD的全部形态也涉及了多表关联查询足够你理解JavaWeb的核心流程。为什么不做权限分级因为一旦引入角色概念你就得设计角色表、权限表、角色权限关联表还要在每次请求时做拦截判断。这些内容不是不能做而是会分散你对核心链路的注意力。等你把基础版本跑通了再往上加角色控制那时候你已经有足够的体感去理解为什么需要拦截器、为什么需要过滤器。2.2 技术栈选择原生ServletJSPJDBC还是上框架这个问题我被问过很多次。我的回答很直接如果你是为了学习JavaWeb的底层原理就用原生ServletJSPJDBC如果你是为了快速出活那就直接上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容器的事实标准有人会问为什么不用JSPServletJavaBean的经典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 driverClassNamecom.mysql.cj.jdbc.Driver urljdbc:mysql://localhost:3306/library?useSSLfalseserverTimezoneAsia/ShanghaicharacterEncodingutf8 usernameroot passwordyour_password initialSize5 maxActive20 maxWait3000工具类的核心方法就两个getConnection()和close()。getConnection()从连接池拿连接close()把连接还回去。注意这里的close不是真的关闭物理连接而是归还到池里。DAO层的写法我推荐用泛型基类具体实现的方式。基类里封装通用的增删改查方法具体DAO继承基类并实现特定查询。比如public abstract class BaseDaoT { 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 ListT query(String sql, RowMapperT mapper, Object... params) { // 执行SELECT并映射结果 } }RowMapper是一个函数式接口负责把ResultSet的一行映射成一个对象。这样写的好处是每个具体DAO只需要关注SQL和映射逻辑不用重复写连接管理代码。4.2 图书管理模块增删改查的完整实现图书管理的核心是CRUD。我们以“添加图书”为例走一遍完整流程。前端页面addBook.jsp一个表单包含ISBN、书名、作者、出版社、价格、库存、分类等字段。表单提交到/book?actionadd。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?actionlist); } 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。这里有一个关键决策密码怎么存我强烈建议不要存明文。正确的做法是用户注册时生成一个随机盐值比如16位随机字符串。把盐值和密码拼接用SHA-256计算哈希值。把盐值和哈希值分别存入数据库。登录时先根据用户名查出盐值和哈希值然后用同样的方式计算输入密码的哈希值比对是否一致。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标签中加URIEncodingUTF-8。场景二响应输出的中文乱码。在Servlet中设置resp.setContentType(text/html;charsetUTF-8)。如果是JSP页面在页面顶部加% page contentTypetext/html;charsetUTF-8 languagejava %。场景三数据库存储的中文乱码。检查数据库和表的字符集是否为utf8mb4检查JDBC连接URL中是否加了characterEncodingutf8。如果还不行检查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 11就能绕过登录验证。正确的做法是使用PreparedStatementString 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确保事务中所有操作共用同一个ConnectionSession失效超时时间太短、浏览器禁用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里直接写% %脚本段查数据库结果页面又慢又难维护。后来改用ServletJSP的模式JSP只负责展示逻辑全部放在Servlet和Service中。这个项目虽然叫“简单”的图书管理系统但如果你认真做一遍把每个环节的原理都搞清楚收获绝对不比做一个“复杂”的分布式项目少。关键不在于项目大小而在于你是否真正理解了每一行代码背后的逻辑。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。