Kotlin事件溯源实战:适配器模式三剑客对比

Kotlin事件溯源实战:适配器模式三剑客对比

为什么事件溯源需要适配器模式?

事件溯源(Event Sourcing)将应用状态的变化记录为不可变的事件序列,但基础设施(如数据库、消息队列)往往与业务逻辑紧耦合。适配器模式在这里充当“转换器”,将领域事件与外部存储、序列化、事件总线等隔离,让核心逻辑保持纯净。Kotlin 的多种语言特性(高阶函数、扩展函数、密封类)催生了三种截然不同的适配器实现风格。

方案一:经典接口适配器——稳定但冗长

传统方式定义一个 EventStoreAdapter 接口,包含 save(events: List)load(aggregateId: String): List 等方法,然后为不同数据库(PostgreSQL、MongoDB、内存)编写实现类。

interface EventStoreAdapter {
    fun save(events: List): Unit
    fun load(aggregateId: String): List
}

class PostgresEventStoreAdapter(private val connection: Connection) : EventStoreAdapter {
    override fun save(events: List) {
        // 使用 JDBC 将事件序列化为 JSON 并批量插入
    }
    override fun load(aggregateId: String): List {
        // 查询并反序列化
    }
}

优势:类型安全、清晰可测试,适合大型团队与多实现场景。劣势:每个实现必须实现所有方法,即使某些方法无需使用;接口膨胀后维护成本上升。

方案二:函数式适配器——轻量灵活

利用 Kotlin 的高阶函数,将适配器定义为 (List<Event>) -> Unit(String) -> List<Event> 的组合。通过闭包携带 Connection 等依赖。

typealias SaveEvents = (List<DomainEvent>) -> Unit
typealias LoadEvents = (String) -> List<DomainEvent>

class EventSourcingRepository(
    private val saveEvents: SaveEvents,
    private val loadEvents: LoadEvents
) {
    fun append(event: DomainEvent) = saveEvents(listOf(event))
    fun history(id: String) = loadEvents(id)
}

// 使用 lambda 创建适配器
val pgSave: SaveEvents = { events ->
    connection.prepareStatement("...").use { /* 批量插入 */ }
}

优势:零模板代码,按需组合,天然与协程流(Flow)配合。适合原型、小型项目或需要极简适配的场景。劣势:失去显式契约,IDE 导航困难,参数顺序容易混淆,不适合长期维护的大型代码库。

方案三:扩展函数适配器——Kotlin 风格利器

不创建新类,而是为已有类型(如 ConnectionObjectMapper)添加扩展函数,使其“变成”适配器。

fun Connection.saveEvents(events: List<DomainEvent>) {
    // 使用 this (Connection)执行 SQL
}

fun Connection.loadEvents(aggregateId: String): List<DomainEvent> {
    // 查询并映射
}

// 使用方式
connection.saveEvents(events)
val history = connection.loadEvents("agg-123")

还可以通过 扩展属性 返回 lambda 来支持动态切换实现:

val Connection.eventStoreAdapter: EventStoreAdapter
    get() = object : EventStoreAdapter {
        override fun save(events: List<DomainEvent>) = this@connection.saveEvents(events)
        override fun load(aggregateId: String) = this@connection.loadEvents(aggregateId)
    }

优势:代码自文档化,无需额外包装类;能够复用已有对象的生命周期管理(如连接池)。劣势:扩展函数难以被 mock(需使用 MockK 的扩展函数模拟),且仅在单一上下文中表现良好,跨模块可见性控制稍弱。

横向对比:选择最适合你的适配器

  • 可读性:扩展函数 > 函数式 > 经典接口(因人而异,扩展函数最简洁)
  • 可测试性:经典接口 > 函数式 > 扩展函数(接口可轻松 mock)
  • 灵活性:函数式 > 扩展函数 > 经典接口(函数式组合最自由)
  • 团队协作:经典接口 > 扩展函数 > 函数式(契约明确)
  • Kotlin 原生感:扩展函数 > 函数式 > 经典接口

结合事件溯源的特殊考量

事件溯源要求适配器处理 序列化/反序列化版本兼容事务性 等问题。经典接口可以封装这些逻辑为一个类,函数式适配器需要借助外部高阶函数(如 serializeWith(version: Int)),扩展函数则可以将序列化器作为接收者扩展:

fun ObjectMapper.saveEvents(connection: Connection, events: List<DomainEvent>) {
    connection.prepareStatement(...).use { stmt ->
        events.forEach { stmt.setString(1, writeValueAsString(it)) }
    }
}

此外,Kotlin 协程与 Flow 的集成也值得关注:适配器可以返回 Flow<DomainEvent> 以便于事件流处理。函数式适配器天然支持 suspend 写法,经典接口则需要定义 suspend fun,扩展函数同样可以标记 suspend

总结

没有绝对的“最佳适配器”,只有最适合当前上下文的方案。如果你的项目需要多人长期维护、大量数据库迁移,经典接口适配器仍然是首选;如果团队追求代码简洁、快速试错,函数式适配器配合 lambda 非常高效;若你希望发挥 Kotlin 语法糖的魅力,且适配对象(如 DataSource)在整个应用中唯一,扩展函数适配器会带来惊喜。事件源头的复杂性越高,越推荐混合使用——核心接口定义契约,扩展函数提供便捷 alias,高阶函数用于动态策略替换。掌握这三把“剑”,你就能在事件溯源架构中游刃有余。