利用 ThreadLocal + Lambda,实现有状态变量的单例模式

通常情况下单例模式的对象应该具有状态,然而现实是复杂的,总会有那么一些特殊情况下需要小小地【违例】一下。

动机

一个父类的方法执行前需要设置一个变量的值,变量值会对方法的执行结果产生影响。现希望子类以单例的方式继承父类。

以我实际遇到的一个问题为例,JOOQ 是一个 ORM 类库,这个类库能够自动扫描数据库并生成 DAO,但是自动生成的 DAO 功能有限,通常需要继承来扩展这些 DAO。每个 DAO 在实例化时需要传入一个 Configuration,这个 Configuration 包含有关数据库的信息。

通常情况下 Configuration 可以由所有的 DAO 共享,然而,在启动事务后 Configuration 会被 JOOQ 派生,且后续所有的事务内数据库操作都应该使用派生的 Configuration

现希望子 DAO 为单例,同时,在不改变父 DAO 的情况下,且不修改子 DAO 的已有方法和对这些方法的调用的情况下,实现 configuration 变量的 “智能” 修改。

我们可以利用 ThreadLocal + Lambda 解决这个问题。

基本概念

ThreadLocal

ThreadLocal<E> 是一个容器,它内部采用 Map 实现,以线程的某种唯一特征为键,用户自定义类型 E 为值。因此不同线程存取同一个 ThreadLocal 容器会得到不同的值,且不同线程互不影响。一个线程总是只能访问到属于他自己的那份值。

Kotlin 协程

建议先跳过本节

Kotlin 协程除了具有协程的本身本性之外,实际上是由 Kotlin 管理的一个线程池。

协程的挂起指的是当前正在运行一块协程代码的线程从这块代码脱离,不再负责这块代码的执行。线程暂时处于空闲状态。每当执行到一个 suspend 函数调用时,都会发生挂起。

协程挂起发生后,开始执行 suspend 函数,负责具体执行这个函数的线程由函数的 withContext() 调用决定。注意:此时可能发生了线程的切换,脱离了原先的线程。

suspend 函数完成后,刚才挂起的协程代码恢复执行。注意:此时可能再次发生线程切换,刚才没执行的代码继续回到原先的线程执行。

问题的解决

要解决一开始提出的问题,容易想到,我们可以利用 ThreadLocal 声明一个“全局”变量,当 DAO 需要用到 Configuration 时,就从 ThreadLocal 容器去取。

同时,再声明一个函数,负责临时更改 ThreadLocal 容器的 Configuration 具体值,当传入的 lambda 执行完毕后再改回原先状态。

传入的 lambda,就是我们希望在新的 Configuration 上下文中所执行的代码。


fun <R> Configuration.use(then: ((Configuration) -> R)): R {
    //jooqConfigurationOrNull 是负责管理存储 Configuration 的属性
    val initialState = jooqConfigurationOrNull  //保存初始状态
    jooqConfigurationOrNull = this //临时变更为新状态
    return try { //注意捕获异常,防止发生异常时无法还原状态
        then(this) //执行传入的 lambda 代码块
    } catch (exception: Throwable) {
        logger.debug("Config use block failed: $exception")
        throw exception //原样抛出异常
    } finally {
        logger.debug("Recover thread local jooq config to initial")
        jooqConfigurationOrNull = initialState //还原初始状态
    }
}

var currentThreadJooqConfiguration: Configuration
    get() = currentThreadJooqConfigurationOrNull ?: jooqConfiguration
    set(value) { currentThreadJooqConfigurationOrNull = value }

//合理利用 getter 和 setter 让 ThreadLocal 对用户不可见
var jooqConfigurationOrNull: Configuration?
    get() = currentThreadJooqConfigurationContainer.get()
    set(value) {
        if (value == null)
            currentThreadJooqConfigurationContainer.remove()
        else
            currentThreadJooqConfigurationContainer.set(value)
    }

// 真正的全局 ThreadLocal 容器
private val currentThreadJooqConfigurationContainer = ThreadLocal<Configuration>()

val jooqConfiguration: Configuration
    get() = realJooqConfiguration.derive() // derive() 等效 clone(),直接取的总是派生出来的。

private lateinit var realJooqConfiguration: Configuration // 派生的根基。只做派生用途。

由于使用了 ThreadLocal,不同线程从该容器取出的结果各不相同且不会互相影响。同时,由于我们的代码霸占着这个线程,因此这里虽然临时改变了 jooqConfigurationOrNull ,但是对其他线程并没有影响。

针对一开始提出的问题,要让父 DAO 获得的 Configuration 也发生改变,只需重写父类的 getter,让父类总是从 ThreadLocal 容器获得值即可。

局限

不难发现,刚才的做法实际上是有漏洞的。

  1. 如果 lambda 代码块内调用线程池执行其他代码,绝对不能执行和数据库操作相关的动作。这是容易理解的,我们所修改的 Configuration 只在当前线程上下文起作用,调用线程池实际上就脱离了当前上下文。
  2. 不应该在 lambda 代码块内直接调用挂起函数。即使所调用的挂起函数并没有操作数据库也不可以。这是因为当前线程在空闲时可能会被安排执行其他协程任务,导致隐患。另外,当协程跑到其他线程上执行时上下文会丢失。
  3. (Kotlin) 不应该在 lambda 代码块内进行 return。若导致 use 函数没有执行后续还原初始状态的代码会导致大问题。

