Spring Boot过滤器与拦截器:从原理到实战的深度解析与应用指南

1. 项目概述:为什么我们需要“门卫”和“巡逻队”?

在Web应用开发的世界里,尤其是基于Spring Boot这类主流框架构建系统时,我们常常会听到“过滤器”和“拦截器”这两个词。很多开发者,尤其是刚入行的朋友,可能会觉得它们功能相似,都是用来“拦”请求的,用哪个好像都差不多。但在我十多年的项目实战中,无数次踩坑和调优的经历告诉我,这俩兄弟虽然目标一致——保卫你的应用,但它们的职责、能力和工作时机有着本质的区别。用错了地方,轻则功能失效、性能低下,重则可能引入安全漏洞,让整个系统的防线形同虚设。

简单来说,你可以把 过滤器 想象成小区门口的 保安(门卫) 。他的工作范围很广,所有想进出小区的人(HTTP请求和响应)都必须经过他。他可以检查你的健康码(验证请求头)、测量体温(校验参数)、或者拒绝一个形迹可疑的人入内(拦截非法请求)。他的权力很大,能在请求真正进入你家(即Spring容器、你的Controller)之前就进行处理,甚至能直接“遣返”(返回响应)。而 拦截器 ,则更像是进入你家楼道后的 巡逻队或智能管家 。他已经知道你是小区的合法住户(请求已经通过了Servlet容器的初步过滤),他的工作更精细,专注于你在“家”里的行为:比如记录你什么时候出门(记录日志)、在你进门前自动开灯(预处理)、或者在你离家后检查电器是否关闭(后处理)。他深度集成在Spring MVC的流程中,能方便地获取Spring容器中的各种“家具”(Bean)。

理解这两者的奥秘,不是为了应付面试,而是为了在构建健壮、安全、高效的应用时,能做出最合适的技术选型。当你的应用面临恶意爬虫刷接口、需要统一鉴权、或者要对所有业务操作进行审计日志记录时,你知道该派“保安”还是“管家”上场,这就是核心价值所在。

2. 核心原理深度拆解:从Servlet到Spring MVC的请求之旅

要彻底搞懂过滤器和拦截器,我们必须把一次HTTP请求在Java Web应用中的完整生命周期摊开来看。这就像跟踪一个快递包裹从发货到签收的全过程,每个环节都有不同的“工作人员”在处理。

2.1 过滤器的定位与能力边界

过滤器是Java EE(现在是Jakarta EE)规范定义的标准组件,它的工作层级在 Servlet容器 (如Tomcat、Jetty)级别。这意味着它完全不依赖于Spring框架,即使是一个最原始的Servlet应用,你也可以使用过滤器。

它的工作时机非常早 :当一个HTTP请求到达服务器时,Servlet容器会首先创建一个 ServletRequest ServletResponse 对象。紧接着,容器就会查找所有配置好的过滤器,并按顺序将它们组织成一个“过滤器链”。请求会像穿过一道道水闸一样,依次经过每个过滤器。

客户端请求 -> Tomcat容器 -> 过滤器1 -> 过滤器2 -> ... -> 过滤器N -> Servlet (DispatcherServlet) -> 你的应用

在这个过程中,每个过滤器都拥有对原始 ServletRequest ServletResponse 的完全控制权:

  1. 检查请求 :可以读取、修改甚至包装请求对象(例如使用 HttpServletRequestWrapper 来增加参数)。
  2. 拦截请求 :如果认为请求非法(如Token无效、IP黑名单),可以直接调用 response.sendError() response.getWriter().write() 返回响应,请求就此终止,根本不会到达后续的过滤器和Servlet。
  3. 放行请求 :调用 chain.doFilter(request, response) ,将请求传递给链中的下一个过滤器或最终的Servlet。
  4. 处理响应 :当请求被后续组件处理完,生成响应后,响应会沿着过滤器链 反向 穿回。每个过滤器还有机会对响应内容进行修改(如统一添加响应头、压缩响应体)。

关键特性总结

  • 作用范围广 :能过滤所有请求,包括静态资源(如 .js , .css , 图片)。
  • 与框架无关 :是Servlet规范的一部分,不感知Spring。
  • 控制力强 :可以终止请求-响应周期。
  • 无法直接使用Spring Bean :因为此时Spring的上下文可能还未完全初始化或无法直接注入。

