12-1 YAML 文件
迷迭香之拥
我很容易忘事,记得提醒我。
或许 Minecraft 的相关开发,从启动器到服务端,从模组到数据包,都绕不开如何存储数据这个永恒的话题。原因很简单,程序运行时所使用的数据是存放在内存(RAM) 中的,当程序结束,这些数据也就会丢失。服务器上的插件和普通的程序没什么区别,插件所使用的数据(各种变量、函数等)同样会在服务器关闭时丢失。
『如果记不住什么东西,那就把它写下来』,如果我们想在服务器的两次启动之间记住任何东西(比如世界存档),那就需要寻求一个能持久化存放数据的地方。对于服务器自身的数据,例如存档、玩家信息、进度等,Bukkit(和 Paper)负责保存它们,而对于插件,这项工作就需要我们来完成。
前面已经提到,插件本质上也是 Kotlin(Java)程序,因此所有可用于 Java 的存储方案,也都可以用于服务器插件。理想情况下,我们会希望一种存储方案有以下的特点:
- 读取和写入都非常快。
- 不需要编写太多代码或配置就可以使用。
- 能够存储大量的数据,而且容易查找。
- 即使遇到极端的意外情况也能确保数据完整。
遗憾的是,没有任何一种存储技术能够同时在这四个方面都做到最好:YAML 使用起来非常容易,但要查找数据则相对麻烦;关系型数据库能很大程度上确保数据的完整,但写入就要慢一些,而且操作起来也很困难;MapDB 能存储大量的数据也能方便地查找,但在完整性方面则略逊一筹……尽管存储行业的工程师和研究人员(很荣幸地说,笔者也是他们之一)花费大量的精力来优化各种系统,但仍然没有一种方案是放之四海而皆准的。
既然没有万能的方案,那么就需要根据用途选择最合适的那个。因此,在接下来的几个小节里,我们会介绍一些常用的存储技术,并列出它们的使用场景,这样大家在编写插件时,就可以挑选最适合自己插件的方案。
表中表中表
我们传统的方法……
最简单的存储方案是使用配置文件 —— 是的,没错,尽管配置文件通常是用户修改、插件读取,但实际上也可以由程序修改并保存。先前我们已经用到过 set 方法来临时修改配置:
config.set("path.to.key", 42)
config.getInt("path.to.key") // 42
像这样的修改是暂时的,在服务器下一次重启时就会失效。不过,Bukkit 提供了一个很方便的方法来保存配置文件:
/**
* 将当前配置文件的内容存入 `config.yml`。
*/
fun Plugin.saveConfig()
像上面这样使用 类名.方法名 的写法,代表 Plugin 类中包含 saveConfig 方法,而不是说这个方法的名字真的叫做 Plugin.saveConfig。这种记法让看到它的人立刻就能明白这个方法和它所属的类,这样我们就不需要再用文字描述这一点。在本书的剩余部分我们都会使用这样的写法。
所以和 MapDB 一样,我们只需要在 onDisable 中调用 saveConfig,就可以把修改过的配置存入 config.yml:
override fun onDisable() {
saveConfig()
}
这样下一次服务器启动时,就可以使用各种 getXXX 方法来获取先前存储的值,这么做 OK。
专事专办
Nyaci:配置文件不应该是存储配置的吗?
……呃,好吧,确实不能就这样糊弄过去。虽然使用配置文件来存储数据非常简单,但就像 Nyaci 所说,用户通常会认为 config.yml 是存储配置的,这是一种约定(Convention)。
计算机行业中的约定(Convention) 就是指一些并非严格规范但仍应当遵守的规则。例如,Kotlin 的函数名可以写作 snake_case 这样使用下划线的版本,但 Kotlin 工程师们约定使用 camelCase —— 尽管使用前者也不会出现什么编译错误,代码也可以正常运行,但那不是 Kotlin 的风格。类似地,Bukkit 约定使用 config.yml 作为用户修改的配置文件,这样用户不论使用什么插件,都知道先打开 config.yml。这些都不是什么不可逾越的红线,但遵循这些规则是推荐的做法。
当然了,既然是约定,就肯定会有不同的尖锐的声音。有人喜欢 get_a_number,那就会有人喜欢 getANumber,但人们的代码是需要相互调用的,所以必须有一个统一的规范。在 Bukkit 中也是同理:诚然,config.yml 也许不是你心目中应该保存配置的地方,但所有人都按照这个方案来,总比每个人都随意地挑选着自己的名称(比如 ciallo.toml)要好得多。
由于 config.yml 一般用于存储配置,所以要存储数据,我们就需要选择另一个文件,这一点我们在双重验证插件中已经做过了,如果你已经忘记了怎么做,大概像这样:
private val dataFile = File(plugin.dataFolder, "data.yml")
// data 是个 Kotlin 关键字,所以使用了缩写 dat
private val dat = YamlConfiguration.loadConfiguration(dataFile)
override fun onDisable() {
dat.save(dataFile)
}
相比于直接使用配置文件,我们基本上只是多了一步读取配置的过程,即 YamlConfiguration.loadConfiguration,此后我们就可以像读写配置文件一样愉快地对这个新的『数据配置』进行 set 和 getXXX 了,是不是非常棒?
不只是文字游戏!
哈,还有更酷的!YAML 文件被广泛应用在 Bukkit 插件开发中的另一大原因,就是它除了可以存储基本类型(Boolean、Int、String 之类),还可以直接保存一些 Bukkit 的类型,例如 Location(位置信息)、ItemStack(物品堆)等,这意味着我们不再需要使用 ItemStack.serializeItemsAsBytes 来把物品堆转换成 ByteArray,而可以像下面这样直接存取它们:
val item = ItemStack(Material.DIAMOND, 64)
dat.set("some-item", item)
val savedItem = dat.getItemStack("some-item")
savedItem.amount // 64
为什么 Bukkit 可以做到这一点呢?这其实并不是什么魔法,因为 YAML 说到底是个文本文件,所以在其中存放的任何东西都必须先被转换成文本,这叫做序列化,由于 ItemStack 之类的类型实在是太过常用,Bukkit 为我们写好了相应的转换代码,但这并不能改变 YAML 只能存储文本内容的事实。
便利的代价
说了这么多 YAML 的优点,那是不是 YAML 就是全能的呢?我们可以使用 getXXX 来查找键,可以使用 set 设置,还能存储很多数据库(比如 MapDB)可以存储或者不能存储的值……看上去一切都很完美,对吧?
但是,YAML 的这种便利性是伴随着代价的。由于 YAML 中的键没有顺序之分,因此为了实现『指定一个键就能找到它的内容』,Bukkit 就必须将整个 YAML 文件的内容悉数加载到内存中。大家都知道,内存对于 Minecraft 服务器而言是一种极为紧缺的资源,不可以随意浪费。像是用户名密码之类的数据,由于其规模很小,尚且可以承受,但若是要像 CoreProtect 那样记录世界上每一个方块(实体)的每一次变更,那要把这些数据全都加载到内存中压根就是不切实际的事情。
除此之外,YAML 还有另一个缺陷,那就是其中的键是无信息组织的,所谓无信息组织,就是指如果要找到一些符合条件的键,那除了『地毯式』地查阅每个记录之外别无他法。举个例子,假设有下面这样存储货币信息的 YAML 文件:
deposit:
- name: Aluka
diamond: 64
gold: 100
- name: Nyaci
diamond: 128
gold: 0
- name: Ted
diamond: -2147483648
gold: 0.30000000000000004
# ……以及其它 3584 条数据
那么如果要在其中找出钻石数量大于 100 的玩家,就只能从上往下地搜寻每一条记录,判断 diamond 是不是大于 100。这样的方法虽然代码上很容易写,但实际运行起来却是非常没有效率的。
为了能够高效地存储和查询数据,我们需要一种信息化组织数据的系统。尽管这样的系统非常多,但我们要介绍的是其中的龙头老大 —— 关系型数据库(RDBMS)。