Minecraft 插件开发中,数据持久化是个绕不过去的问题。玩家数据要存、配置要存、状态要存,但插件的运行环境决定了你不可能引入 Hibernate 这种重量级框架——类加载隔离、JAR 体积、运行时依赖冲突,每一个都是坑。

database-ptc-object 是 TabooLib 框架中的对象关系映射模块,设计目标是让开发者用一个 data class 加几个注解就能完成建表、CRUD、事务、关联查询。这篇文章不讲怎么用,而是讲这个模块是怎么一步步设计出来的——从最初的单文件原型,到支持三种数据库方言、子表级联、版本迁移的完整 ORM。

设计动机

TabooLib 原本有一个 database-ptc 模块,提供的是基于键值对的持久化容器。用起来像这样:给一个 key,存一个序列化后的 value。这种模式够用,但不够好——你没有类型安全,没有结构化查询,每次读写都要手动序列化反序列化。

问题的本质是:插件开发者想的是"我有一个 PlayerHome 对象,帮我存起来",而不是"帮我把 username 字段序列化成字符串,拼成一条 INSERT 语句"。

所以 database-ptc-object 的设计目标很明确:

  1. 声明式映射——写一个 data class,加注解,建表和 CRUD 自动完成

  2. 多数据库透明——同一套代码跑 SQLite、MySQL、PostgreSQL,开发者不需要关心方言差异

  3. 轻量无依赖——不引入第三方 ORM 框架,整个模块自包含

  4. 渐进式复杂度——简单场景一行代码搞定,复杂场景(事务、关联表、子表)按需开启

第一阶段:最小原型

2024 年 9 月,模块随 TabooLib 6.2.0 正式落地。第一版只有 14 个文件,1157 行代码,但核心骨架已经确立。

注解驱动的元数据

设计从注解开始。一个 ORM 最核心的问题是:怎么知道一个类的哪些字段对应数据库的哪些列?

data class PlayerHome(
    @Id val username: UUID,
    @Column(type = ColumnTypeSQL.VARCHAR, length = 64)
    var world: String,
    var x: Double,
    var y: Double,
    var z: Double
)

@Id 标记逻辑主键,@Column 可选地指定列类型和长度。如果不标 @Column,框架根据 Kotlin 类型自动推断 SQL 类型。

这一步的关键决策是:用构造器参数顺序作为字段发现机制。Kotlin data class 的主构造器参数和字段是一一对应的,通过反射拿到 Constructor.parameters 就能按声明顺序遍历所有字段。这比扫描 Java Field 更可靠——Field 的顺序在不同 JVM 上不保证稳定。

AnalyzedClass:反射元数据的缓存层

反射是昂贵的。每次 CRUD 都重新解析类结构不现实,所以第一版就引入了 AnalyzedClass——一个带缓存的类元数据容器。

class AnalyzedClass private constructor(val clazz: Class<*>) {
    val members: List<AnalyzedClassMember>  // 字段列表
    val primaryMember: AnalyzedClassMember? // @Id 字段

    companion object {
        private val cache = ConcurrentHashMap<Class<*>, AnalyzedClass>()
        fun of(clazz: Class<*>) = cache.getOrPut(clazz) { AnalyzedClass(clazz) }
    }
}

ConcurrentHashMap 做单例缓存,同一个类只解析一次。AnalyzedClassMember 封装每个字段的名称、类型、注解信息、是否为主键等元数据。

Container + ContainerOperator:两层抽象

第一版的架构分两层:

  • Container(容器):管理数据库连接和表的生命周期,负责建表

  • ContainerOperator(操作器):执行具体的 CRUD,每张表对应一个操作器

Container 是抽象类,ContainerSQLContainerSQLite 分别处理 MySQL 和 SQLite 的建表逻辑。ContainerOperator 接收一个 Table 对象和 DataSource,封装所有 SQL 操作。

用户入口是 PersistentContainer DSL:

val container = persistentContainer(db()) {
    new<PlayerHome>()
}

一行代码:解析类 → 建表 → 注册操作器。

CustomType:类型扩展点

