grovvy 中,Domain-Specific Languages 是什么

在Groovy中,Domain-Specific Languages(领域特定语言)通常指的是针对特定领域的编程语言或语法,旨在提供更高层次的抽象,以便更自然地表达和解决该领域中的问题。

DSL允许开发人员使用一种更接近领域概念的语言来描述问题和解决方案,从而提高代码的可读性和可维护性。

Groovy是一种灵活的动态语言,它提供了丰富的语法和特性,使得编写DSL变得相对容易。你可以使用Groovy的闭包、运算符重载、元编程等特性来创建自定义的DSL。

DSL可以针对特定的业务领域,如配置文件、测试规范、领域建模等。

Groovy的DSL支持可以通过以下方式实现:

  1. 方法调用风格:通过使用Groovy的方法调用语法,可以创建一种更接近自然语言的语法,以便于表达领域特定概念。例如,你可以编写一个DSL,用于描述数据库查询语句,使其更类似于自然语言的方式。

  2. 闭包:Groovy的闭包是强大的工具,可以用于创建DSL。你可以编写接受闭包作为参数的方法,并使用闭包中的代码来定义DSL的行为。这种方法可以使DSL代码更具可读性和表达力。

  3. 运算符重载:Groovy允许你对运算符进行重载,从而使DSL代码更具表达力。通过重载运算符,你可以改变原始语言的语义,使其更适合于特定领域。

  4. 元编程:Groovy支持元编程,允许在运行时动态地修改和扩展DSL。这使得DSL可以适应不同的场景和需求。

总的来说,Groovy中的DSL是通过利用该语言的动态性、灵活性和特定特性来创建一种更接近领域概念的编程语言或语法,从而提供更好的可读性和可维护性。

1. 命令链 Command chains

Groovy允许在顶层语句的方法调用参数周围省略括号。

“命令链”特性通过允许我们链式调用这种无需括号的方法调用,既不需要参数周围的括号,也不需要链式调用之间的点号来连接。

总体思想是,类似于a b c d这样的调用实际上等同于a(b).c(d)。这也适用于多个参数、闭包参数,甚至是命名参数。此外,这样的命令链也可以出现在赋值语句的右侧。

让我们来看一些由这种新语法支持的示例:

// equivalent to: turn(left).then(right)
turn left then right

// equivalent to: take(2.pills).of(chloroquinine).after(6.hours)
take 2.pills of chloroquinine after 6.hours

// equivalent to: paint(wall).with(red, green).and(yellow)
paint wall with red, green and yellow

// with named parameters too
// equivalent to: check(that: margarita).tastes(good)
check that: margarita tastes good

// with closures as parameters
// equivalent to: given({}).when({}).then({})
given { } when { } then { }

在链式命令中也可以使用不带参数的方法,但在这种情况下,需要使用括号:

// equivalent to: select(all).unique().from(names)
select all unique() from names

如果你的命令链包含奇数个元素,链式命令将由方法/参数组成,并以最后的属性访问结束:

// equivalent to: take(3).cookies
// and also this: take(3).getCookies()
take 3 cookies

这种命令链的方法打开了在Groovy中编写更广泛的DSL的有趣可能性。

上述示例演示了基于命令链的DSL的使用,但没有展示如何创建DSL。

有多种策略可供选择,但为了说明如何创建这样的DSL,我们将展示几个示例,首先是使用映射和闭包:

show = { println it }
square_root = { Math.sqrt(it) }

def please(action) {
  [the: { what ->
    [of: { n -> action(what(n)) }]
  }]
}

// equivalent to: please(show).the(square_root).of(100)
please show the square_root of 100
// ==> 10.0

作为第二个示例,考虑如何为简化现有API之一编写DSL。

也许你需要将此代码提供给客户、业务分析师或可能不是专业Java开发人员的测试人员。

我们将使用Google Guava库项目中的Splitter,因为它已经具有漂亮的流畅API。

下面是我们如何使用它的示例:

