👋 关于作者
你好,这里是一条泥憨鱼 | 专注于 后端开发 、AI 前沿技术与计算机基础 。

🏠 个人主页:一条泥憨鱼
通过技术博客记录技术成长,分享学习心得与经验。期待与您共同交流!
📚精选技术专栏:
|-------------------------------------------------------------------------------------------|--------------------------------------------------------------------------------------------|
| ❄️后端开发 | ❄️Redis| 黑马点评 |
| ❄️从0开始学习计算机网络 | ❄️数据结构与算法 |
| ❄️苍穹外卖 | ❄️探秘AI与agent |

校验写在 Filter 还是 Interceptor,几乎每个 Spring 项目都会遇到一次。
一条请求的完整链路
把一次 HTTP 请求想象成穿过几层管道的流体,先看它到底走了哪些节点:
浏览器
→ Tomcat(Servlet 容器)
→ FilterChain
→ DispatcherServlet
→ HandlerMapping(找到 Controller 方法)
→ HandlerExecutionChain(Interceptor 链)
→ Controller 方法
→ 返回视图/JSON
这条链上有一条关键的分界线:Filter 属于 Servlet 容器规范,Interceptor 属于 Spring MVC。Tomcat 只认识 Servlet 规范里的东西,Filter 接口就定义在这个规范里,所以它由 Tomcat 调用,跟 Spring 没有任何关系。而 DispatcherServlet 是 Spring MVC 的入口 Servlet,Interceptor 是在它内部、由 HandlerMapping 组装出来的,属于 Spring 自己的机制。
这个归属差异直接决定了后面所有行为:Filter 执行时 Spring 的 ApplicationContext 还没给它做任何注入,Interceptor 执行时整个 Spring MVC 的上下文已经就绪,能拿到 HandlerMethod 这种描述"哪个类的哪个方法在处理这次请求"的对象。

过滤器:Servlet 规范里的第一道闸门
Filter 接口只有三个方法:init、doFilter、destroy。init 在容器启动时调用一次,destroy 在容器关闭时调用,真正干活的是 doFilter:
java
public class EncodingFilter implements Filter {
@Override
public void doFilter(ServletRequest request,
ServletResponse response,
FilterChain chain) throws IOException, ServletException {
// chain.doFilter 之前:请求进入业务逻辑前
request.setCharacterEncoding("UTF-8");
response.setContentType("application/json;charset=UTF-8");
chain.doFilter(request, response); // 放行,交给链上的下一个节点
// chain.doFilter 之后:响应即将返回浏览器前
// 这里能拿到完整的响应,适合做耗时统计、响应头补写
}
}
FilterChain 是一个链式结构,chain.doFilter() 的含义是"把请求交给下一个 Filter,如果后面没有 Filter 了,就交给 Servlet"。这个设计带来一个很有用的性质:doFilter 前后都能写逻辑,形成一个环绕结构。请求耗时统计、MDC 埋点、响应包装,都靠这个环绕能力实现。
一个典型的场景是读取请求体做签名校验或日志。HttpServletRequest 的输入流默认只能读一次,读完 Controller 就拿不到了,这时需要用包装类缓存:
java
// 把请求体缓存进内存,让后续 Controller 仍能正常读取
ContentCachingRequestWrapper wrapped =
new ContentCachingRequestWrapper(request);
chain.doFilter(wrapped, response);
// 请求处理完,从缓存里拿到完整请求体
byte[] body = wrapped.getContentAsByteArray();
Filter 的能力边界也很明确。它拿到的是 ServletRequest,这是 Servlet 规范里的通用接口,里面根本没有"当前是哪个 Controller 方法"这种概念。想在 Filter 里判断某个方法上有没有 @RequireLogin 注解,是做不到的。
另一个容易踩的点是依赖注入。Filter 由 Servlet 容器创建,默认不在 Spring 的 Bean 管理范围内。如果 Filter 里要用 RedisTemplate 或某个 Mapper,直接用 @Autowired 注入会得到 null。解决办法通常有两个:一是把 Filter 注册成 Spring Bean(通过 FilterRegistrationBean),让 Spring 管理生命周期;二是在 Filter 里通过 WebApplicationContextUtils 手动取。前者更干净。
拦截器:Spring MVC 的 HandlerExecutionChain 机制
请求进到 DispatcherServlet 之后,HandlerMapping 会做两件事:找到处理这次请求的 Handler(通常是一个 HandlerMethod,封装了 Controller 实例和方法对象),再把当前容器里所有匹配路径的 Interceptor 取出来,和 Handler 一起组装成 HandlerExecutionChain。这个链路对象就是 Interceptor 机制的核心载体。
HandlerInterceptor 有三个回调方法,触发时机完全不同:
java
public class AuthInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest req,
HttpServletResponse resp,
Object handler) throws Exception {
// Controller 方法执行之前
// 返回 false 直接中断,后续拦截器和 Controller 都不执行
return true;
}
@Override
public void postHandle(HttpServletRequest req,
HttpServletResponse resp,
Object handler,
ModelAndView mv) throws Exception {
// Controller 方法正常执行完、视图渲染之前
// 注意:Controller 抛异常时,这个方法不会执行
}
@Override
public void afterCompletion(HttpServletRequest req,
HttpServletResponse resp,
Object handler,
Exception ex) throws Exception {
// 整个请求处理完成之后,无论成功还是异常都会执行
// 适合做资源清理
}
}
三个方法的执行条件要分清楚:
preHandle 返回 false,整条链在这里断掉,postHandle 和 afterCompletion 都不会执行。
preHandle 返回 true,Controller 正常执行完,postHandle 执行,然后 afterCompletion 执行。
Controller 执行中抛异常,postHandle 被跳过,但 afterCompletion 仍然会执行,异常对象通过参数传进来。
这个差异很实用。比如在 preHandle 里往 ThreadLocal 写了用户信息,清理动作必须放 afterCompletion------放 postHandle 的话,一旦业务抛异常,ThreadLocal 就永远留在当前线程里,线程池复用时会串数据。

