(WebFlux)002、如何打印日志与链路ID

WebFlux如何打印日志与链路ID

一、背景

最近在持续改造项目,想通过日志查看用户所有的接口链路日志。在原来基于SpirngMVC的时候,那是比较好处理的,通过ThreadLocal,�放入TraceId,就可以把一个TraceId传到各个地方,然后再需要的地方取出来,相当简单。但是在换了WebFlux,老实说,真还是有些麻烦。但这并不能难倒我们,发车!

现在把使用过程中问题和解决方式列出来,供大家参考。参考原文链接

SpringBoot 版本号: <version>2.6.10</version>

二、 正文

2.1 实现方案

要实现用户调用链路所有的日志,那么我们就得通过唯一的ID去追踪。大致可以通过在请求的header中携带token,或者通过cookie这样的方式。考虑到大多数的使用场景,我们就使用在header中携带token的方式来实现。

2.2 实现方式

既然我们采取的是在header在添加token的方式,那么如何取出来,然后又在打印日志中获取到,这才是关键点。我们在SpringMVC中通常采用AOP的方式打印日志,那我们在WebFlux中是否也可以这样做呢?

2.2.1 步骤1 - 过滤器

当然可以了。要实现拦截,当然还是先实现WebFilter,代码如下。

/**
 * <p>记录traceId</p>
 *
 * @author fattycal@qq.com
 * @since 2022/8/8
 */
@Slf4j
@Configuration
public class TraceIdWebFilter implements WebFilter {

    private static final String TRACE_ID = ConstantsFields.TRACE_ID;

    @Override
    public Mono<Void> filter(ServerWebExchange exchange, WebFilterChain chain) {

        return chain.filter(exchange)
                // 放入当前上下文,类似于ThreadLocal
                .contextWrite(context -> {
                    // header 中是否有TRACE-ID
                    String traceId = exchange.getRequest().getHeaders().getFirst(TRACE_ID);

                    traceId = Optional.ofNullable(traceId).orElse("");
                    if (!StringUtils.hasText(traceId)) {
                        log.warn("TRACE_ID not present in header: {}", exchange.getRequest().getURI());
                    }
                  
                    Context contextTmp = context.put(TRACE_ID, traceId);
                    exchange.getAttributes().put(TRACE_ID, traceId);

                    return contextTmp;
                });
    }
}

实现WebFilter,通过contextWrite方法,把Header中的trace-id存入到上下文中。这个ContextWrite很重要,它是类似于ThreadLocal的东西,如果有老铁不知道,可以参考Context翻译文章,这里我们就不在一一赘述啦。

实现了WebFilter后,并且放入了Context中,这样我们是不是想ThreadLocal一样,取出来直接用就可以了?of course!

2.2.2 步骤2 - 切面

直接贴代码,如下(方式一)。

/**
 * <br>日志切面</br>
 *
 * @author fattyca1@qq.com
 * @since 2022/8/10
 */

@Aspect
@Configuration
@Slf4j
public class LoggerAspect {

    @Around("@annotation(com.fattycal.demo.webflux.annotation.Loggable)")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {

        long start = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        if (result instanceof Mono) {
            Mono monoResult = (Mono) result;
            AtomicReference<String> traceId = new AtomicReference<>("");
            return monoResult.flatMap(ret -> Mono.subscriberContext().map(ctx -> {
                        // 从Context中取出traceId, 放入到了AtomicReference,正常变量没办法操作(内部类)。
                        traceId.set(ctx.getOrDefault(ConstantsFields.TRACE_ID, ""));
                        return ret;
                    }))
                    .doOnSuccess(o -> {
                        String response = "";
                        if (Objects.nonNull(o)) {
                            response = o.toString();
                        }
                        log.info("【{}】,Enter: {}.{}() with argument[s] = {}", traceId,
                                joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(),
                                joinPoint.getArgs());
                        log.info("【{}】,Exit: {}.{}() had arguments = {}, with result = {}, Execution time = {} ms", traceId,
                                joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(),
                                joinPoint.getArgs()[0],
                                response, (System.currentTimeMillis() - start));
                    });
        }

        return result;
    }
}

我们直接通过切面,来判断响应结果是否是属于Mono,如果是,则通过flatmap结合Mono.subscriberContext()拿到traceId,然后在doOnSuccess中打印日志。这样的好处是,不用自己订阅Mono.subscriberContext()

有的哥们就会问,为啥不在doOnSuccess()中去订阅呢? 好问题,我们的尝试一下。代码如下(方式二)。

@Aspect
@Configuration
@Slf4j
public class LoggerAspect {

