Beliebte Suchanfragen
//

Tutorial: Full Stack Web App in Rust

5.4.2024 | 11 Minuten Lesezeit

In diesem Artikel bauen wir gemeinsam eine kleine „Two-Tier“-Web-Anwendung – komplett mit Browser-Frontend und HTTP-Backend. Um den Tech Stack klein zu halten, verwenden wir dafür nur eine Programmiersprache: Rust.

Warum das Ganze und warum ausgerechnet Rust?

Wer schon einmal in einer modernen Web-Anwendung als Entwicklerin oder Entwickler „full stack“ unterwegs war, also sowohl im Frontend als auch im Backend gearbeitet hat, der weiß, dass der ständige Wechsel zwischen den Technologien anstrengend ist. Nehmen wir als Beispiel einmal einen ziemlichen Standardfall: Das Frontend ist in TypeScript geschrieben mit React als Komponenten-Bibliothek, das Backend in Java oder Kotlin mit Spring Boot als HTTP-Framework.

Bei jedem Wechsel muss man gefühlt das ganze Gehirn austauschen. Alles ist anders:

  • die Sprache
  • die Standardbibliothek
  • das Typsystem
  • das Build- und Dependency-System
  • alle Librarys, die mir das Leben einfacher machen könnten
  • das Paradigma (Beans/Objekte vs Komponenten/Hooks/Funktionen)
  • die Art, Tests zu schreiben und auszuführen

und so weiter und so fort …

Obwohl es in vielen Fällen wirklich eine gute Idee ist, Features / Storys vollständig „von vorne bis hinten“ zu implementieren, ist in der Praxis dann doch häufig zu beobachten, dass erst das Backend oder erst das Frontend gebaut wird, und danach der Rest. Die ständigen Wechsel erschweren eine integrativere Arbeitsweise. Auch ist es nicht möglich, Code von der einen Seite zur anderen zu kopieren oder sogar eine selbst geschriebene Sammlung von Funktionen und Datenstrukturen in beiden Welten zu verwenden. Grund genug, sich nach einer Technologie umzusehen, die das kann.

