SpringOne2023峰会总结-02-SpringBoot与Micrometer如何在WebFlux环境下实现的链路日志

2024-05-25 09:04:34 浏览数 (2)

02-SpringBoot与Micrometer如何在WebFlux环境下实现的链路日志

  • 视频原始地址:https://www.youtube.com/watch?v=xF7aZJlfTSw&list=PLgGXSWYM2FpPrAdQor9pi__EV1O69Qbom&index=25
  • 个人翻译地址:https://www.bilibili.com/video/BV12K421y7kF/
  • 个人总结代码与介绍地址:https://github.com/HashZhang/SpringOne-2023/blob/main/02-SpringBoot与Micrometer如何在WebFlux环境下实现的链路日志

我们可以在日志中加入链路信息,这样我们可以找到某个请求,某个事务所有的日志,这样就可以方便的进行问题排查。并且,我们还可以通过 traceId 找到不同微服务调用链路相关的日志。 在 Spring Boot 3.x 之前,我们一般用 spring-cloud-sleuth 去实现,但是在 Spring Boot 3.x 之后,已经去掉了对于 sleuth 的原生支持,全面改用了 micrometer。

首先,我们先思考下,这些链路日志是怎么实现的?我们知道,所有的日志框架,都带有 %X 这个日志格式占位符。这个占位符,就是从 MDC(Mapped Diagnostic Context)中取出对应 key 来实现的。MDC 是一个 ThreadLocal 的变量,它是一个 Map,我们可以在任何地方往里面放值,然后在任何地方取出来。这样,我们就可以在任何地方,把 traceId 放到 MDC 中,然后通过类似于下面的日志格式,就可以在日志中打印出来。

代码语言:javascript复制
logging:
  pattern:
    console: "%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} %X{traceId} - %msg%n"

1. 不依赖任何框架,如何实现链路日志

如果我们不依赖任何框架,看看我们如何实现这个功能。首先在 web-mvc 环境下,我们可以通过拦截器,在请求进来的时候,把 traceId 放到 MDC 中,然后在请求结束的时候,把 traceId 从 MDC 中移除。

然后,我们添加一个普通的接口,这个接口里面,我们打印一下日志,看看 traceId 是否能够打印出来。这些都是在 web-mvc 环境下,我们可以很方便的实现。这里为了方便,我们把所有代码放在一个类里面:

代码语言:javascript复制
import jakarta.servlet.Filter;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.annotation.Bean;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

@Slf4j
@SpringBootApplication
public class Main {
    public static void main(String[] args) {
        SpringApplication.run(Main.class);
    }


    /**
     * Returns an instance of the correlation filter.
     * This filter sets the Mapped Diagnostic Context (MDC) with the requested traceId parameter, if available, before executing the filter chain. After the filter chain is executed
     * , it removes the traceId from the MDC.
     *
     * @return the correlation filter
     */
    @Bean
    public Filter correlationFilter() {
        return (request, response, chain) -> {
            try {
                log.info("adding traceId");
                String name = request.getParameter("traceId");
                if (name != null) {
                    MDC.put("traceId", name);
                }
                log.info("traceId added");
                chain.doFilter(request, response);
            } finally {
                log.info("removing traceId");
                MDC.remove("traceId");
                log.info("traceId removed");
            }
        };
    }

    /**
     * The ExampleController class is a REST controller that handles the "/hello" endpoint.
     * It is responsible for returning a greeting message with the provided traceId parameter,
     * and it logs the message "hello endpoint called" when the endpoint is called.
     */
    @Slf4j
    @RestController
    public static class ExampleController {
        @GetMapping("/hello")
        String hello(@RequestParam String traceId) {
            log.info("hello endpoint called");
            return "Hello, "   traceId   "!";
        }
    }
}

启动后,我们调用 hello 接口,传入 traceId 参数(curl 'http://127.0.0.1:8080/hello?traceId=123456'),我们可以看到日志中打印出了 traceId。

