使用WebAssembly进行扩展开发 - 第二部分

2024年6月7日,作者:Dirk Bäumer

在之前关于使用WebAssembly进行扩展开发的博客文章中,我们演示了如何使用组件模型将WebAssembly代码集成到您的Visual Studio Code扩展中。在这篇博客文章中,我们重点介绍两个额外的独立用例:(a) 在worker中运行WebAssembly代码以避免阻塞扩展宿主的主线程,以及(b) 使用一种编译为WebAssembly的语言创建语言服务器

要运行本博客文章中的示例,您需要以下工具:VS Code、Node.js、Rust编译器工具链wasm-toolswit-bindgen

在worker中执行WebAssembly代码

上一篇博客文章中的示例在VS Code扩展宿主主线程中运行了WebAssembly代码。只要执行时间短,这是可以的。然而,长时间运行的操作应该在worker中执行,以确保扩展宿主主线程仍然可用于其他扩展。

VS Code 组件模型提供了一个元模型,通过使我们能够在工作线程和扩展主端自动生成必要的粘合代码,从而使这变得更容易。

以下代码片段展示了工作者所需的代码。该示例假设代码存储在名为 worker.ts 的文件中:

import { Connection, RAL } from '@vscode/wasm-component-model';
import { calculator } from './calculator';

async function main(): Promise<void> {
  const connection = await Connection.createWorker(calculator._);
  connection.listen();
}

main().catch(RAL().console.error);

代码创建了一个连接以与扩展主机主工作器通信,并使用由wit2ts工具生成的calculator世界初始化该连接。

在扩展端,我们加载WebAssembly模块并将其绑定到calculator世界。由于计算在worker中是异步执行的(例如,await api.calc(...)),因此需要等待相应的调用来执行计算。

// The channel for printing the result.
const channel = vscode.window.createOutputChannel('Calculator');
context.subscriptions.push(channel);

// 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.Promisified = {
  log: async (msg: string): Promise<void> => {
    // Wait 100ms to slow things down :-)
    await new Promise(resolve => setTimeout(resolve, 100));
    log.info(msg);
  }
};

// Load the WASM model
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);

// Create the worker
const worker = new Worker(
  vscode.Uri.joinPath(context.extensionUri, './out/worker.js').fsPath
);
// Bind the world to the worker
const api = await calculator._.bind(service, module, worker);

vscode.commands.registerCommand(
  'vscode-samples.wasm-component-model-async.run',
  async () => {
    channel.show();
    channel.appendLine('Running calculator example');
    const add = Types.Operation.Add({ left: 1, right: 2 });
    channel.appendLine(`Add ${await api.calc(add)}`);
    const sub = Types.Operation.Sub({ left: 10, right: 8 });
    channel.appendLine(`Sub ${await api.calc(sub)}`);
    const mul = Types.Operation.Mul({ left: 3, right: 7 });
    channel.appendLine(`Mul ${await api.calc(mul)}`);
    const div = Types.Operation.Div({ left: 10, right: 2 });
    channel.appendLine(`Div ${await api.calc(div)}`);
  }
);

有几点重要事项需要注意:

  • 本示例中使用的WIT文件与之前博客文章中计算器示例中使用的文件没有区别。
  • 由于WebAssembly代码的执行发生在worker中,导入服务的实现(例如,上面的log函数)可以返回一个Promise,但这不是必须的。
  • WebAssembly 目前仅支持同步执行模型。因此,从执行 WebAssembly 代码的工作线程到扩展宿主主线程的每次调用,以调用导入的服务,都需要以下步骤:
    1. 向扩展宿主主线程发送一条消息,描述要调用的服务(例如,调用 log 函数)。
    2. 使用 Atomics.wait 暂停工作线程的执行。
    3. 在扩展宿主主线程中处理消息。
    4. 恢复工作线程并使用 Atomics.notify 通知其结果。

这种同步增加了可测量的时间开销。尽管所有这些步骤都由组件模型透明地处理,但开发人员应该意识到它们,并在设计导入的API表面时考虑这一点。

您可以在VS Code 扩展示例仓库中找到此示例的完整源代码。

基于WebAssembly的语言服务器

当我们开始着手为VS Code for the Web的WebAssembly支持工作时,我们设想的一个用例是使用WebAssembly执行语言服务器。随着VS Code的LSP库的最新更改以及引入一个新模块来桥接WebAssembly和LSP,现在实现一个WebAssembly语言服务器就像将其实现为一个操作系统进程一样简单。

此外,WebAssembly 语言服务器运行在 WebAssembly Core Extension 上,该扩展完全支持 WASI Preview 1。这意味着语言服务器可以使用其编程语言的常规文件系统 API 访问工作区中的文件,即使这些文件存储在远程位置,例如 GitHub 仓库中。

以下代码片段展示了一个基于lsp_server crate中的示例服务器的Rust语言服务器。这个语言服务器不执行任何语言分析,而是简单地返回一个预定义的结果给GotoDefinition请求:

match cast::<GotoDefinition>(req) {
    Ok((id, params)) => {
        let uri = params.text_document_position_params.text_document.uri;
        eprintln!("Received gotoDefinition request #{} {}", id, uri.to_string());
        let loc = Location::new(
            uri,
            lsp_types::Range::new(lsp_types::Position::new(0, 0), lsp_types::Position::new(0, 0))
        );
        let mut vec = Vec::new();
        vec.push(loc);
        let result = Some(GotoDefinitionResponse::Array(vec));
        let result = serde_json::to_value(&result).unwrap();
        let resp = Response { id, result: Some(result), error: None };
        connection.sender.send(Message::Response(resp))?;
        continue;
    }
    Err(err @ ExtractError::JsonError { .. }) => panic!("{err:?}"),
    Err(ExtractError::MethodMismatch(req)) => req,
};

