使用 WebAssembly 進行擴充套件開發
2024年5月8日,由 Dirk Bäumer 撰寫
Visual Studio Code 透過 WebAssembly 執行引擎擴充套件支援 WASM 二進位制檔案的執行。其主要用例是將用 C/C++ 或 Rust 編寫的程式編譯成 WebAssembly,然後在 VS Code 中直接執行這些程式。一個著名的例子是 Visual Studio Code for Education,它利用此支援在 VS Code for the Web 中執行 Python 直譯器。這篇部落格文章詳細介紹了其實現方式。
2024年1月,位元組碼聯盟(Bytecode Alliance)釋出了 WASI 0.2 預覽版。WASI 0.2 預覽版中的一項關鍵技術是元件模型(Component Model)。WebAssembly 元件模型透過標準化介面、資料型別和模組組合,簡化了 WebAssembly 元件與其宿主環境之間的互動。這種標準化是透過使用 WIT (WASM 介面型別) 檔案來促進的。WIT 檔案有助於描述 JavaScript/TypeScript 擴充套件(宿主)與執行由另一種語言(如 Rust 或 C/C++)編碼的計算的 WebAssembly 元件之間的互動。
這篇部落格文章概述了開發者如何利用元件模型將 WebAssembly 庫整合到他們的擴充套件中。我們關注三個用例:(a) 使用 WebAssembly 實現一個庫,並從 JavaScript/TypeScript 的擴充套件程式碼中呼叫它;(b) 從 WebAssembly 程式碼中呼叫 VS Code API;以及 (c) 演示如何使用資源來封裝和管理 WebAssembly 或 TypeScript 程式碼中的有狀態物件。
這些示例要求您除了安裝 VS Code 和 NodeJS 外,還安裝了以下工具的最新版本:Rust 編譯器工具鏈、wasm-tools 和 wit-bindgen。
我還要感謝來自 Fastly 的 L. Pereira 和 Luke Wagner 對本文提出的寶貴反饋。
一個 Rust 計算器
在第一個示例中,我們演示了開發者如何將一個用 Rust 編寫的庫整合到 VS Code 擴充套件中。如前所述,元件是使用 WIT 檔案描述的。在我們的示例中,該庫執行簡單的操作,如加、減、乘、除。相應的 WIT 檔案如下所示。
package vscode:example;
interface types {
record operands {
left: u32,
right: u32
}
variant operation {
add(operands),
sub(operands),
mul(operands),
div(operands)
}
}
world calculator {
use types.{ operation };
export calc: func(o: operation) -> u32;
}
Rust 工具 wit-bindgen
用於為計算器生成 Rust 繫結。使用該工具有兩種方式:
-
作為過程宏,直接在實現檔案中生成繫結。這種方法是標準的,但缺點是無法檢查生成的繫結程式碼。
-
作為命令列工具,在磁碟上建立一個繫結檔案。這種方法在下面的資源示例中有所體現,相關程式碼可以在 VS Code 擴充套件示例倉庫中找到。
相應的 Rust 檔案使用了 wit-bindgen
工具作為過程宏,其內容如下:
// Use a procedural macro to generate bindings for the world we specified in
// `calculator.wit`
wit_bindgen::generate!({
// the name of the world in the `*.wit` input file
world: "calculator",
});
然而,使用命令 cargo build --target wasm32-unknown-unknown
將 Rust 檔案編譯成 WebAssembly 會導致編譯錯誤,因為缺少匯出的 calc
函式的實現。下面是 calc
函式的一個簡單實現:
// Use a procedural macro to generate bindings for the world we specified in
// `calculator.wit`
wit_bindgen::generate!({
// the name of the world in the `*.wit` input file
world: "calculator",
});
struct Calculator;
impl Guest for Calculator {
fn calc(op: Operation) -> u32 {
match op {
Operation::Add(operands) => operands.left + operands.right,
Operation::Sub(operands) => operands.left - operands.right,
Operation::Mul(operands) => operands.left * operands.right,
Operation::Div(operands) => operands.left / operands.right,
}
}
}
// Export the Calculator to the extension code.
export!(Calculator);
檔案末尾的 export!(Calculator);
語句將 Calculator
從 WebAssembly 程式碼中匯出,以使擴充套件能夠呼叫該 API。
wit2ts
工具用於生成在 VS Code 擴充套件內與 WebAssembly 程式碼互動所需的 TypeScript 繫結。該工具由 VS Code 團隊開發,以滿足 VS Code 擴充套件架構的特定要求,主要原因在於:
- VS Code API 只能在擴充套件宿主工作執行緒(extension host worker)中訪問。從擴充套件宿主工作執行緒派生的任何其他工作執行緒都無法訪問 VS Code API,這與 NodeJS 或瀏覽器等環境形成對比,在那些環境中,每個工作執行緒通常可以訪問幾乎所有的執行時 API。
- 多個擴充套件共享同一個擴充套件宿主工作執行緒。擴充套件應避免在該工作執行緒上執行任何長時間執行的同步計算。
這些架構要求在我們實現 VS Code 的 WASI 預覽版 1 時就已經存在。然而,我們最初的實現是手動編寫的。預見到元件模型將被更廣泛地採用,我們開發了一個工具來促進元件與其 VS Code 特定宿主實現的整合。
命令 wit2ts --outDir ./src ./wit
會在 src
資料夾中生成一個 calculator.ts
檔案,其中包含 WebAssembly 程式碼的 TypeScript 繫結。一個使用這些繫結的簡單擴充套件如下所示:
import * as vscode from 'vscode';
import { WasmContext, Memory } from '@vscode/wasm-component-model';
// Import the code generated by wit2ts
import { calculator, Types } from './calculator';
export async function activate(context: vscode.ExtensionContext): Promise<void> {
// The channel for printing the result.
const channel = vscode.window.createOutputChannel('Calculator');
context.subscriptions.push(channel);
// Load the Wasm module
const filename = vscode.Uri.joinPath(
context.extensionUri,
'target',
'wasm32-unknown-unknown',
'debug',
'calculator.wasm'
);
const bits = await vscode.workspace.fs.readFile(filename);
const module = await WebAssembly.compile(bits);
// The context for the WASM module
const wasmContext: WasmContext.Default = new WasmContext.Default();
// Instantiate the module
const instance = await WebAssembly.instantiate(module, {});
// Bind the WASM memory to the context
wasmContext.initialize(new Memory.Default(instance.exports));
// Bind the TypeScript Api
const api = calculator._.exports.bind(
instance.exports as calculator._.Exports,
wasmContext
);
context.subscriptions.push(
vscode.commands.registerCommand('vscode-samples.wasm-component-model.run', () => {
channel.show();
channel.appendLine('Running calculator example');
const add = Types.Operation.Add({ left: 1, right: 2 });
channel.appendLine(`Add ${api.calc(add)}`);
const sub = Types.Operation.Sub({ left: 10, right: 8 });
channel.appendLine(`Sub ${api.calc(sub)}`);
const mul = Types.Operation.Mul({ left: 3, right: 7 });
channel.appendLine(`Mul ${api.calc(mul)}`);
const div = Types.Operation.Div({ left: 10, right: 2 });
channel.appendLine(`Div ${api.calc(div)}`);
})
);
}
當您在 VS Code for the Web 中編譯並執行上述程式碼時,它會在 Calculator
通道中產生以下輸出:
您可以在 VS Code 擴充套件示例倉庫中找到此示例的完整原始碼。
@vscode/wasm-component-model 內部探究
檢查由 wit2ts
工具生成的原始碼會發現它依賴於 @vscode/wasm-component-model
npm 模組。該模組是 VS Code 對元件模型規範 ABI 的實現,並從相應的 Python 程式碼中汲取了靈感。雖然理解這篇部落格文章並不需要了解元件模型的內部工作原理,但我們將闡明其工作方式,特別是關於資料如何在 JavaScript/TypeScript 和 WebAssembly 程式碼之間傳遞。
與其他為 WIT 檔案生成繫結的工具(如 wit-bindgen 或 jco)不同,wit2ts
建立了一個元模型,該元模型隨後可用於在執行時為各種用例生成繫結。這種靈活性使我們能夠滿足 VS Code 內擴充套件開發的架構要求。透過使用這種方法,我們可以將繫結“Promise 化”(promisify),並使 WebAssembly 程式碼能夠在工作執行緒中執行。我們使用此機制來實現 VS Code 的 WASI 0.2 預覽版。
您可能已經注意到,在生成繫結時,函式是透過像 calculator._.imports.create
這樣的名稱來引用的(注意下劃線)。為了避免與 WIT 檔案中的符號發生名稱衝突(例如,可能有一個名為 imports
的型別定義),API 函式被放置在一個 _
名稱空間中。元模型本身位於一個 $
名稱空間中。因此,calculator.$.exports.calc
代表匯出的 calc
函式的元資料。
在上面的例子中,傳遞給 calc
函式的 add
操作引數包含三個欄位:操作碼、左值和右值。根據元件模型的規範 ABI,引數是按值傳遞的。它還概述了資料如何被序列化,傳遞給 WebAssembly 函式,並在另一側被反序列化。這個過程會產生兩個操作物件:一個在 JavaScript 堆上,另一個在 WebAssembly 線性記憶體中。下圖說明了這一點:
下表列出了可用的 WIT 型別、它們在 VS Code 元件模型實現中與 JavaScript 物件的對映關係,以及所使用的相應 TypeScript 型別。
WIT | JavaScript | TypeScript |
---|---|---|
u8 | number | type u8 = number; |
u16 | number | type u16 = number; |
u32 | number | type u32 = number; |
u64 | bigint | type u64 = bigint; |
s8 | number | type s8 = number; |
s16 | number | type s16 = number; |
s32 | number | type s32 = number; |
s64 | bigint | type s64 = bigint; |
float32 | number | type float32 = number; |
float64 | number | type float64 = number; |
bool | 布林值 | 布林值 |
字串 | 字串 | 字串 |
char | string[0] | 字串 |
record | 物件字面量 | 型別宣告 |
list<T> | [] | Array<T> |
tuple<T1, T2> | [] | [T1, T2] |
enum | 字串值 | 字串列舉 |
flags | number | bigint |
variant | 物件字面量 | 可辨識聯合(discriminated union) |
option<T> | variable | ? 和 (T | undefined) |
result<ok, err> | 異常或物件字面量 | 異常或 result 型別 |
值得注意的是,元件模型不支援低階(C 風格)指標。因此,您不能傳遞物件圖或遞迴資料結構。在這方面,它與 JSON 有著相同的限制。為了最大限度地減少資料複製,元件模型引入了資源(resources)的概念,我們將在本部落格文章的後續部分更詳細地探討這個概念。
jco 專案也支援使用 type
命令為 WebAssembly 元件生成 JavaScript/TypeScript 繫結。如前所述,我們開發了自己的工具以滿足 VS Code 的特定需求。然而,我們與 jco 團隊每兩週舉行一次會議,以確保在可能的情況下工具之間保持一致。一個基本要求是,兩種工具對於 WIT 資料型別應使用相同的 JavaScript 和 TypeScript 表示。我們也在探索在兩種工具之間共享程式碼的可能性。
從 WebAssembly 程式碼呼叫 TypeScript
WIT 檔案描述了宿主(一個 VS Code 擴充套件)與 WebAssembly 程式碼之間的互動,促進了雙向通訊。在我們的示例中,此功能允許 WebAssembly 程式碼記錄其活動的跟蹤資訊。為此,我們按如下方式修改 WIT 檔案:
world calculator {
/// ....
/// A log function implemented on the host side.
import log: func(msg: string);
/// ...
}
在 Rust 端,我們現在可以呼叫 log 函數了。
fn calc(op: Operation) -> u32 {
log(&format!("Starting calculation: {:?}", op));
let result = match op {
// ...
};
log(&format!("Finished calculation: {:?}", op));
result
}
在 TypeScript 端,擴充套件開發者唯一需要做的就是提供 log 函式的實現。然後,VS Code 元件模型會協助生成必要的繫結,這些繫結將作為匯入項傳遞給 WebAssembly 例項。
export async function activate(context: vscode.ExtensionContext): Promise<void> {
// ...
// The channel for printing the log.
const log = vscode.window.createOutputChannel('Calculator - Log', { log: true });
context.subscriptions.push(log);
// The implementation of the log function that is called from WASM
const service: calculator.Imports = {
log: (msg: string) => {
log.info(msg);
}
};
// Create the bindings to import the log function into the WASM module
const imports = calculator._.imports.create(service, wasmContext);
// Instantiate the module
const instance = await WebAssembly.instantiate(module, imports);
// ...
}
與第一個示例相比,WebAssembly.instantiate
呼叫現在包含 calculator._.imports.create(service, wasmContext)
的結果作為第二個引數。這個 imports.create
呼叫從服務實現中生成了底層的 WASM 繫結。在最初的示例中,我們傳遞了一個空的物件字面量,因為不需要任何匯入。這一次,我們在 VS Code 桌面環境的偵錯程式下執行擴充套件。得益於 Connor Peet 出色的工作,現在可以在 Rust 程式碼中設定斷點,並使用 VS Code 偵錯程式單步除錯。
使用元件模型資源
WebAssembly 元件模型引入了資源(resources)的概念,它提供了一種標準化的機制來封裝和管理狀態。這種狀態在呼叫邊界的一側(例如,在 TypeScript 程式碼中)進行管理,而在另一側(例如,在 WebAssembly 程式碼中)進行訪問和操作。資源在 WASI 預覽版 0.2 API 中被廣泛使用,檔案描述符就是一個典型的例子。在這種設定中,狀態由擴充套件宿主管理,並由 WebAssembly 程式碼訪問和操作。
資源也可以反向工作,即其狀態由 WebAssembly 程式碼管理,並由擴充套件程式碼訪問和操作。這種方法對於 VS Code 在 WebAssembly 中實現有狀態的服務,然後從 TypeScript 端訪問它們特別有用。在下面的示例中,我們定義了一個資源,它實現了一個支援逆波蘭表示法的計算器,類似於惠普手持計算器中使用的那種。
// wit/calculator.wit
package vscode:example;
interface types {
enum operation {
add,
sub,
mul,
div
}
resource engine {
constructor();
push-operand: func(operand: u32);
push-operation: func(operation: operation);
execute: func() -> u32;
}
}
world calculator {
export types;
}
下面是該計算器資源在 Rust 中的一個簡單實現:
impl EngineImpl {
fn new() -> Self {
EngineImpl {
left: None,
right: None,
}
}
fn push_operand(&mut self, operand: u32) {
if self.left == None {
self.left = Some(operand);
} else {
self.right = Some(operand);
}
}
fn push_operation(&mut self, operation: Operation) {
let left = self.left.unwrap();
let right = self.right.unwrap();
self.left = Some(match operation {
Operation::Add => left + right,
Operation::Sub => left - right,
Operation::Mul => left * right,
Operation::Div => left / right,
});
}
fn execute(&mut self) -> u32 {
self.left.unwrap()
}
}
在 TypeScript 程式碼中,我們以與之前相同的方式繫結匯出。唯一的區別是,繫結過程現在為我們提供了一個代理類,用於在 WebAssembly 程式碼中例項化和管理 calculator
資源。
// Bind the JavaScript Api
const api = calculator._.exports.bind(
instance.exports as calculator._.Exports,
wasmContext
);
context.subscriptions.push(
vscode.commands.registerCommand('vscode-samples.wasm-component-model.run', () => {
channel.show();
channel.appendLine('Running calculator example');
// Create a new calculator engine
const calculator = new api.types.Engine();
// Push some operands and operations
calculator.pushOperand(10);
calculator.pushOperand(20);
calculator.pushOperation(Types.Operation.add);
calculator.pushOperand(2);
calculator.pushOperation(Types.Operation.mul);
// Calculate the result
const result = calculator.execute();
channel.appendLine(`Result: ${result}`);
})
);
當您執行相應的命令時,它會在輸出通道中列印 Result: 60
。如前所述,資源的狀態存在於呼叫邊界的一側,並使用控制代碼從另一側訪問。除了傳遞給與資源互動的方法的引數外,不會發生資料複製。
此示例的完整原始碼可在 VS Code 擴充套件示例倉庫中找到。
直接從 Rust 使用 VS Code API
元件模型資源可以用於封裝和管理跨 WebAssembly 元件和宿主的狀態。這一能力使我們能夠利用資源將 VS Code API 規範地暴露給 WebAssembly 程式碼。這種方法的優點在於,整個擴充套件可以用一種可以編譯成 WebAssembly 的語言來編寫。我們已經開始探索這種方法,下面是一個用 Rust 編寫的擴充套件的原始碼:
use std::rc::Rc;
#[export_name = "activate"]
pub fn activate() -> vscode::Disposables {
let mut disposables: vscode::Disposables = vscode::Disposables::new();
// Create an output channel.
let channel: Rc<vscode::OutputChannel> = Rc::new(vscode::window::create_output_channel("Rust Extension", Some("plaintext")));
// Register a command handler
let channel_clone = channel.clone();
disposables.push(vscode::commands::register_command("testbed-component-model-vscode.run", move || {
channel_clone.append_line("Open documents");
// Print the URI of all open documents
for document in vscode::workspace::text_documents() {
channel.append_line(&format!("Document: {}", document.uri()));
}
}));
return disposables;
}
#[export_name = "deactivate"]
pub fn deactivate() {
}
請注意,這段程式碼類似於用 TypeScript 編寫的擴充套件。
儘管這一探索看起來很有希望,但我們目前決定暫不繼續推進。主要原因是 WASM 缺乏非同步支援。許多 VS Code API 是非同步的,這使得它們很難直接代理到 WebAssembly 程式碼中。我們可以將 WebAssembly 程式碼在一個單獨的工作執行緒中執行,並採用與 WASI 預覽版 1 支援中相同的同步機制,在 WebAssembly 工作執行緒和擴充套件宿主工作執行緒之間進行同步。然而,這種方法可能會在同步 API 呼叫期間導致意外行為,因為這些呼叫實際上是非同步執行的。結果是,可觀察的狀態可能會在兩個同步呼叫之間發生變化(例如,setX(5); getX();
可能不會返回 5)。
此外,目前正在努力為 WASI 引入完整的非同步支援,預計在 0.3 預覽版的時間範圍內實現。Luke Wagner 在 WASM I/O 2024 上更新了非同步支援的當前狀態。我們決定等待這個支援,因為它將能夠實現一個更完整、更清晰的實現。
如果您對相應的 WIT 檔案、Rust 程式碼和 TypeScript 程式碼感興趣,可以在 vscode-wasm 倉庫的 rust-api 資料夾中找到它們。
下一步計劃
我們目前正在準備一篇後續部落格文章,將涵蓋更多可以使用 WebAssembly 程式碼進行擴充套件開發的領域。主要議題將包括:
- 在 WebAssembly 中編寫語言伺服器。
- 使用生成的元模型將長時間執行的 WebAssembly 程式碼透明地解除安裝到單獨的工作執行緒中。
隨著 VS Code 對元件模型的原生實現到位,我們將繼續努力為 VS Code 實現 WASI 0.2 預覽版。
謝謝,
Dirk 和 VS Code 團隊
編碼愉快!