@Grab('com.google.guava:guava:r09')
import com.google.common.base.*
def result = Splitter.on(',').trimResults(CharMatcher.is('_' as char)).split("_a ,_b_ ,c__").iterator().toList()

对于Java开发人员来说,这段代码读起来相当不错,但如果这不是你的目标受众,或者你需要编写许多类似的语句,它可能会被认为有些冗长。

再次强调,编写DSL有很多选择。我们将使用映射(Maps)和闭包(Closures)来保持简单。

我们首先编写一个辅助方法:

@Grab('com.google.guava:guava:r09')
import com.google.common.base.*
def split(string) {
  [on: { sep ->
    [trimming: { trimChar ->
      Splitter.on(sep).trimResults(CharMatcher.is(trimChar as char)).split(string).iterator().toList()
    }]
  }]
}

现在,我们可以使用以下代码替代我们原始示例中的这一行:

def result = Splitter.on(',').trimResults(CharMatcher.is('_' as char)).split("_a ,_b_ ,c__").iterator().toList()

可以这样写:

def result = split "_a ,_b_ ,c__" on ',' trimming '_\'

2. 运算符重载

在Groovy中,各种运算符被映射为对象上的常规方法调用。

这使你可以提供自己的Java或Groovy对象,并利用运算符重载的特性。

下表描述了Groovy支持的运算符及其对应的方法:

在Groovy中,以下是支持的运算符及其对应的方法:

运算符 方法
+ plus()
- minus()
* multiply()
/ div()
% mod()
** power()
« leftShift()
» rightShift()
»> rightShiftUnsigned()
& and()
| or()
^ xor()
! not()
~ bitwiseNegate()
== equals()
!= notEquals()
> greaterThan()
< lessThan()
>= greaterThanOrEqual()
<= lessThanOrEqual()
<=> compareTo()
&& and()
|| or()
?: elvis()
++ next()
previous()
+= plusAssign()
-= minusAssign()
*= multiplyAssign()
/= divAssign()
%= modAssign()
«= leftShiftAssign()
»= rightShiftAssign()
»>= rightShiftUnsignedAssign()
&= andAssign()
|= orAssign()
^= xorAssign()

这些运算符对应的方法可用于重载,以根据对象的类型和操作进行自定义行为。

3. 脚本基类 Script base classes

3.1 Script类

Groovy脚本总是被编译为类。

例如,一个简单的脚本如下所示:

println 'Hello from Groovy'

编译后,它将生成一个扩展抽象类groovy.lang.Script的类。该类包含一个名为run的抽象方法。

当脚本被编译时,其主体将成为run方法,而脚本中的其他方法将在实现类中找到。

Script类通过Binding对象提供了与应用程序的基本集成支持,如下面的示例所示:

def binding = new Binding()             //一个binding被用于在脚本和调用类之间共享数据   
def shell = new GroovyShell(binding)    //可以使用GroovyShell和该binding
binding.setVariable('x',1)              //输入变量从调用类中设置到binding中    
binding.setVariable('y',3)
shell.evaluate 'z=2*x+y'                //然后对脚本进行求值
assert binding.getVariable('z') == 5    //z变量已经被"导出"到binding中

这是一种在调用方和脚本之间共享数据的非常实用的方法,然而在某些情况下可能不足够或不实用。

为此,Groovy允许您设置自己的基础脚本类。

基础脚本类必须扩展groovy.lang.Script,并且是单个抽象方法类型:

abstract class MyBaseClass extends Script {
    String name
    public void greet() { println "Hello, $name!" }
}

然后可以在编译器配置中声明自定义的脚本基类,例如:

def config = new CompilerConfiguration()        //创建自定义的编译器配置                          
config.scriptBaseClass = 'MyBaseClass'              //将基础脚本类设置为我们自定义的基础脚本类                    
def shell = new GroovyShell(this.class.classLoader, config)             //然后使用该配置创建一个GroovyShell
//脚本将会扩展基础脚本类,直接访问name属性和greet方法
shell.evaluate """              
    setName 'Judith'                                                    
    greet()
"""

