使用 WebAssembly 进行扩展开发
2024年5月8日 作者:Dirk Bäumer
Visual Studio Code 支持通过 WebAssembly 执行引擎 扩展来执行 WASM 二进制文件。主要用例是将用 C/C++ 或 Rust 编写的程序编译成 WebAssembly,然后在 VS Code 中直接运行这些程序。一个显著的例子是 教育版 Visual Studio Code,它利用此支持在 VS Code for Web 中运行 Python 解释器。该 博客文章 提供了有关此实现的详细见解。
2024年1月,字节码联盟推出了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代码中封装和管理状态ful对象。
这些示例需要安装以下工具的最新版本,以及 VS Code 和 NodeJS:rust 编译器工具链,wasm-tools,和 wit-bindgen。
我还想感谢L. Pereira和 Luke Wagner 来自Fastly 对本文的宝贵反馈。
用 Rust 编写的计算器
在第一个示例中,我们展示了开发人员如何将用Rust编写的库集成到VS Code扩展中。如之前所述,组件是使用WIT文件描述的。在我们的示例中,该库执行简单的操作,如加法、减法、乘法和除法。相应的WIT文件如下所示:
包 vscode:example;
接口类型 {
记录操作数 {
左: u32,
右: u32
}
可变操作 {
加(操作数),
减(操作数),
乘(操作数),
除(操作数)
}
}
世界计算器 {
使用类型.{ 操作 };
导出 calc: 函数(o: operation) -> u32;
}
Rust 工具wit-bindgen被用来为计算器生成Rust绑定。使用这个工具有两种方法:
-
作为一个直接在实现文件中生成绑定的程序宏。这种方法是标准的,但缺点是不允许检查生成的绑定代码。
-
作为一个命令行工具,它在磁盘上创建一个绑定文件。这种方法在下面的资源示例中可以在VS Code 扩展示例仓库中找到。
对应的Rust文件,使用了wit-bindgen工具作为过程宏,如下所示:
// 使用程序宏来生成我们指定的世界的绑定
// 在 `calculator.wit`
wit_bindgen::生成!({
// `*.wit` 输入文件中的世界名称
世界: "计算器",
});
然而,使用命令将 Rust 文件编译为 WebAssemblycargo build --target wasm32-unknown-unknown由于导出的未实现,导致编译错误。计算功能。以下是该功能的简单实现。计算功能:
// 使用程序宏来生成我们指定的世界的绑定
// 在 `calculator.wit`
wit_bindgen::生成!({
// `*.wit` 输入文件中的世界名称
世界: "计算器",
});
结构体 计算器;
实现 客户 为 计算器 {
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(操作数) => 操作数.左 / 操作数.右,
}
}
}
// 导出计算器到扩展代码。
导出!(计算器);
该导出!(计算器);文件末尾的语句导出了计算器从 WebAssembly 代码启用扩展调用 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产生一个计算器.ts文件在源文件夹,包含用于 WebAssembly 代码的 TypeScript 绑定。一个简单的利用这些绑定的扩展如下所示:
导入 * 作为 vscode 从 'vscode';
导入 { WasmContext, Memory } 从 ' @vscode/wasm-component-model';
// 导入由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 中编译并运行上述代码时,它会在 中生成以下输出计算器频道:
你可以在这个示例的完整源代码在VS Code 扩展示例仓库中找到。
在 @vscode/wasm-component-model 内部
检查由生成的源代码 wit2ts工具揭示了其对...的依赖@vscode/wasm组件模型 npm 模块。这个模块是 VS Code 中 组件模型的规范 ABI 的实现,并从相应的 Python 代码中汲取灵感。虽然理解组件模型的内部细节不是阅读本文的必要条件,但我们将会揭示其工作原理,特别是关于数据如何在 JavaScript/TypeScript 和 WebAssembly 代码之间传递。
与其他工具如wit-bindgen或jco生成WIT文件绑定的工具不同, wit2ts 创建一个元模型,然后可以在运行时根据各种用例生成绑定。这种灵活性使我们能够满足 VS Code 扩展开发的架构要求。通过使用这种方法,我们可以“promisify”绑定,并使 WebAssembly 代码能够在 workers 中运行。我们使用这种机制来实现 VS Code 的 WASI 0.2 预览版。
你可能已经注意到,在生成绑定时,函数使用像这样的名称进行引用计算器._.导入创建(注意下划线)。为了避免与WIT文件中的符号发生名称冲突(例如,可能会有一个名为的类型定义进口), API 函数被放置在一个输入:_命名空间。元模型本身位于一个输入:$命名空间。因此,计算器.$.exports.calc代表导出的元数据计算功能。
在上面的例子中,添加操作参数传递到计算函数由三个字段组成:操作码、左值和右值。根据组件模型的规范 ABI,参数通过值传递。它还概述了数据如何被序列化、传递到 WebAssembly 函数,并在另一端反序列化。这个过程会产生两个操作对象:一个在 JavaScript 堆中,另一个在线性 WebAssembly 内存中。以下图表说明了这一点:

下表列出了可用的WIT类型、它们在VS Code组件模型实现中的JavaScript对象映射以及相应的TypeScript类型。
| 维特 | JavaScript | TypeScript |
|---|---|---|
| u8 | 数字 | 类型 u8 = 数字; |
| u16 | 数字 | 类型 u16 = 数字; |
| u32 | 数字 | 类型 u32 = number; |
| u64 | 大整数 | 类型 u64 = 大整数; |
| s8 | 数字 | 类型 s8 = 数字; |
| s16 | 数字 | 类型 s16 = 数字; |
| s32 | 数字 | 类型 s32 = 数字; |
| s64 | 大整数 | 类型 s64 = 大整数; |
| 浮点数32 | 数字 | 类型 float32 = number; |
| 浮点型64 | 数字 | 类型 float64 = 数字; |
| 布尔 | 布尔 | 布尔 |
| 字符串 | 字符串 | 字符串 |
| 字符 | 字符串[0] | 字符串 |
| 记录 | 对象字面量 | 类型声明 |
| 列表<T> | [] | 数组<T> |
| 元组<T1, T2> | [] | [T1, T2] |
| 枚举 | 字符串值 | 字符串枚举 |
| 旗帜 | 数字 | 大整数 |
| 变体 | 对象字面量 | 带区分的联合 |
| 选项<T> | 变量 | ? 和 (T | 未定义) |
| 结果<成功, 错误> | 异常或对象字面量 | 异常或结果类型 |
需要注意的是,组件模型不支持低级(C风格)指针。因此,你不能传递对象图或递归数据结构。在这方面,它与JSON具有相同的限制。为了尽量减少数据复制,组件模型引入了资源的概念,我们将在本文的后续部分中对此进行更详细的讨论。
该jco项目还支持使用生成WebAssembly组件的JavaScript/TypeScript绑定。类型命令。正如前面提到的,我们开发了自己的工具以满足 VS Code 的特定需求。然而,我们与 jco 团队定期进行两周一次的会议,以确保在可能的情况下工具之间的一致性。一个基本要求是,两个工具应使用相同的 WIT 数据类型 JavaScript 和 TypeScript 表示。我们还在探索两个工具之间共享代码的可能性。
从 WebAssembly 代码调用 TypeScript
WIT文件描述了主机(VS Code扩展)与WebAssembly代码之间的交互,促进了双向通信。在我们的示例中,此功能允许WebAssembly代码记录其活动的跟踪信息。要启用此功能,我们修改WIT文件如下:
世界计算器 {
/// ....
/// 在主机侧实现的日志函数。
import log: func(msg: string);
/// ...
}
在 Rust 侧,我们现在可以调用 log 函数:
```plaintext
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 实例。
导出 异步 函数 激活(上下文: vscode.扩展上下文): 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);
}
};
// 创建绑定将 log 函数导入到 WASM 模块
const imports = calculator._.imports;create(service, wasmContext);
// 实例化模块
const instance = await WebAssembly.instantiate(module, imports);
// ...
}
与第一个例子相比,WebAssembly实例化调用现在包括结果calculator._.imports.create(service, wasmContext)作为第二个参数。这导入创建 call 从服务实现中生成低级 WASM 绑定。在初始示例中,我们传递了一个空的对象字面量,因为不需要导入。这次,我们在 VS Code 桌面环境中通过调试器执行扩展。多亏了 Connor Peet 的出色工作,现在可以在 Rust 代码中设置断点,并使用 VS Code 调试器逐步执行。
使用组件模型资源
WebAssembly 组件模型引入了资源的概念,提供了封装和管理状态的标准化机制。状态在调用边界的一侧进行管理(例如,在 TypeScript 代码中),在另一侧进行访问和操作(例如,在 WebAssembly 代码中)。资源在 WASI 预览版 0.2 的 API 中被广泛使用,文件描述符就是一个典型的例子。在这种设置中,状态由扩展主机管理,由 WebAssembly 代码进行访问和操作。
资源也可以反向工作,其中它们的状态由WebAssembly代码管理,并由扩展代码访问和操作。这种方法特别有利于VS Code实现状态ful服务在WebAssembly中,然后从TypeScript侧访问这些服务。在下面的示例中,我们定义了一个实现支持逆波兰表示法的计算器的资源,类似于惠普手持计算器中使用的表示法。
// wit/calculator.wit
包 vscode:example;
接口类型 {
枚举操作 {
加,
减,
乘,
除
}
资源引擎 {
构造函数();
压入操作数: 函数(操作数: u32);
压入运算符: 函数(运算符: 运算符);
执行: 函数() -> u32;
}
}
世界计算器 {
导出类型;
}
以下是 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,
操作::乘 => 左 * 右,
操作::除 => 左 / 右,
});
}
```plaintext
fn execute(&mut self) -> u32 {
self.left.unwrap()
}
```
在 TypeScript 代码中,我们以之前相同的方式绑定导出。唯一的区别是,现在绑定过程为我们提供了一个代理类,用于实例化和管理一个计算器在WebAssembly代码中的资源。
// 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);
// 计算结果
const result = calculator.execute();
channel.appendLine(`结果: ${result}`);
})
);
当你运行相应的命令时,它打印结果:60到输出通道。如前面所述,资源的状态位于调用边界的一侧,并通过句柄从另一侧访问。除了传递给与资源交互的方法的参数外,不会发生数据复制。

这个示例的完整源代码可以在VS Code 扩展示例仓库找到。
直接从 Rust 使用 VS Code API
组件模型资源可以封装和管理WebAssembly组件和主机之间的状态。这种能力使我们能够利用资源将VS Code API规范地暴露到WebAssembly代码中。这种方法的优势在于整个扩展可以使用编译为WebAssembly的语言进行编写。我们已经开始探索这种方法,以下是用Rust编写的扩展的源代码:
使用 标准库::引用计数::引用计数;
#[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");
```plaintext
// 打印所有打开文档的URI
for vscode::workspace::text_documents() {
channel.append_line(&format!("Document: {}", document.uri()));
}
return disposables;
}
```
#[导出名称 = "deactivate"]
发布 函数 deactivation() {
}
请注意,这段代码类似于用 TypeScript 编写的扩展。
尽管这一探索看起来很有前景,但我们决定目前不继续推进。主要原因是WASM缺乏异步支持。许多VS Code API是异步的,这使得它们难以直接代理到WebAssembly代码中。我们可以在单独的工人中运行WebAssembly代码,并在WebAssembly工人和扩展主机工人之间使用与WASI Preview 1支持相同的同步机制。然而,这种方法在同步API调用期间可能会导致意外行为,因为这些调用实际上会异步执行。因此,两个同步调用之间可能会观察到的状态变化(例如,setX(5); getX();可能不会返回5)。
此外,正在努力在0.3预览版本的时间框架内为WASI引入完整的异步支持。 Luke Wagner在WASM I/O 2024上提供了当前异步支持状态的更新。我们决定等待这一支持,因为它将使实现更加完整和干净。
如果你对相应的WIT文件、Rust代码和TypeScript代码感兴趣,可以在vscode-wasm仓库的rust-api文件夹中找到它们。
接下来是什么
我们目前正在准备一篇后续博客文章,该文章将涵盖WebAssembly代码在扩展开发中可以利用的更多领域。主要话题将包括:
- 编写语言服务器在WebAssembly中。
- 使用生成的元模型将长时间运行的WebAssembly代码透明地卸载到单独的工人中。
有了 VS Code 组件模型的 idiomatic 实现,我们继续努力为 VS Code 实现 WASI 0.2 预览版。
谢谢,
Dirk 和 VS Code 团队
祝你编码愉快!