將 Groovy 整合到應用程式中

1. Groovy 整合機制

Groovy 語言提出多種方法,可以在執行階段將其整合到應用程式(Java 甚至 Groovy)中,從最基本的簡單程式碼執行到最完整的整合快取和編譯器自訂。

本節中撰寫的所有範例都使用 Groovy,但可以從 Java 使用相同的整合機制。

1.1. Eval

groovy.util.Eval 類別是最簡單的方法,可以在執行階段動態執行 Groovy。這可以透過呼叫 me 方法來完成

import groovy.util.Eval

assert Eval.me('33*3') == 99
assert Eval.me('"foo".toUpperCase()') == 'FOO'

Eval 支援多個變體,這些變體接受參數以進行簡單評估

assert Eval.x(4, '2*x') == 8                (1)
assert Eval.me('k', 4, '2*k') == 8          (2)
assert Eval.xy(4, 5, 'x*y') == 20           (3)
assert Eval.xyz(4, 5, 6, 'x*y+z') == 26     (4)
1 使用一個名為 x 的繫結參數進行簡單評估
2 相同的評估,使用自訂繫結參數名為 k
3 使用兩個名為 xy 的繫結參數進行簡單評估
4 使用三個名為 xyz 的繫結參數進行簡單評估

Eval 類別讓評估簡單腳本變得非常容易,但無法擴充:沒有腳本快取,而且不打算評估超過一行。

1.2. GroovyShell

1.2.1. 多個來源

groovy.lang.GroovyShell 類別是評估腳本的建議方式,並具備快取結果腳本實例的能力。儘管 Eval 類別會傳回已編譯腳本執行結果,但 GroovyShell 類別提供了更多選項。

