Spring Boot 请求链路 Logging 重复打印的过滤器链修复方案
各位开发者朋友们,大家好!今天我们来聊聊在 Spring Boot 项目开发中,一个比较常见且令人头疼的问题:请求链路 Logging 重复打印。这个问题通常发生在使用了自定义过滤器(Filter)对请求进行拦截并打印日志的场景下。当配置不当或者对 Spring Boot 过滤器链的理解不够深入时,很容易导致日志重复输出,影响日志的可读性和问题排查效率。
本次讲座将深入分析导致重复打印的原因,并提供多种解决方案,帮助大家彻底解决这个问题。
一、问题根源:多重拦截与配置错误
Spring Boot 中,请求的处理流程大致如下:
- 请求到达: 客户端发起 HTTP 请求。
- DispatcherServlet: 请求到达 Spring MVC 的核心组件 DispatcherServlet。
- 过滤器链(Filter Chain): DispatcherServlet 将请求交给配置好的过滤器链进行处理。
- Controller: 过滤器链处理完毕后,请求到达相应的 Controller 进行业务逻辑处理。
- 响应返回: Controller 处理完毕后,返回响应,响应再次经过过滤器链,最终返回给客户端。
而日志重复打印问题,往往出现在过滤器链这一环节。以下是一些常见的原因:
- 重复注册过滤器: 同一个过滤器被注册了多次,导致请求被同一个过滤器多次拦截。
- 过滤器配置不当: 过滤器的配置(例如 URL 匹配规则)过于宽泛,导致不应该被拦截的请求也被拦截。
- 过滤器执行顺序问题: 多个过滤器同时记录日志,但执行顺序不当,导致某些过滤器重复记录了其他过滤器的日志。
- ServletRegistrationBean配置不当: 当使用
ServletRegistrationBean注册Servlet时,如果没有正确配置addMapping,可能会导致过滤器被多次应用。 - AOP切面中的日志打印: 某些情况下,使用AOP切面进行日志打印,与Filter中的日志打印发生冲突,导致重复打印。
二、诊断:如何定位重复打印的源头
解决问题的第一步是定位问题。我们需要确定是哪个(或哪些)过滤器在重复打印日志。以下是一些常用的诊断方法:
- 日志分析: 仔细查看日志,分析重复打印的日志内容,尝试找出共同点,例如:
- 相同的请求参数
- 相同的用户 ID
- 相同的请求路径
- 相同的调用栈信息
- 逐个禁用过滤器: 将自定义的过滤器逐个禁用,然后观察日志输出情况,找出导致重复打印的过滤器。这种方法简单粗暴,但非常有效。可以在
@WebFilter注解中设置@WebFilter(urlPatterns = "/*", enabled = false)来禁用过滤器。 - 调试模式: 在代码中设置断点,跟踪请求在过滤器链中的执行流程,观察每个过滤器的执行情况。
- 使用工具: 使用APM (Application Performance Monitoring) 工具,如 SkyWalking, Zipkin等,可以追踪请求在各个服务和组件之间的调用链,更容易发现重复执行的过滤器。
- 打印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 匹配模式。 - 使用
FilterRegistrationBean的addUrlPatterns()方法: 同样可以设置精确的 URL 匹配模式。
例如,如果只想拦截 /api/users 路径的请求,可以这样配置:
@WebFilter(urlPatterns = "/api/users")
public class MyFilter implements Filter {
// ...
}
或者使用 FilterRegistrationBean:
registrationBean.addUrlPatterns("/api/users");
避免使用 /* 这样的通配符,除非确实需要拦截所有请求。
3. 控制过滤器的执行顺序
如果多个过滤器同时记录日志,但执行顺序不当,可能会导致重复记录日志。可以通过以下方式控制过滤器的执行顺序:
- 使用
@Order注解: 在过滤器类上使用@Order注解,数值越小,优先级越高。 - 使用
FilterRegistrationBean的setOrder()方法: 同样可以设置过滤器的执行顺序。
例如,下面的代码设置了两个过滤器的执行顺序:
@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.xml 或 log4j.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.properties或application.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注解或FilterRegistrationBean的setOrder()方法控制过滤器的执行顺序。 - 避免在过滤器中直接打印业务日志: 业务日志应该在 Controller 或 Service 层打印。
- 使用 MDC 传递上下文信息: 方便追踪请求的上下文信息。
- 使用
FilterRegistrationBean注册过滤器: 可以更精细地控制过滤器的注册和配置。 - 使用条件注解灵活控制Filter注册: 根据环境配置决定是否注册Bean。
- 检查ServletRegistrationBean和AOP切面配置: 确保配置正确,避免冲突。
通过以上方法,可以有效地解决 Spring Boot 请求链路 Logging 重复打印问题,提高日志的可读性和问题排查效率。
核心在于理清过滤器链的执行流程,以及控制好每个过滤器的作用范围和执行顺序。
诊断是关键,需要仔细分析日志和跟踪请求执行流程。
选择合适的解决方案,针对性地解决问题。