    @Around("@annotation(com.fattycal.demo.webflux.annotation.Loggable)")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {

        long start = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        if (result instanceof Mono) {
            Mono monoResult = (Mono) result;
            // 错误的实现方式
            return monoResult.doOnSuccess(obj -> Mono.subscriberContext()
                    .map(ctx -> {
                        String traceId = ctx.getOrDefault(ConstantsFields.TRACE_ID, "");
                        String response = "";
                        if (Objects.nonNull(obj)) {
                            response = obj.toString();
                        }
                        log.info("【{}】,Enter: {}.{}() with argument[s] = {}", traceId,
                                joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(),
                                joinPoint.getArgs());
                        log.info("【{}】,Exit: {}.{}() had arguments = {}, with result = {}, Execution time = {} ms", traceId,
                                joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(),
                                joinPoint.getArgs()[0],
                                response, (System.currentTimeMillis() - start));
                        return ctx;
                    })
            );

        }
        return result;
    }
}

一激动,马上唰唰写出来了,但是这样写可不可以呢(文章已标记是错误的写法)?为啥说是错误的写法呢,那是因为在Reactor3中,有一个至理名言,那就是nothing happens until you subscribe()。我们没有订阅,所以Mono.subscriberContext().map()这一个流不会被执行的(点完餐付完钱店家才确定要做)。

所以我们稍微动一下代码,如下。

/**
 * <br>日志切面</br>
 *
 * @author fattyca1@qq.com
 * @since 2022/8/10
 */
@Aspect
@Configuration
@Slf4j
public class LoggerAspect {

    @Around("@annotation(com.fattycal.demo.webflux.annotation.Loggable)")
    public Object logAround(ProceedingJoinPoint joinPoint) throws Throwable {

        long start = System.currentTimeMillis();
        Object result = joinPoint.proceed();
        if (result instanceof Mono) {
            Mono monoResult = (Mono) result;
            // 把doOnSuccess这个操作放到单独线程池里做
            return monoResult.publishOn(Schedulers.newElastic("fattyca1-thread-pool")).doOnSuccess(obj -> Mono.subscriberContext()
                    .map(ctx -> {
                        String traceId = ctx.getOrDefault(ConstantsFields.TRACE_ID, "");
                        String response = "";
                        if (Objects.nonNull(obj)) {
                            response = obj.toString();
                        }
                        log.info("【{}】,Enter: {}.{}() with argument[s] = {}", traceId,
                                joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(),
                                joinPoint.getArgs());
                        log.info("【{}】,Exit: {}.{}() had arguments = {}, with result = {}, Execution time = {} ms", traceId,
                                joinPoint.getSignature().getDeclaringTypeName(), joinPoint.getSignature().getName(),
                                joinPoint.getArgs()[0],
                                response, (System.currentTimeMillis() - start));
                        return ctx;
                    }).subscribe()
            );

        }
        return result;
    }
}

我们在map方法后面又增加了subscribe()方法,这个时候,付钱了,餐馆才给你做饭。当然,我们又添加了publishOn这个方法,那是因为subscribe()是阻塞的,为了不阻塞,我们放进了一个新的线程池中处理。这样我们就大功告成啦! 马上动手测试一下

2.2.3 品尝果实

我们直接来一个朴实无华的测试,代码如下。

@RestController
public class WebfluxController {

    @RequestMapping("/hi/{name}")
    @Loggable
    public Mono<String> helloWorld(@PathVariable("name") String name) {
        return Mono.fromSupplier(() -> "hi, " + name);
    }
}
2.2.3.1 (方式一)

先按照方式一的方式来测试,结果如图所示。

方式一测试

方式一测试出来,的确没问题,我们把Header中的Trace-id打印出来了。那接下来试试方式二。

2.2.3.2 (方式二)

方式一实现方式测试,结果如图所示。

方式二测试

擦,扑街了~,从测试结果看,这样的方式是取不到Context中的值,我在尝试去掉线程池后,也还是取不到Context中的值。为什么没有取到这一点,还没研究透,后面研究透了给大家补上

从结果看,我们还是通过FlatMap的方式,提前拿到Trace-id还是靠谱一点。

2.2.3.2 测试三

我们修改了一下Controller中的代码,通过flatMap,从Context中获取traceId。再测试一下。结果如图所示。

修改Controller代码

通过测试我们发下,通过flatmap,然后再从Context中获取traceId是完全可行的,所以我们在实际使用的时候还是要注意下方式。

三、总结

先实践,实操,在理解原理。以上为实际实践,如果发现有问题,欢迎指出,交流!

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 205,132评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,802评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,566评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,858评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,867评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,695评论 1 282
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,064评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,705评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,915评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,677评论 2 323
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,796评论 1 333
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,432评论 4 322
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,041评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,992评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,223评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,185评论 2 352
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,535评论 2 343

推荐阅读更多精彩内容