代码语言:javascript复制
2024-02-02 17:58:14.727 [http-nio-8080-exec-1][INFO ][c.g.h.s.Example_01.Main][] - adding traceId
2024-02-02 17:58:14.728 [http-nio-8080-exec-1][INFO ][c.g.h.s.Example_01.Main][123456] - traceId added
2024-02-02 17:58:14.736 [http-nio-8080-exec-1][INFO ][c.g.h.s.E.Main$ExampleController][123456] - hello endpoint called
2024-02-02 17:58:14.740 [http-nio-8080-exec-1][INFO ][c.g.h.s.Example_01.Main][123456] - removing traceId
2024-02-02 17:58:14.740 [http-nio-8080-exec-1][INFO ][c.g.h.s.Example_01.Main][] - traceId removed

我们可以看出,随着 traceId 放入 MDC,日志中开始有了 traceId,然后随着 traceId 从 MDC 中移除,日志中的 traceId 也消失了。这就是链路日志的原理。

2. 遇到问题,链路信息丢失

由于 MDC 是一个 ThreadLocal 的变量,所以在 WebFlux 的环境下,由于每个操作符都可能会切换线程(在发生 IO 的时候,或者使用 subscribeOn 或者 publishOn 这种操作符),这就导致了我们在 WebFlux 环境下,无法通过 MDC 来实现链路日志。我们举个例子:

代码语言:javascript复制
@GetMapping("/hello2")
Mono<String> hello2(@RequestParam String traceId) {
    return Mono.fromSupplier(() -> {
        log.info("hello2 endpoint called");
        return "Hello, "   traceId   "!";
    }).subscribeOn(
            Schedulers.boundedElastic()
    ).map(s -> {
        log.info("map operator");
        return s   s;
    }).flatMap(s -> {
        log.info("flatMap operator");
        return Mono.just(s   s);
    });
}

这时候,我们调用 hello2 接口,传入 traceId 参数(curl 'http://127.0.0.1:8080/hello2?traceId=123456'),我们可以看到日志中并没有 traceId。

代码语言:javascript复制
2024-02-02 18:09:08.398 [http-nio-8080-exec-1][INFO ][c.g.h.s.Example_01.Main][] - adding traceId
2024-02-02 18:09:08.398 [http-nio-8080-exec-1][INFO ][c.g.h.s.Example_01.Main][123456] - traceId added
2024-02-02 18:09:08.421 [boundedElastic-1][INFO ][c.g.h.s.E.Main$ExampleController][] - hello2 endpoint called
2024-02-02 18:09:08.421 [boundedElastic-1][INFO ][c.g.h.s.E.Main$ExampleController][] - map operator
2024-02-02 18:09:08.421 [boundedElastic-1][INFO ][c.g.h.s.E.Main$ExampleController][] - flatMap operator
2024-02-02 18:09:08.423 [http-nio-8080-exec-1][INFO ][c.g.h.s.Example_01.Main][123456] - removing traceId
2024-02-02 18:09:08.424 [http-nio-8080-exec-1][INFO ][c.g.h.s.Example_01.Main][] - traceId removed

同时,我们可以看到,我们在 hello2 方法中,使用了 subscribeOn 操作符,这就导致了我们的代码在 boundedElastic 线程中执行,而不是在 http-nio-8080-exec-1 线程中执行。这就导致了我们在 WebFlux 环境下,无法通过 MDC 来实现链路日志。

3. 解决方案,以及观察纯 Webflux 下的效果

Micrometer 社区做了很多兼容各种框架的工作,我们首先添加依赖:

代码语言:javascript复制
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>context-propagation</artifactId>
    <version>1.0.4</version>
</dependency>

然后,通过以下代码启用 Project Reactor 的 ContextPropagation:

代码语言:javascript复制
Hooks.enableAutomaticContextPropagation();

以上代码的作用是,在 WebFlux 的各种操作符的时候,会自动把当前的 Context 传递到下游中。

然后,添加 context-propagation 中从线程上下文获取信息的功能,同时,在这里将 MDC 中 traceId 信息提取:

