Overview

Willkommen zum Rust Workshop Light™! Schön, dass du dabei bist.

Die slides findest du übrigens hier.

Falls du die Rust toolchain noch nicht installiert hast, dann folge dazu den Anweisungen auf der offiziellen Webseite: www.rust-lang.org/tools/install

Hier ist eine Tabelle der verfügbaren Tutorials und ihrer Schwerpunkte:

TutorialSchwerpunkte
Erstelle ein Kommandozeilenprogramm#macros #enums
Hantiere mit Datenformaten wie JSON oder TOML#macros
Schreibe eine Middleware für einen Webserver#traits
Erledige IO intensive Arbeit effizient#async
Erweitere Typen der Standardbibliothek#enums

In der Seitenleiste kannst du direkt zu den einzelnen Tutorials springen.

Wie geht's weiter?

Falls dich der heutige Abend neugierig gestimmt hat, empefehle ich dir folgende Ressourcen um weiter zu lernen:

Kommandozeilenprogramm

Unser Programm wird greet heissen und die Funktionalität des bekannten Hello, world! Programms erweitern.

Projektstruktur

Erstelle zuerst ein neues Rust Projekt:

cargo new greet
cd greet

Als nächstes fügen wir die Bibliothek clap hinzu. Das ist kurz für "command line application parser" und ist die beliebteste Bibliothek, um CLIs zu bauen. Die Dokumentation von clap findest du hier: docs.rs/clap.

cargo add clap --features derive

Vergiss nicht --features derive! Das ist eine optionale Funktion, die wir aber unbedingt verwenden wollen.

"leerer" Kommandozeilenparser

Ab jetzt arbeiten wir in der Datei src/main.rs, um unser programm zu schreiben. Füge die erste Version des Programms ein:

use clap::Parser;

#[derive(clap::Parser)]
struct CliArgs {}

fn main() {
    let args = CliArgs::parse();
    println!("Hello, world!");
}

Ein paar Erklärungen:

  • struct CliArgs {} ist eine Typdefinition, momentan hat sie noch keine Felder. Hier werden wir spezifizieren, was für Kommandozeilenargumente wir erwarten.
  • #[derive(clap::Parser)] ist ein Makro, das unsere Typdefinition in ein Kommandozeilenparser verwandelt.

Wenn du dieses Programm mit cargo run ausführst, dann gibt es erstmal nur Hello, world! aus. (Plus eine Warnung, dass args nicht verwendet wird.)

Wenn du nun aber cargo run -- --help ausführst, dann siehst du schon den ersten Unterschied. clap generiert standardmässig eine --help flag, die dem User aufzeigt wie das Programm verwendet werden kann.

Übrigens: Die zwei Bindestriche in cargo run -- --help sind nötig, damit cargo weiss, dass die folgended argumente für das Programm selber sind, und nicht für cargo. Alternativ kannst du das Programm erst kompilieren mit cargo build und dann manuall ausführen mit ./target/debug/greet --help. Dann sind die zwei Bindestriche nicht mehr nötig.

Das erste Argument

Nun fügen wir das erste Argument hinzu. Anstatt die ganze Welt wollen wir bestimmte Personen grüssen. Dafür benötigen wir einen Namen. Füge ein neues Feld in user struct CliArgs ein:

struct CliArgs {
    name: String,
}

Dieses Feld ist nun in der Variable args verfügbar und wir können den Namen ausgeben:

println!("Hello, {}!", args.name);

Wenn wir dieses Programm ausführen mit cargo run, dann wird es einen Fehler ausgeben, weil das Argument name fehlt. Die Fehlerausgabe sagt uns auch schön, wie wir den Namen mitgeben müssen. Standardmässig sind die Argumente positionell, das heisst ein korrekter Aufruf ist: cargo run -- Jeremy.

Unterbefehle

Das ist toll soweit, aber unser Programm soll beides können. Der Benutze soll wählen können, ob die Welt oder eine Person gegrüsst werden soll. Dazu verwenden wir Unterbefehle (Subcommands) und müssen unsere Typdefinition umkrempeln:

#[derive(clap::Parser)]
struct CliArgs {
    #[command(subcommand)]
    command: Command,
}

#[derive(clap::Subcommand)]
enum Command {
    World,
    Person { name: String },
}