2.2 拦截器的定位与Spring集成优势

拦截器是Spring MVC框架特有的组件。它的工作层级在 Spring Web MVC 框架内部,具体是在核心控制器 DispatcherServlet 处理请求的过程中。

它的工作时机相对靠后 :请求已经通过了Servlet容器的所有过滤器,并已经被 DispatcherServlet 接收。 DispatcherServlet 会根据请求的URL找到对应的处理器( Handler ,通常是我们的Controller方法),但在执行这个处理器前后,就是拦截器发挥作用的舞台。

Spring MVC定义了一个清晰的处理器执行链,其中包含了拦截器:

DispatcherServlet 收到请求 -> 预处理拦截器 -> 执行Controller方法 -> 后处理拦截器 -> 渲染视图 -> 完成处理拦截器

拦截器的主要方法:

  • preHandle :在Controller方法执行 调用。返回 true 则继续执行链,返回 false 则中断流程(类似过滤器的拦截)。
  • postHandle :在Controller方法执行 ,但视图渲染 调用。此时可以修改模型数据( ModelAndView )。
  • afterCompletion :在整个请求完成 调用,主要用于资源清理、日志记录等。

关键特性总结

  • 作用范围精准 :通常只针对Controller的请求映射,可以通过配置排除静态资源。
  • 深度Spring集成 :本身就是Spring Bean,可以方便地使用 @Autowired 注入其他Spring管理的服务(如数据库服务、日志服务)。
  • 能获取处理器信息 :可以拿到即将执行的 HandlerMethod 对象,从而知道是哪个Controller的哪个方法,便于做更精细化的控制(如基于注解的权限检查)。
  • 无法处理静态资源 :默认不拦截静态资源请求。

2.3 核心差异对照表

为了更直观地对比,我将两者的核心差异整理成下表:

特性维度 过滤器 拦截器
规范/框架 Servlet 规范 (javax.servlet.Filter) Spring MVC 框架 (org.springframework.web.servlet.HandlerInterceptor)
作用范围 所有请求,包括静态资源 通常只针对Spring MVC映射的请求(可通过配置调整)
依赖关系 不依赖Spring,是Web容器组件 强依赖Spring MVC框架
获取Spring Bean 无法直接注入,需通过特殊方式 可以直接注入,本身就是Spring Bean
执行时机 在Servlet之前,响应返回客户端之前 在DispatcherServlet之后,Controller方法执行前后
控制粒度 较粗,基于URL模式 较细,可基于HandlerMethod(具体方法)
典型应用场景 全局编码设置、CORS处理、XSS防御、基础身份验证、请求/响应日志记录(原始)、压缩 业务权限校验、审计日志(需业务上下文)、执行时间计算、统一异常处理、模型数据加工

实操心得 :一个快速记忆法—— “滤前拦后” 。过滤器工作在更“前”的容器层,像一道外网防火墙;拦截器工作在更“后”的业务框架层,像一套内网安全策略。选择时,先问自己:这个逻辑是否需要处理静态资源?是否需要用到Spring的Bean?是否需要知道具体是哪个Controller方法?

3. 实战构建:手把手打造你的应用安全防线

理论讲透了,我们来点实际的。我将通过一个典型的Web应用安全需求场景,展示如何分别使用过滤器和拦截器,并解释为什么这么选。

