1. 命令链

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,我们将展示几个示例 - 首先使用映射和闭包

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

作为第二个示例,考虑如何编写一个 DSL 来简化您现有的 API 之一。也许您需要将此代码放在客户、业务分析师或测试人员面前,他们可能不是硬核的 Java 开发人员。我们将使用 Google Guava 库 项目中的 Splitter,因为它已经有一个不错的 Fluent 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 也有很多选择。我们将使用映射和闭包保持简单。我们将首先编写一个辅助方法

@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 中支持的运算符及其映射到的方法。

运算符 方法

a + b

a.plus(b)

a - b

a.minus(b)

a * b

a.multiply(b)

a ** b

a.power(b)

a / b

a.div(b)

a % b

a.mod(b)

a | b

a.or(b)

a & b

a.and(b)

a ^ b

a.xor(b)

a++++a

a.next()

a----a

a.previous()

a[b]

a.getAt(b)

a[b] = c

a.putAt(b, c)

a << b

a.leftShift(b)

a >> b

a.rightShift(b)

a >>> b

a.rightShiftUnsigned(b)

switch(a) { case(b) : }

b.isCase(a)

if(a)

a.asBoolean()

~a

a.bitwiseNegate()

-a

a.negative()

+a

a.positive()

a as b

a.asType(b)

a == b

a.equals(b)

a != b

! a.equals(b)

a <=> b

a.compareTo(b)

a > b

a.compareTo(b) > 0

a >= b

a.compareTo(b) >= 0

a \< b

a.compareTo(b) < 0

a <= b

a.compareTo(b) <= 0

3. 脚本基类

3.1. Script 类

Groovy 脚本始终编译为类。例如,像

println 'Hello from Groovy'

这样的简单脚本编译为扩展抽象 groovy.lang.Script 类的类。此类包含一个名为 run 的单个抽象方法。当脚本编译时,其主体将成为 run 方法,而脚本中找到的其他方法将出现在实现类中。Script 类通过 Binding 对象提供了用于与您的应用程序集成的基本支持,如以下示例所示

def binding = new Binding()             (1)
def shell = new GroovyShell(binding)    (2)
binding.setVariable('x',1)              (3)
binding.setVariable('y',3)
shell.evaluate 'z=2*x+y'                (4)
assert binding.getVariable('z') == 5    (5)
1 绑定用于在脚本和调用类之间共享数据
2 可以使用此绑定创建 GroovyShell
3 输入变量从调用类中的绑定中设置
4 然后评估脚本
5 z 变量已“导出”到绑定中

这是在调用者和脚本之间共享数据的一种非常实用的方法,但是它在某些情况下可能不足或不切实际。为此,Groovy 允许您设置自己的脚本基类。脚本基类必须扩展 groovy.lang.Script 并且是单个抽象方法类型

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

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

def config = new CompilerConfiguration()                                (1)
config.scriptBaseClass = 'MyBaseClass'                                  (2)
def shell = new GroovyShell(this.class.classLoader, config)             (3)
shell.evaluate """
    setName 'Judith'                                                    (4)
    greet()
"""
1 创建自定义编译器配置
2 将脚本基类设置为我们的自定义脚本基类
3 然后使用该配置创建 GroovyShell
4 然后,脚本将扩展脚本基类,从而直接访问 name 属性和 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. 替代抽象方法

我们已经看到,脚本基类是一种单一抽象方法类型,需要实现 run 方法。run 方法由脚本引擎自动执行。在某些情况下,可能有趣的是有一个实现 run 方法的基类,但提供一个替代的抽象方法来用于脚本主体。例如,脚本基类 run 方法可能在执行 run 方法之前执行一些初始化。可以通过以下方式实现这一点

abstract class MyBaseClass extends Script {
    int count
    abstract void scriptBody()                              (1)
    def run() {
        count++                                             (2)
        scriptBody()                                        (3)
        count                                               (4)
    }
}
1 脚本基类应该定义一个(且仅一个)抽象方法
2 可以覆盖 run 方法,并在执行脚本主体之前执行任务
3 run 调用抽象 scriptBody 方法,该方法将委托给用户脚本
4 然后它可以返回与脚本中的值不同的内容

如果执行此代码

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       (1)
    println 10.hours.ago

    def someDate = new Date()       (2)
    println someDate - 3.months
}
1 使用 TimeCategory,一个名为 minute 的属性被添加到 Integer 类中
2 类似地,months 方法返回一个 groovy.time.DatumDependentDuration,它可以在演算中使用

类别是词汇范围绑定,使它们非常适合内部 DSL。

5. @DelegatesTo

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

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

  • 记录使用闭包作为参数的 API

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

Groovy 语言是构建 DSL 的首选平台。使用闭包,很容易创建自定义控制结构,就像创建构建器一样简单。假设您有以下代码

email {
    from '[email protected]'
    to '[email protected]'
    subject 'The pope has resigned!'
    body {
        p 'Really, the pope has resigned!'
    }
}

实现此的一种方法是使用构建器策略,这意味着一个名为 email 的方法,它接受闭包作为参数。该方法可能会将后续调用委托给实现了 fromtosubjectbody 方法的对象。同样,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,我们创建了闭包的副本,并为其设置了 delegateownerthisObject 值。在此处设置所有者和 this 对象并不十分重要,因为我们将使用 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 '[email protected]'
    to '[email protected]'
    subject 'The pope has resigned!'
    body {
        p 'Really, the pope has resigned!'
    }
}

那么类型检查器将知道存在一个接受 Closureemail 方法,但它会对闭包内部的每个方法调用进行抱怨,因为例如 from 不是类中定义的方法。实际上,它是在 EmailSpec 类中定义的,它没有任何提示来帮助它知道闭包委托将在运行时是 EmailSpec 类型。

