ist jetzt verfügbar! Lesen Sie über die neuen Funktionen und Fehlerbehebungen vom November.

WebAssembly für die Erweiterungsentwicklung nutzen – Teil Zwei

7. Juni 2024 von Dirk Bäumer

Im vorherigen Blogbeitrag über die Nutzung von WebAssembly für die Erweiterungsentwicklung haben wir gezeigt, wie das Komponentenmodell verwendet werden kann, um WebAssembly-Code in Ihre Visual Studio Code-Erweiterung zu integrieren. In diesem Blogbeitrag konzentrieren wir uns auf zwei weitere unabhängige Anwendungsfälle: (a) das Ausführen des WebAssembly-Codes in einem Worker, um zu vermeiden, dass der Hauptthread des Erweiterungs-Hosts blockiert wird, und (b) das Erstellen eines Sprachservers mithilfe einer Sprache, die nach WebAssembly kompiliert.

Um die Beispiele in diesem Blogbeitrag auszuführen, benötigen Sie die folgenden Tools: VS Code, Node.js, die Rust-Compiler-Toolchain, wasm-tools und wit-bindgen.

WebAssembly-Code in einem Worker ausführen

Die Beispiele im vorherigen Blogbeitrag führten den WebAssembly-Code im Hauptthread des VS Code-Erweiterungs-Hosts aus. Das ist in Ordnung, solange die Ausführungszeit kurz ist. Lang andauernde Operationen sollten jedoch in einem Worker ausgeführt werden, um sicherzustellen, dass der Hauptthread des Erweiterungs-Hosts für andere Erweiterungen verfügbar bleibt.

Das Komponentenmodell von VS Code bietet ein Metamodell, das dies erleichtert, indem es uns ermöglicht, den erforderlichen Glue-Code automatisch sowohl auf der Worker- als auch auf der Erweiterungs-Hauptseite zu generieren.

Der folgende Codeausschnitt zeigt den notwendigen Code für den Worker. Das Beispiel geht davon aus, dass der Code in einer Datei namens worker.ts gespeichert ist.

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);

Der Code erstellt eine Verbindung zur Kommunikation mit dem Haupt-Worker des Erweiterungs-Hosts und initialisiert die Verbindung mit der calculator-Welt, die vom wit2ts-Tool generiert wurde.