场景假设 :我们有一个Spring Boot 2.7.x的RESTful API项目,需要实现以下安全与管控功能:

  1. 全局请求日志 :记录所有进入应用的请求的IP、URL、方法、时间,以及耗时。用于监控和问题排查。
  2. 接口鉴权 :对于 /api/private/** 路径下的私有API,需要验证请求头中的 X-Auth-Token 是否有效。
  3. 防重复提交 :对于某些关键写操作(如支付下单),需要防止用户在短时间内重复提交。

3.1 使用过滤器实现全局请求日志与基础安全

对于 全局请求日志 ,我们希望记录每一个请求,包括对前端静态页面、图标等资源的请求。这要求组件必须在最外层工作,且不关心具体业务逻辑。过滤器是完美选择。

@Component
@Order(1) // 定义过滤器执行顺序,数字越小优先级越高
@Slf4j
public class GlobalLoggingFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        HttpServletResponse res = (HttpServletResponse) response;

        long startTime = System.currentTimeMillis();
        String requestId = UUID.randomUUID().toString(); // 生成唯一请求ID

        // 将请求ID放入MDC,方便日志链路追踪
        MDC.put("requestId", requestId);
        // 也可以将请求ID设置到响应头,方便前端排查
        res.setHeader("X-Request-ID", requestId);

        // 记录请求开始日志
        log.info(">>> 请求开始 [ID:{}] IP:{} {} {}?{}",
                requestId,
                getClientIp(req),
                req.getMethod(),
                req.getRequestURI(),
                req.getQueryString());

        try {
            // 继续执行过滤器链
            chain.doFilter(request, response);
        } finally {
            // 无论成功失败,最终都会执行这里
            long duration = System.currentTimeMillis() - startTime;
            int status = res.getStatus();
            log.info("<<< 请求结束 [ID:{}] 状态:{} 耗时:{}ms",
                    requestId, status, duration);
            // 清除MDC中的请求ID
            MDC.remove("requestId");
        }
    }

    private String getClientIp(HttpServletRequest request) {
        // 一个简单的获取真实IP的方法,实际生产环境需要考虑代理
        String ip = request.getHeader("X-Forwarded-For");
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getHeader("WL-Proxy-Client-IP");
        }
        if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) {
            ip = request.getRemoteAddr();
        }
        return ip;
    }

    @Override
    public void init(FilterConfig filterConfig) throws ServletException {
        log.info("全局日志过滤器初始化...");
    }

    @Override
    public void destroy() {
        log.info("全局日志过滤器销毁...");
    }
}

为什么用过滤器?

  1. 记录全面 chain.doFilter() 包裹在 try-finally 中,确保无论后续处理是成功、抛出异常还是被其他过滤器拦截,都能记录到结束日志和耗时。
  2. 性能影响小 :仅记录基本信息,不涉及IO操作(如写入数据库),对性能影响微乎其微。
  3. 作用于所有资源 :即使是 /favicon.ico /static/js/app.js 这样的请求也会被记录,这对于分析异常访问模式很有帮助。

注意事项 :在过滤器中直接调用 response.getWriter() 并写入内容后,务必 谨慎调用 chain.doFilter() ,否则可能导致响应体被重复写入。通常,如果你已经在过滤器中生成了完整的响应(如返回错误JSON),就应该直接 return ,不再放行。

3.2 使用拦截器实现精细化的接口鉴权

对于 接口鉴权 ,我们需要验证Token。这个逻辑需要查询数据库或缓存来验证Token有效性,因此必须用到Spring管理的 AuthService Bean。同时,我们可能只需要对特定的API路径进行鉴权。拦截器是最佳选择。

首先,定义一个自定义注解,用于更灵活地标记需要鉴权的方法:

@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface RequireAuth {
    // 可以扩展权限角色,如 String[] roles() default {};
}

然后,实现我们的鉴权拦截器:

@Component
public class AuthenticationInterceptor implements HandlerInterceptor {

    @Autowired
    private AuthService authService; // 依赖Spring Bean
    @Autowired
    private ObjectMapper objectMapper; // Jackson,用于生成JSON响应

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 1. 检查是否为HandlerMethod(排除资源处理器等)
        if (!(handler instanceof HandlerMethod)) {
            return true;
        }

        HandlerMethod handlerMethod = (HandlerMethod) handler;
        Method method = handlerMethod.getMethod();

        // 2. 检查控制器类或方法上是否有@RequireAuth注解
        boolean requireAuthOnMethod = method.isAnnotationPresent(RequireAuth.class);
        boolean requireAuthOnClass = method.getDeclaringClass().isAnnotationPresent(RequireAuth.class);

        if (!requireAuthOnMethod && !requireAuthOnClass) {
            // 不需要鉴权,直接放行
            return true;
        }

        // 3. 需要鉴权,从请求头获取Token
        String token = request.getHeader("X-Auth-Token");
        if (token == null || token.isBlank()) {
            sendErrorResponse(response, 401, "未提供认证令牌");
            return false; // 中断执行链
        }

        // 4. 验证Token(调用Spring Bean服务)
        UserInfo userInfo = authService.validateToken(token);
        if (userInfo == null) {
            sendErrorResponse(response, 403, "令牌无效或已过期");
            return false;
        }

        // 5. 鉴权通过,将用户信息存入请求属性,供Controller使用
        request.setAttribute("CURRENT_USER", userInfo);
        return true;
    }

    private void sendErrorResponse(HttpServletResponse response, int status, String message) throws IOException {
        response.setStatus(status);
        response.setContentType("application/json;charset=UTF-8");
        Map<String, Object> result = new HashMap<>();
        result.put("code", status);
        result.put("message", message);
        result.put("timestamp", System.currentTimeMillis());
        response.getWriter().write(objectMapper.writeValueAsString(result));
    }

    // postHandle和afterCompletion可以根据需要实现,例如记录操作日志
}

最后,通过配置类注册拦截器,并指定拦截路径:

@Configuration
public class WebMvcConfig implements WebMvcConfigurer {

    @Autowired
    private AuthenticationInterceptor authenticationInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(authenticationInterceptor)
                .addPathPatterns("/api/**") // 拦截所有/api下的请求
                .excludePathPatterns("/api/public/**", "/error"); // 排除公开API和错误端点
    }
}

为什么用拦截器?

  1. 依赖注入 :可以方便地使用 @Autowired 注入 AuthService ObjectMapper ,这是过滤器难以做到的(需要额外 hack)。
  2. 精细控制 :通过判断 HandlerMethod 和自定义注解,可以精确控制哪些方法需要鉴权,哪些不需要,避免了在过滤器中写一堆 if-else 判断URL。
  3. 信息丰富 :能获取到即将执行的具体方法信息,为后续的审计日志(记录“谁”在“什么时候”调用了“哪个方法”)提供了极大便利。

3.3 混合使用:防重复提交的进阶方案

防重复提交是一个经典问题。一个健壮的方案往往需要 过滤器和拦截器协作

思路

  1. 过滤器负责生成唯一请求标识 :在请求最早进入时,生成一个唯一键(如: userId:接口路径:请求参数摘要 ),这个键需要能唯一标识“同一个用户的同一笔操作”。
  2. 拦截器(或AOP)负责业务逻辑判断 :在请求进入业务方法前,用这个唯一键去分布式缓存(如Redis)中尝试设置一个带有短暂过期时间的锁。如果设置成功(表示第一次提交),则放行;如果设置失败(表示重复提交),则直接返回错误。

过滤器部分(生成标识)

@Component
@Order(2) // 在日志过滤器之后执行
public class IdempotencyKeyFilter implements Filter {
    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        // 可以从Header中获取客户端生成的幂等键,如果没有则自己生成一个
        String idempotencyKey = req.getHeader("X-Idempotency-Key");
        if (idempotencyKey == null || idempotencyKey.isEmpty()) {
            // 简单示例:使用UUID。生产环境可能需要更复杂的规则,结合用户和请求特征
            idempotencyKey = "gen_" + UUID.randomUUID().toString();
        }
        // 将幂等键存入请求属性,供后续组件使用
        req.setAttribute("IDEMPOTENCY_KEY", idempotencyKey);
        chain.doFilter(request, response);
    }
}

拦截器部分(检查幂等性)

@Component
public class IdempotencyInterceptor implements HandlerInterceptor {

    @Autowired
    private RedisTemplate<String, String> redisTemplate; // 使用Redis

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
        // 只对标注了@Idempotent的方法进行检查
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
            if (!hm.hasMethodAnnotation(Idempotent.class)) {
                return true;
            }
            String idempotencyKey = (String) request.getAttribute("IDEMPOTENCY_KEY");
            if (idempotencyKey == null) {
                sendErrorResponse(response, 400, "缺少幂等键");
                return false;
            }
            // 尝试在Redis中设置键,过期时间设为10秒
            Boolean success = redisTemplate.opsForValue().setIfAbsent("idempotent:" + idempotencyKey, "processing", 10, TimeUnit.SECONDS);
            if (Boolean.FALSE.equals(success)) {
                // 键已存在,说明是重复请求
                sendErrorResponse(response, 409, "请求正在处理或请勿重复提交");
                return false;
            }
        }
        return true;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 请求处理完成后,可以根据业务成功与否,决定是删除键还是更新状态
        // 例如,只有业务成功时才删除,失败则保留键让客户端重试
        if (handler instanceof HandlerMethod) {
            HandlerMethod hm = (HandlerMethod) handler;
            if (hm.hasMethodAnnotation(Idempotent.class)) {
                String idempotencyKey = (String) request.getAttribute("IDEMPOTENCY_KEY");
                if (idempotencyKey != null && response.getStatus() == 200) {
                    // 业务成功,删除锁,允许相同的幂等键发起新的业务请求(非重试)
                    redisTemplate.delete("idempotent:" + idempotencyKey);
                }
            }
        }
    }
    // ... sendErrorResponse 方法同上
}

这种协作模式的优势

  • 职责分离 :过滤器做通用的、与业务无关的“标识生成”工作。拦截器做与业务注解相关的“逻辑判断”工作。
  • 灵活性强 :可以通过注解轻松控制哪些接口需要防重,哪些不需要。
  • 性能与一致性 :利用Redis等外部存储,可以很好地支持分布式环境下的防重提交。

4. 进阶应用与性能调优实战

掌握了基本用法,我们来看看在复杂和高并发场景下,如何用好过滤器和拦截器,并规避其中的陷阱。

4.1 过滤器链的优化与陷阱

当配置了多个过滤器时,它们的执行顺序由 @Order 注解或web.xml中的配置顺序决定。顺序至关重要。

一个常见的性能陷阱:日志过滤器放在鉴权过滤器之后 假设你有两个过滤器: AuthFilter (鉴权)和 LoggingFilter (日志)。如果 LoggingFilter 先执行,它会记录所有请求的开始。但当请求被 AuthFilter 拦截并返回401错误时, LoggingFilter finally 块仍然会记录“请求结束”。这看起来没问题。 但如果顺序反过来, AuthFilter 先执行,它拦截了非法请求并直接返回响应,请求根本不会到达 LoggingFilter 这会导致你的访问日志缺失这部分非法请求记录 ,对于安全审计来说是致命的。因此,日志过滤器的顺序应尽可能靠前。

最佳实践建议

  1. 安全第一 :像 CorsFilter (处理跨域)这种必须最早响应的过滤器,应设为最高优先级( @Order(Ordered.HIGHEST_PRECEDENCE) )。
  2. 日志紧随其后 :全局日志过滤器应放在安全过滤器之后,其他业务过滤器之前,确保记录所有“到达”的请求。
  3. 资源处理靠前 :如 CharacterEncodingFilter (设置编码)应在请求体被读取之前生效。
  4. 业务鉴权居中 :像 AuthenticationFilter 这类业务过滤器,放在编码、日志之后。
  5. 内容处理靠后 :如 GzipFilter (响应压缩)应放在最后,因为它需要处理最终的响应体。

使用 OncePerRequestFilter : Spring提供了一个便利的抽象类 OncePerRequestFilter 。它确保在单个请求生命周期内, doFilterInternal 方法只被执行一次。这在转发( RequestDispatcher.forward )或包含( include )等场景下非常有用,可以避免过滤器被重复执行。 强烈建议继承它来实现自定义过滤器

@Component
public class MySafeFilter extends OncePerRequestFilter {
    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
            throws ServletException, IOException {
        // 你的过滤逻辑
        // 这个方法保证只被调用一次
        filterChain.doFilter(request, response);
    }
}

4.2 拦截器与Spring Boot Actuator的冲突处理

Spring Boot Actuator提供了很多监控端点(如 /actuator/health , /actuator/metrics )。如果你配置的拦截器路径是 /** ,那么这些端点也会被拦截,可能导致健康检查失败或监控数据异常。

解决方案 :在注册拦截器时,明确排除Actuator端点。

@Override
public void addInterceptors(InterceptorRegistry registry) {
    registry.addInterceptor(myInterceptor)
            .addPathPatterns("/**")
            .excludePathPatterns(
                    "/actuator/**",
                    "/error",
                    "/swagger-resources/**",
                    "/swagger-ui/**",
                    "/v3/api-docs/**"
            );
}

4.3 异步请求下的特殊考量

在Spring MVC中,当控制器方法返回 DeferredResult Callable 或使用 @ResponseBody 配合异步Servlet时,请求的处理是异步的。这对拦截器的生命周期有影响。

  • preHandle :在异步线程开始时执行。
  • postHandle 对于异步请求, postHandle 会立即被调用(此时异步处理还未完成),并且传入的 ModelAndView null 。这意味着你不能在 postHandle 中修改异步处理的结果。
  • afterCompletion :在异步请求处理完成 调用,无论是超时、正常完成还是出错。因此, 资源清理和最终日志记录必须放在 afterCompletion

异步场景下的日志记录示例

@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
    long startTime = (Long) request.getAttribute("startTime");
    long endTime = System.currentTimeMillis();
    log.info("异步请求最终完成,总耗时:{}ms", endTime - startTime);
    // 清理ThreadLocal等资源
    MyThreadLocalContext.clear();
}

你需要将 startTime preHandle 中存入 request 属性。

4.4 与Spring Security的协作与区分

这是一个高频困惑点。Spring Security本身就是一个基于过滤器链的强大安全框架。当你同时使用自定义过滤器和Spring Security时,你需要清楚它们的位置关系。

Spring Security的过滤器链( FilterChainProxy )通常作为一个单独的过滤器插入到整个过滤器链中。你的自定义过滤器可能在它之前或之后执行,这取决于你的配置( @Order )。

基本原则

  • 在Security之前的过滤器 :可以处理一些Security不关心的、更通用的逻辑,如全局日志、编码。但 无法获取Security认证信息 ,因为此时用户尚未被认证。
  • 在Security之后的过滤器 :可以获取到SecurityContext中的认证信息(如用户名、角色)。但需注意,如果请求被Security拦截(如未登录),则不会到达后面的过滤器。

更常见的做法是使用Spring Security提供的扩展点 ,而不是自己写过滤器去干涉安全流程。例如,实现 AuthenticationSuccessHandler (认证成功处理)或 AccessDeniedHandler (权限拒绝处理)。对于需要在安全上下文中执行的逻辑,使用 拦截器 是更自然的选择,因为它肯定在Spring Security过滤器之后执行。

5. 生产环境排坑指南与性能考量

在实际项目中,我遇到过不少由过滤器和拦截器引发的问题。这里分享几个典型案例和解决方案。

5.1 问题一:过滤器内读取了请求体,导致Controller中 @RequestBody 为空

现象 :在过滤器中通过 request.getInputStream() 读取了请求体(例如为了做签名验证),然后调用 chain.doFilter() 。结果后面的Controller方法中, @RequestBody 注解绑定的参数始终为 null

根因 :Servlet的 InputStream Reader 通常只能被读取一次。过滤器读取后,流就到了末尾,后续的Spring消息转换器(如 MappingJackson2HttpMessageConverter )再读就是空。

解决方案

  1. 使用 ContentCachingRequestWrapper (Spring提供) :这个包装类可以将请求体缓存起来,允许多次读取。但注意,它需要先将整个请求体读入内存, 对大文件上传不友好
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest req = (HttpServletRequest) request;
        ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(req);
        // 在wrappedRequest上读取body
        byte[] body = wrappedRequest.getContentAsByteArray(); // 第一次读取会触发缓存
        // ... 你的逻辑
        chain.doFilter(wrappedRequest, response); // 传递包装后的请求
    }
    
  2. 自定义 HttpServletRequestWrapper :更灵活的方式是自己实现一个Wrapper,将读取的请求体内容保存下来,并重写 getInputStream() getReader() 方法,返回缓存内容的新流。这是很多开源库的做法。
  3. 改变架构 :如果只是为了获取几个参数做验证,考虑将签名信息放在Header中,而不是Body里。或者使用拦截器,在Spring MVC层面,通过 @RequestBody 的参数解析器处理后的对象进行操作。

5.2 问题二:拦截器 preHandle 中抛出的异常,无法被 @ControllerAdvice 统一异常处理器捕获

现象 :在拦截器的 preHandle 方法中,如果参数校验不通过,直接抛出 RuntimeException ,期望被全局的 @ControllerAdvice + @ExceptionHandler 处理并返回统一的错误JSON,但实际返回的是Tomcat默认的500错误页面。

根因 @ControllerAdvice 是Spring MVC的组件,它只处理 控制器方法执行过程中 以及 视图渲染过程中 抛出的异常。拦截器的 preHandle 执行时,尚未进入控制器方法执行流程,因此其抛出的异常由Servlet容器(Tomcat)处理,而非Spring MVC。

解决方案

  1. 在拦截器内部处理异常并生成响应 (推荐):就像我们前面鉴权拦截器示例中的 sendErrorResponse 方法一样,在 preHandle 里捕获异常或判断失败后,直接操作 HttpServletResponse 返回JSON错误信息,然后返回 false
    @Override
    public boolean preHandle(...) {
        try {
            // 校验逻辑
            if (!valid) {
                writeJsonResponse(response, 400, "参数无效");
                return false;
            }
        } catch (Exception e) {
            writeJsonResponse(response, 500, "系统内部错误");
            return false;
        }
        return true;
    }
    
  2. 配置Servlet容器的错误页面 :在 application.yml 中配置,将特定状态码或异常类型映射到某个Controller路径,由Spring MVC来处理。但这相对繁琐,且不够灵活。

5.3 问题三:过滤器和拦截器中的性能瓶颈

潜在瓶颈

  • 同步阻塞操作 :在 doFilter preHandle 中执行耗时的IO操作(如远程调用、复杂数据库查询),会阻塞整个请求线程。
  • 内存泄漏 :在过滤器或拦截器中使用了 ThreadLocal ,但在请求结束后没有及时清理( remove ),在线程池复用的环境下会导致内存泄漏和信息错乱。
  • 频繁的序列化/反序列化 :如为了日志或验证,在过滤器中反复将请求/响应体转换成字符串。

优化建议

  1. 异步处理 :对于非关键性的、耗时的操作(如发送审计日志到消息队列),考虑使用异步方式。在过滤器中,可以将任务提交给一个线程池,避免阻塞主链。但要注意异步上下文传递(如 RequestContextHolder )。
  2. 资源及时清理 :在 finally 块或拦截器的 afterCompletion 方法中,务必清理 ThreadLocal 、关闭临时流等资源。
  3. 采样记录 :全量日志记录对性能有影响。在高并发场景下,可以考虑采样记录,例如只记录1%的请求,或者只记录慢请求(耗时超过阈值的)。
  4. 避免重复解析 :如果多个组件都需要请求体信息,考虑使用 ContentCachingRequestWrapper 并只解析一次,将解析结果(如Map)存入请求属性供后续使用。

5.4 配置检查清单

在将应用部署上线前,建议对照此清单检查你的过滤器和拦截器配置:

  • [ ] 执行顺序 :关键过滤器(如CORS、日志、编码)的顺序是否正确? @Order 值是否合理?
  • [ ] 路径匹配 :拦截器的 addPathPatterns excludePathPatterns 是否准确覆盖了目标接口,并排除了静态资源、Actuator端点、Swagger文档等?
  • [ ] 异步支持 :如果项目使用异步Servlet,拦截器中的资源清理是否放在了 afterCompletion 中? ThreadLocal 是否被正确清理?
  • [ ] 异常处理 :拦截器 preHandle 中的失败场景,是否已妥善处理并返回了友好的客户端响应?
  • [ ] 性能影响 :是否在过滤/拦截链中引入了不必要的同步阻塞调用?全量日志是否会对高并发接口造成压力?
  • [ ] 安全审查 :用于鉴权的过滤器/拦截器,其逻辑是否存在绕过漏洞?Token验证是否防重放?防重复提交的键生成算法是否足够唯一?
  • [ ] 测试覆盖 :是否编写了单元测试和集成测试,覆盖了正常流程、鉴权失败、重复提交、异常抛出等场景?

纸上得来终觉浅,绝知此事要躬行。过滤器和拦截器是构建稳固Web应用的基石组件,它们的正确使用直接关系到系统的安全性、可观测性和可维护性。我个人的经验是,在项目初期就规划好它们的职责边界,通过一个清晰的配置类来管理顺序和路径,并为之编写充分的测试用例。当出现一个横切关注点时,先问“这个逻辑需要看到Spring的世界吗?”(是则选拦截器),再问“这个逻辑需要处理所有流量包括静态文件吗?”(是则选过滤器),大多数时候你都能找到清晰的答案。记住,没有最好的组件,只有最合适的场景。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值