Kotlin 的类型不可能和 SQL 类型一一对应。UUID、枚举、自定义对象都需要序列化。第一版就设计了 CustomType 接口:

interface CustomType {
    val type: Class<*>
    fun serialize(value: Any): Any
    fun deserialize(value: Any): Any
}

通过 CustomTypeFactory 注册自定义类型转换器。框架内置了 UUID、Location 等常见类型的转换,开发者也可以注册自己的。

这一步的设计原则是开放封闭——框架不需要预知所有可能的类型,只需要提供扩展点。

第二阶段:DataMapper 与快捷操作

最小原型能跑,但用起来还是偏底层。开发者需要手动从 Container 拿 operator,再调 findinsert。这不够 Kotlin。

这一步引入了 DataMapper 接口和 Kotlin 委托语法,让使用体验变成:

val homeTable by mapper<PlayerHome>(dbFile("data.db"))

// 查
homeTable.findById(playerId)
homeTable.findAll { "world" eq "world_nether" }

// 写
homeTable.insert(newHome)
homeTable.update(modifiedHome)

DataMapper:类型安全的 CRUD 门面

DataMapper<T> 是一个纯接口,定义了完整的操作集合:

  • 插入:insert / insertBatch / insertAndGetKey

  • 查询:findById / findOne / findAll / count

  • 更新:update / updateByKey / upsert

  • 删除:delete / deleteWhere

  • 事务:transaction { ... }

  • 原生:rawSelect / rawUpdate / rawDelete

接口设计的关键是不暴露 SQL 细节findById 接收一个 Any 类型的 ID 值,内部自动根据 @Id 注解定位;findAll 接收一个 Filter lambda,用 DSL 构建 WHERE 条件。

MapperDelegate:by 关键字的魔法

Kotlin 的属性委托让初始化代码彻底消失:

inline fun <reified T> mapper(
    source: Any,
    noinline config: MapperConfig<T>.() -> Unit = {}
): ReadOnlyProperty<Any?, DataMapper<T>>

by mapper<T>(...) 做了三件事:

  1. 根据 source 类型创建 Container(File → SQLite,ConfigurationSection → MySQL/PostgreSQL)

  2. 解析类 T 的元数据并建表

  3. 返回一个 DataMapperImpl 实例

这是典型的延迟初始化——第一次访问属性时才触发建表,避免服务器启动时的阻塞。

Cursor 与 Page:大数据集处理

当表里有几万条记录时,findAll 把所有数据一次性加载到内存显然不行。这一步引入了两个工具:

  • Page:分页查询,findAll(page = Page(1, 20)) 返回第一页的 20 条

  • Cursor:流式游标,逐条处理不占内存,适合批量导出场景

homeTable.selectCursor { "active" eq true }.forEach { home ->
    // 逐条处理,内存中同时只有一条记录
}

QueryCache:读缓存层

插件场景的读写比通常很高——玩家数据读取频繁,写入相对稀少。QueryCache(后来演进为 L2Cache)提供了两层缓存:

  • BeanCache:按 ID 缓存单个对象

  • QueryCache:按查询条件缓存结果列表

写入操作自动失效对应缓存,保证一致性。开发者通过配置开启:

val homeTable by mapper<PlayerHome>(dbFile("data.db")) {
    cache {
        beanCache { maximumSize = 10000; expireAfterAccess = 600 }
        queryCache { maximumSize = 1000; expireAfterAccess = 600 }
    }
}

第三阶段:事务支持

到这里模块已经能用了,但还缺一个生产级数据层必备的能力——事务。

事务的核心问题是:多个操作必须共享同一个数据库连接。原本的 ContainerOperator 每次操作都从 DataSource 拿新连接,事务下不行。

Connection 共享机制

ContainerOperatorImpl 增加了一个 sharedConnection 字段:

class ContainerOperatorImpl(
    override val table: Table<*, *>,
    override val dataSource: DataSource,
    private val sharedConnection: Connection? = null,
    // ...
) {
    val isTransactional: Boolean
        get() = sharedConnection != null

    fun withConnection(connection: Connection): ContainerOperatorImpl {
        return ContainerOperatorImpl(table, dataSource, connection, ...)
    }
}

