使用 WebAssembly 进行扩展开发
2024 年 5 月 8 日,作者 Dirk Bäumer
Visual Studio Code 通过 WebAssembly Execution Engine 扩展支持执行 WASM 二进制文件。主要用例是将用 C/C++ 或 Rust 编写的程序编译为 WebAssembly,然后直接在 VS Code 中运行这些程序。一个显著的例子是面向教育的 Visual Studio Code,它利用此支持在 VS Code 网页版中运行 Python 解释器。此博客文章提供了有关如何实现此功能的详细见解。
2024 年 1 月,Bytecode Alliance 推出了 WASI 0.2 预览版。WASI 0.2 预览版中的一项关键技术是 Component Model。WebAssembly Component Model 通过标准化接口、数据类型和模块组成,简化了 WebAssembly 组件与其宿主环境之间的交互。这种标准化通过使用 WIT(WASM Interface Type)文件得以实现。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);
语句从 WebAssembly 代码中导出 Calculator
,以便扩展能够调用 API。
wit2ts
工具用于生成与 VS Code 扩展中的 WebAssembly 代码交互所需的 TypeScript 绑定。此工具由 VS Code 团队开发,旨在满足 VS Code 扩展架构的特定要求,主要原因如下
- VS Code API 仅可在扩展宿主工作进程内访问。从扩展宿主工作进程派生的任何额外工作进程都无法访问 VS Code API,这与 NodeJS 或浏览器等环境不同,在这些环境中,每个工作进程通常都可以访问几乎所有运行时 API。
- 多个扩展共享同一个扩展宿主工作进程。扩展应避免在该工作进程上执行任何长时间运行的同步计算。
当我们为 VS Code 实现 WASI Preview 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 网页版中编译并运行上述代码时,它会在“Calculator
”频道中生成以下输出
您可以在 VS Code 扩展示例仓库中找到此示例的完整源代码。
@vscode/wasm-component-model 内部
检查 wit2ts
工具生成的源代码,可以看到它依赖于 @vscode/wasm-component-model
npm 模块。此模块充当组件模型规范 ABI 的 VS Code 实现,并借鉴了相应的 Python 代码。虽然理解本文博客文章无需理解组件模型的内部原理,但我们将对其工作原理进行一些阐述,特别是关于数据如何在 JavaScript/TypeScript 和 WebAssembly 代码之间传递。
与 wit-bindgen 或 jco 等为 WIT 文件生成绑定的其他工具不同,wit2ts
会创建一个元模型,然后可以在运行时用于各种用例生成绑定。这种灵活性使我们能够满足 VS Code 内扩展开发的架构要求。通过使用这种方法,我们可以“promise 化”绑定,并使 WebAssembly 代码能够在工作进程中运行。我们利用这种机制来为 VS Code 实现 WASI 0.2 预览版。
您可能已经注意到,在生成绑定时,函数会使用 calculator._.imports.create
之类的名称(请注意下划线)进行引用。为了避免与 WIT 文件中的符号(例如,可能存在一个名为 imports
的类型定义)发生名称冲突,API 函数被放置在一个 _
命名空间中。元模型本身位于一个 $
命名空间中。因此,calculator.$.exports.calc
代表导出的 calc
函数的元数据。
在上面的示例中,传递给 calc
函数的加法操作参数包含三个字段:操作码、左值和右值。根据组件模型的规范 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 | boolean | boolean |
string | string | string |
char | string[0] | string |
record | 对象字面量 | 类型声明 |
list<T> | [] | Array<T> |
tuple<T1, T2> | [] | [T1, T2] |
enum | 字符串值 | 字符串枚举 |
flags | number | bigint |
variant | 对象字面量 | 辨别联合类型 |
option<T> | 变量 | ? 和 (T | undefined) |
result<ok, err> | 异常或对象字面量 | 异常或 Result 类型 |
需要注意的是,组件模型不支持低级(C 风格)指针。因此,您不能传递对象图或递归数据结构。在这方面,它与 JSON 具有相同的限制。为了最大程度地减少数据复制,组件模型引入了资源的概念,我们将在本文博客文章的后续部分更详细地探讨这一概念。
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 组件模型引入了资源的概念,它提供了一种标准化的机制来封装和管理状态。此状态在调用边界的一侧(例如,在 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 Preview 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 团队
编程愉快!