Bytecode im Browser: Mit WebAssembly und Rust zur Web-Anwendung

Seite 2: Übersicht des Chat-Systems

Inhaltsverzeichnis

Der Client soll in der Lage sein, mit einem Server zu kommunizieren, um Nachrichten zu versenden und zu empfangen. Um die nachfolgenden Beispiele auszuführen, müssen die Rust-Toolchain sowie Node.js installiert sein. Wichtig ist zudem das wasm-pack, das das Erstellen von WebAssembly-basierten Projekten vereinfacht. Verschiedene Vorlagen erleichtern darüber hinaus den Einstieg in WebAssembly. Mit npm init rust-webpack chat-frontend lässt sich eine Rust-basierte Projektvorlage erzeugen, mit npm start bauen und anschließend lokal über http://localhost:8080 öffnen. Im Browser liefert diese URL zunächst nur eine leere Seite. Der Quellcode der Webseite findet sich im Verzeichnis static unter dem Namen index.html. Direkt nach Erstellen des Beispiels ist die Datei noch relativ leer und lädt nur die JavaScript-Datei index.js. Dieses Skript wiederum lädt den aus dem Rust-Code im Verzeichnis src erzeugten WebAssembly-Code.

Um Nachrichten anzuzeigen und eine Eingabemöglichkeit für neue Nachrichten anzubieten, benötigt der Chatclient eine Textbox. Dazu ist die Datei index.html folgendermaßen anzupassen:

<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <title>My Rust Chat project!</title>
</head>
<body>
    <script src="index.js"></script>
    <div><textarea rows="20" cols="64" id="chat-display" value=""></textarea></div>
    <form id="chat-controls" action="" method="dialog">
        <input type="text" id="username" value="username" name="username">
        <input type="text" id="message" value="message" name="message">
        <input type="submit" value="Send">
    </form>
</body>
</html>

Die erstellte Textbox enthält drei IDs: Mit der ID chat-display lassen sich ankommende Nachrichten visualisieren. Über die ID message lassen sich in den Eingabefeldern Nachrichten verfassen und mit einem Sendernamen (ID username) versehen. Beim Drücken des Send-Button soll der WebAssembly-Code die Nachricht an einen Server senden, der sie an alle Chatteilnehmer weiterleitet.

In der JavaScript-Datei index.js, die den WebAssembly-Code lädt, muss zunächst ein Einstiegspunkt definiert werden, der die Anwendung initialisiert. Für jegliche Interaktion zwischen JavaScript und Rust hilft das Crate wasm-bindgen. Crates stellen eine Code-Sammlung dar, die eine eine Bibliothek zur Verfügung stellt oder selbst eine Anwendung ist (siehe Artikel Rust: Crates und Continuous Integration – eine perfekte Mischung). Durch dieses Crate lassen sich Funktionen mit #[wasm_bindgen] markieren, sodass die Rust-Funktionen sich von JavaScript aus aufrufen lassen. Alternativ lassen sich Funktionen auch automatisch beim Initialisieren des Moduls aufrufen. Dazu ist lediglich der Zusatz start hinzuzufügen:

// Called by our JS entry point to run the example     .
#[wasm_bindgen(start)]
pub fn run() -> Result<(), JsValue> wurde {
    ...
}

Browser stellen eine Reihe von Schnittstellen zur Verfügung, die auch für WebAssembly-Anwendungen interessant sind. Um sie verwenden zu können, steht das Crate web_sys zur Verfügung. Für den Chatclient im Browser ist ein Socket-Interface erforderlich, um eine Verbindung mit einem Server aufzubauen. Dazu stehen in allen Browsern sogenannte WebSockets zur Verfügung. Die Bearbeitung erfolgt komplett asynchron und für jedes Ereignis ist ein Handler zu registrieren, der es bearbeitet.

Um einen Chatclient zu realisieren, baut die Initialisierungsfunktion run über web_sys eine WebSocket-Verbindung auf. Der Aufruf der Funktion setup_ws_connection liefert das erzeugte WebSocket als Ergebnis zurück.

    fn setup_ws_connection() -> WebSocket {
        let ws = WebSocket::new_with_str("ws://localhost:2794", "rust-websocket")
            .expect("WebSocket failed to connect 'ws://localhost:2794'");

        let ws_c = ws.clone();
        let open_handler = Box::new(move || {
            console::log_1(&"Send me messages!".into());
        });
        let cb_oh: Closure<dyn Fn()> = Closure::wrap(open_handler);
        ws.set_onopen(Some(cb_oh.as_ref().unchecked_ref()));
        cb_oh.forget();
        ws
    }

Da WebSockets asynchron arbeiten, ist es nicht notwendig auf den Verbindungsaufbau zu warten. Stattdessen lässt sich ein zuvor registrierter Handler aufrufen, sobald die Verbindung vollständig aufgebaut ist. In Rust stehen dazu namenlose Funktion vom Typ Closure zur Verfügung, die Zugriff auf den Erstellungskontext haben. Wie das vorangehende Beispiel zeigt, wird der Closure open_handler erzeugt und mit set_onopen am WebSocket ws registriert. Innerhalb dieses Closures lässt sich eine Debug-Meldung auf der Console ausgeben, die anzeigt, wann die Verbindung erfolgreich aufgebaut ist.

Auf die gleiche Weise sind zwei weitere Handler zu registrieren: einer, der beim Klicken des Sendeknopfes die Nachricht aus den Eingabefelder versendet, und ein zweiter, der ankommende Nachrichten verarbeitet und in der Textbox hinzufügt. Dabei helfen die IDs, die entsprechenden HTML-Elemente anzusprechen.

Ein simpler Chatclient zählt nicht unbedingt zu den besonders rechenintensiven Aufgaben, bei denen WebAssembly seine Stärken voll ausspielen könnte. Allerdings lässt sich die Komplexität des Chatclient erhöhen, indem Funktionen zum Versenden und Bearbeiten von Bildern hinzukommen.

Der webbasierter Chatclient benötigt grundsätzlich noch einen Server, der die Nachrichten an alle Teilnehmer weiterleitet. Im Server existieren für jeden Teilnehmer beziehungsweise für jede Verbindung zwei Threads. Ein Thread dient dem Verarbeiten ankommender Nachrichten, der zweite sendet die Nachrichten an die Teilnehmer. Darüber hinaus ist ein dritter Thread als Dispatcher erforderlich. Im Stil eines einfachen Message Broker nimmt er alle ankommenden Nachrichten entgegen und leitet sie an sämtliche Sende-Threads weiter. Die Kommunikation zwischen den Threads läuft über Multi-producer, single-consumer channels. Die Verwaltung solcher Channel zwischen dem Dispatcher-Thread und den Sende-Threads erfolgt über einen Vektor.

let (dispatcher_tx, dispatcher_rx) = mpsc::channel::<String>();
let client_senders: Arc<Mutex<Vec<mpsc::Sender<websocket::OwnedMessage>>>> = Arc::new(Mutex::new(vec![]));

thread::spawn(move || {
    while let Ok(msg) = dispatcher_rx.recv() {
        for sender in client_senders.lock().expect("Unable to get lock").iter() {
            let _ = sender.send(msg.clone());
        }
    }
});