教程 在 MC 服务端用 ORM 操作数据库

枫溪

【Lv:3】

开发者
正式会员
注册
2024/02/03
消息
27
金粒
3,656.33金粒
在 MC 服务端用 ORM 操作数据库

写 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 插件这个场景下,这个投入产出比相当划算。
 
内容版权许可
作者保留一切权利,禁止转载
最后编辑:

在线会员

  • JJXFCJW
  • 天禅吖TvT
  • in7
  • 浪人丿
  • DillanWest
  • 2433539854
  • zbwuming
  • ice_watermelon
  • Huode
  • 向着日落许愿
  • 娜娜奇
  • 尘埃downflyin
  • 梦安然
  • opin
  • 幻鲨
后退
顶部 底部