Artikel

Hono statt Express, und warum das mehr als Geschmack war

Für die neue Verwaltungsschnittstelle habe ich kein Express genommen. Was den Ausschlag gegeben hat, wo der Unterschied im Alltag liegt — und wo es keinen gibt.

BlackZackBlackzack

967 Wörter · 5 Min. Lesezeit

  • api
  • werkzeuge
  • leistung

Als die Verwaltungsschnittstelle eine eigene Anwendung wurde, stand die Frage nach dem Unterbau an. Die naheliegende Antwort wäre Express gewesen — es funktioniert, ich kenne es, es ist überall.

Ich habe mich für Hono entschieden. Nicht, weil Express schlecht wäre, sondern wegen drei Punkten, die zu diesem konkreten Projekt passten.

Punkt 1: Typen, die von der Route bis zum Handler durchgehen

Das war der Hauptgrund. In einer typisierten Umgebung ist es ein spürbarer Unterschied, ob der Editor weiß, was in einem Anfrageobjekt steht.

const app = new Hono();
 
app.get("/guilds/:id", async (c) => {
  const id = c.req.param("id");        // typisiert: string
  const gilde = await ladeGilde(id);
  if (!gilde) return c.json({ error: "NOT_FOUND" }, 404);
  return c.json(gilde);
});

In Verbindung mit einer Schemaprüfung wird daraus etwas, das ich vorher immer von Hand gebaut habe: Die geprüften Daten kommen typisiert im Handler an, und ungültige Anfragen erreichen ihn gar nicht erst.

app.patch("/members/:id", pruefe("json", MitgliedSchema), async (c) => {
  const daten = c.req.valid("json");   // vollständig typisiert und geprüft
  ...
});

Punkt 2: Der Umgang mit Antworten ist ausdrücklich

Bei Express schreibt man in ein Antwortobjekt und beendet die Verarbeitung durch Konvention. Vergisst man das, hängt die Anfrage — und man merkt es erst im Betrieb.

Bei Hono gibt ein Handler seine Antwort zurück. Damit ist eine Route ohne Antwort ein Fehler, den man beim Schreiben sieht, und nicht ein Zeitüberschreitungsfehler in der Nacht.

Das ist eine kleine Sache, die auf hundert Routen verteilt eine ganze Fehlerklasse entfernt.

Punkt 3: Wenig Ballast

Die Anwendung macht genau eine Sache: Sie nimmt Anfragen entgegen, prüft ein Token, liest oder schreibt in der Datenbank und antwortet mit JSON. Kein Rendern, keine Dateiuploads, keine Sitzungen.

Für so etwas ist ein schlanker Unterbau angenehm — weniger Abhängigkeiten, schnellerer Start, weniger Angriffsfläche. Der Kaltstart der Anwendung liegt bei deutlich unter einer Sekunde, was beim Neustart nach einer Änderung den Unterschied zwischen „stört nicht" und „stört" macht.

Infobox

Ein Nebeneffekt, den ich nicht eingeplant hatte: Weil Hono auf Standard-Web-APIs aufbaut (Request, Response, Fetch), ist der Code besser übertragbar. Dieselben Handler laufen mit unterschiedlichen Laufzeitumgebungen. Ich brauche das aktuell nicht — aber es bedeutet auch, dass ich Wissen lerne, das nicht an ein einzelnes Ökosystem gebunden ist.

Wo es keinen Unterschied macht

Ich will das nicht größer machen, als es ist.

Bei der Geschwindigkeit im Alltag. Vergleichsmessungen zeigen deutliche Unterschiede bei der Zahl der Anfragen pro Sekunde. In meinem Fall ist das bedeutungslos — meine Schnittstelle beantwortet ein paar Anfragen pro Minute, und die Zeit geht in Datenbankabfragen, nicht in das Framework.

Bei der Verfügbarkeit von Beispielen. Für Express findet man zu jeder Frage zehn Antworten. Bei kleineren Frameworks liest man häufiger die Dokumentation. Das ist kein Nachteil, wenn man es weiß.

Bei der Struktur. Ein schlechtes Routenlayout ist mit jedem Framework schlecht. Die Entscheidungen, die wirklich zählen — wie man Fehler behandelt, wie man Autorisierung einhängt, wie man Antworten formt — sind unabhängig davon.

Die Antwortstruktur, die daraus entstanden ist

Der langlebigste Teil dieses Projekts ist nicht das Framework, sondern die Form der Antworten. Sie ist überall gleich, und das ist ihr ganzer Wert.

{ "ok": true, "data": { } }
{ "ok": false, "error": "VALIDATION_FAILED",
  "message": "…", "fields": { } }

Drei Regeln dazu, die sich bewährt haben:

Der Fehlercode ist maschinenlesbar und stabil. Er ändert sich nicht, wenn jemand den Text umformuliert. Clients prüfen den Code, Menschen lesen die Meldung.

Die Meldung ist für Menschen und enthält keine internen Details. Kein Verlaufsstapel, kein Dateiname, kein SQL.

Feldbezogene Fehler kommen feldbezogen zurück. Eine Oberfläche kann sie dann an den richtigen Stellen anzeigen statt als Sammelmeldung.

Der dritte Punkt hat die Bedienbarkeit meiner Oberflächen mehr verbessert als jede Gestaltungsänderung.

Was ich beim Statuscode gelernt habe

Ein Detail, über das ich lange nicht nachgedacht hatte und das später wichtig wurde: Der Unterschied zwischen „erledigt" und „angenommen".

Ein Aufruf, der etwas sofort tut, antwortet mit dem Erfolgscode. Ein Aufruf, der etwas einreiht, sollte mit dem Code für „angenommen" antworten — er sagt: Ich habe deinen Auftrag, er ist noch nicht ausgeführt.

Das klingt nach Erbsenzählerei und ist ein echter Unterschied für alles, was danach kommt. Eine Oberfläche kann bei „angenommen" einen Wartezustand anzeigen und nachfragen. Bei „erledigt" zeigt sie eine Erfolgsmeldung — und wenn die Aktion in Wahrheit noch läuft, ist diese Meldung falsch.

Ich habe seitdem eine einfache Regel: Wenn die Wirkung nicht innerhalb der Antwort eintritt, ist es nicht erledigt. Und dann muss die Antwort das auch sagen.

Was ich beim Umstieg auf ein kleineres Framework unterschätzt habe

Ein schlankes Werkzeug bedeutet auch, dass man Dinge selbst entscheidet, die einem sonst abgenommen werden.

Fehlerbehandlung an zentraler Stelle. Sie ist schnell gebaut, aber sie ist eben nicht vorhanden. Ohne sie beantwortet ein unerwarteter Fehler die Anfrage mit einer Ausgabe, die man nicht kontrolliert hat.

Protokollierung von Anfragen. Ebenfalls Handarbeit — und die Gelegenheit, sie gleich richtig zu machen: Methode, Pfad, Status, Dauer, Vorgangskennung.

Beenden mit Anstand. Wenn ein Dienst ein Signal zum Beenden bekommt, sollte er laufende Anfragen zu Ende bringen und dann schließen. Das ist ein knappes Dutzend Zeilen und macht den Unterschied zwischen einem sauberen Neustart und abgeschnittenen Antworten.

Alle drei sind Kleinigkeiten. Zusammen sind sie ein halber Tag Arbeit, den man einplanen sollte — und man bekommt dafür genau die Umsetzung, die man haben will, statt einer, die man von jemand anderem erbt.

Was ich mir dabei angewöhnt habe

Zwei Konventionen, die aus diesem Projekt stammen und die ich seitdem überall benutze.

Ein einheitlicher Antwortumschlag für Fehler. Immer dieselbe Form, mit einem maschinenlesbaren Code und einer lesbaren Meldung:

{ "error": "VALIDATION_FAILED", "message": "…", "fields": { "dauer": "muss eine Zahl sein" } }

Der Code ist für den Client, die Meldung für Menschen. Wer nur eine Meldung schickt, zwingt Clients dazu, Texte zu vergleichen — und dann bricht jede Umformulierung eine Integration.

Autorisierung als eine Zwischenschicht, nicht in jeder Route. Die Prüfung hängt vor der gesamten Gruppe. Eine Route kann nicht vergessen zu prüfen, weil sie gar nicht erreichbar ist, ohne die Prüfung zu passieren. Das ist genau der Fehler, den ich im Dashboard einmal hatte — eine einzelne Route ohne Wache.

Diese zweite Konvention ist die wichtigere von beiden. Sicherheit, die man an hundert Stellen wiederholen muss, wird an einer Stelle vergessen. Das ist keine Frage der Sorgfalt, sondern der Statistik.