Groovy DSL 设计之道
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 的设计。
作者:花花子
链接:https://juejin.cn/post/6972423148742377508