ThreadLocal系列之——业务开发实践(一)

写作目的

以前的工作经历中,笔者本人有深度使用ThreadLocal的经验,它在合适的场景下,是非常好用的一个工具,因此打算分享一二,为各位看官们实际编码过程中提供多一种选择,促进大家共同进步

场景

先举两个大家熟悉的、Spring中用到的场景

场景一

在Spring管理的Singleton Bean中,如果期望调用同一个类里被事务注解的方法(m1调用m2),且希望事务能生效,可以考虑的实现如下:

@Service
public class FooService {

    public void m1() {
        // 拿到FooService的代理对象
        FooService self = (FooService)AopContext.currentProxy();
        // 通过代理对象调用m2方法,事务切面就能生效
        self.m2();
    }

    @Transactional
    public void m2() {
        // save to db;
    }

}

之所以要通过代理对象去调用,是因为若在m1中使用原始对象(this),不会进入代理逻辑,因此切面逻辑(事务)是不会生效的。所以问题就转变成了:在一个方法执行过程中,如何拿到当前方法所属对象的代理对象

  1. 在FooService中直接注入自己

  2. 通过调用AopContext.currentProxy()

为与本文扣题,我们的示例采用的是方式2。那么接下来需要探寻的是,为什么AopContext.currentProxy()就能拿到代理对象呢?

  1. 外部方法调用FooService#m1的时候,进入了代理逻辑(org.springframework.aop.framework.CglibAopProxy.DynamicAdvisedInterceptor#intercept),与此同时,会把代理对象放到AopContext
image
  1. AopContext#setCurrentProxy会将代理对象(proxy)放到currentProxy中,而currentProxy是一个ThreadLocal
    image
image
  1. 在m1方法中执行FooService self = (FooService)AopContext.currentProxy();,本质就是从当前线程的ThreadLocal中取出上边1、2步放入的代理对象,接下来就可以用代理对象"搞事情"

    image
  2. 代理对象切面逻辑结束后,用oldProxy将AopContext还原

    image

如此这般,通过预先将代理对象放入当前线程的ThreadLocal的方式,就可以做到在接下来的流程中,在任意位置都可以很方便获取到该代理对象,而不需要通过方法参数一层层透传下去

在理解上,可以将ThreadLocal理解成为当前线程装东西的"篮子":在线程执行任务时,可以在某节点(方法)将一些东西放进"篮子",并可在后续的任意节点(方法)从"篮子"取出之前放入的东西

image
场景二

大家可能会经常使用到Spring @Transactional事务注解,若不理解原理,可能容易踩坑。一个基本共识是:事务由连接管理,一个事务只属于一个连接,若要使得事务生效(相关DB操作同时提交,同时回滚),必须确保是同一个连接

那么就会有如下场景:

@Service
public class FooService {

    @Resource
    private BarService barService;

    @Transactional
    public void foo() {
        barService.bar();

        // save foo
        // ...
    }
}

@Service
public class BarService {

    @Transactional
    public void bar() {
        // save bar
    }
}

FooService#foo调用BarService#bar进行关于Bar的数据库操作,之后进行Foo的数据库操作,由于方法foo与方法bar都被@Transactional注解,可以确保事务操作的同时提交或同时回滚。"确保事务操作的同时提交或同时回滚"是我们在大量的编程经验中可以轻易得出的结论,如果追究根因,其实就是共识:这两个操作处于同一个事务当中,都由同一个连接里的事务进行管理。因此,重点是需要确保"同一个事务"以及"同一个连接"。"同一个事务"是如何实现的,请参考org.springframework.transaction.support.AbstractPlatformTransactionManager#handleExistingTransaction并理解事务的传播属性(不是本文重点,略过)。

