Ferris Talk #12: Web-APIs mit Rust erstellen

Seite 2: Abfrage von Daten

Inhaltsverzeichnis

Nachdem der Server gestartet ist, folgen die API-Handler und das Routing. Da sich die Codebeispiele auf die ausgewählten Web-API-Frameworks konzentrieren, ist die eigentliche Verwaltungslogik der To-do-Liste in eine eigene Bibliothek ausgelagert, die im GitHub-Repository des Autors zu finden ist. Die folgenden API-Handler-Methoden rufen die Funktionen der Bibliothek auf.

Los geht es mit den Methoden zum Abrufen von To-do-Einträgen im Rocket-Framework:

/// Get list of to-do items
///
/// Rocket implements the FromParam trait 
/// for typically used data types so they can
/// be used to extract data from the query string.
/// Of course you can implement
/// FromParam for custom data types, too.
/// More about it at 
/// https://rocket.rs/v0.5-rc/guide/requests/#query-strings.
///
/// The shared state is extracted using the State request
/// guard. Mmore about Rocket's request guards at
/// https://rocket.rs/v0.5-rc/guide/requests/
///   #request-guards.
/// 
/// Also note the Responder trait 
/// https://rocket.rs/v0.5-rc/guide/responses/
      #custom-responders).
/// Rocket comes with a lot of built-in responders, 
/// but you can also implement the trait for your 
/// own custom types.
#[get("/todos?<offset>&<limit>")]
async fn get_todos(offset: Option<usize>, 
                   limit: Option<usize>, 
                   db: &State<Db>) 
  -> Json<Vec<IdentifyableTodoItem>> 
{
  let todos = db.read().await;
  let pagination = Pagination::new(offset, limit);
  Json(todos.get_todos(pagination))
}

/// Get a single to-do item
///
/// Note that Option<T> implements the 
/// Responder trait, too. This makes it really
/// simple to return a 404 if the requested 
/// item does not exist.
#[get("/todos/<id>")]
async fn get_todo(id: usize, db: &State<Db>) 
  -> Option<Json<IdentifyableTodoItem>> 
{
  let todos = db.read().await;
  todos.get_todo(id).map(|item| Json(item.clone()))
}

In Rocket dienen Makros dazu, die Routen der API-Handler-Methoden zu definieren. Der Code verwendet das get-Makro, um Daten abzufragen. Analog dazu gibt es Makros für andere HTTP-Verben wie post und delete. Rocket kann Parameter sowohl im Pfad der URL als auch im Query String verarbeiten. Die Basis für das Extrahieren von Informationen aus der URL ist der Trait FromParam. Jeder Rust-Typ, der ihn implementiert, lässt sich für URL-Parameter verwenden. Rocket bringt fertige Implementierungen für gängige Rust-Datentypen mit. Das schließt auch den Option-Typ ein, mit dem sich optionale Parameter modellieren lassen. Für komplexere Szenarien kann man zudem FromParam individuell für eigene Datentypen implementieren.

Besonders erwähnenswert ist der Parameter db. Dabei kommt ein zentrales Konzept von Rocket zum Einsatz – Request Guards: Typen, die den FromRequest-Trait implementieren. Sie schützen API-Handler, indem sie prüfen, ob benötigte Informationen – im konkreten Beispiel der Shared State – zur Verfügung stehen. Sie stellen diese Daten anschließend den Handler-Methoden zur Verfügung. Weitere typische Anwendungsbeispiele für Request Guards wären der Zugriff auf Cookies und das Prüfen von JSON Web Tokens (JWT).

Das Ergebnis der obigen Handler-Methoden ist eine JSON-Response. Rocket kennt dafür die Struktur Json, die sich sowohl als Request Guard zum Extrahieren von JSON-Daten aus dem Request Body als auch in Form des Responder zur Rückgabe von JSON-Daten im Response Body verwenden lässt. Zum Serialisieren und Deserialisieren kommt sowohl in Axum als auch in Rocket die dafür vorgesehene Serde-Crate zum Einsatz.

Folgender Code zeigt als Vergleich zur Rocket-Implementierung die Umsetzung der Handler-Methoden zur Datenabfrage mit Axum:

/// Get list of to-do items
///
/// Note how the Query extractor is used to get 
/// query parameters (more about extractors at
/// https://docs.rs/axum/latest/axum/#extractors).
/// Note how the State extractor is used to get 
/// the database (will change in Axum 0.6 RC).
/// Extractors are types that implement FromRequest. 
/// You can create your own extractors or use 
/// the ones provided by Axum.
async fn get_todos(pagination: Option<Query<Pagination>>, 
                   State(db): State<Db>) 
  -> impl IntoResponse 
{
  let todos = db.read().await;
  let Query(pagination) = pagination.unwrap_or_default();
  // Json is an extractor and a response.
  Json(todos.get_todos(pagination))
}

