Chicory Native JVM WebAssembly 運行時簡介
1. 概述
Chicory 是一個完全用 Java 寫的 JVM 原生 WebAssembly 執行階段。它在 JVM 內載入.wasm二進位文件,將其匯出為 Java API,從而無需 JNI 或外部原生依賴項。
在本教程中,我們將使用 Chicory 在 JVM 上運行一個最小的add.wasm模組。此外,我們還將執行 JUnit 5 測試來示範其工作原理,並為將 WebAssembly 模組整合到現有 Java 應用程式中奠定基本基礎。
值得注意的是, Chicory 需要 Java 11 LTS 或更高版本。
2. WebAssembly 模組、導入和匯出
在 WebAssembly 層面,所有操作都在模組內進行。模組是一個編譯單元,它將函數、類型定義以及可選的記憶體、表和全域變數打包到一個單獨的.wasm二進位檔案中。編譯完成後,模組只是惰性資料。只有當宿主環境實例化模組並將其轉換為可運行實例時,模組才能執行。
模組透過導入和導出與外部世界互動:
- 導入語句描述了模組期望宿主提供的內容:例如,記錄訊息或存取時鐘的函數。
- 導出描述模組提供給宿主的內容:宿主可以呼叫或讀取的函數、記憶體、表格或全域變數。
換句話說,imports 是模組的依賴項,exports 是其公共 API。
當模組在 JVM 上執行時,Chicory 扮演宿主運行時的角色。 Java 應用程式請求 Chicory 將.wasm二進位檔案解析成模組,實例化該模組,滿足所有匯入條件,然後將匯出的內容作為 Java 物件公開,以便可以從常規 JVM 程式碼中呼叫這些物件:
這樣看來,菊苣又是抽象層次。
所以,讓我們記住這張圖,作為本文其餘部分的概念背景。
3. 快速入門:在 JVM 上執行.wasm文件
在本節中,我們將 Chicory 整合到一個小型 Maven 專案中,建立一個小型 WebAssembly 模組,並從 JUnit 5 測試中呼叫其add()函數。
3.1. 新增 Chicory 運行時依賴項
首先,讓我們確保 JUnit 5 和 Maven Surefire 外掛程式已正確設定。
接下來, **pom.xml中唯一需要的額外依賴項是Chicory 的runtime模組**,它提供了一個 JVM 原生的 WebAssembly 引擎:
<dependency>
<groupId>com.dylibso.chicory</groupId>
<artifactId>runtime</artifactId>
<version>1.5.3</version>
</dependency>
當然,在複製程式碼片段之前,我們應該先在 Maven Central 上查看最新版本。
3.2. 從.wat檔案建立.wasm
第一個例子,我們使用一個最小的 WebAssembly 文字模組,該模組導出一個add函數。
具體來說,我們保存程式碼add.wat :
(module
(func (export "add") (param i32 i32) (result i32)
local.get 0
local.get 1
i32.add))
簡而言之,這段程式碼導出函數add(i32, i32) -> i32 :給定兩個 32 位元整數,它會傳回它們的和。沒有導入任何庫。
現在,為了將這段程式碼轉換成.wasm二進位文件,讓我們使用線上wat2wasm範例並執行以下幾個步驟:
- 從空白範本開始,請在
example下拉選單中選擇Empty。 - 將上方的
.wat程式碼貼到左側的文字區域中,取代任何現有內容。 - 所有選項保留預設值
- 按下
Download按鈕即可取得編譯好的.wasm檔。
讓我們將產生的二進位檔案儲存到src/test/resources/chicory/add.wasm中。
3.3. 實例化模組並呼叫add(2, 40)
此時,我們可以進行初步測試:
@Test
void givenAddModule_whenCallingAddWithTwoAndForty_thenResultIsFortyTwo() {
InputStream wasm = getClass().getResourceAsStream("/chicory/add.wasm");
assertNotNull(wasm);
WasmModule module = Parser.parse(wasm);
Instance instance = Instance.builder(module).build();
ExportFunction add = instance.export("add");
long[] result = add.apply(2, 40);
assertEquals(42, (int) result[0]);
}
現在,讓我們一步一步地看看上面的程式碼是如何運作的:
-
Parser將.wasm二進位檔案讀取到內部模組表示中。 -
Instance.builder(module).build()將其轉換為正在運作的實例。 -
instance.export(“add”)會傳回一個ExportFunction,它表示導出的add函數。 - 呼叫
apply(2, 40)會傳回一個long的值數組,因為 WebAssembly 函數可以傳回多個值。 - 我們斷言,第一個結果在向下轉型為
int後為42
現在我們已經了解了導出,讓我們來看看導入,看看.wasm模組是如何從宿主請求功能的。
4. 重點:導入和值類型
WebAssembly 模組在宿主提供導入之前是純粹的計算型模組。 Chicory 透過宿主函數來實現這些導入,這些宿主函數是用 Java 編寫的,並註冊到Store中。當模組實例化時,Chicory 會將模組宣告的導入解析為Store中已新增的函數。解析完成後,模組就可以在執行期間呼叫這些宿主函數了。
4.1. 最小導入:呼叫宿主函數
首先,我們建立一個依賴一個導入函數的小模組。此模組需要一個類型為i32 -> i32的宿主double函數,並匯出一個可以從 Java 程式碼中呼叫的輔助函數。
**讓我們將範例程式碼儲存為imports.wat , src/test/resources/chicory/imports.wasm**使用wat2wasm演示將其編譯為 src/test/resources/chicory/imports.wasm,就像我們之前已經做過的那樣:
(module
(import "host" "double" (func $double (param i32) (result i32)))
(func (export "useDouble") (param i32) (result i32)
local.get 0
call $double))
接下來,我們在 Java 中使用宿主函數完成導入,並使用Store實例化模組:
@Test
void givenImportDouble_whenCallingUseDouble_thenResultIsDoubled() {
InputStream wasm = getClass().getResourceAsStream("/chicory/imports.wasm");
assertNotNull(wasm);
HostFunction doubleFn = new HostFunction(
"host",
"double",
FunctionType.of(List.of(ValType.I32), List.of(ValType.I32)),
(Instance instance, long... args) -> new long[] { args[0] * 2 }
);
Store store = new Store();
store.addFunction(doubleFn);
WasmModule module = Parser.parse(wasm);
Instance instance = store.instantiate("imports", module);
ExportFunction useDouble = instance.export("useDouble");
long[] result = useDouble.apply(21);
assertEquals(42L, result[0]);
}
那麼,讓我們來總結一下這個邏輯:
- 定義
HostFunctiondoubleFn匯入命名空間host,名稱double,Wasm 簽章i32 -> i32 -
doubleFn已在Store註冊 -
WasmModule透過Store實例化 -
ExportFunction useDouble被調用
讓我們視覺化一下imports.wasm中的useDouble呼叫是如何與 Java HostFunction關聯的,以及值是如何跨越宿主邊界流動的:
現在導入已經完成,讓我們來看看 WebAssembly 的四種數值類型是如何對應到 JVM 原語的。
4.2 值類型映射概覽
在繼續之前,我們應該注意WebAssembly 的基本函數類型僅限於數值: i32 、 i64 、 f32和f64 。字串、陣列和結構化物件不是 Wasm 的原生參數或傳回值。
在 Chicory 的底層 API 中,每次呼叫都會跨越一個僅支援long類型的邊界:參數以long提供,結果以long[]傳回。這種數組形式的結果為單一結果函數和多結果函數提供了統一的表示方式。對於單一結果函數,我們只需讀取索引0值。
整數的處理相當簡單: i32值作為 Java int擴展為long傳遞, i64值作為 Java long傳遞。
浮點值需要在邊界處進行位元級打包和解包。例如,我們需要將 Java float類型的值擴展為long ,然後透過Float.floatToIntBits()將其位元轉換為 intBits。同樣,對於 Java double類型,也需要透過Double.doubleToLongBits()進行轉換。轉換結果必須進行對應的逆操作。
對於非數值數據,流程有所不同。 Chicory 公開的模組實例( Instance )提供線性記憶體。然後,我們透過instance.memory()將位元組寫入其中,並將偏移量和長度傳遞給函數,最後在主機上進行解碼。例如, Chicory 主機函數指南中展示了一個使用readString(offset, len)讀取緊湊String範例。
5. 故障排除:解讀常見錯誤訊息
當我們開始連接模組和主機代碼時,最初的故障通常分為兩類:
- 實例化時未解析的導入
- 呼叫時缺少導出名稱
讓我們用最少的、確定的檢定來檢驗這兩者。
5.1. 未解析的導入(實例化時間)
如果一個模組宣告了導入語句,而我們沒有提供名稱和簽章都符合的宿主函數,則實例化會失敗。這正是當我們嘗試實例化imports.wasm時,如果沒有註冊宿主函數double: i32 -> i32 : 時發生的情況。
@Test
void whenInstantiatingModuleWithoutRequiredImport_thenErrorIsThrown() {
InputStream wasm = getClass().getResourceAsStream("/chicory/imports.wasm");
assertNotNull(wasm);
WasmModule module = Parser.parse(wasm);
assertThrows(RuntimeException.class, () -> {
Instance.builder(module).build();
});
}
在這種情況下,解決方法是為Store新增一個命名空間為host 、名稱為double 、簽署為i32 -> i32 HostFunction ,然後透過該 Store 進行實例化,就像我們以前所做的那樣。
5.2. 缺少導出名稱(呼叫時間查找)
如果實例化成功,但我們請求的函數是模組未匯出的,則在呼叫時查找會失敗。
使用add.wasm時,由於它只導出add(i32, i32) -> i32 ,因此請求sum會觸發錯誤:
@Test
void whenRequestingMissingExport_thenErrorIsThrown() {
InputStream wasm = getClass().getResourceAsStream("/chicory/add.wasm");
assertNotNull(wasm);
WasmModule module = Parser.parse(wasm);
Instance instance = Instance.builder(module).build();
assertThrows(RuntimeException.class, () -> instance.export("sum"));
}
此錯誤通常表示拼字錯誤,或 WebAssembly 模組與宿主之間的匯出名稱不符。一旦診斷出來,應該很容易糾正。
6. 結論
本文中,我們在 JVM 上部署了 Chicory ,編譯了一個小型 WAT 模組到add.wasm ,並使用 JUnit 5 驅動了整個流程。首先,我們實例化該模組,呼叫其add導出函數,並驗證結果。接下來,我們透過 Java HostFunction和Store引入了一個最小的導入。
在此過程中,我們概述了 Chicory 所暴露的底層調用邊界。參數以long傳入,結果以long[]傳回,因此整數可以直接對應。另一方面,浮點值和非數值資料需要序列化。
最後,我們討論了實例化時未解析的導入和呼叫時導出名稱不符的問題。
與往常一樣,本文的完整程式碼可在 GitHub 上找到。