☰
纯Java Web图书系统:Servlet+JDBC手写全链路实战
2026/11/2 15:32:53 网站建设 项目流程

简介:本资源是《Java Web数据库系统应用开发与实例》配套学习包,面向Java Web初学者及中级开发者,聚焦Web层与数据库的协同开发实战,解决Servlet/JSP基础薄弱、JDBC连接不熟、MVC项目结构不清等典型痛点。压缩包共884个文件,含163个Java源码(覆盖Servlet、DAO、Service等分层逻辑)、140个JSP页面(含登录、列表、增删改查等完整视图)、181个class字节码、48个jar依赖库、17个SQL脚本(含建表、初始化及事务示例),以及48个AVI视频——涵盖系统演示、共通Servlet设计、报表打印、Servlet原理讲解等核心实操录屏,总大小222.5MB。已有471人学习下载,资源结构清晰,视频与源码严格对应,支持边看边练;所有JSP页面均含注释,SQL脚本可直接导入MySQL执行,配套XML配置与properties参数文件便于快速部署到Tomcat环境,是贯通Java Web全栈开发流程的高实用性入门套件。

1. 这不是又一个学生管理系统:它用纯 Java Web + JDBC 跑通了从登录到报表导出的完整业务闭环,连分页、文件上传、SQL 注入防护都写在源码里

某高校课程设计答辩现场,A同学演示完“图书借阅系统”后被连续追问三个问题:登录失败时错误信息为什么没做脱敏?借书记录导出 Excel 的模板是硬编码还是可配置?管理员删用户前有没有二次确认+操作日志?——他当场翻出本项目源码包里的LoginServlet.java、ExportExcelServlet.java和UserDeleteServlet.java,三处代码逐行指给老师看:异常捕获后统一跳转到/error.jsp并记录log4j日志;Excel 模板路径从config.properties读取;删除前校验 session 权限 + 插入sys_operation_log表。这不是 Demo,是能直接部署进实训机房、经得起学生反复压测的真实 Web 应用骨架。它不依赖 Spring Boot 自动装配,不包装 MyBatis 封装层,所有 JDBC 连接、PreparedStatement 参数绑定、ResultSet 映射、事务控制全手写——适合刚学完 Servlet 生命周期、正卡在“怎么把数据库查出来的数据塞进 JSP 表格里”这一步的新手;也适合想带学生抠透“一次 HTTP 请求背后到底发生了多少次对象创建和资源释放”的实训导师。源码含完整视频讲解(含 Tomcat 部署、MySQL 建库脚本、JDBC 驱动版本踩坑),不是 PPT 录屏,是真实敲键盘录屏。

2. 环境准备与项目结构解析:从 Tomcat 9.0.83 到 web.xml 的 7 个关键配置项

2.1 JDK 8 与 Tomcat 9 的兼容性实测要点

本项目基于 JDK 8u202 编译(source=1.8,target=1.8),必须使用 Tomcat 9.0.83 或更低版本。Tomcat 10+ 因 Jakarta EE 命名空间迁移(javax.servlet.*→jakarta.servlet.*),会导致所有web.xml中的<servlet-class>报ClassNotFoundException。实测中,若误用 Tomcat 10.1.15,启动时控制台会抛出:

SEVERE [main] org.apache.catalina.startup.VersionLoggerListener.log Failed to initialize component [StandardServer[8005]] java.lang.NoClassDefFoundError: javax/servlet/Servlet

解决方法:下载 Tomcat 9.0.83(官方 archive 版本),解压后修改conf/server.xml中Connector port="8080"为port="8081"(避免与本地其他服务冲突),再将项目WebContent目录整体复制到webapps/下重命名为booksystem。

2.2 项目目录结构:src下的 4 个包与WebContent的 3 类资源

项目采用经典 Java Web 分层结构,无 Maven,纯手动依赖管理:

