有偿问答
面经分享
技术探讨
资料领取
登录
基于SpringBoot实现让日志像诗一样有韵律(日志追踪)
社长
1年前
⋅ 423 阅读
### 前言 在传统系统中,如果能够提供日志输出,基本上已经能够满足需求的。但一旦将系统拆分成两套及以上的系统,再加上负载均衡等,调用链路就变得复杂起来。 特别是进一步向微服务方向演化,如果没有日志的合理规划、链路追踪,那么排查日志将变得异常困难。 比如系统 A、B、C,调用链路为 A -> B -> C,如果每套服务都是双活,则调用路径有 2 的三次方种可能性。如果系统更多,服务更多,调用链路则会成指数增长。 因此,无论是几个简单的内部服务调用,还是复杂的微服务系统,都需要通过一个机制来实现日志的链路追踪。让你系统的日志输出,像诗一样有形式美,又有和谐的韵律。 日志追踪其实已经有很多现成的框架了,比如 Sleuth、Zipkin 等组件。但这不是我们要讲的重点,本文重点基于 Spring Boot、LogBack 来手写实现一个简单的日志调用链路追踪功能。基于此实现模式,大家可以更细粒度的去实现。 ### Spring Boot 中集成 Logback Spring Boot 本身就内置了日志功能,这里使用 logback 日志框架,并对输出结果进行格式化。先来看一下 SpringBoot 对 Logback 的内置集成,依赖关系如下。当项目中引入了: ```
org.springframework.boot
spring-boot-starter-web
``` spring-boot-starter-web中间接引入了: ```
org.springframework.boot
spring-boot-starter
``` spring-boot-starter 又引入了 logging 的 starter: ```
org.springframework.boot
spring-boot-starter-logging
``` 在 logging 中真正引入了所需的 logback 包: ```
ch.qos.logback
logback-classic
org.apache.logging.log4j
log4j-to-slf4j
org.slf4j
jul-to-slf4j
``` 因此,我们使用时,只需将 logback-spring.xml 配置文件放在 resources 目录下即可。理论上配置文件命名为 logback.xml 也是支持的,但 Spring Boot 官网推荐使用的名称为:logback-spring.xml。 然后,在 logback-spring.xml 中进行日志输出的配置即可。这里不贴全部代码了,只贴出来相关日志输出格式部分,以控制台输出为例: ```
``` 在 value 属性的表达式中,我们新增了自定义的变量值 requestId,通过 “[%X{requestId}]” 的形式来展示。 这个 requestId 便是我们用来追踪日志的唯一标识。如果一个请求,从头到尾都使用了同一个 requestId 便可以把整个请求链路串联起来。如果系统还基于 EKL 等日志搜集工具进行统一收集,就可以更方便的查看整个日志的调用链路了。 那么,这个 requestId 变量是如何来的,又存储在何处呢?要了解这个,我们要先来了解一下日志框架提供的 MDC 功能。 ### 什么是 MDC? MDC(Mapped Diagnostic Contexts) 是一个线程安全的存放诊断日志的容器。MDC 是 slf4j 提供的适配其他具体日志实现包的工具类,目前只有 logback 和 log4j 支持此功能。 MDC 是线程独立、线程安全的,通常无论是 HTTP 还是 RPC 请求,都是在各自独立的线程中完成的,这与 MDC 的机制可以很好地契合。 在使用 MDC 功能时,我们主要使用是 put 方法,该方法间接的调用了 MDCAdapter 接口的 put 方法。 看一下接口 MDCAdapter 其中一个实现类 BasicMDCAdapter 中的代码来: ``` public class BasicMDCAdapter implements MDCAdapter { private InheritableThreadLocal
> inheritableThreadLocal = new InheritableThreadLocal
>() { @Override protected Map
childValue(Map
parentValue) { if (parentValue == null) { return null; } return new HashMap
(parentValue); } }; public void put(String key, String val) { if (key == null) { throw new IllegalArgumentException("key cannot be null"); } Map
map = inheritableThreadLocal.get(); if (map == null) { map = new HashMap
(); inheritableThreadLocal.set(map); } map.put(key, val); } // ... } ``` 通过源码可以看出内部持有一个 InheritableThreadLocal 的实例,该实例中通过 HashMap 来保存 context 数据。 此外,MDC 提供了 put/get/clear 等几个核心接口,用于操作 ThreadLocal 中存储的数据。而在 logback.xml 中,可在 layout 中通过声明 “%X{requestId}” 这种形式来获得 MDC 中存储的数据,并进行打印此信息。 基于 MDC 的这些特性,因此它经常被用来做日志链路跟踪、动态配置用户自定义信息(比如 requestId、sessionId 等)等场景。 ### 实战使用 上面了解了一些基础的原理知识,下面我们就来看看如何基于日志框架的 MDC 功能实现日志的追踪。 #### 工具类准备 首先定义一些工具类,这个强烈建议大家将一些操作通过工具类的形式进行实现,这是写出优雅代码的一部分,也避免后期修改时每个地方都需要改。 TraceID(我们定义参数名为 requestId)的生成类,这里采用 UUID 进行生成,当然可根据你的场景和需要,通过其他方式进行生成。 ``` public class TraceIdUtils { /** * 生成traceId * * @return TraceId 基于UUID */ public static String getTraceId() { return UUID.randomUUID().toString().replace("-", ""); } } ``` 对 Context 内容的操作工具类 TraceIdContext: ``` public class TraceIdContext { public static final String TRACE_ID_KEY = "requestId"; public static void setTraceId(String traceId) { if (StringLocalUtil.isNotEmpty(traceId)) { MDC.put(TRACE_ID_KEY, traceId); } } public static String getTraceId() { String traceId = MDC.get(TRACE_ID_KEY); return traceId == null ? "" : traceId; } public static void removeTraceId() { MDC.remove(TRACE_ID_KEY); } public static void clearTraceId() { MDC.clear(); } } ``` 通过工具类,方便所有服务统一使用,比如 requestId 可以统一定义,避免每处都不一样。这里不仅提供了 set 方法,还提供了移除和清理的方法。 需要注意的是,MDC.clear() 方法的使用。如果所有的线程都是通过 new Thread 方法建立的,线程消亡之后,存储的数据也随之消亡,这倒没什么。但如果采用的是线程池的情况时,线程是可以被重复利用的,如果之前线程的 MDC 内容没有清除掉,再次从线程池中获取这个线程,会取出之前的数据 (脏数据),会导致一些不可预期的错误,所以当前线程结束后一定要清掉。 #### Filter 拦截 既然我们要实现日志链路的追踪,最直观的思路就是在访问的源头生成一个请求 ID,然后一路传下去,直到这个请求完成。这里以 Http 为例,通过 Filter 来拦截请求,并将数据通过 Http 的 Header 来存储和传递数据。涉及到系统之间调用时,调用方设置 requestId 到 Header 中,被调用方从 Header 中取即可。 Filter 的定义: ``` public class TraceIdRequestLoggingFilter extends AbstractRequestLoggingFilter { @Override protected void beforeRequest(HttpServletRequest request, String message) { String requestId = request.getHeader(TraceIdContext.TRACE_ID_KEY); if (StringLocalUtil.isNotEmpty(requestId)) { TraceIdContext.setTraceId(requestId); } else { TraceIdContext.setTraceId(TraceIdUtils.getTraceId()); } } @Override protected void afterRequest(HttpServletRequest request, String message) { TraceIdContext.removeTraceId(); } } ``` 在 beforeRequest 方法中,从 Header 中获取 requestId,如果获取不到则视为 “源头”,生成一个 requestId,设置到 MDC 当中。当这个请求完成时,将设置的 requestId 移除,防止上面说到的线程池问题。系统中每个服务都可以通过上述方式实现,整个请求链路就串起来了。 当然,上面定义的 Filter 是需要进行初始化的,在 Spring Boot 中实例化方法如下: ``` @Configuration public class TraceIdConfig { @Bean public TraceIdRequestLoggingFilter traceIdRequestLoggingFilter() { return new TraceIdRequestLoggingFilter(); } } ``` 针对普通的系统调用,上述方式基本上已经能满足了,实践中可根据自己的需要在此基础上进行扩展。这里使用的是 Filter,也可以通过拦截器、Spring 的 AOP 等方式进行实现。 #### 微服务中的 Feign 如果你的系统是基于 Spring Cloud 中的 Feign 组件进行调用,则可通过实现 RequestInterceptor 拦截器来达到添加 requestId 效果。具体实现如下: ``` @Configuration public class FeignConfig implements RequestInterceptor { @Override public void apply(RequestTemplate requestTemplate) { requestTemplate.header(TraceIdContext.TRACE_ID_KEY, TraceIdContext.getTraceId()); } } ``` #### 结果验证 当完成上述操作之后,对一个 Controller 进行请求,会打印如下的日志: ``` 2021-04-13 10:58:31.092 cloud-sevice-consumer-demo [http-nio-7199-exec-1] INFO [ef76526ca96242bc8e646cdef3ab31e6] c.b.demo.controller.CityController - getCity 2021-04-13 10:58:31.185 cloud-sevice-consumer-demo [http-nio-7199-exec-1] WARN [ef76526ca96242bc8e646cdef3ab31e6] o.s.c.o.l.FeignBlockingLoadBalancerClient - ``` 可以看到 requestID 已经被成功添加。当我们排查日志时,只需找到请求的关键信息,然后根据关键信息日志中的 requestId 值就可以把整个日志串联起来。 ### 小结 最后,我们来回顾一下日志追踪的整个过程:当请求到达第一个服务器,服务检查 requestId 是否存在,如果不存在,则创建一个,放入 MDC 当中;服务调用其他服务时,再通过 Header 将 requestId 进行传递;而每个服务的 logback 配置 requestId 的输出。从而达到从头到尾将日志串联的效果。 在学习本文,如果你只学到了日志追踪,那是一种损失,因为文中还涉及到了 SpringBoot 对 logback 的集成、MDC 的底层实现及坑、过滤器的使用、Feign 的请求拦截器等。如果感兴趣,每个都可以发散一下,学习到更多的知识点。 >https://juejin.cn/post/6950772721139580936
阅读全部
全部评论:
0
条
我有话说:
@
发送
-- 目录 --
关注官方公众号:
Java问答社
接收最新有赏问答推送!
最新发布
1.
SpringBoot 接口数据加解密技巧,so easy!
2.
一个依赖搞定 Spring Boot 反爬虫,防止接口盗刷!
3.
Java8 Stream 极大简化了代码,它是如何实现的?
4.
马上大四了,秋招还是春招好?先找工作还是找实习?
5.
万字详解 Linux 常用指令(值得收藏)
6.
4年工作经验,多线程间的5种通信方式都说不出来,你敢信?
最新评论
部署文档没有了,您能提供下吗
部署文档没有了,能提供下吗
我测你的🐎
源码从哪里获取请问
想学
那篇石墨文档 没有权限查看哇