使用 WebAssembly 进行扩展开发

2024年5月8日,作者:Dirk Bäumer

Visual Studio Code 通过 WebAssembly Execution Engine 扩展支持 WASM 二进制文件的执行。其主要用例是将 C/C++ 或 Rust 编写的程序编译为 WebAssembly,然后直接在 VS Code 中运行这些程序。一个显著的例子是 Visual Studio Code for Education,它利用此支持在 VS Code for the Web 中运行 Python 解释器。这篇博客文章详细介绍了该功能的实现方式。

2024年1月,字节码联盟(Bytecode Alliance)发布了 WASI 0.2 预览版。WASI 0.2 预览版中的一项关键技术是组件模型(Component Model)。WebAssembly 组件模型通过标准化接口、数据类型和模块组合,简化了 WebAssembly 组件与其宿主环境之间的交互。这种标准化通过使用 WIT(WASM 接口类型)文件来实现。WIT 文件有助于描述 JavaScript/TypeScript 扩展(宿主)与在其他语言(如 Rust 或 C/C++)中编写并执行计算的 WebAssembly 组件之间的交互。

本篇博客概述了开发人员如何利用组件模型将 WebAssembly 库集成到他们的扩展中。我们将重点关注三个用例:(a) 使用 WebAssembly 实现一个库并从 JavaScript/TypeScript 扩展代码中调用它;(b) 从 WebAssembly 代码中调用 VS Code API;(c) 演示如何使用资源(resources)来封装和管理 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 绑定。使用该工具有两种方法:

  • 作为过程宏(procedural macro),直接在实现文件中生成绑定。这种方法很标准,但缺点是无法检查生成的绑定代码。

  • 作为命令行工具,在磁盘上创建绑定文件。下文关于资源示例的 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 工具用于生成必要的 TypeScript 绑定,以便在 VS Code 扩展中与 WebAssembly 代码进行交互。该工具由 VS Code 团队开发,旨在满足 VS Code 扩展架构的特定需求,主要是因为:

  • VS Code API 仅在扩展宿主工作线程(extension host worker)内可访问。从扩展宿主工作线程生成的任何额外工作线程都无法访问 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)}`);
    })
  );
}

当您在 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 文件绑定的工具(如 wit-bindgenjco)不同,wit2ts 创建了一个元模型(meta model),该模型随后可用于为各种用例在运行时生成绑定。这种灵活性使我们能够满足 VS Code 扩展开发的架构要求。通过使用这种方法,我们可以将绑定“Promise 化”(promisify),并实现在工作线程中运行 WebAssembly 代码。我们利用这一机制为 VS Code 实现了 WASI 0.2 预览版

您可能已经注意到,在生成绑定时,函数是通过类似 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 布尔值 布尔值
字符串 字符串 字符串
char string[0] 字符串
record 对象字面量 类型声明
list<T> [] Array<T>
tuple<T1, T2> [] [T1, T2]
enum 字符串值 字符串枚举
标志 (flags) number bigint
变体 (variant) 对象字面量 判别联合 (discriminated union)
option<T> variable ? 和 (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 组件模型引入了资源(resources)概念,它提供了一种标准化的机制来封装和管理状态。此状态在调用边界的一侧(例如,在 TypeScript 代码中)进行管理,并在另一侧(例如,在 WebAssembly 代码中)进行访问和操作。资源在 WASI 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 打印到输出通道。如前所述,资源的状态位于调用边界的一侧,并使用句柄从另一侧进行访问。除与资源交互的方法传递的参数外,不发生任何数据拷贝。

Diagram illustrating how resources are accessed.

此示例的完整源代码可在 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 代码运行在单独的工作线程中,并使用在 WASI Preview 1 支持中使用的相同同步机制,在 WebAssembly 工作线程和扩展宿主工作线程之间进行协调。然而,这种方法可能导致在同步 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 惯用的组件模型实现到位,我们将继续努力为 VS Code 实现 WASI 0.2 预览版。

谢谢,

Dirk 和 VS Code 团队

编码愉快!

© . This site is unofficial and not affiliated with Microsoft.