booksystem/ ├── src/ │ ├── dao/ # 数据访问层:UserDao.java, BookDao.java 等 │ ├── entity/ # 实体类:User.java, Book.java(含 getter/setter + toString) │ ├── servlet/ # 控制层:LoginServlet.java, AddBookServlet.java │ └── util/ # 工具类:DBUtil.java(封装 getConnection/close)、DateUtil.java ├── WebContent/ │ ├── css/ # bootstrap.min.css(v3.3.7)+ 自定义 style.css │ ├── js/ # jquery-1.12.4.min.js + validate.js(表单校验) │ ├── images/ # 图标、背景图 │ ├── WEB-INF/ │ │ ├── web.xml # 核心配置:7 个 servlet 映射 + 2 个 filter │ │ └── lib/ # 4 个 jar:mysql-connector-java-5.1.47.jar, log4j-1.2.17.jar, poi-3.17.jar, commons-fileupload-1.3.3.jar │ ├── index.jsp # 首页(未登录跳转页) │ ├── login.jsp # 登录表单(含验证码图片生成逻辑) │ └── admin/ # 后台页面:book_list.jsp, user_manage.jsp 等

提示:WEB-INF/lib/中的poi-3.17.jar是 Excel 导出的关键依赖,其org.apache.poi.ss.usermodel.Workbook接口被ExportExcelServlet.java直接调用。若替换为 poi-5.x 版本,HSSFWorkbook构造函数签名变更,需同步修改ExportExcelServlet.java第 42 行代码。

2.3 web.xml 的 7 处核心配置解析

web.xml是本项目的“总开关”,以下 7 项配置决定请求能否正确路由:

  1. 字符编码 Filter(CharacterEncodingFilter):强制request.setCharacterEncoding("UTF-8"),解决中文参数乱码;
  2. 登录拦截器(LoginFilter):对/admin/*路径做 session 校验,未登录则重定向至/login.jsp;
  3. Servlet 映射:LoginServlet绑定/login,AddBookServlet绑定/addBook,注意 URL 模式必须以/开头(如<url-pattern>/login</url-pattern>),否则 Tomcat 无法识别;
  4. 欢迎文件列表:<welcome-file-list>中index.jsp为首页,但实际业务入口是login.jsp;
  5. 错误页面配置:<error-page>将 HTTP 404/500 映射到/error.jsp,该页面通过${exception.message}输出异常摘要;
  6. 上下文参数:<context-param>定义db.driver和db.url,供DBUtil.java读取;
  7. 监听器:ContextLoaderListener无实际作用(未配 Spring),但保留此配置是为后续扩展预留接口。

2.4 MySQL 数据库初始化:建库、建表、插入测试数据三步到位

项目附带sql/book_system.sql脚本,执行前需确保 MySQL 5.7+ 服务运行:

-- 1. 创建数据库(指定 UTF8MB4 支持 emoji) CREATE DATABASE booksystem CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci; -- 2. 执行建表语句(关键字段注释已加在源码 SQL 中) USE booksystem; CREATE TABLE users ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, -- 存储 BCrypt 加密后的密文 role ENUM('admin','user') DEFAULT 'user', create_time DATETIME DEFAULT CURRENT_TIMESTAMP ); -- 3. 插入初始管理员账号(密码为 '123456' 经 BCrypt 加密) INSERT INTO users (username, password, role) VALUES ('admin', '$2a$10$ZzZzZzZzZzZzZzZzZzZzZuZzZzZzZzZzZzZzZzZzZzZzZzZzZzZzZ', 'admin');

注意:password字段值是BCryptPasswordEncoder.encode("123456")的结果,非明文。若需重置密码,用在线 BCrypt 工具生成新密文后更新数据库。

3. 核心模块拆解:LoginServlet 与 DBUtil 的 3 层防御链

3.1 LoginServlet:从 request.getParameter() 到 session.setAttribute() 的完整链路

LoginServlet.java是整个系统的入口守门人,其doPost()方法实现三层防御:

protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 第一层:基础参数校验(空值防御) String username = request.getParameter("username").trim(); String password = request.getParameter("password"); String verifyCode = request.getParameter("verifyCode"); if (username.isEmpty() || password == null || verifyCode == null) { request.setAttribute("msg", "用户名或密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; // 立即终止,防止空指针 } // 第二层:验证码比对(Session 中存储的验证码) String sessionCode = (String) request.getSession().getAttribute("verifyCode"); if (!verifyCode.equalsIgnoreCase(sessionCode)) { request.setAttribute("msg", "验证码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 第三层:数据库校验(调用 UserDao) UserDao userDao = new UserDao(); User user = userDao.findByUsername(username); // 查询用户是否存在 if (user == null || !BCrypt.checkpw(password, user.getPassword())) { request.setAttribute("msg", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } // 成功后:设置 Session 并跳转 request.getSession().setAttribute("user", user); response.sendRedirect(request.getContextPath() + "/admin/index.jsp"); }

关键点说明:

  • request.getParameter("username").trim():.trim()防止用户输入空格导致登录失败,这是新手常漏的细节;
  • verifyCode.equalsIgnoreCase(sessionCode):忽略大小写比对,提升用户体验;
  • BCrypt.checkpw():使用bcrypt算法验证密码,而非password.equals(),杜绝明文比对风险;
  • response.sendRedirect():重定向到后台首页,避免 F5 刷新重复提交登录请求。

3.2 DBUtil:连接池缺失下的 5 个资源释放保障

本项目未使用 Druid/HikariCP,而是通过DBUtil.java手动管理 Connection:

public class DBUtil { private static final String DRIVER = "com.mysql.jdbc.Driver"; private static final String URL = "jdbc:mysql://localhost:3306/booksystem?useSSL=false&serverTimezone=UTC"; private static final String USER = "root"; private static final String PASSWORD = "123456"; static { try { Class.forName(DRIVER); // 驱动加载,仅执行一次 } catch (ClassNotFoundException e) { throw new RuntimeException("MySQL Driver not found", e); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } // 关键:closeAll() 方法确保 ResultSet/Statement/Connection 全部关闭 public static void closeAll(ResultSet rs, Statement stmt, Connection conn) { if (rs != null) try { rs.close(); } catch (SQLException e) { /* 忽略 */ } if (stmt != null) try { stmt.close(); } catch (SQLException e) { /* 忽略 */ } if (conn != null) try { conn.close(); } catch (SQLException e) { /* 忽略 */ } } }

