1. 项目整体设计与技术选型
1.1 为什么要用 JSP + Servlet + Filter 这套组合
很多同学拿到“登录验证系统”这个实验题,第一反应是:直接用 JSP 把页面写了,再用一个 JSP 处理登录判断,不是更简单吗?确实,纯 JSP 方案在代码量上会少一些,但这类实验的考察点从来不只是“能跑通”,而是看你有没有真正理解 Web 应用的职责划分。
JSP 的本质是模板页面,它擅长的是展示数据,而不是处理业务逻辑。如果把数据库查询、密码校验、Session 判断全堆在 JSP 里,一个页面几百行代码混在一起,后续维护就是一场灾难。更重要的是,Filter 做页面保护这件事,纯 JSP 方案根本做不干净——你总不想在每个页面顶部都复制一段“检查是否登录”的判断代码吧?
所以我在做这套实验时,坚持用的是经典的三层拆分:
- JSP:只负责渲染页面、展示提示信息、提供表单
- Servlet:负责接收请求、调用逻辑、控制页面跳转
- Filter:负责拦截未登录用户的非法访问,统一做页面保护
这个组合看起来“老派”,但它是 Java Web 最稳定的架构骨架。学透了这套逻辑,后面再接触 Spring MVC、Shiro、Spring Security,你会发现它们的核心思路完全没有变——Filter 就是拦截器的前身,Servlet 就是 Controller 的前身,思想是通的。
1.2 系统的完整请求流程设计
动手写代码之前,我建议你先在纸上把整个流程画清楚。登录验证系统的核心流程其实就三句话:谁来了、是不是合法用户、有没有权限访问目标页面。
具体到我这套实验的实现,请求流转是这样的:
- 用户在
login.jsp输入用户名和密码,点击提交,表单以 POST 方式发送到/login这个 URL(对应一个名为LoginServlet的控制器)。 LoginServlet从请求中取出参数,先做空值校验(用户名和密码都不能为空),再调用一个UserDao类的查询方法,去 MySQL 数据库的t_user表里查记录。- 如果用户名和密码匹配,就在 Session 中写入一个标志,比如
session.setAttribute("user", username),然后跳转到welcome.jsp。 - 如果用户名或密码错误,就回到
login.jsp,同时通过 request 域传递一个错误提示,比如“用户名或密码错误”。 - 页面保护这一层交给
AuthFilter:它拦截所有以/pages/*开头的 URL(或者用更简单的规则,拦截除了登录页和登录接口之外的所有请求),检查 Session 里有没有刚才写入的user属性。没有的话就重定向回login.jsp。 - 用户点击“退出登录”,发送请求到
/logout,对应的LogoutServlet调用session.invalidate()销毁整个会话,然后重定向到login.jsp,同时提示“您已安全退出”。
这套流程的关键在于:所有页面的权限校验都收敛在 Filter 这一层,后续哪怕新增十几个受保护页面,也只需要改过滤规则,不用一个个去改页面代码。
1.3 用户表与会话状态的最小化建模
既然是综合实验,数据库设计不用太复杂,但也不能只有一个 id 和 username 就完事。我采用的表结构是这样的:
CREATE TABLE t_user ( id INT PRIMARY KEY AUTO_INCREMENT, username VARCHAR(50) NOT NULL UNIQUE, password VARCHAR(100) NOT NULL, nickname VARCHAR(50), create_time TIMESTAMP DEFAULT CURRENT_TIMESTAMP );这里有两个细节值得注意。
第一,username加了UNIQUE约束。虽然登录实验本身不涉及注册功能,但唯一约束是用户表的基本素养,防止实验扩展时出现重复账号。
第二,password字段长度设为 100,不是 20。原因是后面如果升级成 MD5 加盐或者 SHA-256 哈希存储,哈希值的长度远大于明文密码。我见过不少同学的 password 字段只设了 20 个字符,结果存进去的时候被 MySQL 截断,登录怎么都验证不过,排查半天才发现是字段长度不够。
如果想把实验做得更完整,可以再加一张登录日志表,记录每次登录的时间、IP、是否成功。这个不是题目硬性要求,但作为综合实验的加分项非常合适,后文会详细说。
2. 环境准备与核心原理
2.1 开发环境的搭建清单
这套实验对硬件和软件的要求都不高,我当时的配置是:
| 组件 | 版本 | 说明 |
|---|---|---|
| JDK | 8 以上 | 推荐用 8,稳定且适配各种 Tomcat 版本 |
| Tomcat | 8.5 / 9.0 | 如果用 Tomcat 10 要注意包名变化 |
| MySQL | 5.7 / 8.0 | 5.7 够用,8.0 需要调整驱动依赖 |
| Eclipse / IDEA | 任意 | 用 IDEA 的话建议装 Tomcat 插件 |
| MySQL Connector/J | 5.1.49(对应 MySQL 5.7) | 注意驱动类路径差异 |
环境搭建中最容易踩的坑有两个。第一个是 Tomcat 版本和 JDK 版本不兼容,比如 Tomcat 10 要求 JDK 11 以上,旧项目直接跑不起来。第二个是驱动版本和数据库版本不匹配,MySQL 8.0 使用的驱动类是com.mysql.cj.jdbc.Driver,而 5.x 版本是com.mysql.jdbc.Driver,写错类名或者加载不到驱动,启动时直接报ClassNotFoundException。
如果你跟我一样用 Maven 管理依赖,pom.xml 里只需要加两个坐标:
<dependency> <groupId>javax.servlet</groupId> <artifactId>javax.servlet-api</artifactId> <version>4.0.1</version> <scope>provided</scope> </dependency> <dependency> <groupId>mysql</groupId> <artifactId>mysql-connector-java</artifactId> <version>8.0.33</version> </dependency>如果不熟悉 Maven,也可以用传统方式把驱动 jar 包直接扔到WEB-INF/lib目录下,效果是一样的。这里要注意的是,javax.servlet-api必须设置为provided作用域,因为 Tomcat 自带了 Servlet 容器实现,打包进 WAR 反而会冲突。
2.2 Session 到底是怎么工作的
页面保护的核心是 Session,但很多同学对 Session 的认知只停留在“好像是个存数据的地方”。要想把这套实验做出深度,面试或者答辩时能说清楚,必须弄明白几件事。
第一,Session 是服务端的数据存储。用户在浏览器首次访问服务器时,Tomcat 会创建一个HttpSession对象,并通过响应头Set-Cookie: JSESSIONID=xxx把这个会话的唯一标识下发到浏览器。浏览器后续每次请求都会带上这个 Cookie,服务器依据JSESSIONID找到对应的 Session。如果用户清空了 Cookie,或者浏览器禁用了 Cookie,服务器就再新建一个 Session,表现出来就是“刚登录又变成未登录了”。
第二,Session 默认有超时时间。Tomcat 默认的 Session 超时时间是 30 分钟,意味着用户 30 分钟没有操作,Session 就会失效,需要重新登录。这个时间可以在web.xml里配置:
<session-config> <session-timeout>30</session-timeout> </session-config>单位是分钟。实验演示的时候,我建议把超时时间调成 5 分钟,方便向老师展示“会话过期需要重新登录”这个特性。
第三,request.getSession()和request.getSession(true)是一回事,request.getSession(false)则不会创建新的 Session。在 Filter 中判断登录状态的正确姿势是request.getSession(false),然后判断是否为 null。如果直接用getSession(),那么即使一个从未访问过的用户也会被强行创建一个 Session,Filter 的判断就失去了意义。
2.3 密码绝对不能明文存储
我知道课程实验的要求通常只是比对用户名和密码,很多同学的t_user表里存的就是明文,比如123456。这样做虽然能跑通,但放到企业项目里就是重大漏洞。作为综合实验,如果你想体现工程化思维,密码存储这个点就可以做文章。
最简单的升级方案是用 MD5 加盐。MD5 本身是可以被彩虹表破解的,所以需要每个用户生成一个随机盐值,和密码拼接后再哈希。注册时把盐值和哈希结果一起存数据库,登录时用“用户提交的密码 + 从库里查出的盐值”重新计算哈希,比对是否一致。
public static String md5WithSalt(String password, String salt) { String input = salt + password; try { MessageDigest md = MessageDigest.getInstance("MD5"); byte[] digest = md.digest(input.getBytes("UTF-8")); // 转为十六进制字符串 StringBuilder sb = new StringBuilder(); for (byte b : digest) { sb.append(String.format("%02x", b)); } return sb.toString(); } catch (Exception e) { throw new RuntimeException(e); } }登录校验时,先用用户名查库,取到盐值和数据库里的哈希值,再对用户输入的密码计算哈希,最后用String.equals比对。这里有一个小技巧:比较两个哈希字符串时,不要用==,要用equals,并且可以用MessageDigest.isEqual做常量时间比较,避免时间侧信道攻击。当然,做实验不用这么深,但答辩时能说出这一点,会显得你对安全是有思考的。
3. 核心功能逐模块实现
3.1 数据库连接与 DAO 层的封装
实验里最常见的数据库操作问题,不是 SQL 写错,而是连接管理混乱——每用一个Connection就开一个,用完不关,数据库连接数很快耗尽。我建议用一个简单的 JDBC 工具类把所有连接逻辑收敛起来,再加一个 DAO 类专门负责查询用户。
public class DBUtil { private static final String URL = "jdbc:mysql://localhost:3306/login_demo?useSSL=false&characterEncoding=utf8&serverTimezone=Asia/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); } }注意这里的数据库连接参数:
characterEncoding=utf8保证中文参数不乱码serverTimezone=Asia/Shanghai解决 MySQL 8.0 的时区报错问题useSSL=false本地开发不需要 SSL
接下来是UserDao,我只封装了一个方法:按用户名查用户,返回一个User对象。登录时把数据库查出来的密码(或哈希值)和用户输入的密码做比对。
public class UserDao { public User findByUsername(String username) { String sql = "SELECT id, username, password, nickname FROM t_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; } }这里使用的try-with-resources是 Java 7 以后推荐的写法,能自动关闭连接、预处理语句和结果集,避免忘记关闭资源。SQL 中用?占位、用PreparedStatement传参,这是防 SQL 注入的基本操作,绝对不能省略,实验报告中如果写“用字符串拼接 SQL”,分数高不了。
3.2 登录 Servlet 的完整实现
登录接口是整个系统的入口,需要处理的细节最多。我的实现是这样的:
@WebServlet("/login") public class LoginServlet extends HttpServlet { private UserDao userDao = new UserDao(); @Override protected void doPost(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { request.setCharacterEncoding("UTF-8"); String username = request.getParameter("username"); String password = request.getParameter("password"); // 空值校验 if (username == null || username.trim().isEmpty() || password == null || password.isEmpty()) { request.setAttribute("error", "用户名和密码不能为空"); request.getRequestDispatcher("/login.jsp").forward(request, response); return; } User user = userDao.findByUsername(username.trim()); if (user != null && user.getPassword().equals(password)) { // 登录成功:写入 Session HttpSession session = request.getSession(); session.setAttribute("user", user.getNickname() != null ? user.getNickname() : username); session.setMaxInactiveInterval(300); // 5 分钟超时,方便演示 response.sendRedirect(request.getContextPath() + "/welcome.jsp"); } else { request.setAttribute("error", "用户名或密码错误"); request.setAttribute("username", username); request.getRequestDispatcher("/login.jsp").forward(request, response); } } @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { // 为方便演示,GET 请求直接转发到登录页 request.getRequestDispatcher("/login.jsp").forward(request, response); } }两个细节值得说明。
第一,登录成功后用的是sendRedirect,不是forward。原因很简单:如果用forward,地址栏会停留在/login,用户刷新页面就会重复提交表单,导致“重复登录”的脏数据。用sendRedirect发送 302 跳转,浏览器地址栏变成/welcome.jsp,刷新也不会重复提交,这是 PRG(Post-Redirect-Get)模式的核心思想。
第二,失败时用的是forward并且回显用户名。在登录失败时,把错误信息和用户输入的用户名放进 request 域,login.jsp里通过${error}显示错误,通过${username}回显输入值。这对用户体验很重要——用户输错密码后,用户名还在,只需要重新输密码,不用重打一遍用户名。
3.3 页面保护:Filter 拦截器的实现与配置
Filter 是整这套实验里最容易出彩、也最容易出问题的部分。很多同学知道要用 Filter,但配置不对,过滤规则写错,导致要么登不进去、要么页面保护形同虚设。
我用的 Filter 实现:
@WebFilter("/*") public class AuthFilter 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(); String contextPath = request.getContextPath(); String path = uri.substring(contextPath.length()); // 放行不需要登录的路径 if (path.equals("/login.jsp") || path.equals("/login") || path.equals("/logout")) { chain.doFilter(req, resp); return; } // 静态资源(CSS、JS、图片)也放行 if (path.startsWith("/static/")) { chain.doFilter(req, resp); return; } HttpSession session = request.getSession(false); if (session != null && session.getAttribute("user") != null) { // 已登录,放行 chain.doFilter(req, resp); } else { // 未登录,重定向到登录页 String queryString = request.getQueryString(); String target = queryString == null ? path : path + "?" + queryString; request.getSession().setAttribute("targetUrl", target); response.sendRedirect(contextPath + "/login.jsp"); } } }这套 Filter 的逻辑只需要记住一个原则:静态资源和认证接口放行,其余一律拦截。在web.xml里配置的时候,也可以写成<url-pattern>/*</url-pattern>,和注解效果一样。
我踩过最大的坑是配置了@WebFilter("/pages/*")但项目没有pages目录,导致 Filter 完全不起作用。后面我换成/*再配合内部放行逻辑后,这个问题就彻底解决了。这里给一个建议:Filter 的 URL 匹配是基于路径前缀的粗粒度匹配,不是精确到文件的,用/*统一拦截并在代码中做白名单放行,最稳定。
另外一个细节:被 Filter 重定向到登录页时,把用户原本想访问的 URL 记录到 Session 里(代码中的targetUrl)。登录成功后就可以读取这个值,跳回原来的页面。这个功能在 Web 中叫“回跳”,实战中很常用,即使实验不要求,写进报告也是加分项。
3.4 登录页、欢迎页与退出登录
页面部分不需要花哨,但要把该展示的信息展示清楚。登录页我用了很简洁的表单设计,三个关键点:错误提示区、用户名回显、密码框。
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>用户登录</title> </head> <body> <h2>用户登录</h2> <%-- 错误提示 --%> <c:if test="${not empty error}"> <p style="color:red;">${error}</p> </c:if> <form action="${pageContext.request.contextPath}/login" method="post"> <label>用户名:</label> <input type="text" name="username" value="${username}" required /> <br/> <label>密码:</label> <input type="password" name="password" required /> <br/> <button type="submit">登录</button> </form> </body> </html>看得仔细的话会发现这里用了 JSTL 的<c:if>和${}EL 表达式。如果项目里没有引入 JSTL 依赖,也可以用纯 Java 脚本<% if (request.getAttribute("error") != null) { %>代替。但既然做实验,我建议顺手把 JSTL 加上,这是 JSP 技术栈里必须掌握的东西,后续写迭代循环、条件判断都靠它。
欢迎页welcome.jsp主要展示登录用户的信息和一个退出按钮:
<%@ page contentType="text/html;charset=UTF-8" language="java" %> <html> <head> <title>欢迎页</title> </head> <body> <h2>欢迎,${sessionScope.user}!</h2> <p>你已成功登录。这个页面受 Filter 保护,未登录用户无法直接访问。</p> <a href="${pageContext.request.contextPath}/logout">退出登录</a> </body> </html>退出登录的实现更加简单,关键是session.invalidate():
@WebServlet("/logout") public class LogoutServlet extends HttpServlet { @Override protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { HttpSession session = request.getSession(false); if (session != null) { session.invalidate(); } request.getSession().setAttribute("msg", "您已安全退出"); response.sendRedirect(request.getContextPath() + "/login.jsp"); } }这里我额外做了一步:退出后往新的 Session 里放一个msg提示,登录页顶部显示“您已安全退出”。注意,操作顺序是先invalidate()销毁旧会话,再获取新会话放提示,否则提示会被销毁掉。这个顺序错了,退出提示就会神奇地消失。
4. 常见问题与排查技巧实录
4.1 我在实际操作中遇到的 5 个典型问题
课程实验里,相同的问题我看了不止十遍。这里直接把典型的画面记录下来,对号入座能省下很多排查时间。
| 现象 | 可能原因 | 解决办法 |
|---|---|---|
404:访问/login找不到资源 | Servlet 类没有加@WebServlet注解,或web.xml没有配置映射 | 检查注解路径,重启 Tomcat 清缓存 |
500:ClassNotFoundException: com.mysql.cj.jdbc.Driver | MySQL 驱动包没有放到WEB-INF/lib,或版本与 MySQL 版本不匹配 | 确认 jar 包已导入项目,检查驱动类名 |
| 中文乱码 | 页面或 Servlet 没有统一设置 UTF-8 | 每个 JSP 加pageEncoding="UTF-8",Servlet 里request.setCharacterEncoding("UTF-8") |
| 直接访问受保护页面也能看到内容 | Filter 的url-pattern配置错误,或 Filter 没有加载 | 确认 Filter 类有@WebFilter("/*"),检查 Tomcat 日志是否加载 |
| 登陆成功后刷新会重复提交 | 登录成功用了forward而不是sendRedirect | 改成sendRedirect跳转 |
| Session 提示登录超时 | session-timeout没配置或配置得太短 | 在web.xml配<session-timeout>30</session-timeout> |
第一行的 404 是最容易出现的,因为有些同学用的是旧式web.xml配置 Servlet 的方式,忘记在<servlet-mapping>里写<url-pattern>,结果 Tomcat 匹配不到 URL。现在用注解驱动开发后,这类问题少了很多,但同样要注意:改完代码必须重启 Tomcat,JSP 文件改动会被热加载,但 Java 类的改动不一定毫秒级生效。我之前在 IDEA 里改完 Servlet 后点了一下“更新资源”,结果跑起来还是旧逻辑,折腾了半天才发现热部署没生效。
4.2 排查必备:看 Tomcat 日志的姿势
遇到问题第一件事是看 Tomcat 控制台日志,不是改代码。很多同学看到红色报错信息就懵了,其实 Tomcat 的报错信息非常规范,重点是看Caused by后面的部分。举个真实场景:
java.lang.NullPointerException at com.example.dao.UserDao.findByUsername(UserDao.java:23) at com.example.servlet.LoginServlet.doPost(LoginServlet.java:41)这种情况九成是数据库返回了null。原因可能是用户名查不到记录,也可能是DBUtil.getConnection()返回了null。这时候在UserDao.findByUsername方法的第一行加一个System.out.println("username = " + username)打印一下入参,再逐行看是哪一步出现的空指针,比无脑去改 SQL 高效得多。
我个人的排查习惯是“二分法”:先在 Filter 入口打印path和 session 状态,确认请求有没有走到正确的地方;再在 Servlet 打印参数,确认表单有没有提交成功;最后在 DAO 打印 SQL 参数,确认数据库层面的问题。这样每走一步就圈定一个范围,定位问题的速度能快好几倍。
4.3 浏览器开发者工具帮大忙
还有一个非常实用的排查姿势:按 F12 打开浏览器的开发者工具,切换到 Network 面板,刷新页面后能看到每个请求的状态码和重定向轨迹。比如登录成功后,应该看到 302 跳转到welcome.jsp,如果看到的是 200 并且地址栏还停在/login,说明没有正确使用重定向。未登录直接访问受保护页面时,应该看到 302 跳转到login.jsp,如果没有跳转,说明 Filter 压根没生效。
重定向和转发的区别,通过 Network 面板非常直观:重定向是浏览器先后发出两个请求,转发只是在服务器内部跳转、浏览器只发了一个请求。这个观察能让你对“请求-响应”模型的理解上一个台阶。
5. 实验之外的深度扩展
5.1 登录日志、记住我、验证码三个加分项
实验题目只要求登录、保护、退出,但如果想在答辩时多讲点东西,这三个扩展方向是最合理的。
登录日志表是最“轻量”的加分项。建一张t_login_log表,字段包括id、username、login_time、ip、success。登录成功或失败时插入一条记录。注意获取用户 IP 的方式:request.getRemoteAddr()得到的是服务器视角的 IP,如果经过反向代理(比如 Nginx),需要额外处理X-Forwarded-For头。实验环境一般不用考虑这个,但能说出来就说明你懂生产环境的坑。
记住我(Remember Me)的实现思路是:登录成功后生成一个加密的 Token,写到 Cookie 里,设置较长的有效期(比如 7 天)。下次用户访问时,Filter 检查到 Session 中没有登录标志,但 Cookie 中有有效 Token,就自动恢复登录状态。这个功能可以在答辩时引出“Session 和 Cookie 的区别”“Token 有效期”这些话题。
验证码的实现用现成的库可以,但我更推荐手写一个简易版。生成一个随机四位数字或字母,画到BufferedImage上,把答案存到 Session 里,登录时比对。调包虽然简单,但手写能展现你对 Java 图形编程、随机数、Session 的综合掌握。这个小模块在综合实验里的性价比很高。
5.2 实验报告里值得写的几个“为什么”
除了代码,实验报告也是分数的大头。写报告的时候,不要只抄代码和截图,把下面这些问题回答清楚,会让报告有真正的思考深度:
- 为什么不直接在 JSP 里校验登录状态,而是用 Filter?
- 为什么登录成功要用
sendRedirect而不是forward? - 为什么数据库密码建议用哈希存储而不是明文?
- 为什么数据库连接要用
PreparedStatement而不是拼接 SQL? - 为什么 Session 会过期,过期的机制是什么?
这些问题每个都能展开几百字。答辩的时候老师最喜欢问的就是“你这里为什么这么写”,能逻辑清晰地回答出来,比代码写得漂亮更重要。
5.3 这套系统还能往哪个方向演进
最后聊一个方向问题。这个登录验证系统虽然简单,但它几乎包含了所有 Web 框架的初始形态。后面学 SSM(Spring + Spring MVC + MyBatis)的时候,你会发现LoginServlet变成了LoginController,UserDao变成了 Mapper 接口,AuthFilter变成了拦截器。唯一不同的是,Spring 把对象的创建和依赖关系管理了起来,让代码的扩展性更强。
如果想在课程设计上继续进阶,下一步可以把“用户登录”升级成“基于角色的访问控制(RBAC)”,引入用户表、角色表、权限表的三表关联;再进一步可以加入“接口幂等性”和“登录防重提”的处理,用 Token 机制解决表单重复提交问题。这些都是脱离课程实验、走向真实开发的必经之路。
做这个实验的时候,我自己的体会是:弄懂原理比跑通代码重要得多。网上代码一抓一大把,但只有当你理解了为什么 Filter 能拦截请求、为什么 Session 能保存状态、为什么重定向能防重复提交之后,你才算真正“会”了这个实验。把每个环节背后的“为什么”都吃透,这套登录验证系统就是你以后无数系统的一个坚实底座。
最后分享一个检查小习惯:把所有 JSP、Servlet、Filter 的代码写完以后,务必用浏览器干净模式测试一遍,依次验证“正常登录”“错误密码”“未登录直接访问”“登录后访问”“退出后再访问”五个场景。这五个场景全部通过,实验就稳了。