/// Get a single to-do item
///
/// Note how the Path extractor is used 
/// to get query parameters.
async fn get_todo(Path(id): Path<usize>, 
                  State(db): State<Db>) 
  -> impl IntoResponse 
{
  let todos = db.read().await;
  if let Some(item) = todos.get_todo(id) {
    // Note how to return Json
    Json(item).into_response()
  } else {
    // Note how a tuple can be turned into a response
    (StatusCode::NOT_FOUND, "Not found").into_response()
  }
}

Ein Unterschied zwischen Rocket und Axum sticht sofort ins Auge: Das Routing geschieht nicht durch Makros bei der Handler-Methode, sondern war bereits in der zuvor gezeigten main-Methode zu finden. Ansonsten ähneln sich die Konzepte. Extractors dienen in Axum dazu, Daten aus der URL zu extrahieren. Technisch gesehen handelt es sich dabei um Typen, die den Trait FromRequest implementieren. Wie Rocket kommt Axum mit FromRequest-Implementierungen für viele, häufig verwendete Einsatzfälle. Ein nützliches Detail ist dabei in der Methode get_todos zu sehen. Statt die Query-Parameter einzeln angeben zu müssen, verwendet man eine Struktur (Pagination), die alle Query-Parameter empfängt.

Das Erzeugen des Ergebnisses, also der HTTP Response Message, basiert bei Axum auf dem IntoResponse-Trait. Handler-Methoden können jeden Typ zurückgeben, der diesen Trait implementiert. Für JSON steht ähnlich wie bei Rocket die Json-Struktur bereit, die sowohl Extractor als auch Responder ist, um JSON sowohl im Request als auch im Response Body verarbeiten zu können.

Mehr zum Umgang mit JSON in Zusammenhang mit HTTP Requests ist in der Handler-Methode zum Hinzufügen von To-do-Einträgen erkennbar. Folgender Code zeigt die Implementierung in Rocket:

/// Add a new to-do item
///
/// Note the use of a "Request Guard" (FromRequest
/// trait) here. Here it is used to extract the 
/// JSON body of the request. You can implement 
/// your own guards, too
/// (https://rocket.rs/v0.5-rc/guide/requests/
///   #custom-guards). Many things that you
/// would do with middleware in other frameworks are 
/// done with request guards in Rocket.
#[post("/todos", format = "json", data = "<todo>")]
async fn add_todo(todo: Json<TodoItem>, 
                  db: &State<Db>) 
  -> Created<Json<IdentifyableTodoItem>> 
{
  let mut todos = db.write().await;
  let todo = todos.add_todo(todo.0);

  // Nice detail here: The uri macro helps you 
  // generate URIs for your routes.
  // Very useful for building the location header.
  let location = uri!("/", get_todo(todo.id));
  Created::new(location.to_string()).body(Json(todo))
}

Das post-Makro erlaubt die Angabe, dass die Methode Daten im JSON-Format erwartet, die in den Parameter todo eingetragen werden sollen. Beim Datentyp Json handelt es sich um die gleiche Struktur, die oben bereits beim Zusammenstellen der JSON Response Message zum Einsatz kam.

Ein schönes Detail ist das Erstellen der Created Response Message. Rocket bietet eine eigene Struktur mit zugehörigem Makro an, die das Erstellen des bei RESTful-Web-APIs üblichen Location-Headers vereinfacht.

Bei Axum sieht die Handler-Methode, die To-do-Elemente hinzufügt, ähnlich aus:

/// Add a new to-do item
///
/// Note that this time, Json is used as an extractor. 
/// That means that the request body
/// will be deserialized into a TodoItem.
async fn add_todo(State(db): State<Db>, 
                  Json(todo): Json<TodoItem>) 
  -> impl IntoResponse 
{
  let mut todos = db.write().await;
  let todo = todos.add_todo(todo);
  (StatusCode::CREATED, Json(todo))
}

Die Umsetzungen der Methode add_todo zeigen erneut, dass die beiden Frameworks konzeptionell ähnlich sind, wenn es darum geht, die eigentliche API-Logik umzusetzen. Größere Unterschiede gibt es beim API-Design, darunter die Rolle von Makros und die Integration in den Tokio-Stack sowie beim Aufbau der zentralen Abstraktionen, auf denen die beiden Frameworks aufbauen, wie Request Guards bei Rocket versus Extractors bei Axum.

Die restlichen API-Methoden setzen ebenso auf die vorgestellten Konzepte. Der vollständige Sourcecode findet sich in dem GitHub-Repository zum Artikel.

Für die Auswahl des Frameworks zum Entwickeln einer umfangreichen Web-API spielen nicht nur technische Aspekte eine Rolle. Punkte wie Dokumentation, Wartung, regelmäßige Erweiterungen oder eine lebendige Community sind bedeutend, da man eine langfristige Bindung eingeht.

In Sachen Dokumentation gibt es bei beiden Frameworks nicht viel auszusetzen. Rocket bietet eine umfangreiche Anleitung, die weit über die reine API-Dokumentation hinausgeht. Axum hinkt bei dem Punkt einen Tick hinterher. Die konzeptionelle Erklärung ist in die Dokumentation der Crate eingebettet und nicht so umfangreich wie bei Rocket, aber ausreichend. Beim Beispielcode ist es umgekehrt: Beide Frameworks liefern im jeweiligen GitHub-Repository Beispiele für viele Anwendungsfälle mit, und bei Axum ist die Beispielsammlung eine Spur umfangreicher als bei Rocket.

Was die Weiterentwicklung betrifft, sind die Unterschiede größer. Die Beliebtheit von Axum ist gemessen an den Downloadzahlen auf crates.io seit dem Start des Projekts stark gestiegen. Das Framework ist deutlich jünger als Rocket, hat aber bezüglich der Downloadzahlen bereits eindeutig die Nase vorn. Für Axum erscheinen regelmäßig neue Versionen. Im Vergleich dazu ist der erste Release Candidate (RC) von Rocket 0.5 bereits über ein Jahr und der aktuelle RC2 ein halbes Jahr alt. Die Entwicklung von Rocket schreitet somit momentan langsamer voran als die von Axum.

Der wahrscheinlich wichtigste Unterschied in Sachen Zukunftssicherheit der Frameworks ist die enge Einbindung von Axum in das Tokio-Projekt gegenüber der loseren Kopplung bei Rocket. Ein gutes Beispiel dafür ist das Middleware-System beider Frameworks. Während Rocket mit den Request Guards und den Fairings (mehr dazu im iX-Artikel "Rostige Raketen") eigene Konzepte mitbringt, verzichtet Axum auf ein eigenes Middleware-Konzept und baut auf dem Tower-Projekt von Tokio auf. Letzteres ist in der Rust-Welt zu einem zentralen Baustein geworden, und die enge Verzahnung wird sich bei anhaltendem Erfolg von Tokio positiv auf Axum auswirken.

Einige mögen sich wegen der wachsenden Aufmerksamkeit für Rust fragen, ob sie ihr angestammtes Web-API-Framework beiseite legen und in Zukunft auf Rust setzen sollen. Obwohl durch Frameworks wie Rocket und Axum die API-Entwicklung in Rust abseits der systemnahen Softwareentwicklung auf jeden Fall machbar und sinnvoll geworden ist, ist der Funktionsumfang noch nicht mit dem von älteren und weit verbreiteten Frameworks wie ASP.NET Core vergleichbar. Funktionen wie das Generieren einer Open-API-Spezifikation (Swagger), fertige Komponenten für gängige Authentifizierungsplattformen oder nahtlose Integration anderer Protokolle wie gRPC sind in Rust nicht vorhanden, oder man muss sich eigenhändig Umsetzungen dafür bauen beziehungsweise zusammensuchen.

Man könnte vermuten, dass Rust diesen Nachteil durch Performancevorteile aufwiegen kann. Was für zahlreiche Projekte gelten mag, ist jedoch nicht zwangsläufig der Fall. In den aktuellen TechEmpower Web Framework Benchmarks liegen Axum und ASP.NET Core Kopf an Kopf auf den Plätzen 8 und 9. Rocket ist zumindest bei diesem Benchmark nicht im Spitzenfeld zu finden. Beim Vergleich von Frameworks anhand von Benchmarks ist allerdings Vorsicht geboten: Oft lassen sich allgemeine Ergebnisse nicht auf konkrete Projekte übertragen. Außerdem ist zu bedenken, dass Axum im Vergleich zu ASP.NET Core ein Jungspund ist. Da Rust sich durch hervorragende Effizienz auszeichnet, ist zu erwarten, dass es bei Axum noch einiges an Optimierungspotenzial gibt. Im Vergleich dazu müssen etablierte Plattformen wie ASP.NET Core fundamentale Veränderungen andenken, um beispielsweise durch Ahead-of-Time-Übersetzung Startup-Zeiten noch weiter zu verbessern und Anwendungen noch wesentlich zu verkleinern.