Rust bietet sich hier an. Dass Rust sicher, stabil und schnell ist, wird vermutlich allgemein bekannt sein. Mit Rust ein Web-Backend zu schreiben, ist eine gute Wahl. Aber auch für das Frontend ist Rust bestens gerüstet. Anders als andere Ansätze, die ich mir angesehen habe (z. B. F# in diesem mehrteiligen Tutorial) wird das Frontend nicht nach JavaScript übersetzt. Stattdessen nutzen Web-Frontends in Rust WebAssembly (Wasm).

Anders als die JavaScript Engine eines Browsers ist die Wasm-Runtime deutlich weniger komplex. Sie enthält zum Beispiel keine Garbage Collection – was perfekt zu Rust passt, denn die Sprache erfordert ja keine.

Damit ist Rust die einzige Alternative dazu, alles direkt in JavaScript oder vielleicht TypeScript zu schreiben oder (wie bei ClojureScript, Kotlin/JS oder F#) einen permanenten Spagat zwischen unterschiedlichen Welten machen zu müssen.

Wir werden in den nächsten vier Schritten gemeinsam eine lauffähige Anwendung erstellen, die natürlich nicht sehr viel tut, aber bereits viele Technologien abdeckt, die ihr bei eurer Full-Stack-Web-Anwendung brauchen werdet. Wenn ihr keine Lust habt, jeden Schritt von Hand mitzumachen, könnt ihr euch auch das fertige Projekt aus Github holen: https://github.com/goetz-markgraf/rust_fullstack_template

In diesem Sinne: Let's get our hands rusty!

Schritt 1: Den Workspace vorbereiten

Wir benötigen drei separate Rust-Projekte:

  1. frontend – Anwendung
  2. backend – Anwendung
  3. shared – Bibliothek

In der shared-Bibliothek werden in jenen Code unterbringen, der sowohl vom Frontend als auch vom Backend benutzt wird. Insbesondere definieren wir darin die „data transfer objects“ (DTOs), sodass Frontend und Backend stets zur Kommunikation untereinander dieselben Datenstrukturen verwenden und nicht auseinanderlaufen können.

Damit wir nicht mit drei losgelösten, einzelnen Projekten hantieren müssen (git, build etc.), erstellen wir einen Workspace, in den wir die anderen Verzeichnisse einbetten. Dazu legen wir zunächst ein Verzeichnis für die gesamte Anwendung an:

1mkdir my_awesome_app
2cd my_awesome_app
3git init

Den Namen (my_awesome_app) könnt ihr natürlich frei wählen. Sinnvollerweise stellen wir das ganze Projekt unter die Versionskontrolle von git.

Als Nächstes legen wir (leider von Hand) eine Cargo.toml-Datei in diesem Verzeichnis mit dem folgenden Inhalt an:

1[workspace]
2members = [
3    "shared",
4    "backend",
5    "frontend",
6    ]
7resolver = "2"

Hiermit sagen wir dem Buildsystem cargo, dass es drei Unterverzeichnisse mit Rust-Projekten geben wird. Die Angabe resolver = "2" ist notwendig, damit wir die aktuelle Rust-Edition 2021 nutzen können.

Jetzt können wir die drei Projekte anlegen:

1cargo new frontend
2cargo new backend
3cargo new shared --lib

Die erfahrene Rust-Entwicklerin wird feststellen, dass

  1. in den jeweiligen Projekten kein .git-Unterverzeichnis angelegt wird und
  2. es dort auch keine Cargo.lock-Datei gibt.

Beides haben wir im Basisverzeichnis unseres Workspaces.

Da wir im Frontend Features verwenden werden, die es nur in der nightly Version von Rust gibt, müssen wir diese noch installieren und unser Projekt dafür markieren. Außerdem müssen wir sicherstellen, dass Wasm als target verfügbar ist:

1rustup toolchain install nightly
2rustup override set nightly
3rustup target add wasm32-unknown-unknown --toolchain nightly

Selbst wenn ihr das schon einmal getan habt, dienen diese Befehle auch dazu, die entsprechenden Toolchains auf den aktuellen Stand zu heben.

Als letztes installieren wir noch Trunk. Dieses Tool macht das, was vite oder webpack für JavaScript machen: Zu Entwicklungszwecken stellt es einen Dev-Server (mit Hot-Reload) zur Verfügung, der das Frontend ausspielt. Gleichzeitig dient es auch als Erweiterung des cargo-Build-Systems, um später auslieferungsfähige Dateien zu erzeugen (trunk build --release).

Schritt 2: Die einzelnen Projekte konfigurieren

Als Nächstes werden wir die jeweiligen Projekte mit den notwendigen Dependencys ausrüsten, bevor wir dann endlich im Schritt 3 Code schreiben können.

Wir wenden also nacheinander in den jeweiligen Unterverzeichnissen die folgenden Schritte an:

shared/

Wir fangen mit der Bibliothek an, die geteilten Code enthalten soll. Dazu brauchen wir genau eine Dependency:

1cargo add serde --features=derive

Die Bibliothek serde ist der De-facto-Standard für das Serialisieren und Deserialisieren von Datenstrukturen.

backend/

Dem Backend fügen wir als erstes die shared-Bibliothek hinzu:

1cargo add --path=../shared

Dann natürlich serde und serde_json:

1cargo add serde --features=derive
2cargo add serde_json

Als letztes benötigen wir noch ein HTTP-Framework. Hier gibt es zum Glück eine große Auswahl. Ich habe mich für Rocket entschieden.

1cargo add rocket --features=json

Wer möchte, kann jetzt noch weitere Dependencys hinzufügen, z. B. anyhow, aber für dieses Tutorial brauchen wir das nicht.

frontend/

Im Frontend fügen wir auch zunächst die shared-Bibliothek sowie das bereits bekannte serde hinzu:

1cargo add --path=../shared
2cargo add serde --features=derive
3cargo add serde_json

Dann brauchen wir ein Komponenten-Framework. Auch hier gibt es viel Auswahl. Sehr bekannt ist Yew, das sich an react anlehnt. Eine Alternative ist Leptos, das in seiner Logik eher solid.js ähnelt. Ich habe mich für leptos entschieden:

1cargo add leptos --features=csr,nightly

Was hat es mit dem csr auf sich? Leptos bietet im Feature ssr, ähnlich wie next.js, serverseitiges Rendering mit anschließender Hydration des Frontends. Da wir das aber für unser Beispiel nicht brauchen, haben wir das Feature-Set als "client side rendering" (csr) festgelegt. Das Feature nightly erlaubt uns, eine schönere Syntax zu verwenden, die es (noch) nur im nightly Rust gibt.

Als Letztes brauchen wir noch eine Möglichkeit, vom Frontend aus mit dem Backend zu sprechen, also einen HTTP-Client. Dazu nutzen wir reqwest. Und da es bei jedem Aufruf externer Ressourcen zu Fehlern kommen kann, fügen wir gleich noch anyhow hinzu, das das Fehlerhandling etwas einfacher macht:

1cargo add reqwest
2cargo add anyhow

Schritt 3: Let's code!

Zeit, Rust-Code zu schreiben.

Zunächst bauen wir ein „frei fliegendes“ Frontend auf, also ein Frontend, das noch nicht auf das Backend zugreift. Das tun wir, um die Arbeit im Frontend in kleinere Häppchen zu zerteilen:

Einfaches Frontend

Wir bauen eine erste, einfache Basis-Komponente. Dafür erzeugen wir die Datei frontend/src/app.rs:

1use leptos::*;
2
3#[component]
4pub fn App() -> impl IntoView {
5    view! {
6        <div>
7            <h1>{ "Hello, World!" }</h1>
8        </div>
9    }
10}

Die Funktion App (ja, mit Großbuchstaben!) erstellt das DOM, das gleich in die Webseite eingefügt wird. Durch das Attribut #[component] wird alles Notwendige dafür bereitgestellt. Das view!-Makro hilft, HTML-Elemente zu erzeugen. Wer schon einmal mit react oder solid.js gearbeitet hat, der wird das Pattern wiedererkennen.

Dann müssen wir in der Datei frontend/src/main.rs unsere Basis-Komponente in das DOM einhängen:

1use leptos::*;
2
3mod app;
4
5fn main() {
6    mount_to_body(app::App)
7}

Jetzt brauchen wir noch eine (weitgehend leere) HTML-Seite, in die unsere wunderbare Anwendung auch eingehängt werden kann. Dazu erstellen wir frontend/index.html:

1<!DOCTYPE html>
2<html lang="en">
3<head>
4    <title>Rust Frontend</title>
5    <meta content="text/html; charset=utf-8" http-equiv="Content-Type">
6</head>
7<body></body>
8</html>

Jetzt könnt ihr diesen Teil schon einmal ausprobieren. Aus dem frontend/-Verzeichnis heraus startet ihr einen Dev-Server mit:

1trunk serve

Wenn alles richtig war, dann könnt ihr in einem Browser http://localhost:8080 öffnen. Dort findet ihr das allseits bekannte „Hello, World!“

Ändert gerne mal den Text und seht, dass beim Speichern direkt die neue Version angezeigt wird (Hot-Reload).

Shared-Bibliothek

Jetzt fangen wir an, unser minimales Frontend mit dem Backend zu verknüpfen. Dazu brauchen wir als „Contract“ die Definition unseres DTOs. Das machen wir direkt in der Datei shared/src/lib.rs:

1use serde::{Deserialize, Serialize};
2
3#[derive(Serialize, Deserialize)]
4pub struct MessageDto {
5    pub text: String,
6}

Nichts Spannendes hier: Wir erstellen eine Datenstruktur mit einem (public) Field und sorgen über das Attribut dafür, dass serde daraus später JSON machen kann.

Backend

Im Backend ersetzen wir den Inhalt der Datei: backend/src/main.rs:

1extern crate rocket;
2
3use rocket::{get, launch, routes};
4use rocket::serde::json::Json;
5
6use shared::MessageDto;
7
8#[get("/api/message")]
9fn index() -> Json<MessageDto> {
10    Json(MessageDto { text: "Hello from Backend".to_string() })
11}
12
13#[launch]
14fn rocket() -> _ {
15    rocket::build().mount("/", routes![index])
16}

Die Funktion rocket() ersetzt die main() Funktion (dafür sorgt das #[launch]-Attribut), in der unser HTTP-Backend konfiguriert und gestartet wird. Das Wichtigste hier ist das routes!-Makro, das unsere Handlerfunktion index erhält.

Bei index() sorgt das Attribut #[get...] dafür, dass eine bestimmte Route mit einer bestimmten Methode hier ankommt. Als Ergebnis der Funktion nutzen wir den Rocket-Datentyp Json (daher das json-Feature bei Rocket), und geben statisch eine Instanz unseres DTOs zurück. Rocket kümmert sich um das Umwandeln – unter Zuhilfenahme von serde.

Starten kann man das Backend wie bei Rust gewohnt mit cargo run. Das Backend hört auf den Port 8000, was sich freundlicherweise nicht mit dem Frontend beißt.

Schaut gerne man auf die URL: http://localhost:8000/api/message.

Fertig ist das Backend, auf zur letzten Runde ...

Erweitertes Frontend

Als Erstes sorgen wir dafür, dass der trunk-Dev-Server bestimmte Anfragen an das Backend weiterleitet. Dazu legen wir eine Konfigurationsdatei an: frontend/Trunk.toml:

1[[proxy]]
2backend = "http://localhost:8000/api"

(Ja, mit zwei [[]]! Warum, weiß ich nicht.)

Alle Anfragen, die der Dev-Server erhält und die mit /api beginnen, werden ab jetzt an unser Backend weitergeleitet.

Alles Weitere erfolgt in unserer App-Komponente (frontend/src/app.rs).

Wir brauchen zunächst eine Funktion, die unser Backend aufruft:

1use anyhow::{anyhow, Result};
2
3fn get_path_for(endpoint: &str) -> Result<String> {
4    let window = web_sys::window()
5                 .ok_or(anyhow!("cannot retrieve window object"))?;
6    let host = window.location().origin()
7                 .map_err(|_| anyhow!("cannot get origin"))?;
8    Ok(format!("{}/{}", host, endpoint.trim_start_matches('/')))
9}
10
11async fn fetch_message() -> Result<String> {
12    let response = reqwest::get(get_path_for("/api/message")?).await?;
13    if response.status() != 200 {
14        return Err(anyhow!("Failed to fetch message, error code {}.",
15                           response.status().as_str())
16        );
17    }
18    let body = response.text().await?;
19    let body = serde_json::from_str::<shared::MessageDto>(&body)?;
20    Ok(body.text)
21}

Die Funktion fetch_message ist dabei die entscheidende. Sie nutzt reqwest, um einen Call ans Backend abzusetzen. Damit wir die URL localhost:8080 nicht hart kodieren müssen (die wäre ja in Produktion auch eine ganz andere), nutzen wir die Wasm-DOM-Bridge (web_sys) und holen uns in der Funktion get_path_for die gerade aktuelle URL, wie wir das in JavaScript auch tun können (window.location.origin).

Hier sieht man die Stärke von Rust: Damit zur Laufzeit keine Nullpointer-Exceptions auftreten können, die einem die Konsole zukleistern, muss man diese Fälle stets im Code abhandeln. Durch anyhow und die Verwendung von ? können wir elegant die Fehler von unten nach oben weiterreichen. ok_or ist dabei der Wechsel von Option auf Result und mit map_err verwandeln wir einen Fehler in einen anderen, der für uns einfacher zu behandeln ist.

Jetzt müssen wir „nur noch“ diese Funktion aufrufen. Leptos kennt dafür das Konzept der Resource. Wir ersetzen also unsere App()-Funktion:

1#[component]
2pub fn App() -> impl IntoView {
3    let message = create_resource(|| (), |_| async {
4        fetch_message().await.map_err(|e| e.to_string())
5    });
6
7    view! {
8        <div>
9            <h1>{ "Hello, World!" }</h1>
10        </div>
11        <Suspense fallback=move || view!{ <p> "Loading..." </p> }>
12            { move || match message()
13                       .unwrap_or(Ok("Loading ...".to_string())) {
14                Ok(text) => view!{ <p>{ text }</p> },
15                Err(e) => view!{ <p style:color="red" >{ e }</p> },
16            }}
17        </Suspense>
18    }
19}

Die Funktion create_resource nimmt zwei Parameter. Der erste ist eigentlich ein veränderlicher Wert, der für die eigentliche Ressource entscheidend ist. Das könnte z. B. eine eingegebene ID sein, zu der ein Datensatz geladen werden soll. Ändert sich die ID, wird der Request neu ausgeführt. Wir rufen unser Backend aber nur einmal auf, also geben wir ein leeres Lambda an: (|| ()).

Das zweite Lambda enthält den (asynchronen) Aufruf unserer fetch_message-Funktion und ein bisschen Fehlerhandling. Insbesondere machen wir mit map_err den Fehler lesbar, mehr brauchen wir aktuell nicht.

In der Variablen message haben wir jetzt ein Objekt vom Typ Resource<(), Result<String,String>>.

Innerhalb des view!-Makros können wir dann mit message() auf den Inhalt zugreifen. Die Ressource liefert uns dabei ein Option<Result<...>>, weil es ja sein kann, dass der async Call noch nicht zurück ist. Das entpacken wir mit unwrap_or und prüfen dann, ob wir einen Fehler oder eine richtige Antwort bekommen haben.

Das Ganze ist dann noch mit einem <Suspense>-Tag eingerahmt. Dieser dient dazu, dass, wenn die Resource noch nicht da ist, der fallback angezeigt wird. Das heißt, dass wir beim Entpacken der Option auch einfach unwrap() hätten nutzen können – aber sich unwrap() komplett oder zumindest weitgehend abzugewöhnen, ist sicher keine schlechte Übung.

Schritt 4: Alles starten

Die Anwendung ist fertig, wir können starten. Für diejenigen, die nur das GitHub-Repo ausgecheckt haben, hier noch einmal die Befehle zum Starten.

Im Ordner frontend/:

1trunk serve

Im Ordner backend/:

1cargo run

Dann Browser öffnen unter http://localhost:8080:

Herzlichen Glückwunsch.

Wenn ihr eine Fehlermeldung sehen möchtet, dann stoppt das Backend und refresht das Frontend.

Zusammenfassung

Wir haben gesehen, wie man mit wenigen Schritten eine Full-Stack-Basis aufbauen kann, die dazu geeignet ist, zu einer großen Anwendung erweitert zu werden.

Der Tech Stack ist kleiner als normalerweise, da man sowohl im Frontend als auch im Backend mit nur einer Sprache und einem Ökosystem unterwegs ist. Natürlich muss man die Besonderheiten von Leptos oder Rocket lernen, aber trotzdem sind Frontend und Backend ähnlicher, als das bei TypeScript und Kotlin der Fall wäre.

Und noch etwas: Ändert doch einmal in shared/src/lib.rs in der Struktur MessageDto den Namen des Feldes text. Ihr werdet sehen, dass euch der Compiler sowohl das Frontend als auch das Backend als fehlerhaft markiert, bis ihr überall auf die Änderung reagiert habt. Wer schon einmal lange suchen musste, nur um festzustellen, dass die Datentypen im Frontend und Backend nicht mehr passen, der weiß das zu schätzen.

Die Verwendung einer sicheren, schnellen, stabilen und leistungsfähigen Technologie (in diesem Fall Rust) in Frontend und Backend gemeinsam kann die Entwicklung moderner Software deutlich vereinfachen.

Viel Spaß mit Rust!

Beitrag teilen

//

Weitere Artikel in diesem Themenbereich

Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.

//

Gemeinsam bessere Projekte umsetzen.

Wir helfen deinem Unternehmen.

Du stehst vor einer großen IT-Herausforderung? Wir sorgen für eine maßgeschneiderte Unterstützung. Informiere dich jetzt.

Hilf uns, noch besser zu werden.

Wir sind immer auf der Suche nach neuen Talenten. Auch für dich ist die passende Stelle dabei.