ARTICLE DETAIL

资讯详情

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

JavaWeb图书订阅管理系统实战:Servlet+JSP+MySQL完整案例与并发避坑

JavaWeb图书订阅管理系统实战:Servlet+JSP+MySQL完整案例与并发避坑 简介这是一套面向JavaWeb初学者与课程设计需求的图书订阅管理系统源码基于Servlet、JSP与JSTL构建配合MySQL数据库和Tomcat服务器运行适合用于毕业设计、课程实训或自学练手。压缩包共76个文件约5.16MB其中37个java文件承载登录注册、图书浏览、购买下单及后台增删改查等业务逻辑20个jsp页面负责前台展示与表单交互另有9个jar依赖包、1个sql初始化脚本及properties、xml等配置资源目录按src、web、pages等模块划分结构清晰便于二次开发。系统覆盖用户账户管理、图书分类检索、购物车结算、管理员后台维护等完整流程并涉及预编译SQL防注入、输入过滤、错误处理与日志记录等基础安全实践。目前已有677人学习下载适合希望打通JavaWeb全链路、积累项目经验的开发者参考借鉴。1. 从一份 JavaWeb 图书订阅管理源码说起它到底能跑通什么很多同学在搜「javaweb项目完整案例mysql」的时候其实心里想的是另一件事我手上这套 Servlet JSP Tomcat 的老架构到底还能不能撑起一个像样的业务系统。这份简易的 JavaWeb 图书订阅管理系统就是冲着这个问题来的。它把图书信息维护、读者订阅、借阅状态流转这几条主线用最朴素的 Servlet 请求转发加 JSP 渲染串了起来数据库落在 MySQL容器跑 TomcatIDE 用 IDEA 就能直接导入。适合两类人一类是刚学完 Servlet 生命周期、想找个完整案例把 JDBC、会话管理、过滤器串一遍的新手另一类是熟手想拿它当模板快速验证某个业务字段或权限逻辑的改法。它不解决高并发也不解决前后端分离它解决的是「我能不能独立把一个有增删改查、有登录态、有业务约束的 Web 项目从零跑起来」。2. 环境搭建与 IDEA 运行配置把 Tomcat 和 MySQL 接上2.1 为什么这套组合仍然值得跑一遍Servlet JSP 这套东西在今天看起来确实老但它的价值在于「没有魔法」。Spring Boot 帮你自动装配的那些东西在这里全是你自己写的web.xml里的 servlet 映射、Filter里的编码处理、DBUtil里的连接获取。你跑通一遍对 HTTP 请求怎么进到 Java 方法、响应怎么回到浏览器会有一个不再模糊的认识。这也是为什么「黑马javaweb笔记数据」这类关键词一直有人搜——大家需要一条从容器到代码的清晰链路。选型上Tomcat 建议用 8.5 或 9.0 这两个大版本别一上来就上 Tomcat 10。原因很实际Tomcat 10 之后 Servlet API 的包名从javax.servlet换成了jakarta.servlet而这套源码大概率还是javax的写法直接部署会报ClassNotFoundException。MySQL 用 5.7 或 8.0 都行但驱动包版本要对应8.0 的驱动类名是com.mysql.cj.jdbc.Driver连接串还要带时区和 SSL 参数这些后面会细说。JDK 用 8 最稳源码里的语法基本不会超过 8。2.2 导入项目与配置 Tomcat在 IDEA 里导入这套源码常见做法是当成普通 Java Web 项目处理而不是 Maven 项目除非源码里带了pom.xml。步骤如下。第一步确认项目结构。打开File - Project Structure在Modules里检查Webfacet 是否指向了正确的web目录web.xml路径要对得上。如果 IDEA 没自动识别手动加一个 Web facet把Web Resource Directory指到src/main/webapp或项目根下的web目录。第二步配置 Artifact。在Artifacts里新建一个Web Application: Exploded输出目录一般默认即可。这一步决定了 Tomcat 部署时到底把哪些文件拷过去WEB-INF/lib下的 jar 必须被包含进去否则运行时报找不到类。第三步配置 Tomcat Run Configuration。点Add Configuration选Tomcat Server - Local在Deployment标签页把刚才的 Artifact 加进去Application context设成/book之类的短路径。端口默认 8080如果被占用就改。# 检查 8080 是否被占用Windows netstat -ano | findstr :8080 # Linux / macOS lsof -i :8080这两条命令是用来排查端口冲突的。netstat那条会列出占用 8080 的进程 PID拿到 PID 后可以在任务管理器里结束或者直接改 Tomcat 端口。lsof那条更直接输出里能看到进程名。很多人启动报Address already in use就是这里没查。2.3 数据库连接配置与初始化数据库这块是翻车重灾区。先建库再改配置最后确认驱动。建库语句一般源码里会带一个.sql文件导入即可。CREATE DATABASE book_subscribe DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci; USE book_subscribe; CREATE TABLE book ( id INT PRIMARY KEY AUTO_INCREMENT, title VARCHAR(100) NOT NULL, author VARCHAR(50), stock INT DEFAULT 0, status TINYINT DEFAULT 1 ); CREATE TABLE subscribe ( id INT PRIMARY KEY AUTO_INCREMENT, user_id INT NOT NULL, book_id INT NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, state TINYINT DEFAULT 0 );这里book表用stock表示库存status表示上架状态subscribe表用state表示订阅状态0 是待处理1 是已通过。字符集一定用utf8mb4不然书名里出现生僻字或 emoji 会插入失败。create_time给了默认值插入时可以不管它。接着改连接配置。源码里通常是一个db.properties或直接在DBUtil里写死。jdbc.drivercom.mysql.cj.jdbc.Driver jdbc.urljdbc:mysql://localhost:3306/book_subscribe?useUnicodetruecharacterEncodingutf8serverTimezoneAsia/ShanghaiuseSSLfalse jdbc.usernameroot jdbc.password你的密码serverTimezone必须给MySQL 8.0 驱动不加这个会报时区错误。useSSLfalse是本地开发图省事生产环境别这么干。characterEncodingutf8配合库的utf8mb4能覆盖绝大多数中文场景。改完配置把mysql-connector-java的 jar 丢进WEB-INF/lib版本和你的 MySQL 对上。提示如果启动后报No suitable driver found九成是 jar 没进WEB-INF/lib或者驱动类名写成了老版本的com.mysql.jdbc.Driver。3. 核心业务链路拆解登录态、订阅流转与库存扣减3.1 登录与会话管理是怎么串的这套系统的登录逻辑走的是最经典的HttpSession方案。用户在login.jsp提交表单LoginServlet拿到用户名密码查库比对成功就把用户对象塞进 session然后重定向到图书列表页。后续所有需要登录的页面靠一个LoginFilter拦截检查 session 里有没有用户对象没有就踢回登录页。WebFilter(/*) public class LoginFilter implements Filter { Override public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { HttpServletRequest request (HttpServletRequest) req; HttpServletResponse response (HttpServletResponse) resp; String uri request.getRequestURI(); // 放行登录页、登录接口和静态资源 if (uri.endsWith(login.jsp) || uri.endsWith(login) || uri.contains(/static/)) { chain.doFilter(req, resp); return; } Object user request.getSession().getAttribute(user); if (user null) { response.sendRedirect(request.getContextPath() /login.jsp); return; } chain.doFilter(req, resp); } }这段过滤器的关键在放行名单。uri.endsWith(login)对应登录接口/static/对应 css、js、图片。漏掉任何一个都会导致登录页样式丢失或者登录请求被自己拦下来形成死循环。request.getContextPath()拼在重定向路径前面是为了适配你设置的Application context不写的话部署到非根路径下会 404。会话超时时间在web.xml里配默认 30 分钟。session-config session-timeout30/session-timeout /session-config这个值按业务调图书订阅这种低频操作30 分钟够用。调太短用户填一半表单被踢出去调太长服务器内存压力大。3.2 订阅流程与库存扣减的边界订阅这条链路是这套系统里唯一有点业务味道的地方。用户点「订阅」SubscribeServlet要做三件事查这本书还有没有库存、查这个用户是不是已经订阅过、扣库存并写订阅记录。这三步必须在一个事务里否则会出现超卖或者重复订阅。public boolean subscribe(int userId, int bookId) { Connection conn null; try { conn DBUtil.getConnection(); conn.setAutoCommit(false); // 1. 带条件扣库存stock 0 才扣得动 String deductSql UPDATE book SET stock stock - 1 WHERE id ? AND stock 0; PreparedStatement ps1 conn.prepareStatement(deductSql); ps1.setInt(1, bookId); int affected ps1.executeUpdate(); if (affected 0) { conn.rollback(); return false; // 库存不足 } // 2. 写订阅记录 String insertSql INSERT INTO subscribe(user_id, book_id, state) VALUES(?, ?, 0); PreparedStatement ps2 conn.prepareStatement(insertSql); ps2.setInt(1, userId); ps2.setInt(2, bookId); ps2.executeUpdate(); conn.commit(); return true; } catch (Exception e) { if (conn ! null) try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } e.printStackTrace(); return false; } finally { DBUtil.close(conn); } }核心在第一条 SQL 的WHERE id ? AND stock 0。把库存判断和扣减合并成一条原子语句靠数据库的行锁保证并发下不会扣成负数。如果先SELECT查库存再UPDATE两个请求同时查到库存为 1就会双双扣减这就是典型的超卖。setAutoCommit(false)开启事务任何一步失败都rollback。affected 0说明库存已经没了直接回滚返回失败不要继续往下写订阅记录。重复订阅的校验可以放在写记录之前用一条SELECT COUNT(*)查也可以给(user_id, book_id)加唯一索引让数据库兜底。常见做法是两者都做应用层给友好提示数据库层防脏数据。3.3 JSP 渲染与数据传递列表页的数据传递走的是request.setAttribute加forward。Servlet 查完列表塞进 request转发到 JSPJSP 用 JSTL 遍历。ListBook books bookService.findAll(); request.setAttribute(books, books); request.getRequestDispatcher(/book_list.jsp).forward(request, response);% taglib prefixc urihttp://java.sun.com/jsp/jstl/core % table c:forEach items${books} varb tr td${b.title}/td td${b.author}/td td${b.stock}/td td c:if test${b.stock 0} a hrefsubscribe?bookId${b.id}订阅/a /c:if c:if test${b.stock 0}已售罄/c:if /td /tr /c:forEach /tableforward和redirect的区别在这里很关键forward是服务器内部跳转request 域里的数据还在JSP 能拿到booksredirect是让浏览器重新发请求request 域清空列表就渲染不出来。所以查完数据渲染页面用forward操作完跳转列表页用redirect避免刷新重复提交。4. 避坑与排查那些让项目起不来的常见问题4.1 中文乱码POST 和 GET 要分开治现象是表单提交的中文书名存进库变成问号或者页面显示乱码。原因是请求体和响应流的编码没统一。POST 请求在过滤器里设request.setCharacterEncoding(UTF-8)就能解决GET 请求的参数编码取决于 Tomcat 的server.xml里 Connector 的URIEncodingTomcat 8 以后默认就是 UTF-8一般不用动。响应这边response.setContentType(text/html;charsetUTF-8)必须设否则 JSP 输出中文会乱。数据库连接串里的characterEncodingutf8和库的utf8mb4也要对上三层里漏一层都会出问题。4.2 404路径拼错或 Artifact 没更新现象是访问某个 Servlet 报 404。先看web.xml或WebServlet里的 url-pattern 和你浏览器里敲的路径是否一致注意Application context那一段。再看 IDEA 的 Artifact 是不是Exploded且包含了最新的 class 文件改了代码没重新 build 就部署跑的还是旧版本。最后确认 Tomcat 的Deployment里 Artifact 加对了没加的话根路径下什么都没有。4.3 数据库连接池耗尽或连接泄漏现象是跑一段时间后报Too many connections或者页面卡死。这套简易系统如果用的是DriverManager直接拿连接每次用完必须closefinally块里漏掉一次就泄漏一次。常见做法是换成 Druid 或 HikariCP 连接池配置最大连接数和超时时间。如果暂时不想引池至少保证Connection、PreparedStatement、ResultSet三层都在finally里关关的顺序和开的顺序相反。4.4 事务没生效自动提交没关现象是扣了库存但订阅记录没写进去或者反过来。原因是conn.setAutoCommit(false)没设每条 SQL 各自提交中间失败前面的也生效了。还有一种情况是DBUtil.getConnection()每次返回新连接事务里用的不是同一个Connection那事务根本无从谈起。事务内的所有操作必须共用同一个连接对象这一点在封装工具类时特别容易踩。4.5 JSP 页面报找不到标签库现象是 JSTL 的c:forEach不生效页面直接把标签当文本输出。原因是jstl.jar和standard.jar没进WEB-INF/lib或者 taglib 的 uri 写错了。JSTL 1.2 的 uri 是http://java.sun.com/jsp/jstl/core别写成旧版本的http://java.sun.com/jstl/core后者在 1.2 里已经废弃。5. 进阶改造把订阅状态流转做成可验证的闭环跑通基础功能之后真正让这套系统有价值的是把订阅状态做成一个可验证的闭环。原始版本里subscribe.state只有 0 和 1实际业务里应该有「待审核、已通过、已取消、已完成」多个状态并且每次流转都要留痕。我一般会加一张subscribe_log表记录谁在什么时间把哪条订阅从什么状态改成了什么状态。CREATE TABLE subscribe_log ( id INT PRIMARY KEY AUTO_INCREMENT, subscribe_id INT NOT NULL, from_state TINYINT, to_state TINYINT NOT NULL, operator VARCHAR(50), op_time DATETIME DEFAULT CURRENT_TIMESTAMP );状态流转的代码要包在事务里改状态和写日志必须同时成功。这样做的直接好处是出问题的时候你能查到每一步是谁操作的而不是面对一个黑匣子干瞪眼。验证方法也很简单写一个 JUnit 测试模拟并发订阅同一本书断言最终stock不小于 0 且subscribe表里没有重复的(user_id, book_id)。Test public void testConcurrentSubscribe() throws Exception { int bookId 1; int threads 20; ExecutorService pool Executors.newFixedThreadPool(threads); CountDownLatch latch new CountDownLatch(threads); AtomicInteger success new AtomicInteger(); for (int i 0; i threads; i) { final int userId i 1; pool.submit(() - { try { if (subscribeService.subscribe(userId, bookId)) { success.incrementAndGet(); } } finally { latch.countDown(); } }); } latch.await(); // 断言成功数不超过初始库存 assertTrue(success.get() 10); }这段测试用CountDownLatch让 20 个线程同时冲success计数不能超过初始库存。跑之前把book表的stock设成 10跑完再查库确认stock是 0 而不是负数。这个测试能帮你验证前面那条WHERE stock 0到底有没有起作用比肉眼看代码靠谱得多。还有一个容易被忽略的点subscribe表的(user_id, book_id)唯一索引。加了之后重复订阅会在数据库层直接抛异常应用层捕获这个异常返回「您已订阅过」即可。这比先查再插更可靠因为查和插之间有时间窗口并发下照样能插进去两条。从那以后我每次拿到这类 JavaWeb 老项目都强制先跑一遍并发订阅测试再动业务代码因为库存和状态这两块一旦有并发问题后面所有功能都是建在沙子上。希望帮到你。本文还有配套的精品资源点击获取
返回列表