在 MC 服务端用 ORM 操作数据库
写 Minecraft 插件,数据存储是绕不开的话题。
玩家的家、背包、统计、经济、权限……几乎每个插件都要和数据库打交道。但说实话,在 MC 插件里写数据库代码,体验一直不太好。
传统方案有多痛
裸写 JDBC
最原始的方式。手动拼语句、手动管连接、手动处理结果集。一个简单的插入操作就要写十几行:获取连接、创建 PreparedStatement、逐个 set 参数、执行、关闭。
问题一目了然:
用配置文件当数据库
很多新手插件直接把数据存 YAML:
小规模数据没问题,但玩家一多就完蛋:
引入专业 ORM 框架
有经验的开发者会想到 Ktorm、Exposed、MyBatis 这些。功能确实强大,但在 MC 插件场景下有点"杀鸡用牛刀":
PTC Object:为 MC 插件量身定做的 ORM
TabooLib 内置了一个叫 PTC Object(Persistent Container Object)的轻量级 ORM 模块。它不是要和 MyBatis 或 Ktorm 竞争,而是专门解决 MC 插件开发中"存几张表"这个高频需求。
核心思路很简单:写一个 data class,加几个注解,就能直接 CRUD。
安装
五分钟上手
第一步:定义数据类
就是一个普通的 Kotlin data class,加了几个注解:
驼峰命名会自动转下划线列名:serverName 变成 server_name。
第二步:创建 DataMapper
一行代码。建表、连接池、方言适配全自动。
配置文件长这样:
enable 为 true 走 MySQL,false 走 SQLite。
第三步:开始 CRUD
对比裸写 JDBC 的版本,代码量大概少了 80%。而且你完全不用关心语句语法、连接管理、类型映射这些事情。
条件查询
PTC Object 提供了类型安全的 Filter DSL:
排序与分页
做排行榜、分页列表这种需求特别方便:
@ID + @key 复合定位
一个很常见的场景:同一个玩家在不同服务器有不同的家。这时候光靠 @Id(玩家 UUID)不够,还需要 @Key(服务器名)一起定位:
事务
批量操作需要原子性?用 transaction:
事务自动提交/回滚,嵌套事务自动复用外层连接。
游标查询:大数据量不炸内存
如果你的表有几十万条数据,一次性 findAll 会把内存撑爆。游标查询逐行读取,内存中始终只有一条数据:
注意:游标查询必须在 transaction 中使用。
容器类型:List / Set / Map 自动建子表
玩家有一堆标签?有一组属性?直接写进 data class,框架自动建子表:
更厉害的是,你可以拿到一个"数据库代理",所有操作直接转化为数据库语句,不在内存中缓存:
看起来像在操作普通的 Kotlin 集合,实际上每个操作背后都是一条数据库语句。
联表查询
玩家家园表和统计表要联查?用 join DSL:
支持 innerJoin、leftJoin、rightJoin,还支持子查询 JOIN。
自定义类型
想存一个自定义对象?实现 CustomType 接口,框架自动发现,不用手动注册:
然后在 data class 里直接用:
枚举存数值:IndexedEnum
枚举默认存名称字符串,但有时候你想存数值(更省空间、更快索引):
外键关联:@LinkTable
数据类之间有关联关系?用 @LinkTable 声明,查询时自动关联,写入时自动级联保存:
支持多层嵌套(A 关联 B,B 关联 C),框架自动递归展开关联查询和级联保存。
版本迁移
表结构要改?用版本迁移,框架按版本号顺序执行变更语句:
实际使用时 version() 里传的是标准的 DDL 语句(比如 ADD COLUMN 之类的)。框架通过 _ptc_meta 表跟踪版本号,只执行版本号大于当前版本的迁移,每个表独立跟踪。
缓存
高频读取场景可以开启 L2 双层缓存:
实战:一个完整的家园系统
把上面的东西串起来,一个完整的玩家家园管理系统大概长这样:
整个系统的数据库层代码就这么多。没有手写语句,没有连接管理,没有结果集映射。换 MySQL 只需要改配置文件,代码一行不动。
实战:排行榜系统
注解速查表
支持的数据类型
什么时候该用,什么时候不该用
适合的场景:
不太适合的场景:
如果你还在裸写 JDBC 或者用 YAML 存数据,PTC Object 值得一试。写一个 data class、加几个注解、一行创建 mapper,就能拿到完整的 CRUD + 事务 + 分页 + 缓存能力。在 MC 插件这个场景下,这个投入产出比相当划算。
写 Minecraft 插件,数据存储是绕不开的话题。
玩家的家、背包、统计、经济、权限……几乎每个插件都要和数据库打交道。但说实话,在 MC 插件里写数据库代码,体验一直不太好。
传统方案有多痛
裸写 JDBC
最原始的方式。手动拼语句、手动管连接、手动处理结果集。一个简单的插入操作就要写十几行:获取连接、创建 PreparedStatement、逐个 set 参数、执行、关闭。
问题一目了然:
- 语句字符串和 Kotlin 代码混在一起,改字段名要同时改两个地方
- 手动管理连接和 Statement 的生命周期,忘了 close 就是连接泄漏
- 结果集到对象的映射全靠手写,字段多了就是体力活
- 换数据库(SQLite 到 MySQL)要改一堆语法
用配置文件当数据库
很多新手插件直接把数据存 YAML:
代码:
config.set("homes." + player.name + ".world", loc.world.name)
config.set("homes." + player.name + ".x", loc.x)
config.set("homes." + player.name + ".y", loc.y)
config.save(file)
小规模数据没问题,但玩家一多就完蛋:
- 文件 I/O 是同步的,玩家多了直接卡主线程
- 没有索引,查询只能全量遍历
- 并发写入容易丢数据
- 数据量大了文件加载慢得离谱
引入专业 ORM 框架
有经验的开发者会想到 Ktorm、Exposed、MyBatis 这些。功能确实强大,但在 MC 插件场景下有点"杀鸡用牛刀":
- 依赖体积大,打进插件 JAR 里动辄几 MB
- 需要额外配置数据源、连接池、方言
- 和 TabooLib 的生命周期、类加载机制不一定兼容
- 学习成本不低,为了存几张表引入一整套框架有点重
PTC Object:为 MC 插件量身定做的 ORM
TabooLib 内置了一个叫 PTC Object(Persistent Container Object)的轻量级 ORM 模块。它不是要和 MyBatis 或 Ktorm 竞争,而是专门解决 MC 插件开发中"存几张表"这个高频需求。
核心思路很简单:写一个 data class,加几个注解,就能直接 CRUD。
安装
代码:
taboolib {
env {
install(Basic, Bukkit, Database)
install("database-ptc-object")
}
}
五分钟上手
第一步:定义数据类
代码:
data class PlayerHome(
@Id
val username: UUID,
@Key
@Length(32)
val serverName: String,
@Length(32)
var world: String,
var x: Double,
var y: Double,
var z: Double,
var yaw: Float,
var pitch: Float,
var active: Boolean
)
就是一个普通的 Kotlin data class,加了几个注解:
- @ID —— 逻辑主键,框架的 find / update / delete 都通过它定位记录
- @key —— 索引字段,配合 @ID 做复合定位(比如同一个玩家在不同服务器有不同的家)
- @Length(32) —— 字段长度,影响 VARCHAR 列的长度
驼峰命名会自动转下划线列名:serverName 变成 server_name。
第二步:创建 DataMapper
代码:
// SQLite 文件
val homeMapper by mapper(dbFile("homes.db"))
// 或者从配置文件读取(支持 MySQL / SQLite 自动切换)
val homeMapper by mapper(db("config.yml", "database", "homes.db"))
一行代码。建表、连接池、方言适配全自动。
配置文件长这样:
YAML:
database:
enable: true
host: localhost
port: 3306
user: root
password: root
database: minecraft
enable 为 true 走 MySQL,false 走 SQLite。
第三步:开始 CRUD
代码:
// 插入
val home = PlayerHome(player.uniqueId, "lobby", "world", 1.0, 64.0, 1.0, 0f, 0f, true)
homeMapper.insert(home)
// 查询
val found = homeMapper.findById(player.uniqueId)
// 更新(只更新 var 字段,val 字段不参与)
val updated = found!!.copy(world = "world_nether", x = 10.0)
homeMapper.update(updated)
// 删除
homeMapper.deleteById(player.uniqueId)
// 检查是否存在
val exists = homeMapper.exists(player.uniqueId)
对比裸写 JDBC 的版本,代码量大概少了 80%。而且你完全不用关心语句语法、连接管理、类型映射这些事情。
条件查询
PTC Object 提供了类型安全的 Filter DSL:
代码:
// 相等
homeMapper.findAll { "world" eq "world_nether" }
// 大于 / 小于
homeMapper.findAll { "x" gt 100.0 }
homeMapper.findAll { "y" lte 200.0 }
// 模糊匹配
homeMapper.findAll { "world" like "%nether%" }
// IN 查询
homeMapper.findAll { "server_name" inside listOf("survival", "creative") }
// 计数
val count = homeMapper.count { "world" eq "world" }
排序与分页
做排行榜、分页列表这种需求特别方便:
代码:
// 击杀数降序,取前 10
val top10 = statsMapper.sortDescending("kills", limit = 10)
// 分页查询
val page = homeMapper.findPage(page = 1, size = 20) {
"world" eq "world"
}
// Page 对象包含完整的分页信息
page.content // 当前页数据
page.total // 总记录数
page.totalPages // 总页数
page.hasNext // 是否有下一页
@ID + @key 复合定位
一个很常见的场景:同一个玩家在不同服务器有不同的家。这时候光靠 @Id(玩家 UUID)不够,还需要 @Key(服务器名)一起定位:
代码:
data class PlayerHome(
@Id val username: UUID,
@Key @Length(32) val serverName: String,
@Length(32) var world: String,
var x: Double, var y: Double, var z: Double,
var yaw: Float, var pitch: Float
)
代码:
// 构造一个"探针"对象,只需填充 @Id 和 @Key 字段
val probe = PlayerHome(playerUUID, "survival", "", 0.0, 0.0, 0.0, 0f, 0f)
// 通过 @Id + @Key 精确定位
homeMapper.findOneByKey(probe)
homeMapper.existsByKey(probe)
homeMapper.updateByKey(home)
homeMapper.deleteByKey(probe)
事务
批量操作需要原子性?用 transaction:
代码:
val result = homeMapper.transaction {
// 事务内的所有操作要么全部成功,要么全部回滚
insert(PlayerHome(uuid1, "lobby", "world", 0.0, 64.0, 0.0, 0f, 0f))
insert(PlayerHome(uuid2, "survival", "world", 100.0, 64.0, 100.0, 0f, 0f))
// 事务内可以读己之写
val home = findById(uuid1)
if (home != null) {
update(home.copy(world = "world_nether"))
}
"done"
}
// 检查结果
result.isSuccess
result.getOrNull()
result.exceptionOrNull()
事务自动提交/回滚,嵌套事务自动复用外层连接。
游标查询:大数据量不炸内存
如果你的表有几十万条数据,一次性 findAll 会把内存撑爆。游标查询逐行读取,内存中始终只有一条数据:
代码:
homeMapper.transaction {
selectCursor { "world" eq "overworld" }.forEach { home ->
// 逐条处理,不会 OOM
processHome(home)
}
// 排序游标 + 提前终止
sortDescendingCursor("x") { "world" eq "overworld" }.use { cursor ->
for (home in cursor) {
if (home.x < 0) break
}
}
}
注意:游标查询必须在 transaction 中使用。
容器类型:List / Set / Map 自动建子表
玩家有一堆标签?有一组属性?直接写进 data class,框架自动建子表:
代码:
data class PlayerProfile(
@Id val username: UUID,
var tags: List,
var scores: Set,
var props: Map
)
更厉害的是,你可以拿到一个"数据库代理",所有操作直接转化为数据库语句,不在内存中缓存:
代码:
// Map 代理
val props = mapper.mapOf(playerUUID, "props")
props["vip_level"] = "3" // 直接写入数据库
val level = props["vip_level"] // 直接从数据库读
props.remove("vip_level") // 直接从数据库删
props.size // 直接查数据库计数
// List 代理
val tags = mapper.listOf(playerUUID, "tags")
tags.add("builder")
tags[0]
tags.removeAt(1)
// Set 代理
val perms = mapper.setOf(playerUUID, "permissions")
perms.add("home.set")
perms.contains("home.set")
看起来像在操作普通的 Kotlin 集合,实际上每个操作背后都是一条数据库语句。
联表查询
玩家家园表和统计表要联查?用 join DSL:
代码:
val results = homeMapper.join {
innerJoin(PlayerStats::class) {
on("player_home.username" eq pre("player_stats.username"))
}
selectAs(
"player_home.username" to "username",
"player_home.world" to "world",
"player_stats.kills" to "kills",
"player_stats.deaths" to "deaths"
)
where { "player_home.username" eq "some_player" }
}.execute()
// 通过别名取值
val row = results[0]
val kills = row.get("kills")
支持 innerJoin、leftJoin、rightJoin,还支持子查询 JOIN。
自定义类型
想存一个自定义对象?实现 CustomType 接口,框架自动发现,不用手动注册:
代码:
data class Coordinate(val x: Double, val y: Double, val z: Double)
object CoordinateCustomType : CustomType {
override val type = Coordinate::class.java
override val typeSQL = ColumnTypeSQL.TEXT
override val typeSQLite = ColumnTypeSQLite.TEXT
override val length = 128
override fun serialize(value: Any): Any {
val coord = value as Coordinate
return coord.x.toString() + "," + coord.y + "," + coord.z
}
override fun deserialize(value: Any): Any {
val parts = value.toString().split(",")
return Coordinate(parts[0].toDouble(), parts[1].toDouble(), parts[2].toDouble())
}
}
然后在 data class 里直接用:
代码:
data class Waypoint(
@Id val name: String,
var location: Coordinate // 自动用 CoordinateCustomType 序列化
)
枚举存数值:IndexedEnum
枚举默认存名称字符串,但有时候你想存数值(更省空间、更快索引):
代码:
enum class AccountType(override val index: Long, val desc: String) : IndexedEnum {
NORMAL(1, "普通用户"),
VIP(2, "VIP用户"),
ADMIN(3, "管理员"),
}
data class AccountData(
@Id val username: String,
var type: AccountType, // 数据库中存储 1, 2, 3
var score: Double
)
// 条件查询自动转换
accountMapper.findAll { "type" eq AccountType.VIP }
// 实际查询条件: type = 2
外键关联:@LinkTable
数据类之间有关联关系?用 @LinkTable 声明,查询时自动关联,写入时自动级联保存:
代码:
data class LinkedPlayerHome(
@Id val username: String,
@Length(32) val serverName: String,
@Length(32) var world: String,
@LinkTable("statsUsername") val stats: PlayerStats?
)
data class PlayerStats(
@Id val username: String,
var kills: Int,
var deaths: Int,
var playtime: Long
)
支持多层嵌套(A 关联 B,B 关联 C),框架自动递归展开关联查询和级联保存。
版本迁移
表结构要改?用版本迁移,框架按版本号顺序执行变更语句:
代码:
val homeMapper by mapper(dbFile("data.db")) {
migration {
version(1,
"给 player_home 表添加 x 列",
"给 player_home 表添加 y 列"
)
version(2,
"给 player_home 表添加 z 列"
)
}
}
实际使用时 version() 里传的是标准的 DDL 语句(比如 ADD COLUMN 之类的)。框架通过 _ptc_meta 表跟踪版本号,只执行版本号大于当前版本的迁移,每个表独立跟踪。
缓存
高频读取场景可以开启 L2 双层缓存:
代码:
val homeMapper by mapper(dbFile("data.db")) {
cache {
beanCache {
maximumSize = 10000
expireAfterWrite = 600
}
queryCache {
maximumSize = 1000
expireAfterAccess = 600
}
}
}
- Bean Cache:按实体 ID 存储,更新/删除只影响特定 ID
- Query Cache:按查询哈希存储,任何写操作清空整个 Query Cache
实战:一个完整的家园系统
把上面的东西串起来,一个完整的玩家家园管理系统大概长这样:
代码:
data class PlayerHome(
@Id val username: UUID,
@Key @Length(32) val homeName: String,
@Length(32) var world: String,
var x: Double, var y: Double, var z: Double,
var yaw: Float, var pitch: Float
)
object HomeManager {
val homeMapper by mapper(db("config.yml", "database", "homes.db"))
fun createHome(player: Player, name: String, loc: Location) {
homeMapper.insert(PlayerHome(
player.uniqueId, name, loc.world.name,
loc.x, loc.y, loc.z, loc.yaw, loc.pitch
))
}
fun getHomes(player: Player): List {
return homeMapper.findAll(player.uniqueId)
}
fun getHome(player: Player, name: String): PlayerHome? {
val probe = PlayerHome(player.uniqueId, name, "", 0.0, 0.0, 0.0, 0f, 0f)
return homeMapper.findOneByKey(probe)
}
fun deleteHome(player: Player, name: String) {
val probe = PlayerHome(player.uniqueId, name, "", 0.0, 0.0, 0.0, 0f, 0f)
homeMapper.deleteByKey(probe)
}
}
整个系统的数据库层代码就这么多。没有手写语句,没有连接管理,没有结果集映射。换 MySQL 只需要改配置文件,代码一行不动。
实战:排行榜系统
代码:
data class PlayerStats(
@Id val uuid: UUID,
var kills: Long = 0,
var deaths: Long = 0,
var level: Int = 1
)
object RankSystem {
val statsMapper by mapper(dbFile("stats.db")) {
cache {
beanCache { maximumSize = 5000; expireAfterAccess = 300 }
}
}
// 击杀排行榜
fun getKillsTop(limit: Int = 10) =
statsMapper.sortDescending("kills", limit = limit)
// 分页查询
fun getStatsPage(page: Int) =
statsMapper.sortDescendingPage("level", page = page, size = 20)
// 事务内批量更新
fun batchUpdate(statsList: List) {
statsMapper.transaction {
updateBatch(statsList)
}
}
}
注解速查表
| 注解 | 作用 | 说明 |
|---|---|---|
| @ID | 逻辑主键 | CRUD 定位依据,每个类最多一个 |
| @key | 索引 + 复合定位 | 配合 @ID 做精确定位,可多个 |
| @UniqueKey | 唯一索引 | 数据库层面唯一约束 |
| @Length(n) | 字段长度 | -1 为 LONGTEXT,默认 64 |
| @NotNull | 非空约束 | NOT NULL |
| @Alias("name") | 列名别名 | 覆盖驼峰转下划线 |
| @ColumnType | 显式列类型 | 手动指定列类型 |
| @Ignore | 忽略字段 | 不参与数据库读写 |
| @LinkTable | 外键关联 | 自动关联查询 + 级联保存 |
| @TableName | 自定义表名 | 支持 PostgreSQL Schema |
支持的数据类型
- 基础类型:Boolean, Byte, Short, Int, Long, Float, Double, Char
- 常用类型:String, UUID, Enum, ByteArray
- 容器类型:List, Set, Map(自动创建子表)
- IndexedEnum:以数值存储的枚举
- 自定义类型:实现 CustomType 接口,框架自动发现
什么时候该用,什么时候不该用
适合的场景:
- MC 插件中常见的数据持久化(玩家数据、配置、统计、排行榜)
- 需要同时支持 SQLite 和 MySQL 的插件
- 不想引入重量级 ORM 框架的轻量项目
- TabooLib 生态内的插件开发
不太适合的场景:
- 需要复杂的多表关联、存储过程、触发器等高级特性
- 数据模型非常复杂,表之间关系错综复杂
- 已经在用 Ktorm / Exposed / MyBatis 且运行良好的项目
如果你还在裸写 JDBC 或者用 YAML 存数据,PTC Object 值得一试。写一个 data class、加几个注解、一行创建 mapper,就能拿到完整的 CRUD + 事务 + 分页 + 缓存能力。在 MC 插件这个场景下,这个投入产出比相当划算。
- 内容版权许可
- 作者保留一切权利,禁止转载
最后编辑: