Spring Boot请求链路Logging重复打印的过滤器链修复方案

Spring Boot 请求链路 Logging 重复打印的过滤器链修复方案

各位开发者朋友们,大家好!今天我们来聊聊在 Spring Boot 项目开发中,一个比较常见且令人头疼的问题:请求链路 Logging 重复打印。这个问题通常发生在使用了自定义过滤器(Filter)对请求进行拦截并打印日志的场景下。当配置不当或者对 Spring Boot 过滤器链的理解不够深入时,很容易导致日志重复输出,影响日志的可读性和问题排查效率。

本次讲座将深入分析导致重复打印的原因,并提供多种解决方案,帮助大家彻底解决这个问题。

一、问题根源:多重拦截与配置错误

Spring Boot 中,请求的处理流程大致如下:

  1. 请求到达: 客户端发起 HTTP 请求。
  2. DispatcherServlet: 请求到达 Spring MVC 的核心组件 DispatcherServlet。
  3. 过滤器链(Filter Chain): DispatcherServlet 将请求交给配置好的过滤器链进行处理。
  4. Controller: 过滤器链处理完毕后,请求到达相应的 Controller 进行业务逻辑处理。
  5. 响应返回: Controller 处理完毕后,返回响应,响应再次经过过滤器链,最终返回给客户端。

而日志重复打印问题,往往出现在过滤器链这一环节。以下是一些常见的原因:

  • 重复注册过滤器: 同一个过滤器被注册了多次,导致请求被同一个过滤器多次拦截。
  • 过滤器配置不当: 过滤器的配置(例如 URL 匹配规则)过于宽泛,导致不应该被拦截的请求也被拦截。
  • 过滤器执行顺序问题: 多个过滤器同时记录日志,但执行顺序不当,导致某些过滤器重复记录了其他过滤器的日志。
  • ServletRegistrationBean配置不当: 当使用ServletRegistrationBean注册Servlet时,如果没有正确配置addMapping,可能会导致过滤器被多次应用。
  • AOP切面中的日志打印: 某些情况下,使用AOP切面进行日志打印,与Filter中的日志打印发生冲突,导致重复打印。

二、诊断:如何定位重复打印的源头

解决问题的第一步是定位问题。我们需要确定是哪个(或哪些)过滤器在重复打印日志。以下是一些常用的诊断方法:

  1. 日志分析: 仔细查看日志,分析重复打印的日志内容,尝试找出共同点,例如:
    • 相同的请求参数
    • 相同的用户 ID
    • 相同的请求路径
    • 相同的调用栈信息
  2. 逐个禁用过滤器: 将自定义的过滤器逐个禁用,然后观察日志输出情况,找出导致重复打印的过滤器。这种方法简单粗暴,但非常有效。可以在@WebFilter注解中设置@WebFilter(urlPatterns = "/*", enabled = false)来禁用过滤器。
  3. 调试模式: 在代码中设置断点,跟踪请求在过滤器链中的执行流程,观察每个过滤器的执行情况。
  4. 使用工具: 使用APM (Application Performance Monitoring) 工具,如 SkyWalking, Zipkin等,可以追踪请求在各个服务和组件之间的调用链,更容易发现重复执行的过滤器。
  5. 打印Filter的名称: 在每个Filter的doFilter方法中,打印当前Filter的名称或类名,可以帮助快速定位哪个Filter被多次执行。
@Component
@WebFilter(urlPatterns = "/*")
public class MyFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        System.out.println("MyFilter is executing for URL: " + httpRequest.getRequestURI());
        chain.doFilter(request, response);
    }
}

三、解决方案:多种策略,对症下药

找到重复打印的原因后,就可以针对性地采取解决方案了。以下是一些常用的解决方案:

1. 避免重复注册过滤器

这是最常见的原因之一。确保同一个过滤器只被注册一次。

  • 使用 @Component + @WebFilter 注解: 这种方式会自动注册过滤器,但需要确保同一个过滤器类只被 @Component 注解一次。
  • 使用 FilterRegistrationBean 如果需要更精细的控制过滤器的注册,可以使用 FilterRegistrationBean。确保同一个过滤器只被注册一次。

例如,下面代码展示了如何使用 FilterRegistrationBean 注册一个过滤器:

@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<MyFilter> myFilterRegistrationBean(MyFilter myFilter) {
        FilterRegistrationBean<MyFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(myFilter);
        registrationBean.addUrlPatterns("/api/*"); // 设置拦截的 URL 模式
        registrationBean.setOrder(1); // 设置过滤器的执行顺序
        return registrationBean;
    }
}

确保FilterRegistrationBean只被定义一次,并且@Component注解的Filter没有被重复注册(比如在多个配置类中都被扫描到)。