那么问题就来了:"同一个连接"又是如何实现的呢?代码跑在不同类以及不同方法上,Spring如何做到前后两次获取的是同一个连接?如果看官们理解了场景一,相信此处应该有了结论 -> ThreadLocal

  1. 外部调用FooService#foo第一次开启事务的时候,将从连接池中取出一个连接,并将连接放到 ConnectionHolder 中,然后将ConnectionHolder绑定到当前线程

    // org.springframework.jdbc.datasource.DataSourceTransactionManager#doBegin
    
    protected void doBegin(Object transaction, TransactionDefinition definition) {
     DataSourceTransactionObject txObject = (DataSourceTransactionObject) transaction;
     Connection con = null;
    
     try {
         if (!txObject.hasConnectionHolder() ||
                 txObject.getConnectionHolder().isSynchronizedWithTransaction()) {
             // 从连接池中取出连接,并将连接放到 ConnectionHolder 中
             
             Connection newCon = this.dataSource.getConnection();
             // ...(省略)
             txObject.setConnectionHolder(new ConnectionHolder(newCon), true);
         }
         // ...(省略)
    
         // Bind the connection holder to the thread.
         // 将 ConnectionHolder 绑定到当前线程
         if (txObject.isNewConnectionHolder()) {
             TransactionSynchronizationManager.bindResource(getDataSource(), txObject.getConnectionHolder());
         }
     }
     // ...(省略)
    }
    

将value(ConnectionHolder)放到map之后,再放到当前线程的ThreadLocal中,以实现与当前线程的绑定

// org.springframework.transaction.support.TransactionSynchronizationManager#bindResource

public static void bindResource(Object key, Object value) throws IllegalStateException {
 Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key); // 忽略
 
 // resources 是ThreadLocal类型的成员变量
 Map<Object, Object> map = resources.get();
 // set ThreadLocal Map if none found
 if (map == null) {
     map = new HashMap<Object, Object>();
     resources.set(map);
 }
 // 将value(ConnectionHolder)放到map之后,再放到当前线程的ThreadLocal中,以实现与当前线程的绑定
 Object oldValue = map.put(actualKey, value);
 
 // ...(省略)
}
image
  1. FooService#foo调用BarService#bar将触发第二次开启事务,发现传播属性为TransactionDefinition.PROPAGATION_REQUIRED(默认值),会将第二次事务直接"融入"第一次事务,能实现的关键点在于前后两次操作使用同一个连接。如下,是从resources(ThreadLocal)中取出map,并取出第1步中放入的ConnectionHolder,因此就可以确保拿到同一个连接

    // org.springframework.transaction.support.TransactionSynchronizationManager#getResource
    
    public static Object getResource(Object key) {
     Object actualKey = TransactionSynchronizationUtils.unwrapResourceIfNecessary(key);
     return doGetResource(actualKey);
    }
    
    private static Object doGetResource(Object actualKey) {
     // resources 是ThreadLocal类型的成员变量
     // 从ThreadLocal中取到第1步中放入的ConnectionHolder
     Map<Object, Object> map = resources.get();
     if (map == null) {
         return null;
     }
     Object value = map.get(actualKey);
     // ...(省略)
     
     return value;
    }
    

实战

以上使用ThreadLocal的场景,都是框架里提供的,是否业务开发中就用不到了呢?非也,业务开发中如果运用妥当,同样能省掉很多事,实现精简代码的目的。笔者同样举两个业务开发用到的场景,以供大家开阔思路

场景一

该场景比较普遍,几乎所有公司都用的上,因与业务无关,首先介绍,帮助没有使用过ThreadLocal的看官们找找感觉

所有公司所有业务都会有用户体系,进行业务操作都需要登录、鉴权,以识别某用户是否有操作某项资源的权限,因此基本上很多业务都需要拿到当前请求的用户信息

一种可以考虑的方式是:每次请求到来时,可否在入口中统一鉴权,然后将鉴权之后的用户信息记录下来,接下来但凡有业务要用到,可以直接从保存的地方获取到用户信息,不用再一次鉴权,省去一次次的计算消耗;待请求结束后,就将用户信息销毁

  1. 注册一个HandlerInterceptorFilter,用于在入口处拦截请求,并对当前请求进行鉴权,获取用户信息,放入UserHolder中,并在请求结束的时候清理掉该用户信息(注:请求结束后清理用户信息很重要,避免内存泄露)
public class LoginCheckInterceptor extends HandlerInterceptorAdapter {

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {
        // 鉴权(获取用户信息)
        User user = xxx(request);
        if (user != null) {
            // 放到UserHolder这个容器(Holder)中
            UserHolder.putUser(user);
            return true;
        }
        // 鉴权(获取用户信息)失败,则直接拦截
        return false;
    }

    @Override
    public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
        // 请求结束后,清理掉用户信息(画重点:很重要)
        UserHolder.clear();
    }
}

UserHolder是一个容器,包装ThreadLocal,放置当次请求中用户的信息,如下示:

