译者 | 布加迪
审校 | 重楼
程序员总是在争论哪种语言是最好的。我们曾比较过C和Pascal,但时过境迁。Python与Ruby之争和Java与C#之争早已远去。每种语言有其优缺点。理想情况下,我们希望扩展语言以满足自己的需要。程序员早就有这样的机会。我们知道元编程(即创建用来创建程序的程序)的不同方式。在C中,连不起眼的宏都允许您用小的描述生成大段代码。然而,这些宏是不可靠的、有限的,表达力不强。现代语言拥有极富表现力的扩展方式,其中一种语言是Kotlin。
一、领域特定语言的定义
领域特定语言(DSL)是一种专门为特定主题领域开发的语言,与Java、C#和C++等通用语言不同。这意味着它描述主题领域的任务更容易、更方便、更富有表现力,但同时它解决日常任务也不方便、不实用,即它不是一种通用语言。作为DSL的一个例子,您可以使用正则表达式语言。正则表达式的主题领域是字符串格式。
要检查字符串是否符合格式,只需使用支持正则表达式的库就够了:
private boolean isIdentifierOrInteger(String s) {
return s.matches("^\\s*(\\w+\\d*|\\d+)$");
}
如果您检查字符串是否符合通用语言(比如Java)中的指定格式,您将得到以下代码:
private boolean isIdentifierOrInteger(String s) {
int index = 0;
while (index < s.length() && isSpaceChar(s.charAt(index))) {
index++;
}
if (index == s.length()) {
return false;
}
if (isLetter(s.charAt(index))) {
index++;
while (index < s.length() && isLetter(s.charAt(index)))
index++;
while (index < s.length() && isDigit(s.charAt(index)))
index++;
} else if (Character.isDigit(s.charAt(index))) {
while (index < s.length() && isDigit(s.charAt(index)))
index++;
}
return index == s.length();
}
上面的代码比正则表达式更难阅读,更容易出错,更难以变更。
DSL的其他常见例子有HTML、CSS、SQL、UML和BPMN(后两种使用图形符号)。不仅开发人员使用DSL,测试人员和非IT专家也使用DSL。
二、DSL的类型
DSL分为两种类型:外部和内部。外部DSL语言有自己的语法,它们不依赖用来实现支持的通用编程语言。
外部DSL的优缺点:
- 使用不同语言/现成库生成代码
- 设置语法方面拥有更多的选项
- 使用专门的工具:ANTLR、yacc和lex
- 有时很难描述语法
- 没有IDE支持,您需要编写插件
内部DSL基于特定的通用编程语言(宿主语言)。也就是说,在宿主语言的标准工具的帮助下,创建允许您编写更紧凑的库。Fluent API方法就是一个例子。
内部DSL的优缺点:
- 使用宿主语言的表达式作为基础
- 很容易将DSL嵌入到宿主语言的代码中,反之亦然
- 不需要生成代码
- 可以作为宿主语言中的子程序进行调试
- 设置语法方面机会有限
三、一个真实的例子
最近,我们公司需要创建DSL。我们的产品已经实现了购买验收功能。该模块是BPM(业务流程管理)的一个小型引擎。业务流程常以图形方式表示。比如说,下面的BPMN标注显示了一个由执行任务1,然后并行执行任务2和任务3组成的流程。
能够以编程方式创建业务流程对我们来说非常重要,包括动态构建路径、为审批阶段设置执行者、为阶段执行设置截止日期等。为此,我们先尝试使用Fluent API方法来解决这个问题。
然后我们得出了结论:使用Fluent API设置验收路径仍然很麻烦,我们的团队考虑了创建自己的DSL这种方案。我们研究了基于Kotlin外部DSL和内部DSL上的验收路径是什么样子(因为我们的产品代码是用Java和Kotlin编写的)。
外部DSL:
Acceptance
addStep
executor: HEAD_OF_DEPARTMENT
duration: 7 days
protocol should be formed
parallel
addStep
executor: FINANCE_DEPARTMENT or CTO or CEO
condition: ${!request.isInternal}
duration: 7 work days after start date
addStep
executor: CTO
dueDate: 2022-12-08 08:00 PST
can change
addStep
executor: SECRETARY
protocol should be signed
内部DSL:
acceptance {
addStep {
executor = HEAD_OF_DEPARTMENT
duration = days(7)
protocol shouldBe formed
}
parallel {
addStep {
executor = FINANCE_DEPARTMENT or CTO or CEO
condition = !request.isInternal
duration = startDate() + workDays(7)
}
addStep {
executor = CTO
dueDate = "2022-12-08 08:00" timezone PST
+canChange
}
}
addStep {
executor = SECRETARY
protocol shouldBe signed
}
}
除了花括号外,这两个选项几乎一样。因此,决定不浪费时间和精力开发外部DSL,而是创建内部DSL。
四、实施DSL的基本结构
不妨开始开发一个对象模型
interface AcceptanceElement
class StepContext : AcceptanceElement {
lateinit var executor: ExecutorCondition
var duration: Duration? = null
var dueDate: ZonedDateTime? = null
val protocol = Protocol()
var condition = true
var canChange = ChangePermission()
}
class AcceptanceContext : AcceptanceElement {
val elements = mutableListOf<AcceptanceElement>()
fun addStep(init: StepContext.() -> Unit) {
elements += StepContext().apply(init)
}
fun parallel(init: AcceptanceContext.() -> Unit) {
elements += AcceptanceContext().apply(init)
}
}
object acceptance {
operator fun invoke(init: AcceptanceContext.() -> Unit):
AcceptanceContext {
val acceptanceContext = AcceptanceContext()
acceptanceContext.init()
return acceptanceContext
}
}
Lambdas
首先看一下AcceptanceContext类。它旨在用于存储路径元素的集合,并用于表示整个图以及parallel-blocks。addStep和parallel方法接受带有接收者的lambda作为参数。
带有接收者的lambda是定义可以访问特定接收者对象的lambda表达式的一种方式。在函数主体中,传递给调用的接收者对象变成了隐式的this,这样您就可以在没有任何附加限定符的情况下访问该接收者对象的成员,或者使用this表达式访问接收者对象。
此外,如果方法调用的最后一个参数是lambda,可以将lambda放在括号之外。这就是为什么在DSL中我们可以按如下方式编写代码:
parallel {
addStep {
executor = FINANCE_DEPARTMENT
...
}
addStep {
executor = CTO
...
}
}
这相当于没有语法糖的代码:
parallel({
this.addStep({
this.executor = FINANCE_DEPARTMENT
...
})
this.addStep({
this.executor = CTO
...
})
})
带接收者的Lambda和括号外的Lambda是Kotlin在处理DSL时特别有用的特性。
对象声明
现在不妨看看实体acceptance。acceptance是一个对象。在Kotlin中,对象声明是定义单例的一种方式,单例指只有一个实例的类。因此,对象声明同时定义了类及其单个实例。
“invoke”操作符重载
此外,为accreditation对象重载了invoke操作符。invoke操作符是一个可以在类中定义的特殊函数。当您像调用函数一样调用类的实例时,调用invoke操作符函数。这允许您将对象作为函数来处理,并以类似函数的方式调用它们。
注意,invoke方法的参数也是带接收者的lambda。现在我们可以定义验收路径:
val acceptanceRoute = acceptance {
addStep {
executor = HEAD_OF_DEPARTMENT
...
}
parallel {
addStep {
executor = FINANCE_DEPARTMENT
...
}
addStep {
executor = CTO
...
}
}
addStep {
executor = SECRETARY
...
}
}
然后处理它
val headOfDepartmentStep = acceptanceRoute.elements[0] as StepContext
val parallelBlock = acceptanceRoute.elements[1] as AcceptanceContext
val ctoStep = parallelBlock.elements[1] as StepContext
五、添加细节
中缀函数
看看这段代码:
addStep {
executor = FINANCE_DEPARTMENT or CTO or CEO
...
}
我们可以按以下方式实现这个:
enum class ExecutorConditionType {
EQUALS, OR
}
data class ExecutorCondition(
private val name: String,
private val values: Set<ExecutorCondition>,
private val type: ExecutorConditionType,
) {
infix fun or(another: ExecutorCondition) =
ExecutorCondition("or", setOf(this, another),
ExecutorConditionType.OR)
}
val HEAD_OF_DEPARTMENT =
ExecutorCondition("HEAD_OF_DEPARTMENT", setOf(),
ExecutorConditionType.EQUALS)
val FINANCE_DEPARTMENT =
ExecutorCondition("FINANCE_DEPARTMENT", setOf(),
ExecutorConditionType.EQUALS)
val CHIEF = ExecutorCondition("CHIEF", setOf(),
ExecutorConditionType.EQUALS)
val CTO = ExecutorCondition("CTO", setOf(), ExecutorConditionType.EQUALS)
val SECRETARY =
ExecutorCondition("SECRETARY", setOf(), ExecutorConditionType.EQUALS)
ExecutorCondition类允许我们设置几个可能的任务执行器。在ExecutorCondition中定义了中缀函数or。中缀函数是一种特殊的函数,允许您使用更自然的中缀符号来调用它。
如果不使用语言的这个特性,我们将不得不这样写:
addStep {
executor = FINANCE_DEPARTMENT.or(CTO).or(CEO)
...
}
中缀函数还用于设置协议的所需状态和时区时间。
enum class ProtocolState {
formed, signed
}
class Protocol {
var state: ProtocolState? = null
infix fun shouldBe(state: ProtocolState) {
this.state = state
}
}
enum class TimeZone {
...
PST,
...
}
infix fun String.timezone(tz: TimeZone): ZonedDateTime {
val format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm z")
return ZonedDateTime.parse("$this $tz", format)
}
扩展函数
String.timezone是一个扩展函数。在Kotlin中,扩展函数允许您向现有类添加新函数,而无需修改它们的源代码。当您想要增强无法控制的类的功能时,比如来自标准库或外部库的类,这项特性特别有用。
DSL中的用法:
addStep {
...
protocol shouldBe formed
dueDate = "2022-12-08 08:00" timezone PST
...
}
这里的“2022-12-08 08:00”是接收者对象,针对它调用扩展函数timezone,而PST是参数。使用this关键字访问接收者对象。
操作符重载
我们在DSL中使用的下一个Kotlin特性是操作符重载。我们已经考虑了invoke操作符的重载。在Kotlin中,您可以重载其他操作符,包括算术操作符。
addStep {
...
+canChange
}
这里,一元操作符+被重载。下面是实现这个重载的代码:
class StepContext : AcceptanceElement {
...
var canChange = ChangePermission()
}
data class ChangePermission(
var canChange: Boolean = true,
) {
operator fun unaryPlus() {
canChange = true
}
operator fun unaryMinus() {
canChange = false
}
}
结语
现在我们可以描述DSL上的验收路径。然而,应该保护DSL用户避免可能的错误。比如在当前版本中,以下代码是可以接受的:
val acceptanceRoute = acceptance {
addStep {
executor = HEAD_OF_DEPARTMENT
duration = days(7)
protocol shouldBe signed
addStep {
executor = FINANCE_DEPARTMENT
}
}
}
addStep中的addStep看起来很奇怪,是不是?不妨弄清楚为什么这段代码成功编译而没有出现任何错误。如上所述,acceptance#invoke和AcceptanceContext#addStep方法接受带有接收者的lambda作为参数,而接收者对象可以通过this关键字来访问。所以我们可以像这样重写前面的代码:
val acceptanceRoute = acceptance {
this@acceptance.addStep {
this@addStep.executor = HEAD_OF_DEPARTMENT
this@addStep.duration = days(7)
this@addStep.protocol shouldBe signed
this@acceptance.addStep {
executor = FINANCE_DEPARTMENT
}
}
}
现在您可以看到this@acceptance.addStep两次都被调用了。特别是对于这种情况,Kotlin有一个DslMarker注释。您可以使用@DslMarker来定义自定义注释。用相同此类注释标记的接收者无法在对方的内部被访问。
@DslMarker
annotation class AcceptanceDslMarker
@AcceptanceDslMarker
class AcceptanceContext : AcceptanceElement {
...
}
@AcceptanceDslMarker
class StepContext : AcceptanceElement {
...
}
现在
val acceptanceRoute = acceptance {
addStep {
...
addStep {
...
}
}
}
由于错误'fun addStep(init: StepContext.() -> Unit): Unit'无法通过隐式接收者在此上下文中调用,上面这段代码无法编译。必要时使用显式接收者。
原文标题:How to Develop a DSL in Kotlin,作者:Fedor Yaremenko