@groovy.transform.TypeChecked
void sendEmail() {
    email {
        from '[email protected]'
        to '[email protected]'
        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 '[email protected]'

5.2. @DelegatesTo

出于这些原因,Groovy 2.1 引入了一个名为 @DelegatesTo 的新注解。该注解的目的是解决文档问题,这将让你的 IDE 知道闭包体中预期的方法,它还将解决类型检查问题,通过向编译器提供关于闭包体中方法调用的潜在接收者的提示。

想法是对 email 方法的 Closure 参数进行注解

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

我们在这里所做的是告诉编译器(或 IDE),当方法被调用时使用闭包,该闭包的委托将被设置为 email 类型的对象。但仍然存在一个问题:默认的委托策略不是我们方法中使用的策略。因此,我们将提供更多信息并告诉编译器(或 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 提供智能代码补全,但也将在编译时消除只因为程序的行为通常只在运行时才知晓而存在的错误!

以下代码现在将通过编译

@TypeChecked
void doEmail() {
    email {
        from '[email protected]'
        to '[email protected]'
        subject 'The pope has resigned!'
        body {
            p 'Really, the pope has resigned!'
        }
    }
}

5.3. DelegatesTo 模式

@DelegatesTo 支持多种模式,我们将在本节中用示例进行描述。

5.3.1. 简单委托

在这种模式下,唯一必需的参数是 value,它表示我们委托调用的类。仅此而已。我们告诉编译器,委托的类型将始终是 @DelegatesTo 文档中所记录的类型(注意,它可以是子类,但如果是,子类定义的方法将对类型检查器不可见)。

void body(@DelegatesTo(BodySpec) Closure cl) {
    // ...
}

5.3.2. 委托策略

在这种模式下,你必须指定委托类和委托策略。如果闭包没有使用默认的委托策略(即 Closure.OWNER_FIRST),则必须使用此模式。

void body(@DelegatesTo(strategy=Closure.DELEGATE_ONLY, value=BodySpec) Closure cl) {
    // ...
}

5.3.3. 委托到参数

在这种变体中,我们将告诉编译器我们正在委托给方法的另一个参数。请看以下代码

def exec(Object target, Closure code) {
   def clone = code.rehydrate(target, this, this)
   clone()
}

在这里,将使用的委托不是在 exec 方法内部创建的。实际上,我们获取方法的一个参数并委托给它。用法可能如下所示

def email = new Email()
exec(email) {
   from '...'
   to '...'
   send()
}

每个方法调用都委托给 email 参数。这是一种广泛使用的模式,也受 @DelegatesTo 使用配套注解的支持

def exec(@DelegatesTo.Target Object target, @DelegatesTo Closure code) {
   def clone = code.rehydrate(target, this, this)
   clone()
}

一个闭包使用 @DelegatesTo 进行注解,但这次没有指定任何类。相反,我们使用 @DelegatesTo.Target 对另一个参数进行注解。然后,委托的类型在编译时确定。有人可能会认为我们正在使用参数类型,在本例中是 Object,但事实并非如此。请看这段代码

class Greeter {
   void sayHello() { println 'Hello' }
}
def greeter = new Greeter()
exec(greeter) {
   sayHello()
}

请记住,这无需使用 @DelegatesTo 进行注解即可开箱即用。但是,为了让 IDE 了解委托类型,或者让类型检查器了解它,我们需要添加 @DelegatesTo。在这种情况下,它将知道 Greeter 变量是 Greeter 类型,因此它不会在 sayHello 方法上报告错误,即使 exec 方法没有明确地将目标定义为 Greeter 类型。这是一个非常强大的功能,因为它可以防止你为不同的接收类型编写多个相同 exec 方法的版本!

在这种模式下,@DelegatesTo 注解也支持我们上面描述的 strategy 参数。

5.3.4. 多个闭包

在前面的示例中,exec 方法只接受一个闭包,但你的方法可能接受多个闭包

void fooBarBaz(Closure foo, Closure bar, Closure baz) {
    ...
}

那么,你就可以用 @DelegatesTo 对每个闭包进行注解

class Foo { void foo(String msg) { println "Foo ${msg}!" } }
class Bar { void bar(int x) { println "Bar ${x}!" } }
class Baz { void baz(Date d) { println "Baz ${d}!" } }

void fooBarBaz(@DelegatesTo(Foo) Closure foo, @DelegatesTo(Bar) Closure bar, @DelegatesTo(Baz) Closure baz) {
   ...
}

但更重要的是,如果你有多个闭包和多个参数,你可以使用多个目标

void fooBarBaz(
    @DelegatesTo.Target('foo') foo,
    @DelegatesTo.Target('bar') bar,
    @DelegatesTo.Target('baz') baz,

    @DelegatesTo(target='foo') Closure cl1,
    @DelegatesTo(target='bar') Closure cl2,
    @DelegatesTo(target='baz') Closure cl3) {
    cl1.rehydrate(foo, this, this).call()
    cl2.rehydrate(bar, this, this).call()
    cl3.rehydrate(baz, this, this).call()
}

def a = new Foo()
def b = new Bar()
def c = new Baz()
fooBarBaz(
    a, b, c,
    { foo('Hello') },
    { bar(123) },
    { baz(new Date()) }
)
此时,你可能想知道为什么我们不使用参数名称作为引用。原因是该信息(参数名称)并不总是可用(它只是一种调试信息),因此这是 JVM 的一个限制。

5.3.5. 委托给泛型类型

在某些情况下,向 IDE 或编译器指示委托类型将不是参数而是泛型类型很有意义。想象一下,一个在元素列表上运行的配置器

public <T> void configure(List<T> elements, Closure configuration) {
   elements.each { e->
      def clone = configuration.rehydrate(e, this, this)
      clone.resolveStrategy = Closure.DELEGATE_FIRST
      clone.call()
   }
}

然后,可以使用任何类似的列表调用此方法

@groovy.transform.ToString
class Realm {
   String name
}
List<Realm> list = []
3.times { list << new Realm() }
configure(list) {
   name = 'My Realm'
}
assert list.every { it.name == 'My Realm' }

为了让类型检查器和 IDE 知道 configure 方法对列表中的每个元素调用闭包,你需要以不同的方式使用 @DelegatesTo

public <T> void configure(
    @DelegatesTo.Target List<T> elements,
    @DelegatesTo(strategy=Closure.DELEGATE_FIRST, genericTypeIndex=0) Closure configuration) {
   def clone = configuration.rehydrate(e, this, this)
   clone.resolveStrategy = Closure.DELEGATE_FIRST
   clone.call()
}

@DelegatesTo 带有一个可选的 genericTypeIndex 参数,它告诉将用作委托类型的泛型类型的索引。这必须与 @DelegatesTo.Target 结合使用,索引从 0 开始。在上面的示例中,这意味着委托类型针对 List<T> 解析,并且由于索引 0 处的泛型类型是 T 并推断为 Realm,因此类型检查器推断委托类型将是 Realm 类型。

我们使用 genericTypeIndex 而不是占位符 (T),这是因为 JVM 的限制。

5.3.6. 委托给任意类型

可能以上所有选项都无法代表你想要委托的类型。例如,让我们定义一个使用对象进行参数化的映射器类,并定义一个返回另一种类型对象的 map 方法

class Mapper<T,U> {                             (1)
    final T value                               (2)
    Mapper(T value) { this.value = value }
    U map(Closure<U> producer) {                (3)
        producer.delegate = value
        producer()
    }
}
1 映射器类接受两个泛型类型参数:源类型和目标类型
2 源对象存储在一个 final 字段中
3 map 方法要求将源对象转换为目标对象

正如你所看到的,map 的方法签名没有提供关于闭包将操作哪个对象的任何信息。阅读方法体,我们知道它将是 value,它的类型是 T,但 T 在方法签名中没有找到,因此我们遇到了一个所有可用 @DelegatesTo 选项都不合适的用例。例如,如果我们尝试静态编译这段代码

def mapper = new Mapper<String,Integer>('Hello')
assert mapper.map { length() } == 5

那么编译器将失败,并出现以下错误

Static type checking] - Cannot find matching method TestScript0#length()

在这种情况下,你可以使用 @DelegatesTo 注解的 type 成员来引用 T 作为类型标记

class Mapper<T,U> {
    final T value
    Mapper(T value) { this.value = value }
    U map(@DelegatesTo(type="T") Closure<U> producer) {  (1)
        producer.delegate = value
        producer()
    }
}
1 @DelegatesTo 注解引用了一个不在方法签名中找到的泛型类型

注意,你并不局限于泛型类型标记。type 成员可用于表示复杂类型,例如 List<T>Map<T,List<U>>。你应该在最后不得已才使用它的原因是,类型只在类型检查器找到 @DelegatesTo 的用法时才会被检查,而不是在注解方法本身被编译时被检查。这意味着类型安全只在调用点得到保证。此外,编译将变慢(尽管对于大多数情况来说可能不会察觉)。

6. 编译定制器

6.1. 简介

无论你是使用 groovyc 编译类还是使用 GroovyShell(例如)执行脚本,在幕后都会使用一个 编译器配置。该配置保存着诸如源编码或类路径之类的信息,但它也可以用于执行更多操作,例如默认情况下添加导入、透明地应用 AST 变换或禁用全局 AST 变换。

编译定制器的目标是简化这些常见任务的实现。为此,CompilerConfiguration 类是入口点。一般模式始终基于以下代码

import org.codehaus.groovy.control.CompilerConfiguration
// create a configuration
def config = new CompilerConfiguration()
// tweak the configuration
config.addCompilationCustomizers(...)
// run your script
def shell = new GroovyShell(config)
shell.evaluate(script)

编译定制器必须扩展 org.codehaus.groovy.control.customizers.CompilationCustomizer 类。定制器工作

  • 在特定编译阶段

  • 在被编译的每个类节点上

你可以实现自己的编译定制器,但 Groovy 包含了一些最常见的操作。

6.2. 导入定制器

使用此编译定制器,你的代码将透明地添加导入。这对实现 DSL 的脚本特别有用,因为在 DSL 中,你希望避免用户必须编写导入语句。导入定制器将让你添加 Groovy 语言允许的所有导入变体,即

  • 类导入,可选别名

  • 星号导入

  • 静态导入,可选别名

  • 静态星号导入

import org.codehaus.groovy.control.customizers.ImportCustomizer

def icz = new ImportCustomizer()
// "normal" import
icz.addImports('java.util.concurrent.atomic.AtomicInteger', 'java.util.concurrent.ConcurrentHashMap')
// "aliases" import
icz.addImport('CHM', 'java.util.concurrent.ConcurrentHashMap')
// "static" import
icz.addStaticImport('java.lang.Math', 'PI') // import static java.lang.Math.PI
// "aliased static" import
icz.addStaticImport('pi', 'java.lang.Math', 'PI') // import static java.lang.Math.PI as pi
// "star" import
icz.addStarImports 'java.util.concurrent' // import java.util.concurrent.*
// "static star" import
icz.addStaticStars 'java.lang.Math' // import static java.lang.Math.*

所有快捷方式的详细说明可以在 org.codehaus.groovy.control.customizers.ImportCustomizer 中找到

6.3. AST 变换定制器

AST 变换定制器旨在透明地应用 AST 变换。与在类路径上找到变换后就会对每个被编译类应用的全局 AST 变换(这有缺点,例如会增加编译时间或由于在不应应用变换的地方应用变换而导致副作用)不同,定制器将让你有选择地仅对特定脚本或类应用变换。

例如,假设您希望在脚本中使用 @Log。问题是 @Log 通常应用于类节点,而脚本根据定义不需要类节点。但从实现角度来看,脚本是类,只是您不能使用 @Log 注释此隐式类节点。使用 AST 自定义器,您可以找到一种解决方法

import org.codehaus.groovy.control.customizers.ASTTransformationCustomizer
import groovy.util.logging.Log

def acz = new ASTTransformationCustomizer(Log)
config.addCompilationCustomizers(acz)

就这样!在内部, @Log AST 转换将应用于编译单元中的每个类节点。这意味着它将应用于脚本,但也应用于脚本中定义的类。

如果使用的 AST 转换接受参数,则也可以在构造函数中使用参数

def acz = new ASTTransformationCustomizer(Log, value: 'LOGGER')
// use name 'LOGGER' instead of the default 'log'
config.addCompilationCustomizers(acz)

由于 AST 转换自定义器使用对象而不是 AST 节点,因此并非所有值都可以转换为 AST 转换参数。例如,原始类型将转换为 ConstantExpression(即 LOGGER 将转换为 new ConstantExpression('LOGGER'),但如果您的 AST 转换使用闭包作为参数,则必须为其提供一个 ClosureExpression,如下例所示

def configuration = new CompilerConfiguration()
def expression = new AstBuilder().buildFromCode(CompilePhase.CONVERSION) { -> true }.expression[0]
def customizer = new ASTTransformationCustomizer(ConditionalInterrupt, value: expression, thrown: SecurityException)
configuration.addCompilationCustomizers(customizer)
def shell = new GroovyShell(configuration)
shouldFail(SecurityException) {
    shell.evaluate("""
        // equivalent to adding @ConditionalInterrupt(value={true}, thrown: SecurityException)
        class MyClass {
            void doIt() { }
        }
        new MyClass().doIt()
    """)
}

6.4. 安全 AST 自定义器

此自定义器将允许 DSL 开发人员限制语言的**语法**,例如,防止用户使用特定构造。它只在这一方面是“安全的”,即限制 DSL 中允许的构造。它**不**替代安全管理器,安全管理器可能作为整体安全性的一个正交方面而被额外需要。它存在的唯一原因是限制语言的表现力。此自定义器仅在 AST(抽象语法树)级别起作用,在运行时不起作用!乍一看可能很奇怪,但如果您将 Groovy 视为构建 DSL 的平台,就会更有意义。您可能不希望用户拥有完整的语言。在下面的示例中,我们将使用一个仅允许算术运算的语言示例来演示它,但此自定义器允许您

  • 允许/禁止创建闭包

  • 允许/禁止导入

  • 允许/禁止包定义

  • 允许/禁止定义方法

  • 限制方法调用的接收者

  • 限制用户可以使用 AST 表达式的类型

  • 限制用户可以使用的标记(语法方面)

  • 限制可以在代码中使用的常量的类型

对于所有这些功能,安全 AST 自定义器使用允许列表(允许的元素列表)**或**禁止列表(不允许的元素列表)。对于每种类型的功能(导入、标记等),您可以选择使用允许列表或禁止列表,但可以将禁止/允许列表混合使用以用于不同的功能。通常,您会选择允许列表(只允许列出的构造,不允许其他所有构造)。

import org.codehaus.groovy.control.customizers.SecureASTCustomizer
import static org.codehaus.groovy.syntax.Types.* (1)

def scz = new SecureASTCustomizer()
scz.with {
    closuresAllowed = false // user will not be able to write closures
    methodDefinitionAllowed = false // user will not be able to define methods
    allowedImports = [] // empty allowed list means imports are disallowed
    allowedStaticImports = [] // same for static imports
    allowedStaticStarImports = ['java.lang.Math'] // only java.lang.Math is allowed
    // the list of tokens the user can find
    // constants are defined in org.codehaus.groovy.syntax.Types
    allowedTokens = [ (1)
            PLUS,
            MINUS,
            MULTIPLY,
            DIVIDE,
            MOD,
            POWER,
            PLUS_PLUS,
            MINUS_MINUS,
            COMPARE_EQUAL,
            COMPARE_NOT_EQUAL,
            COMPARE_LESS_THAN,
            COMPARE_LESS_THAN_EQUAL,
            COMPARE_GREATER_THAN,
            COMPARE_GREATER_THAN_EQUAL,
    ].asImmutable()
    // limit the types of constants that a user can define to number types only
    allowedConstantTypesClasses = [ (2)
            Integer,
            Float,
            Long,
            Double,
            BigDecimal,
            Integer.TYPE,
            Long.TYPE,
            Float.TYPE,
            Double.TYPE
    ].asImmutable()
    // method calls are only allowed if the receiver is of one of those types
    // be careful, it's not a runtime type!
    allowedReceiversClasses = [ (2)
            Math,
            Integer,
            Float,
            Double,
            Long,
            BigDecimal
    ].asImmutable()
}
1 用于来自 org.codehaus.groovy.syntax.Types 的标记类型
2 您可以在此处使用类字面量

如果安全 AST 自定义器提供的开箱即用功能不足以满足您的需求,那么在创建您自己的编译自定义器之前,您可能对 AST 自定义器支持的表达式和语句检查器感兴趣。基本上,它允许您在 AST 树上添加自定义检查,在表达式(表达式检查器)或语句(语句检查器)上添加自定义检查。为此,您必须实现 org.codehaus.groovy.control.customizers.SecureASTCustomizer.StatementChecker 或 org.codehaus.groovy.control.customizers.SecureASTCustomizer.ExpressionChecker

这些接口定义了一个名为 isAuthorized 的方法,该方法返回一个布尔值,并使用一个 Statement(或 Expression)作为参数。它允许您对表达式或语句执行复杂逻辑,以判断用户是否被允许执行该操作。

例如,自定义器中没有预定义的配置标志可以让您阻止人们使用属性表达式。使用自定义检查器,这很简单

def scz = new SecureASTCustomizer()
def checker = { expr ->
    !(expr instanceof AttributeExpression)
} as SecureASTCustomizer.ExpressionChecker
scz.addExpressionCheckers(checker)

然后,我们可以通过评估一个简单的脚本来确保它有效

new GroovyShell(config).evaluate '''
    class A {
        int val
    }

    def a = new A(val: 123)
    a.@val (1)
'''
1 编译将失败

6.5. 源感知自定义器

此自定义器可以用作其他自定义器的过滤器。在这种情况下,过滤器是 org.codehaus.groovy.control.SourceUnit。为此,源感知自定义器将使用另一个自定义器作为委托,并且只有在源单元上的谓词匹配时,它才会应用该委托的自定义。

SourceUnit 使您可以访问多个内容,但尤其是正在编译的文件(当然,如果从文件编译)。它使您能够根据文件名执行操作,例如。以下是创建源感知自定义器的方式

import org.codehaus.groovy.control.customizers.SourceAwareCustomizer
import org.codehaus.groovy.control.customizers.ImportCustomizer

def delegate = new ImportCustomizer()
def sac = new SourceAwareCustomizer(delegate)

然后,您可以对源感知自定义器使用谓词

// the customizer will only be applied to classes contained in a file name ending with 'Bean'
sac.baseNameValidator = { baseName ->
    baseName.endsWith 'Bean'
}

// the customizer will only be applied to files which extension is '.spec'
sac.extensionValidator = { ext -> ext == 'spec' }

// source unit validation
// allow compilation only if the file contains at most 1 class
sac.sourceUnitValidator = { SourceUnit sourceUnit -> sourceUnit.AST.classes.size() == 1 }

// class validation
// the customizer will only be applied to classes ending with 'Bean'
sac.classValidator = { ClassNode cn -> cn.endsWith('Bean') }

6.6. 自定义器构建器

如果您在 Groovy 代码中使用编译自定义器(如上面的示例),那么您可以使用替代语法来自定义编译。构建器 (org.codehaus.groovy.control.customizers.builder.CompilerCustomizationBuilder) 使用分层 DSL 简化了自定义器的创建。

import org.codehaus.groovy.control.CompilerConfiguration
import static org.codehaus.groovy.control.customizers.builder.CompilerCustomizationBuilder.withConfig (1)

def conf = new CompilerConfiguration()
withConfig(conf) {
    // ... (2)
}
1 构建器方法的静态导入
2 配置位于此处

上面的代码示例展示了如何使用构建器。一个静态方法 withConfig 使用与构建器代码相对应的闭包,并将编译自定义器自动注册到配置中。分布中可用的每个编译自定义器都可以通过这种方式进行配置

6.6.1. 导入自定义器

withConfig(configuration) {
   imports { // imports customizer
      normal 'my.package.MyClass' // a normal import
      alias 'AI', 'java.util.concurrent.atomic.AtomicInteger' // an aliased import
      star 'java.util.concurrent' // star imports
      staticMember 'java.lang.Math', 'PI' // static import
      staticMember 'pi', 'java.lang.Math', 'PI' // aliased static import
   }
}

6.6.2. AST 转换自定义器

withConfig(conf) {
   ast(Log) (1)
}

withConfig(conf) {
   ast(Log, value: 'LOGGER') (2)
}
1 透明地应用 @Log
2 使用不同的日志记录器名称应用 @Log

6.6.3. 安全 AST 自定义器

withConfig(conf) {
   secureAst {
       closuresAllowed = false
       methodDefinitionAllowed = false
   }
}

6.6.4. 源感知自定义器

withConfig(configuration){
    source(extension: 'sgroovy') {
        ast(CompileStatic) (1)
    }
}

withConfig(configuration){
    source(extensions: ['sgroovy','sg']) {
        ast(CompileStatic) (2)
    }
}

withConfig(configuration) {
    source(extensionValidator: { it.name in ['sgroovy','sg']}) {
        ast(CompileStatic) (2)
    }
}

withConfig(configuration) {
    source(basename: 'foo') {
        ast(CompileStatic) (3)
    }
}

withConfig(configuration) {
    source(basenames: ['foo', 'bar']) {
        ast(CompileStatic) (4)
    }
}

withConfig(configuration) {
    source(basenameValidator: { it in ['foo', 'bar'] }) {
        ast(CompileStatic) (4)
    }
}

withConfig(configuration) {
    source(unitValidator: { unit -> !unit.AST.classes.any { it.name == 'Baz' } }) {
        ast(CompileStatic) (5)
    }
}
1 在 .sgroovy 文件上应用 CompileStatic AST 注释
2 在 .sgroovy 或 .sg 文件上应用 CompileStatic AST 注释
3 在名为 'foo' 的文件上应用 CompileStatic AST 注释
4 在名为 'foo' 或 'bar' 的文件上应用 CompileStatic AST 注释
5 在不包含名为 'Baz' 的类的文件上应用 CompileStatic AST 注释

6.6.5. 内联自定义器

内联自定义器允许您直接编写编译自定义器,而无需为此创建类。

withConfig(configuration) {
    inline(phase:'CONVERSION') { source, context, classNode ->  (1)
        println "visiting $classNode"                           (2)
    }
}
1 定义一个将在 CONVERSION 阶段执行的内联自定义器
2 打印正在编译的类节点的名称

6.6.6. 多个自定义器

当然,构建器允许您同时定义多个自定义器

withConfig(configuration) {
   ast(ToString)
   ast(EqualsAndHashCode)
}

6.7. configscript 命令行参数

到目前为止,我们已经描述了如何使用 CompilationConfiguration 类来自定义编译,但这只有在您嵌入 Groovy 并创建您自己的 CompilerConfiguration 实例(然后使用它创建 GroovyShellGroovyScriptEngine 等)时才有可能。

如果您希望将它应用于使用普通 Groovy 编译器(也就是说,例如,使用 groovycantgradle)编译的类,那么可以使用名为 configscript 的命令行参数,该参数使用 Groovy 配置脚本作为参数。

此脚本使您可以在**编译**文件之前访问 CompilerConfiguration 实例(作为名为 configuration 的变量公开到配置脚本中),以便您可以对其进行调整。

它还透明地集成了上面的编译器配置构建器。例如,让我们看看如何默认情况下在所有类上激活静态编译。

6.7.1. Configscript 示例:默认情况下静态编译

通常,Groovy 中的类使用动态运行时进行编译。您可以通过在任何类上放置名为 @CompileStatic 的注释来激活静态编译。有些人希望默认情况下激活这种模式,也就是说,不必注释(可能很多)类。使用 configscript,这成为可能。首先,您需要在 src/conf 中创建一个名为 config.groovy 的文件,内容如下

withConfig(configuration) { (1)
   ast(groovy.transform.CompileStatic)
}
1 configuration 引用 CompilerConfiguration 实例

这实际上是您需要做的所有事情。您不必导入构建器,它会自动在脚本中公开。然后,使用以下命令行编译您的文件

groovyc -configscript src/conf/config.groovy src/main/groovy/MyClass.groovy

我们强烈建议您将配置文件与类分开,因此我们建议在上面使用 src/mainsrc/conf 目录。

6.7.2. Configscript 示例:设置系统属性

在配置文件中,您还可以设置系统属性,例如

System.setProperty('spock.iKnowWhatImDoing.disableGroovyVersionCheck', 'true')

如果您要设置大量系统属性,那么使用配置文件将减少使用长的命令行或适当定义的环境变量来设置大量系统属性的需要。您还可以通过简单地共享配置文件来共享所有设置。

6.8. AST 转换

如果

  • 运行时元编程不允许您执行您想要的操作

  • 您需要提高 DSL 执行的性能

  • 您希望利用与 Groovy 相同的语法,但具有不同的语义

  • 您希望增强对 DSL 中类型检查的支持

那么 AST 转换是您的不二之选。与到目前为止使用的技术不同,AST 转换旨在在将代码编译为字节码之前更改或生成代码。例如,AST 转换能够在编译时添加新方法,或者根据您的需要完全更改方法的主体。它们是一个非常强大的工具,但也需要付出难以编写的代价。有关 AST 转换的更多信息,请查看本手册的 编译时元编程 部分。

7. 自定义类型检查扩展

在某些情况下,可能希望尽快向用户提供有关错误代码的反馈,即在编译 DSL 脚本时,而不是等到脚本执行完毕。然而,对于动态代码来说,这通常是不可能的。Groovy 实际上提供了一种实用的解决方案,被称为 类型检查扩展

8. 构建器

许多任务都需要构建事物,构建器模式是开发人员用来简化构建过程的技术之一,尤其是在构建层次结构的结构时。这种模式非常普遍,Groovy 有专门的内置支持。首先,有许多内置的构建器。其次,有一些类可以更轻松地编写自己的构建器。

8.1. 现有构建器

Groovy 附带了许多内置的构建器。让我们看看其中一些。

8.1.3. SaxBuilder

用于生成 Simple API for XML (SAX) 事件的构建器。

如果您有以下 SAX 处理程序

class LogHandler extends org.xml.sax.helpers.DefaultHandler {

    String log = ''

    void startElement(String uri, String localName, String qName, org.xml.sax.Attributes attributes) {
        log += "Start Element: $localName, "
    }

    void endElement(String uri, String localName, String qName) {
        log += "End Element: $localName, "
    }
}

您可以使用 SaxBuilder 为处理程序生成 SAX 事件,如下所示

def handler = new LogHandler()
def builder = new groovy.xml.SAXBuilder(handler)

builder.root() {
    helloWorld()
}

然后检查一切按预期工作

assert handler.log == 'Start Element: root, Start Element: helloWorld, End Element: helloWorld, End Element: root, '

8.1.4. StaxBuilder

一个与 Streaming API for XML (StAX) 处理器一起工作的 Groovy 构建器。

以下是一个使用 Java 的 StAX 实现来生成 XML 的简单示例

def factory = javax.xml.stream.XMLOutputFactory.newInstance()
def writer = new StringWriter()
def builder = new groovy.xml.StaxBuilder(factory.createXMLStreamWriter(writer))

builder.root(attribute:1) {
    elem1('hello')
    elem2('world')
}

assert writer.toString() == '<?xml version="1.0" ?><root attribute="1"><elem1>hello</elem1><elem2>world</elem2></root>'

可以使用像 Jettison 这样的外部库,如下所示

@Grab('org.codehaus.jettison:jettison:1.3.3')
@GrabExclude('stax:stax-api') // part of Java 6 and later
import org.codehaus.jettison.mapped.*

def writer = new StringWriter()
def mappedWriter = new MappedXMLStreamWriter(new MappedNamespaceConvention(), writer)
def builder = new groovy.xml.StaxBuilder(mappedWriter)

builder.root(attribute:1) {
     elem1('hello')
     elem2('world')
}

assert writer.toString() == '{"root":{"@attribute":"1","elem1":"hello","elem2":"world"}}'

8.1.5. DOMBuilder

一个将 HTML、XHTML 和 XML 解析为 W3C DOM 树的构建器。

例如,这个 XML String

String recordsXML = '''
    <records>
      <car name='HSV Maloo' make='Holden' year='2006'>
        <country>Australia</country>
        <record type='speed'>Production Pickup Truck with speed of 271kph</record>
      </car>
      <car name='P50' make='Peel' year='1962'>
        <country>Isle of Man</country>
        <record type='size'>Smallest Street-Legal Car at 99cm wide and 59 kg in weight</record>
      </car>
      <car name='Royale' make='Bugatti' year='1931'>
        <country>France</country>
        <record type='price'>Most Valuable Car at $15 million</record>
      </car>
    </records>'''

可以使用 DOMBuilder 像这样解析成 DOM 树

def reader = new StringReader(recordsXML)
def doc = groovy.xml.DOMBuilder.parse(reader)

然后可以进一步处理,例如使用 DOMCategory

def records = doc.documentElement
use(groovy.xml.dom.DOMCategory) {
    assert records.car.size() == 3
}

8.1.6. NodeBuilder

NodeBuilder 用于创建 groovy.util.Node 对象的嵌套树,以处理任意数据。要创建简单的用户列表,可以使用 NodeBuilder,如下所示

def nodeBuilder = new NodeBuilder()
def userlist = nodeBuilder.userlist {
    user(id: '1', firstname: 'John', lastname: 'Smith') {
        address(type: 'home', street: '1 Main St.', city: 'Springfield', state: 'MA', zip: '12345')
        address(type: 'work', street: '2 South St.', city: 'Boston', state: 'MA', zip: '98765')
    }
    user(id: '2', firstname: 'Alice', lastname: 'Doe')
}

现在您可以进一步处理数据,例如使用 GPath 表达式

assert [email protected](', ') == 'John, Alice'
assert userlist.user.find { it.@lastname == 'Smith' }.address.size() == 2

8.1.7. JsonBuilder

Groovy 的 JsonBuilder 使创建 Json 变得容易。例如,要创建此 Json 字符串

String carRecords = '''
    {
        "records": {
        "car": {
            "name": "HSV Maloo",
            "make": "Holden",
            "year": 2006,
            "country": "Australia",
            "record": {
              "type": "speed",
              "description": "production pickup truck with speed of 271kph"
            }
          }
      }
    }
'''

您可以使用 JsonBuilder,如下所示

JsonBuilder builder = new JsonBuilder()
builder.records {
  car {
        name 'HSV Maloo'
        make 'Holden'
        year 2006
        country 'Australia'
        record {
            type 'speed'
            description 'production pickup truck with speed of 271kph'
        }
  }
}
String json = JsonOutput.prettyPrint(builder.toString())

我们使用 JsonUnit 来检查构建器是否生成了预期结果

JsonAssert.assertJsonEquals(json, carRecords)

如果您需要自定义生成的输出,可以在创建 JsonBuilder 时传递一个 JsonGenerator 实例

import groovy.json.*

def generator = new JsonGenerator.Options()
        .excludeNulls()
        .excludeFieldsByName('make', 'country', 'record')
        .excludeFieldsByType(Number)
        .addConverter(URL) { url -> "https://groovy-lang.cn" }
        .build()

JsonBuilder builder = new JsonBuilder(generator)
builder.records {
  car {
        name 'HSV Maloo'
        make 'Holden'
        year 2006
        country 'Australia'
        homepage new URL('http://example.org')
        record {
            type 'speed'
            description 'production pickup truck with speed of 271kph'
        }
  }
}

assert builder.toString() == '{"records":{"car":{"name":"HSV Maloo","homepage":"https://groovy-lang.cn"}}}'

8.1.8. StreamingJsonBuilder

与在内存中创建数据结构的 JsonBuilder 不同,JsonBuilder 在您需要在输出之前以编程方式更改结构的情况下非常有用,而 StreamingJsonBuilder 直接流到编写器中,没有任何中间内存数据结构。如果您不需要修改结构,并且希望使用更节约内存的方法,请使用 StreamingJsonBuilder

StreamingJsonBuilder 的用法与 JsonBuilder 类似。为了创建此 Json 字符串

String carRecords = """
    {
      "records": {
        "car": {
          "name": "HSV Maloo",
          "make": "Holden",
          "year": 2006,
          "country": "Australia",
          "record": {
            "type": "speed",
            "description": "production pickup truck with speed of 271kph"
          }
        }
      }
    }
"""

您使用 StreamingJsonBuilder,如下所示

StringWriter writer = new StringWriter()
StreamingJsonBuilder builder = new StreamingJsonBuilder(writer)
builder.records {
    car {
        name 'HSV Maloo'
        make 'Holden'
        year 2006
        country 'Australia'
        record {
            type 'speed'
            description 'production pickup truck with speed of 271kph'
        }
    }
}
String json = JsonOutput.prettyPrint(writer.toString())

我们使用 JsonUnit 来检查预期结果

JsonAssert.assertJsonEquals(json, carRecords)

如果您需要自定义生成的输出,可以在创建 StreamingJsonBuilder 时传递一个 JsonGenerator 实例

def generator = new JsonGenerator.Options()
        .excludeNulls()
        .excludeFieldsByName('make', 'country', 'record')
        .excludeFieldsByType(Number)
        .addConverter(URL) { url -> "https://groovy-lang.cn" }
        .build()

StringWriter writer = new StringWriter()
StreamingJsonBuilder builder = new StreamingJsonBuilder(writer, generator)

builder.records {
    car {
        name 'HSV Maloo'
        make 'Holden'
        year 2006
        country 'Australia'
        homepage new URL('http://example.org')
        record {
            type 'speed'
            description 'production pickup truck with speed of 271kph'
        }
    }
}

assert writer.toString() == '{"records":{"car":{"name":"HSV Maloo","homepage":"https://groovy-lang.cn"}}}'

8.1.9. SwingBuilder

SwingBuilder 允许您以声明式和简洁的方式创建完整的 Swing GUI。它通过使用 Groovy 中的常用习惯用法(构建器)来实现这一点。构建器为您处理创建复杂对象的工作,例如实例化子对象、调用 Swing 方法以及将这些子对象附加到它们的父对象。因此,您的代码更易读且更易维护,同时仍然允许您访问完整的 Swing 组件范围。

以下是一个使用 SwingBuilder 的简单示例

import groovy.swing.SwingBuilder
import java.awt.BorderLayout as BL

count = 0
new SwingBuilder().edt {
  frame(title: 'Frame', size: [250, 75], show: true) {
    borderLayout()
    textlabel = label(text: 'Click the button!', constraints: BL.NORTH)
    button(text:'Click Me',
         actionPerformed: {count++; textlabel.text = "Clicked ${count} time(s)."; println "clicked"}, constraints:BL.SOUTH)
  }
}

它看起来像这样

SwingBuilder001

通常,可以通过一系列重复的实例化、设置器以及最后将该子对象附加到其相应的父对象来创建此组件层次结构。但是,使用 SwingBuilder 允许您以其本机形式定义此层次结构,这使得界面设计仅通过阅读代码就能理解。

这里显示的灵活性是通过利用 Groovy 中内置的许多编程特性来实现的,例如闭包、隐式构造函数调用、导入别名和字符串插值。当然,您无需完全理解这些特性才能使用 SwingBuilder;如您从上面的代码中看到的,它们的用法是直观的。

以下是一个更复杂的示例,它使用闭包展示了 SwingBuilder 代码重用的示例。

import groovy.swing.SwingBuilder
import javax.swing.*
import java.awt.*

def swing = new SwingBuilder()

def sharedPanel = {
     swing.panel() {
        label("Shared Panel")
    }
}

count = 0
swing.edt {
    frame(title: 'Frame', defaultCloseOperation: JFrame.EXIT_ON_CLOSE, pack: true, show: true) {
        vbox {
            textlabel = label('Click the button!')
            button(
                text: 'Click Me',
                actionPerformed: {
                    count++
                    textlabel.text = "Clicked ${count} time(s)."
                    println "Clicked!"
                }
            )
            widget(sharedPanel())
            widget(sharedPanel())
        }
    }
}

以下是以可观察 bean 和绑定为基础的另一种变体

import groovy.swing.SwingBuilder
import groovy.beans.Bindable

class MyModel {
   @Bindable int count = 0
}

def model = new MyModel()
new SwingBuilder().edt {
  frame(title: 'Java Frame', size: [100, 100], locationRelativeTo: null, show: true) {
    gridLayout(cols: 1, rows: 2)
    label(text: bind(source: model, sourceProperty: 'count', converter: { v ->  v? "Clicked $v times": ''}))
    button('Click me!', actionPerformed: { model.count++ })
  }
}

@Bindable 是核心 AST 变换之一。它生成所有必需的样板代码,将简单的 bean 转换为可观察的 bean。bind() 节点创建适当的 PropertyChangeListeners,这些监听器将在每次触发 PropertyChangeEvent 时更新感兴趣的方。

8.1.10. AntBuilder

这里我们描述了 AntBuilder,它允许您使用 Groovy 而不是 XML 编写 Ant 构建脚本。您可能也对使用 Groovy Ant 任务 从 Ant 中使用 Groovy 感兴趣。

尽管主要是一个构建工具,但 Apache Ant 是一个非常实用的工具,用于操作文件,包括 zip 文件、复制、资源处理等。但是,如果您曾经使用过 build.xml 文件或一些 Jelly 脚本,并发现自己受到所有尖括号的限制,或者发现使用 XML 作为脚本语言有点奇怪,并且想要一些更简洁、更直接的东西,那么使用 Groovy 进行 Ant 脚本编写可能正是您想要的。

Groovy 有一个名为 AntBuilder 的帮助类,它使 Ant 任务的脚本编写变得非常容易;允许使用真正的脚本语言来进行编程构造(变量、方法、循环、逻辑分支、类等)。它仍然看起来像 Ant XML 的一个简洁版本,没有所有尖括号;尽管您可以在脚本中混合使用这种标记。Ant 本身是一组 jar 文件。通过将它们添加到您的类路径中,您可以像在 Groovy 中一样轻松地使用它们。我们认为使用 AntBuilder 会导致更简洁、更容易理解的语法。

AntBuilder 使用我们习惯于在 Groovy 中使用的方便的构建器表示法直接公开 Ant 任务。以下是最基本的示例,它在标准输出上打印一条消息

def ant = new groovy.ant.AntBuilder()          (1)
ant.echo('hello from Ant!')         (2)
1 创建一个 AntBuilder 的实例
2 使用参数中的消息执行 echo 任务

假设您需要创建一个 ZIP 文件。它可以像这样简单

def ant = new AntBuilder()
ant.zip(destfile: 'sources.zip', basedir: 'src')

在接下来的示例中,我们演示了如何使用 AntBuilder 来使用经典的 Ant 模式直接在 Groovy 中复制文件列表

// let's just call one task
ant.echo("hello")

// here is an example of a block of Ant inside GroovyMarkup
ant.sequential {
    echo("inside sequential")
    def myDir = "build/AntTest/"
    mkdir(dir: myDir)
    copy(todir: myDir) {
        fileset(dir: "src/test") {
            include(name: "**/*.groovy")
        }
    }
    echo("done")
}

// now let's do some normal Groovy again
def file = new File(ant.project.baseDir,"build/AntTest/some/pkg/MyTest.groovy")
assert file.exists()

另一个示例是遍历与特定模式匹配的文件列表

// let's create a scanner of filesets
def scanner = ant.fileScanner {
    fileset(dir:"src/test") {
        include(name:"**/My*.groovy")
    }
}

// now let's iterate over
def found = false
for (f in scanner) {
    println("Found file $f")
    found = true
    assert f instanceof File
    assert f.name.endsWith(".groovy")
}
assert found

或执行 JUnit 测试

ant.junit {
    classpath { pathelement(path: '.') }
    test(name:'some.pkg.MyTest')
}

我们甚至可以更进一步,直接从 Groovy 编译并执行 Java 文件

ant.echo(file:'Temp.java', '''
    class Temp {
        public static void main(String[] args) {
            System.out.println("Hello");
        }
    }
''')
ant.javac(srcdir:'.', includes:'Temp.java', fork:'true')
ant.java(classpath:'.', classname:'Temp', fork:'true')
ant.echo('Done')

值得一提的是,AntBuilder 包含在 Gradle 中,因此您可以在 Gradle 中像在 Groovy 中一样使用它。可以在 Gradle 手册 中找到其他文档。

8.1.11. CliBuilder

CliBuilder 提供了一种紧凑的方式来指定命令行应用程序可用的选项,然后根据该规范自动解析应用程序的命令行参数。按照惯例,命令行参数之间有所区别,即传递给应用程序作为其参数的 选项 命令行参数和任何剩余的参数。通常,可能支持几种类型的选项,例如 -V--tabsize=4CliBuilder 消除了为命令行处理开发大量代码的负担。相反,它支持一种声明式方法来声明您的选项,然后提供一个简单的机制来解析命令行参数并查询选项(您可以将其视为选项的简单模型)。

即使您创建的每个命令行的详细信息可能都非常不同,但每次都会遵循相同的步骤。首先,创建一个 CliBuilder 实例。然后,定义允许的命令行选项。这可以使用 动态 API 样式或 注释 样式来完成。然后根据选项规范解析命令行参数,从而生成一个选项集合,然后对其进行查询。

以下是一个简单的示例 Greeter.groovy 脚本,说明了用法

// import of CliBuilder not shown                          (1)
// specify parameters
def cli = new CliBuilder(usage: 'groovy Greeter [option]') (2)
cli.a(longOpt: 'audience', args: 1, 'greeting audience')   (3)
cli.h(longOpt: 'help', 'display usage')                    (4)

// parse and process parameters
def options = cli.parse(args)                              (5)
if (options.h) cli.usage()                                 (6)
else println "Hello ${options.a ? options.a : 'World'}"    (7)
1 早期版本的 Groovy 在 groovy.util 包中有一个 CliBuilder,不需要导入。在 Groovy 2.5 中,这种方法已过时:应用程序应改为选择 groovy.cli.picocligroovy.cli.commons 版本。Groovy 2.5 中的 groovy.util 版本指向 commons-cli 版本以实现向后兼容性,但在 Groovy 3.0 中已被删除。
2 定义一个新的 CliBuilder 实例,指定一个可选的用法字符串
3 指定一个 -a 选项,它接受一个参数,并且有一个可选的长变体 --audience
4 指定一个 -h 选项,它不接受任何参数,并且有一个可选的长变体 --help
5 解析传递给脚本的命令行参数
6 如果找到 h 选项,则显示用法消息
7 显示标准问候语,或者如果找到 a 选项,则显示自定义问候语

使用不带任何命令行参数的脚本运行,即

> groovy Greeter

会产生以下输出

Hello World

使用 -h 作为单个命令行参数运行脚本,即

> groovy Greeter -h

会产生以下输出

usage: groovy Greeter [option]
 -a,--audience <arg>   greeting audience
 -h,--help             display usage

使用 --audience Groovologist 作为命令行参数运行脚本,即

> groovy Greeter --audience Groovologist

会产生以下输出

Hello Groovologist

在上面的示例中创建 CliBuilder 实例时,我们在构造函数调用中设置了可选的 usage 属性。这遵循了 Groovy 在构建过程中设置实例的其他属性的正常能力。还有许多其他可以设置的属性,例如 headerfooter。有关可用属性的完整列表,请参见 groovy.util.CliBuilder 类的可用属性。

定义允许的命令行选项时,必须提供一个短名称(例如,前面显示的 help 选项的 "h")和一个简短的描述(例如,help 选项的 "显示用法")。在上面的示例中,我们还设置了一些额外的属性,例如 longOptargs。在指定允许的命令行选项时,支持以下附加属性

名称 描述 类型

argName

此选项在输出中使用的参数的名称

String

longOpt

选项的长表示或长名称

String

args

参数值的个数

intString     (1)

optionalArg

参数值是否可选

boolean

required

选项是否为强制性

boolean

type

此选项的类型

Class

valueSeparator

作为值分隔符的字符

char     (2)

defaultValue

默认值

String

convert

将传入的 String 转换为所需的类型

Closure     (1)

(1) 稍后提供更多详细信息
(2) 在 Groovy 的特殊情况下,单字符字符串会强制转换为字符

如果您有一个只有 longOpt 变体的选项,您可以使用特殊短名称“_”来指定该选项,例如:cli._(longOpt: 'verbose', 'enable verbose logging')。一些剩余的命名参数应该相当直观,而另一些则需要更多解释。但在进一步解释之前,让我们看看使用 CliBuilder 与注释的方法。

使用注释和接口

与其进行一系列方法调用(尽管以非常声明性的迷你 DSL 形式)来指定允许的选项,您可以提供一个允许选项的接口规范,其中使用注释来指示和提供这些选项的详细信息,以及如何处理未处理的参数。使用两个注释:groovy.cli.Optiongroovy.cli.Unparsed

以下是这种规范的定义方式

interface GreeterI {
    @Option(shortName='h', description='display usage') Boolean help()        (1)
    @Option(shortName='a', description='greeting audience') String audience() (2)
    @Unparsed(description = "positional parameters") List remaining()         (3)
}
1 使用 -h--help 指定布尔选项集
2 使用 -a--audience 指定字符串选项集
3 指定任何剩余参数将存储的位置

请注意,长名称是如何从接口方法名称自动确定的。您可以使用 longName 注释属性来覆盖此行为并指定自定义长名称,如果您希望使用“_”作为 longName 来指示不提供长名称。在这种情况下,您需要指定一个 shortName。

以下是如何使用接口规范

// import CliBuilder not shown
def cli = new CliBuilder(usage: 'groovy Greeter')  (1)
def argz = '--audience Groovologist'.split()
def options = cli.parseFromSpec(GreeterI, argz)             (2)
assert options.audience() == 'Groovologist'                 (3)

argz = '-h Some Other Args'.split()
options = cli.parseFromSpec(GreeterI, argz)                 (4)
assert options.help()
assert options.remaining() == ['Some', 'Other', 'Args']     (5)
1 像以前一样创建 CliBuilder 实例,并带有可选属性
2 使用接口规范解析参数
3 使用接口中的方法查询选项
4 解析不同的参数集
5 查询剩余的参数

当调用 parseFromSpec 时,CliBuilder 会自动创建一个实现该接口的实例并对其进行填充。您只需调用接口方法即可查询选项值。

使用注释和实例

或者,也许您已经拥有一个包含选项信息的域类。您可以简单地对该类中的属性或 setter 进行注释,以使 CliBuilder 能够适当地填充您的域对象。每个注释都通过注释属性描述该选项的属性,并指示 CliBuilder 将用来在您的域对象中填充该选项的 setter。

以下是这种规范的定义方式

class GreeterC {
    @Option(shortName='h', description='display usage')
    Boolean help                        (1)

    private String audience
    @Option(shortName='a', description='greeting audience')
    void setAudience(String audience) { (2)
        this.audience = audience
    }
    String getAudience() { audience }

    @Unparsed(description = "positional parameters")
    List remaining                      (3)
}
1 指示布尔属性是一个选项
2 指示字符串属性(带有显式 setter)是一个选项
3 指定任何剩余参数将存储的位置

以下是使用该规范的方法

// import CliBuilder not shown
def cli = new CliBuilder(usage: 'groovy Greeter [option]') (1)
def options = new GreeterC()                               (2)
def argz = '--audience Groovologist foo'.split()
cli.parseFromInstance(options, argz)                       (3)
assert options.audience == 'Groovologist'                  (4)
assert options.remaining == ['foo']                        (5)
1 像以前一样创建 CliBuilder 实例,并带有可选参数
2 CliBuilder 创建一个实例来填充
3 解析参数并填充提供的实例
4 查询字符串选项属性
5 查询剩余参数属性

当调用 parseFromInstance 时,CliBuilder 会自动填充您的实例。您只需查询实例属性(或您在域对象中提供的任何访问器方法)即可访问选项值。

使用注释和脚本

最后,还有两个专门针对脚本的额外便利注释别名。它们只是组合了前面提到的注释和 groovy.transform.Field。这些注释的 groovydoc 揭示了详细信息:groovy.cli.OptionFieldgroovy.cli.UnparsedField

以下是一个在独立脚本中使用这些注释的示例,该脚本将使用与前面实例示例中所示相同的参数调用

// import CliBuilder not shown
import groovy.cli.OptionField
import groovy.cli.UnparsedField

@OptionField String audience
@OptionField Boolean help
@UnparsedField List remaining
new CliBuilder().parseFromInstance(this, args)
assert audience == 'Groovologist'
assert remaining == ['foo']
带参数的选项

我们在初始示例中看到,一些选项充当标志,例如 Greeter -h,而另一些则带有一个参数,例如 Greeter --audience Groovologist。最简单的案例涉及充当标志或具有单个(可能为可选)参数的选项。以下是一个涉及这些案例的示例

// import CliBuilder not shown
def cli = new CliBuilder()
cli.a(args: 0, 'a arg') (1)
cli.b(args: 1, 'b arg') (2)
cli.c(args: 1, optionalArg: true, 'c arg') (3)
def options = cli.parse('-a -b foo -c bar baz'.split()) (4)

assert options.a == true
assert options.b == 'foo'
assert options.c == 'bar'
assert options.arguments() == ['baz']

options = cli.parse('-a -c -b foo bar baz'.split()) (5)

assert options.a == true
assert options.c == true
assert options.b == 'foo'
assert options.arguments() == ['bar', 'baz']
1 一个仅仅是标志的选项 - 默认值;允许将 args 设置为 0,但不需要。
2 带有一个参数的选项
3 带有可选参数的选项;如果省略该选项,则充当标志
4 使用此规范的示例,其中向“c”选项提供了一个参数
5 使用此规范的示例,其中未向“c”选项提供任何参数;它仅仅是标志

注意:当遇到带有可选参数的选项时,它将(某种程度上)贪婪地从提供的命令行参数中消耗下一个参数。但是,如果下一个参数与已知的长选项或短选项匹配(以单个或双连字符开头),则该选项将优先,例如上面示例中的 -b

也可以使用注释样式指定选项参数。以下是一个接口选项规范,说明了这种定义

interface WithArgsI {
    @Option boolean a()
    @Option String b()
    @Option(optionalArg=true) String[] c()
    @Unparsed List remaining()
}

以下是使用它的方法

def cli = new CliBuilder()
def options = cli.parseFromSpec(WithArgsI, '-a -b foo -c bar baz'.split())
assert options.a()
assert options.b() == 'foo'
assert options.c() == ['bar']
assert options.remaining() == ['baz']

options = cli.parseFromSpec(WithArgsI, '-a -c -b foo bar baz'.split())
assert options.a()
assert options.c() == []
assert options.b() == 'foo'
assert options.remaining() == ['bar', 'baz']

此示例使用基于数组的选项规范。我们将在稍后讨论多个参数时详细介绍这一点。

指定类型

命令行上的参数本质上是字符串(或者可以说对于标志可以被认为是布尔值),但可以通过提供额外的类型信息自动转换为更丰富的类型。对于基于注释的参数定义样式,这些类型是使用注释属性的字段类型或注释方法的返回值类型(或 setter 方法的 setter 参数类型)来提供的。对于动态方法样式的参数定义,支持一个特殊的“type”属性,它允许您指定一个类名称。

当定义显式类型时,args 命名参数假定为 1(布尔类型选项除外,其默认值为 0)。如果需要,仍然可以提供显式 args 参数。以下是一个使用动态 api 参数定义样式的类型示例

def argz = '''-a John -b -d 21 -e 1980 -f 3.5 -g 3.14159
    -h cv.txt -i DOWN and some more'''.split()
def cli = new CliBuilder()
cli.a(type: String, 'a-arg')
cli.b(type: boolean, 'b-arg')
cli.c(type: Boolean, 'c-arg')
cli.d(type: int, 'd-arg')
cli.e(type: Long, 'e-arg')
cli.f(type: Float, 'f-arg')
cli.g(type: BigDecimal, 'g-arg')
cli.h(type: File, 'h-arg')
cli.i(type: RoundingMode, 'i-arg')
def options = cli.parse(argz)
assert options.a == 'John'
assert options.b
assert !options.c
assert options.d == 21
assert options.e == 1980L
assert options.f == 3.5f
assert options.g == 3.14159
assert options.h == new File('cv.txt')
assert options.i == RoundingMode.DOWN
assert options.arguments() == ['and', 'some', 'more']

支持原始类型、数字类型、文件、枚举及其数组(它们是使用 org.codehaus.groovy.runtime.StringGroovyMethods#asType 转换的)。

自定义解析参数字符串

如果支持的类型不足,您可以提供一个闭包来处理字符串到丰富类型的转换。以下是一个使用动态 api 样式的示例

def argz = '''-a John -b Mary -d 2016-01-01 and some more'''.split()
def cli = new CliBuilder()
def lower = { it.toLowerCase() }
cli.a(convert: lower, 'a-arg')
cli.b(convert: { it.toUpperCase() }, 'b-arg')
cli.d(convert: { Date.parse('yyyy-MM-dd', it) }, 'd-arg')
def options = cli.parse(argz)
assert options.a == 'john'
assert options.b == 'MARY'
assert options.d.format('dd-MM-yyyy') == '01-01-2016'
assert options.arguments() == ['and', 'some', 'more']

或者,您可以通过将转换闭包作为注释参数来使用注释样式。以下是一个规范示例

interface WithConvertI {
    @Option(convert={ it.toLowerCase() }) String a()
    @Option(convert={ it.toUpperCase() }) String b()
    @Option(convert={ Date.parse("yyyy-MM-dd", it) }) Date d()
    @Unparsed List remaining()
}

以下是一个使用该规范的示例

Date newYears = Date.parse("yyyy-MM-dd", "2016-01-01")
def argz = '''-a John -b Mary -d 2016-01-01 and some more'''.split()
def cli = new CliBuilder()
def options = cli.parseFromSpec(WithConvertI, argz)
assert options.a() == 'john'
assert options.b() == 'MARY'
assert options.d() == newYears
assert options.remaining() == ['and', 'some', 'more']
带有多个参数的选项

使用大于 1 的 args 值也支持多个参数。有一个特殊的命名参数 valueSeparator,也可以在处理多个参数时可选地使用。它允许在命令行上提供此类参数列表时,对支持的语法进行一些额外的灵活控制。例如,提供一个值为“,”的值分隔符,允许在命令行上传递逗号分隔的值列表。

args 值通常是整数。它可以可选地作为字符串提供。有两个特殊的字符串符号:` 和 `\*`。* 值表示 0 个或多个。` 值表示 1 个或多个。* 值与使用 + 并将 optionalArg 值设置为 true 相同。

访问多个参数遵循一个特殊的约定。只需在您用来访问参数选项的普通属性中添加一个“s”,您将检索所有提供的参数作为列表。因此,对于一个名为“a”的短选项,您可以使用 options.a 访问第一个“a”参数,使用 options.as 访问所有参数的列表。如果您的选项名称以“s”结尾,或者没有其他不带“s”的单数变体,则可以正常使用。因此,如果 name 是您的选项之一,具有多个参数,而 guess 是另一个选项,具有单个参数,则使用 options.namesoptions.guess 不会造成混淆。

以下是一段突出显示使用多个参数的代码片段

// import CliBuilder not shown
def cli = new CliBuilder()
cli.a(args: 2, 'a-arg')
cli.b(args: '2', valueSeparator: ',', 'b-arg') (1)
cli.c(args: '+', valueSeparator: ',', 'c-arg') (2)

def options = cli.parse('-a 1 2 3 4'.split()) (3)
assert options.a == '1' (4)
assert options.as == ['1', '2'] (5)
assert options.arguments() == ['3', '4']

options = cli.parse('-a1 -a2 3'.split()) (6)
assert options.as == ['1', '2']
assert options.arguments() == ['3']

options = cli.parse(['-b1,2']) (7)
assert options.bs == ['1', '2']

options = cli.parse(['-c', '1'])
assert options.cs == ['1']

options = cli.parse(['-c1'])
assert options.cs == ['1']

options = cli.parse(['-c1,2,3'])
assert options.cs == ['1', '2', '3']
1 作为字符串提供的 args 值,并指定了逗号值分隔符
2 允许一个或多个参数
3 两个命令行参数将作为“b”选项的参数列表提供
4 访问“a”选项的第一个参数
5 访问“a”选项的参数列表
6 指定“a”选项的两个参数的另一种语法
7 作为逗号分隔值提供的“b”选项的参数

作为使用复数名称方法访问多个参数的替代方法,您可以为该选项使用基于数组的类型。在这种情况下,所有选项都将始终通过数组返回,该数组通过正常的单数名称访问。我们将在讨论类型时看到一个这样的示例。

通过使用注释类成员(方法或属性)的数组类型,使用注释样式的选项定义也支持多个参数,如本示例所示

interface ValSepI {
    @Option(numberOfArguments=2) String[] a()
    @Option(numberOfArgumentsString='2', valueSeparator=',') String[] b()
    @Option(numberOfArgumentsString='+', valueSeparator=',') String[] c()
    @Unparsed remaining()
}

使用方式如下

def cli = new CliBuilder()

def options = cli.parseFromSpec(ValSepI, '-a 1 2 3 4'.split())
assert options.a() == ['1', '2']
assert options.remaining() == ['3', '4']

options = cli.parseFromSpec(ValSepI, '-a1 -a2 3'.split())
assert options.a() == ['1', '2']
assert options.remaining() == ['3']

options = cli.parseFromSpec(ValSepI, ['-b1,2'] as String[])
assert options.b() == ['1', '2']

options = cli.parseFromSpec(ValSepI, ['-c', '1'] as String[])
assert options.c() == ['1']

options = cli.parseFromSpec(ValSepI, ['-c1'] as String[])
assert options.c() == ['1']

options = cli.parseFromSpec(ValSepI, ['-c1,2,3'] as String[])
assert options.c() == ['1', '2', '3']
类型和多个参数

以下是一个使用动态 api 参数定义样式的类型和多个参数的示例

def argz = '''-j 3 4 5 -k1.5,2.5,3.5 and some more'''.split()
def cli = new CliBuilder()
cli.j(args: 3, type: int[], 'j-arg')
cli.k(args: '+', valueSeparator: ',', type: BigDecimal[], 'k-arg')
def options = cli.parse(argz)
assert options.js == [3, 4, 5] (1)
assert options.j == [3, 4, 5]  (1)
assert options.k == [1.5, 2.5, 3.5]
assert options.arguments() == ['and', 'some', 'more']
1 对于数组类型,可以使用尾随的“s”,但不是必需的
设置默认值

Groovy 使用 Elvis 运算符可以轻松地在某些变量的使用点提供默认值,例如 String x = someVariable ?: 'some default'。但有时您希望将此类默认值作为选项规范的一部分,以最大限度地减少后来阶段的询问者的工作。CliBuilder 支持 defaultValue 属性来满足这种场景。

以下是使用动态 api 样式的方法

def cli = new CliBuilder()
cli.f longOpt: 'from', type: String, args: 1, defaultValue: 'one', 'f option'
cli.t longOpt: 'to', type: int, defaultValue: '35', 't option'

def options = cli.parse('-f two'.split())
assert options.hasOption('f')
assert options.f == 'two'
assert !options.hasOption('t')
assert options.t == 35

options = cli.parse('-t 45'.split())
assert !options.hasOption('from')
assert options.from == 'one'
assert options.hasOption('to')
assert options.to == 45

类似地,您可能希望使用注释样式进行这样的规范。以下是一个使用接口规范的示例

interface WithDefaultValueI {
    @Option(shortName='f', defaultValue='one') String from()
    @Option(shortName='t', defaultValue='35') int to()
}

使用方法如下

def cli = new CliBuilder()

def options = cli.parseFromSpec(WithDefaultValueI, '-f two'.split())
assert options.from() == 'two'
assert options.to() == 35

options = cli.parseFromSpec(WithDefaultValueI, '-t 45'.split())
assert options.from() == 'one'
assert options.to() == 45

您也可以在使用带有实例的注释时使用 defaultValue 注释属性,尽管为该属性(或后备字段)提供初始值可能同样容易。

TypeChecked 一起使用

使用 CliBuilder 的动态 api 样式本质上是动态的,但如果您想利用 Groovy 的静态类型检查功能,您可以选择一些方法。首先,考虑使用注释样式,例如,以下是一个接口选项规范

interface TypeCheckedI{
    @Option String name()
    @Option int age()
    @Unparsed List remaining()
}

它可以与@TypeChecked结合使用,如下所示

@TypeChecked
void testTypeCheckedInterface() {
    def argz = "--name John --age 21 and some more".split()
    def cli = new CliBuilder()
    def options = cli.parseFromSpec(TypeCheckedI, argz)
    String n = options.name()
    int a = options.age()
    assert n == 'John' && a == 21
    assert options.remaining() == ['and', 'some', 'more']
}

其次,动态 API 风格提供了一些支持。定义语句本质上是动态的,但实际上会返回一个值,我们在之前的示例中忽略了它。返回值实际上是一个TypedOption<Type>,并且特殊的getAt支持允许使用类型化选项来查询这些选项,例如options[savedTypeOption]。因此,如果您在代码的非类型检查部分有类似于以下的语句

def cli = new CliBuilder()
TypedOption<Integer> age = cli.a(longOpt: 'age', type: Integer, 'some age option')

那么,以下语句可以在代码的类型检查部分

def args = '--age 21'.split()
def options = cli.parse(args)
int a = options[age]
assert a == 21

最后,CliBuilder 提供了一个额外的便利方法,甚至允许定义部分进行类型检查。这是一个稍微冗长的调用方法。在方法调用中,不要使用简短名称(opt名称),而是使用固定的名称option,并将opt值作为属性提供。您还需要直接指定类型,如以下示例所示

import groovy.cli.TypedOption
import groovy.transform.TypeChecked

@TypeChecked
void testTypeChecked() {
    def cli = new CliBuilder()
    TypedOption<String> name = cli.option(String, opt: 'n', longOpt: 'name', 'name option')
    TypedOption<Integer> age = cli.option(Integer, longOpt: 'age', 'age option')
    def argz = "--name John --age 21 and some more".split()
    def options = cli.parse(argz)
    String n = options[name]
    int a = options[age]
    assert n == 'John' && a == 21
    assert options.arguments() == ['and', 'some', 'more']
}
高级 CLI 使用

注意 高级 CLI 功能

CliBuilder 可以被认为是基于 picocliApache Commons CLI 之上的一个 Groovy 友好包装器。如果CliBuilder 没有提供某个您知道底层库支持的功能,当前的CliBuilder 实现(以及各种 Groovy 语言功能)使您可以轻松地直接调用底层库方法。这样做是一种务实的做法,可以利用CliBuilder 提供的 Groovy 友好语法,同时仍然可以访问一些底层库的高级功能。但是需要注意的是,将来版本的CliBuilder 可能使用其他底层库,在这种情况下,您的 Groovy 类和/或脚本可能需要进行一些移植工作。

Apache Commons CLI

例如,以下是一些使用 Apache Commons CLI 的分组机制的代码

import org.apache.commons.cli.*

def cli = new CliBuilder()
cli.f longOpt: 'from', 'f option'
cli.u longOpt: 'until', 'u option'
def optionGroup = new OptionGroup()
optionGroup.with {
  addOption cli.option('o', [longOpt: 'output'], 'o option')
  addOption cli.option('d', [longOpt: 'directory'], 'd option')
}
cli.options.addOptionGroup optionGroup
assert !cli.parse('-d -o'.split()) (1)
1 解析将失败,因为一次只能使用一个组中的一个选项。
Picocli

以下是 picocli 版本的 CliBuilder 中提供的一些功能。

新属性:errorWriter

当应用程序的用户提供无效的命令行参数时,CliBuilder 会向stderr输出流写入错误消息和使用帮助消息。它不使用stdout流来防止错误消息在程序输出被用作另一个进程的输入时被解析。您可以通过将errorWriter设置为不同的值来自定义目标。

另一方面,CliBuilder.usage() 会将使用帮助消息打印到stdout流。这样,当用户请求帮助(例如,使用--help参数)时,他们可以将输出管道到lessgrep之类的实用程序。

您可以为测试指定不同的编写器。请注意,为了向后兼容,将writer属性设置为不同的值会将writererrorWriter都设置为指定的编写器。

ANSI 颜色

picocli 版本的 CliBuilder 会在支持的平台上自动以 ANSI 颜色渲染使用帮助消息。如果需要,您可以自定义它。(下面有一个示例。)

新属性:name

与之前一样,您可以使用usage属性设置使用帮助消息的概要。您可能对一个小改进感兴趣:如果您只设置命令name,则会自动生成概要,重复元素后面跟着…​,可选元素用[]括起来。(下面有一个示例。)

新属性:usageMessage

此属性公开来自底层 picocli 库的UsageMessageSpec对象,该对象可以对使用帮助消息的各个部分进行细粒度的控制。例如

def cli = new CliBuilder()
cli.name = "myapp"
cli.usageMessage.with {
    headerHeading("@|bold,underline Header heading:|@%n")
    header("Header 1", "Header 2")                     // before the synopsis
    synopsisHeading("%n@|bold,underline Usage:|@ ")
    descriptionHeading("%n@|bold,underline Description heading:|@%n")
    description("Description 1", "Description 2")      // after the synopsis
    optionListHeading("%n@|bold,underline Options heading:|@%n")
    footerHeading("%n@|bold,underline Footer heading:|@%n")
    footer("Footer 1", "Footer 2")
}
cli.a('option a description')
cli.b('option b description')
cli.c(args: '*', 'option c description')
cli.usage()

产生以下输出

usageMessageSpec

属性:parser

parser属性可以访问 picocli ParserSpec对象,该对象可用于自定义解析器行为。

当控制解析器的CliBuilder选项不够细粒度时,这将非常有用。例如,为了与CliBuilder的 Commons CLI 实现向后兼容,默认情况下,CliBuilder会在遇到未知选项时停止查找选项,后续的命令行参数将被视为位置参数。CliBuilder提供了一个stopAtNonOption属性,通过将其设置为false,您可以使解析器更加严格,因此未知选项会导致error: Unknown option: '-x'

但是,如果您想将未知选项视为位置参数,并且仍然将后续命令行参数作为选项处理,该怎么办?

这可以通过parser属性来实现。例如

def cli = new CliBuilder()
cli.parser.stopAtPositional(false)
cli.parser.unmatchedOptionsArePositionalParams(true)
// ...
def opts = cli.parse(args)
// ...

有关详细信息,请参阅文档

映射选项

最后,如果您的应用程序具有键值对形式的选项,您可能对 picocli 对映射的支持感兴趣。例如

import java.util.concurrent.TimeUnit
import static java.util.concurrent.TimeUnit.DAYS
import static java.util.concurrent.TimeUnit.HOURS

def cli = new CliBuilder()
cli.D(args: 2,   valueSeparator: '=', 'the old way')                          (1)
cli.X(type: Map, 'the new way')                                               (2)
cli.Z(type: Map, auxiliaryTypes: [TimeUnit, Integer].toArray(), 'typed map')  (3)

def options = cli.parse('-Da=b -Dc=d -Xx=y -Xi=j -ZDAYS=2 -ZHOURS=23'.split())(4)
assert options.Ds == ['a', 'b', 'c', 'd']                                     (5)
assert options.Xs == [ 'x':'y', 'i':'j' ]                                     (6)
assert options.Zs == [ (DAYS as TimeUnit):2, (HOURS as TimeUnit):23 ]         (7)
1 以前,key=value对被拆分成多个部分并添加到列表中
2 Picocli 映射支持:只需将Map指定为选项的类型
3 您甚至可以指定映射元素的类型
4 为了比较,让我们为每个选项指定两个键值对
5 以前,所有键值对都最终出现在一个列表中,应用程序需要处理此列表
6 Picocli 将键值对作为Map返回
7 映射的键和值都可以是强类型

控制 Picocli 版本

要使用特定版本的 picocli,请在构建配置中添加对该版本的依赖项。如果使用预安装版本的 Groovy 运行脚本,请使用@Grab注释来控制在CliBuilder中使用的 picocli 版本。

@GrabConfig(systemClassLoader=true)
@Grab('info.picocli:picocli:4.2.0')
import groovy.cli.picocli.CliBuilder

def cli = new CliBuilder()

8.1.12. ObjectGraphBuilder

ObjectGraphBuilder 是一个构建器,用于构建遵循 JavaBean 约定的任意 bean 图。它尤其适用于创建测试数据。

让我们从属于您的域的类列表开始

package com.acme

class Company {
    String name
    Address address
    List employees = []
}

class Address {
    String line1
    String line2
    int zip
    String state
}

class Employee {
    String name
    int employeeId
    Address address
    Company company
}

然后使用ObjectGraphBuilder 构建一个包含三个员工的Company,就像这样简单

def builder = new ObjectGraphBuilder()                          (1)
builder.classLoader = this.class.classLoader                    (2)
builder.classNameResolver = "com.acme"                          (3)

def acme = builder.company(name: 'ACME') {                      (4)
    3.times {
        employee(id: it.toString(), name: "Drone $it") {        (5)
            address(line1:"Post street")                        (6)
        }
    }
}

assert acme != null
assert acme instanceof Company
assert acme.name == 'ACME'
assert acme.employees.size() == 3
def employee = acme.employees[0]
assert employee instanceof Employee
assert employee.name == 'Drone 0'
assert employee.address instanceof Address
1 创建一个新的对象图构建器
2 设置类将被解析的类加载器
3 设置要解析的类的基本包名
4 创建一个Company实例
5 包含 3 个Employee实例
6 每个实例都有一个不同的Address

在幕后,对象图构建器

  • 将尝试使用默认的ClassNameResolver策略将节点名称匹配到Class,该策略需要包名

  • 然后将使用默认的NewInstanceResolver策略创建适当类的实例,该策略调用无参构造函数

  • 解析嵌套节点的父子关系,涉及另外两种策略

    • RelationNameResolver将产生父节点中子属性的名称,以及子节点中父属性的名称(如果有的话,在本例中,Employee有一个恰好名为company的父属性)

    • ChildPropertySetter将把子节点插入父节点,考虑到子节点是否属于Collection(在本例中,employees应该是CompanyEmployee实例的列表)。

所有 4 种策略都有默认的实现,如果代码遵循编写 JavaBean 的常用约定,它们将按预期工作。如果您的任何 bean 或对象不遵循约定,您可以插入您自己的每种策略的实现。例如,假设您需要构建一个不可变的类

@Immutable
class Person {
    String name
    int age
}

那么,如果您尝试使用构建器创建一个Person

def person = builder.person(name:'Jon', age:17)

它将在运行时失败,并显示

Cannot set readonly property: name for class: com.acme.Person

可以通过更改新实例策略来解决此问题

builder.newInstanceResolver = { Class klazz, Map attributes ->
    if (klazz.getConstructor(Map)) {
        def o = klazz.newInstance(attributes)
        attributes.clear()
        return o
    }
    klazz.newInstance()
}

ObjectGraphBuilder 支持每个节点的 ID,这意味着您可以在构建器中存储对节点的引用。当多个对象引用同一个实例时,这很有用。因为名为id的属性可能在某些域模型中具有业务含义,所以ObjectGraphBuilder有一个名为IdentifierResolver的策略,您可以配置它来更改默认名称值。对于用于引用以前保存的实例的属性,也会发生同样的事情,名为ReferenceResolver的策略将产生适当的值(默认值为`refId')

def company = builder.company(name: 'ACME') {
    address(id: 'a1', line1: '123 Groovy Rd', zip: 12345, state: 'JV')          (1)
    employee(name: 'Duke', employeeId: 1, address: a1)                          (2)
    employee(name: 'John', employeeId: 2 ){
      address( refId: 'a1' )                                                    (3)
    }
}
1 可以创建一个带有id的地址
2 员工可以直接使用其 ID 引用地址
3 或者使用对应于相应地址的idrefId属性

值得一提的是,您不能修改引用 bean 的属性。

8.1.13. JmxBuilder

有关详细信息,请参阅使用 JMX - JmxBuilder

8.1.14. FileTreeBuilder

groovy.util.FileTreeBuilder 是一个构建器,用于根据规范生成文件目录结构。例如,要创建以下树

 src/
  |--- main
  |     |--- groovy
  |            |--- Foo.groovy
  |--- test
        |--- groovy
               |--- FooTest.groovy

您可以使用FileTreeBuilder,就像这样

tmpDir = File.createTempDir()
def fileTreeBuilder = new FileTreeBuilder(tmpDir)
fileTreeBuilder.dir('src') {
    dir('main') {
       dir('groovy') {
          file('Foo.groovy', 'println "Hello"')
       }
    }
    dir('test') {
       dir('groovy') {
          file('FooTest.groovy', 'class FooTest extends groovy.test.GroovyTestCase {}')
       }
    }
 }

为了检查一切都按预期工作,我们使用以下`assert`

assert new File(tmpDir, '/src/main/groovy/Foo.groovy').text == 'println "Hello"'
assert new File(tmpDir, '/src/test/groovy/FooTest.groovy').text == 'class FooTest extends groovy.test.GroovyTestCase {}'

FileTreeBuilder还支持简写语法

tmpDir = File.createTempDir()
def fileTreeBuilder = new FileTreeBuilder(tmpDir)
fileTreeBuilder.src {
    main {
       groovy {
          'Foo.groovy'('println "Hello"')
       }
    }
    test {
       groovy {
          'FooTest.groovy'('class FooTest extends groovy.test.GroovyTestCase {}')
       }
    }
 }

这将生成与上述相同的目录结构,如这些`assert`所示

assert new File(tmpDir, '/src/main/groovy/Foo.groovy').text == 'println "Hello"'
assert new File(tmpDir, '/src/test/groovy/FooTest.groovy').text == 'class FooTest extends groovy.test.GroovyTestCase {}'

8.2. 创建构建器

虽然 Groovy 有许多内置构建器,但构建器模式非常常见,您最终一定会遇到内置构建器没有满足的构建需求。好消息是,您可以自己构建。您可以完全从头开始,依靠 Groovy 的元编程功能。或者,BuilderSupportFactoryBuilderSupport类可以使您设计自己的构建器变得更加容易。

8.2.1. BuilderSupport

构建构建器的一种方法是子类化BuilderSupport。使用这种方法,一般思路是重写一个或多个生命周期方法,包括setParentnodeCompleted以及BuilderSupport抽象类中的所有或部分createNode方法。

例如,假设我们想要创建一个用于跟踪运动训练计划的构建器。每个计划都包含多个组,每个组都有自己的步骤。一个步骤本身可能是一组更小的步骤。对于每个setstep,我们可能希望记录所需的distance(或time)、是否repeat步骤一定的次数、是否在每个步骤之间break等等。

为了简化示例,我们将使用地图和列表来捕获训练程序。一个训练计划包含一系列步骤。像 `repeat` 次数或 `distance` 这样的信息将被记录在每个步骤和计划的属性映射中。

构建器实现如下

  • 重写几个 `createNode` 方法。我们将创建一个映射来捕获计划名称、空步骤列表以及一些潜在的属性。

  • 每当我们完成一个节点时,我们都会将该节点添加到父节点(如果有)的步骤列表中。

代码如下

class TrainingBuilder1 extends BuilderSupport {
    protected createNode(name) {
        [name: name, steps: []]
    }

    protected createNode(name, Map attributes) {
        createNode(name) + attributes
    }

    void nodeCompleted(maybeParent, node) {
        if (maybeParent) maybeParent.steps << node
    }

    // unused lifecycle methods
    protected void setParent(parent, child) { }
    protected createNode(name, Map attributes, value) { }
    protected createNode(name, value) { }
}

接下来,我们将编写一个简单的辅助方法,该方法递归地将所有子步骤的距离相加,并根据需要考虑重复的步骤。

def total(map) {
    if (map.distance) return map.distance
    def repeat = map.repeat ?: 1
    repeat * map.steps.sum{ total(it) }
}

最后,我们现在可以使用我们的构建器和辅助方法来创建一个游泳训练程序并检查其总距离

def training = new TrainingBuilder1()

def monday = training.swimming {
    warmup(repeat: 3) {
        freestyle(distance: 50)
        breaststroke(distance: 50)
    }
    endurance(repeat: 20) {
        freestyle(distance: 50, break: 15)
    }
    warmdown {
        kick(distance: 100)
        choice(distance: 100)
    }
}

assert 1500 == total(monday)

8.2.2. FactoryBuilderSupport

构建构建器的第二种方法是子类化 `FactoryBuilderSupport`。该构建器与 `BuilderSupport` 有类似的目标,但具有额外的功能来简化领域类构造。

使用这种方法,总体思路是重写 `FactoryBuilderSupport` 抽象类中的多个生命周期方法,包括 `resolveFactory`、`nodeCompleted` 和 `postInstantiate` 方法。

我们将使用与之前 `BuilderSupport` 示例相同的示例;一个跟踪运动训练程序的构建器。

对于此示例,我们不会使用地图和列表来捕获训练程序,而是将使用一些简单的领域类。

构建器实现如下

  • 重写 `resolveFactory` 方法以返回一个特殊的工厂,该工厂通过大写我们的迷你 DSL 中使用的名称来返回类。

  • 每当我们完成一个节点时,我们都会将该节点添加到父节点(如果有)的步骤列表中。

代码(包括特殊工厂类的代码)如下

import static org.apache.groovy.util.BeanUtils.capitalize

class TrainingBuilder2 extends FactoryBuilderSupport {
    def factory = new TrainingFactory(loader: getClass().classLoader)

    protected Factory resolveFactory(name, Map attrs, value) {
        factory
    }

    void nodeCompleted(maybeParent, node) {
        if (maybeParent) maybeParent.steps << node
    }
}

class TrainingFactory extends AbstractFactory {
    ClassLoader loader
    def newInstance(FactoryBuilderSupport fbs, name, value, Map attrs) {
        def clazz = loader.loadClass(capitalize(name))
        value ? clazz.newInstance(value: value) : clazz.newInstance()
    }
}

我们不会使用列表和地图,而是会有一些简单的领域类和相关的特征。

trait HasDistance {
    int distance
}

trait Container extends HasDistance {
    List steps = []
    int repeat
}

class Cycling implements Container { }

class Interval implements Container { }

class Sprint implements HasDistance {}

class Tempo implements HasDistance {}

就像 `BuilderSupport` 示例一样,拥有一个辅助方法来计算训练期间覆盖的总距离非常有用。实现与我们之前的示例非常相似,但已调整为与我们新定义的特征良好配合。

def total(HasDistance c) {
    c.distance
}

def total(Container c) {
    if (c.distance) return c.distance
    def repeat = c.repeat ?: 1
    repeat * c.steps.sum{ total(it) }
}

最后,我们现在可以使用我们的新构建器和辅助方法来创建一个自行车训练程序并检查其总距离

def training = new TrainingBuilder2()

def tuesday = training.cycling {
    interval(repeat: 5) {
        sprint(distance: 400)
        tempo(distance: 3600)
    }
}

assert 20000 == total(tuesday)