做Web课程设计,十个里有八个会选登录系统,但能把登录、页面保护、退出这几个环节做到结构清晰、代码不乱、后续还能扩展的,其实不多。这篇把JSP + Servlet + Session + Filter这套经典组合从头到尾拆开讲,适合刚学完Java Web基础、准备动手做第一个完整项目的同学,也适合做课程设计想要参考思路的人。
这个题目看起来简单,实际上把Web开发里最核心的几个概念全串起来了:客户端怎么把数据送到服务端,服务端怎么校验身份,校验通过后怎么记住用户的登录状态,没登录的人访问受保护页面时怎么被拦下来,还有退出时怎么把状态清干净。把这套逻辑走通一遍,后面做任何需要用户体系的系统,Spring MVC也好、Spring Boot也好,骨架都是同一个思路。
1. 整体设计与实现思路拆解
1.1 核心技术选型:为什么要用JSP + Servlet这套组合
现在Java Web开发的主流方案是Spring Boot + Spring MVC,很多同学上来就学框架,反而把底层的请求-响应模型、Session机制这些东西给跳过了。课程设计选JSP + Servlet,不是为了复古,而是这套东西能把HTTP协议的本质暴露得很清楚——浏览器发一个请求,Tomcat收到后创建一个request对象和一个response对象,Servlet里通过doGet/doPost取参数、写响应,JSP最终也是被编译成Servlet执行的,本质上就是一回事。
这个项目中,我选择了JSP负责页面展示,Servlet负责业务控制,因为JSP里写Java代码虽然被诟病,但在小项目里做页面回显非常方便——登录失败后把错误信息塞到request域里,JSP里用EL表达式直接取出来展示,省去了一大堆字符串拼接。Servlet则用来处理表单提交、调用数据库校验、管理Session状态。这两者的分工就是视图和控制的分离,对应的是Model 2架构。
1.2 登录状态管理方案:Session为主,Cookie为辅
登录验证最核心的问题,是怎么让服务器记住“这个用户已经登录过了”。
HTTP协议本身是无状态的,每一次请求都是独立的。同样一个浏览器,第一次请求登录页面,第二次请求提交账号密码,第三次请求访问受保护的用户列表页,服务器需要一种机制把这三次请求关联到同一个人身上。
这里用到了Session机制。用户登录成功后,服务器创建一个Session对象,并分配一个唯一的Session ID,通过响应头发给浏览器。浏览器会把Session ID存在一个名为JSESSIONID的Cookie里。后续每次请求,浏览器自动带上这个Cookie,服务器就能通过Session ID找到对应的Session,判断里面有没有登录用户的信息。
我选择Session而不是单纯靠Cookie存用户名,是因为Session的数据保存在服务器端,客户端只保存一个随机ID,安全性好很多。如果直接把用户名甚至密码放在Cookie里,客户端可以随意篡改,那登录验证就形同虚设了。
1.3 页面保护机制:Filter拦截器
页面保护的实现方案有几种:每个受保护页面的Servlet里都手动检查Session,或者写一个公共的BaseServlet统一检查,或者用Filter在请求到达Servlet之前统一拦截。
手动逐个页面检查的方式最容易理解,但缺点是重复代码多,万一漏掉一个页面就出现权限漏洞。Filter的方式是做一个统一的关卡——所有符合拦截规则的请求,都会先经过过滤器,由过滤器决定是放行还是拦截重定向。这样新增一个受保护页面时,不需要改任何Java代码,只要它的URL符合拦截规则,自动就被保护了。
这个思路就是把“是否已登录”这个横切逻辑,从每个业务Servlet里抽出来,放到一个统一的地方管理。代码维护起来清爽很多。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 环境准备与数据库设计
2.1 开发环境搭建
这个项目需要的环境非常标准:
- JDK 8或以上版本
- Tomcat 8.5或9.0
- MySQL 5.7或8.0
- Eclipse IDE for Enterprise Java and Web Developers 或 IntelliJ IDEA Ultimate
- Maven(用于管理依赖)
注意:如果用的是IntelliJ IDEA社区版,本身不带Web开发支持,直接建Dynamic Web Project会有阻碍。建议用IDEA Ultimate,或者干脆用Eclipse,课程设计场景下Eclipse完全够用,而且Tomcat配置更直观。
创建一个标准的Dynamic Web Project后,项目结构大概是这样的:
code复制/src/main/java # Java源码
├── com.example.entity # 实体类
├── com.example.dao # 数据访问层
├── com.example.servlet # Servlet控制层
├── com.example.filter # 过滤器
└── com.example.util # 工具类(数据库连接等)
/src/main/webapp # Web资源
├── login.jsp # 登录页面
├── index.jsp # 受保护主页面
├── userList.jsp # 用户列表页
├── css/ # 样式文件
└── WEB-INF/web.xml # Web配置
2.2 用户表设计
登录验证系统的核心数据表非常简洁,就是一张用户表:
sql复制CREATE DATABASE IF NOT EXISTS login_demo DEFAULT CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci;
USE login_demo;
CREATE TABLE t_user (
id INT PRIMARY KEY AUTO_INCREMENT COMMENT '用户ID',
username VARCHAR(50) NOT NULL UNIQUE COMMENT '用户名',
password VARCHAR(100) NOT NULL COMMENT '密码(MD5加盐)',
nickname VARCHAR(50) DEFAULT NULL COMMENT '昵称',
create_time DATETIME DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间'
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='用户表';
密码字段我特意设计成VARCHAR(100),因为存储的不是明文密码,而是加盐后的MD5哈希值。这是很多课程设计里容易被忽略的点——直接把明文密码存数据库,答辩时被老师问一句“密码安全怎么保证”就卡住了。
插入两条测试数据:
sql复制INSERT INTO t_user (username, password, nickname) VALUES
('zhangsan', MD5('123456zhangsan_salt'), '张三'),
('lisi', MD5('123456lisi_salt'), '李四');
这里用MD5加盐只是一个课程设计层面够用的方案,生产环境建议用BCrypt这类专门为密码哈希设计的算法。MD5本身已经不够安全,加盐也只是提高破解成本,但作为教学项目理解“不能存明文”这个原则已经完全够用了。
2.3 JDBC连接工具类
数据访问用最朴素的JDBC,不引入MyBatis,因为课程设计的重点在登录验证逻辑,而不是ORM框架。工具类负责获取数据库连接和关闭资源:
java复制package com.example.util;
import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.ResultSet;
import java.sql.SQLException;
import java.sql.Statement;
public class DBUtil {
private static final String DRIVER = "com.mysql.cj.jdbc.Driver";
private static final String URL = "jdbc:mysql://localhost:3306/login_demo?useUnicode=true&characterEncoding=utf8&serverTimezone=Asia/Shanghai&useSSL=false";
private static final String USERNAME = "root";
private static final String PASSWORD = "root";
static {
try {
Class.forName(DRIVER);
} catch (ClassNotFoundException e) {
throw new ExceptionInInitializerError(e);
}
}
public static Connection getConnection() throws SQLException {
return DriverManager.getConnection(URL, USERNAME, PASSWORD);
}
public static void close(ResultSet rs, Statement stmt, Connection conn) {
try { if (rs != null) rs.close(); } catch (SQLException e) { e.printStackTrace(); }
try { if (stmt != null) stmt.close(); } catch (SQLException e) { e.printStackTrace(); }
try { if (conn != null) conn.close(); } catch (SQLException e) { e.printStackTrace(); }
}
}
URL里带上useUnicode=true和characterEncoding=utf8,是为了让Java程序和MySQL之间传递中文正常。不加这两个参数,登录成功后页面展示用户昵称时很可能出现乱码。
3. 核心功能实现:登录验证
3.1 登录页面设计
登录页面是一个标准的HTML表单,提交方式是post。这里有个很重要的点:用get方式提交登录表单,账号密码会直接暴露在URL地址栏里,浏览器历史记录里也会存一份,这是绝对不能接受的。post方式把参数放在请求体里,不会出现在URL上。
login.jsp核心代码:
jsp复制<%@ page language="java" contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<title>用户登录</title>
<style>
.login-box { width: 360px; margin: 100px auto; padding: 30px; border: 1px solid #ddd; border-radius: 8px; }
.form-item { margin-bottom: 15px; }
.form-item input { width: 100%; padding: 8px; box-sizing: border-box; }
.error { color: red; margin-bottom: 10px; }
</style>
</head>
<body>
<div class="login-box">
<h2>系统登录</h2>
<%-- 登录失败后,错误信息通过request域传递到这里展示 --%>
<%
String error = (String) request.getAttribute("error");
if (error != null) {
%>
<div class="error"><%= error %></div>
<%
}
%>
<form action="loginServlet" method="post">
<div class="form-item">
<label>用户名:</label>
<input type="text" name="username" required>
</div>
<div class="form-item">
<label>密码:</label>
<input type="password" name="password" required>
</div>
<button type="submit" style="width:100%;padding:8px;background:#4CAF50;color:#fff;border:none;">登录</button>
</form>
</div>
</body>
</html>
JSP页面里用Java代码块来读取request域中的错误信息,这是最直接的写法。后面如果想做得更规范,可以用EL表达式+JSTL来替换,不过对小项目区别不大。
3.2 登录Servlet逻辑拆解
LoginServlet是整个登录验证的核心,配置好@WebServlet注解后,表单提交请求会到达这里。
java复制package com.example.servlet;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
import java.sql.Connection;
import java.sql.PreparedStatement;
import java.sql.ResultSet;
import java.sql.SQLException;
import com.example.util.DBUtil;
@WebServlet("/loginServlet")
public class LoginServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
request.setCharacterEncoding("UTF-8");
response.setCharacterEncoding("UTF-8");
String username = request.getParameter("username");
String password = request.getParameter("password");
// 基础校验:任何一项为空则返回错误
if (username == null || username.trim().isEmpty()
|| password == null || password.trim().isEmpty()) {
request.setAttribute("error", "用户名和密码不能为空");
request.getRequestDispatcher("login.jsp").forward(request, response);
return;
}
// 密码加盐后计算MD5
String salt = username + "_salt";
String hashedPassword = MD5Util.md5(password + salt);
// 数据库校验
Connection conn = null;
PreparedStatement ps = null;
ResultSet rs = null;
try {
conn = DBUtil.getConnection();
String sql = "SELECT id, username, nickname FROM t_user WHERE username = ? AND password = ?";
ps = conn.prepareStatement(sql);
ps.setString(1, username);
ps.setString(2, hashedPassword);
rs = ps.executeQuery();
if (rs.next()) {
// 登录成功:将用户信息放入Session
HttpSession session = request.getSession();
session.setAttribute("userId", rs.getInt("id"));
session.setAttribute("username", rs.getString("username"));
session.setAttribute("nickname", rs.getString("nickname"));
// 跳转到主页面
response.sendRedirect("index.jsp");
} else {
request.setAttribute("error", "用户名或密码错误");
request.getRequestDispatcher("login.jsp").forward(request, response);
}
} catch (SQLException e) {
e.printStackTrace();
request.setAttribute("error", "系统异常,请稍后重试");
request.getRequestDispatcher("login.jsp").forward(request, response);
} finally {
DBUtil.close(rs, ps, conn);
}
}
}
3.3 关键细节:为什么登录成功后用sendRedirect,登录失败用forward
这是一个非常经典的细节问题。登录成功后我用的是sendRedirect("index.jsp"),也就是重定向;登录失败后我用的是forward,也就是转发。
两者的区别在于:重定向会发一个新的请求给浏览器,浏览器地址栏会变成index.jsp,同时之前的request对象销毁,新请求里不携带登录失败时设置的error属性。转发是服务器内部直接跳转,浏览器地址栏不变,请求和request对象是同一个,所以页面能拿到之前setAttribute的error信息。
用重定向还有一个更重要的原因:防止表单重复提交。如果登录成功后用forward,用户按F5刷新index.jsp时,浏览器会重新执行最后一次请求——也就是提交登录表单,导致账号密码被重复提交。而重定向之后,地址栏已经变了,刷新是按新地址重新请求index.jsp,不会触发表单重复提交。
3.4 密码加密工具类
MD5Util是一个封装好的工具类:
java复制package com.example.util;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
public class MD5Util {
public static String md5(String text) {
try {
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] bytes = md.digest(text.getBytes("UTF-8"));
StringBuilder sb = new StringBuilder();
for (byte b : bytes) {
String hex = Integer.toHexString(0xff & b);
if (hex.length() == 1) {
sb.append('0');
}
sb.append(hex);
}
return sb.toString();
} catch (Exception e) {
throw new RuntimeException(e);
}
}
}
这里盐值直接用用户名拼接固定的salt字符串,目的是让同一个密码在不同用户名下生成的哈希值不一样。测试数据里插入用户的密码是md5('123456zhangsan_salt'),登录验证时同样计算md5(输入的密码 + 用户名 + "_salt"),两边结果一致才说明密码正确。
4. 页面保护:用Filter统一拦截未登录请求
4.1 过滤器的作用机制
过滤器是Java Web三大组件(Servlet、Filter、Listener)之一,它在Servlet处理请求之前介入,在Servlet处理完响应之后也能介入。整个请求流程变成:
浏览器发起请求 → Tomcat按URL匹配过滤器 → 过滤器chain.doFilter放行 → Servlet处理业务 → 返回响应 → 过滤器做后处理 → 返回浏览器
页面保护用的是过滤器里的前置拦截。过滤器的核心方法doFilter做的事情是:先检查Session里有没有登录用户的标记,如果有就放行,如果没有就重定向到登录页。
java复制package com.example.filter;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.annotation.WebFilter;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
@WebFilter("/*")
public class LoginFilter implements Filter {
public void init(FilterConfig filterConfig) throws ServletException {
System.out.println("LoginFilter 初始化");
}
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) resp;
// 获取Session(如果没有Session则返回null,不创建新Session)
HttpSession session = request.getSession(false);
boolean loggedIn = (session != null && session.getAttribute("userId") != null);
// 获取请求路径
String uri = request.getRequestURI();
String contextPath = request.getContextPath();
String path = uri.substring(contextPath.length());
// 不需要登录就能访问的资源列表
boolean needLogin = true;
if (path.equals("/login.jsp") || path.equals("/loginServlet")) {
needLogin = false;
}
if (path.startsWith("/css/") || path.startsWith("/js/") || path.startsWith("/images/")) {
needLogin = false;
}
// 登录页面本身及其提交地址放行,静态资源放行
if (needLogin && !loggedIn) {
// 未登录,重定向到登录页
response.sendRedirect(request.getContextPath() + "/login.jsp");
return;
}
// 已登录或放行路径,继续走后续流程
chain.doFilter(req, resp);
}
public void destroy() {
System.out.println("LoginFilter 销毁");
}
}
4.2 过滤器设计中的几个关键决策
第一个关键决策是使用request.getSession(false)而不是request.getSession()。不带参数的那个getSession()在Session不存在时会自动创建一个新Session。过滤器里如果直接调用getSession(),所有访问者,包括那些根本没打算登录的人,都会被动创建一个Session对象,白白浪费服务器内存。getSession(false)只在Session已经存在时返回现有Session,不存在时返回null,这样未登录用户就不会产生多余的Session。
第二个关键决策是必须放行静态资源。如果Filter用/*拦截所有请求,那么CSS、JS、图片这些静态资源也会被拦下来。问题在于,登录页本身引用了CSS样式文件,如果CSS被拦截重定向到登录页,浏览器拿到的是一个HTML页面却按CSS格式去解析,样式全部失效,页面看起来一团糟。所以过滤器里要专门放行css、js、images这些路径。
第三个关键决策是登录Servlet请求本身也必须放行。如果loginServlet被拦截了,用户登录时提交账号密码的请求会被过滤器重定向回login.jsp,表单数据丢失,登录就永远不可能成功。这是一个新手非常容易踩的坑——只放行了login.jsp,忘了放行loginServlet。
4.3 拦截路径的两种配置方式
上面代码用的是注解方式@WebFilter("/*"),匹配所有请求。除了注解,还可以在web.xml里配置:
xml复制<filter>
<filter-name>loginFilter</filter-name>
<filter-class>com.example.filter.LoginFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>loginFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
如果项目里正好需要配置Servlet映射、欢迎页、错误页,用web.xml可以集中管理。注解方式的优点是省事,代码和配置放在一起。课程设计项目结构简单,用注解方式完全够用。
注意:如果同时用注解和web.xml配置同一个Filter,容易造成重复拦截的混乱。建议只选一种方式,不要混用。
5. 退出登录:清理服务端Session与客户端信息
5.1 退出登录的正确姿势
退出登录的核心是让服务器和客户端都“忘记”当前用户的登录状态。服务端的Session要销毁,客户端对应的JSESSIONID Cookie也要清除。
一个典型的退出登录Servlet:
java复制package com.example.servlet;
import javax.servlet.ServletException;
import javax.servlet.annotation.WebServlet;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpSession;
import java.io.IOException;
@WebServlet("/logoutServlet")
public class LogoutServlet extends HttpServlet {
private static final long serialVersionUID = 1L;
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws ServletException, IOException {
// 清空Session
HttpSession session = request.getSession(false);
if (session != null) {
session.invalidate(); // 销毁Session
}
// 清除JSESSIONID Cookie(可选,服务器端Session销毁后客户端JSESSIONID一般也被标记失效)
Cookie cookie = new Cookie("JSESSIONID", null);
cookie.setMaxAge(0);
cookie.setPath(request.getContextPath());
response.addCookie(cookie);
// 重定向到登录页
response.sendRedirect(request.getContextPath() + "/login.jsp");
}
}
session.invalidate()是退出登录最关键的一步。这个方法会让服务器销毁当前Session对象,之后即使客户端还拿着原来的JSESSIONID来访问,服务器也找不到对应的Session,只能创建一个新的。这时候过滤器判断session.getAttribute("userId"),拿到的是null,未登录用户就被拦截回登录页了。
手动清除JSESSIONID Cookie这一步是双保险。因为正常情况下Session失效后,JSESSIONID对应的服务端数据已经没了,但浏览器里还会留着这个ID,虽然它已经无效了。清理掉Cookie,浏览器连这个无效ID都不存在了,下次访问是全新的会话。
5.2 页面上的退出入口
在主页面上提供一个退出按钮:
jsp复制<div class="header">
<span>欢迎:${sessionScope.nickname != null ? sessionScope.nickname : sessionScope.username}</span>
<a href="logoutServlet">退出登录</a>
</div>
用EL表达式从Session域中取值,比Java代码块简洁。注意这里用了sessionScope这个隐含对象,明确告诉EL表达式从Session范围找nickname,而不是去request里找。
5.3 顺带处理:浏览器直接关闭、Session超时
如果用户没有点退出,而是直接关闭浏览器,服务端的Session并不会马上消失,它会在服务器端保留一段时间,直到超过超时时间被自动清理。Tomcat默认的超时时间是30分钟(可以在web.xml中配置session-config的timeout值)。
这就带来一个情况:用户关掉浏览器再打开,JSESSIONID还在Cookie里,重开后如果没超过30分钟,可能还能直接进入受保护页面。这在某些场景下是安全隐患。解决办法是设置更短的Session超时时间,或者在敏感页面上定期做额外的二次验证。
课堂测验、考试系统这类场景往往希望关掉浏览器就退出,那可以考虑用Cookie + 一个随机token的方式管理登录状态,而不是纯靠Session。不过课程设计阶段,用Session默认机制就够了,老师更看重的是登录验证逻辑本身是否清晰。
6. 常见问题与排查技巧
6.1 过滤器导致的重定向死循环
现象:登录成功后跳转index.jsp,页面一直刷新不出来,浏览器提示重定向次数过多。
原因:Filter的放行路径配置有问题。如果登录成功跳转的index.jsp没有在放行列表里,过滤器发现未登录(可能是实际已登录但Session没生效),又重定向回login.jsp,登录页又可能被拦截,来回跳转。
排查思路:先确认登录成功后Session到底有没有写入userId。在LoginServlet登录成功分支里加一行System.out.println("登录成功,userId=" + session.getAttribute("userId")),用控制台日志来验证。如果Session有值但过滤器还是拦,检查Session是否在过滤器里被误记为未登录。
另一种常见的死循环发生在logoutServlet未放行的情况下——用户点击退出,logoutServlet请求到达过滤器,如果未登录检查失败(此时用户已经登录),过滤器拦截并重定向到login.jsp,看起来就像是点了没反应。所以logoutServlet一般也要放行。
6.2 表单提交中文乱码
现象:登录界面输入中文用户名,后台接收到的是一串???,或者登录后页面上中文昵称乱码。
原因:Tomcat 8以上版本,POST请求的中文乱码问题通常只需要在Servlet最前面加一句request.setCharacterEncoding("UTF-8")就能解决。这句代码设置了请求体的解码格式,必须在读取任何request.getParameter()之前调用。
GET请求参数的中文处理方式不一样。Tomcat 8.0版本之后,GET请求的URI解码默认已经是UTF-8,所以正常不需要额外处理。如果是老版本的Tomcat,要在server.xml里给Connector配置URIEncoding="UTF-8"。课程设计一般用新版本Tomcat,不用纠结这个。
6.3 明明数据库有用户,登录却提示用户名或密码错误
排查步骤:
-
第一步先检查密码加密逻辑是否一致。数据库里存的哈希值是用
md5(密码 + 用户名 + "_salt")生成的,Servlet里校验时也必须用同样的拼接规则。最常见的错误是两边的盐拼接顺序不一样,一个拼成username+"_salt"+password,一个拼成password+username+"_salt",结果永远不一致。 -
第二步检查SQL语句参数顺序。PreparedStatement的setString(1, username)和setString(2, hashedPassword),要让顺序和SQL里?的顺序一一对应。
-
在控制台打印一下最终执行的SQL参数。很多课程设计项目习惯直接把SQL拼成字符串打出来看,但PreparedStatement没法直接打印最终SQL,所以我一般在执行前打印set进去的参数值,一眼就能看出问题在哪。
6.4 JSESSIONID在Cookie中的Path问题
把项目部署到Tomcat时,如果在访问路径上带了应用上下文(比如http://localhost:8080/login_demo/login.jsp),那么Cookie的默认Path就是/login_demo。如果手动创建Cookie时没有设置Cookie.setPath(request.getContextPath()),浏览器可能不会回传这个Cookie,导致Session一直无法保持。
手动清除JSESSIONID的时候尤其容易踩这个坑。不设置path的话,新Cookie的Path是默认来自请求的路径,如果是/login_demo/login.jsp请求触发的,Cookie Path可能不对。所以只要手动操作JSESSIONID这个Cookie,务必调用setPath(request.getContextPath())。
6.5 过滤器执行顺序的疑惑
现象:多个Filter都加载了,但执行顺序和预期不一样。
原因:Filter的执行顺序在web.xml里由filter-mapping的声明顺序决定,先声明先执行。使用注解@WebFilter时,执行顺序不保证,官方文档也说注解方式不保证顺序。如果一个项目同时有登录过滤器、字符编码过滤器、日志过滤器,建议用web.xml管理,避免出现编码过滤器还没执行,登录过滤器就开始处理请求导致乱码。
7. 项目完成后还可以怎么扩展
7.1 增加验证码机制
最简单的升级是在登录页加入图片验证码。用Java的BufferedImage + Graphics2D生成图片,把验证码文字存到Session中,登录时对比用户输入和Session中存储的值。这一步能有效防止脚本批量尝试密码,属于实用性很强的改进点。
7.2 记住登录状态(Remember Me)
Session本身有生命周期,关闭浏览器或超时后就会失效。想要实现“记住我”效果,可以额外发放一个长效Cookie(比如有效期7天),里面保存一个随机生成的token字符串,同时把这个token存在数据库的一张remember_token表里。用户带着Cookie访问时,过滤器发现没有登录Session但有有效token,就直接完成自动登录。
这个方案的难点在于token的安全存储,要定期清理过期token,不能把用户ID明文放在Cookie里。对课程设计来说,这是一个很有含金量的加分项。
7.3 基于角色的权限控制
当前项目只有“已登录”和“未登录”两种状态。实际系统中往往还需要区分“普通用户”和“管理员”——管理员能访问用户管理页面,普通用户只能访问自己的页面。
实现思路是在用户表里加一个role字段,Filter拦截时除了检查是否登录,还要检查当前请求需要的角色和用户所属角色是否匹配。这个逻辑可以进一步演进出RBAC(基于角色的访问控制)模型,但课程设计能做到角色判断这个粒度已经能完好地展示项目层次感了。
7.4 登录日志记录
每次登录成功或失败都向数据库的登录日志表插入一条记录,包含用户名、时间、IP地址、成功/失败状态。这既能为后续做“多次失败锁定账号”提供数据基础,也是一个很容易展示的系统完整度提升点。
7.5 通过BaseServlet优化Servlet数量
这个项目里有LoginServlet、LogoutServlet,未来扩展会增加UserServlet、AdminServlet等。可以在写一个BaseServlet,内部用反射根据请求参数调用对应的方法:
java复制package com.example.servlet.base;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.lang.reflect.Method;
public class BaseServlet extends HttpServlet {
protected void doGet(HttpServletRequest request, HttpServletResponse response)
throws javax.servlet.ServletException, java.io.IOException {
doPost(request, response);
}
protected void doPost(HttpServletRequest request, HttpServletResponse response)
throws javax.servlet.ServletException, java.io.IOException {
String method = request.getParameter("method");
try {
Method targetMethod = this.getClass().getDeclaredMethod(method, HttpServletRequest.class, HttpServletResponse.class);
targetMethod.setAccessible(true);
targetMethod.invoke(this, request, response);
} catch (Exception e) {
e.printStackTrace();
}
}
}
这样所有业务Servlet都继承BaseServlet,一个Servlet里就能写多个业务方法,通过?method=login和?method=logout的方式调用,扩展起来会很顺手。
最后说点实在的
做这类登录验证系统,最大的收获不在于“会写那几个文件”,而在于真正理解了HTTP请求从浏览器发出、经过服务端处理、再回到浏览器的完整链路。Session是什么东西、为什么放在服务端、Cookie是怎么和Session配合的、过滤器为什么能拦截请求——这些概念背多少遍书都不如自己亲手跑通一遍来得深刻。
我在搭建这个项目时,也经历过反复翻文档排查问题的阶段。最常犯的错误是过滤器的路径判断写漏了某个资源,或者登录成功后的跳转方式和失败时的跳转方式没区分清楚。一个很好的排查窍门是:在Servlet和Filter的入口处临时打印请求路径和Session状态,把系统当成黑盒观察每一次请求:到哪一步被拦了、Session在哪个时间点写入的,几条日志就能把整个流程看得清清楚楚。在很多课程项目里,把日志临时留在代码里被老师看到,会给你加分不少,因为直接证明了你对请求链路的理解是清晰的。