你可以在VS Code示例仓库中找到语言服务器的完整源代码。

你可以使用新的@vscode/wasm-wasi-lsp npm模块在扩展的TypeScript代码中创建一个WebAssembly语言服务器。通过使用WebAssembly Core Extension,将WebAssembly代码实例化为支持WASI的工作线程,这在我们在VS Code中运行WebAssemblies的博客文章中详细描述。

扩展的TypeScript代码也很直接。它为纯文本文件注册了服务器。

import {
  createStdioOptions,
  createUriConverters,
  startServer
} from '@vscode/wasm-wasi-lsp';

export async function activate(context: ExtensionContext) {
  const wasm: Wasm = await Wasm.load();

  const channel = window.createOutputChannel('LSP WASM Server');
  // The server options to run the WebAssembly language server.
  const serverOptions: ServerOptions = async () => {
    const options: ProcessOptions = {
      stdio: createStdioOptions(),
      mountPoints: [{ kind: 'workspaceFolder' }]
    };

    // Load the WebAssembly code
    const filename = Uri.joinPath(
      context.extensionUri,
      'server',
      'target',
      'wasm32-wasip1-threads',
      'release',
      'server.wasm'
    );
    const bits = await workspace.fs.readFile(filename);
    const module = await WebAssembly.compile(bits);

    // Create the wasm worker that runs the LSP server
    const process = await wasm.createProcess(
      'lsp-server',
      module,
      { initial: 160, maximum: 160, shared: true },
      options
    );

    // Hook stderr to the output channel
    const decoder = new TextDecoder('utf-8');
    process.stderr!.onData(data => {
      channel.append(decoder.decode(data));
    });

    return startServer(process);
  };

  const clientOptions: LanguageClientOptions = {
    documentSelector: [{ language: 'plaintext' }],
    outputChannel: channel,
    uriConverters: createUriConverters()
  };

  let client = new LanguageClient('lspClient', 'LSP Client', serverOptions, clientOptions);
  await client.start();
}

运行代码会在纯文本文件的上下文菜单中添加一个Goto Definition条目。执行此操作会向LSP服务器发送相应的请求。

运行转到定义操作

需要注意的是,@vscode/wasm-wasi-lsp npm 模块会自动将文档 URI 从其工作区值转换为 WASI Preview 1 主机识别的值。在上面的示例中,VS Code 中的文本文档 URI 通常是类似 vscode-vfs://github/dbaeumer/plaintext-sample/lorem.txt 的值,这个值会被转换为 file:///workspace/lorem.txt,这是在 WASI 主机中识别的值。当语言服务器将 URI 发送回 VS Code 时,这种转换也会自动发生。

大多数语言服务器库支持自定义消息,这使得向语言服务器添加语言服务器协议规范中尚未存在的功能变得容易。以下代码片段展示了如何为我们之前使用的Rust语言服务器添加一个自定义消息处理程序,用于计算给定工作区文件夹中的文件数量:

#[derive(Debug, Eq, PartialEq, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct CountFilesParams {
    pub folder: Url,
}

pub enum CountFilesRequest {}
impl Request for CountFilesRequest {
    type Params = CountFilesParams;
    type Result = u32;
    const METHOD: &'static str = "wasm-language-server/countFilesInDirectory";
}

//...

for msg in &connection.receiver {
    match msg {
		//....
		match cast::<CountFilesRequest>(req) {
    		Ok((id, params)) => {
				eprintln!("Received countFiles request #{} {}", id, params.folder);
        		let result = count_files_in_directory(&params.folder.path());
        		let json = serde_json::to_value(&result).unwrap();
        		let resp = Response { id, result: Some(json), error: None };
        		connection.sender.send(Message::Response(resp))?;
        		continue;
    		}
    		Err(err @ ExtractError::JsonError { .. }) => panic!("{err:?}"),
    		Err(ExtractError::MethodMismatch(req)) => req,
		}
	}
	//...
}

fn count_files_in_directory(path: &str) -> usize {
    WalkDir::new(path)
        .into_iter()
        .filter_map(Result::ok)
        .filter(|entry| entry.file_type().is_file())
        .count()
}

将这种自定义请求发送到LSP服务器的TypeScript代码如下所示:

const folder = workspace.workspaceFolders![0].uri;
const result = await client.sendRequest(CountFilesRequest, {
  folder: client.code2ProtocolConverter.asUri(folder)
});
window.showInformationMessage(`The workspace contains ${result} files.`);

vscode-languageserver 仓库上运行此操作会显示以下通知:

运行计数所有文件

请注意,语言服务器不一定需要实现语言服务器协议规范中指定的任何功能。如果扩展希望集成只能编译为WASI Preview 1目标的库代码,在VS Code的组件模型实现支持WASI 0.2预览之前,实现一个带有自定义消息的语言服务器可能是一个不错的选择。

接下来是什么

正如之前的博客文章所提到的,我们继续努力为VS Code实现WASI 0.2预览版。我们还计划扩展代码示例,以包括除Rust之外的其他可以编译为WASM的语言。

谢谢,

Dirk 和 VS Code 团队

编程快乐!