9. 构建 DSL
DSL,全称为 Domain Sepcific Language,领域特定语言,它通常都是为了解决某一个问题而诞生的:比如 SQL 语句就是为了解决程序员和数据库之间的交互问题。DSL 足够精巧,富有表现力,且具备两个特点:上下文驱动,非常流畅 ( 当然,一个用起来流畅的 DSL 设计起来往往却十分复杂 )。
DSL 分为两种类型:外部 DSL 或者是内部 DSL。外部DSL是从零开发的DSL,在词法分析、解析技术、解释、编译、代码生成等方面拥有独立的设施。开发外部DSL近似于从零开始实现一种拥有独特语法和语义的全新语言。构建工具 make、语法分析器生成工具 YACC、词法分析工具 LEX 等都是常见的外部 DSL。
内部 DSL 相对来说代价要小些,因为它的构建没有脱离于宿主语言。但也正因如此,内部 DSL 的功能和语法都会受到宿主语言本身的限制。如何将内部 DSL 的语法巧妙映射到宿主语言的底层逻辑,并让内部 DSL 相较宿主语言在某方面更富有表达力是一件有趣的工作。
一般来说,使用动态语言实现内部 DSL 都会比较容易一些,这些语言均能够提供很好的元编程能力和灵活的语法,比如 Ruby,Python 等等。笔者也曾了解过 Scala 如何通过解析器组合子的形式创建内部 DSL,虽然 Scala 是一门 JVM 的静态语言,但是它自身的抽象表达能力 ( 模式匹配,隐式类,型变 ) 和几乎完全自由的操作符重载实在是太 amazing 了。相对的,使用 Scala 设计内部 DSL 难度要高上不少。
从容地设计内部 DSL 是 Groovy 的核心 Features 之一,它被标识在 Apache Groovy 的官网上:The Apache Groovy programming language (groovy-lang.org)。创建内部 DSL 不仅需要在设计上付出一些努力,还需要使用很多聪明的技巧。比如本章会综合利用 Groovy 提供的这些特性:
- 动态加载,拼接,执行 Groovy 脚本的灵活性。( Groovy as Script )
- 使用分类或者 ExpandoMetaClass 在运行时为类注入方法。
- 利用闭包委托和
with
方法提供上下文 Context。 - 操作符重载。
- 调用方法时,对括号
()
的简化。
9.1 命令链接特性
我们很久以前就注意到,在 Groovy 中调用方法可以省略掉括号。比如:
println("hello,Groovy")
println "hello,Groovy"
复制代码
这种灵活的处理方式进而引申出了 Groovy 的命令链接特性。
def move(String dir){
print "move $dir "
this
}
def turn(String dir){
print "turn $dir"
this
}
def jump(String speed,String dir){
print "jump ${dir} ${speed}"
this
}
//move("forward").turn("right").turn("right").move("back")
move "forward" turn "right" turn "right" move "back" // 1
//jump("fast","forward").move("back").move("forward")
jump "fast","forward" move "back" move "forward" // 2
复制代码
第一条语句调用没有逗号。Groovy 首先会认为我们调用了一个 move("forward")
方法,该方法调用返回同样支持调用 move
,turn
,jump
,等方法的对象实例自身 this
。随后,进一步调用它的 turn("right")
方法。以此类推,一条连贯的命令链接就出来了。
第二条语句调用多了一个逗号,其原因是:jump
方法接收两个参数,代表跳跃的速度 speed
和跳跃的方向 dir
。
9.2 利用闭包委托创建上下文
有关闭包委托,或者是
with
方法的知识回顾可以参考 如何用 Groovy 闭包优雅地 FP 编程? (juejin.cn) 。
设计上下文 ( Context ) 也是 DSL 的特点。比如:”Venti latte with two extra shots!” 。这是星巴克的 DSL,尽管我们全局都没有提到咖啡两字,但是服务员照样会为我们提供一份超大杯拿铁 —— 但是在蜜雪冰城可就不一定了。每种 DSL 都依附于各自的上下文环境,或者称上下文驱动。
下面是一个订购 Pizza 的代码:
class PizzaShop {
def setSize(String size){}
def setAddress(String addr){}
def setPayment(String cardId){}
}
def pizzaShop = new PizzaShop()
pizzaShop.setSize("large")
pizzaShop.setAddress("XXX street")
pizzaShop.setPayment("WeChat")
复制代码
由于缺少上下文,pizzaShop
引用会被反复调用。在 Groovy 中,对于这样的方法可以使用 with
进行梳理:
pizzaShop.with {
setSize "large"
setAddress "XXX street"
setPayment "WeChat"
}
复制代码
实例 pizaaShop
在此处充当了上下文,它使得代码风格变得更加紧凑了。
另一个例子,用户不想主动创建一个 PizzaShop
实例 ( 因为创建一个实例或许需要很多的额外配置,假定我们遵循 “约定大于配置” 的原则 ),他们的目的仅仅是获得一个披萨。如果希望创建一个隐式的上下文对象,不妨试着利用 Groovy 闭包的委托功能:
// 缺点是编写代码时,IntelliJ 无法对动态委托的闭包给出代码提示。
getPizza {
setSize "large"
setAddress "XXX street"
setPayment "WeChat"
}
def getPizza(Closure closure){
def pizzaShop = new PizzaShop()
closure.delegate = pizzaShop
closure.run()
}
复制代码
9.3 巧用 Groovy 脚本聚合和方法拦截
利用 Groovy DSL 的能力,我们还可以自行组织配置文件的格式,下面通过一个例子一步一步实现。每行配置需要两项:配置名和值。我们可以将它写成这样:
// 把它看作是一项配置 -> size = "large", 以此类推。
size "large"
payment "WeChat"
address "XXXStreet"
复制代码
每行配置项在 Groovy 中可以视作是调用了 k(v)
方法 ( 比如配置项中的 size "large"
相当于调用了 size("large")
)。为了避免报错,我们可能会想到提前实现和配置同名的方法:
// 该 config 没有 def 关键字,表示它是个脚本内的全局变量。
config = [:]
def size(String size){
config["size"] = size
}
// 类似的还有 payment,address ...
复制代码
假如设定的配置项非常多的话,要填充完这些方法可要好一阵时间。实际上,这对于 Groovy 来说根本没有必要。回顾前几章 MOP 的内容,我们只需要在 methodMissing
方法中将这些同名方法合成出来即可,如下面的代码块所示。同时,定义一个 acceptOrder
“上下文”,它负责遍历 config
项的内容并输出到控制台:
config = [:]
def methodMissing(String name,args){
// 拦截方法名 (代表了配置名),作为 k 存储。
// 拦截参数值 (代表了配置项,可以是一个整体数组,可以是多项参数,取决于你如何设计),作为 v 存储。
config[name] = args
}
// 充当隐式上下文的作用。
def acceptConfig(Closure closure){
// 这样,"配置文件" 中的 "方法调用" 会引导至当前脚本的 methodMissing() 方法。
closure.delegate = this
// 只有调用闭包才能使脚本利用 methodMissing 方法读取到配置。
closure()
println "加载配置:--------------------------"
config.each {
k,v ->
println("config[$k] = $v")
}
}
复制代码
在当前脚本内部,关于配置项的使用大概是这样的:
acceptConfig {
// 这一部分配置内容可以分离到另一个 PizzaShopDSL.dsl 格式的文件中去。
size "large"
addr "XXX street"
payment "WeChat"
}
复制代码
我们不希望将配置的内容硬编码到源文件内部。因此,不妨将配置项的内容分离到另一个 PizzaShopDSL.dsl
的文本文件,然后将 methodMissing
,acceptConfig
以及 config
属性分离到另一个 LoadConfig.groovy
脚本文件中去。
这样的话,如果要在外部脚本文件中读取并使用配置,就要读取这两个文本文件,然后将它们拼接为一个完整的 Groovy 脚本并执行。
String config = new File("config.dsl").text
String loadConfig = new File("LoadConfig.groovy").text
def script =
"""
${loadConfig}
acceptConfig {
${config}
}
"""
// 执行拼接好的脚本
new GroovyShell().evaluate(script)
复制代码
顺带一提,如果字符串形式的 Groovy 脚本内部使用到了 GString 表达式,比如 ${k}
,需要将它转义为 \${k}
。否则的话,Groovy 会从当前脚本中寻找 k
,这是不符合语义的。
另外,当前例子中的 LoadConfig.groovy
本质上也是文本。因此理论上也可以存储为 txt
,或者是其它文本格式,这并没有严肃的规定。注意,用于拼凑的代码块没有 package
声明。
9.4 对空括号方法的变通方案
在下面的例子中,Groovy 基于 DSL 设计了一个简单的计数器,其中用于清零的 clear
方法和用于输出到控制台的 outLine
方法不需要参数。
count = (int)0
def Add(int i){
count += i
this
}
def Sub(int i){
count -= i
this
}
def clear(){
count = 0
this
}
def outLine(){
println count
this
}
Add 1 Sub 10 clear() Add 5 outLine()
复制代码
空括号 ()
在连贯的 DSL 中显得格外扎眼。如果去掉了它们,Groovy 会认为我们在访问 clear
,outLine
属性 ( 但很显然并没有 ) 而报错。前文曾提到,利用 Groovy 的统一访问原则,只需稍加改动,就能移除掉 ()
:
count = (int)0
def Add(int i){
count += i
this
}
def Sub(int i){
count -= i
this
}
// 因为后续无法衔接其它操作,因此返回 this 也没有意义了。
def getClear(){
count = 0
}
def getOutline(){
println count
}
// -9
Add 1 Sub 10 outline
// 3
Add 12 outline
clear
outline
复制代码
而这样做的缺陷是:在 “执行” 完 clear
或是 outLine
方法之后,想要继续做 Add
或者是 Sub
操作就必须另起一行。这个计数器其实很像 Java 的 Stream 流,Add
和 Sub
属于中间操作 ( 或者称转换操作 ),而 outline
,clear
则代表了终结操作 ( 或者称终止操作 )。不过,Java 流在执行终止操作之后会关闭,但我们的计数器并不会。
一切中间操作都应当是纯函数,即返回结果只和外部输入参数有关,并仅通过返回值和外部交互,内部无任何副作用。而终止操作则相反:仅通过内部的副作用和外部交互 ( 典型的就是将结果输出到控制台,因为这相当于是做了一步 IO ),不通过外部的输入参数和返回值进行数据交互。
如果一个方法同时满足这两种特征,那么它极易容易引起混乱;反之,如果一个方法既没有参数和返回值,也没有副作用,老实说,这样的方法没有任何的意义。这么一看,似乎每一段 DSL 的语句以一个终结操作来收尾也不是一件 “难以接收的事情” 了 ( 仁者见仁智者见智 )。
9.5 构建 DSL 的其它形式
我们希望程序能够识别类似这样的一段语句:
5 days ago at 10:30
// 即使一个人不懂 Groovy 语法,他也能一下了解这段代码的意思。
// 5.days.ago.at(10:30)
复制代码
如果将这段表达式输出,那么程序就会正确地输出 5 天之前 ( 或者之后 ) 的日期,并且将时间设置在上午 10 点 30 分。很明显,我们要在既有的 Integer
类型的基础之上进行一些方法注入,因而下面给出两种实现方式:
9.6 利用分类实现 DSL
第一种是利用之前讲过的分类来实现 ( 之前的章节中,用于在有限域对某个类进行代码增强,类似于 Scala 的隐式类 ):
class DateUtil {
// days,没有实际意义,主要是为了保证上下文的语义连贯。
static def getDays(Integer self){ self }
// ago,返回 x 天前的日期
static Calendar getAgo(Integer self){
def date =Calendar.instance
date.add(Calendar.DAY_OF_MONTH,-self)
date
}
// after, 返回 x 天后的日期。
static Calendar getAfter(Integer self){
def date = Calendar.instance
date.add(Calendar.DAY_OF_MONTH,self)
date
}
// at, 可以设定具体的时间。
static Date at(Calendar self,Map<Integer,Integer> time){
assert time.size() == 1
def timeEntry = time.find {true}
self.set(Calendar.HOUR_OF_DAY,(Integer)timeEntry.key)
self.set(Calendar.MINUTE,(Integer)timeEntry.value)
self.set(Calendar.SECOND,0)
self.time
}
}
use(DateUtil){
println 5.days.ago.at(10:30)
println 10.days.after.at(13:30)
println 10.days.after.time
}
复制代码
在调用完 10.days.ago
之后,程序返回的将是一个 Calendar
类型单例。因此我们要将 at
方法注入到 Calendar
类型 ( 而不是 Integer
类型 ),并让它返回一个 Date
类型。为了能够让用户以 HH:mm
的自然写法表达时间,因此 at
方法有意被设计成了接收一个 Map。
9.7 通过方法注入实现 DSL
这段代码块表达的意思大体相同,唯一不同的一点是:通过 ExpandoMetaClass 注入的方法能够在全局生效。
Integer.metaClass {
getDays = {
-> delegate
}
getAgo = {
-> delegate
def date = Calendar.instance
date.add(Calendar.DAY_OF_MONTH,(Integer)delegate)
return date
}
}
Calendar.metaClass.at = {
Map<Integer,Integer> time ->
assert time.size() == 1
def timeEntry = time.find {true}
def t = ((Calendar)delegate)
t.set(Calendar.HOUR_OF_DAY,(int)timeEntry.key)
t.set(Calendar.MINUTE,(int)timeEntry.value)
t.set(Calendar.SECOND,0)
t.time
}
println 5.days.ago.at(14:30)
复制代码
现在,我们了解到在 Groovy 内部创建 DSL 是如此的容易。动态特性和可选类型对创建流畅的接口帮助很大;闭包委托有助于创建上下文;分类和 ExpandoMetaClass 对方法的注入和调用在这个例子中也被使用到。
至此,对 Groovy 的学习和了解告一段落,在未来,笔者还会更新如何使用 Scala 实现 DSL 的设计。