Hier kommt ein neuer Typ namens Command hinzu. Es ist ein enum, also eine Aufzählung aller möglichen Befehle. Beim Befehl World gibt es keine zusätzlichen Argumente, beim Befehl Person muss noch der Name mit. Ausserdem braucht es im Haupstruct CliArgs die Annotation #[command(subcommand)] über dem Feld command, damit das Makro von clap weiss, dass es sich nicht um ein Argument, sondern einen Unterbefehl handeln soll.

Die main Funktion müssen wir auch anpassen, weil args kein Feld name mehr hat:

fn main() {
    let args = CliArgs::parse();

    match args.command {
        Command::World => println!("Hello, world!"),
        Command::Person { name } => println!("Hello, {name}!"),
    };
}

Als erstes "matchen" wir auf args.command. Das bedeuted, wir führen unterschiedlichen Code aus, je nachdem welche "Struktur" der Befehl annimmt. Die match-Anweisung ist vergleichbar mit switch in anderen Sprachen, es ist aber wie gesagt auf die Struktur von Daten spezialisiert.

Handelt es sich beim Befehl um World, dann geben wir Hello, world! aus, ganz einfach. Wenn der Befehl Person ist, dann wird das enthaltene Feld name direkt an eine gleichnamige Variable gebunden, die wir danach ausgeben können.

Beobachte das folgende Verhalten des Programms:

  • cargo run schlägt fehl und zeigt die Liste verfügbarer Unterbefehle.
  • cargo run -- world ist erfolgreich.
  • cargo run -- person schlägt fehl und macht auf das fehlende argument "name" aufmerksam.
  • cargo run -- person Jeremy ist erfolgreich.

Fazit

Toll! Damit haben wir schon ein bisschen hands-on Erfahrung mit Makros und Enums gesammelt.

  • Makros nehmen Code als Eingabe (in Unserem fall die Typdeklarationen CliArgs und Command) und produzieren neuen Code daraus. Der produzierte Code kann ein ganzer Komgandozeilenparser sein.

  • Enums erlauben uns die Struktur unserer Daten präzise zu beschreiben. Die match-Anweisung lässt uns diese Struktur verarbeiten.

JSON und TOML

Um den Umgang mit Datenformaten wie JSON und TOML anzuschauen werden wir eine Datei vom einen Format ins andere migrieren. Wir stellen uns vor das sei die Konfigurationsdatei eines Texteditors. Die Migration machen wir, weil TOML mehr "human friendly" ist als JSON.

Projektstruktur

Erstelle zuerst ein neues Rust Projekt:

cargo new json2toml
cd json2toml

Als nächstes fügen wir die Bibliotheken serde, serde_json und toml hinzu. serde ist eine extrem verbreitet Bibliothek, welche für SERialiasieren und DEserialisieren verschiedener Datenformate verwendet wird. Die Unterstützung für die einzelnen Formate selbst sind in separate Bibliotheken ausgelagert, deshalb brauchen wir noch je eine für JSON und TOML. Die Dokumentation von serde findest du hier: serde.rs

cargo add serde --features derive
cargo add serde_json toml

Vergiss nicht --features derive! Das ist eine optionale Funktion von serde, die wir aber unbedingt verwenden wollen.

Nun brauchen wir noch eine Datei, welche wir migrieren wollen. Speichere den folgenden Inhalt unter config.json (im json2toml Ordner) ab:

{
  "showUpdatePopup": false,
  "theme": "monokai",
  "indentationByLanguage": {
    "Rust": 4,
    "JavaScript": 2,
    "C": 8
  }
}

JSON zu einem Rust Datentyp einlesen

Nun definieren wir einen entsprechenden Rust Datentyp:

use std::collections::HashMap;

struct Config {
    show_update_popup: bool,
    theme: String,
    indentation_by_language: HashMap<String, u8>,
}

Um eine Darstellung wie JSON nun in diesen Datentyp einlesen zu können, lassen wir serde für uns den nötigen Code generieren:

use serde::Deserialize;

#[derive(Deserialize)]
struct Config { /**/ }

Dank diesem derive-Makro können wir nun die Funktion serde_json::from_str verwenden:

fn main() {
    let json_string = std::fs::read_to_string("config.json").unwrap();

    let config: Config = serde_json::from_str(&json_string).unwrap();

    println!("theme: {}", config.theme);
}

Mit std::fs::read_to_string lesen wir die Datei ein. unwrap ist eine Funktion für die Fehlerbehandlung. Wenn ein Fehler auftaucht, dann bringt unwrap das Programm einfach zum Absturz. In einem Produktionsreifen Programm würde man natürlich bessere Fehlerbehandlung machen.

Was passiert, wenn dieses Programm mit cargo run ausgeführt wird? Es gibt folgenden Fehler aus:

missing field show_update_popup

Wir haben noch das Problem, dass JavaScript / JSON und Rust unterschiedliche Namenskonventionen haben. In JSON wird oft camelCase verwendent, in Rust ist hingegen snake_case die Konvention. Dies müssen wir der Bibliothek serde beibringen:

#[derive(Deserialize)]
#[serde(rename_all(deserialize = "camelCase"))]
struct Config { /**/ }

Hier kommt also die Annotation #[serde(rename_all(deserialize = "camelCase"))] hinzu. Das ist eine Information, die vom darüberliegenden #[derive(Deserialize)] gelesen werden kann. Dadurch können wir das Verhalten von serde beim Deserialisieren genau steuern.

Wenn du das Programm jetzt nochmal ausführst, dann sollte es klappen.

Ausgabe in TOML

Nun wollen wir die Konfiguration im TOML Format ausgeben. Der Deserialize Trait funktioniert nur in eine Richtung, für die andere Richtung braucht es den Serialize Trait:

use serde::{Deserialize, Serialize};

#[derive(Deserialize, Serialize)]
struct Config { /**/ }

Und in der main Funktion:

let toml_string = toml::to_string_pretty(&config).unwrap();

print!("{toml_string}");

Toll! Der Weg von JSON zu TOML ist geschafft.

Umbenennen beim Serialisieren

Wir mussten eine Annotation verwenden, um beim Deserialisieren von camelCase zu snake_case zu übersetzen. Ähnliches funktioniert auch beim Serialisieren. Angenommen wir wollen den Konfigurationswert show_update_popup noch zu verbose umbenennen. Das geht ganz einfach:

struct Config {
    #[serde(rename(serialize = "verbose"))]
    show_update_popup: bool,
    // ...
}

Es gibt eine grosse Auswahl solcher Annotationen und man kann sogar eigene Funktionen angeben, welche das Deserialisieren oder Serialisieren übernehmen. Mit serde hat man immer genau das Verhältnis von Bequemlichkeit und Kontrolle, das man gerade braucht.

Fazit

Makros sind ein mächtiges Instrument. Oft stellen Bibliotheken Makros zur Verfügung, damit die Konsumenten mit wenig Code viel erreichen können.

Middleware

Um uns mit Traits vertraut zu machen, werden wir ein Middleware für eine Webserver-Bibliothek schreiben.

Projektstruktur

Erstelle zuerst ein neues Rust Projekt:

cargo new middleware
cd middleware

Als nächstes fügen wir die Bibliotheken axum und tokio hinzu. Axum ist für Webserver und tokio is die async runtime, welche axum benötigt. Die Dokumentation von axum findest du hier: docs.rs/axum

cargo add axum tokio

Hier ist die erste Version von main.rs:

use axum::Router;
use axum::routing::get;

