简介:这是一份面向Java Web初学者的MVC架构实战项目资源,基于JSP+JavaBean+Servlet技术栈,完整实现用户登录注册、留言板交互及数据分页三大核心功能,适用于高校课程设计、Web开发入门实训与传统JavaEE技术栈巩固学习。资源包共78个文件,包含14个JSP页面(负责视图展示)、14个Java源码(含Servlet控制器与JavaBean模型类)、14个编译后class文件、10个CSS样式文件、9个XML配置文件(如web.xml、数据库连接配置)以及SQL建库脚本等,整体压缩包仅1.86MB,轻量易部署。已有1611人学习下载,适配无密码本地MySQL数据库(库名webapp),目录结构清晰呈现标准Web应用分层:src/java为逻辑层,WEB-INF/web.xml定义路由,db.sql提供初始化脚本,css与images保障基础界面呈现。读者可直接导入NetBeans运行,快速理解Model-View-Controller职责分离、请求流转机制与数据库CRUD集成实践。
1. 为什么还在用 JSP+JavaBean+Servlet 做登录注册和留言板?这不是“过时”,而是教学闭环的刚需
很多同学看到这个标题第一反应是:“都2024年了,还写JSP?Spring Boot不香吗?”——但现实是:某高校《Java Web程序设计》课程设计明确要求禁用框架,必须手写三层结构;某实验室课设答辩现场,导师盯着web.xml里<servlet-mapping>的路径配置连问三遍“为什么不能写成/user/*”;还有A同学交毕设被退回两次,只因LoginServlet中混写了 JDBC 连接池初始化逻辑,被批“职责混乱”。这套组合不是技术怀旧,而是对 Web 基础链路最严苛的压测:从 HTTP 请求如何触发 Servlet,到 JavaBean 如何承载表单数据并校验,再到 JSP 如何安全渲染动态内容——它逼你亲手拧紧每一个螺丝。适合正在赶 JavaWeb 课设、毕设 deadline 的人,也适合想真正搞懂“请求怎么进来、响应怎么出去”的开发者。资源已按教学验收标准打包:含完整可运行工程(Tomcat 9+ 兼容)、数据库建表脚本、各层关键代码注释、常见报错排查清单,以及一份能直接粘贴进答辩PPT的架构说明页。
2. 总体架构设计:MVC 三层解耦与四个核心模块的职责边界
这套方案严格遵循经典 MVC 模式,但不是教科书里的抽象图,而是每一行代码都对应明确职责的落地结构。整个系统划分为四个物理模块:login_register(登录注册主流程)、message_board(留言板CRUD)、common_utils(工具类)、db_config(数据库连接管理)。它们之间通过接口契约通信,杜绝跨层调用——比如MessageBoardServlet绝不直接 newMessageDAO,而是通过MessageService接口注入。这种设计在答辩时能清晰回答“为什么这里要加 Service 层”,而不是含糊说“为了分层”。
2.1 控制层(Servlet):HTTP 协议的守门人与调度中心
Servlet 不是万能胶,它的唯一使命是:解析请求、调用业务、选择视图。以LoginServlet为例,它只做三件事:
- 从
request.getParameter()提取用户名密码; - 调用
UserService.login(username, password)获取返回结果; - 根据结果
request.getRequestDispatcher()转发到success.jsp或login.jsp并携带错误信息。
关键点在于:绝不处理任何业务逻辑,绝不拼接 SQL,绝不操作 session 以外的状态。下面这段代码是实际项目中经过三次重构后定稿的:
// LoginServlet.java protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 1. 提取参数(注意:此处必须做 trim(),否则空格密码会绕过校验) String username = request.getParameter("username").trim(); String password = request.getParameter("password").trim(); // 2. 参数非空校验(这是控制层的底线职责) if (username.isEmpty() || password.isEmpty()) { request.setAttribute("error", "用户名或密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; // 必须 return,避免后续逻辑执行 } // 3. 调用服务层,获取结果 UserService userService = new UserServiceImpl(); User user = userService.login(username, password); // 4. 根据业务结果跳转(注意:forward 是服务器端跳转,不会改变URL) if (user != null) { // 登录成功,将用户信息存入 session(不是 request!) request.getSession().setAttribute("currentUser", user); response.sendRedirect(request.getContextPath() + "/message_board.jsp"); } else { request.setAttribute("error", "用户名或密码错误"); request.getRequestDispatcher("/login.jsp").forward(request, response); } }提示:
response.sendRedirect()和request.getRequestDispatcher().forward()的选择是高频答辩问题。前者是客户端重定向(URL 变化、两次请求),后者是服务器端转发(URL 不变、一次请求)。登录成功后跳转到留言板必须用sendRedirect(),否则刷新页面会重复提交表单——这是血泪经验。
2.2 业务层(Service):事务边界与校验规则的执行者
Service 层是真正的“大脑”,它协调 DAO、封装事务、执行校验。以UserService为例,它的核心方法login()必须满足:
- 原子性:查询用户、比对密码、更新最后登录时间,三步要么全成功,要么全回滚;
- 一致性:密码必须用
BCryptPasswordEncoder加密比对,绝不能明文比较; - 隔离性:同一用户并发登录时,不能出现脏读(比如查到未更新的 last_login_time)。
因此UserServiceImpl中的login方法必须包裹在事务中。由于我们禁用框架,事务管理需手动控制:
// UserServiceImpl.java public User login(String username, String password) { Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; User user = null; try { conn = DBUtil.getConnection(); // 从DBUtil获取连接 conn.setAutoCommit(false); // 关闭自动提交,开启事务 // 1. 查询用户(注意:使用 ? 占位符防SQL注入) String sql = "SELECT id, username, password, email, last_login_time FROM users WHERE username = ?"; pstmt = conn.prepareStatement(sql); pstmt.setString(1, username); rs = pstmt.executeQuery(); if (rs.next()) { String dbPassword = rs.getString("password"); // 2. 密码加密比对(BCrypt 是 JavaWeb 教学标配,比 MD5/SHA 更安全) if (BCrypt.checkpw(password, dbPassword)) { user = new User(); user.setId(rs.getLong("id")); user.setUsername(rs.getString("username")); user.setEmail(rs.getString("email")); // 3. 更新最后登录时间(同一事务内) updateLastLoginTime(conn, rs.getLong("id")); } } conn.commit(); // 手动提交事务 } catch (SQLException e) { try { if (conn != null) conn.rollback(); // 出错回滚 } catch (SQLException rollbackEx) { rollbackEx.printStackTrace(); } e.printStackTrace(); } finally { DBUtil.closeAll(rs, pstmt, conn); // 必须关闭资源,否则连接泄漏 } return user; } private void updateLastLoginTime(Connection conn, Long userId) throws SQLException { String sql = "UPDATE users SET last_login_time = NOW() WHERE id = ?"; try (PreparedStatement pstmt = conn.prepareStatement(sql)) { pstmt.setLong(1, userId); pstmt.executeUpdate(); } }注意:
DBUtil.closeAll()是自定义工具方法,确保ResultSet、PreparedStatement、Connection按反向顺序关闭。很多同学翻车就在这里——conn.close()写在rs.close()前,导致rs关闭失败却无异常,后续查不到数据还以为逻辑错了。
2.3 数据访问层(DAO):SQL 的翻译官与连接的守夜人
DAO 层只做一件事:把 Java 对象和 SQL 语句互相翻译。它不关心事务,不处理校验,只专注 CRUD。以MessageDAO为例,其listMessages()方法必须做到:
- 分页支持(教学项目常要求显示前10条);
- 时间倒序(最新留言在最上面);
- 防 SQL 注入(所有参数必须用
?); - 字段映射准确(比如
content字段可能含 HTML 标签,需转义输出)。
// MessageDAO.java public List<Message> listMessages(int offset, int limit) { List<Message> messages = new ArrayList<>(); Connection conn = null; PreparedStatement pstmt = null; ResultSet rs = null; try { conn = DBUtil.getConnection(); // 使用 LIMIT ? OFFSET ? 实现分页(MySQL语法) String sql = "SELECT id, user_id, content, create_time FROM messages ORDER BY create_time DESC LIMIT ? OFFSET ?"; pstmt = conn.prepareStatement(sql); pstmt.setInt(1, limit); pstmt.setInt(2, offset); rs = pstmt.executeQuery(); while (rs.next()) { Message msg = new Message(); msg.setId(rs.getLong("id")); msg.setUserId(rs.getLong("user_id")); // 注意:content 直接取值,但 JSP 渲染时需用 EL 表达式转义 msg.setContent(rs.getString("content")); msg.setCreateTime(rs.getTimestamp("create_time")); messages.add(msg); } } catch (SQLException e) { e.printStackTrace(); } finally { DBUtil.closeAll(rs, pstmt, conn); } return messages; }提示:
LIMIT ? OFFSET ?是 MySQL 分页标准写法。如果部署到 Oracle,需改为ROWNUM子查询——但教学环境默认 MySQL,不必过度扩展。重点是让学生理解“分页是数据库做的,不是 Java 循环截取的”。
3. 关键实现细节:从 JavaBean 封装到 JSP 安全渲染的全链路
JavaBean 不是摆设,它是 MVC 中承上启下的数据载体;JSP 也不只是模板,它是最后一道安全防线。这两个组件的实现质量,直接决定系统是否“看着能跑、一用就崩”。
3.1 JavaBean:带校验逻辑的 POJO,不是属性容器
很多同学把 JavaBean 写成只有 getter/setter 的空壳,这是重大误区。合格的 JavaBean 必须包含:
- 字段约束:如
username长度 3~20,email符合正则; - 状态标识:
isValid()方法统一返回校验结果; - 错误收集:
getErrors()返回 Map,记录每个字段的错误信息。
以User类为例:
// User.java public class User { private Long id; private String username; private String password; private String email; private Date lastLoginTime; // 校验错误存储 private Map<String, String> errors = new HashMap<>(); public boolean isValid() { errors.clear(); // 每次校验前清空 // 用户名校验 if (username == null || username.trim().length() < 3 || username.trim().length() > 20) { errors.put("username", "用户名长度必须为3-20个字符"); } // 邮箱校验(简单正则,教学够用) if (email != null && !email.matches("^[a-zA-Z0-9._%+-]+@[a-zA-Z0-9.-]+\\.[a-zA-Z]{2,}$")) { errors.put("email", "邮箱格式不正确"); } // 密码非空(注册时校验,登录时不校验密码强度) if (password != null && password.length() < 6) { errors.put("password", "密码长度不能少于6位"); } return errors.isEmpty(); } public Map<String, String> getErrors() { return Collections.unmodifiableMap(errors); } // getter/setter 省略... }注意:
isValid()方法不抛异常,而是通过errorsMap 返回具体问题。这样在 Servlet 中可以if (!user.isValid()) { request.setAttribute("errors", user.getErrors()); },JSP 用${errors.username}直接取错,体验远好于弹窗 alert。
3.2 JSP 页面:EL 表达式与 JSTL 的安全协作
JSP 是最终面向用户的界面,必须解决两个致命问题:
- XSS 攻击:用户在留言板输入
<script>alert(1)</script>,必须原样显示而非执行; - 表单重复提交:刷新成功页导致重复发帖。
解决方案是:所有动态内容必须用 EL 表达式${},且对可能含 HTML 的字段强制转义;表单提交必须用 POST,且成功后重定向(PRG 模式)。
message_board.jsp关键片段:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <%@ taglib prefix="c" uri="http://java.sun.com/jsp/jstl/core" %> <%@ taglib prefix="fn" uri="http://java.sun.com/jsp/jstl/functions" %> <!DOCTYPE html> <html> <head> <title>留言板</title> <meta charset="UTF-8"> </head> <body> <!-- 1. 显示当前用户 --> <div>欢迎,${currentUser.username}!<a href="${pageContext.request.contextPath}/logout">退出</a></div> <!-- 2. 留言表单(POST 提交,防止刷新重复) --> <form action="${pageContext.request.contextPath}/AddMessageServlet" method="post"> <textarea name="content" placeholder="写下你的想法..." required></textarea> <button type="submit">发布</button> </form> <!-- 3. 留言列表(关键:${fn:escapeXml(msg.content)} 防 XSS) --> <div class="messages"> <c:forEach items="${messages}" var="msg" varStatus="status"> <div class="message-item"> <strong>用户${msg.userId}:</strong> <span>${fn:escapeXml(msg.content)}</span> <small>${msg.createTime}</small> </div> </c:forEach> </div> <!-- 4. 分页导航(JSTL 控制显示逻辑) --> <c:if test="${not empty messages}"> <div class="pagination"> <c:forEach begin="1" end="${totalPages}" var="i"> <a href="?page=${i}">${i}</a> </c:forEach> </div> </c:if> </body> </html>提示:
fn:escapeXml()是 JSTL 函数库的核心安全措施,它把<转成<,>转成>,彻底阻断脚本执行。很多同学漏掉这行,导致“留言板变 XSS 演示台”。另外,<c:forEach>的varStatus属性能获取循环索引,方便做“仅显示前5条”等教学需求。
3.3 web.xml 配置:Servlet 映射与全局初始化的硬性规范
web.xml是整个 Web 应用的宪法,教学项目中常被忽视,却是答辩必问点。它必须明确:
- Servlet 名称与类的绑定(
<servlet>); - URL 路径与 Servlet 的映射(
<servlet-mapping>); - 全局上下文参数(如数据库驱动、URL);
- 监听器注册(如
ContextLoaderListener初始化 DAO)。
以下是精简但完整的web.xml片段:
<?xml version="1.0" encoding="UTF-8"?> <web-app xmlns="http://xmlns.jcp.org/xml/ns/javaee" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/web-app_4_0.xsd" version="4.0"> <!-- 1. 全局上下文参数 --> <context-param> <param-name>driver</param-name> <param-value>com.mysql.cj.jdbc.Driver</param-value> </context-param> <context-param> <param-name>url</param-name> <param-value>jdbc:mysql://localhost:3306/javaweb_demo?useSSL=false&serverTimezone=UTC</param-value> </context-param> <context-param> <param-name>username</param-name> <param-value>root</param-value> </context-param> <context-param> <param-name>password</param-name> <param-value>123456</param-value> </context-param> <!-- 2. Servlet 定义 --> <servlet> <servlet-name>LoginServlet</servlet-name> <servlet-class>com.example.servlet.LoginServlet</servlet-class> </servlet> <servlet> <servlet-name>AddMessageServlet</servlet-name> <servlet-class>com.example.servlet.AddMessageServlet</servlet-class> </servlet> <!-- 3. Servlet 映射(路径必须以 / 开头) --> <servlet-mapping> <servlet-name>LoginServlet</servlet-name> <url-pattern>/login</url-pattern> </servlet-mapping> <servlet-mapping> <servlet-name>AddMessageServlet</servlet-name> <url-pattern>/add_message</url-pattern> </servlet-mapping> <!-- 4. 监听器:应用启动时初始化数据库连接池 --> <listener> <listener-class>com.example.listener.ContextLoaderListener</listener-class> </listener> <!-- 5. 欢迎文件 --> <welcome-file-list> <welcome-file>login.jsp</welcome-file> </welcome-file-list> </web-app>注意:
<url-pattern>的值必须是/login,不能是login(缺/会导致 404);<param-value>中的&必须写成&,否则 XML 解析失败;<welcome-file>设为login.jsp,确保项目启动后直接进入登录页,符合教学演示逻辑。
4. 避坑指南:五个让答辩老师当场皱眉的典型问题及根治方案
这套方案在多个高校课设中验证过,以下问题是学生踩坑率最高、老师提问最密集的五个点。每一条都来自真实翻车现场,附带可立即执行的修复命令和验证方法。
4.1 现象:登录后跳转到空白页,浏览器 URL 显示http://localhost:8080/success.jsp
原因:LoginServlet中用了request.getRequestDispatcher("/success.jsp").forward(request, response),但success.jsp文件实际放在WebContent下的子目录(如pages/success.jsp),路径没写对。
解决:检查web.xml中<welcome-file>和所有getRequestDispatcher()的路径。路径必须相对于WebContent根目录。若success.jsp在WebContent/pages/下,则应写request.getRequestDispatcher("/pages/success.jsp")。验证方法:直接在浏览器访问http://localhost:8080/pages/success.jsp,能打开即路径正确。
4.2 现象:留言板显示乱码,中文变成??或方块
原因:三个环节编码不一致:JSP 页面声明<%@ page contentType="text/html;charset=UTF-8" %>缺失;Tomcatserver.xml中Connector未设置URIEncoding="UTF-8";MySQL 连接 URL 未加characterEncoding=utf8。
解决:
- 确保所有 JSP 顶部有
<%@ page contentType="text/html;charset=UTF-8" %>; - 修改 Tomcat
conf/server.xml,找到<Connector port="8080" ... />,添加URIEncoding="UTF-8"; - 修改
web.xml中数据库 URL 为:jdbc:mysql://localhost:3306/javaweb_demo?useSSL=false&serverTimezone=UTC&characterEncoding=utf8。
验证:重启 Tomcat,在登录框输入中文用户名,提交后看数据库users表中username字段是否为中文。
4.3 现象:注册新用户时报错java.sql.SQLException: Field 'xxx' doesn't have a default value
原因:MySQL 表字段设置了NOT NULL,但插入 SQL 中未提供该字段值(如last_login_time未设默认值或未在 INSERT 中指定)。
解决:修改建表语句,为NOT NULL字段加DEFAULT。例如:
CREATE TABLE users ( id BIGINT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(20) NOT NULL, password VARCHAR(100) NOT NULL, email VARCHAR(50), last_login_time DATETIME DEFAULT CURRENT_TIMESTAMP );验证:执行INSERT INTO users (username, password) VALUES ('test', '123');,确认不报错。
4.4 现象:点击“退出”链接后,再点浏览器后退,又回到留言板(session 未销毁)
原因:LogoutServlet中只调用了session.invalidate(),但未重定向到登录页,导致浏览器缓存了留言板页面。
解决:LogoutServlet必须invalidate()后sendRedirect():
protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } response.sendRedirect(request.getContextPath() + "/login.jsp"); // 关键! }验证:退出后按浏览器后退键,页面应显示“404”或跳转回登录页,而非留言板。
4.5 现象:Eclipse 中启动 Tomcat 报错java.lang.ClassNotFoundException: com.mysql.cj.jdbc.Driver
原因:MySQL 驱动 JAR 包未放入WEB-INF/lib目录,或放入了但名称含版本号(如mysql-connector-java-8.0.33.jar),而web.xml中driver参数写的是com.mysql.jdbc.Driver(老版)。
解决:
- 下载
mysql-connector-java-8.0.xx.jar,复制到项目WebContent/WEB-INF/lib/下; - 确认
web.xml中<param-value>为com.mysql.cj.jdbc.Driver(注意cj); - Eclipse 中右键项目 →
Properties→Deployment Assembly→ 确认lib目录已添加。
验证:重启 Tomcat,在DBUtil.getConnection()中打日志,确认能成功获取连接。
5. 验收与答辩:用三张表、一个流程图、两次实测搞定全部考核点
答辩不是背诵代码,而是证明你真正掌控了整个链路。我带过的十几个项目,最终能一次性通过的,都做了这三件事:用表格固化验收标准、用流程图讲清数据流向、用两次实测覆盖边界场景。下面直接给你可复用的交付物。
5.1 功能验收表:对照打钩,答辩前逐项自检
这张表是答辩材料的核心附件,打印出来贴在笔记本上,每完成一项打钩。老师提问时,直接翻开说“第3项,我验证了……”,比说“我做了”有力十倍。
| 序号 | 功能模块 | 验收标准 | 自检结果(✓/✗) | 备注 |
|---|---|---|---|---|
| 1 | 用户登录 | 输入正确账号密码 → 跳转留言板;错误密码 → 提示“用户名或密码错误” | ✓ | 已用 Postman 测试 |
| 2 | 用户注册 | 输入合法信息 → 插入数据库;用户名重复 → 提示“用户名已存在” | ✓ | 数据库 unique 约束生效 |
| 3 | 留言发布 | 发布后立即显示在列表顶部;内容含<script>→ 原样显示不执行 | ✓ | fn:escapeXml()已验证 |
| 4 | 会话管理 | 登录后关闭浏览器再打开 → 仍需登录;退出后后退 → 显示登录页 | ✓ | session.invalidate()生效 |
| 5 | 数据库连接 | 启动 Tomcat 时控制台无ClassNotFoundException;DBUtil.getConnection()返回非 null | ✓ | mysql-connector-java-8.0.33.jar已部署 |
提示:备注栏写具体验证方式,比如“用 Postman 测试”比“已测试”专业得多。老师可能追问“怎么用 Postman 测试的?”,你就答:“用 POST 方法请求
/login,Body 选 x-www-form-urlencoded,填入 username=test&password=123,看响应状态码和 body 内容”。
5.2 核心流程图:一张图讲清“用户点击登录按钮后发生了什么”
答辩时老师最爱问“这个请求是怎么处理的?”,画流程图是最直观的回答。不要用 Visio 画复杂 UML,就用纯文本描述,确保每一步对应一行代码:
用户点击登录按钮 ↓ 浏览器发送 POST 请求到 /login(由 web.xml 中 servlet-mapping 定义) ↓ Tomcat 调用 LoginServlet 的 doPost() 方法 ↓ LoginServlet 调用 request.getParameter() 提取参数 ↓ LoginServlet 调用 UserServiceImpl.login() 方法 ↓ UserServiceImpl 通过 DBUtil.getConnection() 获取数据库连接 ↓ UserServiceImpl 执行 PreparedStatement 查询 users 表 ↓ 查询结果封装为 User 对象,返回给 LoginServlet ↓ LoginServlet 判断 user 是否为 null,决定 forward 或 sendRedirect ↓ 最终渲染 login.jsp 或 message_board.jsp注意:流程图中每个箭头必须指向一个具体的类或方法名,不能写“业务处理”“数据处理”等模糊词。当老师问“查询是在哪一步执行的?”,你能立刻指出“第7步,UserServiceImpl 中的 PreparedStatement.executeQuery()”。
5.3 边界场景实测:两次必做测试,堵死所有质疑漏洞
教学项目最怕“理论上可行,实际上翻车”。我要求所有学生必须完成以下两次实测,并把截图和日志贴在答辩 PPT 最后一页:
实测一:高并发登录压力测试(用 Apache Bench)
目的:验证UserService.login()的线程安全性。虽然教学项目不强调并发,但老师可能问“如果100人同时登录会怎样?”。
命令:
ab -n 100 -c 10 http://localhost:8080/login?username=test&password=123预期结果:Complete requests: 100,Failed requests: 0,Requests per second: > 50。
关键证据:查看 Tomcatlogs/catalina.out,确认无ConcurrentModificationException或连接池耗尽日志。
实测二:XSS 攻击模拟测试(手工注入)
目的:证明fn:escapeXml()生效。
步骤:
- 在留言板输入
<img src=x onerror=alert('xss')>; - 提交后查看源代码(Chrome 右键 → 查看网页源代码);
- 确认
<变成了<,>变成了>。
预期结果:页面显示纯文本<img src=x onerror=alert('xss')>,不弹窗、不加载图片。
关键证据:截图源代码中转义后的 HTML 片段。
从那以后我每次带学生做 JavaWeb 课设,都强制他们在LoginServlet的doPost方法开头加一行日志System.out.println("[DEBUG] Login request received for user: " + username);,并在答辩前用ab命令跑一遍并发测试——不是为了炫技,而是让“我能跑”变成“我证明了它能跑”。希望帮到你。
本文还有配套的精品资源,点击获取