阅读 283

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 提供的这些特性:

  1. 动态加载,拼接,执行 Groovy 脚本的灵活性。( Groovy as Script )

  2. 使用分类或者 ExpandoMetaClass 在运行时为类注入方法。

  3. 利用闭包委托和 with 方法提供上下文 Context。

  4. 操作符重载。

  5. 调用方法时,对括号 () 的简化。

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") 方法,该方法调用返回同样支持调用 moveturnjump,等方法的对象实例自身 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 的文本文件,然后将 methodMissingacceptConfig 以及 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 会认为我们在访问 clearoutLine 属性 ( 但很显然并没有 ) 而报错。前文曾提到,利用 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 流,AddSub 属于中间操作 ( 或者称转换操作 ),而 outlineclear 则代表了终结操作 ( 或者称终止操作 )。不过,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


文章分类
后端
文章标签
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