3.2 @BaseScript注解

作为另一种选择,还可以直接在脚本中使用 @BaseScript 注解:

import groovy.transform.BaseScript

@BaseScript MyBaseClass baseScript
setName 'Judith'
greet()

其中,@BaseScript应该注解一个类型为基础脚本类的变量。

或者,您可以将基础脚本类设置为@BaseScript注解本身的成员。

@BaseScript(MyBaseClass)
import groovy.transform.BaseScript

setName 'Judith'
greet()

3.3 替代的抽象方法 Alternate abstract method

我们已经看到基础脚本类是一种需要实现run方法的单个抽象方法类型。run方法会由脚本引擎自动执行。

在某些情况下,我们可能希望有一个基类来实现run方法,但提供一个替代的抽象方法供脚本主体使用。

例如,基础脚本的run方法可能在执行run方法之前进行一些初始化操作。

可以通过以下方式实现这一点:

abstract class MyBaseClass extends Script {
    int count
    abstract void scriptBody()            //基础脚本类应该定义一个(且只能一个)抽象方法                  
    def run() {
        count++                             //run方法可以被重写,在执行脚本主体之前执行某些任务                        
        scriptBody()                        //run方法调用抽象的scriptBody方法,该方法将委托给用户脚本                    
        count                               //然后它可以返回与脚本的返回值不同的东西                                    
    }
}

如果你执行下面的代码:

def result = shell.evaluate """
    println 'Ok'
"""
assert result == 1

然后您会发现脚本被执行,但评估的结果是1,这是基类的run方法返回的结果。

如果使用parse而不是evaluate,情况会更清晰,因为它允许您在同一个脚本实例上多次执行run方法:

def script = shell.parse("println 'Ok'")
assert script.run() == 1
assert script.run() == 2

4. 为数字添加属性

在Groovy中,数值类型被视为与其他类型相等。

因此,可以通过向数字添加属性或方法来增强它们。

当处理可测量的数量时,这非常方便。有关如何在Groovy中增强现有类的详细信息,请参阅扩展模块部分或分类部分。

在Groovy中,可以使用TimeCategory来说明这一点:

use(TimeCategory)  {
    println 1.minute.from.now       //使用TimeCategory,将一个名为`minute`的属性添加到Integer类中。
    println 10.hours.ago

    def someDate = new Date()       
    println someDate - 3.months    //类似地,`months`方法返回一个`groovy.time.DatumDependentDuration`,该方法可以在计算中使用。
}

5. @DelegatesTo

5.1. 在编译时解释委托策略

@groovy.lang.DelegatesTo是一个文档和编译时注解,旨在:

为使用闭包作为参数的API提供文档

为静态类型检查器和编译器提供类型信息

Groovy语言是构建领域特定语言(DSL)的首选平台。使用闭包,可以很容易地创建自定义控制结构,也可以简单地创建构建器。

想象一下,您有以下代码:

email {
    from 'dsl-guru@mycompany.com'
    to 'john.doe@waitaminute.com'
    subject 'The pope has resigned!'
    body {
        p 'Really, the pope has resigned!'
    }
}

一种实现方法是使用构建器策略,这意味着有一个名为email的方法,接受一个闭包作为参数。

该方法可以将后续的调用委托给实现了from、to、subject和body方法的对象。

同样,body是一个接受闭包作为参数的方法,并且使用了构建器策略。

通常,实现这样的构建器的方法如下所示:

def email(Closure cl) {
    def email = new EmailSpec()
    def code = cl.rehydrate(email, this, this)
    code.resolveStrategy = Closure.DELEGATE_ONLY
    code()
}

EmailSpec类实现了fromto等方法。

通过调用rehydrate,我们创建了闭包的副本,并设置了委托对象(delegate)、所有者(owner)和当前对象(thisObject)的值。

