springboot设置traceID

本文介绍如何基于 spring boot 获取、传递、使用一次会话的全局参数,并在所有链路日志中打印。

支持场景

  • 微服务中使用 traceId 实现跨应用日志追踪 。
  • 用户认证信息传递。
  • 和全局参数有关的日志追踪。

具体实现

TraceId 获取

在决定在什么位置获取 traceId 之前先看一下 Tomcat 容器中 Servlet、Filter 、 Listener 和 Interceptor 的关系:

在选择获取 traceId 的时候我希望他是一个全局的,对所有请求生效的配置,因此放在 Filter 层,下面尝试在 Filter 中打印出 traceId,例如ContextFilter.java

@Component
@Order(Ordered.HIGHEST_PRECEDENCE)
@Slf4j
public class ContextFilter implements Filter {
    @Override
    public void doFilter(ServletRequest servletRequest, ServletResponse servletResponse, FilterChain filterChain)
        throws IOException, ServletException {
        HttpServletRequest request = (HttpServletRequest) servletRequest;

        Enumeration<String> headerNames = request.getHeaderNames();
        String traceId = request.getHeader("Trace-Id");

        if (StringUtils.isNotBlank(traceId)) {
            log.info(traceId);
        }

        filterChain.doFilter(servletRequest, servletResponse);
    }
    @Override
    public void init(FilterConfig filterConfig) throws ServletException {

    }
    @Override
    public void destroy() {

    }
}

全局参数传递

验证完获取 traceId 之后,我们需要把 traceId 在一个次会话中传递,先使用 Thread local 实现(AppContext.java):

public class AppContext implements Serializable {
    public static final String TRACE_ID_HEADER = "Trace-Id";
    private static final long serialVersionUID = -979220111440953115L;

    private String traceId;

    private static final ThreadLocal<AppContext> LOCAL = ThreadLocal.withInitial(AppContext::new);

    public static AppContext getContext() {
        return LOCAL.get();
    }

    public static void setContext(AppContext context) {
        LOCAL.set(context);
    }

    public static void removeContext() {
        LOCAL.remove();
    }

    public String getTraceId() {
        return traceId;
    }

    public void setTraceId(String traceId) {
        this.traceId = traceId;
    }
}

有了 Thread local 对象,就可以实现随用随取了,类似的,用户信息也可以用这种方式隐式传递。

AppContext.getContext().setTraceId(traceId); # 在 Filter 中将 traceId 存入 thread local
AppContext.getContext().getTraceId();        # 在 controller 中取出 traceId

多线程全局参数传递

Thread local 可以优雅地实现 Session 管理,但仅仅局限在一个封闭线程内,在使用线程池等会池化复用线程的执行组件情况下 Thread local 的信息就会丢失,例如我们在获取用户信息的地方加入一个线程池:(UserService.java)

private static final ExecutorService SAVE_USER_THREAD_POOL = Executors.newFixedThreadPool(5);

public User getUser(Long userId) throws InterruptedException, ExecutionException {
        log.info("[{}]Get user by id={}", AppContext.getContext().getTraceId(), userId);
        try {
            return GET_USER_THREAD_POOL.submit(() -> userRepository.getUserById(userId)).get();
        } catch (ExecutionException e) {
            Throwable t = e.getCause();
            if (t instanceof CommonException) {
                throw (CommonException)t;
            }
            throw e;
        }
    }

接下来做一个测试:

curl -H 'Trace-Id:123' localhost:8080/user?id=3

查看日志:

可以看到,进入线程池之前的 UserService 中是可以打印出 traceId 的,但是在线程池中的 UserRepository 渠道的 traceId 则为 null,正是由于 Thread local 无法实现跨线程传递阿里的TTL 帮我们解决了这个问题,可以把把任务提交给线程池时的 Thread local 值传递到 任务执行时: 

private static final TransmittableThreadLocal<AppContext> LOCAL = new TransmittableThreadLocal<AppContext>() {
        @Override
        protected AppContext initialValue() {
            return new AppContext();
        }
    };

再次测试:

单元测试

TTL 好用,可是当隐式传递的 Session 信息无孔不入地被应用到业务逻辑的时候,如果对 Thread local 对象添加单元测试成了不得不面对的问题,下面给出一种方式:Mock Context 对象的静态方法:

pom.xml

