使用 WebAssembly 进行扩展开发 - 第二部分
2024 年 6 月 7 日 作者:Dirk Bäumer
在前一篇关于使用 WebAssembly 进行扩展开发的博客文章中,我们演示了如何使用组件模型将 WebAssembly 代码集成到 Visual Studio Code 扩展中。在本篇博客文章中,我们将重点介绍另外两个独立的用例:(a) 在 worker 中运行 WebAssembly 代码,以避免阻塞扩展宿主的主线程;以及 (b) 使用可编译为 WebAssembly 的语言创建语言服务器。
要运行本博客文章中的示例,您需要以下工具:VS Code、Node.js、Rust 编译器工具链、wasm-tools 和 wit-bindgen。
在 worker 中执行 WebAssembly 代码
前一篇博客文章中的示例在 VS Code 扩展宿主的主线程中运行 WebAssembly 代码。只要执行时间短,这是没问题的。然而,耗时较长的操作应该在 worker 中执行,以确保扩展宿主主线程对其他扩展保持可用。
VS Code 组件模型提供了一个元模型,通过使我们能够在 worker 和扩展主线程侧自动生成必要的粘合代码,从而简化了这一点。
以下代码片段显示了 worker 所需的代码。此示例假设代码存储在名为 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);
此代码创建了一个连接以与扩展宿主主 worker 进行通信,并使用 wit2ts
工具生成的 calculator
世界初始化连接。
在扩展侧,我们也加载 WebAssembly 模块并将其绑定到 calculator
世界。由于执行是在 worker 中异步发生的,因此执行计算的相应调用需要使用 await(例如,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 代码的 worker 到扩展宿主主线程以调用导入服务的每次调用都需要以下步骤
- 向扩展宿主主线程发送消息,描述要调用的服务(例如,调用
log
函数)。 - 使用
Atomics.wait
暂停 worker 执行。 - 在扩展宿主主线程中处理消息。
- 恢复 worker 并使用
Atomics.notify
通知其结果。
- 向扩展宿主主线程发送消息,描述要调用的服务(例如,调用
此同步增加了可衡量的额外时间开销。虽然所有这些步骤都由组件模型透明处理,但开发者应该意识到这一点,并在设计导入的 API 表面时加以考虑。
您可以在 VS Code 扩展示例存储库中找到此示例的完整源代码。
基于 WebAssembly 的语言服务器
当我们开始为 Web 版 VS Code 添加 WebAssembly 支持时,我们设想的用例之一就是使用 WebAssembly 执行语言服务器。随着 VS Code 的 LSP 库的最新更改以及引入了一个新的模块来桥接 WebAssembly 和 LSP,现在实现一个 WebAssembly 语言服务器就像将其实现为一个操作系统进程一样简单。
此外,WebAssembly 语言服务器运行在WebAssembly Core 扩展上,该扩展完全支持 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 扩展(该扩展在我们的博客文章在 Web 版 VS Code 中运行 WebAssembly 中有详细描述)实例化支持 WASI 的 WebAssembly 代码作为 worker。
扩展的 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(¶ms.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 团队
编码愉快!