FEATURED · 精选文章

SpringBoot AOP统一Web请求日志:从原理到生产级实现

发布时间 / 2026/8/7 7:28:21
来源 / 创域科博编辑部
栏目 / 资讯中心
SpringBoot AOP统一Web请求日志:从原理到生产级实现 1. 项目缘起为什么我们需要统一处理Web请求日志在任何一个后端服务里日志都是我们排查问题的“眼睛”。尤其是Web请求日志它记录了谁、在什么时候、用什么方式、访问了哪个接口、得到了什么结果。当线上出现一个诡异的接口超时或者用户反馈“我的操作没生效”时第一反应是什么对就是去翻日志。但如果你还在用最原始的方式在每个Controller方法里手动写log.info(“收到请求参数是{}”, param)那很快就会陷入几个困境。首先代码严重重复每个方法开头结尾都是那几行日志代码枯燥且容易出错。其次日志格式不统一张三喜欢打印JSON李四喜欢用逗号分隔排查时看得眼花缭乱。最关键的是你很容易遗漏关键信息比如处理耗时、用户IP、请求IDTraceId等而这些信息在分布式链路追踪时至关重要。所以统一处理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 groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdorg.springframework.boot/groupId artifactIdspring-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}]它会从MDCMapped Diagnostic Context中取出我们设置的traceId并打印出来。这样同一个请求下的所有日志包括切面里的、Service里手动打的都会自动带上这个ID用grep traceIdxxx就能轻松串起整个请求链路的所有日志。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上下文MapString, 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等信息。生产预览测试在预发布环境通过真实的网关或前端调用观察ELKElasticsearch, Logstash, Kibana或你的日志平台上日志格式是否规整traceId是否能成功串联起一个完整请求的多个步骤如网关-服务A-服务B。一个常见的验证点是故意在Controller里抛出一个异常看看错误日志是否被切面的AfterThrowing或Around中的catch块捕获并记录同时确保异常依然向上抛出了没有被“吞掉”。走到这一步你的统一请求日志切面就不再是一个简单的工具类而是一个融入了可观测性思维的、为生产环境准备的基础设施组件。它带来的不仅是开发时的便利更是线上问题定位效率的质变。当你下次凌晨三点被告警电话叫醒能在一分钟内通过traceId定位到问题根因时你会感谢今天花时间搭建了它。
RELATED — 相关阅读

相关资讯

LATEST — 最新资讯

最新发布

TODAY — 本日精选

新闻

WEEKLY — 本周精选

新闻

MONTHLY — 本月精选

新闻