前言
在DDD项目中,为了方便参数的传递,通常会使用ThreadLocal
来保存一个对象
来实现对参数的跨方法传递,避免通过形参的形式传递。在内部项目中,有一个项目使用的是 alibaba开源的 transmittable-thread-local
来存储参数,新建了一个上下文对象(AbilityContext.java
),使用HashMap
来临时存储和获取参数。
AbilityContext 示例
public class AbilityContext {
private static final ThreadLocal<Map<String, Object>> CONTEXT = new TransmittableThreadLocal<>();
private AbilityContext() {
}
/**
* 初始化上下文
*/
public static void initContext() {
Map<String, Object> con = CONTEXT.get();
if (con == null) {
CONTEXT.set(new HashMap<>(8));
} else {
CONTEXT.get().clear();
}
}
/**
* 清除上下文
*/
public static void clearContext() {
CONTEXT.remove();
}
public static Map<String, Object> getInnerMap() {
return CONTEXT.get();
}
/**
* 获取上下文内容
*/
public static <T> T getValue(String key) {
Map<String, Object> con = CONTEXT.get();
if (con == null) {
return null;
}
return (T) con.get(key);
}
/**
* 设置上下文参数
*/
public static void putValue(String key, Object value) {
Map<String, Object> con = CONTEXT.get();
if (con == null) {
CONTEXT.set(new HashMap<>(8));
con = CONTEXT.get();
}
con.put(key, value);
}
}
项目情况介绍
通常来说,DDD项目的基本流程是由interface->application
,中间封装一层来集中处理上下文的初始化和清空动作,如下图:
在正常情况下,上述流程可以正确的完成参数的写入和获取,但是,在项目运行过程中遇到了一个bug,正常写入参数后,偶现性(低频)获取值为NULL,导致程序出错,示例代码如下(隐去业务代码,重新写的伪代码):
- 其中
demo()
方法为当时复现的方法 demo2()
为伪代码,是业务代码中调用了另一个application
,假设其逻辑和demo()
方法一致的业务代码。
@Slf4j
public class AlibabaTtlWrongUsageExampleApplication {
public static void main(String[] args) {
demo(i);
}
private static void demo(int idx) {
// 初始化
AbilityContext.initContext();
// 赋业务值
AbilityContext.putValue("main", "mainValue");
// 这里简化了代码,实际上经过了很多层业务代码调用后才出现了此方法
ThreadUtil.execute(() -> {
execute->demo2();
});
// do something
// 主线程再次获取业务值(偶现为null)
String value = AbilityContext.getValue("main");
if (Objects.isNull(value)) {
log.warn("lastGetNullValue, idx={}", idx);
}
}
}
上述代码运行设置了一个key=main
,值为mainValue
。在下方AbilityContext.getValue("main")
偶现获取==NULL。
展开分析
当时在分析的开始有推测是业务代码中参数被重新赋值为NULL,但通过对后续业务代码逐行查看,并没有找到重新赋值的逻辑。
在深入业务代码分析的过程中,发现主流程中有一个异步方法调用(ThreadUtil.execute()
),再次调用了另一个领域服务(这是不符合DDD规范的!),而领域服务的入口都会AbilityContext.initContext()
的逻辑,通过这个线索 ,继续展开了深入分析。
编码者的初衷可能是想到异步线程已经脱离了当前线程,再次调用 initContext()
方法是初始化了一个新的对象上下文,但是由于项目使用的是 alibaba TTL
,能够实现跨线程的传递,所以在子线程中依旧能拿到父线程的HashMap
。并且TTL默认是使用的浅拷贝对象。由于initContext()
中,调用了HashMap.clear()
方法,相当于将父线程的HashMap
给清空了!。
通过比对父子线程的hashCode值确定为同一对象
// 主线程获取hashCode
final int hashCode = AbilityContext.getInnerMap().hashCode();
ThreadUtil.execute(() -> {
// 子线程对比hashCode
log.info("{}, ThreadUtil hashCode={}", idx, AbilityContext.getInnerMap().hashCode() == hashCode);
// 子线程再次初始化(错误的根源)
AbilityContext.initContext();
// do something
});
14:42:28.198 [pool-1-thread-26] INFO top.imyzt.learning.caseanalysis.ttl.AlibabaTtlWrongUsageExampleApplication -- 25, ThreadUtil hashCode=true
持续分析
有了上述的线索,基本把问题原因找到了,但是为什么是偶现的呢?
因为使用了异步线程,而线程的调度由操作系统的线程调度算法来决定,并不是一定保证顺序的,所以只要当操作系统优先调度异步线程,那么HashMap
就被清空了,如果主线程优先往下走,那么就能够获取到完整的HashMap
。
后记
至此,问题分析就告一段落了,整个过程中涉及到 TTL值的父子线程传递、对象浅拷贝、线程的调度,还涉及到了DDD的不规范逻辑编排,整个分析下来花费了一上午的时间,收获还是很大的。
我将源代码上传了GitHub,如果你想在本地调试运行上述案例,可以下载到本地调试,有问题可以评论区沟通。