使用 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);
該程式碼建立了一個用於與擴充套件主機主執行緒通訊的連線,並使用 wit2ts
工具生成的 calculator
world 對連線進行初始化。
在擴充套件端,我們載入 WebAssembly 模組並將其也繫結到 calculator
world。由於執行是在 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 的執行。 - 在擴充套件主機主執行緒中處理該訊息。
- 使用
Atomics.notify
恢復 worker 並通知其結果。
- 向擴充套件主機主執行緒傳送一條訊息,描述要呼叫的服務(例如,呼叫
這種同步會增加可觀的時間開銷。雖然所有這些步驟都由元件模型透明地處理,但開發人員應該意識到它們,並在設計匯入的 API 介面時加以考慮。
你可以在 VS Code 擴充套件示例倉庫中找到此示例的完整原始碼。
基於 WebAssembly 的語言伺服器
當我們開始為 VS Code for the Web 提供 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 擴充套件,將 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 時,這種轉換也會自動發生。
大多數語言伺服器庫都支援自定義訊息,這使得為語言伺服器新增語言伺服器協議規範中尚未包含的功能變得很容易。以下程式碼片段展示瞭如何為我們之前使用的 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 preview 之前,實現一個帶有自定義訊息的語言伺服器可能是一個不錯的選擇。
下一步計劃
正如在上一篇部落格文章中提到的,我們將繼續努力為 VS Code 實現 WASI 0.2 preview。我們還計劃擴充套件程式碼示例,以包含除 Rust 之外其他可編譯為 WASM 的語言。
謝謝,
Dirk 和 VS Code 團隊
編碼愉快!