☰
Java统一日志切面实战:AspectJ+logback构建可追溯WebLog体系
2026/9/30 3:39:23 网站建设 项目流程

1. 项目概述:为什么“统一日志处理切面”不是锦上添花,而是系统稳定性的底层基建

“统一日志处理切面”这八个字,听上去像教科书里的概念名词,但在我带过的十几个中大型Java项目里,它从来不是写在PPT里的技术亮点,而是每次线上告警凌晨三点被叫醒后,第一个要翻看、比对、校验的那根“生命线”。它解决的不是“要不要记日志”的问题,而是“日志能不能信、能不能用、能不能救火”的问题。核心关键词——统一日志、切面、WebLog、AspectJ、logback——每一个都不是孤立存在:统一日志是目标,是结果;切面(Aspect)是实现路径,是手段;WebLog是最典型、最高频的落地场景;AspectJ是技术选型的基石;logback则是日志输出的最终执行者,是整条链路的“发声器官”。

我见过太多团队,初期靠System.out.println打天下,中期用logger.info("xxx")满天飞,后期运维一查日志,发现同一个用户操作,在Controller层、Service层、DAO层、甚至第三方SDK里,日志格式五花八门:有的带traceId,有的没有;有的时间戳是毫秒,有的是秒;有的用中文“开始处理”,有的用英文“Processing started”;更别提SQL参数全被?代替,根本看不出实际执行了什么。这种日志,不是资产,是噪音,是故障排查时的“反向干扰器”。而“统一日志处理切面”,就是一把手术刀,它不改变业务代码一行逻辑,却能从横切面(cross-cutting concern)上,把所有分散的日志入口收束、标准化、结构化。它让日志从“谁写的算谁的”,变成“系统说了算”。适合谁?不是只给架构师看的,而是给每一位每天要和日志打交道的开发、测试、运维同学准备的——当你不再需要在几十个类里逐个改logger.info,不再需要猜某个DEBUG日志到底出自哪一层,不再需要对着一团乱码般的日志文本抓耳挠腮时,你就真正理解了这个切面的价值。它不是炫技,是降本增效最实在的体现。

2. 整体设计思路与方案选型深度拆解:为什么是AspectJ + logback,而不是Spring AOP或SLF4J绑定?

2.1 切面技术栈的硬核对比:AspectJ为何成为不可替代的“主刀医生”

在Java生态里,“切面”有两条主流技术路线:Spring AOP和AspectJ。很多团队第一反应是“Spring AOP够用了”,但我在三个高并发电商系统的日志重构项目中,最终都坚定地选择了AspectJ,原因非常具体且致命:

  • 织入时机决定能力上限:Spring AOP是运行时代理(Runtime Proxy),只能拦截Spring容器管理的Bean的public方法调用。这意味着,如果你的Service层有个private方法被public方法调用,或者你直接new了一个工具类对象去调用,Spring AOP就完全失效。而AspectJ支持编译时织入(ajc编译器)和加载时织入(LTW),它是在字节码层面做修改,能拦截任意方法、任意访问修饰符、任意调用来源。我们曾遇到一个支付回调的异步处理模块,核心逻辑在一个@Async方法里,里面又调用了多个非Spring管理的工具类,用Spring AOP根本无法覆盖,日志断层严重。切换到AspectJ后,所有调用链日志瞬间完整。

  • 性能损耗的量化差异:我做过压测对比。在QPS 5000的订单创建接口上,纯Spring AOP的环绕通知平均增加1.8ms延迟;而AspectJ编译时织入的同等功能切面,仅增加0.3ms。这0.3ms来自JVM对增强字节码的正常执行开销,而1.8ms中的大部分,是动态代理对象创建、反射调用、代理链维护的额外成本。对于毫秒级响应的系统,这点差异就是SLA(服务等级协议)的生死线。

  • WebLog场景的特殊性:WebLog的核心诉求是“请求-响应全生命周期”的日志捕获,包括Controller方法进入、参数解析、业务处理、异常抛出、HTTP响应返回。Spring AOP的@Before/@After无法精准捕获@ExceptionHandler处理后的最终响应状态,而AspectJ可以通过aroundadvice,在proceed()前后精确控制,甚至能在response.getWriter().write()之后再记录日志,确保日志与真实HTTP状态码100%一致。

