如何使用Kotlin开发DSL?

译文
开发
程序员总是在争论哪种语言是最好的。我们曾比较过C和Pascal,但时过境迁。Python与Ruby之争和Java与C#之争早已远去。每种语言有其优缺点。理想情况下,我们希望扩展语言以满足自己的需要。

译者 | 布加迪

审校 | 重楼

程序员总是在争论哪种语言是最好的。我们曾比较过CPascal,但时过境迁。Python与Ruby之争Java与C#之争远去。每种语言有其优缺点。理想情况下,我们希望扩展语言以满足自己的需要。程序员早就有这样的机会。我们知道元编程(即创建用来创建程序的程序)的不同方式。在C中,连不起眼的宏允许您小的描述生成大代码。然而,这些宏是不可靠的、有限的,表达力不强。现代语言拥有极富表现力的扩展方式其中一种语言是Kotlin

一、领域特定语言的定义

领域特定语言DSL一种专门为特定主题领域开发的语言,与JavaC#和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的其他常见例HTMLCSSSQLUMLBPMN后两使用图形符号。不仅开发人员使用DSL,测试人员和非IT专家也使用DSL

二、DSL的类型

DSL分为两种类型外部和内部。外部DSL语言有自己的语法,它们不依赖用来实现持的通用编程语言。

外部DSL的优缺点

  • 使用不同语言/现成库生成代码
  • 设置语法方面拥有更多选项
  1. 使用专门的工具ANTLRyacclex
  2. 有时很难描述语法
  3. 没有IDE支持,您需要编写插件

内部DSL基于特定的通用编程语言(宿主语言。也就是说,在宿主语言的标准工具的帮助下,创建允许您编写更紧凑的库。Fluent API方法就是一个例子。

内部DSL优缺点:

  • 使用宿主语言表达式为基础
  • 很容易将DSL嵌入到宿主语言的代码中,反之亦然
  • 不需要生成代码
  • 可以作为宿主语言的子程序进行调试
  1. 设置语法方面机会有限

三、一个真实的例子

最近,我们公司需要创建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时特别有用的特性。

对象声明

现在不妨看看实体acceptanceacceptance是一个对象。在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


责任编辑:华轩 来源: 51CTO
相关推荐

2018-04-24 15:00:59

Kotlin语言函数

2023-01-04 12:17:07

开源携程

2011-05-11 09:47:14

mobl移动web开发

2016-11-24 17:21:30

2019-10-23 14:34:15

KotlinAndroid协程

2019-04-01 14:17:36

kotlin开发Java

2009-12-29 13:56:07

DSL技术

2021-09-16 16:08:43

KotlinAndroidAOSP

2011-05-06 15:31:28

moblweb开发DSL

2023-04-09 14:49:57

开发语言Kotlin

2016-11-03 09:59:38

kotlinjavaspring

2022-01-06 09:55:19

鸿蒙HarmonyOS应用

2017-05-22 11:09:53

KotlinAndroid

2017-10-20 10:19:49

Kotlin语言陷阱

2020-03-04 11:20:22

DSL开发领域特定语言

2014-12-25 10:15:37

DockerJava

2009-12-14 18:23:38

Ruby DSL测试

2009-12-14 18:30:59

Ruby DSL特点

2009-12-14 18:14:27

Ruby DSL

2020-04-20 12:45:20

编程语言JavaScriptKotlin
点赞
收藏

51CTO技术栈公众号