为什么必须写closeAll()?

  • 若只关闭Connection,ResultSet和Statement可能因 GC 延迟未释放,导致 MySQL 连接数耗尽(报错Too many connections);
  • try-catch中忽略SQLException是合理做法:关闭失败不影响业务逻辑,且日志已由log4j记录;
  • static块加载驱动:避免每次getConnection()都重复加载,提升性能。

3.3 UserDao:PreparedStatement 防注入的 2 种写法对比

UserDao.java中findByUsername()方法展示两种 SQL 写法:

// ✅ 正确:使用 PreparedStatement 占位符(防 SQL 注入) public User findByUsername(String username) { String sql = "SELECT id, username, password, role FROM users WHERE username = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, username); // 参数绑定,自动转义单引号 try (ResultSet rs = pstmt.executeQuery()) { if (rs.next()) { return new User(rs.getInt("id"), rs.getString("username"), rs.getString("password"), rs.getString("role")); } } } catch (SQLException e) { log.error("Query user by username failed", e); } return null; } // ❌ 错误:字符串拼接(高危!) public User findByUsernameBad(String username) { String sql = "SELECT * FROM users WHERE username = '" + username + "'"; // 若 username='admin' OR '1'='1',则绕过认证! // ... 后续执行 }

血泪经验:某次学生调试时把pstmt.setString(1, username)误写成pstmt.setString(0, username),导致IndexOutOfBoundsException。原因:PreparedStatement 参数索引从 1 开始,不是 0!

3.4 避坑 / 常见问题 / 排查 / 注意:部署阶段的 4 个高频翻车点

现象 1:登录页面显示 “HTTP Status 404 – Not Found”