在这里设置所有者和当前对象并不是非常重要,因为我们将使用DELEGATE_ONLY策略,该策略表示方法调用仅针对闭包的委托对象解析。

class EmailSpec {
    void from(String from) { println "From: $from"}
    void to(String... to) { println "To: $to"}
    void subject(String subject) { println "Subject: $subject"}
    void body(Closure body) {
        def bodySpec = new BodySpec()
        def code = body.rehydrate(bodySpec, this, this)
        code.resolveStrategy = Closure.DELEGATE_ONLY
        code()
    }
}

EmailSpec类本身具有一个接受闭包的body方法,该闭包被克隆并执行。

这就是我们在Groovy中称之为构建器模式。

我们展示的代码的一个问题是email方法的使用者无法获得关于在闭包内部可以调用的方法的任何信息。唯一可能的信息来源是方法的文档。

这方面有两个问题:首先,文档并不总是编写的,即使编写了文档,也并不总是可用(例如,未下载javadoc)。

其次,它对IDE没有帮助。真正有趣的是,IDE在进入闭包体后可以通过建议存在于email类上的方法来帮助开发人员。

此外,如果用户在闭包中调用了EmailSpec类未定义的方法,IDE至少应发出警告(因为很可能在运行时出现错误)。

上述代码的另一个问题是它与静态类型检查不兼容。类型检查可以让用户在编译时知道方法调用是否被授权,而不是在运行时。

但如果您尝试对此代码执行类型检查,将会遇到问题:

email {
    from 'dsl-guru@mycompany.com'
    to 'john.doe@waitaminute.com'
    subject 'The pope has resigned!'
    body {
        p 'Really, the pope has resigned!'
    }
}

然后类型检查器会知道有一个接受闭包的email方法,但它会对闭包内的每个方法调用发出警告,因为例如from不是类中定义的方法。

实际上,它在EmailSpec类中定义,它完全没有提示来帮助它知道闭包委托在运行时将是EmailSpec类型:

@groovy.transform.TypeChecked
void sendEmail() {
    email {
        from 'dsl-guru@mycompany.com'
        to 'john.doe@waitaminute.com'
        subject 'The pope has resigned!'
        body {
            p 'Really, the pope has resigned!'
        }
    }
}

编译将失败,并出现以下错误:

[Static type checking] - Cannot find matching method MyScript#from(java.lang.String). Please check if the declared type is correct and if the method exists.
 @ line 31, column 21.
                       from 'dsl-guru@mycompany.com'

5.2. @DelegatesTo

因此,Groovy 2.1引入了一个名为@DelegatesTo的新注解。

该注解的目标是解决文档问题,让您的IDE了解闭包体中预期的方法,并通过为编译器提供关于闭包体中方法调用的潜在接收者的提示来解决类型检查问题。

具体做法是给email方法的闭包参数加上注解:

def email(@DelegatesTo(EmailSpec) Closure cl) {
    def email = new EmailSpec()
    def code = cl.rehydrate(email, this, this)
    code.resolveStrategy = Closure.DELEGATE_ONLY
    code()
}

我们在这里告诉编译器(或者IDE),当使用闭包调用该方法时,闭包的委托将设置为一个类型为EmailSpec的对象。

但是仍然存在一个问题:默认的委托策略不是我们方法中使用的策略。

因此,我们将提供更多信息,并告诉编译器(或者IDE)委托策略也发生了变化:

def email(@DelegatesTo(strategy=Closure.DELEGATE_ONLY, value=EmailSpec) Closure cl) { 
    def email = new EmailSpec()
    def code = cl.rehydrate(email, this, this)
    code.resolveStrategy = Closure.DELEGATE_ONLY
    code()
}

现在,无论是IDE还是类型检查器(如果您使用了 @TypeChecked 注解),都将意识到委托对象和委托策略。

这非常好,因为它不仅可以让IDE提供智能代码补全,还可以在编译时消除仅在运行时才能确定的错误!

以下代码现在将通过编译:

参考资料

chatGPT

https://groovy-lang.org/dsls.html