<dependency>
            <groupId>junit</groupId>
            <artifactId>junit</artifactId>
            <version>4.12</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.powermock</groupId>
            <artifactId>powermock-module-junit4</artifactId>
            <version>2.0.4</version>
            <scope>test</scope>
        </dependency>

        <dependency>
            <groupId>org.powermock</groupId>
            <artifactId>powermock-api-mockito2</artifactId>
            <version>2.0.4</version>
            <scope>test</scope>
        </dependency>

UserServiceTest.java

@RunWith(PowerMockRunner.class)
@PrepareForTest(AppContext.class)
public class UserServiceTest {

    private static final long CONTEXT_USER_ID = 123L;
    private static final String CONTEXT_TRACE_ID = "trace-id";
    @InjectMocks
    private UserService userService;
    @Mock
    private UserRepository userRepository;

    @Before
    public void setUp() throws Exception {
        AppContext context = new AppContext();
        context.setTraceId(CONTEXT_TRACE_ID);
        context.setUserId(CONTEXT_USER_ID);
        PowerMockito.mockStatic(AppContext.class);
        BDDMockito.given(AppContext.getContext()).willReturn(context);
    }

    @Test
    public void shouldReturnUserWhenUserIdInContext() throws ExecutionException, InterruptedException {
        // given
        when(userRepository.getUserById(eq(CONTEXT_USER_ID))).thenReturn(new User(CONTEXT_USER_ID, "userName"));

        // when
        User user = userService.getCurrentUser();

        // then
        Assert.assertEquals(Long.valueOf(123), user.getId());
        Assert.assertEquals("userName", user.getName());
    }
}

日志中打印全局参数

在上文的日志中我使用 log.("[{}]", traceId)的方式在日志中加入全局参数,事实上很多情况下我们是想随时知道全局参数的变量值的,怎么避免每次重复地写打印全局变量日志的方式呢?log4j 和 logback 提供的一种方便在多线程条件下记录日志的功能:MDC(Mapped Diagnostic Context,映射调试上下文) 。

只需要在日志模板中加入 %X{traceId},再在适当的位置把 traceId 放入 org.slf4j.MDC 中即可:(logback-spring.xml)

<?xml version="1.0" encoding="UTF-8"?>
<configuration>
    <!-- https://github.com/spring-projects/spring-boot/blob/v1.5.13.RELEASE/spring-boot/src/main/resources/org/springframework/boot/logging/logback/defaults.xml -->
    <include resource="org/springframework/boot/logging/logback/defaults.xml" />

    <property name="FILE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:-%5p} [%t] [traceId:%X{traceId}] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>
    <property name="CONSOLE_LOG_PATTERN" value="%d{yyyy-MM-dd HH:mm:ss.SSS} ${LOG_LEVEL_PATTERN:-%5p} [%t] [traceId:%X{traceId}] %-40.40logger{39} : %m%n${LOG_EXCEPTION_CONVERSION_WORD:-%wEx}"/>

    <property name="APP_NAME" value="global-param-trace" />
    <property name="LOG_PATH" value="${user.home}/${APP_NAME}/logs" />
    <property name="LOG_FILE" value="${LOG_PATH}/application.log" />

    <appender name="APPLICATION"
              class="ch.qos.logback.core.rolling.RollingFileAppender">
        <file>${LOG_FILE}</file>
        <encoder>
            <pattern>${FILE_LOG_PATTERN}</pattern>
        </encoder>
        <rollingPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy">
            <fileNamePattern>${LOG_FILE}.%d{yyyy-MM-dd}.%i.log</fileNamePattern>
            <maxHistory>7</maxHistory>
            <maxFileSize>50MB</maxFileSize>
            <totalSizeCap>20GB</totalSizeCap>
        </rollingPolicy>
    </appender>

    <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>${CONSOLE_LOG_PATTERN}</pattern>
            <charset>utf8</charset>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="CONSOLE" />
        <appender-ref ref="APPLICATION" />
    </root>
</configuration>

这里我选择使用 Interceptor 来放入MDC,给 Session 信息补偿和权限判定预留空间: (MdcInterceptor.java)

@Component
public class MdcInterceptor implements HandlerInterceptor {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
        throws Exception {
        MDC.put(AppContext.KEY_TRACE_ID, AppContext.getContext().getTraceId());
        return true;
    }