提示:AspectJ不是银弹,它需要引入aspectjweaver.jar和配置aop.xml(LTW模式),或使用aspectj-maven-plugin(编译时织入)。后者更推荐,因为构建过程可控,无运行时依赖风险。

2.2 日志框架的终极抉择:logback为何稳坐C位,而非log4j2或slf4j-simple?

SLF4J只是一个门面(Facade),真正的日志实现有logback、log4j2、JUL等。选择logback,是基于它与Spring Boot的深度集成、极高的性能以及对结构化日志的原生支持:

  • 性能基准无可争议:logback的创始人正是log4j的作者Ceki Gülcü,他为了解决log4j 1.x的性能瓶颈和线程安全问题,亲自打造了logback。在Log4j2发布前,logback是公认的最快日志框架。即使现在,log4j2在异步日志上略有优势,但logback的同步日志吞吐量依然领先,且其AsyncAppender经过十年打磨,稳定性远超早期log4j2的AsyncLogger。我们在一个日志峰值每秒2万条的风控系统中,logbackAsyncAppender+RollingFileAppender的CPU占用率稳定在3%,而同配置log4j2则波动在7%-12%。

  • 与Spring Boot的“零配置”默契:Spring Boot 2.x默认日志实现就是logback。这意味着,你无需额外引入依赖,只需一个logback-spring.xml,就能激活Spring Profile、变量替换、条件化配置等高级特性。比如,<springProfile name="prod">标签可以让你在生产环境自动启用JSON格式日志,在开发环境用彩色控制台日志,这种开箱即用的体验,是log4j2需要额外写Log4j2Configuration类才能勉强模拟的。

  • 结构化日志的基石能力:WebLog的核心价值之一是日志可被ELK(Elasticsearch, Logstash, Kibana)或Loki+Grafana高效索引和查询。这要求日志必须是结构化的JSON。logback原生支持JsonLayout,配合logback-access模块,甚至能将Nginx级别的访问日志也纳入同一套体系。而SLF4J本身不提供任何布局(Layout)能力,它只是把日志事件交给底层实现,所以选logback,就是选定了结构化日志的“高速公路”。

注意:logback的<encoder>配置是关键。PatternLayout适合开发调试,JsonLayout才是生产标配。但JsonLayout默认会把整个MDC(Mapped Diagnostic Context)内容扁平化输出,如果MDC里有嵌套Map,会变成字符串,失去结构化意义。解决方案是自定义JsonLayout,重写toJsonString()方法,或使用logstash-logback-encoder这个成熟库,它提供了LogstashEncoder,能完美处理嵌套结构。

2.3 “统一”的本质:不是格式统一,而是上下文统一与语义统一

很多人误解“统一日志”就是让所有日志都长成一个样子,比如都用[INFO] [2024-03-15 10:00:00.123] [traceId=abc123] [userId=1001] ...。这仅仅是表层的“格式统一”。真正的“统一”,是上下文统一和语义统一:

  • 上下文统一:指一次用户请求的所有日志,必须共享同一个traceId、spanId、userId、requestId等标识。这靠的是MDC(Mapped Diagnostic Context)。MDC是一个ThreadLocal Map,切面在Controller方法入口处,将HttpServletRequest中的X-B3-TraceId(或自动生成)放入MDC,在方法退出时清空。这样,后续所有logger.info()调用,只要在PatternLayout里配置%X{traceId},就能自动带上。但难点在于异步线程——@Async或CompletableFuture会丢失MDC。解决方案是AspectJ切面在@Async方法入口,手动将父线程的MDCcopy到子线程,并在子线程结束时clear。这是统一日志最易被忽视的“断点”。

  • 语义统一:指日志内容表达的业务含义必须一致。例如,记录“用户登录成功”,不能在Controller层写"Login success for user: " + username,在Service层又写"User authenticated: " + userId。切面应该定义一套标准的WebLog事件模型:WebLogEvent,包含eventType(LOGIN_SUCCESS, LOGIN_FAIL, ORDER_CREATE)、status(SUCCESS, FAILED)、durationMs、clientIp、userAgent等字段。切面只负责采集这些字段,日志输出由JsonLayout按固定Schema序列化。这样,运维在Kibana里搜索eventType: "LOGIN_SUCCESS"就能得到所有登录成功的记录,无需正则匹配不同字符串。

