现已推出!阅读 10 月份发布的新功能和修复。

将 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-toolswit-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 ./witsrc 文件夹中生成一个 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-bindgenjco 等其他生成 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 内存中。下图说明了这一点

Diagram illustrating how parameters are passed.

下表列出了可用的 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` 打印到输出通道。如前所述,资源的状态驻留在调用边界的一侧,并使用句柄从另一侧访问。除了传递给与资源交互的方法的参数外,没有发生数据复制。

Diagram illustrating how resources are accessed.

此示例的完整源代码可在 [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 团队

祝您编码愉快!