需求:
前文已经讲过如何使用MDC在日志中为每个请求生成一个唯一traceID
,日志生成traceID。
请求作为入口,一般的系统都会有一个表
或者文件
记录每个请求,方便运维统计接口调用情况,实现方案大体两种:
- 使用 Spring AOP
- 使用
Filter
- 使用
Interceptor
感兴趣的,代码可以通过我的Demo工程获取。
一、Filter
1.1 CommonsRequestLoggingFilter
Spring Boot 自带了一个现成的 CommonsRequestLoggingFilter,它可以记录请求的详细信息并支持非常灵活的配置,省去了手动管理 Filter 的复杂性。
但是个人不建议在生产环境使用!
- 配置
Filter
@Configuration
public class RequestLoggingConfig {
@Bean
public CommonsRequestLoggingFilter requestLoggingFilter() {
CommonsRequestLoggingFilter loggingFilter = new CommonsRequestLoggingFilter();
loggingFilter.setIncludeClientInfo(true);
loggingFilter.setIncludeQueryString(true);
loggingFilter.setIncludePayload(true);
loggingFilter.setMaxPayloadLength(1000); // 设置要记录的最大请求体长度
loggingFilter.setIncludeHeaders(true); // 可选:是否记录请求头
return loggingFilter;
}
}
- 配置日志打印
logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<logger name="org.springframework.web.filter.CommonsRequestLoggingFilter">
<level value="DEBUG" />
</logger>
</configuration>
要注意如果没有重写CommonsRequestLoggingFilter的方法,日志级别必须是 DEBUG
。
1.1.1 INFO级别 日志不打印问题
通过
CommonsRequestLoggingFilter
源码可以知晓,shouldLog
默认是使用DEBUG
的,错误原因就很简单了。
根据类的设计可以知晓,CommonsRequestLoggingFilter
设计是为了开发人员在开发阶段、排查错误阶段打印接口日志,所以对于统计接口信息来讲就不太合适,所以个人不推荐使用。
CommonsRequestLoggingFilter
继承AbstractRequestLoggingFilter
再往上继承OncePerRequestFilter
再往上继承GenericFilterBean
,GenericFilterBean
实现了Filter
。所以本质上都是Filter
方案,只不过可以选择使用现有的类去满足自己的需求而已。
CommonsRequestLoggingFilter.java
public class CommonsRequestLoggingFilter extends AbstractRequestLoggingFilter {
@Override
protected boolean shouldLog(HttpServletRequest request) {
return logger.isDebugEnabled();
}
/**
* Writes a log message before the request is processed.
*/
@Override
protected void beforeRequest(HttpServletRequest request, String message) {
logger.debug(message);
}
/**
* Writes a log message after the request is processed.
*/
@Override
protected void afterRequest(HttpServletRequest request, String message) {
logger.debug(message);
}
}
修改后的代码:
@Configuration
public class WebConfig {
@Bean
public CommonsRequestLoggingFilter logFilter() {
CommonsRequestLoggingFilter filter
= new CommonsRequestLoggingFilter() {
@Override
protected boolean shouldLog(HttpServletRequest request) {
return true;
}
@Override
protected void beforeRequest(HttpServletRequest request, String message) {
logger.info(message);
}
@Override
protected void afterRequest(HttpServletRequest request, String message) {
logger.info(message);
}
};
filter.setIncludeQueryString(true);
filter.setIncludePayload(true);
filter.setMaxPayloadLength(10000);
filter.setIncludeHeaders(false);
filter.setAfterMessagePrefix("REQUEST DATA: ");
return filter;
}
}
1.2 OncePerRequestFilter
OncePerRequestFilter
是 Spring 提供的一个抽象类,它可以确保一个请求只会经过一次过滤。
- 日志类继承实现
@Slf4j
@Component
public class CustomRequestLoggingFilter extends OncePerRequestFilter {
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
long startTime = System.currentTimeMillis();
// 忽略文件上传请求
if (isFileUpload(request)) {
filterChain.doFilter(request, response);
return;
}
try {
// 继续处理请求
filterChain.doFilter(request, response);
} finally {
// 请求结束后记录日志
long duration = System.currentTimeMillis() - startTime;
logRequest(request, duration);
}
}
private void logRequest(HttpServletRequest request, long duration) {
StringBuilder logMessage = new StringBuilder();
logMessage.append("Method=").append(request.getMethod()).append("; ");
logMessage.append("URI=").append(request.getRequestURI()).append("; ");
logMessage.append("Query=").append(request.getQueryString()).append("; ");
logMessage.append("RemoteIP=").append(request.getRemoteAddr()).append("; ");
logMessage.append("Duration=").append(duration).append("ms;");
log.info(logMessage.toString());
}
private boolean isFileUpload(HttpServletRequest request) {
return "POST".equalsIgnoreCase(request.getMethod()) && request.getContentType() != null
&& request.getContentType().startsWith("multipart/form-data");
}
}
- 配置日志打印
logback-spring.xml
<?xml version="1.0" encoding="UTF-8"?>
<configuration>
<logger name="com.github.nan.web.core.filter.CustomRequestLoggingFilter" level="INFO" additivity="false">
<!-- 一般来讲是写到一个单独的文件,这里只是一个参考 -->
<appender-ref ref="STDOUT"/>
</logger>
</configuration>
二、AOP
AOP(面向切面编程) 也是一个非常灵活且强大的方式来记录请求数据。AOP 可以在不修改现有代码的情况下,横切关注点(如日志记录、事务管理等),并且能够更加精细地控制在哪些方法或控制器上进行日志记录。
- 请求日志切面
@Slf4j
public class RequestLoggingAspect {
@Before("execution(* com.github.nan.web.demos.web..*(..))")
public void logBefore(JoinPoint joinPoint) {
HttpServletRequest request = ((ServletRequestAttributes) RequestContextHolder.currentRequestAttributes()).getRequest();
// 忽略文件上传请求
if ("POST".equalsIgnoreCase(request.getMethod()) && request.getContentType() != null
&& request.getContentType().startsWith("multipart/form-data")) {
return;
}
log.info("Request received: [Method: {}] [URI: {}] [Query: {}] [Remote IP: {}] [Arguments: {}]",
request.getMethod(),
request.getRequestURI(),
request.getQueryString(),
request.getRemoteAddr(),
Arrays.toString(joinPoint.getArgs()));
}
@Around("execution(* com.github.nan.web.demos.web..*(..))")
public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {
long startTime = System.currentTimeMillis();
Object result = joinPoint.proceed();
long duration = System.currentTimeMillis() - startTime;
log.info("Request completed: [Duration: {} ms] [Return value: {}]",
duration,
result != null ? result.toString() : "null");
return result;
}
}
AOP 的优点
- 灵活性:你可以根据具体的类或方法定义切点,选择性地记录某些控制器的请求。
- 高可定制性:可以记录更加详细的日志内容,例如请求参数、执行耗时、返回值等。
- 避免重复代码:AOP 可以在不同的控制器中实现统一的日志记录逻辑,不需要在每个控制器中写相同的代码。
三、Interceptor
Spring 提供了 HandlerInterceptor 接口,用于在处理 HTTP 请求之前、处理之后以及完成请求时执行一些操作。拦截器通常用于日志记录、权限检查等场景。
- 拦截器代码
@Slf4j
@Component
public class RequestLoggingInterceptor implements HandlerInterceptor {
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) {
request = wrapRequest(request);
String payload = "";
if (request instanceof ContentCachingRequestWrapper) {
ContentCachingRequestWrapper wrapper = (ContentCachingRequestWrapper) request;
byte[] content = wrapper.getContentAsByteArray();
try {
payload = new String(content, wrapper.getCharacterEncoding());
} catch (UnsupportedEncodingException e) {
payload = "[unknown encoding]";
}
}
log.info("Request received: [Method: {}] [URI: {}] [Query: {}] [Remote IP: {}] [Payload : {}]",
request.getMethod(),
request.getRequestURI(),
request.getQueryString(),
request.getRemoteAddr(),
payload);
return true; // 返回 true 继续处理请求,false 则终止请求
}
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) {
log.info("Request completed: [Status: {}]", response.getStatus());
}
private HttpServletRequest wrapRequest(HttpServletRequest request) {
// 仅在 POST 请求的 JSON 请求体时进行包装,确保缓存请求体
if (HttpMethod.POST.name().equalsIgnoreCase(request.getMethod()) && MediaType.APPLICATION_JSON_VALUE.equals(request.getContentType())) {
return new ContentCachingRequestWrapper(request);
}
return request;
}
}
- 注册拦截器
@Configuration
public class WebConfig implements WebMvcConfigurer {
@Autowired
private RequestLoggingInterceptor requestLoggingInterceptor;
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(requestLoggingInterceptor).addPathPatterns("/**");
}
}
四、常见问题
4.1 Post
请求 payLoad 丢失
HttpServletRequest 的请求体只能被消费一次,之后再尝试读取时就会发现请求体已经被“耗尽”。
解决方案:使用 ContentCachingRequestWrapper
你可以将原始的 HttpServletRequest 包装为 ContentCachingRequestWrapper,这样就可以在拦截器中读取请求体,而不会影响后续控制器的处理。 可以参考 Interceptor 的代码。
五、总结
上述,
Filter
、AOP
、Interceptor
的代码方案只是阐述了实现的方式,一些实现的细节,需要根据自己的需求去补充,例如上传/下载文件要如何记录? 存在敏感信息的接口如何处理?