SpringBoot AOP统一Web请求日志:从原理到生产级实现
1. 项目缘起:为什么我们需要统一处理Web请求日志?
在任何一个后端服务里,日志都是我们排查问题的“眼睛”。尤其是Web请求日志,它记录了谁、在什么时候、用什么方式、访问了哪个接口、得到了什么结果。当线上出现一个诡异的接口超时,或者用户反馈“我的操作没生效”时,第一反应是什么?对,就是去翻日志。
但如果你还在用最原始的方式,在每个Controller方法里手动写log.info(“收到请求,参数是:{}”, param),那很快就会陷入几个困境。首先,代码严重重复,每个方法开头结尾都是那几行日志代码,枯燥且容易出错。其次,日志格式不统一,张三喜欢打印JSON,李四喜欢用逗号分隔,排查时看得眼花缭乱。最关键的是,你很容易遗漏关键信息,比如处理耗时、用户IP、请求ID(TraceId)等,而这些信息在分布式链路追踪时至关重要。
所以,统一处理Web请求日志,本质上是在做两件事:一是通过技术手段消灭重复劳动,提升开发效率;二是标准化日志输出,为后续的监控、告警、链路追踪打下坚实基础。SpringBoot + AOP的组合,正是解决这个问题的“银弹”。AOP(面向切面编程)允许我们将这些横跨多个模块的公共行为(日志记录)从业务逻辑中剥离出来,集中到一个地方进行声明和管理。这不仅仅是写几行代码,更是一种工程化的思维。
2. AOP核心概念扫盲:切面、连接点与通知
在动手之前,我们得先统一语言。AOP里有些概念听起来有点玄乎,但其实用生活中的例子一比喻就懂了。假设你的项目是一栋大楼,每个Controller方法就是一个房间。
- 连接点 (Join Point): 这栋楼里所有可能被“增强”的地方,比如每个房间的门口(方法执行前)、窗户(方法异常时)、后门(方法执行后)。在Spring AOP中,连接点特指方法的执行。
- 切点 (Pointcut): 你不是要对所有房间都做同样的事。你可能只想监控“所有总经理办公室”或者“所有带窗户的会议室”。切点就是一个表达式,用来匹配和筛选你感兴趣的连接点。比如,
execution(* com.example.controller..*.*(..))这个表达式,就匹配了com.example.controller包及其子包下所有类的所有方法。 - 通知 (Advice): 你想在那些被选中的地方(切点)具体做什么。这就是通知。它定义了增强行为的时机和内容。
@Before: 在房间门口放个迎宾员(方法执行前)。@AfterReturning: 客人满意离开后,在门口做个登记(方法成功返回后)。@AfterThrowing: 客人生气摔门而出时,记录下投诉原因(方法抛出异常后)。@After: 不管客人是满意还是生气,只要他离开了房间,就打扫一下卫生(方法执行后,无论成功或异常)。@Around: 这是最强大的保安,他可以在客人进门前检查证件(前置处理),在客人进入后全程陪同(执行原方法),在客人离开时出具访客记录(后置处理),甚至有权不让客人进入(阻止方法执行)。
- 切面 (Aspect): 把上面这些概念组合起来,形成一个完整的方案。比如,“大楼安全监控方案”这个切面,就包含了监控哪些房间(切点)、在什么时机(通知类型)、做什么具体动作(通知内容)。在我们的项目里,“统一请求日志切面”就是一个具体的Aspect。
理解了这些,你就知道,我们要做的就是定义一个切面,写一个切点来匹配所有Web请求方法,然后用@Around通知把这些方法的进、出、耗时、参数、结果都记录下来。
3. 实战构建:从零搭建一个生产可用的日志切面
光说不练假把式,我们直接上代码。我会用一个围绕Controller层设计的@Around通知作为核心,因为它能提供最全面的控制。
3.1 基础依赖与环境准备
首先,确保你的pom.xml里已经引入了SpringBoot的Web和AOP依赖。一般来说,用Spring Initializr创建项目时勾选Spring Web和Spring AOP即可。
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-aop</artifactId> </dependency>如果你需要更丰富的日志内容,比如获取HTTP请求对象,spring-boot-starter-web已经包含了必要的Servlet API。另外,推荐使用SLF4J + Logback/Log4j2作为日志框架,SpringBoot默认已经集成好了。
3.2 核心切面类设计与实现
我们来创建一个名为WebLogAspect的切面类。这里有几个关键设计点:
- 使用
@Around:因为它能完全控制方法的执行,方便计算耗时和在异常情况下也能记录日志。 - 定义清晰的切点:精准匹配Web控制器层的方法,避免切入到Service或Dao层,造成日志泛滥。
- 获取完整的上下文信息:包括请求IP、URL、参数、方法、用户标识等。
- 使用MDC实现请求链路追踪:为同一个请求的所有日志打上同一个
traceId,这在微服务环境下是黄金标准。
package com.example.demo.aspect; import com.fasterxml.jackson.databind.ObjectMapper; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.Around; import org.aspectj.lang.annotation.Aspect; import org.aspectj.lang.annotation.Pointcut; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import org.springframework.web.util.ContentCachingRequestWrapper; import javax.servlet.http.HttpServletRequest; import java.util.UUID; /** * 统一Web请求日志处理切面 */ @Aspect @Component public class WebLogAspect { private static final Logger LOGGER = LoggerFactory.getLogger(WebLogAspect.class); private final ObjectMapper objectMapper = new ObjectMapper(); /** * 定义切点:匹配controller包下的所有公有方法 * execution(* com.example.demo.controller..*.*(..)) * 第一个*:返回值任意 * com.example.demo.controller..:controller包及其所有子包 * 第二个*:类名任意 * 第三个*:方法名任意 * (..):参数任意 */ @Pointcut("execution(public * com.example.demo.controller..*.*(..))") public void webLog() {} /** * 环绕通知 */ @Around("webLog()") public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { // 1. 请求开始时间 long startTime = System.currentTimeMillis(); // 2. 获取当前HTTP请求对象 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes == null) { // 非Web请求上下文,直接执行原方法 return joinPoint.proceed(); } HttpServletRequest request = attributes.getRequest(); // 3. 生成或获取本次请求的唯一追踪ID,并放入MDC String traceId = request.getHeader("X-Trace-Id"); if (traceId == null || traceId.isEmpty()) { traceId = UUID.randomUUID().toString().replace("-", "").substring(0, 16); } MDC.put("traceId", traceId); // 关键:设置到MDC,后续所有日志自动携带 // 4. 构建请求日志信息 String requestLog = buildRequestLog(request, joinPoint); LOGGER.info("[Request Start] {}", requestLog); Object result = null; try { // 5. 执行目标方法 result = joinPoint.proceed(); // 6. 计算耗时并记录响应日志 long endTime = System.currentTimeMillis(); long costTime = endTime - startTime; String responseLog = buildResponseLog(result, costTime); LOGGER.info("[Request End] Cost: {}ms, Response: {}", costTime, responseLog); return result; } catch (Throwable e) { // 7. 异常处理日志 long endTime = System.currentTimeMillis(); long costTime = endTime - startTime; LOGGER.error("[Request Error] Cost: {}ms, Exception: {}", costTime, e.getMessage(), e); // 异常需要继续抛出,让全局异常处理器或上层处理 throw e; } finally { // 8. 最终清理:务必清除MDC中的traceId,防止内存泄漏和上下文污染 MDC.clear(); } } /** * 构建请求日志字符串 */ private String buildRequestLog(HttpServletRequest request, ProceedingJoinPoint joinPoint) { StringBuilder sb = new StringBuilder(); sb.append("TraceId=").append(MDC.get("traceId")).append(" | "); sb.append("IP=").append(getClientIp(request)).append(" | "); sb.append("Method=").append(request.getMethod()).append(" | "); sb.append("URI=").append(request.getRequestURI()).append(" | "); sb.append("Class.Method=").append(joinPoint.getSignature().getDeclaringTypeName()) .append(".").append(joinPoint.getSignature().getName()).append(" | "); // 谨慎记录参数:敏感信息(如密码)需要脱敏,大参数可能影响性能 Object[] args = joinPoint.getArgs(); if (args != null && args.length > 0) { try { // 简单示例,生产环境建议自定义序列化,过滤敏感字段 String params = objectMapper.writeValueAsString(args); // 对参数进行脱敏处理(示例:隐藏密码) params = maskSensitiveInfo(params); sb.append("Args=").append(params); } catch (Exception e) { sb.append("Args=[Serialization Error]"); } } else { sb.append("Args=[]"); } return sb.toString(); } /** * 构建响应日志字符串 */ private String buildResponseLog(Object result, long costTime) { try { String resultStr = objectMapper.writeValueAsString(result); // 响应结果也可能需要脱敏或截断 if (resultStr.length() > 500) { // 避免日志过长 resultStr = resultStr.substring(0, 500) + "...(truncated)"; } return resultStr; } catch (Exception e) { return "[Serialization Error]"; } } /** * 获取客户端真实IP(处理代理情况) */ private String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("WL-Proxy-Client-IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_CLIENT_IP"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("HTTP_X_FORWARDED_FOR"); } if (ip == null || ip.length() == 0 || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } // 对于通过多个代理的情况,第一个IP为客户端真实IP if (ip != null && ip.contains(",")) { ip = ip.split(",")[0].trim(); } return ip; } /** * 简单的敏感信息脱敏(示例) */ private String maskSensitiveInfo(String jsonStr) { // 这是一个非常简单的示例,实际应用中应使用更健壮的方式,如注解或配置中心 return jsonStr.replaceAll("(?i)\"password\"\\s*:\\s*\"[^\"]*\"", "\"password\":\"******\"") .replaceAll("(?i)\"token\"\\s*:\\s*\"[^\"]*\"", "\"token\":\"******\""); } }3.3 日志配置优化
光有切面还不够,我们需要配置日志框架,让输出的日志格式美观且包含我们注入的traceId。在application.yml或logback-spring.xml中配置:
logging: pattern: console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{traceId}] %-5level %logger{50} - %msg%n" level: com.example.demo.aspect: INFO关键部分是[%X{traceId}],它会从MDC(Mapped Diagnostic Context)中取出我们设置的traceId并打印出来。这样,同一个请求下的所有日志(包括切面里的、Service里手动打的)都会自动带上这个ID,用grep traceId=xxx就能轻松串起整个请求链路的所有日志。
4. 深入细节:那些容易踩坑的“暗礁”
代码跑起来不难,但想在生产环境稳定运行,以下几个坑你必须提前知道。
4.1 切点表达式失效:为什么我的Controller没被切入?
这是新手最高频的问题。首先,检查你的切面类是否被Spring管理(加了@Component或@Aspect)。其次,确保你的Controller方法是通过Spring代理调用的。如果一个Controller方法内部调用了另一个Controller方法,或者被同一个类内部的其他方法调用,由于不是通过代理对象调用,AOP会失效。这是Spring AOP基于代理机制的本质决定的。
解决方案:避免在Controller内部进行方法调用。如果架构上必须,可以考虑使用AopContext.currentProxy()来获取当前代理对象,然后通过它来调用,但这会引入代码耦合,不推荐。更优雅的方式是重新审视职责划分,将需要AOP增强的逻辑提取到独立的Bean中。
4.2 请求体(Request Body)只能读一次的问题
在buildRequestLog方法中,我们通过joinPoint.getArgs()获取参数。但如果参数中有@RequestBody注解的对象,并且你在Controller里已经通过HttpServletRequest.getInputStream()读取了,那么在切面里再读可能会报错,因为流只能读一次。
解决方案:使用ContentCachingRequestWrapper。你可以配置一个Filter,在最开始将原生的HttpServletRequest包装一下。
@Component public class CachingRequestBodyFilter extends OncePerRequestFilter { @Override protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException { ContentCachingRequestWrapper wrappedRequest = new ContentCachingRequestWrapper(request); filterChain.doFilter(wrappedRequest, response); } }然后在切面中,判断如果是ContentCachingRequestWrapper,就可以通过wrappedRequest.getContentAsByteArray()安全地多次读取请求体。不过要注意,这会把整个请求体缓存到内存,对于上传大文件等场景需要谨慎评估。
4.3 异步线程导致MDC上下文丢失
如果你的服务中使用了@Async或线程池处理异步任务,你会发现子线程里的日志丢失了traceId。因为MDC是基于ThreadLocal实现的,线程切换时上下文不会自动传递。
解决方案:需要手动传递。在提交异步任务前,先获取当前的MDC上下文:
Map<String, String> context = MDC.getCopyOfContextMap(); CompletableFuture.runAsync(() -> { // 在子线程开始时设置 if (context != null) { MDC.setContextMap(context); } try { // 你的异步逻辑 } finally { MDC.clear(); } }, executor);对于Spring的@Async,可以定义一个AsyncConfigurer,配置一个TaskDecorator来包装任务,自动完成MDC的传递。
4.4 日志性能与磁盘I/O瓶颈
全量、详细的日志打印,尤其是将大对象序列化为JSON,在高并发下会成为性能杀手,甚至打满磁盘I/O。
优化策略:
- 异步日志:配置Logback或Log4j2的异步Appender,让日志写入操作由独立的线程池完成,不阻塞主业务线程。
- 采样打印:非核心查询接口,可以按比例采样打印日志,比如只记录1%的请求详情。
- 条件化日志:使用
LOGGER.isDebugEnabled()或LOGGER.isInfoEnabled()判断后再进行昂贵的字符串拼接或序列化操作。 - 精简日志内容:对于大的列表返回结果,只记录条数或摘要,而非全部数据。敏感信息和超大参数体要有选择地过滤或截断。
4.5 循环依赖与初始化顺序
如果你的切面@Autowired了某个Bean,而这个Bean又间接依赖了被切面拦截的Bean,可能会导致循环依赖。或者,切面在Spring容器初始化早期就被加载,但此时一些它依赖的Bean(如ObjectMapper)可能还未完全初始化。
解决方案:尽量避免在切面中注入复杂的Bean。如果必须注入,考虑使用@Lazy注解进行延迟加载。确保切面本身的逻辑尽可能轻量,只做日志记录这一件事。
5. 进阶与扩展:让日志系统更具价值
基础功能实现后,我们可以思考如何让它更好地服务于监控和运维。
5.1 集成Metrics与告警
日志不仅是事后排查的,也可以做实时的健康度指标。我们可以在切面中,将请求耗时、状态(成功/失败)等信息推送到时序数据库(如Prometheus)或监控系统。
// 伪代码示例 @Around("webLog()") public Object doAroundWithMetrics(ProceedingJoinPoint joinPoint) throws Throwable { String metricName = "http.request"; Timer.Sample sample = Timer.start(); boolean isSuccess = false; try { Object result = joinPoint.proceed(); isSuccess = true; return result; } finally { sample.stop(Metrics.timer(metricName, "uri", request.getRequestURI(), "method", request.getMethod(), "status", isSuccess ? "success" : "error")); } }这样,你可以在Grafana上绘制接口的P99耗时、QPS、错误率大盘,并设置规则,当某个接口错误率突增或耗时飙升时自动告警。
5.2 基于注解的精细化控制
不是所有接口都需要记录详细日志。我们可以自定义一个注解@Loggable,通过它来控制日志级别、是否记录参数/返回值、是否忽略该接口等。
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface Loggable { boolean recordParams() default true; boolean recordResult() default true; Level level() default Level.INFO; }然后在切点表达式中,可以结合@annotation()来匹配带有该注解的方法,实现更灵活的日志策略。
5.3 与分布式链路追踪系统集成
我们手动生成的traceId是一个简易方案。在生产级的微服务体系中,应该集成SkyWalking、Zipkin、Jaeger这样的专业APM(应用性能管理)工具。它们提供了自动注入的TraceId,并且能跨服务、跨进程传递。我们的切面可以调整为优先使用这些工具提供的TraceId,并确保日志格式与其兼容,实现日志与链路的关联查询。
6. 测试与验证:如何确保切面按预期工作?
写完代码不上线测试,等于闭着眼睛开车。验证AOP切面,我习惯用三层测试法:
- 单元测试(隔离测试切面逻辑):使用SpringBootTest,但只加载切面所在的上下文,Mock一个
HttpServletRequest和ProceedingJoinPoint,验证你的buildRequestLog等方法逻辑是否正确,特别是脱敏和IP获取逻辑。 - 集成测试(验证切面与Controller的编织):写一个简单的Test Controller,使用
MockMvc发起HTTP请求,然后断言日志是否被正确打印。这里可以检查日志输出中是否包含了预期的URL、方法、traceId等信息。 - 生产预览测试:在预发布环境,通过真实的网关或前端调用,观察ELK(Elasticsearch, Logstash, Kibana)或你的日志平台上,日志格式是否规整,traceId是否能成功串联起一个完整请求的多个步骤(如网关->服务A->服务B)。
一个常见的验证点是,故意在Controller里抛出一个异常,看看错误日志是否被切面的@AfterThrowing或@Around中的catch块捕获并记录,同时确保异常依然向上抛出了,没有被“吞掉”。
走到这一步,你的统一请求日志切面就不再是一个简单的工具类,而是一个融入了可观测性思维的、为生产环境准备的基础设施组件。它带来的不仅是开发时的便利,更是线上问题定位效率的质变。当你下次凌晨三点被告警电话叫醒,能在一分钟内通过traceId定位到问题根因时,你会感谢今天花时间搭建了它。