def shell = new GroovyShell()                           (1)
def result = shell.evaluate '3*5'                       (2)
def result2 = shell.evaluate(new StringReader('3*5'))   (3)
assert result == result2
def script = shell.parse '3*5'                          (4)
assert script instanceof groovy.lang.Script
assert script.run() == 15                               (5)
1 建立新的 GroovyShell 實例
2 可用作 Eval,直接執行程式碼
3 可從多個來源讀取(StringReaderFileInputStream
4 可延後執行腳本。parse 會傳回 Script 實例
5 Script 定義 run 方法

1.2.2. 在腳本和應用程式之間共用資料

可以使用 groovy.lang.Binding 在應用程式和腳本之間共用資料

def sharedData = new Binding()                          (1)
def shell = new GroovyShell(sharedData)                 (2)
def now = new Date()
sharedData.setProperty('text', 'I am shared data!')     (3)
sharedData.setProperty('date', now)                     (4)

String result = shell.evaluate('"At $date, $text"')     (5)

assert result == "At $now, I am shared data!"
1 建立新的 Binding,其中會包含共用資料
2 使用這個共用資料建立 GroovyShell
3 將字串新增至繫結
4 將日期新增至繫結(不限於簡單類型)
5 評估腳本

請注意,也可以從腳本寫入繫結

def sharedData = new Binding()                          (1)
def shell = new GroovyShell(sharedData)                 (2)

shell.evaluate('foo=123')                               (3)

assert sharedData.getProperty('foo') == 123             (4)
1 建立新的 Binding 實例
2 使用那個共用資料建立新的 GroovyShell
3 使用未宣告變數將結果儲存在繫結中
4 從呼叫者讀取結果

重要的是,如果您要寫入繫結,則需要使用未宣告變數。使用 def明確 類型(如下面的範例)會失敗,因為您會建立區域變數

def sharedData = new Binding()
def shell = new GroovyShell(sharedData)

shell.evaluate('int foo=123')

try {
    assert sharedData.getProperty('foo')
} catch (MissingPropertyException e) {
    println "foo is defined as a local variable"
}
在多執行緒環境中使用共用資料時,您必須非常小心。傳遞給 GroovyShellBinding 實例不是執行緒安全的,而且所有腳本都會共用。

可以透過利用 parse 傳回的 Script 實例,來解決 Binding 的共用實例

def shell = new GroovyShell()

def b1 = new Binding(x:3)                       (1)
def b2 = new Binding(x:4)                       (2)
def script = shell.parse('x = 2*x')
script.binding = b1
script.run()
script.binding = b2
script.run()
assert b1.getProperty('x') == 6
assert b2.getProperty('x') == 8
assert b1 != b2
1 會將 x 變數儲存在 b1 內部
2 會將 x 變數儲存在 b2 內部

但是,您必須知道您仍然共用腳本的同一個實例。因此,如果兩個執行緒在同一個腳本上執行,則無法使用這個技巧。在這種情況下,您必須建立兩個不同的腳本實例

def shell = new GroovyShell()

def b1 = new Binding(x:3)
def b2 = new Binding(x:4)
def script1 = shell.parse('x = 2*x')            (1)
def script2 = shell.parse('x = 2*x')            (2)
assert script1 != script2
script1.binding = b1                            (3)
script2.binding = b2                            (4)
def t1 = Thread.start { script1.run() }         (5)
def t2 = Thread.start { script2.run() }         (6)
[t1,t2]*.join()                                 (7)
assert b1.getProperty('x') == 6
assert b2.getProperty('x') == 8
assert b1 != b2
1 建立執行緒 1 的腳本實例
2 建立執行緒 2 的腳本實例
3 指定第一個繫結至腳本 1
4 指定第二個繫結至腳本 2
5 在個別執行緒中啟動第一個腳本
6 在個別執行緒中啟動第二個腳本
7 等待完成

如果您需要執行緒安全性,建議直接使用 GroovyClassLoader

1.2.3. 自訂腳本類別

我們已經看到 parse 方法會傳回 groovy.lang.Script 的執行個體,但可以給定擴充 Script 本身的自訂類別。它可以用於提供額外的腳本行為,如下面的範例

abstract class MyScript extends Script {
    String name

    String greet() {
        "Hello, $name!"
    }
}

自訂類別定義一個名為 name 的屬性,以及一個名為 greet 的新方法。這個類別可以使用自訂組態作為腳本基礎類別

import org.codehaus.groovy.control.CompilerConfiguration

def config = new CompilerConfiguration()                                    (1)
config.scriptBaseClass = 'MyScript'                                         (2)

def shell = new GroovyShell(this.class.classLoader, new Binding(), config)  (3)
def script = shell.parse('greet()')                                         (4)
assert script instanceof MyScript
script.setName('Michel')
assert script.run() == 'Hello, Michel!'
1 建立一個 CompilerConfiguration 執行個體
2 指示它使用 MyScript 作為腳本的基礎類別
3 然後在建立 shell 時使用編譯器組態
4 腳本現在可以存取新方法 greet
您不限於單一的 scriptBaseClass 組態。您可以使用任何編譯器組態調整,包括 編譯自訂程式

1.3. GroovyClassLoader

上一節 中,我們已經展示 GroovyShell 是執行腳本的簡易工具,但它讓編譯任何非腳本的內容變得複雜。在內部,它使用 groovy.lang.GroovyClassLoader,這是執行時期編譯和載入類別的核心。

透過使用 GroovyClassLoader 代替 GroovyShell,您將能夠載入類別,而不是腳本執行個體

import groovy.lang.GroovyClassLoader

def gcl = new GroovyClassLoader()                                           (1)
def clazz = gcl.parseClass('class Foo { void doIt() { println "ok" } }')    (2)
assert clazz.name == 'Foo'                                                  (3)
def o = clazz.newInstance()                                                 (4)
o.doIt()                                                                    (5)
1 建立一個新的 GroovyClassLoader
2 parseClass 會傳回 Class 的執行個體
3 您可以檢查傳回的類別是否真的為腳本中定義的類別
4 您可以建立類別的新執行個體,它不是腳本
5 然後呼叫任何方法
GroovyClassLoader 會保留所有它建立的類別的參考,因此很容易產生記憶體外洩。特別是,如果您執行相同的腳本兩次,如果它是一個字串,那麼您會取得兩個不同的類別!
import groovy.lang.GroovyClassLoader

def gcl = new GroovyClassLoader()
def clazz1 = gcl.parseClass('class Foo { }')                                (1)
def clazz2 = gcl.parseClass('class Foo { }')                                (2)
assert clazz1.name == 'Foo'                                                 (3)
assert clazz2.name == 'Foo'
assert clazz1 != clazz2                                                     (4)
1 動態建立一個名為「Foo」的類別
2 使用個別 parseClass 呼叫建立一個外觀相同的類別
3 確保兩個類別具有相同名稱
4 但實際上它們是不同的!

原因是 GroovyClassLoader 沒有追蹤來源文字。如果您想要擁有相同的實例,則來源必須是一個檔案,就像這個範例中

def gcl = new GroovyClassLoader()
def clazz1 = gcl.parseClass(file)                                           (1)
def clazz2 = gcl.parseClass(new File(file.absolutePath))                    (2)
assert clazz1.name == 'Foo'                                                 (3)
assert clazz2.name == 'Foo'
assert clazz1 == clazz2                                                     (4)
1 File 解析類別
2 從不同的檔案實例解析類別,但指向同一個實體檔案
3 確保我們的類別具有相同名稱
4 但現在,它們是相同的實例

使用 File 作為輸入,GroovyClassLoader 能夠快取產生的類別檔案,這可以避免在執行階段為相同的來源建立多個類別。

1.4. GroovyScriptEngine

groovy.util.GroovyScriptEngine 類別為依賴指令碼重新載入和指令碼相依性的應用程式提供一個彈性的基礎。雖然 GroovyShell 專注於獨立的 Script,而 GroovyClassLoader 處理任何 Groovy 類別的動態編譯和載入,但 GroovyScriptEngine 會在 GroovyClassLoader 上面新增一層來處理指令碼相依性和重新載入。

為了說明這一點,我們將建立一個指令碼引擎,並在一個無限迴圈中執行程式碼。首先,您需要建立一個目錄,其中包含以下指令碼

ReloadingTest.groovy
class Greeter {
    String sayHello() {
        def greet = "Hello, world!"
        greet
    }
}

new Greeter()

然後您可以使用 GroovyScriptEngine 執行此程式碼

def binding = new Binding()
def engine = new GroovyScriptEngine([tmpDir.toURI().toURL()] as URL[])          (1)

while (true) {
    def greeter = engine.run('ReloadingTest.groovy', binding)                   (2)
    println greeter.sayHello()                                                  (3)
    Thread.sleep(1000)
}
1 建立一個指令碼引擎,它會在我們的來源目錄中尋找來源
2 執行指令碼,它會傳回 Greeter 的一個實例
3 列印問候訊息

在這個時候,您應該會看到每秒列印一則訊息

Hello, world!
Hello, world!
...

在不中斷指令碼執行的狀況下,現在將 ReloadingTest 檔案的內容替換為

ReloadingTest.groovy
class Greeter {
    String sayHello() {
        def greet = "Hello, Groovy!"
        greet
    }
}

new Greeter()

訊息應該會變更為

Hello, world!
...
Hello, Groovy!
Hello, Groovy!
...

但也可以相依於另一個指令碼。為了說明這一點,在不中斷執行指令碼的狀況下,在同一個目錄中建立以下檔案

Dependency.groovy
class Dependency {
    String message = 'Hello, dependency 1'
}

並像這樣更新 ReloadingTest 指令碼

ReloadingTest.groovy
import Dependency

class Greeter {
    String sayHello() {
        def greet = new Dependency().message
        greet
    }
}

new Greeter()

這次,訊息應該會變更為

Hello, Groovy!
...
Hello, dependency 1!
Hello, dependency 1!
...

最後一個測試,您可以更新 Dependency.groovy 檔案,而不變更 ReloadingTest 檔案

Dependency.groovy
class Dependency {
    String message = 'Hello, dependency 2'
}

您應該會觀察到相依檔案已重新載入

Hello, dependency 1!
...
Hello, dependency 2!
Hello, dependency 2!

1.5. CompilationUnit

最後,透過直接依賴 org.codehaus.groovy.control.CompilationUnit 類別,可以在編譯期間執行更多操作。此類別負責決定編譯的各種步驟,並允許您引入新的步驟,甚至在不同的階段停止編譯。例如,這是聯合編譯器如何執行 stub 產生的方式。

不過,不建議覆寫 CompilationUnit,只有在沒有其他標準解決方案可行時才應該這麼做。

2. JSR 223 javax.script API

JSR-223 是一個標準 API,用於在 Java 中呼叫腳本架構。它自 Java 6 起提供,旨在提供一個共用架構,用於從 Java 呼叫多種語言。Groovy 提供了自己的更豐富的整合機制,如果您不打算在同一個應用程式中使用多種語言,建議您使用 Groovy 整合機制,而不是受限的 JSR-223 API。

以下是您需要如何初始化 JSR-223 引擎,才能從 Java 與 Groovy 對話

import javax.script.ScriptEngine;
import javax.script.ScriptEngineManager;
import javax.script.ScriptException;
...
ScriptEngineManager factory = new ScriptEngineManager();
ScriptEngine engine = factory.getEngineByName("groovy");

然後您可以輕鬆執行 Groovy 腳本

Integer sum = (Integer) engine.eval("(1..10).sum()");
assertEquals(Integer.valueOf(55), sum);

也可以共用變數

engine.put("first", "HELLO");
engine.put("second", "world");
String result = (String) engine.eval("first.toLowerCase() + ' ' + second.toUpperCase()");
assertEquals("hello WORLD", result);

下一個範例說明如何呼叫可呼叫函式

import javax.script.Invocable;
...
ScriptEngineManager factory = new ScriptEngineManager();
ScriptEngine engine = factory.getEngineByName("groovy");
String fact = "def factorial(n) { n == 1 ? 1 : n * factorial(n - 1) }";
engine.eval(fact);
Invocable inv = (Invocable) engine;
Object[] params = {5};
Object result = inv.invokeFunction("factorial", params);
assertEquals(Integer.valueOf(120), result);

引擎預設會對腳本函式保留硬式參照。若要變更此設定,您應該將引擎層級範圍屬性設定為腳本內容的 #jsr223.groovy.engine.keep.globals 名稱,其中字串為 phantom 以使用幻影參照、weak 以使用弱參照或 soft 以使用軟參照 - 不區分大小寫。任何其他字串都會導致使用硬式參照。