关键的一点是 preHandle 的第三个参数 handler。对于普通的 Controller 请求,这个对象就是 HandlerMethod,可以从中拿到方法对象和类对象:
java
if (handler instanceof HandlerMethod handlerMethod) {
// 读取方法上的注解
RequireLogin annotation = handlerMethod
.getMethodAnnotation(RequireLogin.class);
if (annotation == null) {
return true; // 没标注解,放行
}
}
这正是 Filter 做不到的事情。基于注解的权限控制、接口级别的限流、操作日志记录,都依赖这个能力。
登录拦截器怎么写
先给出实现。拦截器本身:
java
public class LoginInterceptor implements HandlerInterceptor {
private final TokenService tokenService;
// 通过构造器注入,因为拦截器由 Spring 管理
public LoginInterceptor(TokenService tokenService) {
this.tokenService = tokenService;
}
@Override
public boolean preHandle(HttpServletRequest req,
HttpServletResponse resp,
Object handler) throws Exception {
// 预检请求直接放行,否则浏览器跨域会失败
if (HttpMethod.OPTIONS.matches(req.getMethod())) {
return true;
}
String token = req.getHeader("Authorization");
if (token == null || !tokenService.isValid(token)) {
// 返回 JSON,不要重定向
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
resp.setContentType("application/json;charset=UTF-8");
resp.getWriter().write("{\"code\":401,\"msg\":\"未登录\"}");
return false;
}
return true;
}
}
注册需要在 WebMvcConfigurer 里完成,注意拦截器必须由 Spring 管理,否则拿不到注入的 TokenService:
java
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Bean
public LoginInterceptor loginInterceptor(TokenService tokenService) {
return new LoginInterceptor(tokenService);
}
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(loginInterceptor(null))
.addPathPatterns("/**") // 默认全拦
.excludePathPatterns(
"/login", // 登录接口
"/error", // 错误页
"/static/**", "/css/**", // 静态资源
"/js/**", "/images/**",
"/favicon.ico"
);
}
}
这段代码里有三个坑,每一个都很常见。
第一个坑:静态资源和登录页被拦,形成死循环。用 addPathPatterns("/**") 全拦之后,如果登录页的 HTML、CSS、JS 也被拦,浏览器拿不到登录页,用户就无法登录;登录接口本身被拦,也会导致永远登不进去。所以 excludePathPatterns 必须把登录接口和静态资源路径都排除掉。

第二个坑:OPTIONS 预检被拦。跨域场景下,浏览器在发真实请求前会先发一个 OPTIONS 预检请求,这个请求不带 Authorization 头。如果拦截器把它拦下来返回 401,浏览器会判定跨域失败,真实请求根本发不出去。所以 preHandle 开头要先判断 OPTIONS 直接放行。
第三个坑:返回重定向而不是 JSON。前后端分离项目里,前端拿到的应该是 401 状态码和 JSON 错误体,由前端决定跳转登录页。如果拦截器直接 resp.sendRedirect("/login"),前端会收到一个 HTML 登录页,AJAX 回调里解析 JSON 直接报错,问题还不好排查。
另外,如果项目里同时用了 Filter 做 CORS 处理,要确保 CORS 相关的响应头在拦截器返回 401 时也带上,否则浏览器仍然会报跨域错误。通常把 CORS 处理放在优先级最高的 Filter 里统一做。
怎么选与常见误区
把两者的差异放在统一维度上对比:
| 维度 | Filter | Interceptor |
| 规范归属 | Servlet 容器 | Spring MVC |
| 能否注入 Bean | 默认不行,需注册为 Bean | 可以,由 Spring 管理 |
| 能否拿到方法注解 | 不能 | 能,通过 HandlerMethod |
| 执行次数 | 每个请求一次 | 每个请求一次(含 forward 时需注意) |
| 能否修改请求/响应 | 能,可包装 | 不能,只能读 |
| 异常处理位置 | 在 Filter 内自行处理 | 可配合 HandlerExceptionResolver |
选择建议很直接:
全局、粗粒度、与业务无关的逻辑放 Filter:字符编码、跨域、请求日志、全局限流、请求体包装。
与业务、权限、注解相关的逻辑放 Interceptor:登录校验、基于注解的权限判断、接口级操作日志、接口级限流。
一个常见误区是把登录校验全放在 Filter 里,然后在 Filter 里用反射去解析 Controller 方法注解。这样做不是不行,但等于绕开 Spring MVC 已经准备好的 HandlerMethod 自己重造一遍,代码复杂且容易出错,属于典型的用错层级。
另一个误区是把 Interceptor 和 AOP 混为一谈。两者都能在方法前后织入逻辑,但关注点不同:Interceptor 绑定的是 HTTP 请求,能拿到 HttpServletRequest、HttpServletResponse,适合处理请求维度的逻辑;AOP 绑定的是方法调用,能拿到方法参数和返回值,适合处理业务维度的逻辑,比如事务、缓存、方法级日志。需要请求上下文时用 Interceptor,只需要方法上下文时用 AOP。
判断一段逻辑该放哪,先问一句:它需不需要知道是哪个 Controller 方法在处理这次请求。需要,就放 Interceptor,因为只有它能拿到 HandlerMethod;不需要,就放 Filter,让它在更靠前的位置、以更低的耦合度完成。这个判断成立的前提是理解了两者的容器归属------Filter 在 Spring 之外,Interceptor 在 Spring MVC 之内,所有行为差异都从这里长出来。