2. 精确配置过滤器的 URL 匹配规则

如果过滤器的 URL 匹配规则过于宽泛,会导致不应该被拦截的请求也被拦截。应该尽可能精确地配置 URL 匹配规则。

  • 使用 @WebFilter 注解的 urlPatterns 属性: 设置精确的 URL 匹配模式。
  • 使用 FilterRegistrationBeanaddUrlPatterns() 方法: 同样可以设置精确的 URL 匹配模式。

例如,如果只想拦截 /api/users 路径的请求,可以这样配置:

@WebFilter(urlPatterns = "/api/users")
public class MyFilter implements Filter {
    // ...
}

或者使用 FilterRegistrationBean

registrationBean.addUrlPatterns("/api/users");

避免使用 /* 这样的通配符,除非确实需要拦截所有请求。

3. 控制过滤器的执行顺序

如果多个过滤器同时记录日志,但执行顺序不当,可能会导致重复记录日志。可以通过以下方式控制过滤器的执行顺序:

  • 使用 @Order 注解: 在过滤器类上使用 @Order 注解,数值越小,优先级越高。
  • 使用 FilterRegistrationBeansetOrder() 方法: 同样可以设置过滤器的执行顺序。

例如,下面的代码设置了两个过滤器的执行顺序:

@Component
@WebFilter(urlPatterns = "/*")
@Order(1)
public class FilterA implements Filter {
    // ...
}

@Component
@WebFilter(urlPatterns = "/*")
@Order(2)
public class FilterB implements Filter {
    // ...
}

或者使用 FilterRegistrationBean

registrationBeanA.setOrder(1);
registrationBeanB.setOrder(2);

合理安排过滤器的执行顺序,避免重复记录日志。通常,负责认证授权的过滤器应该放在前面,负责日志记录的过滤器可以放在后面。

4. 避免在过滤器中直接打印业务日志

过滤器主要负责请求的预处理和后处理,不应该直接打印业务日志。业务日志应该在 Controller 或 Service 层打印。

如果需要在过滤器中记录日志,应该只记录与请求处理相关的日志,例如:

  • 请求到达的时间
  • 请求的 IP 地址
  • 请求的 URL
  • 请求的参数
  • 处理请求的耗时

避免在过滤器中打印业务逻辑相关的日志,例如:

  • 用户登录信息
  • 订单信息
  • 商品信息

5. 使用 MDC (Mapped Diagnostic Context) 传递上下文信息

如果需要在多个过滤器和 Controller 中记录日志,可以使用 MDC 来传递上下文信息。MDC 是 Logback 和 Log4j 等日志框架提供的一种机制,可以在不同的线程中共享上下文信息。

例如,可以在第一个过滤器中设置 MDC:

@Component
@WebFilter(urlPatterns = "/*")
@Order(1)
public class RequestIdFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        String requestId = UUID.randomUUID().toString();
        MDC.put("requestId", requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("requestId"); // 清除 MDC
        }
    }
}

然后在其他过滤器和 Controller 中就可以直接使用 MDC 中的 requestId

@Component
@WebFilter(urlPatterns = "/*")
@Order(2)
public class LoggingFilter implements Filter {

    private static final Logger logger = LoggerFactory.getLogger(LoggingFilter.class);

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        logger.info("Request received: {} {}", httpRequest.getMethod(), httpRequest.getRequestURI());
        chain.doFilter(request, response);
    }
}

logback.xmllog4j.properties 中配置日志格式,将 requestId 包含在日志中:

<pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg [%X{requestId}]%n</pattern>

这样就可以在所有日志中追踪同一个请求的上下文信息,方便问题排查。

6. 检查ServletRegistrationBean配置

如果使用ServletRegistrationBean注册Servlet,确保addMapping方法配置正确。错误的配置可能导致Filter被应用到不应该应用的地方,从而导致重复打印。例如:

@Bean
public ServletRegistrationBean myServletRegistration (MyServlet myServlet) {
    ServletRegistrationBean registration = new ServletRegistrationBean(myServlet);
    registration.addUrlMappings("/myServlet/*"); // 正确的配置
    return registration;
}

确保addUrlMappings与Servlet的实际处理路径匹配,避免Filter被错误地应用到其他路径。

7. 检查AOP切面日志打印

如果使用了AOP切面进行日志打印,确保切面表达式(Pointcut Expression)足够精确,避免与Filter中的日志打印发生冲突。

例如,如果只想打印Controller方法的日志,可以使用如下切面:

@Aspect
@Component
public class ControllerLogAspect {

    private static final Logger logger = LoggerFactory.getLogger(ControllerLogAspect.class);

    @Pointcut("execution(* com.example.controller.*.*(..))") // 精确匹配Controller方法
    public void controllerMethods() {}

    @Before("controllerMethods()")
    public void logBefore(JoinPoint joinPoint) {
        logger.info("Entering method: " + joinPoint.getSignature().toShortString());
    }

    @AfterReturning(pointcut = "controllerMethods()", returning = "result")
    public void logAfterReturning(JoinPoint joinPoint, Object result) {
        logger.info("Exiting method: " + joinPoint.getSignature().toShortString() + " with result: " + result);
    }
}

确保切面表达式只匹配Controller方法,避免与Filter中的日志打印重复。

8. 使用条件注解

使用Spring Boot的条件注解(Conditional Annotations)可以根据特定的条件来决定是否注册Bean。 例如,可以根据环境配置(dev, prod)来决定是否注册某个Filter。

@Configuration
public class FilterConfig {

    @Bean
    @ConditionalOnProperty(name = "my.filter.enabled", havingValue = "true")
    public FilterRegistrationBean<MyFilter> myFilterRegistrationBean(MyFilter myFilter) {
        FilterRegistrationBean<MyFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(myFilter);
        registrationBean.addUrlPatterns("/api/*");
        registrationBean.setOrder(1);
        return registrationBean;
    }
}

application.propertiesapplication.yml中配置my.filter.enabled属性:

my.filter.enabled=true # 启用Filter

这样可以灵活地控制Filter的注册,避免在不需要的环境中重复打印日志。

四、代码示例:一个完整的解决方案

下面是一个完整的代码示例,演示了如何使用 FilterRegistrationBean 和 MDC 来解决日志重复打印的问题:

// 自定义过滤器
@Component
public class MyFilter implements Filter {

    private static final Logger logger = LoggerFactory.getLogger(MyFilter.class);

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        String requestId = UUID.randomUUID().toString();
        MDC.put("requestId", requestId);
        logger.info("Request received: {} {} [requestId={}]", httpRequest.getMethod(), httpRequest.getRequestURI(), requestId);
        try {
            chain.doFilter(request, response);
        } finally {
            MDC.remove("requestId");
        }
    }
}

// 过滤器配置
@Configuration
public class FilterConfig {

    @Bean
    public FilterRegistrationBean<MyFilter> myFilterRegistrationBean(MyFilter myFilter) {
        FilterRegistrationBean<MyFilter> registrationBean = new FilterRegistrationBean<>();
        registrationBean.setFilter(myFilter);
        registrationBean.addUrlPatterns("/api/*"); // 精确匹配 URL 模式
        registrationBean.setOrder(1); // 设置执行顺序
        return registrationBean;
    }
}

// Controller
@RestController
@RequestMapping("/api")
public class MyController {

    private static final Logger logger = LoggerFactory.getLogger(MyController.class);

    @GetMapping("/users")
    public String getUsers() {
        logger.info("Getting users [requestId={}]", MDC.get("requestId"));
        return "users";
    }
}

在这个示例中,MyFilter 使用 FilterRegistrationBean 注册,并精确匹配 /api/* 路径。同时,MyFilter 使用 MDC 设置 requestId,并在日志中包含 requestId,方便追踪请求的上下文信息。MyController 也可以直接使用 MDC 中的 requestId,保证日志的一致性。

五、总结与建议

解决 Spring Boot 请求链路 Logging 重复打印问题,需要仔细分析问题根源,并采取针对性的解决方案。以下是一些建议:

  • 仔细分析日志: 通过日志分析,找出重复打印的过滤器。
  • 精确配置 URL 匹配规则: 避免使用过于宽泛的 URL 匹配规则。
  • 合理安排过滤器执行顺序: 使用 @Order 注解或 FilterRegistrationBeansetOrder() 方法控制过滤器的执行顺序。
  • 避免在过滤器中直接打印业务日志: 业务日志应该在 Controller 或 Service 层打印。
  • 使用 MDC 传递上下文信息: 方便追踪请求的上下文信息。
  • 使用 FilterRegistrationBean 注册过滤器: 可以更精细地控制过滤器的注册和配置。
  • 使用条件注解灵活控制Filter注册: 根据环境配置决定是否注册Bean。
  • 检查ServletRegistrationBean和AOP切面配置: 确保配置正确,避免冲突。

通过以上方法,可以有效地解决 Spring Boot 请求链路 Logging 重复打印问题,提高日志的可读性和问题排查效率。

核心在于理清过滤器链的执行流程,以及控制好每个过滤器的作用范围和执行顺序。
诊断是关键,需要仔细分析日志和跟踪请求执行流程。
选择合适的解决方案,针对性地解决问题。

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注