原因:Tomcat 启动后webapps/booksystem/目录下缺少WEB-INF/web.xml,或web.xml中<servlet-mapping>的<url-pattern>写成了/login.do(而前端 form action 是/login)。
解决:检查webapps/booksystem/WEB-INF/web.xml是否存在;确认<url-pattern>与 JSP 中action属性完全一致(区分大小写)。

现象 2:登录成功后跳转到空白页,地址栏显示/booksystem/admin/index.jsp

原因:admin/index.jsp中<%@ page contentType="text/html;charset=UTF-8" %>缺失,或response.setContentType("text/html;charset=UTF-8")未设置,导致浏览器用 GBK 解析 UTF-8 页面。
解决:在admin/index.jsp顶部添加<%@ page contentType="text/html;charset=UTF-8" %>,并确保web.xml中CharacterEncodingFilter已启用。

现象 3:Excel 导出文件打开提示 “文件格式与扩展名不匹配”

原因:ExportExcelServlet.java中response.setHeader("Content-Disposition", "attachment;filename=booklist.xls")的filename值含中文(如filename=图书列表.xls),IE 浏览器无法解析。
解决:对中文文件名进行 URL 编码:

String fileName = URLEncoder.encode("图书列表.xls", "UTF-8"); response.setHeader("Content-Disposition", "attachment;filename=" + fileName);
现象 4:添加图书时上传封面图片失败,控制台报java.lang.NoClassDefFoundError: org/apache/commons/fileupload/FileItem

原因:WEB-INF/lib/中缺少commons-fileupload-1.3.3.jar或版本不匹配(如用了 1.5.0 版本,但源码调用的是 1.3.3 的 API)。
解决:严格使用项目自带的commons-fileupload-1.3.3.jar,删除其他版本;检查AddBookServlet.java第 68 行是否为List<FileItem> items = upload.parseRequest(request)(1.3.3 写法)。

4. 前端交互与安全加固:JSP + jQuery 的 3 个实战技巧

4.1 login.jsp 的验证码生成:不用第三方库,纯 Servlet 实现

验证码图片由VerifyCodeServlet.java生成,核心逻辑:

protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 创建 BufferedImage(120x40 像素) BufferedImage image = new BufferedImage(120, 40, BufferedImage.TYPE_INT_RGB); Graphics2D g = image.createGraphics(); // 2. 填充背景色(浅灰色) g.setColor(new Color(230, 230, 230)); g.fillRect(0, 0, 120, 40); // 3. 生成 4 位随机字符(数字+大写字母) String code = ""; String chars = "0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"; Random random = new Random(); for (int i = 0; i < 4; i++) { char c = chars.charAt(random.nextInt(chars.length())); code += c; } request.getSession().setAttribute("verifyCode", code); // 存入 Session // 4. 绘制干扰线(2 条) g.setColor(Color.GRAY); for (int i = 0; i < 2; i++) { int x1 = random.nextInt(120), y1 = random.nextInt(40); int x2 = random.nextInt(120), y2 = random.nextInt(40); g.drawLine(x1, y1, x2, y2); } // 5. 绘制字符(每个字符颜色、角度随机) g.setFont(new Font("Arial", Font.BOLD, 22)); for (int i = 0; i < code.length(); i++) { g.setColor(new Color(random.nextInt(100), random.nextInt(100), random.nextInt(100))); g.rotate(random.nextDouble() * 0.2 - 0.1, 20 + i * 25, 25); g.drawString(code.charAt(i) + "", 20 + i * 25, 25); g.rotate(-random.nextDouble() * 0.2 + 0.1, 20 + i * 25, 25); } // 6. 输出图片 response.setContentType("image/jpeg"); ImageIO.write(image, "jpg", response.getOutputStream()); }

玄学细节:g.rotate()后必须g.rotate()反向恢复,否则后续字符位置错乱;response.setContentType("image/jpeg")必须在ImageIO.write()之前设置,否则浏览器无法识别 MIME 类型。

