使用 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 world 初始化連線。
在擴充套件端,我們同樣載入 WebAssembly 模組並將其繫結到 calculator world。執行計算的相應呼叫需要等待,因為執行是非同步在 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 檔案與上一篇博文中 計算器示例 中使用的 WIT 檔案沒有區別。
- 由於 WebAssembly 程式碼的執行發生在 worker 中,因此匯入的服務(例如上面的
log函式)的實現可以返回Promise,但並非必須。 - WebAssembly 目前僅支援同步執行模型。因此,從 worker 執行 WebAssembly 程式碼到擴充套件宿主主執行緒呼叫匯入服務的每一次呼叫都需要以下步驟:
- 向擴充套件宿主主執行緒傳送一條訊息,描述要呼叫的服務(例如,呼叫
log函式)。 - 使用
Atomics.wait掛起 worker 執行。 - 在擴充套件宿主主執行緒中處理訊息。
- 使用
Atomics.notify恢復 worker 並通知其結果。
- 向擴充套件宿主主執行緒傳送一條訊息,描述要呼叫的服務(例如,呼叫
此同步會增加可衡量的開銷。儘管所有這些步驟都由元件模型透明地處理,但開發人員應意識到它們,並在設計匯入的 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 的 worker,該擴充套件在我們的 在 VS Code for the Web 中執行 WebAssembly 博文中有詳細介紹。
擴充套件的 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 時,此轉換也會自動發生。
大多數語言伺服器庫都支援自定義訊息,這使得向語言伺服器新增 Language Server Protocol Specification 中尚不存在的功能變得容易。以下程式碼片段展示瞭如何為我們之前使用的 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 儲存庫上執行此程式碼會顯示以下通知:

請注意,語言伺服器不一定需要實現 Language Server Protocol 規範中指定的任何功能。如果擴充套件希望整合只能編譯為 WASI Preview 1 目標庫的程式碼,那麼在 VS Code 的元件模型實現支援 WASI 0.2 preview 之前,實現一個帶有自定義訊息的語言伺服器可能是一個不錯的選擇。
下一步
正如上一篇博文中所述,我們將繼續努力為 VS Code 實現 WASI 0.2 preview。我們還計劃擴充套件程式碼示例,使其包含除 Rust 之外的其他編譯到 WASM 的語言。
謝謝,
Dirk 和 VS Code 團隊
編碼愉快!