非事务模式下 sharedConnection 为 null,每次操作走 DataSource。事务模式下传入一个共享连接,所有操作都用这一个。withConnection 是个工厂方法,根据现有 operator 复制出一个事务版本。

TransactionContext:DSL 入口

事务上下文封装了用户视角的事务操作:

container.transaction {
    val homes = get<PlayerHome>()
    val stats = get<PlayerStats>()

    homes.insert(listOf(newHome))
    stats.update(playerStats)

    if (someError) {
        rollback()  // 标记回滚
    }
}

get<T>() 返回的是事务感知的 operator——共享连接、共享事务边界。block 执行完毕后,根据 rollbackOnly 标志决定 commit 还是 rollback;中途抛异常自动回滚。

事务传播:嵌套事务的连接复用

后来增加了一个细节:嵌套 transaction { } 应该复用外层连接,而不是开一个新事务。这通过 ThreadLocal 实现:

companion object {
    internal val currentConnection = ThreadLocal<Connection>()
}

进入事务时把 Connection 设到 ThreadLocal,新的 transaction { } 调用先查 ThreadLocal——有就直接复用,没有才开新连接。这一步对应业内常说的"传播行为 REQUIRED",但实现上只用了一个 ThreadLocal,没引入复杂的传播策略枚举。够用就行,不要过度设计

第四阶段:PostgreSQL 支持

一开始模块只支持 MySQL 和 SQLite。要支持 PostgreSQL 时,第一反应是再加一个 ContainerPostgreSQL,照抄 ContainerSQL 的代码改改就行。

但这条路走了不久就发现问题——ContainerSQL 和 ContainerSQLite 已经有大量重复代码,每加一种数据库重复就翻倍。建表逻辑、列类型映射、索引创建,三份几乎一样的代码用不同的 SQL 方言重写。

这是个明显的坏味道,但当时为了快速支持 PostgreSQL,先把功能加上了。新增的 ContainerPostgreSQL 处理了几个关键差异:

  • 列名引用:MySQL 用反引号,PostgreSQL 用双引号

  • 自增主键:MySQL 用 AUTO_INCREMENT,PostgreSQL 用 SERIAL

  • 字符串类型:MySQL 的 VARCHAR(64) 在 PostgreSQL 中需要不同的列类型映射

这次提交还顺带升级了 MySQL 驱动版本。但代码重复的问题留到了后面的重构。

Schema 与版本迁移

PostgreSQL 有 Schema 概念,一个数据库可以分多个命名空间。@TableName 注解扩展了 schema 参数:

@TableName("player_home", schema = "minecraft")
data class PlayerHome(...)

建表时自动 CREATE SCHEMA IF NOT EXISTS minecraft,表名解析为 minecraft.player_home。这里踩过一个坑——PostgreSQL 索引名不能含点号,所以索引名要把 schema 前缀剥掉。

同时引入了手动建表版本迁移两个机制:

val homeTable by mapper<PlayerHome>(dbFile("data.db")) {
    // 跳过自动建表,用户提供完整 SQL
    manualTable("CREATE TABLE IF NOT EXISTS player_home (...)")

    // 或者:版本迁移
    migration {
        version(1, "ALTER TABLE player_home ADD COLUMN x DOUBLE DEFAULT 0")
        version(2, "ALTER TABLE player_home ADD COLUMN z DOUBLE DEFAULT 0")
    }
}

迁移机制通过一个 _ptc_meta 元数据表跟踪当前版本号。每次启动框架检查版本,按升序执行未应用的迁移 SQL。这是从 Flyway / Liquibase 借来的思路,但只保留了核心机制,没有 checksum、没有回滚脚本——够用就好

第五阶段:容器类型子表与关联表

到这里,简单类型字段的映射已经没问题了。但现实中的数据结构不可能全是扁平的——玩家有多个家、有物品列表、有属性键值对。List<String>Map<String, Int> 这些集合类型怎么存?

有两条路:

  1. 序列化成 JSON 塞进一个 TEXT 列(简单但不能查询)

  2. 拆成子表,外键关联(规范但复杂)

