JSP+JavaBean+Servlet实现登录注册与留言板实战指南
简介这是一套基于JSPJavaBeanServlet的JavaWeb入门实战资源面向正在学习MVC三层架构的学生或初级开发者系统展示登录注册、留言板与分页功能的完整实现思路。整套代码按model、view、controller分层组织JavaBean负责读写数据库数据库名为webapp且无密码JSP完成页面展示Servlet负责请求转发与交互代码职责边界清楚。压缩包约1.86MB共78个文件主要包含14个Java源码、14个JSP页面、10个CSS样式、9个XML配置和2个JAR包另有SQL脚本、HTML页面及NetBeans工程配置等覆盖前端、后端、数据库与构建配置主要环节其中Java源码还配套了同名class编译文件便于直接部署。已有1611人浏览学习适合作为课程设计、毕业设计或JavaWeb练习的参考通过源码可快速掌握MVC分离、分页查询、留言板前后台配合等关键知识点也能理解登录校验、表单回显和数据库读写流程方便改造成其他业务模块。1. jspjavabeanservlet 组合到底在解决什么问题如果你接过一个老系统维护任务或者刚进团队就被分到一个 2015 年之前的 Java Web 项目大概率会遇到 jspjavabeanservlet 这套组合。登录注册加留言板是这类项目最典型的入门场景用户提交表单、服务端校验身份、把数据写进数据库、再渲染回页面。这套技术栈的价值不在于新而在于它把 HTTP 请求的流转路径展示得极其直白——浏览器发请求Servlet 接住JavaBean 装数据JSP 画页面。对新手来说这是理解 MVC 分层和 Session 机制最便宜的入口对熟手来说它是快速接手遗留系统、判断代码坏味道的试金石。这篇文章不讨论要不要追 Spring Boot只讲怎么用这套组合把登录注册和留言板跑通以及那些文档里从来不写的坑——比如转发和重定向的区别、Session 失效的时机、编码问题为什么反复出现。2. 架构与数据准备从请求流转到建表 SQL2.1 为什么这个组合是经典的 MVC 落地方式MVC 三个字母在教科书里被讲烂了但 jspjavabeanservlet 是少数能让你在真实代码里把三者对应起来的技术组合。Servlet 是 Controller负责接收 HttpServletRequest、解析参数、调用业务逻辑、决定跳转到哪个页面JavaBean 是 Model承载数据结构和简单的业务规则JSP 是 View只负责读取数据并输出 HTML。这里有个常见误解有人把 SQL 写在 JSP 里用% %拼查询语句这等于把 View 和 Model 搅在一起。我见过一个留言板项目页面里直接写了 JDBC 连接换数据库时要改十几个文件这就是没守住分层边界。正确的做法是 JSP 里只用 EL 表达式和 JSTL 标签取值所有数据库操作都收敛到 Dao 层。请求流转路径是这样的浏览器提交表单 → web 容器根据 URL 映射找到对应的 Servlet → Servlet 调用 JavaBean/Dao 完成业务逻辑 → 把结果放进 request 或 session 作用域 → 转发到 JSP 渲染响应。注意是“转发”而不是“重定向”这两个词的区别到第 5 章避坑部分会具体讲。2.2 项目目录结构与核心文件职责先看一下目录长什么样后续代码按这个结构组织src/main/java ├── com.demo.entity // User.java Message.java ├── com.demo.dao // UserDao.java MessageDao.java ├── com.demo.servlet // RegisterServlet LoginServlet LogoutServlet MessageServlet ├── com.demo.util // DBUtil.java src/main/webapp ├── WEB-INF/web.xml // 老项目可能需要注解方式可以不要 ├── login.jsp ├── register.jsp ├── index.jsp // 登录后的个人信息展示页面 ├── message.jsp // 留言板页面如果你的开发环境是 Eclipse 配 Tomcat目录结构略有差异但职责划分不变。Entity 类对应数据库表Dao 类只做增删改查Servlet 只做请求分发和参数处理JSP 只做展示。这样拆开之后改页面样式不会碰 Java 代码换数据库驱动也只需要动 DBUtil。2.3 数据库建表用户表和留言表的最小设计登录注册需要一张用户表留言板需要一张留言表。两张表最小化设计如下CREATE TABLE user ( id INT NOT NULL AUTO_INCREMENT, username VARCHAR(50) NOT NULL, password VARCHAR(64) NOT NULL COMMENT MD5加密后的密文, nickname VARCHAR(50) DEFAULT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), UNIQUE KEY uk_username (username) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4; CREATE TABLE message ( id INT NOT NULL AUTO_INCREMENT, user_id INT NOT NULL, content VARCHAR(500) NOT NULL, create_time DATETIME DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (id), KEY idx_user_id (user_id) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;几个设计考量username 加了唯一索引注册时判断重名靠数据库兜底除了 Dao 层查重插入时捕获 DuplicateKeyException 是第二道防线。password 字段长度留 64因为后面要用 MD5 加盐明文长度和密文长度不一样。message 表的 user_id 关联 user 表查留言时要 join 出用户名索引放在 user_id 上避免全表扫描。2.4 数据库连接工具类JDBC 的写法与坑传统项目最常写的连接方式是 DriverManager它直白、容易理解但每次请求都建立物理连接高并发下性能会崩。另一个选择是德鲁伊或 C3P0 连接池配置稍复杂但生产环境必须要用。这里给一个 DriverManager 版本的 DBUtil适合课程设计和内部系统package com.demo.util; import java.sql.*; public class DBUtil { private static final String URL jdbc:mysql://localhost:3306/yourdb?useUnicodetruecharacterEncodingutf8useSSLfalseserverTimezoneAsia/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) { e.printStackTrace(); } } public static Connection getConnection() throws SQLException { return DriverManager.getConnection(URL, USER, PASSWORD); } public static void close(Connection conn, PreparedStatement ps, ResultSet rs) { try { if (rs ! null) rs.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (ps ! null) ps.close(); } catch (SQLException e) { e.printStackTrace(); } try { if (conn ! null) conn.close(); } catch (SQLException e) { e.printStackTrace(); } } }URL 里的characterEncodingutf8必须和 JSP 页面编码、Servlet 请求编码保持一致少一个环节对不上就会出现中文乱码这个在第 5 章详谈。serverTimezoneAsia/Shanghai是 MySQL 8 的时区要求不加会报错。close 方法里三个 try-catch 分别关闭三个资源顺序是 ResultSet → PreparedStatement → Connection反了会引发连接泄露。3. 登录注册实现Servlet 控制、JavaBean 建模、JSP 展示3.1 User 实体类和 UserDao数据怎么在层之间传递JavaBean 并不是一个特殊的技术它只是一组规范属性私有、提供无参构造、通过 getter/setter 访问属性。写一个 User 类package com.demo.entity; public class User { private int id; private String username; private String password; private String nickname; public User() {} public User(String username, String password, String nickname) { this.username username; this.password password; this.nickname nickname; } public int getId() { return id; } public void setId(int id) { this.id id; } // 其他 getter/setter 略按同样格式补全 }有了实体类UserDao 负责和数据库打交道。注册逻辑需要两个方法一个按用户名查询一个插入新用户。package com.demo.dao; import com.demo.entity.User; import com.demo.util.DBUtil; import java.sql.*; public class UserDao { public User findByUsername(String username) { String sql SELECT id, username, password, nickname FROM user WHERE username ?; try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, username); try (ResultSet rs ps.executeQuery()) { if (rs.next()) { User user new User(); user.setId(rs.getInt(id)); user.setUsername(rs.getString(username)); user.setPassword(rs.getString(password)); user.setNickname(rs.getString(nickname)); return user; } } } catch (SQLException e) { e.printStackTrace(); } return null; } public boolean insert(User user) { String sql INSERT INTO user(username, password, nickname) VALUES(?, ?, ?); try (Connection conn DBUtil.getConnection(); PreparedStatement ps conn.prepareStatement(sql)) { ps.setString(1, user.getUsername()); ps.setString(2, user.getPassword()); ps.setString(3, user.getNickname()); return ps.executeUpdate() 0; } catch (SQLException e) { e.printStackTrace(); return false; } } }JDK 7 之后的 try-with-resources 语法会自动关闭实现了 AutoCloseable 的资源连接、语句、结果集都包含在内比手写 close 方法简洁也不容易漏掉。PreparedStatement 用?占位符防止 SQL 注入——这是必须养成的习惯千万不要用字符串拼接 SQL。3.2 注册和登录 Servlet核心逻辑与跳转决策Servlet 是整个流程的调度中心。注册流程要做的判断是用户名是否为空、是否已存在、两次密码是否一致。这里用注册 Servlet 举例package com.demo.servlet; import com.demo.dao.UserDao; import com.demo.entity.User; import com.demo.util.MD5Util; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; WebServlet(/register) public class RegisterServlet extends HttpServlet { private UserDao userDao new UserDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); String username req.getParameter(username); String password req.getParameter(password); String password2 req.getParameter(password2); String nickname req.getParameter(nickname); if (username null || username.trim().isEmpty() || password null || password.isEmpty()) { req.setAttribute(error, 用户名和密码不能为空); req.getRequestDispatcher(/register.jsp).forward(req, resp); return; } if (!password.equals(password2)) { req.setAttribute(error, 两次密码不一致); req.getRequestDispatcher(/register.jsp).forward(req, resp); return; } UserDao dao new UserDao(); if (dao.findByUsername(username) ! null) { req.setAttribute(error, 用户名已存在); req.getRequestDispatcher(/register.jsp).forward(req, resp); return; } User user new User(username, MD5Util.md5(password), nickname); if (dao.insert(user)) { resp.sendRedirect(req.getContextPath() /login.jsp); } else { req.setAttribute(error, 注册失败请重试); req.getRequestDispatcher(/register.jsp).forward(req, resp); } } }三个关键点一是req.setCharacterEncoding(UTF-8)必须放在读取任何参数之前否则用户名里的中文直接乱码二是出错时用 forward 转发到注册页错误信息放在 request 域里JSP 用${error}取三是注册成功用sendRedirect重定向到登录页避免用户刷新浏览器时重复提交表单。forward 和 sendRedirect 的区别就在这——前者是服务端内部跳转浏览器 URL 不变后者是浏览器收到 302 后重新发起请求URL 变化。登录 Servlet 的逻辑类似区别是用户提交用户名密码后要先查询用户是否存在再用 MD5 密文比对密码。登录成功要把 user 对象放进 sessionHttpSession session req.getSession(); session.setAttribute(loginUser, user); resp.sendRedirect(req.getContextPath() /index.jsp);这里强调一点getSession()是拿当前会话如果不存在会创建一个新会话。登录成功后才能把状态放进 session不要在登录之前就塞数据。3.3 注册页面和登录页面表单写法与错误提示JSP 页面本身不复杂难在让它保持干净——不写 Java 脚本片段只用 EL 和 JSTL。注册页核心表单% page contentTypetext/html;charsetUTF-8 languagejava % html headtitle注册/title/head body h2用户注册/h2 p stylecolor:red${error}/p form action${pageContext.request.contextPath}/register methodpost 用户名input typetext nameusername /br/ 密码input typepassword namepassword /br/ 确认密码input typepassword namepassword2 /br/ 昵称input typetext namenickname /br/ input typesubmit value注册 / /form /body /html${pageContext.request.contextPath}是动态获取项目上下文路径这样页面部署到任何路径下表单提交地址都不会写死这是从静态页面转 JSP 时最容易忽略的一个细节。action 指向/register和 Servlet 上的WebServlet(/register)对应。3.4 登录后的个人信息展示页面user 对象的可见性登录成功后跳转到 index.jsp也就是热词里常说的 jsp 个人信息展示页面。这个页面从 session 里取出 user 对象展示同时提供留言板入口和退出登录按钮。% page contentTypetext/html;charsetUTF-8 languagejava % % taglib prefixc urihttp://java.sun.com/jsp/jstl/core % html headtitle个人中心/title/head body c:if test${empty sessionScope.loginUser} c:redirect url/login.jsp / /c:if h2欢迎${sessionScope.loginUser.nickname ! null ? sessionScope.loginUser.nickname : sessionScope.loginUser.username}/h2 p用户名${sessionScope.loginUser.username}/p a href${pageContext.request.contextPath}/message.jsp进入留言板/a a href${pageContext.request.contextPath}/logout退出登录/a /body /html这段 JSP 做了两件事一是用c:if判断 session 里有没有 loginUser没有就重定向回登录页这是最粗粒度的登录校验二是用 EL 表达式从 sessionScope 里取属性${sessionScope.loginUser.username}等价于先取 loginUser 对象再调用 getUsername()。注意如果 nickname 为空展示逻辑回退到 username这个三元表达式写在 EL 里是合法的。4. 留言板实现把用户状态和数据库操作串起来4.1 留言写入从表单向数据库插入数据留言板的核心场景是登录用户发留言、所有人看留言列表。发留言的 Servlet 只做三件事确认用户是否登录、读取 content 参数、调用 Dao 插入。package com.demo.servlet; import com.demo.dao.MessageDao; import com.demo.entity.User; import javax.servlet.ServletException; import javax.servlet.annotation.WebServlet; import javax.servlet.http.*; import java.io.IOException; WebServlet(/addMessage) public class MessageServlet extends HttpServlet { private MessageDao messageDao new MessageDao(); Override protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { req.setCharacterEncoding(UTF-8); HttpSession session req.getSession(); User loginUser (User) session.getAttribute(loginUser); if (loginUser null) { resp.sendRedirect(req.getContextPath() /login.jsp); return; } String content req.getParameter(content); if (content null || content.trim().isEmpty()) { req.setAttribute(error, 留言内容不能为空); req.getRequestDispatcher(/message.jsp).forward(req, resp); return; } boolean ok messageDao.insert(loginUser.getId(), content.trim()); if (ok) { resp.sendRedirect(req.getContextPath() /message.jsp); } else { req.setAttribute(error, 留言失败请重试); req.getRequestDispatcher(/message.jsp).forward(req, resp); } } }从 session 里拿 user 对象时只取 id不信任前端传的 user_id 参数。前端表单里如果藏了一个 user_id 的隐藏域攻击者完全可以改成别人的 id留言就变成替别人发的了。凡是涉及用户身份的数据必须从服务端会话里取这是一个安全底线。MessageDao 的 insert 方法和 UserDao 写法类似SQL 是INSERT INTO message(user_id, content) VALUES(?, ?)不再重复贴代码。4.2 留言列表展示JSP 里循环输出还是 Servlet 拼 HTML留言列表有两种实现路径。路径一是 JSP 里用 JSTL 的c:forEach循环输出数据从 request 域取。路径二是 Servlet 直接输出 JSON 给前端 ajax 渲染。传统 jspservlet 项目绝大多数走路径一简单直接页面加载时就渲染完成。要给 JSP 准备数据通常由一个查询 Servlet 来查数据库并转发到页面。这个 Servlet 的 doGet 方法protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException { MessageDao dao new MessageDao(); ListMessage messages dao.findAll(); req.setAttribute(messages, messages); req.getRequestDispatcher(/message.jsp).forward(req, resp); }注意这里用的是 forward 而不是 sendRedirect。如果用 sendRedirectrequest 域里的 messages 列表就丢了页面拿不到数据。留言板页面的 JSP 片段c:forEach items${messages} varm div classmessage-item pstrong${m.username}/strong 发表于 ${m.createTime}/p p${m.content}/p /div /c:forEach这里有一个非常重要的安全点留言内容是用户输入的直接${m.content}输出到页面上如果用户提交了scriptalert(1)/script就会触发存储型 XSS 漏洞。正确的做法是在后端做 HTML 转义把转成lt;转成gt;。一个简单的转义方法写在工具类里public static String escapeHtml(String s) { if (s null) return ; return s.replace(, amp;) .replace(, lt;) .replace(, gt;) .replace(\, quot;) .replace(, #39;); }在留言插入时就把转义后的内容存进数据库还是展示时再转义两种做法各有优劣。存储时转义一劳永逸但原始内容丢失之后如果想做关键词过滤就麻烦了展示时转义保留原文灵活度更高但所有展示入口都得记得转。我一般倾向于展示时转义因为一个escapeHtml方法可以被多个页面复用也不破坏原始数据。4.3 留言板的用户信息 join避免 N1 查询留言列表要显示用户名用 SQL join 一次查出比每条留言单独查一次用户更高效。Message 类里增加一个 username 字段Dao 里用 join 查SELECT m.id, m.user_id, m.content, m.create_time, u.username FROM message m JOIN user u ON m.user_id u.id ORDER BY m.create_time DESC如果不 join而是先查所有留言再循环每条查用户表留言多时会产生 N1 次查询。join 一条 SQL 解决。ResultSet 取数据时rs.getString(username)对应的是 user 表的字段Message 实体类里新增的 username 属性只用于展示不映射到 message 表。5. 避坑指南五条血泪经验5.1 404 和 500从 URL 到 Servlet 的映射排查现象访问/register报 404或者点了提交按钮页面变 500。原因分两类404 多半是 URL 映射对不上500 多半是代码运行时抛异常。解决时先看 Tomcat 日志localhost 的日志文件里会打印完整的异常堆栈。常见具体原因WebServlet(/register)的路径没加项目上下文路径表单 action 写成了/register而没加${pageContext.request.contextPath}。另一种情况是 JSP 里 import 了不存在的类页面编译时直接 500。排查思路是先确认请求到了哪个 Servlet在 doPost 入口打一行日志再看日志堆栈定位到行号不要盯着页面的错误提示猜。5.2 中文乱码三个环节必须同时设 UTF-8现象注册的用户名存进数据库是乱码或者页面上显示的问号。原因浏览器、Servlet、数据库三个环节的编码不一致任何一个环节断了都会乱。在 Tomcat 8 之前还要配URIEncodingUTF-8修改 GET 请求的编码Tomcat 8 默认就是 UTF-8不用改。全链路解决方案JSP 文件头部contentTypetext/html;charsetUTF-8和pageEncodingUTF-8都写上Servlet 的doPost里第一行req.setCharacterEncoding(UTF-8)数据库连接 URL 里加characterEncodingutf8MySQL 表结构用utf8mb4。这条链路是死记硬背的少一步就玄学乱码用排除法逐个环节验证。5.3 数据库连接失败驱动、URL、防火墙三层问题现象启动 Tomcat 后第一次请求报ClassNotFoundException或Communications link failure。原因分三种驱动 jar 没放进WEB-INF/lib、数据库地址端口写错、MySQL 服务没启动或防火墙拦了 3306 端口。解决时按顺序查先确认mysql-connector-java的 jar 在WEB-INF/lib目录下新版驱动类名是com.mysql.cj.jdbc.Driver老版本是com.mysql.jdbc.Driver写错也报 ClassNotFound。再用命令行mysql -uroot -p本地连一次确认账密和库名没问题。最后检查连接 URL 里的serverTimezone参数少这个参数 MySQL 8 会报时区错误。这些都是我踩过的坑每一条都对应一个晚上的排查时间。5.4 Session 失效登录状态莫名丢失现象登录成功进入个人信息展示页面点进留言板写一条留言提交后又被踢回登录页。原因有两种一种是 session 过期Tomcat 默认 30 分钟另一种是项目部署路径变了浏览器里的 cookie 存了旧的 JSESSIONID访问新路径时拿不到会话。排查方法在留言板的 Servlet 里打印session.getId()和loginUser是不是 null看每次请求是不是同一个 session ID。如果 ID 变了说明客户端 cookie 没带上检查浏览器开发者工具里 JSESSIONID 的 Domain 和 Path 设置。还有一种低级错误登录成功往 session 里放的是loginUser登录检查时取的是user名字对不上这个错字排查起来最浪费时间。5.5 重复提交刷新页面就把留言发了两次现象用户提交留言后按 F5 刷新同一条留言出现两条。原因doPost 里处理完直接 forward 到 JSP 页面刷新时浏览器重新提交了上一次的 POST 请求。解决POST 处理成功后必须sendRedirect让浏览器跳转到 GET 请求的消息列表页面。这是 forward 和 sendRedirect 在生产环境中的一个典型区别——前者适合在出错时回显表单后者适合在成功时避免重复提交。6. 从跑通到能用的验证方法与进阶技巧6.1 手工测试用例清单功能完成后别急着交活按下面这张表过一遍大部分问题都能暴露。编号操作步骤预期结果1未登录直接访问 index.jsp重定向到 login.jsp2注册空用户名页面提示用户名不能为空3注册两次密码不一致页面提示两次密码不一致4注册已存在的用户名页面提示用户名已存在5用正确账密登录跳转个人信息展示页面并显示昵称6用错误密码登录提示用户名或密码错误7登录后发表留言留言出现在列表顶部8留言为空提交提示留言内容不能为空9留言内容含script页面按文本显示不弹窗10连续两次 POST 留言后刷新不会出现重复留言第 9 条最容易被忽略但也是面试或代码评审时最容易被问到的点。用浏览器的开发者工具看响应如果script原样出现在 HTML 里说明测试没过赶紧加上转义。6.2 密码存储升级从 MD5 到加盐如果这个项目要真正部署密码存储不能只做 MD5。MD5 已经被彩虹表攻破同一明文永远得到同一密文攻击者拿一个常见密码的 MD5 字典一比对就全出来了。升级方案是加随机盐一个用户一个盐值public static String md5WithSalt(String password, String salt) { String input password : salt; // 循环哈希 1000 次增加暴力破解成本 for (int i 0; i 1000; i) { input DigestUtils.md5Hex(input); } return input; }盐值可以用 UUID 随机生成存进 user 表单独一列。注册时生成盐并存储登录时先查盐再算哈希比对。这一步改动不大但安全等级提升了一个量级。配套的还有登录失败次数限制、验证码校验以及 Session 固定攻击防护——登录成功后调用session.changeSessionId()或者request.changeSessionId()换一个新会话 ID。6.3 Filter 统一编码与登录校验前面讲的req.setCharacterEncoding(UTF-8)每个 Servlet 都要写一遍写多了容易漏。经验是写一个编码过滤器统一处理WebFilter(/*) public class EncodingFilter implements Filter { public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { req.setCharacterEncoding(UTF-8); resp.setCharacterEncoding(UTF-8); chain.doFilter(req, resp); } }登录校验也可以做成 Filter拦截除了 login、register、静态资源以外的所有路径统一检查 session 里有没有 loginUser。这样比在每个 JSP 里写c:if判断更规范也避免遗漏某个页面。6.4 从课程设计到真实项目的思路迁移这套 jspjavabeanservlet 组合的最大价值是让你把 HTTP 的每个环节都摸透。等你理解了请求流转、Session 原理、Servlet 生命周期再看 Spring MVC 就会觉得亲切——DispatcherServlet 就是一个超级 ServletController 是 Servlet 的变体ModelAndView 里的 Model 对应 JavaBean。动态网页技术迭代快但 HTTP 协议二十年没变过Session 和 Cookie 的机制也没有变。热词里那些“基于 jsp 的毕业论文管理过程系统设计与实现”之类的题目换汤不换药核心仍然是用户登录、表单提交、数据展示这三板斧。最后说一个我自己的习惯每次完成这类项目我都会在一个文本文件里记录踩过的坑和对应的解决参数编码设置、连接 URL 参数、Session 配置下次再做相关项目直接翻开看。这个习惯帮我省下了大量重复排查的时间。希望这篇笔记里的经验也能帮你少走几段弯路。本文还有配套的精品资源点击获取