使用Kotlin开发Bukkit插件的命令处理DSL

本文适合有Java基础并且想了解下Kotlin的人阅读,没有Java或别的程序语言基础的不建议继续阅读(

前言

最近在开发一个Minecraft Bukkit服务端插件,需要写一个玩家命令处理。由于Bukkit原版的命令处理非常答辩,所以基本上插件都是自己实现了一套命令处理系统。

先来看看Bukkit给JavaPlugin插件提供的onCommand事件的签名:

1
boolean onCommand(CommandSender sender, Command command, String label, String[] args)

可以看到该事件仅仅是传递了命令发送者sender、命令本体command、标签label和后续的参数args,对于命令的处理则是完全没有提供对应的方法。因此,你不得不自己研究一套命令处理机制。

比如CoreProtect就是嗯写了几十个if ... else ...来处理的(

正好我前段时间在写的插件InteractionSupervisor是用Kotlin进行开发的,而Kotlin正好适合用于写DSL(比如gradle就可以使用build.gradle.kts进行配置),因此我就用Kotlin写了个迫真命令处理DSL

成品展示

先看看最终效果(插件里定义的完整命令见此

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
val commands = buildCommand {
"test" {
execute {
sender.sendMessage("args=" + args.joinToString(","))
}
tabComplete { mutableListOf("1", "2", "3") }
}
"group" {
usage("group [a|b]")
"a" {
execute { sender.sendMessage("a") }
}
"b" {
execute { sender.sendMessage("b") }
}
}
}

以上代码定义了以下内容:

  1. test命令,执行后输出该命令附带的参数(如test a b c会向命令发送者发送args=a,b,c),同时在输入该命令时提供补全内容12、和3
  2. 命令组group
    • 该命令组会要求所有子命令都以此作为开头,如下文定义的命令ab需要以group agroup b的方式调用
    • 同时命令组也会自动提供对其子命令的补全,玩家输入group时,会自动提供补全内容ab
    • 玩家输入错误内容时会向上查找由usage定义的第一条有效的帮助内容并返回给玩家

onCommandonTabComplete事件处调用commands变量内定义的对应方法即可实现以上功能。

使用buildCommand即可开始构建命令处理模块,在模块中:

  • 使用"命令(组)名称" { ... }即可定义一个命令
  • 在命令组中继续使用"命令名称" { ... }即可定义子命令,同时命令组也会提供对该组命令所有子命令的补全
  • 使用execute { ... }即可定义命令执行代码,其中commandsenderargs等命令参数会被一同提供至上下文中,可以直接使用
  • 使用tabComplete { ... }可以定义命令的Tab补全内容
  • 使用usage("...")可以定义命令组的帮助信息

相比于传统的方法,使用DSL写出来的命令处理可读性更强,代码也更清晰。

能实现以上功能都得归功于Kotlin的一大堆逆天语法和特性。

前置知识介绍

以上DSL的核心代码基本就这些:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class Command(val name: String) {
val subCommands = mutableListOf<Command>()
var usage: String? = null
var tabComplete: (() -> MutableList<String>) = { mutableListOf() }
var execute: ((CommandExecuteParams) -> Unit)? = null

fun tabComplete(init: () -> MutableList<String>) {
tabComplete = init
}

fun execute(init: CommandExecuteParams.() -> Unit) {
execute = init
}

fun usage(init: String) {
usage = init
}

operator fun String.invoke(init: Command.() -> Unit) = Command(this).apply(init).also(subCommands::add)
}

class CommandBuilder {
private val commands = mutableListOf<Command>()
operator fun String.invoke(init: Command.() -> Unit) = Command(this).apply(init).also(commands::add)
fun build() = CommandExecutor(commands)
}

fun buildCommand(init: CommandBuilder.() -> Unit) = CommandBuilder().apply(init).build()

完整的定义可在这里查看。

该DSL涉及到大量的Kotlin特性,我们一个个来讲。

扩展函数

Kotlin可以给任意类型添加扩展函数。扩展函数我认为可以理解成一种特殊的普通函数的简写,如以下函数:

1
2
3
fun String.aaa() {
println(this)
}

在定义后即可在任意的String变量上调用,如"test".aaa()

运算符重载

Kotlin支持运算符重载,可以重新定义各个类型的变量对于不同的运算符的操作,使用operator fun 运算符类型()即可为当前类定义运算符的重载。

例如以下定义:

1
2
3
4
5
class A {
operator fun plus(other: Any) {
println("A 与 $other 相加了!")
}
}

重载了类型Aplus(+)运算符,当你将A类型的变量与任意别的变量相加(+)时,将会在控制台输出A 与 另一个变量 相加了!

1
2
A() + 123
// 控制台输出:A 与 123 相加了!

运算符重载同样可以配合上述的扩展函数使用,如以下定义:

1
operator fun String.times(count: Int) = this.repeat(count)

重载了字符串的times(*)运算符和Int的操作,将允许你在任意的字符串上使用*n将该字符串的内容重复n遍,如"a" * 5将得到"aaaaa"

在扩展函数里可以直接以this来访问该变量本身,上文就是在用this表示字符串的值。

在Kotlin中还有个神秘的运算符叫invoke,实现这个运算符即可将任何对象都当作一个迫真函数来使用,非常离谱。如以下定义:

1
operator fun String.invoke() = println(this)

将允许你在任意字符串上以"xxx"()(或"xxx".invoke())的形式直接当成一个迫真函数来进行使用,效果是在控制台输出该字符串本身。

如果你在一个类/接口里面定义一个其他类型的运算符重载,那么这个重载就只在这个类/接口及其子类里面可用,避免外部环境受到污染。

例如我这里在CommandCommandBuilder里重载了Stringinvoke,使得你可以直接在一个字符串上执行代码,这样就实现了写个字符串就能定义一个命令。并且由于该重载并没有定义在全局上,这种写法只在这个DSL里面可用,不会影响到项目的其他部分。

其实Kotlin的官方文档里也存在类似的写法,在他们的HTML示例里也是通过重载字符串的unaryPlus(+)运算符,使得你可以直接使用+"xxx"的方式直接往HTML元素里添加正文。并且同样由于该重载扩展函数只存在于这个DSL的定义中,这种重载也不会污染到外部环境,可以放心使用。
至于为什么最终成品的写法能简化成"xxx" { ... }而不是"xxx"( ... ),这就要看下文的lambda表达式简写了

高阶函数

和Java不同的是,Kotlin提供了强大的函数式编程的支持(Java里的lambda最多算是个SAM(单抽象方法)的语法糖)。在Kotlin里,函数是一等公民。函数不仅可以正常定义和调用,也可以像普通变量一样被存储和传递。如果一个函数的参数里有其他函数,或者说这个函数的返回值也是一个函数,那这个函数就是一个高阶函数。

在Kotlin里,你可以直接声明一个函数类型的变量,写法:

1
val f : (Int, Int) -> Int = { a, b -> a + b }

以上代码定义了变量f,该变量是一个函数,可以用f(111, 222)的方式直接调用,返回的值是333

我们可以看到这个函数变量由以下几个部分组成:

  1. 类型声明
    (Int, Int) -> Int
    前面的(Int, Int)表示的是这个函数所接收的参数,这里是接收两个Int。
    后面的-> Int表示函数的返回值类型,这里就是返回一个Int。
  2. 代码块
    { a, b -> a + b }
    这里的a, b就是上面说的函数所接收的参数,后面的a + b就是这个函数具体执行的内容了。

类似{ a, b -> a + b }的这种定义函数的方法有很多名称,例如匿名函数、lambda表达式、箭头函数等。

匿名函数:因为用这种方式定义函数的时候,你没有说明函数的名称(正常情况下定义函数都是需要声明函数名称的),所以用这种写法就是定义了一个没有名字的匿名函数。

lambda表达式:这个名字来源于数学中的λ演算,具体的我也不懂,反正在编程里lambda表达式指的就是用这种方式定义的匿名函数。

箭头函数:顾名思义,在各个编程语言里,定义这种函数往往都需要使用->=>

匿名函数的简写

只是使用以上方法还写不出这个DSL,因为按上述定义:

1
operator fun String.invoke(init: Command.() -> Unit) = Command(this).apply(init).also(commands::add)

,那要调用这个扩展函数应该是这么写的:

1
2
3
4
5
"xxx"({
execute({
...
})
})

那为什么能写成现在这样呢?这就要提到kotlin对于lambda表达式的专用简写了。

匿名函数作为函数的最后一个参数

kotlin规定,当一个匿名函数是函数的最后一个参数时,可以把它挪到小括号外面,所以上文我们可以改成:

1
2
3
4
5
"xxx"() {
execute() {
...
}
}

函数参数仅有单个匿名函数时的小括号省略

kotlin还规定了,当一个函数的参数仅有一个匿名函数时,小括号也可以省略,所以我们还可以改成:

1
2
3
4
5
"xxx" {
execute {
...
}
}

这就已经是成品的样子了。

对于单个参数的匿名函数的变量声明省略

虽然这里没有用到这个特性,但也单独讲一下,当lambda表达式的参数只有一个的时候,这个参数可以省略不写,kotlin会直接将it作为它的名称:

1
val f : (Int) -> Int = { it * 2 }

这里的it就是那个参数的名字,可以直接在lambda表达式里面使用。

最终原理讲解

讲完上述这些就可以开始讲解这个DSL究竟是如何做到的了。

我们先来逐个看上文的定义:

1
2
3
buildCommand {
...
}

有上文基础就能看懂这段在干什么了。很明显,buildCommand是一个函数,接收一个匿名函数作为参数。因此,小括号可以省略,并且能把大括号写到外面,大括号里面的内容就是这个匿名函数的代码了。

回头看一眼buildCommand的定义:

1
fun buildCommand(init: CommandBuilder.() -> Unit) = CommandBuilder().apply(init).build()

可以看到这个函数接收的参数init是一个针对CommandBuilder的扩展函数,返回类型为Unit,即空。

这个函数的作用就是创建一个CommandBuilder的实例,并调用init初始化它的内容,最后build()得到我们需要的CommandExecutor实例。

这里的apply表示将指定的函数作用于该对象上,这里的效果基本等同于CommandBuilder().init()

既然initCommandBuilder的扩展函数,那这个函数的this也就会被赋值为上文的CommandBuilder实例,因此我们可以直接在buildCommand { ... }里使用CommandBuilder的成员实例String.invoke(即上文使用的"xxx" { ... }语法,看到这里就能明白这其实是在调用字符串的invoke运算符操作)了。

接下来我们看看核心部分,String.invoke到底定义了什么:

1
operator fun String.invoke(init: Command.() -> Unit) = Command(this).apply(init).also(commands::add)

创建一个命令(组),可以看到传递的参数还是一个init,所以依然可以使用kotlin特有的匿名函数简写。后面的代码大同小异,同样是apply(init),然后附加一个also将定义的命令加到命令列表里。

同样的,由于initCommand的扩展函数,因此也可以直接在里面使用Command类的tabCompleteexecuteusage方法,以及定义子命令的同样的String.invoke扩展函数。

最后这几个方法就很简单了,比如execute的函数签名长这样:

1
2
3
fun execute(init: CommandExecuteParams.() -> Unit) {
execute = init
}

仅仅是将Command内部的execute变量赋值为init

同样的,由于initCommandExecuteParams的扩展函数,函数内的this直接指向CommandExecuteParams实例,因此可以直接在里面使用CommandExecuteParams内定义的命令参数而无需单独传递。

总结

以上就是这个迫真命令DSL的实现原理,希望本文能多少让你了解点Kotlin的逆天特性()

文章作者: Light_Quanta
文章链接: https://lq0.tech/2024/01/13/ktplugindsl/
版权声明: 本博客所有文章除特别声明外,均采用 CC BY 4.0 许可协议。转载请注明来自 Light_Quanta's Site