公司动态

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

📅 2026/8/8 2:13:53
SpringBoot AOP实现Web请求日志统一处理:从原理到生产实践
1. 项目概述为什么我们需要统一处理Web请求日志在任何一个稍具规模的Web应用后台开发中日志记录都是不可或缺的一环。尤其是对于HTTP请求的日志它不仅是排查线上问题的“第一现场”更是分析用户行为、监控接口性能、审计安全操作的关键数据源。然而如果你还在每个Controller的方法里手动写log.info(“收到请求参数是{}”, param)那不仅代码冗余、难以维护更可怕的是一旦日志格式需要调整或者需要增加新的记录字段比如记录用户ID或请求耗时你就得把所有相关方法都改一遍这无异于一场灾难。SpringBoot AOP面向切面编程正是解决这类横切关注点Cross-cutting Concerns的利器。通过AOP我们可以将日志记录这种与核心业务逻辑无关但又遍布系统各处的功能抽取到一个独立的“切面”中。这样一来业务代码保持干净纯粹日志逻辑集中管理修改起来只需动一处。这个项目就是带你从零开始搭建一个基于SpringBoot AOP的、功能完备的Web请求日志统一处理组件。我们会覆盖从基础注解拦截到异步处理、性能监控、异常捕获再到生产环境部署和问题排查的全链路实践。无论你是刚接触AOP的新手还是想优化现有日志体系的老手这篇内容都能提供可直接“抄作业”的落地方案。2. 核心设计思路与AOP选型考量2.1 横切关注点的抽象什么该记什么不该记在设计日志切面之前首先要明确我们到底要记录什么。一个完整的Web请求日志至少应包含以下几个维度请求信息URL、HTTP方法、请求头、客户端IP、用户代理User-Agent。请求参数GET请求的Query String、POST请求的Body需注意敏感信息过滤。响应信息HTTP状态码、响应体通常只记录关键信息或大小避免日志膨胀。上下文信息当前登录用户ID从Session或Token中解析、请求耗时、请求时间戳。异常信息当请求处理过程中抛出异常时需要记录异常堆栈。AOP让我们有能力在请求处理的特定“连接点”插入这些记录逻辑。主要关注两个点方法执行前记录请求和方法执行后记录响应。对于异常则有专门的异常通知来处理。2.2 Spring AOP vs. AspectJ为何选择Spring AOPSpring提供了两种AOP支持功能更强大的AspectJ和更轻量、与Spring容器集成更深的Spring AOP。对于Web请求日志这种场景Spring AOP是更合适的选择原因如下代理模式Spring AOP基于动态代理JDK动态代理或CGLIB。它只能拦截Spring容器管理的Bean的方法调用这完美契合了我们的需求——拦截Controller或RestController中的方法。配置简单无需复杂的编译时或加载时织入Weaving配置通过Aspect注解和Spring Boot的自动配置即可快速上手。够用原则Web请求日志不需要拦截私有方法、构造方法或静态方法Spring AOP的方法拦截能力完全足够。注意如果你需要拦截非Spring管理的对象如直接new出来的实例或更细粒度的控制如字段访问则需要考虑使用AspectJ。但99%的Web日志场景Spring AOP足矣。2.3 切点表达式设计精准拦截Web层切点Pointcut表达式定义了在何处执行通知Advice。我们的目标是拦截所有Web请求入口。// 常见的切点表达式示例 Pointcut(“execution(public * com.yourpackage.controller..*.*(..))“) public void webLog() {}这个表达式解读为拦截com.yourpackage.controller包及其子包下所有类的所有public方法。这是最直接的方式。但在微服务或模块化清晰的项目中你可能只想拦截标注了RestController的类或者特定的URL模式。这时可以结合注解和annotation、within或args等指示符来设计更灵活的切点。例如可以创建一个自定义注解Loggable只有标注了该注解的方法才记录详细日志实现更精细的控制。3. 核心组件实现与代码详解3.1 基础依赖引入与配置首先在pom.xml中引入必要依赖。Spring Boot Starter AOP已经包含了Spring AOP和AspectJ相关的核心库。dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-aop/artifactId /dependency !-- 如果使用Jackson来序列化日志对象推荐确保有starter-web -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency确保主应用类或配置类上开启了AOP支持Spring Boot默认已开启无需额外注解EnableAspectJAutoProxy但了解其存在有益。3.2 日志切面Aspect骨架搭建我们创建一个名为WebLogAspect的类并用Aspect和Component注解标记它使其成为Spring容器管理的切面Bean。package com.example.demo.aspect; import lombok.extern.slf4j.Slf4j; import org.aspectj.lang.ProceedingJoinPoint; import org.aspectj.lang.annotation.*; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import javax.servlet.http.HttpServletRequest; import java.util.Arrays; Aspect Component Slf4j // 使用Lombok的Slf4j注解 public class WebLogAspect { /** * 定义切点拦截controller包下的所有公共方法 */ Pointcut(“execution(public * com.example.demo.controller..*.*(..))“) public void webLog() {} // 通知Advice将在这里定义 }3.3 环绕通知实现完整记录请求与响应环绕通知Around功能最强大它可以在目标方法执行前后和抛出异常时插入逻辑最适合用来计算耗时和统一处理。/** * 环绕通知记录请求日志、响应日志和耗时 * param joinPoint 连接点提供了方法执行的上下文信息 * return 目标方法的执行结果 * throws Throwable 可能抛出的异常 */ Around(“webLog()“) public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { // 1. 获取当前HTTP请求对象 ServletRequestAttributes attributes (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); if (attributes null) { // 非Web请求上下文直接执行原方法例如单元测试环境 return joinPoint.proceed(); } HttpServletRequest request attributes.getRequest(); // 2. 记录请求内容 long startTime System.currentTimeMillis(); String requestLog buildRequestLog(request, joinPoint); log.info(“Request Log: {}“, requestLog); Object result; try { // 3. 执行目标方法 result joinPoint.proceed(); long endTime System.currentTimeMillis(); long costTime endTime - startTime; // 4. 记录响应内容 String responseLog buildResponseLog(result, costTime); log.info(“Response Log: {}, Cost: {}ms“, responseLog, costTime); } catch (Throwable e) { long endTime System.currentTimeMillis(); long costTime endTime - startTime; // 5. 记录异常日志 log.error(“Error Log: URL[{}], Method[{}], IP[{}], Cost[{}ms], Exception:“, request.getRequestURL().toString(), request.getMethod(), getClientIp(request), costTime, e); // 异常需要继续抛出让全局异常处理器或框架处理 throw e; } return result; } /** * 构建请求日志字符串 */ private String buildRequestLog(HttpServletRequest request, ProceedingJoinPoint joinPoint) { StringBuilder sb new StringBuilder(); sb.append(“URL“).append(request.getRequestURL().toString()); sb.append(“, HTTP_METHOD“).append(request.getMethod()); sb.append(“, IP“).append(getClientIp(request)); sb.append(“, CLASS_METHOD“).append(joinPoint.getSignature().getDeclaringTypeName()) .append(“.“).append(joinPoint.getSignature().getName()); sb.append(“, ARGS“).append(Arrays.toString(joinPoint.getArgs())); // 注意生产环境记录请求体需谨慎可能包含敏感信息或大文件 return sb.toString(); } /** * 构建响应日志字符串 */ private String buildResponseLog(Object result, long costTime) { // 这里简单处理实际可根据需要序列化result注意避免循环引用和过大对象 String resultStr “null“; if (result ! null) { // 对于常见的REST响应体如ResultVO可以只记录关键字段 try { // 使用Jackson简单序列化控制长度 com.fasterxml.jackson.databind.ObjectMapper mapper new com.fasterxml.jackson.databind.ObjectMapper(); resultStr mapper.writeValueAsString(result); if (resultStr.length() 500) { // 防止日志过长 resultStr resultStr.substring(0, 500) “... (truncated)“; } } catch (Exception e) { resultStr result.toString(); } } return “RESULT“ resultStr; } /** * 获取客户端真实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; }3.4 敏感信息过滤与日志脱敏这是生产环境必须考虑的一环。直接在日志中输出用户密码、身份证号、手机号、Token等是严重的安全隐患。我们需要在记录前对参数进行脱敏处理。实操心得不要在buildRequestLog里写一堆if-else来判断哪个参数需要脱敏这会让切面代码变得臃肿且难以维护。更好的做法是注解驱动脱敏自定义一个注解Sensitive可以标注在DTO类的字段上。在序列化时处理利用Jackson的JsonSerializer来自定义序列化逻辑。在切面中使用配置了自定义序列化器的ObjectMapper来序列化参数对象。在切面中过滤对于简单的场景可以在buildRequestLog方法中对joinPoint.getArgs()数组进行遍历如果参数是String或Map类型根据关键字如“password“、“idCard“进行正则替换。这里给出一个在切面中进行简单关键字过滤的示例private String maskSensitiveInfo(String original) { if (original null) return null; // 简单演示对包含password、token等字段的值进行脱敏 // 实际应用中这个逻辑需要更严谨可能结合注解和反射 String lower original.toLowerCase(); if (lower.contains(“password“) || lower.contains(“token“) || lower.contains(“authorization“)) { // 使用正则匹配并替换值部分 return original.replaceAll(“(?i)(password|token|authorization)[^]*“, “$1******“); } return original; } // 在buildRequestLog中对参数字符串调用此方法更优雅的方案是集成专门的日志脱敏组件但这超出了基础切面的范围。核心原则是日志中绝不能出现明文密码和有效的Token。4. 性能优化与高级特性集成4.1 异步日志记录避免阻塞业务线程如果日志输出到文件或网络存储如ELK比较慢同步写日志会阻塞业务线程增加接口响应时间。我们可以使用Spring的Async注解将日志记录操作改为异步执行。步骤在主应用类上添加EnableAsync注解。将日志切面中实际执行日志输出的方法如log.info抽取到一个单独的服务类中。在该服务类的方法上标记Async。在切面中调用这个异步服务。Service public class AsyncLogService { Async // 指定使用异步任务执行器 public void saveRequestLog(String logContent) { // 这里可以执行较慢的IO操作如写入文件、发送到MQ或远程日志服务 log.info(logContent); // 即使这里慢也不会阻塞主线程 } } // 在WebLogAspect中注入AsyncLogService并调用其异步方法注意使用Async需要配置任务执行器TaskExecutor否则可能不生效或使用默认的单线程执行器效果不佳。建议在配置类中自定义一个线程池。4.2 集成SLF4J MDC实现请求链路追踪在微服务或高并发场景下如何将同一个用户请求在不同方法、甚至不同服务中打印的日志串联起来SLF4J的MDCMapped Diagnostic Context是关键。我们可以在请求进入切面时将一个唯一的追踪ID如UUID放入MDC在该请求的整个处理周期内这个ID都会自动附加到每条日志上。import org.slf4j.MDC; Around(“webLog()“) public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { // 在请求开始时生成并设置Trace ID String traceId UUID.randomUUID().toString().replace(“-“, ““); MDC.put(“TRACE_ID“, traceId); try { // ... 原有的around逻辑 log.info(“With TraceId: {}“, traceId); // 这条日志会自动包含TraceId return joinPoint.proceed(); } finally { // 请求结束后务必清除MDC防止内存泄漏和上下文污染 MDC.clear(); } }在logback-spring.xml配置文件中修改日志模式以包含%X{TRACE_ID}pattern%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] [%X{TRACE_ID}] %-5level %logger{50} - %msg%n/pattern这样所有来自同一次请求的日志都会带有相同的TRACE_ID在日志聚合系统中可以轻松过滤和追踪。4.3 条件化日志记录基于配置或注解的开关不是所有请求都需要记录完整日志。对于健康检查接口/actuator/health或静态资源请求频繁的详细日志没有意义且浪费存储。我们可以通过以下几种方式实现条件化记录配置文件控制在application.yml中定义一个开关。logging: web: enable-detail: true # 默认开启详细日志 exclude-paths: /health,/favicon.ico # 排除的路径自定义注解控制创建Loggable(level “INFO“, ignore false)和IgnoreLog注解。在切点表达式中通过annotation或within来匹配。对于想忽略日志的方法标注IgnoreLog即可。Pointcut(“webLog() !annotation(com.example.demo.annotation.IgnoreLog)“) public void loggableWebLog() {}然后在Around通知中使用loggableWebLog()作为切点。5. 生产环境部署、监控与问题排查5.1 日志输出配置与滚动策略在application.yml或logback-spring.xml中为我们的Web请求日志配置独立的Appender和日志文件避免和业务日志混在一起。logging: level: com.example.demo.aspect.WebLogAspect: INFO # 控制切面本身的日志级别 file: name: logs/app.log logback: rollingpolicy: max-file-size: 50MB max-history: 30 # 自定义Appender配置通常写在logback-spring.xml中更灵活在logback-spring.xml中可以配置一个专门的文件Appender来记录访问日志并设置按天或按大小滚动。5.2 常见问题排查与解决方案实录在实际使用中你肯定会遇到下面这些问题这里是我的踩坑记录问题1AOP切面不生效日志没有打印。排查步骤检查切面类是否被Spring管理是否有Component或Aspect注解。检查切点表达式是否正确。特别是包路径com.example.demo.controller..*..代表任意深度的子包*.*代表任意方法。可以用一个简单的Before通知打印一句话来测试切点是否匹配。检查目标Controller方法是否是通过Spring代理调用的。在同一个类内部的方法调用例如Controller方法A调用同一个Controller里的方法B是不会触发AOP的因为这是目标对象自身的调用绕过了代理。这是Spring AOP基于代理机制的一个经典限制。确认没有其他AOP切面如事务切面的切点表达式优先级冲突或导致代理创建失败。问题2获取HttpServletRequest对象为null。原因RequestContextHolder.getRequestAttributes()依赖于Spring的RequestContextListener或DispatcherServlet创建的请求上下文。如果切面拦截了一个非Web请求的调用如定时任务、MQ监听器内部调用了Service方法而该Service方法也被相同的切点匹配或者在某些异步线程中请求上下文会丢失。解决在切面方法开始处进行判空就像我们示例代码中做的那样。如果是非Web上下文可以选择跳过日志记录或者尝试从其他上下文如SecurityContext获取信息。问题3记录请求体RequestBody后后续的Controller无法再读取。原因HttpServletRequest的输入流InputStream只能读取一次。如果在切面中通过request.getInputStream()读取了Body内容流的位置就到了末尾Controller里再读就没了。解决使用ContentCachingRequestWrapper。在Spring中你可以通过一个Filter提前对Request进行包装。更简单的做法是在AOP中避免直接读取getInputStream()。对于JSON请求可以通过joinPoint.getArgs()获取已经被Spring MVC解析好的RequestBody对象参数。如果非要读取原始流必须自行实现流的缓存和重置比较复杂不推荐在简单的日志切面中做。问题4日志输出量巨大影响磁盘IO和应用性能。解决异步日志如前所述使用Async。采样记录非核心接口可以按比例采样记录比如只记录1%的请求。可以在切面中通过随机数实现。日志级别控制为切面设置更高的日志级别如WARN只在出错时记录。或者通过配置中心动态调整。精简日志内容不要记录完整的请求/响应体只记录URL、方法、状态码、耗时等关键元数据。对于响应体只记录业务状态码或错误信息。问题5如何对文件上传MultipartFile请求进行日志记录挑战MultipartFile对象直接打印会输出无意义的引用地址且文件内容不适合记录到日志。方案在切面中判断参数类型。如果是MultipartFile或MultipartFile[]则记录文件名、文件大小和Content-Type而不是文件内容。private String processArgs(Object arg) { if (arg instanceof MultipartFile) { MultipartFile file (MultipartFile) arg; return String.format(“MultipartFile[name%s, size%d, contentType%s]“, file.getOriginalFilename(), file.getSize(), file.getContentType()); } else if (arg instanceof MultipartFile[]) { // ... 处理数组 } else if (arg instanceof String || arg instanceof Number || arg instanceof Boolean) { return String.valueOf(arg); } else { // 其他复杂对象用JSON序列化注意脱敏和循环引用 return toJsonString(arg); } }5.3 与监控系统如Prometheus, SkyWalking集成AOP记录的耗时、状态码是宝贵的监控指标。除了写入日志文件我们还可以将这些数据推送到监控系统。集成MicrometerSpring Boot Actuator集成了Micrometer你可以轻松地在切面中记录计时器和计数器。Autowired private MeterRegistry meterRegistry; Around(“webLog()“) public Object doAround(ProceedingJoinPoint joinPoint) throws Throwable { String methodName joinPoint.getSignature().getName(); Timer.Sample sample Timer.start(meterRegistry); try { Object result joinPoint.proceed(); sample.stop(Timer.builder(“http.request.duration“) .tag(“method“, methodName) .tag(“status“, “success“) .register(meterRegistry)); return result; } catch (Exception e) { sample.stop(Timer.builder(“http.request.duration“) .tag(“method“, methodName) .tag(“status“, “error“) .register(meterRegistry)); Counter.builder(“http.request.errors“) .tag(“method“, methodName) .tag(“exception“, e.getClass().getSimpleName()) .register(meterRegistry) .increment(); throw e; } }集成SkyWalking/APM对于分布式链路追踪通常不需要在业务AOP中手动埋点。这些APM agent如SkyWalking Java Agent会在字节码层面自动增强Servlet和Spring MVC框架收集更精细的链路信息。你的AOP日志可以作为辅助和补充尤其是在APM工具尚未覆盖的自定义逻辑处。6. 总结与个人实践建议经过上面从设计到实现再到优化和排坑的完整流程一个健壮、可用的Web请求日志切面就已经搭建起来了。回过头看最关键的不是代码本身而是几个设计决策点切点范围的界定、敏感信息的处理、性能影响的权衡以及与现有基础设施日志、监控的融合。我个人在多个项目中实践下来的体会是初期可以做一个功能全面的“豪华版”切面记录所有能想到的信息。但在项目上线前一定要做压力测试观察这个切面对接口响应时间RT和吞吐量QPS的影响。根据测试结果果断地做减法关闭非核心接口的日志、缩短日志内容、启用异步输出。记住日志系统的首要原则是不能影响主业务的稳定性。另一个实用的技巧是为这个日志切面配置独立的日志级别和输出目标。比如在logback-spring.xml里配置一个叫WEB_ACCESS的Logger将它的日志单独输出到access.log文件并且按天滚动。这样当你需要分析用户访问行为或排查特定请求问题时可以直接tail -f access.log而不必在海量的业务日志中苦苦搜寻。最后这个切面只是一个起点。你可以基于它轻松扩展出操作审计日志记录谁在什么时间做了什么、接口耗时报警当某个接口耗时超过阈值时发邮件、限流监控统计单位时间内的请求量等功能。AOP提供的这种非侵入式能力让这些全局性的功能增强变得异常优雅和高效。