程序员们一直在争论哪种语言最好。曾经我们比较过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 的优缺点:
🟢 不同语言/现成库的代码生成
🟢 设置语法的更多选项
🔴专用工具的使用:ANTLR、yacc、lex
🔴 有时很难描述语法
🔴没有IDE支持,需要自己编写插件
内部 DSL基于特定的通用编程语言(主机语言)。也就是说,在宿主语言的标准工具的帮助下,创建的库可以让您编写得更紧凑。作为示例,请考虑 Fluent API 方法。
内部 DSL 的优缺点:
🟢 以宿主语言的表达方式为基础
🟢 将 DSL 嵌入到宿主语言的代码中很容易,反之亦然
🟢 不需要代码生成
🟢 可以作为宿主语言的子程序进行调试
🔴 设置语法的可能性有限
最近,我们公司面临创建 DSL 的需求。我们的产品实现了购买受理的功能。该模块是BPM(业务流程管理)的微型引擎。业务流程通常以图形方式表示。例如,下面的 BPMN 表示法显示了一个由执行任务 1,然后并行执行任务 2 和任务 3 组成的流程。
对我们来说,能够以编程方式创建业务流程非常重要,包括动态构建路线、设置审批阶段的执行者、设置阶段执行的截止日期等。为此,我们首先尝试使用 Fluent API 来解决这个问题方法。
然后我们得出的结论是,使用 Fluent API 设置接受路由仍然很麻烦,我们的团队考虑了创建自己的 DSL 的选项。我们研究了外部 DSL 和基于 Kotlin 的内部 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 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 } }
首先,让我们看一下AcceptanceContext
类。它旨在存储路线元素的集合,并用于表示整个图以及parallel
块。
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 { [email protected] { [email protected] = HEAD_OF_DEPARTMENT [email protected] = days(7) [email protected] shouldBe signed [email protected] { executor = FINANCE_DEPARTMENT } } }
现在您可以看到[email protected]
被调用了两次。特别是对于这种情况,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' can't be called in this context by implicit receiver. Use the explicit one if necessary
以下是有关本文中考虑的语言功能的 Kotlin 官方文档的链接:
特定于领域的语言通过提供专门且富有表现力的方式来建模和解决特定领域内的问题,为提高生产力、减少错误和改善协作提供了强大的手段。 Kotlin 是一种现代编程语言,具有许多功能和语法糖,因此非常适合作为内部 DSL 的宿主语言。