将 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 月,字节码联盟推出了 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 扩展示例存储库 中的代码中得到了体现。
相应的 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 只能在扩展主机工作程序中访问。从扩展主机工作程序生成的任何其他工作程序都无法访问 VS Code API,这与 NodeJS 或浏览器等环境形成对比,在这些环境中,每个工作程序通常都可以访问几乎所有运行时 API。
- 多个扩展共享同一个扩展主机工作程序。扩展应避免在该工作程序上执行任何长时间运行的同步计算。
当我们实现 WASI Preview 1 for VS Code 时,这些架构要求已经到位。但是,我们最初的实现是手动编写的。为了预测组件模型更广泛的采用,我们开发了一个工具来促进组件与其特定于 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 模块。此模块充当 组件模型的规范 ABI 的 VS Code 实现,并从相应的 Python 代码中汲取灵感。虽然理解组件模型的内部机制对于理解这篇文章来说不是必需的,但我们将阐明其工作原理,特别是在 JavaScript/TypeScript 代码与 WebAssembly 代码之间传递数据的方式方面。
与 wit-bindgen 或 jco 等其他生成 WIT 文件绑定的工具不同,wit2ts
创建了一个元模型,然后可以在运行时用于为各种用例生成绑定。这种灵活性使我们能够满足 VS Code 中扩展开发的架构要求。通过使用这种方法,我们可以“使绑定成为承诺”,并能够在工作程序中运行 WebAssembly 代码。我们使用这种机制来实现 WASI 0.2 预览版 for 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 | 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> | variable | ? 和 (T | undefined) |
result<ok, err> | 异常或对象字面量 | 异常或结果类型 |
需要注意的是,组件模型不支持低级(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](https://github.com/connor4312) 的出色工作,现在可以在 Rust 代码中设置断点,并使用 VS Code 调试器单步执行代码。
使用组件模型资源
WebAssembly 组件模型引入了资源的概念,它提供了一种标准化的机制来封装和管理状态。此状态在一个调用边界的一侧管理(例如,在 TypeScript 代码中),并在另一侧(例如,在 WebAssembly 代码中)访问和操作。资源在 [WASI 预览 0.2](https://bytecodealliance.org/articles/WASI-0.2) API 中被广泛使用,文件描述符就是一个典型的例子。在此设置中,状态由扩展主机管理,并由 WebAssembly 代码访问和操作。
资源也可以在相反的方向上起作用,其状态由 WebAssembly 代码管理,并由扩展代码访问和操作。这种方法对于 VS Code 在 WebAssembly 中实现有状态服务特别有利,这些服务随后可以从 TypeScript 侧访问。在下面的示例中,我们定义了一个资源,它实现了支持 [逆波兰表达式](https://en.wikipedia.org/wiki/Reverse_Polish_notation) 的计算器,类似于 [惠普](https://www.hp.com/) 手持计算器中使用的计算器。
// 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 扩展示例存储库](https://insiders.vscode.dev/github/microsoft/vscode-extension-samples/blob/main/wasm-component-model-resource/src/extension.ts#L1) 中找到。
直接从 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 代码中。我们可以在单独的 worker 中运行 WebAssembly 代码,并使用与 [WASI 预览 1 支持](https://vscode.js.cn/blogs/2023/06/05/vscode-wasm-wasi) 相同的同步机制在 WebAssembly worker 和扩展主机 worker 之间进行同步。但是,这种方法可能会导致同步 API 调用期间出现意外行为,因为这些调用实际上是异步执行的。因此,可观察到的状态可能会在两次同步调用之间发生变化(例如,`setX(5); getX();` 可能不会返回 5)。
此外,人们正在努力在 0.3 预览时间范围内为 WASI 引入完整的异步支持。Luke Wagner 在 [WASM I/O 2024](https://www.youtube.com/watch?v=y3x4-nQeXxc) 上提供了关于异步支持当前状态的更新。我们决定等待此支持,因为它将使我们能够实现更完整和干净的实现。
如果你对相应的 WIT 文件、Rust 代码和 TypeScript 代码感兴趣,可以在 [vscode-wasm 存储库的 rust-api 文件夹](https://insiders.vscode.dev/github/microsoft/vscode-wasi/blob/dbaeumer/early-kingfisher-tan/rust-api/package.json#L1) 中找到它们。
下一步
我们目前正在准备一篇后续博文,将涵盖更多可以使用 WebAssembly 代码进行扩展开发的领域。主要主题将包括:
- 用 WebAssembly 编写 [语言服务器](https://microsoft.github.io/language-server-protocol/)。
- 使用生成的元模型将长时间运行的 WebAssembly 代码透明地卸载到单独的 worker 中。
随着 VS Code 中对组件模型的惯用实现到位,我们将继续努力为 VS Code 实现 WASI 0.2 预览。
感谢,
Dirk 和 VS Code 团队
祝您编码愉快!