4.2 book_list.jsp 的分页实现:不依赖 PageHelper,手写 LIMIT 逻辑

分页由BookDao.java的findBooksByPage(int pageNum, int pageSize)完成:

public List<Book> findBooksByPage(int pageNum, int pageSize) { int offset = (pageNum - 1) * pageSize; // 计算偏移量 String sql = "SELECT * FROM books ORDER BY id DESC LIMIT ?, ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setInt(1, offset); pstmt.setInt(2, pageSize); try (ResultSet rs = pstmt.executeQuery()) { List<Book> list = new ArrayList<>(); while (rs.next()) { list.add(new Book(rs.getInt("id"), rs.getString("name"), rs.getString("author"), rs.getDouble("price"))); } return list; } } catch (SQLException e) { log.error("Query books by page failed", e); return Collections.emptyList(); } }

前端 JSP 分页导航(book_list.jsp片段):

<!-- 计算总页数 --> <% int total = new BookDao().getTotalCount(); // 查询 SELECT COUNT(*) FROM books int totalPages = (int) Math.ceil((double) total / 10); // 每页 10 条 %> <!-- 生成页码链接 --> <div class="pagination"> <c:forEach begin="1" end="${totalPages}" var="i"> <a href="book_list.jsp?pageNum=${i}" class="${param.pageNum == i ? 'active' : ''}">${i}</a> </c:forEach> </div>

注意:getTotalCount()方法必须单独查询COUNT(*),不能复用findBooksByPage()的 SQL,否则LIMIT会截断总数。

4.3 表单提交的双重校验:前端 jQuery + 后端 Servlet

add_book.jsp使用jquery.validate.js做前端校验:

<form id="bookForm" action="${pageContext.request.contextPath}/addBook" method="post" enctype="multipart/form-data"> <input type="text" name="name" required minlength="2" maxlength="50"> <input type="text" name="author" required minlength="1" maxlength="30"> <input type="number" name="price" required min="0.01" max="9999.99" step="0.01"> <input type="file" name="cover" accept="image/*" required> </form> <script> $("#bookForm").validate({ rules: { name: { required: true, minlength: 2, maxlength: 50 }, author: { required: true, minlength: 1, maxlength: 30 }, price: { required: true, min: 0.01, max: 9999.99 } }, messages: { name: "书名长度 2-50 个字符", author: "作者名不能为空" } }); </script>

后端 Servlet 的兜底校验(AddBookServlet.java):

// 获取文件项后,校验文件类型和大小 if (item.getFieldName().equals("cover")) { String fileName = item.getName(); if (!fileName.toLowerCase().endsWith(".jpg") && !fileName.toLowerCase().endsWith(".png")) { request.setAttribute("msg", "封面必须是 JPG 或 PNG 格式"); request.getRequestDispatcher("/admin/add_book.jsp").forward(request, response); return; } if (item.getSize() > 5 * 1024 * 1024) { // 5MB 限制 request.setAttribute("msg", "封面文件不能超过 5MB"); request.getRequestDispatcher("/admin/add_book.jsp").forward(request, response); return; } }

为什么需要双重校验?

  • 前端校验可即时反馈,提升体验;
  • 后端校验不可绕过(禁用 JS 后仍生效),是安全底线;
  • 文件类型校验必须在后端:前端accept属性可被篡改。

5. 二次开发实战:从“图书系统”扩展为“多角色协同平台”的 3 个改造点

5.1 新增角色权限控制:在 User 表增加 permission 字段并重构 LoginFilter

原系统只有admin/user二元角色,要支持editor(可编辑图书但不可删)、auditor(可审核借阅申请),需:

第一步:数据库改造

ALTER TABLE users ADD COLUMN permission VARCHAR(255) DEFAULT ''; -- 示例:editor 用户 permission = 'BOOK_EDIT,REPORT_VIEW' -- auditor 用户 permission = 'BORROW_AUDIT,USER_VIEW'

第二步:修改 LoginFilter.java

public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest request = (HttpServletRequest) req; HttpServletResponse response = (HttpServletResponse) res; String uri = request.getRequestURI(); // 白名单:登录页、验证码、静态资源放行 if (uri.contains("/login.jsp") || uri.contains("/VerifyCodeServlet") || uri.contains("/css/") || uri.contains("/js/")) { chain.doFilter(req, res); return; } // 校验登录态 User user = (User) request.getSession().getAttribute("user"); if (user == null) { response.sendRedirect(request.getContextPath() + "/login.jsp"); return; } // 新增:权限校验(精确匹配) String permission = user.getPermission(); // 从数据库读取 if (uri.contains("/admin/deleteBook") && !permission.contains("BOOK_DELETE")) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "无删除权限"); return; } if (uri.contains("/admin/exportExcel") && !permission.contains("REPORT_EXPORT")) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "无导出权限"); return; } chain.doFilter(req, res); }

关键点:permission字段存逗号分隔的权限码,用String.contains()快速判断,避免引入复杂 RBAC 框架。生产环境可升级为FIND_IN_SET()SQL 查询。

5.2 文件上传路径改造:从绝对路径改为相对路径 + 时间戳防重名

原AddBookServlet.java将封面保存到C:/uploads/,硬编码路径导致跨平台失败。改造为:

// 获取项目根目录下的 uploads 文件夹(Tomcat 部署后路径为 webapps/booksystem/uploads/) String uploadPath = request.getServletContext().getRealPath("/uploads"); File uploadDir = new File(uploadPath); if (!uploadDir.exists()) uploadDir.mkdirs(); // 生成唯一文件名:时间戳 + 原文件名哈希 String originalFileName = item.getName(); String extension = originalFileName.substring(originalFileName.lastIndexOf(".")); String newFileName = System.currentTimeMillis() + "_" + Integer.toHexString(originalFileName.hashCode()) + extension; File uploadedFile = new File(uploadDir, newFileName); item.write(uploadedFile); // 保存到数据库的路径为相对路径(便于前端访问) String dbPath = "/uploads/" + newFileName;

好处:

  • getServletContext().getRealPath()自动适配 Windows/Linux 路径分隔符;
  • 时间戳保证并发上传不冲突;
  • 哈希值避免相同文件名覆盖;
  • 数据库存储/uploads/xxx.jpg,前端<img src="${book.coverPath}">可直接渲染。

5.3 日志增强:在 UserDao 中添加 SQL 执行耗时监控

为定位慢查询,改造UserDao.java的findByUsername():

public User findByUsername(String username) { long startTime = System.currentTimeMillis(); String sql = "SELECT id, username, password, role FROM users WHERE username = ?"; try (Connection conn = DBUtil.getConnection(); PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setString(1, username); try (ResultSet rs = pstmt.executeQuery()) { long cost = System.currentTimeMillis() - startTime; if (cost > 500) { // 超过 500ms 记为慢查询 log.warn("Slow query: {} | Cost: {}ms | Username: {}", sql, cost, username); } if (rs.next()) { return new User(rs.getInt("id"), rs.getString("username"), rs.getString("password"), rs.getString("role")); } } } catch (SQLException e) { long cost = System.currentTimeMillis() - startTime; log.error("Query user failed | Cost: {}ms | SQL: {}", cost, sql, e); } return null; }

落地技巧:

  • System.currentTimeMillis()比System.nanoTime()更适合业务耗时统计(单位毫秒,易读);
  • log.warn()记录慢查询,log.error()记录异常,便于 ELK 日志平台按级别过滤;
  • 在log4j.properties中配置%d{yyyy-MM-dd HH:mm:ss} [%t] %-5p %c{1} - %m%n,日志带毫秒级时间戳。

从那以后我每次接手老项目做二次开发,都强制走一遍「数据库字段扫描 → Servlet URL 映射检查 → web.xml Filter 链路梳理」三步流程。不是为了炫技,而是避免在LoginFilter里加了一行权限判断,结果发现VerifyCodeServlet被拦截导致验证码刷不出来这种低级翻车。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询