Auf der Erweiterungsseite laden wir das WebAssembly-Modul und binden es ebenfalls an die calculator-Welt. Die entsprechenden Aufrufe zur Durchführung der Berechnungen müssen mit await aufgerufen werden, da die Ausführung asynchron im Worker erfolgt (z. B. 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)}`);
  }
);

Es gibt ein paar wichtige Dinge zu beachten:

  • Die in diesem Beispiel verwendete WIT-Datei unterscheidet sich nicht von der, die für das Taschenrechner-Beispiel im vorherigen Blogbeitrag verwendet wurde.
  • Da die Ausführung des WebAssembly-Codes in einem Worker erfolgt, kann die Implementierung importierter Dienste (z. B. die obige log-Funktion) ein Promise zurückgeben, muss es aber nicht.
  • WebAssembly unterstützt derzeit nur ein synchrones Ausführungsmodell. Infolgedessen erfordert jeder Aufruf vom Worker, der den WebAssembly-Code ausführt, zum Hauptthread des Erweiterungs-Hosts, um importierte Dienste aufzurufen, die folgenden Schritte:
    1. Senden Sie eine Nachricht an den Hauptthread des Erweiterungs-Hosts, die den aufzurufenden Dienst beschreibt (z. B. Aufruf der log-Funktion).
    2. Suspendieren Sie die Worker-Ausführung mit Atomics.wait.
    3. Verarbeiten Sie die Nachricht im Hauptthread des Erweiterungs-Hosts.
    4. Setzen Sie den Worker fort und benachrichtigen Sie ihn über das Ergebnis mit Atomics.notify.

Diese Synchronisierung verursacht messbaren Zeitaufwand. Obwohl all diese Schritte transparent vom Komponentenmodell gehandhabt werden, sollten sich Entwickler dieser bewusst sein und dies bei der Gestaltung der importierten API-Oberfläche berücksichtigen.

Den vollständigen Quellcode für dieses Beispiel finden Sie im VS Code-Erweiterungs-Beispiel-Repository.

Ein WebAssembly-basierter Sprachserver

Als wir begannen, an der WebAssembly-Unterstützung für VS Code für das Web zu arbeiten, war einer unserer angedachten Anwendungsfälle die Ausführung von Sprachservern mithilfe von WebAssembly. Mit den neuesten Änderungen an den LSP-Bibliotheken von VS Code und der Einführung eines neuen Moduls zur Brücke zwischen WebAssembly und LSP ist die Implementierung eines WebAssembly-Sprachservers jetzt so einfach wie die Implementierung als Betriebssystemprozess.

Darüber hinaus laufen WebAssembly-Sprachserver auf der WebAssembly Core Extension, die WASI Preview 1 vollständig unterstützt. Das bedeutet, dass Sprachserver auf die Dateien im Workspace über die reguläre Dateisystem-API ihrer Programmiersprache zugreifen können, auch wenn die Dateien remote gespeichert sind, z. B. in einem GitHub-Repository.

Der folgende Codeausschnitt zeigt einen Rust-Sprachserver, der auf dem Beispielserver aus dem lsp_server-Crate basiert. Dieser Sprachserver führt keine Sprachanalyse durch, sondern gibt einfach ein vordefiniertes Ergebnis für eine GotoDefinition-Anfrage zurück.

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,
};

Den vollständigen Quellcode des Sprachservers finden Sie im VS Code-Beispiel-Repository.

Sie können das neue @vscode/wasm-wasi-lsp npm-Modul verwenden, um einen WebAssembly-Sprachserver im TypeScript-Code der Erweiterung zu erstellen. Instanziieren Sie den WebAssembly-Code als Worker mit WASI-Unterstützung mithilfe der WebAssembly Core Extension, die in unserem Blogbeitrag Run WebAssemblies in VS Code for the Web ausführlich beschrieben wird.

Der TypeScript-Code der Erweiterung ist ebenfalls unkompliziert. Er registriert den Server für einfache Textdateien.

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();
}

Das Ausführen des Codes fügt dem Kontextmenü von einfachen Textdateien einen Eintrag Goto Definition hinzu. Das Ausführen dieser Aktion sendet eine entsprechende Anfrage an den LSP-Server.

Running the goto definition action

Es ist wichtig zu beachten, dass das @vscode/wasm-wasi-lsp npm-Modul Dokument-URIs automatisch von ihrem Workspace-Wert in den von der WASI Preview 1-Host erkannten Wert transformiert. Im obigen Beispiel ist die URI des Textdokuments innerhalb von VS Code normalerweise so etwas wie vscode-vfs://github/dbaeumer/plaintext-sample/lorem.txt, und dieser Wert wird in file:///workspace/lorem.txt umgewandelt, was innerhalb des WASI-Hosts erkannt wird. Diese Transformation erfolgt auch automatisch, wenn der Sprachserver eine URI zurück an VS Code sendet.

Die meisten Sprachserver-Bibliotheken unterstützen benutzerdefinierte Nachrichten, sodass Sie einfach Funktionen zu einem Sprachserver hinzufügen können, die in der Language Server Protocol Specification noch nicht vorhanden sind. Der folgende Codeausschnitt zeigt, wie ein benutzerdefinierter Nachrichtenhandler zum Zählen der Dateien in einem bestimmten Workspace-Ordner zum zuvor verwendeten Rust-Sprachserver hinzugefügt wird.

#[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()
}

Der TypeScript-Code zum Senden dieser benutzerdefinierten Anfrage an den LSP-Server sieht wie folgt aus:

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

Das Ausführen dieses Codes auf dem Repository vscode-languageserver zeigt die folgende Benachrichtigung:

Running count all files

Bitte beachten Sie, dass ein Sprachserver nicht unbedingt alle in der Language Server Protocol-Spezifikation definierten Features implementieren muss. Wenn eine Erweiterung Bibliotheks-Code integrieren möchte, der nur auf das WASI Preview 1-Ziel kompiliert werden kann, könnte die Implementierung eines Sprachservers mit benutzerdefinierten Nachrichten eine gute Wahl sein, bis VS Code die WASI 0.2 Preview in seiner Komponentenmodell-Implementierung unterstützt.

Was kommt als Nächstes

Wie im vorherigen Blogbeitrag erwähnt, setzen wir unsere Bemühungen fort, die WASI 0.2 Preview für VS Code zu implementieren. Wir planen außerdem, die Codebeispiele zu erweitern, um neben Rust auch andere Sprachen einzubeziehen, die zu WASM kompiliert werden.

Danke,

Dirk und das VS Code-Team

Viel Spaß beim Programmieren!

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