    @Override
    public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler,
        ModelAndView modelAndView) throws Exception {
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex)
        throws Exception {
    }
}

在Configuration中配置interceptor:

@Configuration
public class AppConfiguration extends WebMvcConfigurerAdapter {

    @Autowired
    private MdcInterceptor mdcInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(mdcInterceptor);
    }
}

把日志中的 [{}] 移除,尝试请求 API,可以看到日志中已经可以出现 traceId了:

使用 AOP 在多线程日志中打印全局参数

上一步中打印出来的 traceId 在线程池中再次丢失了,这是因为与第一个实现 Context 的方式类似,MDC 也是使用 thread local,无法跨线程传递,要解决这个问题可以使用一个切面,讲 TTL 中的全局参数补偿到 DMC 中:

定义一个注解:

@Documented
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface MdcCompensation {
}

将注解和包的位置添加到切面的切点:(MdcAspect)

@Aspect
@Component
public class MdcAspect {

    @Pointcut("execution(* com.adu21.spring.boot.global.param.trace.repository..*.*(..))")
    public void service() {}

    @Before(value = "service() && @annotation(mdcCompensation)")
    public void doBefore(MdcCompensation mdcCompensation) {
        MDC.put(AppContext.KEY_TRACE_ID, AppContext.getContext().getTraceId());
    }
}

使用切面,注意这个地方由于 Spring 基于动态代理生成代理类的方式让客户端调用代理类而达到织入的效果,因此使用注解的方法只有在被外部类调用的时候才会生效: 

@MdcCompensation
public User getUserById(Long userId) {
    ...
}

 再次运行:

代码示例

文中使用的代码,完整版在这里:GitHub - ADU-21/global-param-trace: Demo for enable global param via spring boot and trace via log

Reference

 

### 如何在Spring Boot中配置日志以包含Trace ID 要在Spring Boot应用程序的日志中添加`traceId`,可以通过多种方式实现。以下是具体的方法: #### 方法一:通过自定义过滤器设置`MDC`中的`traceId` 可以创建一个过滤器,在每次HTTP请求到达时生成唯一的`traceId`并将其存储到SLF4J的`MDC`(Mapped Diagnostic Context)中[^3]。 ```java import javax.servlet.Filter; import javax.servlet.FilterChain; import javax.servlet.ServletException; import javax.servlet.ServletRequest; import javax.servlet.ServletResponse; import java.io.IOException; import java.util.UUID; public class TraceIdFilter implements Filter { @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { try { String traceId = UUID.randomUUID().toString(); MDC.put("traceId", traceId); // 将traceId存入MDC chain.doFilter(request, response); } finally { MDC.clear(); // 清理MDC以防内存泄漏 } } } ``` 接着,将此过滤器注册到Spring容器中: ```java @Configuration public class FilterConfig { @Bean public FilterRegistrationBean<Filter> filterRegistrationBean() { FilterRegistrationBean<Filter> bean = new FilterRegistrationBean<>(); bean.setFilter(new TraceIdFilter()); bean.addUrlPatterns("/*"); return bean; } } ``` #### 方法二:修改日志格式以显示`traceId` 为了使日志输出包含`traceId`,需要调整日志框架的配置文件。如果使用的是Logback,则可以在`logback-spring.xml`中更新日志模式如下[^2]: ```xml <configuration> <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg [traceId=%X{traceId}]%n</pattern> </encoder> </appender> <root level="info"> <appender-ref ref="STDOUT"/> </root> </configuration> ``` 上述配置会在每条日志消息后面附加形如 `[traceId=...]` 的字段,其中 `...` 是实际的`traceId` 值。 #### 方法三:集成分布式追踪工具(可选) 对于更复杂的微服务架构场景,推荐采用像Zipkin或Sleuth这样的分布式追踪解决方案。这些工具有助于自动管理跨多个服务边界的`traceId` 和 `spanId`,从而简化全链路跟踪过程[^1]。 例如启用Spring Cloud Sleuth只需引入依赖项即可: ```xml <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-sleuth</artifactId> </dependency> ``` 这样无需手动操作就可以让应用支持全局唯一标识符作为其一部分记录下来。 --- ### 总结 以上介绍了三种不同的技术手段来实现在Spring Boot项目里向日志加入`traceId`的功能。无论是简单的单体程序还是复杂的服务集群环境都有对应的解决办法可供选择。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值