database-ptc-object 两条路都支持,由用户选择。

集合 CustomType:单列存储

对于简单场景(不需要对集合元素做独立查询),把整个集合序列化成 JSON 存进一列是最省事的。通过 CustomTypeelementType 字段标识"这是一个集合类型转换器":

interface CustomType {
    val type: Class<*>
    val elementType: Class<*>?  // 非 null 时表示集合 CustomType
        get() = null
    fun serialize(value: Any): Any
    fun deserialize(value: Any): Any
}

框架在写入时检查字段类型,如果匹配到集合 CustomType,就用它的 serialize 把整个 List<XXXData> 转成字符串存入一列。读取时反序列化回来。

Collection Accessor:子表存储

当你需要对集合元素做独立 CRUD(比如"删除玩家的第三个家"而不是"读取全部家再过滤"),就需要真正的子表。

框架自动为带 @Collection 语义的字段创建子表。子表名规则是 {父表名}__{字段名},包含一个外键列关联父记录的 @Id。

player_home (主表)
├── id: INT (PK)
├── username: VARCHAR
└── world: VARCHAR

player_home__tags (子表)
├── id: INT (PK)
├── __fk__: INT (外键,指向 player_home.id)
├── __key__: VARCHAR (Map 的 key)
└── __value__: VARCHAR (Map 的 value)

这里的设计难点是连接池死锁。第一版的实现在查询主表时,拿着 ResultSet 不释放,就去查子表——一个连接等另一个连接,如果连接池满了就死锁。

解决方案是两阶段读取:先把主表 ResultSet 的数据全部读到内存中(Map 形式),关闭 ResultSet 释放连接,再去查子表。牺牲一点内存换来死锁消除。

CollectionAccessor 提供了 DatabaseMap / DatabaseList / DatabaseSet 三个代理类,实现标准 MutableCollection 接口。对代理的修改直接转化为 SQL 操作:

val props: MutableMap<String, String?> = mapper.mapOf(playerId, "props")
props["key1"] = "value1"    // INSERT INTO player__props ...
props.remove("key1")        // DELETE FROM player__props WHERE __key__ = 'key1'

@LinkTable:关联表级联

子表解决的是"一个字段存多值"。还有一种场景:一个类引用另一个类,两个类各有自己的表。

data class PlayerHome(
    @Id val id: Int,
    var world: String,
    @LinkTable val owner: PlayerProfile  // 关联到另一张表
)

@LinkTable 标记的字段不会在主表中展开为多列,而是存储一个外键值(关联类的 @Id)。查询时自动 JOIN,写入时级联保存。

LinkTableHandler 负责级联逻辑:

  1. 写入:深度优先递归,先保存最深层的关联对象,确保外键引用完整

  2. 查询:构建 JOIN SQL,用列名前缀区分主表和关联表的字段

  3. 环检测:通过 visited 集合避免 A→B→A 的无限递归

fun cascadeSaveLinkedObjects(
    typeClass: AnalyzedClass,
    dataList: List<Any>,
    visited: MutableSet<Class<*>> = mutableSetOf()
) {
    if (!typeClass.hasLinkMembers) return
    visited.add(typeClass.clazz)
    for (member in typeClass.linkMembers) {
        val linkClass = member.linkTableClass ?: continue
        if (linkClass in visited) continue  // 环检测
        // 深度优先:先保存更深层的关联对象
        cascadeSaveLinkedObjects(linkedTypeClass, linkedObjects, visited)
        // 再保存当前层
        operator.upsert(linkedObjects)
    }
}

这里有一个设计取舍:级联保存用的是 upsert(存在则更新,不存在则插入),而不是无条件 insert。因为关联对象可能已经被别的主对象保存过了——比如多个玩家的家都关联同一个 PlayerProfile。

第六阶段:架构重构

重构后的架构如下图所示,每一层职责清晰、依赖方向单一。