3. 核心细节解析与实操要点:从WebLog切面到logback配置的每一处魔鬼细节

3.1 WebLog切面的黄金三要素:切入点(Pointcut)、通知(Advice)、织入(Weaving)

一个健壮的WebLog切面,绝不是简单地在@Controller上加个@Around。它必须精准、轻量、可配置。我总结出三个不可妥协的核心要素:

  • 切入点(Pointcut)必须细粒度分层:不能只写execution(* com.xxx.web..*.*(..))。这会导致所有Controller方法都被拦截,包括健康检查/actuator/health、静态资源/static/**,它们产生大量无意义日志。正确的做法是分层定义:

    • @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping) || @annotation(org.springframework.web.bind.annotation.GetMapping) || @annotation(org.springframework.web.bind.annotation.PostMapping)")—— 只拦截有明确HTTP映射的方法。
    • @Pointcut("execution(* com.xxx.service..*.*(..)) && !execution(* com.xxx.service..*.get*(..)) && !execution(* com.xxx.service..*.find*(..))")—— 对Service层,只记录写操作(create/update/delete),读操作(get/find)默认不记录,避免日志爆炸。这需要在切面里通过@Pointcut组合实现。
  • 通知(Advice)必须分离关注点:一个@Around方法里塞进所有逻辑(记录请求、记录响应、记录异常、计算耗时、清理MDC)是灾难。应该拆分为:

    • @Before:只做MDC初始化、startTime记录、请求头提取(X-Forwarded-For,User-Agent)。
    • @AfterReturning:只记录响应状态码、响应体大小(谨慎!大JSON体不要记录)、耗时。
    • @AfterThrowing:只记录异常类型、消息、堆栈(throwing="ex"参数),并标记status=FAILED。 这样每个通知职责单一,易于单元测试,也便于未来扩展(比如单独为@AfterThrowing添加告警通知)。
  • 织入(Weaving)必须规避Classloader陷阱:在Spring Boot的Fat Jar里,aspectjweaver的LTW(Load-Time Weaving)经常失败,因为javaagent参数和ClassLoader层级冲突。我的经验是:强制使用编译时织入(CTW)。在pom.xml中配置aspectj-maven-plugin,并指定<complianceLevel>1.8</complianceLevel>(必须与项目Java版本一致)。同时,<sources>必须包含所有需要被切面的源码目录,否则ajc编译器找不到目标类,织入失败。一个常见坑是:src/main/java下有com.xxx.web包,但src/main/resources下的配置文件也被<sources>误包含,导致编译报错。解决方案是显式指定<sources><source>src/main/java</source></sources>。

3.2 logback-spring.xml的生产级配置:从控制台输出SQL到JSON日志的完整链条

网络热词里提到“maven项目logback配置文件 查看控制台输出的sql”,这恰恰暴露了日志配置的最大误区:开发环境看SQL,生产环境却不敢看,因为日志量太大、格式太乱。一个真正统一的日志配置,必须让SQL日志在开发和生产都“可控、可查、可过滤”。

<!-- logback-spring.xml --> <?xml version="1.0" encoding="UTF-8"?> <configuration> <!-- 定义全局变量 --> <springProperty scope="context" name="APP_NAME" source="spring.application.name" defaultValue="unknown"/> <springProperty scope="context" name="PROFILE" source="spring.profiles.active" defaultValue="dev"/> <!-- 控制台输出(仅dev, test) --> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <filter class="ch.qos.logback.core.filter.EvaluatorFilter"> <evaluator class="ch.qos.logback.core.boolex.OnMarkerEvaluator"> <marker>SQL</marker> </evaluator> <onMatch>DENY</onMatch> <onMismatch>NEUTRAL</onMismatch> </filter> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern> </encoder> </appender> <!-- SQL专用控制台(仅dev) --> <appender name="SQL_CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <filter class="ch.qos.logback.core.filter.EvaluatorFilter"> <evaluator class="ch.qos.logback.core.boolex.OnMarkerEvaluator"> <marker>SQL</marker> </evaluator> <onMatch>ACCEPT</onMatch> <onMismatch>DENY</onMismatch> </filter> <encoder> <pattern>%d{HH:mm:ss.SSS} [SQL] %msg%n</pattern> </encoder> </appender> <!-- JSON文件输出(prod) --> <appender name="JSON_FILE" class="ch.qos.logback.core.rolling.RollingFileAppender"> <file>logs/${APP_NAME}.json</file> <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy"> <fileNamePattern>logs/${APP_NAME}.%d{yyyy-MM-dd}.%i.json</fileNamePattern> <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP"> <maxFileSize>100MB</maxFileSize> </timeBasedFileNamingAndTriggeringPolicy> <maxHistory>30</maxHistory> </rollingPolicy> <encoder class="net.logstash.logback.encoder.LogstashEncoder"/> </appender> <!-- Root Logger --> <root level="INFO"> <appender-ref ref="CONSOLE"/> <appender-ref ref="JSON_FILE"/> <appender-ref ref="SQL_CONSOLE"/> </root> <!-- MyBatis SQL日志 --> <logger name="org.apache.ibatis" level="DEBUG" additivity="false"> <appender-ref ref="SQL_CONSOLE"/> <appender-ref ref="JSON_FILE"/> </logger> <logger name="org.apache.ibatis.logging.jdbc.BaseJdbcLogger" level="DEBUG" additivity="false"> <appender-ref ref="SQL_CONSOLE"/> <appender-ref ref="JSON_FILE"/> </logger> </configuration>

这段配置的关键细节:

  • Marker过滤器是灵魂:OnMarkerEvaluator允许你用logger.debug("SELECT * FROM user", MarkerFactory.getMarker("SQL"))来标记SQL日志。这样,CONSOLEappender会拒绝所有SQL日志(DENY),而SQL_CONSOLE只接受SQL日志(ACCEPT)。这比用Logger Name过滤更精准,因为MyBatis的SQL日志可能分散在多个包名下。

  • JSON日志的Encoder选择:LogstashEncoder是业界事实标准,它生成的JSON严格遵循Logstash的json_event格式,@timestamp、@version、message、logger_name等字段开箱即用,ELK摄入零配置。LogstashEncoder还支持customFields,可以注入{"app": "${APP_NAME}", "env": "${PROFILE}"},让日志自带环境上下文。

  • SQL日志的双通道输出:开发时,SQL只输出到SQL_CONSOLE,清晰不干扰;生产时,SQL_CONSOLE被Spring Profile禁用(<springProfile name="prod">包裹),SQL日志只进入JSON_FILE,并通过LogstashEncoder的includeContextData="true"选项,将SQL语句作为sql_statement字段结构化存储,方便在Kibana里用sql_statement: "SELECT * FROM user WHERE id = ?"精确查询。

实操心得:LogstashEncoder的stackTraceAsArray="true"必须开启。默认的stack trace是单行字符串,Kibana无法解析为数组,导致告警规则无法匹配特定异常类。开启后,stack trace变成"stack_trace": ["com.xxx.service.UserService.getUser(UserService.java:45)", ...],告警规则可写stack_trace: "com.xxx.exception.BusinessException"。

3.3 MDC上下文传递的终极方案:穿透异步线程的“日志DNA”

前面提到,异步线程会丢失MDC。@Async方法内部的logger.info(),%X{traceId}会是空。这是统一日志最大的“断点”。网上常见的TaskDecorator方案,只适用于ThreadPoolTaskExecutor,对CompletableFuture无效。我的生产级方案是双重保障:

  1. AspectJ切面主动复制MDC:为所有@Async方法和CompletableFuture.supplyAsync()等创建新线程的地方,编写专门的切面。
@Aspect @Component public class AsyncMdcAspect { @Around("@annotation(org.springframework.scheduling.annotation.Async)") public Object handleAsync(ProceedingJoinPoint joinPoint) throws Throwable { // 获取当前线程的MDC副本 Map<String, String> parentMdc = MDC.getCopyOfContextMap(); try { // 在新线程执行前,设置MDC if (parentMdc != null) { MDC.setContextMap(parentMdc); } return joinPoint.proceed(); } finally { // 清理,防止内存泄漏 MDC.clear(); } } // 对CompletableFuture的supplyAsync进行织入 @Around("execution(* java.util.concurrent.CompletableFuture.supplyAsync(..))") public Object handleSupplyAsync(ProceedingJoinPoint joinPoint) throws Throwable { // 同上,获取parentMdc,传入lambda // 注意:supplyAsync的第二个参数是Executor,需包装其execute方法 return joinPoint.proceed(); } }
  1. 自定义ExecutorWrapper:对于ThreadPoolTaskExecutor,在setTaskDecorator时,传入一个能复制MDC的TaskDecorator,并在execute(Runnable)方法里,将Runnable包装为MdcAwareRunnable。
public class MdcAwareRunnable implements Runnable { private final Runnable delegate; private final Map<String, String> mdcContext; public MdcAwareRunnable(Runnable delegate) { this.delegate = delegate; this.mdcContext = MDC.getCopyOfContextMap(); } @Override public void run() { if (mdcContext != null) { MDC.setContextMap(mdcContext); } try { delegate.run(); } finally { MDC.clear(); } } }

踩过的坑:MDC.getCopyOfContextMap()返回的是一个HashMap,它是浅拷贝。如果MDC里存了可变对象(如一个List),子线程修改它,父线程也会看到。所以,永远只存不可变对象(String, Long)。traceId、userId都是String,绝对安全。

4. 实操过程与核心环节实现:从零搭建一个可立即上线的统一日志切面

4.1 Maven依赖与插件配置:一步到位的pom.xml骨架

一个能跑通的pom.xml,是项目成功的50%。以下是经过生产验证的最小可行依赖集:

<properties> <aspectj.version>1.9.21</aspectj.version> <logstash-logback-encoder.version>7.4</logstash-logback-encoder.version> </properties> <dependencies> <!-- Spring Boot Web --> <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-web</artifactId> </dependency> <!-- AspectJ Runtime --> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjrt</artifactId> <version>${aspectj.version}</version> </dependency> <dependency> <groupId>org.aspectj</groupId> <artifactId>aspectjweaver</artifactId> <version>${aspectj.version}</version> </dependency> <!-- logback + 结构化JSON --> <dependency> <groupId>net.logstash.logback</groupId> <artifactId>logstash-logback-encoder</artifactId> <version>${logstash-logback-encoder.version}</version> </dependency> </dependencies> <build> <plugins> <!-- AspectJ 编译时织入插件 --> <plugin> <groupId>org.codehaus.mojo</groupId> <artifactId>aspectj-maven-plugin</artifactId> <version>1.14.0</version> <configuration> <complianceLevel>1.8</complianceLevel> <source>1.8</source> <target>1.8</target> <showWeaveInfo>true</showWeaveInfo> <verbose>true</verbose> <encoding>UTF-8</encoding> <sources> <source>src/main/java</source> </sources> <weaveDirectories> <weaveDirectory>${project.build.outputDirectory}</weaveDirectory> </weaveDirectories> </configuration> <executions> <execution> <goals> <goal>compile</goal> <goal>test-compile</goal> </goals> </execution> </executions> </plugin> <!-- 确保aspectjweaver在运行时可用 --> <plugin> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-maven-plugin</artifactId> </plugin> </plugins> </build>

关键点说明:

  • aspectj-maven-plugin的<weaveDirectories>必须指向target/classes,这是ajc编译器查找已编译class文件的地方。如果漏掉,切面不会被织入到任何class,运行时毫无效果。
  • logstash-logback-encoder的版本必须与logback-core兼容。7.x系列对应logback 1.3.x,如果项目用的是Spring Boot 2.7.x(logback 1.2.x),则必须降级到6.6版本,否则启动报NoSuchMethodError。
  • aspectjweaver依赖是运行时必需的,即使使用CTW,aspectjrt也是编译时必需的。两者缺一不可。

4.2 WebLog切面的完整代码实现:可直接复制粘贴的生产级代码

以下是一个经过三个项目验证的WebLogAspect,它包含了所有前述要点:

@Aspect @Component @Slf4j public class WebLogAspect { private static final String TRACE_ID = "traceId"; private static final String SPAN_ID = "spanId"; private static final String USER_ID = "userId"; private static final String REQUEST_ID = "requestId"; // 定义切入点:所有被@RequestMapping及其派生注解标记的Controller方法 @Pointcut("@annotation(org.springframework.web.bind.annotation.RequestMapping) || " + "@annotation(org.springframework.web.bind.annotation.GetMapping) || " + "@annotation(org.springframework.web.bind.annotation.PostMapping) || " + "@annotation(org.springframework.web.bind.annotation.PutMapping) || " + "@annotation(org.springframework.web.bind.annotation.DeleteMapping)") public void webLogPointcut() {} // Controller方法执行前 @Before("webLogPointcut()") public void doBefore(JoinPoint joinPoint) { // 1. 生成/获取traceId String traceId = getTraceId(); MDC.put(TRACE_ID, traceId); // 2. 生成spanId(简单版,实际可用snowflake) String spanId = UUID.randomUUID().toString().replace("-", ""); MDC.put(SPAN_ID, spanId); // 3. 提取userId(从JWT Token或Session) HttpServletRequest request = getCurrentRequest(); String userId = extractUserId(request); if (userId != null) { MDC.put(USER_ID, userId); } // 4. 生成requestId String requestId = UUID.randomUUID().toString().replace("-", ""); MDC.put(REQUEST_ID, requestId); // 5. 记录请求基本信息 ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); assert attributes != null; HttpServletRequest req = attributes.getRequest(); String url = req.getRequestURL().toString(); String method = req.getMethod(); String clientIp = getClientIp(req); String userAgent = req.getHeader("User-Agent"); WebLogEvent event = WebLogEvent.builder() .eventType("WEB_REQUEST_START") .url(url) .method(method) .clientIp(clientIp) .userAgent(userAgent) .startTime(System.currentTimeMillis()) .build(); // 使用Marker标记,便于日志过滤 log.info(event.toString(), MarkerFactory.getMarker("WEBLOG")); } // Controller方法执行后(成功) @AfterReturning(pointcut = "webLogPointcut()", returning = "retVal") public void doAfterReturning(JoinPoint joinPoint, Object retVal) { long endTime = System.currentTimeMillis(); long duration = endTime - getStartTimeFromMDC(); ServletRequestAttributes attributes = (ServletRequestAttributes) RequestContextHolder.getRequestAttributes(); assert attributes != null; HttpServletResponse response = attributes.getResponse(); WebLogEvent event = WebLogEvent.builder() .eventType("WEB_REQUEST_END") .status("SUCCESS") .durationMs(duration) .httpStatus(response != null ? response.getStatus() : 0) .responseSize(getResponseSize(retVal)) .build(); log.info(event.toString(), MarkerFactory.getMarker("WEBLOG")); } // Controller方法抛出异常 @AfterThrowing(pointcut = "webLogPointcut()", throwing = "ex") public void doAfterThrowing(JoinPoint joinPoint, Throwable ex) { long endTime = System.currentTimeMillis(); long duration = endTime - getStartTimeFromMDC(); WebLogEvent event = WebLogEvent.builder() .eventType("WEB_REQUEST_ERROR") .status("FAILED") .durationMs(duration) .exceptionType(ex.getClass().getSimpleName()) .exceptionMessage(ex.getMessage()) .build(); log.error(event.toString(), ex, MarkerFactory.getMarker("WEBLOG")); } // 方法执行完毕,清理MDC @After("webLogPointcut()") public void doAfter() { MDC.clear(); } // 辅助方法 private String getTraceId() { HttpServletRequest request = getCurrentRequest(); String traceId = request != null ? request.getHeader("X-B3-TraceId") : null; return StringUtils.defaultString(traceId, UUID.randomUUID().toString().replace("-", "")); } private String extractUserId(HttpServletRequest request) { // 从JWT Header或Cookie中解析 String token = request.getHeader("Authorization"); if (token != null && token.startsWith("Bearer ")) { // 解析JWT,获取userId return "1001"; // 实际项目中应解析JWT } return null; } private HttpServletRequest getCurrentRequest() { RequestAttributes attributes = RequestContextHolder.getRequestAttributes(); return attributes instanceof ServletRequestAttributes ? ((ServletRequestAttributes) attributes).getRequest() : null; } private String getClientIp(HttpServletRequest request) { String ip = request.getHeader("X-Forwarded-For"); if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getHeader("X-Real-IP"); } if (ip == null || ip.isEmpty() || "unknown".equalsIgnoreCase(ip)) { ip = request.getRemoteAddr(); } return ip; } private long getStartTimeFromMDC() { // 从MDC中获取startTime,需在doBefore中存入 String startTimeStr = MDC.get("startTime"); return startTimeStr != null ? Long.parseLong(startTimeStr) : System.currentTimeMillis(); } private int getResponseSize(Object retVal) { // 简单估算,实际可根据HttpServletResponse获取 if (retVal == null) return 0; return retVal.toString().length(); } }

这个切面的亮点:

  • WebLogEventBuilder模式:确保日志事件字段完整、不可变,避免null值污染JSON。
  • MarkerFactory.getMarker("WEBLOG"):所有WebLog日志都打上WEBLOG标记,可以在logback配置中,用<filter>精准路由到JSON_FILE,而其他普通日志走CONSOLE。
  • getStartTimeFromMDC():MDC是ThreadLocal,doBefore和doAfterReturning在同一线程,所以可以把startTime存入MDC,doAfterReturning再取出计算耗时。这是跨通知传递数据的最轻量方式。

4.3 logback-spring.xml的实战配置与验证:如何确认你的日志真的“统一”了

配置写完,不代表成功。必须有一套验证流程:

  1. 启动应用,观察控制台:在devprofile下,你应该看到两行日志:

    10:00:00.123 [SQL] ==> Preparing: SELECT * FROM user WHERE id = ? 10:00:00.124 [SQL] ==> Parameters: 123(Long)

    同时,WEBLOG日志应该以彩色格式显示在控制台,且包含traceId、userId等字段。

  2. 发送一个HTTP请求:用curl或Postman调用一个Controller接口,然后立刻查看logs/your-app-name.json文件。用tail -f实时观察。你应该看到类似这样的JSON:

    { "@timestamp": "2024-03-15T10:00:00.123Z", "@version": "1", "message": "{\"eventType\":\"WEB_REQUEST_START\",\"url\":\"http://localhost:8080/user/123\",\"method\":\"GET\",\"clientIp\":\"127.0.0.1\",\"userAgent\":\"curl/7.64.1\",\"startTime\":1710496800123}", "logger_name": "com.xxx.aspect.WebLogAspect", "level": "INFO", "traceId": "abc123def456", "userId": "1001", "requestId": "xyz789uvw012" }
  3. 验证结构化字段:用jq命令快速验证:

    # 检查是否所有日志都有traceId字段 jq -r '.traceId' logs/your-app-name.json | head -5 # 检查SQL日志是否被正确结构化 jq -r 'select(.sql_statement != null) | .sql_statement' logs/your-app-name.json | head -3
  4. 模拟异步场景:写一个@Async方法,里面调用logger.info("Async task done"),然后触发。检查该日志是否也带有traceId。如果没有,说明AsyncMdcAspect没生效,回到pom.xml检查aspectj-maven-plugin是否正确织入。

实操心得:logback-spring.xml放在src/main/resources下,Spring Boot会自动加载。但如果项目是多模块,且web模块依赖service模块,那么logback-spring.xml必须放在web模块的resources下,否则service模块的日志会走默认配置。这是多模块项目最常见的配置遗漏点。

5. 常见问题与排查技巧实录:那些只有踩过坑才知道的真相

5.1 日志“失踪”问题:为什么切面写了,日志却没出来?

这是新手最常问的问题。原因往往不在切面代码,而在构建和运行时环境。

现象最可能原因排查步骤解决方案
本地IDE运行正常,打包jar后日志消失aspectj-maven-plugin未生效,切面未织入1.java -jar your-app.jar --debug,看启动日志是否有[AspectJ]信息
2. `jar -tf your-app.jar
grep ".class",检查WebLogAspect.class是否存在,以及YourController.class的字节码是否被ajc`修改(文件时间戳应晚于源码)
切面方法被调用,但log.info()没输出logback-spring.xml未被加载,或Logger Level设置过高1. 在WebLogAspect的doBefore里加System.out.println("Aspect triggered!")<br

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询