在Kotlin协程中,如何基于请求链路作用域共享数据(以MDC为例)
我们需要在Kotlin协程环境中复刻Java异步场景下MDC的能力:在请求入口(比如Web Filter或Android点击监听器)生成requestId这类上下文数据,让整个链路里的日志——不管是挂起函数还是普通非挂起函数里的调用——都自动带上这些信息,不用手动传入。
传统ThreadLocal在异步场景会失效,CompositionLocal又只在挂起函数里生效,没法覆盖非挂起函数。但协程运行时确实会把上下文绑定到当前线程(靠内部ThreadLocal),我们可以利用这一点,结合自定义协程上下文元素实现全场景覆盖。
具体实现方案
定义自定义协程上下文元素
先搞个类来存储日志上下文数据,实现CoroutineContext.Element,作为协程上下文的一部分传递:data class MdcContext(val contextMap: Map<String, String>) : CoroutineContext.Element { companion object Key : CoroutineContext.Key<MdcContext> override val key: CoroutineContext.Key<MdcContext> = Key }绑定协程上下文与ThreadLocal
实现一个CoroutineInterceptor,在协程执行前后把上下文同步到ThreadLocal里,这样非挂起函数也能通过ThreadLocal拿到上下文:object MdcInterceptor : ContinuationInterceptor { private val mdcThreadLocal = ThreadLocal<Map<String, String>>() // 给日志框架提供获取当前上下文的方法 fun getCurrentContext(): Map<String, String> = mdcThreadLocal.get() ?: emptyMap() override fun <T> interceptContinuation(continuation: Continuation<T>): Continuation<T> { val mdcContext = continuation.context[MdcContext]?.contextMap ?: emptyMap() return object : Continuation<T> by continuation { override fun resumeWith(result: Result<T>) { val originalContext = mdcThreadLocal.get() try { mdcThreadLocal.set(mdcContext) continuation.resumeWith(result) } finally { // 执行完恢复原线程的上下文,避免污染 mdcThreadLocal.set(originalContext) } } } } }简化上下文注入
给协程作用域扩展几个方法,方便在入口处快速添加MDC上下文:fun CoroutineScope.withMdc(vararg pairs: Pair<String, String>): CoroutineScope { val newMdc = MdcContext(mapOf(*pairs)) return this + newMdc + MdcInterceptor } fun CoroutineScope.launchWithMdc( context: CoroutineContext = EmptyCoroutineContext, vararg mdcPairs: Pair<String, String>, block: suspend CoroutineScope.() -> Unit ) { val mdcContext = MdcContext(mapOf(*mdcPairs)) launch(context + mdcContext + MdcInterceptor, block = block) }适配日志框架(以Logback为例)
自定义一个Converter,从MdcInterceptor里拿上下文数据,配置到logback.xml里:
先写Converter类:class CoroutineMdcConverter : ClassicConverter() { override fun convert(event: ILoggingEvent): String { val contextMap = MdcInterceptor.getCurrentContext() return contextMap.getOrDefault(getFirstOption(), "") } }然后在logback.xml里配置:
<conversionRule conversionWord="mdcCoroutine" converterClass="com.yourpackage.CoroutineMdcConverter" /> <appender name="CONSOLE" class="ch.qos.logback.core.ConsoleAppender"> <encoder> <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} %level [%mdcCoroutine{requestId}] %logger{36}%n%msg%n</pattern> </encoder> </appender>入口处使用示例
- Web Filter场景:
fun filter(request: Request, chain: FilterChain) { val requestId = UUID.randomUUID().toString() runBlocking { launchWithMdc("requestId" to requestId) { chain.doFilter(request) // 后续所有挂起/非挂起函数的日志都会自动带requestId } } } - Android点击监听器场景:
button.setOnClickListener { val clickId = UUID.randomUUID().toString() lifecycleScope.launchWithMdc("clickId" to clickId) { handleClick() // 不管内部调用挂起还是非挂起函数,日志都带clickId } }
- Web Filter场景:
验证非挂起函数
随便写个非挂起函数调用日志,会自动带上上下文:fun nonSuspendFunction() { log.info("这是一个非挂起函数的日志") // 输出会自动带上requestId/clickId }
原理说明
协程执行时,MdcInterceptor会在resumeWith阶段把协程上下文里的MdcContext同步到ThreadLocal,所以哪怕是非挂起函数,只要在协程执行的线程上调用,就能拿到上下文;协程切换线程时,Interceptor会自动在新线程重新设置ThreadLocal,执行完再恢复原线程的上下文,不会造成线程污染。
内容的提问来源于stack exchange,提问作者Dean Hiller

