使用 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 月,字节码联盟发布了 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);
语句从 WebAssembly 代码导出 Calculator
,以使扩展能够调用 API。
wit2ts
工具用于生成必要的 TypeScript 绑定,以便在 VS Code 扩展中与 WebAssembly 代码进行交互。此工具由 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)}`);
})
);
}
当您在 Web 版 VS Code 中编译并运行上述代码时,它会在 Calculator
通道中生成以下输出
您可以在 VS Code 扩展示例仓库 中找到此示例的完整源代码。
深入了解 @vscode/wasm-component-model
检查 wit2ts
工具生成的源代码会发现它依赖于 @vscode/wasm-component-model
npm 模块。此模块充当 VS Code 的 组件模型的规范 ABI 实现,并从相应的 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
函数的 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> | 变量 | ? 和 (T | undefined) |
result<ok, err> | 异常或对象字面量 | 异常或结果类型 |
重要的是要注意,组件模型不支持低级(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 preview 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 代码在单独的 worker 中运行,并采用 WASI Preview 1 支持中在 WebAssembly worker 和扩展主机 worker 之间使用的相同同步机制。但是,此方法可能会在同步 API 调用期间导致意外行为,因为这些调用实际上将异步执行。因此,可观察状态可能会在两个同步调用之间发生更改(例如,setX(5); getX();
可能不会返回 5)。
此外,正在努力在 0.3 预览版时间范围内将完全异步支持引入 WASI。Luke Wagner 在 WASM I/O 2024 上提供了关于当前异步支持状态的更新。我们已决定等待此支持,因为它将实现更完整和更清晰的实现。
如果您对相应的 WIT 文件、Rust 代码和 TypeScript 代码感兴趣,可以在 vscode-wasm 存储库的 rust-api 文件夹中找到它们。
接下来会发生什么
我们目前正在准备一篇后续博客文章,其中将涵盖更多可以利用 WebAssembly 代码进行扩展开发的领域。主要主题将包括
- 使用 WebAssembly 编写 语言服务器。
- 使用生成的元模型将长时间运行的 WebAssembly 代码透明地卸载到单独的 worker 中。
随着 VS Code 中组件模型的惯用实现的到位,我们将继续努力为 VS Code 实现 WASI 0.2 预览版。
谢谢,
Dirk 和 VS Code 团队
快乐编码!