漏洞 1 可以通过人为规范避免。Kotlin 在语法上避免了 3 问题。

针对问题 2,要执行 suspend 函数比较困难,但是也不是不能做。首先,通过 runBlocking 使得当前线程处于阻塞状态,不允许安排其他协程任务(但遗憾的是你的函数也不肯在当前线程跑)。然后,使用 asContextElement 使协程上下文携带某种信息。但这意味着前面的代码几乎都得为此重写,总体上还是比较难做的。

Kotlin 的那些骚操作

最近在学习 Kotlin 这门编程语言,不得不感叹 Kotlin 这语言是真的骚。

重载操作符

在伴生对象重载 invoke 操作符

interface BotUser {
    val id: Long
    val name: String
    val description: String

    companion object {
        operator fun invoke(
            id: Long,
            name: String = "",
            description: String = ""
        ): BotUser {
            return BotUserImpl(id, name, description)
        }
    }
}

class BotUserImpl(...) { ... }

如果你这样做的话,可以让接口或者抽象类仿佛看起来能够被实例化一样(看似调用“构造方法”,实际上是调用了 invoke() )。

用途:项目中途将某个类抽象为一个接口,并将原有类作为此接口的默认实现,这样可以做到源代码的兼容(对 Java 代码以及二进制仍不兼容,毕竟本质不同)。

代码来自我的项目 “MoeCraftBotNG”

其他操作符重载

一般可以重载 get() set() 来使对象能够按数组、字典一样去操作。

为对象编写 contains() 方法时(判断某个元素是否“属于”此对象),可以顺便为此方法加个 operator 关键字可以让 in 关键字支持这个对象

然而操作符重载还是慎用为妙,众所周知这个特性就是被 C++ 玩坏的,当你 java 甚至以不支持操作符重载为荣。好在 Kotlin 在这方面也比较节制。

协程

如果想让你的 suspend 函数被 Java 用户友善地调用,防止你被人砍,你可以:

用 Kotlin 编写一个类:

class Coroutines {
    /**
     * 获取在 Java 代码中调用 Kotlin Suspend 函数所需的最后一个参数 (Continuation)
     * @param onFinished 当suspend函数执行完毕后所调用的回调。若 Throwable 不为 null 则说明执行失败。否则为执行成功
     * @param dispatcher 协程执行线程的类型。可以为 Dispatchers.Default(CPU密集型) Dispatchers.Main(主线程) Dispatchers.IO(IO密集型)
     */
    @JvmOverloads
    fun <R> getContinuation(onFinished: BiConsumer<R?, Throwable?>, dispatcher: CoroutineDispatcher = Default): Continuation<R> {
        return object : Continuation<R> {
            override val context: CoroutineContext
                get() = dispatcher

            override fun resumeWith(result: Result<R>) {
                //注意 Result 是 inline class,不可直接给出去
                onFinished.accept(result.getOrNull(), result.exceptionOrNull())
            }
        }
    }
}

kotlin suspend 函数的调用难点在于最后一个参数,这个参数是 suspend 函数自动生成的,但是在 Java 方面处理起来却十分棘手。你可以提供这样的一个类来生成最后一个参数的值。

然后在 Java 中,就可以这样调用了:

Coroutines coroutines = new Coroutines();

//假设一个 suspend fun login(username: String, password: String): RequestResult,位于 object UserUtils
UserUtils.INSTANCE.login("user", "pass", coroutines.getContinuation(
        (result, throwable) -> {
            //suspend fun执行结束的回调
            System.out.println("Coroutines finished");
            System.out.println("Result: " + result);
            System.out.println("Exception: " + throwable);
        }
    )
);

另外,也可以使用 org.jetbrains.kotlinx:kotlinx-coroutines-jdk8,这个库可以让 suspend fun 返回 CompletableFuture<> 以便在 java 使用

fun doSomethingAsync(): CompletableFuture<List<MyClass>> =
    GlobalScope.future { doSomething() } //返回 CompletableFuture 包装的 suspend fun doSomething()

内置函数

Kotlin 有很多实用的内置函数,只提几个。

run()

run() 字面意思,用于执行一个任意代码块,并返回代码块的返回值

run() 有两种,签名如下:

public inline fun <R> run(block: () -> R): R
public inline fun <T, R> T.run(block: T.() -> R): R

第一种 run() 在为构造函数委托传递参数时特别有用,例如有一个类和两个次构造函数

class ManagedJavaProperties(val inputStream: InputStream, val outputStream: OutputStream? = null) {
    constructor(file: File): this(file.inputStream(), file.outputStream())
    constructor(fileName: String): this(???) //需要进行处理才能委托给其他构造函数
}

第二个次构造函数中需要对 fileName 进行一些处理,此时需要 run() 登场了:

class ... {
    constructor(fileName: String): this(
        kotlin.run {
            val file = File(fileName)
            if (!file.exists()) {
                file.createNewFile()
            }

            file
        }
    )
}

第二种 run() 则是将调用者当作 this 传递给 lambda,除此之外和第一种完全相同

run() 也可以用于防止代码块内变量污染当前作用域。(类似于 Java 的 { }

抑制错误

Kotlin 的 @Suppress 注解不仅可以抑制警告,还可以抑制任何错误。具体的错误名字可以到 kotlin 编译器项目按错误提示寻找。

点击阅读全文 →