特定領域語言

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. 腳本類別

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 binding 用於在腳本和呼叫類別之間共用資料
2 GroovyShell 可與此 binding 搭配使用
3 輸入變數從呼叫類別設定在 binding 中
4 然後評估腳本
5 z 變數已「匯出」到 binding 中

這是呼叫者與腳本之間共用資料的非常實用的方法,但在某些情況下可能不足或不切實際。基於此目的,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 'dsl-guru@mycompany.com'
    to 'john.doe@waitaminute.com'
    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 值。在此設定 owner 和 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 'dsl-guru@mycompany.com'
    to 'john.doe@waitaminute.com'
    subject 'The pope has resigned!'
    body {
        p 'Really, the pope has resigned!'
    }
}

那麼類型檢查器將會知道有一個接受 Closureemail 方法,但它會抱怨封閉 內部的每個方法呼叫,因為例如 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 方法的 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 'dsl-guru@mycompany.com'
        to 'john.doe@waitaminute.com'
        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. 委派至任意類型

上述選項可能無法代表您要委派的類型。例如,我們定義一個映射器類別,其參數化為一個物件,並定義一個映射方法,該方法會傳回另一種類型的物件

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 來源物件儲存在最終欄位中
3 map 方法要求將來源物件轉換為目標物件

如您所見,map 的方法簽章未提供任何關於閉包將處理哪個物件的資訊。閱讀方法主體,我們知道它將是類型為 Tvalue,但方法簽章中找不到 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 的指令碼特別有用,在這種情況下,你希望避免使用者必須撰寫匯入。匯入自訂程式將允許你新增 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 定義一個內聯自訂器,它會在轉換階段執行
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 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 expressions

assert userlist.user.@firstname.join(', ') == '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.dev.org.tw" }
        .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.dev.org.tw"}}}'

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.dev.org.tw" }
        .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.dev.org.tw"}}}'

8.1.9. SwingBuilder

SwingBuilder 讓您能夠以宣告式且簡潔的方式建立完整的 Swing GUI。它透過採用 Groovy 中常見的慣用語法,也就是 Builder,來達成這個目的。Builder 會為您處理建立複雜物件的繁瑣工作,例如實例化子物件、呼叫 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 撰寫 Ant 建置指令碼,而不是 XML。您可能也有興趣使用 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 直接在 Groovy 中使用傳統的 Ant 模式複製檔案清單

// 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

輸出中使用的此選項的引數名稱

字串

longOpt

選項的長表示法或長名稱

字串

args

引數值的數量

intString     (1)

optionalArg

引數值是否為選用

布林值

required

選項是否為強制性

布林值

type

此選項的類型

類別

valueSeparator

作為值分隔符號的字元

字元     (2)

defaultValue

預設值

字串

convert

將輸入字串轉換為所需的類型

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 註解屬性來覆寫該行為,並在需要時指定自訂長名稱,或使用 '_' 長名稱來表示不提供長名稱。在這種情況下,您需要指定短名稱。

以下是使用介面規格的方式

// 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 會自動建立實作介面的執行個體,並填入資料。您只需呼叫介面方法即可詢問選項值。

使用註解和執行個體

或者,您可能已經有一個包含選項資訊的網域類別。您可以簡單地註解該類別的屬性或設定器,以啟用 CliBuilder 適當地填入您的網域物件。每個註解都透過註解屬性描述該選項的屬性,並指示 CliBuilder 將用於在您的網域物件中填入該選項的設定器。

以下是定義此類規格的方式

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 表示字串屬性(具有明確設定器)為選項
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']

此範例使用陣列型選項規格。我們在稍後討論多個參數時,將更詳細地介紹這個主題。

指定類型

命令列上的參數本質上是字串(或可視為旗標的布林值),但可透過提供額外的類型資訊,自動轉換為更豐富的類型。對於基於註解的參數定義樣式,這些類型會使用註解屬性的欄位類型或註解方法的傳回類型(或設定器方法的設定器參數類型)來提供。對於動態方法的參數定義樣式,支援特殊的「類型」屬性,可讓您指定類別名稱。

定義明確類型時,假設 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 提供了一個額外的方便方法,甚至允許定義部分經過類型檢查。它是一個稍微冗長的呼叫方法。在方法呼叫中,你使用固定的名稱 option,並將 opt 值作為屬性提供,而不是在方法呼叫中使用簡稱(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 可以視為一個 Groovy 友善的封裝器,建構於 picocliApache Commons CLI 之上。如果 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

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

新屬性:errorWriter

當你的應用程式的使用者提供無效的命令列引數時,CliBuilder 會將錯誤訊息和使用說明訊息寫入 stderr 輸出串流。它不會使用 stdout 串流,以防止當你的程式輸出用作另一個程式的輸入時,錯誤訊息被解析。你可以透過將 errorWriter 設定為不同的值來自訂目的地。

另一方面,CliBuilder.usage() 會將使用說明訊息列印到 stdout 串流。這樣,當使用者要求協助(例如使用 --help 參數)時,他們可以將輸出導向到 lessgrep 等公用程式。

你可以指定不同的寫入器來進行測試。請注意,為了向後相容,將 writer 屬性設定為不同的值會將 兩個 writererrorWriter 設定為指定的寫入器。

ANSI 顏色

CliBuilder 的 picocli 版本會自動在支援的平台上以 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 個策略都有預設實作,如果程式碼遵循撰寫 JavaBeans 的慣例,則會如預期般運作。如果您的任何 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。採用這種方法,一般概念是覆寫 BuilderSupport 抽象類別中的多個生命週期方法,包括 setParentnodeCompleted 和部分或全部 createNode 方法。

例如,假設我們要建立一個用於追蹤運動訓練計畫的建構器。每個計畫由多組組成,而每組都有自己的步驟。一個步驟本身可能是一組較小的步驟。對於每個 setstep,我們可能希望記錄所需 distance(或 time),是否要重複這些步驟特定次數、是否要在每個步驟之間休息,等等。

為了簡化此範例,我們將使用對應和清單擷取訓練程式設計。一組有一系列步驟。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 抽象類別中的多個生命週期方法,包括 resolveFactorynodeCompletedpostInstantiate 方法。

我們將使用與前一個 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)