ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

JSP+Servlet+MySQL实战:图书管理系统开发与排错指南

JSP+Servlet+MySQL实战:图书管理系统开发与排错指南 简介一套JavaJSPServletMySQL实现的Web图书管理系统完整源码包面向Java Web初学者、课程设计与毕业设计者可帮助理解Servlet请求处理、JSP视图渲染、JDBC操作MySQL及MVC分层等核心链路。资源为zip压缩包共102个文件包含26个JSP动态页面、13个Java源码、26个编译后class、12个CSS与12个JS前端文件另附SQL建表脚本、XML配置及依赖JAR整体仅4.42MB。已有5540人学习下载。包内覆盖图书增删改查、读者管理、借还书流程等核心业务配套数据库脚本可直接初始化表结构便于快速运行调试。对想从零梳理Java Web项目结构、补齐前后端协作与数据库设计细节的读者是一份实用的参考实践。1. 为什么还要拆开 JSP Servlet MySQLJSP 页面渲染、Servlet 接收请求、MySQL 存数据这套 JavaJSPServletMySQL 的图书管理系统几乎是每个 Web 后端入门时都会撞上的第一个完整项目。没有 Spring 帮你接管对象创建和事务BookAction、BookDao、IODao、ReaderDao 这些类之间的调用关系必须自己搭反而能把 MVC 的每个环节看清楚。它对两类人有实际价值一类是想弄明白 HTTP 请求从浏览器到数据库再回页面的完整路径的人另一类是维护老项目、整天和 JSP 编译产物、JDBC 连接、Session 丢失打交道的人。下面按数据层、控制层、视图层、部署排错的顺序把代码和容易踩的坑放在一起拆。2. MySQL 表设计与 JDBC 数据层BookDao 和 ReaderDao 的落地2.1 图书、读者、借阅记录各自成表设计这类型系统时我一般先把业务实体拆成三张表book、reader、borrow_record。book 存图书基本信息reader 存读者borrow_record 存借还记录。也可以用视图联查但初版不建议用视图后期要加字段或改索引直接改表结构比改视图可维护。下面是常用的 MySQL 建表语句。CREATE DATABASE IF NOT EXISTS library DEFAULT CHARSET utf8mb4; USE library; CREATE TABLE book ( id INT AUTO_INCREMENT PRIMARY KEY, book_name VARCHAR(128) NOT NULL COMMENT 书名, author VARCHAR(64) DEFAULT , publisher VARCHAR(128) DEFAULT , isbn VARCHAR(32) DEFAULT , total INT NOT NULL DEFAULT 1 COMMENT 总册数, remaining INT NOT NULL DEFAULT 1 COMMENT 剩余可借册数, create_time DATETIME DEFAULT CURRENT_TIMESTAMP ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE reader ( id INT AUTO_INCREMENT PRIMARY KEY, reader_no VARCHAR(32) NOT NULL UNIQUE COMMENT 借书证号, real_name VARCHAR(64) NOT NULL, phone VARCHAR(20) DEFAULT , status TINYINT DEFAULT 1 COMMENT 1启用 0停用 ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE borrow_record ( id INT AUTO_INCREMENT PRIMARY KEY, book_id INT NOT NULL, reader_id INT NOT NULL, borrow_time DATETIME DEFAULT CURRENT_TIMESTAMP, return_time DATETIME NULL, KEY idx_book_id (book_id), KEY idx_reader_id (reader_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;这段 SQL 里有几个参数值得注意。DEFAULT CHARSET utf8mb4比 utf8 更能容纳生僻字和特殊符号JSP 页面表单提交书名时少一个乱码来源。remaining字段单独维护是为了借书时UPDATE book SET remaining remaining - 1 WHERE remaining 0这一步能做原子扣减避免先 SELECT 再 UPDATE 带来的并发超借。borrow_record 上只建了简单的单列索引借还查询通常按 book_id 和 reader_id 过滤这两个索引够用如果报表要统计某个时间段的借阅量再补(borrow_time)索引不要一开始把所有索引都加上。表名核心字段对应 DAO主要用途bookid, book_name, remainingBookDao图书信息与库存readerid, reader_no, statusReaderDao读者信息与状态borrow_recordbook_id, reader_idIODao借书、还书流水提示读者表里的reader_no加了 UNIQUE业务上借书证号应该唯一这也天然给 DAO 的findByNo查询提供了唯一约束。2.2 用 JDBC 封装 DAO而不是把 SQL 散落在 JSP 里在经典 JSPServlet 项目里最典型的问题就是 JSP 页面里直接写% Class.forName(com.mysql.jdbc.Driver) ... %这种写法在页面首次加载时创建连接刷新几次就报 Too many connections。正确做法是单独抽一个 DBUtil再让 BookDao、ReaderDao、IODao 去调用它。下面是一个基础版 DBUtil。package com.library.util; import java.sql.Connection; import java.sql.DriverManager; import java.sql.SQLException; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/library?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/Shanghai; private static final String USER root; private static final String PASSWORD 123456; static { try { Class.forName(com.mysql.cj.jdbc.Driver); } catch (ClassNotFoundException e) { throw new ExceptionInInitializerError(e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } }连接参数里有三处和排查问题强相关。useUnicodetruecharacterEncodingutf8保证中文参数从请求到数据库不乱码MySQL 8 的驱动类名是com.mysql.cj.jdbc.Driver旧的com.mysql.jdbc.Driver在 MySQL 8 下已经废弃serverTimezoneAsia/Shanghai是解决 JDBC 连 MySQL 8 时报时区错误的常用参数。USER和PASSWORD只是示例生产环境不应该硬编码在 Java 类里可以放到 Tomcat 的 JNDI 数据源或环境变量里。DAO 的基础是对表做增删改查。以 BookDao 的列表查询为例。package com.library.dao; import com.library.model.Book; import com.library.util.DBUtil; import java.sql.*; import java.util.ArrayList; import java.util.List; public class BookDao { public ListBook findBookList(int offset, int limit) { String sql SELECT id, book_name, author, publisher, isbn, total, remaining FROM book WHERE remaining 0 LIMIT ?, ?; ListBook list new ArrayList(); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setInt(1, offset); ps.setInt(2, limit); try (ResultSet rs ps.executeQuery()) { while (rs.next()) { Book book new Book(); book.setId(rs.getInt(id)); book.setBookName(rs.getString(book_name)); book.setAuthor(rs.getString(author)); book.setTotal(rs.getInt(total)); book.setRemaining(rs.getInt(remaining)); list.add(book); } } } catch (SQLException e) { e.printStackTrace(); } return list; } }这里特意用了PreparedStatement而不是Statement因为书名里如果有单引号或者用户输入了 or 11PreparedStatement会把它当普通字符串处理这是防 SQL 注入的第一道闸门。LIMIT ?, ?里的两个问号由setInt(1, offset)和setInt(2, limit)绑定注意LIMIT的偏移量从 0 开始第一页的 offset 是 0下一页按(page - 1) * 10计算。try-with-resources写法让 Connection、PreparedStatement、ResultSet 在方法结束后自动关闭避免连接泄漏。2.3 借还书操作需要事务不能只做单条 INSERT图书管理系统表面上都是单表操作但借书至少涉及两张表往 borrow_record 插入一条记录同时把 book 表的 remaining 减一。如果第一条 INSERT 成功第二条 UPDATE 失败库存就错了。所以 IODao 的借书方法应该在一个事务里完成。public boolean borrowBook(int bookId, int readerId) { String insertRecord INSERT INTO borrow_record(book_id, reader_id) VALUES(?, ?); String decreaseBook UPDATE book SET remaining remaining - 1 WHERE id ? AND remaining 0; try (Connection conn DBUtil.getConnection()) { conn.setAutoCommit(false); try (PreparedStatement ps1 conn.prepareStatement(insertRecord); PreparedStatement ps2 conn.prepareStatement(decreaseBook)) { ps1.setInt(1, bookId); ps1.setInt(2, readerId); int row1 ps1.executeUpdate(); ps2.setInt(1, bookId); int row2 ps2.executeUpdate(); if (row1 1 row2 1) { conn.commit(); return true; } else { conn.rollback(); return false; } } catch (SQLException e) { conn.rollback(); e.printStackTrace(); return false; } } catch (SQLException e) { e.printStackTrace(); return false; } }conn.setAutoCommit(false)是事务开始的标志之后所有 SQL 都在同一事务里直到commit()或rollback()。注意decreaseBook里的AND remaining 0是最后一道防线就算并发请求同时执行也只有一条 UPDATE 能成功返回值row2为 0 就走回滚。如果项目里有人把借书逻辑写成 MySQL 存储过程也不是不行但存储过程的参数校验和调试都不如 JDBC 直观遇到问题还得 DB 与 Java 两边查我一般不推荐在课程设计里主动用。3. Servlet 控制层与 MVC 路由BookAction 如何把 HTTP 请求转成业务动作3.1 用 BaseAction 统一编码和分发Servlet 的生命周期由 Tomcat 管理开发者重写的是doGet和doPost这两个方法对应 HTTP 的 GET 和 POST。为了统一处理编码我先写一个 BaseAction。package com.library.action; import javax.servlet.ServletException; import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; public class BaseAction extends HttpServlet { Override protected void service(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); super.service(req, resp); } }这里的super.service(req, resp)会根据请求方法调用对应的 doGet 或 doPost。req.setCharacterEncoding(UTF-8)处理的是 POST 请求体的编码GET 请求的 query string 编码不受它控制需要额外检查 Tomcat 的 connector 配置这是 JSP 页面显示正常但搜索关键字乱码的第一步排查点。3.2 BookAction 里的路由映射BookAction 继承 BaseAction并按照路径后缀分发。配置WebServlet(/book/*)后/book/list和/book/edit都会进入同一个 Servlet。package com.library.action; import com.library.dao.BookDao; import com.library.model.Book; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import java.io.IOException; import java.util.List; public class BookAction extends BaseAction { private final BookDao bookDao new BookDao(); Override protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String path req.getPathInfo(); if (/list.equals(path)) { listBooks(req, resp); } else if (/edit.equals(path)) { showEditPage(req, resp); } else { resp.sendError(404); } } Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String path req.getPathInfo(); if (/save.equals(path)) { saveBook(req, resp); } else { resp.sendError(404); } } private void listBooks(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { String pageParam req.getParameter(page); int page pageParam null || pageParam.isEmpty() ? 1 : Integer.parseInt(pageParam); int pageSize 10; req.setAttribute(bookList, bookDao.findBookList((page - 1) * pageSize, pageSize)); req.getRequestDispatcher(/WEB-INF/jsp/book_list.jsp).forward(req, resp); } }req.getPathInfo()拿到的是WebServlet里的通配符之后的路径。真正的 JSP 放在/WEB-INF下用户不能直接访问必须由 Servlet forward 进去这比把 JSP 放在 webapp 根目录更安全。page参数我加了空值判断但没有做数字类型校验实际项目里应该用 try-catch 包一层避免手输pageabc时直接 500。请求路径HTTP 方法处理动作返回方式/book/listGET查询列表forward 到 book_list.jsp/book/edit?id3GET查询单本图书forward 到 book_edit.jsp/book/savePOST新增或更新sendRedirect 回列表/reader/listGET查询读者forward 到 reader_list.jsp/borrow/addPOST借书事务sendRedirect 回借阅页3.3 POST 成功后要重定向而不是 forward很多刚上手的人在这里踩坑/book/save处理完直接forward到列表页看起来数据正常但浏览器地址栏仍然是/book/save用户按 F5 会重复提交同一本书被插入两条。正确做法是private void saveBook(HttpServletRequest req, HttpServletResponse resp) throws IOException { String bookName req.getParameter(bookName); String author req.getParameter(author); // 省略 Book 对象组装和 BookDao.insertBook 调用 resp.sendRedirect(req.getContextPath() /book/list); }sendRedirect会先返回一个 302 响应浏览器再发起 GET 请求访问/book/list这就是 Post/Redirect/Get 模式。它和 forward 的关键区别在于浏览器是否重新发了一次请求forward 是服务器内部跳转URL 不变sendRedirect 是客户端跳转URL 变成新地址刷新也不会重复提交。如果项目容器是 Tomcat 10注意javax.servlet包名已经改为jakarta.servlet代码 import 要同步调整否则容器启动直接抛 ClassNotFound。4. JSP 视图层实战用 EL/JSTL 渲染数据Session 记录登录态4.1 JSP 里的三件事渲染、循环、取值JSP 的作用是视图渲染不是业务处理。图书列表页的典型写法是用 JSTL 的c:forEach遍历 Servlet 放进 request 里的集合。% page contentTypetext/html; charsetUTF-8 pageEncodingUTF-8 % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html body table border1 trth书名/thth作者/thth剩余/thth操作/th/tr c:forEach items${bookList} varbook tr td${book.bookName}/td td${book.author}/td td${book.remaining}/td tda hrefedit?id${book.id}编辑/a/td /tr /c:forEach /table /body /html${book.bookName}不是直接访问字段而是调用 Book 类的getBookName()所以 Book 模型里必须写对应的 getter/setter。c:forEach的items接收 List 或数组var是循环变量名。如果页面报urihttp://java.sun.com/jsp/jstl/core cannot be resolved说明项目里缺少 JSTL 依赖Maven 项目需要加dependency groupIdjavax.servlet/groupId artifactIdjstl/artifactId version1.2/version /dependency不要为了省依赖在 JSP 里写% for (...) { %。脚本片段和 HTML 混编后JSP 引擎把它翻译成 Java 类时变量作用域容易互相污染页面报错也只能看到编译后的行号调试成本很高。4.2 Session 与 Cookie 的边界要划清登录模块是这个系统里最需要理清状态的地方。登录成功后在 Servlet 里写入 Session。req.getSession().setAttribute(loginUser, reader); req.getSession().setMaxInactiveInterval(30 * 60); // 30 分钟失效前端 JSP 判断当前是否登录可以用 EL 配合 JSTL 的条件标签。c:if test${empty loginUser} a hreflogin.jsp请登录/a /c:if c:if test${not empty loginUser} 欢迎${loginUser.realName} /c:ifSession 默认存在服务端通过JSESSIONID这个 Cookie 和浏览器关联。如果用户禁用了 Cookie每次请求都会新建 Session表现为登录后立刻跳回登录页。这个问题在本地浏览器很难复现放到一个权限要求严格的网络环境里会非常明显。存储位置有效期典型用途对应的 JSP 取值request一次请求内Servlet 向 JSP 传列表${bookList}session浏览器会话或超时时间登录用户信息${loginUser}application应用生命周期全局配置项${appName}不要拿 Session 存大对象比如把整个图书列表塞进去那只会让服务器内存变大。课程设计里有人习惯把查询结果放进 Session 来“优化”其实是把并发用户的问题变成了内存泄漏的前奏。4.3 表单提交后回显的细节图书编辑页通常要从 Servlet 把查到的 Book 对象放进 request然后在 JSP 的 input 里回显。input typetext namebookName value${book.bookName} / input typetext nameauthor value${book.author} /这个场景里有一个坑如果上一步保存失败 forward 回表单页表单里的值应该保留用户刚输入的内容而不是重新从数据库查。常见做法是保存失败时把提交的参数封装回 Book 对象再req.setAttribute(book, book)返回。如果保存成功则 sendRedirect 到列表页不要再把对象留在 request 里。5. 部署、排错与两个值得留住的优化技巧5.1 先分清 Tomcat 里的运行边界常见错误“找不到或无法加载主类”出在一个前提错误上有人直接把 BookAction 当普通 Java 类用java命令运行。Servlet 没有 main 方法它的实例化和销毁都由 Tomcat 管理正确做法是打成 war 包放到 Tomcat 的webapps目录下启动。JSP 页面第一次被访问时JSP 引擎会把页面翻译成 Servlet编译后的.class保存在 Tomcat 的work/Catalina/localhost/项目名/org/apache/jsp/目录book_list_jsp.java对应book_list.jsp。如果改了 JSP 不生效先看这个目录下文件的时间戳多半是 IDE 没有真正重新部署而不是代码没改。5.2 一张排错表现象优先检查位置ClassNotFoundException: com.mysql.cj.jdbc.Drivermysql-connector jar 是否在 WEB-INF/lib中文乱码页面 contentType、Servlet 编码、JDBC URL 参数、表字符集登录后马上失效JSESSIONID Cookie 是否跟随了正确的域名和上下文路径每次刷新页面都变慢是否每次请求都新建 Connection改用连接池5.3 分页和事务的持久优化老代码里列表页通常用LIMIT offset, size当页码超过几千时 offset 越来越大MySQL 需要扫描前面全部行。可以把分页 SQL 改成WHERE id ? ORDER BY id LIMIT 20的 keyset 分页配合主键索引性能不随页数下降。这个改动只影响 BookDao 的一个方法却比盲目加缓存更实际。我在重构时先把remaining 0写进库存 UPDATE再把 LIMIT 改成 id 条件这两处代码最少、收益最大值得放进你自己的重构清单。本文还有配套的精品资源点击获取
返回列表