public class UserHolder {
        // 内部维护一个ThreadLocal成员变量
    private static ThreadLocal<User> tl = new ThreadLocal<>();

    public static void putUser(User user) {
        tl.set(user);
    }

    public static User getUser() {
        return tl.get();
    }

    public static Long getUid() {
        return Optional.ofNullable(tl.get()).map(User::getId).orElse(null);
    }

    public static void clear() {
        tl.remove();
    }
}
  1. 在需要使用的地方,例如XXXService,直接在任意方法内部调用UserHolder#getUser 即可获取到当前用户的完整信息。如此这般,就不需要将User从Controller层层传递到Service里的某个小方法,避免了"依赖污染",对于[多层次]的业务代码组织尤为有效,省去了多次参数传递的烦恼

反例:明明requestxxx, yyy 都不直接依赖User,只是在最后一层zzz用到User,却不得不将用户信息层层传递,造成"依赖污染"

// Service接口方法
public void request(User user) {
    xxx(user);
}

private void xxx(User user) {
    yyy(user);
}

private void yyy(User user) {
    zzz(user);
}

private void zzz(User user) {
    String email = user.getEmail();
}

使用ThreadLocal后,简化如下,只有zzz需要用到User信息,那就在zzz中直接获取用户信息,并进行业务逻辑

public void request() {
     xxx();
}

public void xxx() {
    yyy();
}

private void yyy() {
    zzz();
}

private void zzz() {
    User user = UserHolder.getUser();
    String email = user.getEmail();
}

看官们可能会有疑问:我们的后端服务并不需要鉴权,而是由前边的网关做好了鉴权,然后通过某些方式(如请求头)携带给后端服务,那还能使用吗?答案是肯定的,请看场景二

场景二

笔者以前从事广告相关业务,广告投放逻辑里需要识别是哪个用户,当前手机型号、品牌是什么,操作系统版本号、APP版本号、设备尺寸等等总共几十项信息,这些信息都是由请求头携带到后端服务,而广告投放逻辑里面不同的模块会使用不同头字段来做相应的业务逻辑

image

在实现上,同样是在入口处,通过自定义拦截器或过滤器的方式拦截请求,获取到所有的请求头信息,封装进HeaderHolder,以便在不同模块间方便获取请求头信息做业务逻辑。可以想像,一个业务有不同的模块,不同模块又分为不同的业务抽象层次,而不同层次代码中可能需要使用到请求头信息都不一样,如果是通过函数传参的方式将Header层层传递,代码将变得多么糟糕

一般化推论

诚然,使用ThreadLocal会为我们的编程带来许多好处,同样的也为代码的管理,依赖的梳理带来了一些挑战。因此真正要把ThreadLocal用好并不简单,并非任何场景都可以使用的,用好是一个亮点,用不好就是坑:这也是所有技术选型所要面临的问题,技术本身无所谓好坏,没有任何一种技术能在所有方面碾压同类的竞品技术,否则一定能将竞品取代而不需要面临trade off,抛开业务场景谈技术(架构)就是耍流氓

结合笔者本人的实际使用经验,将使用ThreadLocal的场景总结为一句话:在尽可能早的时机将一些大多数后续流程要使用的只读信息封装到ThreadLocal里,供后续流程任意取用

  1. 尽可能早的时机:好事须趁早,越早生成,就越早能使用,可扩展性越高,最好是在与业务无关的入口处
  2. 大多数: 有选择性地将大多数后续都需要用到的信息放到ThreadLocal里,而不是所有信息无脑放入
  3. 只读信息: 封装的信息最好"Read Only",意味着一旦生成,将不可再更改,整个后续流程都只能读取,该要求是为了让代码更可控,倘若信息可以修改,就有可能导致不同层次的代码产生间接依赖(如下层依赖上层),系统行为将不可控

总结

本文从Spring使用ThreadLocal的案例为切入点,介绍了ThreadLocal在开源框架中使用的两个场景;接着又分享了两个业务开发中能够用上的场景,帮助看官们更感性地理解ThreadLocal的作用;最后,针对ThreadLocal潜在"可维护性"问题,鲜明的给出笔者本人的观点:技术无所谓好坏,谨慎而不盲目使用,并针对如何正确使用ThreadLocal提出了一般化的方法论,只要遵循相应原则,基本上对于代码还是相当可控,而不致引入额外的维护成本

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