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:
frontend
– Anwendungbackend
– Anwendungshared
– 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
- in den jeweiligen Projekten kein
.git
-Unterverzeichnis angelegt wird und - 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.
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
Trunk macht dabei 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
).
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 leptos::*;
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!
Weitere Beiträge
von Goetz Markgraf
Dein Job bei codecentric?
Weitere Artikel in diesem Themenbereich
Entdecke spannende weiterführende Themen und lass dich von der codecentric Welt inspirieren.