代码语言:javascript复制
ContextRegistry.getInstance().registerThreadLocalAccessor(
    //key
    "traceId",
    //提取什么信息,这里提取 MDC 中的 traceId
    () -> MDC.get("traceId"),
    //设置什么信息,这里设置 MDC 中的 traceId
    traceId -> MDC.put("traceId", traceId),
    //清理什么信息,这里清理 MDC 中的 traceId
    () -> MDC.remove("traceId"));

之后,重启我们的应用,我们调用 hello2 接口,传入 traceId 参数(curl 'http://http://127.0.0.1:8080/hello2?traceId=123456'):

代码语言:javascript复制
2024-02-02 19:49:47.729 [http-nio-8080-exec-9][INFO ][c.g.h.s.Example_01.Main][] - adding traceId
2024-02-02 19:49:47.730 [http-nio-8080-exec-9][INFO ][c.g.h.s.Example_01.Main][123456] - traceId added
2024-02-02 19:49:47.730 [boundedElastic-3][INFO ][c.g.h.s.E.Main$ExampleController][123456] - hello2 endpoint called
2024-02-02 19:49:47.731 [boundedElastic-3][INFO ][c.g.h.s.E.Main$ExampleController][123456] - map operator
2024-02-02 19:49:47.731 [http-nio-8080-exec-9][INFO ][c.g.h.s.Example_01.Main][123456] - removing traceId
2024-02-02 19:49:47.731 [boundedElastic-3][INFO ][c.g.h.s.E.Main$ExampleController][123456] - flatMap operator
2024-02-02 19:49:47.731 [http-nio-8080-exec-9][INFO ][c.g.h.s.Example_01.Main][] - traceId removed

我们可以看到,我们在 hello2 方法中,使用了 subscribeOn 操作符,这就导致了我们的代码在 boundedElastic 线程中执行,而不是在 http-nio-8080-exec-1 线程中执行。但是,我们可以看到,我们的日志中,traceId 依然被打印出来了。这就是我们通过 Micrometer 实现链路日志的原理。

4. 框架自动实现链路日志

上面我们演示的工作,其实框架都会帮我们做了。我们只需要添加依赖:

代码语言:javascript复制
<dependency>
    <groupId>io.micrometer</groupId>
    <artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>

重新编写代码,依然需要启用 Project Reactor 的 ContextPropagation:

代码语言:javascript复制
import jakarta.servlet.Filter;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import reactor.core.publisher.Hooks;
import reactor.core.publisher.Mono;
import reactor.core.scheduler.Schedulers;

@Slf4j
@SpringBootApplication
public class Main {
    public static void main(String[] args) {
        Hooks.enableAutomaticContextPropagation();
        SpringApplication.run(Main.class);
    }
    
    @Slf4j
    @RestController
    public static class ExampleController {
        @GetMapping("/hello")
        String hello() {
            log.info("hello endpoint called");
            return "Hello!";
        }

        @GetMapping("/hello2")
        Mono<String> hello2() {
            return Mono.fromSupplier(() -> {
                log.info("hello2 endpoint called");
                return "Hello2!";
            }).subscribeOn(
                    Schedulers.boundedElastic()
            ).map(s -> {
                log.info("map operator");
                return s   s;
            }).flatMap(s -> {
                log.info("flatMap operator");
                return Mono.just(s   s);
            });
        }
    }
}

之后,重启我们的应用,调用 hello 和 hello2 接口,可以看到日志中都有 traceId。

代码语言:javascript复制
2024-02-02 20:32:11.263 [http-nio-8080-exec-5][INFO ][c.g.h.s.E.Main$ExampleController][65bce0cb58b130d852320a114ffa79d0] - hello endpoint called
2024-02-02 20:32:13.262 [boundedElastic-2][INFO ][c.g.h.s.E.Main$ExampleController][65bce0cd3ca3e1a311aaa81e13317436] - hello2 endpoint called
2024-02-02 20:32:13.262 [boundedElastic-2][INFO ][c.g.h.s.E.Main$ExampleController][65bce0cd3ca3e1a311aaa81e13317436] - map operator
2024-02-02 20:32:13.262 [boundedElastic-2][INFO ][c.g.h.s.E.Main$ExampleController][65bce0cd3ca3e1a311aaa81e13317436] - flatMap operator

0 人点赞