译者 | 布加迪
审校 | 重楼
程序员总是在争论哪种语言是最好的。我们曾比较过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基于特定的通用编程语言(宿主语言)。也就是说,在宿主语言的标准工具的帮助下,创建允许您编写更紧凑的库。Fluent API方法就是一个例子。
内部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。
不妨开始开发一个对象模型
interface AcceptanceElementclass 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 }}
首先看一下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中,对象声明是定义单例的一种方式,单例指只有一个实例的类。因此,对象声明同时定义了类及其单个实例。
此外,为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
本文链接:http://www.28at.com/showinfo-26-57843-0.html如何使用Kotlin开发DSL?
声明:本网页内容旨在传播知识,若有侵权等问题请及时与本网联系,我们将在第一时间删除处理。邮件:2376512515@qq.com
上一篇: 线程池异常黑洞及其防范策略