到 2026 年 2 月,模块已经膨胀到几千行代码。几个文件变得庞大:

  • ContainerOperatorImpl.kt 超过 900 行,里面混着主表 CRUD、子表操作、LinkTable 级联

  • AnalyzedClass.kt 超过 400 行,既是元数据容器又负责 ResultSet 读取和实例创建

  • ContainerSQL / ContainerSQLite / ContainerPostgreSQL 三个类大量重复的建表逻辑

这次重构的指导思想是四个设计模式的组合应用:

Strategy:DatabaseDialect 消除方言重复

提取 DatabaseDialect 接口,每种数据库实现为一个单例:

interface DatabaseDialect {
    fun createTable(type: AnalyzedClass, name: String, host: Host<*>): Table<*, *>
    fun createCollectionTable(...): Table<*, *>
    fun postInit(container: Container<*>) {}
}

object MySQLDialect : DatabaseDialect { ... }
object SQLiteDialect : DatabaseDialect { ... }
object PostgreSQLDialect : DatabaseDialect { ... }

原来三个 Container 子类各自 200+ 行的建表逻辑,全部归入 DatabaseDialect.kt 一个文件。Container 子类变得极薄——只需要声明用哪个方言:

class ContainerSQL(...) : Container<SQL>(host) {
    override val dialect = MySQLDialect
}

class ContainerSQLite(...) : Container<SQLite>(host) {
    override val dialect = SQLiteDialect
}

这一步的收益是新增数据库方言只需要加一个 Dialect 实现,不用动 Container 层。

Template Method:AbstractDataMapper 提取公共基类

DataMapperImplTransactionalDataMapper 有大量相同的 CRUD 代码。唯一的差异是:

  • operator 来源不同(动态查找 vs 构造注入)

  • 读操作是否走缓存

  • 事务和生命周期行为

提取 AbstractDataMapper 基类,用抽象方法定义差异点:

abstract class AbstractDataMapper<T> : DataMapper<T> {
    protected abstract val type: Class<T>
    protected abstract val operator: ContainerOperator
    protected abstract val cache: L2Cache?

    // 子类可覆写为走缓存
    protected open fun <R> executeRead(key: String, vararg args: Any?, query: () -> R): R = query()

    // 公共 CRUD 实现
    override fun insert(data: T) {
        operator.insert(listOf(data as Any))
        invalidateOnInsert()
    }
    // ...
}

DataMapperImpl 只需要覆写 executeRead 加上缓存逻辑,TransactionalDataMapper 只需要覆写 operator 的获取方式。公共代码归一处,差异代码各归各位。

SRP:EntityMapper 从 AnalyzedClass 中分离

AnalyzedClass 原本身兼两职:类结构元数据 + ResultSet 读取/实例创建。这违反单一职责。

拆出 EntityMapper——专门负责 ResultSet → 对象的映射:

class EntityMapper<T> private constructor(private val analyzedClass: AnalyzedClass) {
    fun read(result: ResultSet): Map<String, Any?>
    fun readWithLinks(result: ResultSet, prefix: String = ""): Map<String, Any?>
    fun createInstance(map: Map<String, Any?>): T
}

AnalyzedClass 变回纯元数据容器(字段列表、注解信息、类型推断),不再包含任何 I/O 逻辑。

Facade + Composite:拆分 ContainerOperatorImpl

900 行的 ContainerOperatorImpl 拆成三个 Handler:

  • DataExecutor:接口,定义 SQL 执行能力(获取连接、设置 quoter)

  • LinkTableHandler:封装 @LinkTable 级联保存和 JOIN 查询

  • CollectionTableHandler:封装容器子表的 CRUD

ContainerOperatorImpl 变成 Facade——把请求分发给对应的 Handler,自身只保留编排逻辑。

分包结构

重构后的包结构:

taboolib/expansion/
├── container/          # 容器层:Container + Dialect
│   ├── Container.kt
│   ├── ContainerSQL.kt
│   ├── ContainerSQLite.kt
│   ├── ContainerPostgreSQL.kt
│   └── DatabaseDialect.kt
├── orm/                # ORM 核心:元数据 + 映射
│   ├── AnalyzedClass.kt
│   ├── AnalyzedClassMember.kt
│   └── EntityMapper.kt
├── mapper/             # DataMapper 层:用户 API
│   ├── AbstractDataMapper.kt
│   ├── DataMapperImpl.kt
│   └── TransactionalDataMapper.kt
├── operator/           # 操作器层:SQL 执行
│   ├── ContainerOperatorImpl.kt
│   ├── DataExecutor.kt
│   ├── LinkTableHandler.kt
│   └── CollectionTableHandler.kt
├── Annotations.kt      # 注解定义
├── CustomType.kt       # 类型扩展
├── DataMapper.kt       # DataMapper 接口
├── ContainerOperator.kt # ContainerOperator 抽象类
├── PersistentContainer.kt # DSL 入口
├── MapperDelegate.kt   # by 委托
└── ...

公开 API 保持不变——用户代码不需要任何修改。内部实现从扁平结构变成按职责分层的子包,每个文件 100-300 行,职责清晰。

设计复盘

回看整个开发过程,几条线索贯穿始终。

渐进式设计的节奏

模块不是一次设计出来的。从第一版 14 个文件到现在 30+ 个文件,每一步都是"遇到问题 → 解决问题 → 发现新问题"的循环。

  • 第一版解决了"data class → 表"的映射

  • 快捷操作解决了"底层 API 太啰嗦"的体验问题

  • 事务解决了"多操作原子性"的正确性问题

  • 子表和 LinkTable 解决了"复杂数据结构"的表达能力

  • 重构解决了"代码膨胀后的可维护性"问题

每一步都不是预先规划好的,而是需求驱动的自然演进。如果一开始就设计子表系统和版本迁移,模块可能到现在还没上线。

三个关键的设计决策

决策一:构造器参数作为字段发现机制。 这规避了 Java Field 顺序不稳定的问题,但也带来了约束——data class 必须有主构造器。后来通过 fieldScanMode(无参构造器 + 字段扫描)做了兜底,让普通 Java 类也能用。

决策二:ContainerOperator 和 DataMapper 分两层。 ContainerOperator 是底层的、表级别的操作器,不关心类型安全。DataMapper 是上层的、带泛型的用户 API。这样 IoC 容器(database-ioc 模块)可以直接操作 ContainerOperator,不用知道具体的实体类型。

决策三:先加功能后重构。 PostgreSQL 支持加进来时明知有重复代码,但还是先发布再说。等功能稳定、用户反馈收集够了,再统一重构。这不是技术债的借口,而是刻意的节奏把控——未验证的抽象比重复代码更危险

踩过的坑

  • 连接池死锁:查主表时持有 ResultSet 又去查子表,解法是两阶段读取

  • PostgreSQL 列名引用:反引号在 PostgreSQL 中是非法字符,用 asFormattedColumnName() 统一处理

  • SQLite 的 @Id 语义:SQLite 中 PRIMARY KEY 强制唯一,但 MySQL 中 KEY 只是索引不强制唯一,同一套注解在两种数据库下行为不同,需要在文档中明确说明

  • Kotlin 注解目标@Id var x 注解可能落在 property 而不是 field 上,需要通过 $annotations 合成方法兜底检查

总线图

2024-09 ─── 最小原型(14 文件,1157 行)
             AnalyzedClass + Container + ContainerOperator
                │
2024-10 ─── 事务支持 / 主键优化
                │
2025-?? ─── DataMapper + Cursor + Page + QueryCache
                │
2026-02-11 ─ Collection 子表 + CollectionAccessor
                │
2026-02-14 ─ @Ignore 注解 + 集合 CustomType + 事务传播
                │
2026-02-15 ─ 手动建表 + 版本迁移 + PostgreSQL Schema
                │
2026-02-20 ─ 架构重构:Strategy + Template Method + SRP + Facade
                │
2026-03 ─── SQLite @Id 行为修正

从一个 1000 行的原型到一个生产级 ORM,核心方法论就一条:让每一步的设计决策都有对应的真实痛点驱动。不要为假设中的需求写代码,不要为未来可能的扩展做架构——等问题真正出现时再解决它,你会发现解决方案比预想中简单得多。