使用 WebAssembly 进行扩展开发
2024 年 5 月 8 日,作者 Dirk Bäumer
Visual Studio Code 支持通过 WebAssembly 执行引擎 扩展执行 WASM 二进制文件。主要用例是将用 C/C++ 或 Rust 编写的程序编译成 WebAssembly,然后在 VS Code 中直接运行这些程序。一个值得注意的例子是 Visual Studio Code for Education,它利用此支持在 Web 版本的 VS Code 中运行 Python 解释器。这个 博客文章 提供了关于如何实现此功能的详细见解。
2024 年 1 月,Bytecode Alliance 发布了 WASI 0.2 预览版。WASI 0.2 预览版中的一项关键技术是 组件模型。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 扩展示例仓库 中资源示例的代码中得到了体现。
使用 wit-bindgen 工具作为过程宏的相应 Rust 文件如下所示
// 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 工具用于生成与 WebAssembly 代码在 VS Code 扩展中交互所需的 TypeScript 绑定。该工具由 VS Code 团队开发,以满足 VS Code 扩展架构的特定要求,主要是因为
- VS Code API 只能在扩展宿主 worker 中访问。从扩展宿主 worker 生成的任何其他 worker 无法访问 VS Code API,这与 NodeJS 或浏览器等环境形成对比,在这些环境中,每个 worker 通常可以访问几乎所有的运行时 API。
- 多个扩展共享相同的扩展宿主 worker。扩展应避免在该 worker 上执行任何长时间同步计算。
当我们实现 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)}`);
})
);
}
在 Web 版本的 VS Code 中编译并运行上述代码,会在 Calculator 频道中产生以下输出
您可以在 VS Code 扩展示例仓库 中找到此示例的完整源代码。
在 @vscode/wasm-component-model 内部
检查 wit2ts 工具生成的源代码显示它依赖于 @vscode/wasm-component-model npm 模块。该模块充当组件模型的 规范 ABI 的 VS Code 实现,并受到相应的 Python 代码的启发。虽然理解组件模型的内部原理对于理解本博客文章不是必需的,但我们将阐明其工作原理,特别是关于数据如何在 JavaScript/TypeScript 和 WebAssembly 代码之间传递。
与其他生成 WIT 文件绑定的工具(例如 wit-bindgen 或 jco)不同,wit2ts 创建一个元模型,然后可以将其用于在运行时为各种用例生成绑定。这种灵活性使我们能够满足 VS Code 扩展开发的架构要求。通过这种方法,我们可以“promise 化”绑定并启用在 worker 中运行 WebAssembly 代码。我们利用这种机制来实现 WASI 0.2 预览版 的 VS Code。
您可能注意到,在生成绑定时,函数使用类似于 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 | object literal | type declaration |
| list<T> | [] | Array<T> |
| tuple<T1, T2> | [] | [T1, T2] |
| enum | string values | string enum |
| flags | number | bigint |
| variant | object literal | discriminated union |
| option<T> | variable | ? and (T | undefined) |
| result<ok, err> | Exception or object literal | Exception or result type |
重要的是要注意,组件模型不支持低级(C 风格)指针。因此,您无法传递对象图或递归数据结构。在这方面,它与 JSON 具有相同的限制。为了最大限度地减少数据复制,组件模型引入了资源的概念,我们将在本博客文章后面的部分中更详细地探讨它。
jco 项目 也支持使用 type 命令为 WebAssembly 组件生成 JavaScript/TypeScript 绑定。如前所述,我们开发了自己的工具来满足 VS Code 的特定需求。但是,我们与 jco 团队进行双周会议,以确保工具尽可能保持一致。一个基本要求是,两个工具都应使用相同的 JavaScript 和 TypeScript 表示形式来表示 WIT 数据类型。我们还在探索在两个工具之间共享代码的可能性。
从 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 preview 0.2 API 中被广泛使用,文件描述符是一个典型的例子。在这种设置中,状态由扩展宿主管理,并由 WebAssembly 代码访问和操作。
资源也可以反向工作,即其状态由 WebAssembly 代码管理,并由扩展代码访问和操作。这种方法对于 VS Code 实现 WebAssembly 中的有状态服务特别有益,然后从 TypeScript 端访问这些服务。在下面的示例中,我们定义了一个资源,它实现了一个计算器,支持 逆波兰表示法,类似于 惠普 (Hewlett-Packard) 手持计算器中使用的计算器。
// 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 打印到输出通道。如前所述,资源的 state 驻留在调用边界的一侧,并使用句柄从另一侧访问。除了传递给与资源交互的方法的参数外,不会发生数据复制。

此示例的完整源代码可在 VS Code 扩展示例仓库 中找到。
直接从 Rust 使用 VS Code API
组件模型资源可以用来封装和管理 WebAssembly 组件和宿主之间的 state。这种能力使我们能够利用资源将 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 中缺乏 async 支持。许多 VS Code API 是异步的,因此很难直接代理到 WebAssembly 代码中。我们可以在单独的 worker 中运行 WebAssembly 代码,并采用在 WASI Preview 1 支持 中使用的 worker 和扩展宿主 worker 之间的相同同步机制。但是,这种方法可能会导致同步 API 调用期间出现意外行为,因为这些调用实际上将异步执行。因此,可观察的 state 可能会在两次同步调用之间发生变化(例如,setX(5); getX(); 可能不会返回 5)。
此外,目前正在努力在 0.3 preview 时间范围内为 WASI 引入完整的 async 支持。Luke Wagner 在 WASM I/O 2024 上提供了有关 async 支持当前状态的更新。我们决定等待此支持,因为它将能够实现更完整和清晰的实现。
如果您对相应的 WIT 文件、Rust 代码和 TypeScript 代码感兴趣,可以在 rust-api 文件夹的 vscode-wasm 仓库中找到它们。
接下来会发生什么
我们目前正在准备一篇后续博客文章,该文章将涵盖 WebAssembly 代码可用于扩展开发的更多领域。主要主题将包括
- 用 WebAssembly 编写 语言服务器。
- 使用生成的元模型将长时间运行的 WebAssembly 代码透明地卸载到单独的 worker 中。
在组件模型的 VS Code 惯用实现到位后,我们继续努力为 VS Code 实现 WASI 0.2 preview。
谢谢,
Dirk 和 VS Code 团队
编码愉快!