#[tokio::main(flavor = "current_thread")]
async fn main() {
    let app = Router::new().route("/", get(greet));
    let listener = tokio::net::TcpListener::bind("0.0.0.0:4000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

async fn greet() -> String {
    "Hello, world!".into()
}

Diese Programm kann mit cargo run gestartet werden. Den Webserver ansprechen kann man dann z.B. mit curl localhost:4000. Die Antwort sollte "Hello, world!" sein.

Der Path Extractor

Oft können einem Webserver Parameter über den Pfad der Anfrage mitgegben werden. Das machen wir mit dem Namen der Person, die gegrüsst werden soll:

Das neue Programm sieht wie folgt aus:

use axum::extract::Path;

async fn main() {
    let app = Router::new().route("/{name}", get(greet));
    // ...
}

async fn greet(Path(name): Path<String>) -> String {
    format!("Hello, {name}!")
}

Ein neuer Import kommt dazu, die Route-Definition hat sich zu "/{name}" geändert und die greet Funktion nimmt den Parameter entgegen.

Testen kannst du das Programm zum Beispiel mit curl localhost:4000/Jeremy.

Wie funktioniert das? Der Typ Path implementiert den Trait FromRequestParts. Dieser definiert, wie ein Parameter aus einer HTTP-Anfrage ausgelesen werden kann. Diesen Trait können wir selber implementieren!

FromRequestParts implementieren

Normalerweise gibt man seinen Username ja nicht über den URL Pfad an. Schon üblicher wäre da der Authorization Header. Wir wollen also folgende Anfrage an unseren Server schicken können:

curl --header 'Authorization: Jeremy' localhost:4000

Zunächst setzen wir das Programm auf den Anfangszustand zurück:

async fn main() {
    let app = Router::new().route("/", get(greet));
    // ...
}

async fn greet() -> String {
    "Hello, world!".into()
}

Nun definieren wir unseren eigenen Typ für die Authentifizierung:

struct User(String);

Für diesen Typ müssen wir nun den FromRequestParts Trait implementieren, die Struktur sieht so aus:

use axum::extract::FromRequestParts;
use axum::http::StatusCode;
use axum::http::request::Parts;

impl FromRequestParts<()> for User {
    type Rejection = StatusCode;

    async fn from_request_parts(
        parts: &mut Parts,
        _: &(),
    ) -> Result<User, StatusCode> {
        // TODO
    }
}

Ein paar Erklärungen sind angebracht:

  • Das <()> nach FromRequestParts in der ersten Zeile interessiert uns nicht weiter Da könnte man einen Typparameter angeben, wenn man für seinen Server globalen Zustand bräuchte.
  • type Rejection = StatusCode; gibt den Fehlertyp an, wenn bei der "Extraction" etwas schief geht. Wir geben in diesem Fall einen HTTP Statuscode zurück.
  • Die Funktion from_request_parts wird kurz vor dem eigentlichen Handler greet ausgeführt, um den User zu ermitteln.
  • parts: &mut Parts ist der Parameter, für den wir uns interessieren. Diese "request parts", beinhalten Metadaten über die HTTP Anfrage, inklusive der Header.
  • Der zweite Parameter _: &() interessiert uns wiederum nicht, das ist der oben erwähnte globale Zustand.
  • Und letztlich der Rückgabetyp Result<Self, StatusCode>. Der sagt, dass im Erfolgsfall ein User zurückgegeben wird ansonsten ein StatusCode.

Nun können wir uns an die Implementation wagen:

let Some(user) = parts.headers.get("Authorization") else {
    return Err(StatusCode::UNAUTHORIZED);
};
let Ok(user) = user.to_str() else {
    return Err(StatusCode::UNAUTHORIZED);
};
Ok(User(user.to_string()))

Grob zusammengefasst:

  • Wenn die Anfrage keinen Authorization Header hat, dann wird direkt mit 401 Unauthorized geantwortet. (Der eigentliche greet Handler wird gar nie aufgerufen.)
  • Wenn der Wert kein valider String ist, dann wird ebenfalls mit 401 Unauthorized geantwortet.
  • Wenn bis jetzt alles gut ging, dann wird ein neuer User mit dem Wert des Headers als Namen konstruiert.

Nun müssen wir nur noch den greet Handler anpassen, damit er nach einem User verlangt:

async fn greet(User(name): User) -> String {
    format!("Hello, {name}!")
}

Das war's! Der folgende Befehl sollte zu einer erfolgreichen Antwort führen:

curl --header 'Authorization: Jeremy' localhost:4000

Um den Fehlerfall zu testen: curl -v localhost:4000

In einer realistischen Applikation würde man natürlich nicht den Usernamen direkt auslesen. Sinnvoller wäre es, im "Authorization" header ein JWT Token zu erwarten, das zu lesen und die Signatur zu verifizieren. Die Vorgehensweise ist aber dieselbe.

Fazit

Bibliotheken stellen oft Traits zur Verfügung, um Benutern zu erlauben die Funktionalität der Bibliothek zu erweitern und den eigenen Bedürfnissen anzupassen.

IO intensive Arbeit

Wir wollen uns mit async vertraut machen. Viele IO intensive Arbeiten machen zu müssen ist einer der wichtigsten Anwendungsfälle für async. Zum Bespiel mehrere Netzwerkanfragen machen. Das werden wir hier simulieren um zu sehen, wie es effizient erledigt werden kann.

Projektstruktur

Erstelle zuerst ein neues Rust Projekt:

cargo new async_io
cd async_io

Als nächstes fügen wir die Bibliotheken tokio hinzu. Das ist eine asynchrone Laufzeit. Rust's async System ist unabhängig von der Laufzeit, eine solche muss man sich deswegen separat besorgen. Die Dokumentation von tokio findest du hier: docs.rs/tokio

cargo add tokio --features full

Tokio hat eine grosse Zahl verschiedener Features, die man wahlweise aktivieren oder deaktivieren kann. Zum Experimentieren ist es am einfachsten alles zu aktivieren, darum --features full.

Simulieren einer Netzwerkanfrage

Zuerst verwenden wir noch kein async, um das Problem aufzuzeigen. Unsere make_request Funktion wartet einfach eine Sekunde. Fünf davon werden in Sequenz ausgeführt:

use std::time::{Duration, Instant};

fn make_request(id: u8) {
    std::thread::sleep(Duration::from_secs(1));
    println!("request {id} done");
}

fn main() {
    let start_time = Instant::now();

    for i in 1..=5 {
        make_request(i);
    }

    println!("total time: {}", start_time.elapsed().as_secs())
}

Wie zu erwarten braucht dieses Programm fünf Sekunden. Mit async geht das besser.

Ein gescheiterter Versuch mit async

Eine naive Übersetzung in async könnte so aussehen:

use std::time::{Duration, Instant};

async fn make_request(id: u8) {
    tokio::time::sleep(Duration::from_secs(1)).await;
    println!("request {id} done");
}

#[tokio::main]
async fn main() {
    let start_time = Instant::now();

    for i in 1..=5 {
        make_request(i).await;
    }

    println!("total time: {}", start_time.elapsed().as_secs())
}

Die Funktion make_request ist nun async und verwended tokio's sleep Funktion, welche async-kompatibel ist. Leider läuft dieses Programm gleich langsam, wie das erste.

Das Problem ist, dass .await sequenziell aufgeführt wird. Normalerweise macht das Sinn, wenn man das Ergebnis einer asynchronen Operation für den nächsten Schritt benötigt. Wir möchten allerdings, dass unsere fünf Anfragen nebenläufig ausgeführt werden.

Zweiter Versuch

Für Nebenläufigkeit git es tokio::spawn. Wir ersetzen das in der Schleife und schauen was passiert:

for i in 1..=5 {
    tokio::spawn(make_request(i));
}

Dieses Programm beendet leider sofort wieder, ohne dass eine der Anfragen abschliessen konnte. Das ist leider immer noch nicht, was wir wollen.

Aller Guten Dinge sind Drei

Ähnlich wie bei Threads werden nebenläufige async Tasks abgebrochen, wenn die main Funktion fertig ist. Und wiederum ähnlich wie bei Threads können wir darauf warten, bis alle Tasks beendet sind. Zu diesem Zweck bekommt man von tokio::spawn einen JoinHandle zurück.

let mut task_handles = Vec::new();
for i in 1..=5 {
    task_handles.push(tokio::spawn(make_request(i)));
}
for handle in task_handles {
    handle.await.unwrap();
}

Das funktioniert nun endlich. Die Tasks werden nebenläufig ausgeführt und die main Funktion wartet, bis alle Tasks fertig sind. Das Programm braucht insgesamt eine Sekunde um alle Tasks auszuführen und die einzelnen Tasks werden vermutlich in zufälliger Reihenfolge fertig werden.

Ein kleines Detail

Um deine CPU Kerne maximal auszunutzen verwendet tokio standardmässig einen Threadpool und verteilt deine Tasks darauf. Nun könnte man meinen: Das hört sich an nach "threads with extra steps!"

Eine gute Beobachtung, aber das ist absichtlich so. async Tasks haben eine ähnliche benutzerfreundlichkeit wie Threads, sind aber effizienter:

  1. Sie sind kooperativ geschedulet. Das heisst, sie werden nicht zu ungünstigen Zeitpunkten von der Laufzeit unterbrochen (wie es bei Threads vorkommt).
  2. Sie haben viel weniger Overhead als Threads. Ein Thread bekommt vom Betriebsystem seinen eigenen Stack und jeder Wechsel vom einen Thread zum anderen geht per Kontextswitch über das Betriebssystem. Das ist relativ viel Zeit und Raum, den so ein Thread in Anspruch nimmt. Tasks hingegen haben keinen eigenen Stack und können ohne Kontextswitch geschedulet werden.

Und letztlich kann man tokio auch explizit sagen, dass man nur einen Thread verwenden will:

#[tokio::main(flavor = "current_thread")]
async fn main() {
    // ...
}

Auch dieses Programm brauch eine Sekunde für die fünf simulierten Netzwerkanfragen - mit nur einem Thread.

Fazit

Für gewisse Anwendungsbereiche ist async ein hervorragendes System um maximale Performance zu erreichen.

std-Erweiterung

Eine mächtige Eigenschaft von Traits erlaubt es uns, die Funktionalität anderer Bibliotheken zu erweitern Wir untersuchen das an einem Beispiel aus der Standardbibliothek.

Ein hässliches Programm?

Betrachte den folgenden Code:

fn main() {
    let nums = [3, 8, 5, 4, 0, 9, 1, 6];

    let mut vec = nums
        .iter()
        .copied()
        .filter(|n| n % 2 == 0)
        .map(|n| n * 10)
        .collect::<Vec<_>>();

    vec.sort();

    println!("{vec:?}");
}

Hier sieht man das sogenannt "method-chaining", das von Rust unterstützt wird. Auf dem Rückgabewert einer Methode kann wiederum die nächste Methode aufgerufen werden. Wenn Methoden gut durchdacht sind, dann lassen sie sich in langen Ketten aneinanderhängen. Viele Programmierer finden das schön und lesbar.

Was passiert in diesem Beispiel genau?

  • aus einem Array werden Zahlen kopiert
  • die geraden Zahlen werden herausgefiltert
  • der rest wird je mit 10 multipliziert
  • das resultat wird in einen Vektor gesammelt

Zuletzt wird der Vektor sortiert. Das passiert aber ausserthalb der "Methodenkette". Wäre es nicht schön, wenn es anders wäre?

Vec erweitern

Können wir dem Vektor eine neue Methode geben? Eigentlich hätten wir gerne sowas:

impl Vec<i32> {
    fn sorted(self) -> Self {
        todo!()
    }
}

Ganz so einfach ist es leider nicht. Der Compiler reklamiert mit:

cannot define inherent impl for foreign type

Also, freie methoden können wir nicht einfach zu Typen hinzufügen, die wir nicht selber definiert haben. Aber: Über einen Trait geht das!

Zuerst definieren wir also einen Trait, der ausdrückt: "Dieser Typ kann in einer Methodenkette sortiert werden". So könnte das aussehen:

trait Sorted {
    fn sorted(self) -> Self;
}

Nun implementieren wir diesen Typ für Vectoren (vorläufig nur von Zahlen, ist einfacher):

impl Sorted for Vec<i32> {
    fn sorted(mut self) -> Self {
        self.sort();
        self
    }
}

Einfach, was? Die Methode sortiert den Vector und gibt ihn wieder zurück. Jetzt haben Vectoren eine Methode, welche von der signatur her in einer Methodenkette verwendet werden kann. Versuchen wir es:

fn main() {
    let nums = [3, 8, 5, 4, 0, 9, 1, 6];

    let vec = nums
        .iter()
        .copied()
        .filter(|n| n % 2 == 0)
        .map(|n| n * 10)
        .collect::<Vec<_>>()
        .sorted();

    println!("{vec:?}");
}

Fazit

In diesem Beispiel ging es nur um Ästhetik. Das ist eigentlich nicht so wichtig (mMn).

Allerdings kannst du dir bestimmt vorstellen, wie diese Eigenschaft von Traits es ermöglicht, die Typen der Standardbibliothek mit komplett unabhängigen Bibliotheken wunderbar kompatibel zu machen. Und noch mehr: Bibliotheken, die voneinenader wissen können untereinander ebenfall ohne viel Mühe Kompatibilität erreichen. Als Konsumenten von Bibliotheken profitieren wir schlussendlich von einer äusserst modularen Bibliothekenlandschaft.