mitmario.dev

Eigene Middleware

Node.js Sandbox 4 Min Lesezeit 3 BeispieleLektion 2 von 7

Das Muster ist immer dasselbe: prüfen oder ergänzen, dann next(). Was du dabei ergänzt, gehört an req.

Prüfen oder ergänzen, dann weitergeben

Eine Kennung an die Anfrage hängen
import express from "express";

const app = express();

let zaehler = 0;

// Das Protokoll liegt in einer Liste statt im Log, damit du es unter
// /protokoll nachlesen kannst.
const protokoll = [];

// Der Pfad steht beim Anmelden, nicht in der Funktion: diese Middleware gilt
// nur fuer Anfragen, die mit /api beginnen.
app.use("/api", (req, res, next) => {
  zaehler += 1;
  req.kennung = `a${zaehler}`;
  res.setHeader("X-Anfrage-Id", req.kennung);
  protokoll.push(`${req.kennung} kommt an: req.url=${req.url} req.originalUrl=${req.originalUrl}`);

  const start = process.hrtime.bigint();
  res.on("finish", () => {
    const ms = Number(process.hrtime.bigint() - start) / 1e6;
    protokoll.push(`${req.kennung} ist fertig: ${res.statusCode}, unter 100 ms: ${ms < 100}`);
  });

  next();
});

app.get("/api/status", (req, res) => res.json({ id: req.kennung, ok: true }));
app.get("/start", (req, res) => res.json({ id: req.kennung ?? "ohne Kennung" }));
app.get("/protokoll", (req, res) => res.json(protokoll));

app.listen(3000);

Drei Dinge auf einmal, und alle drei siehst du in echten Projekten ständig.

req.kennung = ... hängt einen Wert an die Anfrage. Ab dieser Zeile kann jede Middleware und jede Route darunter ihn lesen. Nur /api/status bekommt eine, weil die Middleware unter /api angemeldet ist; /start läuft ohne. Deshalb steht dort req.kennung ?? "ohne Kennung", und deshalb fehlt bei /start die Kopfzeile. Im Browser siehst du Kopfzeilen nicht, im Terminal schon:

curl -i http://localhost:3000/api/status

curl -i http://localhost:3000/start

res.setHeader funktioniert hier, obwohl die Antwort noch gar nicht geschrieben ist. Express sammelt Kopfzeilen und schickt sie erst mit, wenn die Antwort rausgeht. Solange noch nichts gesendet wurde, darfst du also welche setzen.

res.on("finish", ...) läuft, wenn die Antwort draußen ist. Das ist der richtige Ort für alles, was das Ergebnis kennen muss: Statuscode, Dauer, Größe. Für eine Kopfzeile ist es zu spät, die sind dann längst unterwegs. Merk dir die Trennung: Was in die Antwort soll, setzt du vorher. Was über die Antwort geschrieben wird, protokollierst du nachher.

Das Beispiel schreibt unter 100 ms: true statt einer Millisekundenzahl. In deinem eigenen Protokoll gehört dort die echte Zahl hin; hier wäre sie bei jedem Lauf eine andere und würde nur davon ablenken, worum es geht.

Warum der Pfad beim Anmelden abgeschnitten wird

Ruf /api/status auf und danach /protokoll. In der ersten Zeile steht: req.url sagt /status, req.originalUrl sagt /api/status.

Das ist kein Fehler, sondern Absicht: Innerhalb einer unter /api angemeldeten Middleware ist /api bereits abgearbeitet. Dazu gibt es req.baseUrl mit dem abgeschnittenen Teil. Wenn du in einem Protokoll die vollständige Adresse sehen willst, nimm also req.originalUrl, sonst wunderst du dich irgendwann über Logzeilen, in denen der halbe Pfad fehlt.

Warum nicht einfach eine Variable daneben?

Das ist die Frage, die sich beim ersten Mal jeder stellt. Eine Variable außerhalb wäre kürzer, und sie funktioniert auch. Beim Ausprobieren.

Die Variable daneben, wenn zwei gleichzeitig fragen
import express from "express";

const app = express();

// Falsch: eine Variable ausserhalb, fuer alle Anfragen dieselbe.
let aktuellerName = "";

app.use("/aussen", (req, res, next) => {
  aktuellerName = req.query.name;
  next();
});

app.get("/aussen", async (req, res) => {
  await new Promise((r) => setTimeout(r, Number(req.query.warten)));
  res.json({ gruss: `Hallo ${aktuellerName}` });
});

// Richtig: an die Anfrage gehaengt, also fuer jede Anfrage ein eigener Wert.
app.use("/innen", (req, res, next) => {
  req.name = req.query.name;
  next();
});

app.get("/innen", async (req, res) => {
  await new Promise((r) => setTimeout(r, Number(req.query.warten)));
  res.json({ gruss: `Hallo ${req.name}` });
});

app.listen(3000);

Rechts steht der harmlose Fall: eine Anfrage allein, und die Antwort stimmt. Zwei gleichzeitig bekommst du in einem Browser-Tab nicht hin, im Terminal schon. Das & schickt den ersten Aufruf in den Hintergrund, sleep lässt ihn anlaufen, und der zweite kommt dazwischen:

curl -s "http://localhost:3000/aussen?name=Ada&warten=300" & sleep 0.1; curl -s "http://localhost:3000/aussen?name=Grace&warten=10"; wait

Ada fragt zuerst und ihre Antwort dauert länger, Grace kommt hundert Millisekunden später dazwischen. Die Middleware überschreibt die Variable, und als Adas Route endlich antwortet, steht dort Graces Name. Beide Zeilen sagen Hallo Grace.

Derselbe Befehl mit /innen statt /aussen, und jede bekommt ihren eigenen Namen:

curl -s "http://localhost:3000/innen?name=Ada&warten=300" & sleep 0.1; curl -s "http://localhost:3000/innen?name=Grace&warten=10"; wait

Ada bekommt eine Antwort, die für jemand anderen gedacht war. Bei einem Namen ist das peinlich. Bei einer Nutzerkennung oder einem Warenkorb ist es ein Sicherheitsvorfall.

Und jetzt der eigentliche Punkt: Beim Entwickeln merkst du davon nichts. Du klickst dich allein durch deine Anwendung, es ist immer nur eine Anfrage unterwegs, und alles stimmt. Der Fehler erscheint erst, wenn mehrere Leute gleichzeitig da sind, also genau dann, wenn du am wenigsten Lust darauf hast.

Die Regel dazu ist kurz: Alles, was zu einer einzelnen Anfrage gehört, gehört an req. Eine Variable außerhalb ist für Dinge da, die bewusst geteilt werden, ein Zähler zum Beispiel, wie im ersten Beispiel.

Middleware mit einer Einstellung

Dir ist in Abschnitt 9 aufgefallen, dass manche Middleware mit Klammern angemeldet wird (express.static("public")) und manche ohne. Der Unterschied ist einfacher, als er aussieht.

Middleware mit einer Einstellung
import express from "express";

// Eine Funktion, die eine Middleware zurueckgibt. Genau so sieht fast jedes
// Express-Paket aus, das man beim Anmelden mit Klammern aufruft.
function nurMitSchluessel(erwartet) {
  return (req, res, next) => {
    if (req.headers["x-schluessel"] !== erwartet) {
      return res.status(401).json({ fehler: "Schluessel fehlt oder stimmt nicht" });
    }
    next();
  };
}

const app = express();

app.use("/intern", nurMitSchluessel("geheim"));

app.get("/", (req, res) => res.send("offen fuer alle"));
app.get("/intern/zahlen", (req, res) => res.json({ zahlen: [1, 2, 3] }));

app.listen(3000);

nurMitSchluessel ist keine Middleware, sondern eine Funktion, die eine zurückgibt. Beim Anmelden rufst du sie auf, sie merkt sich den erwarteten Schlüssel und liefert die eigentliche Funktion mit drei Argumenten.

Damit kannst du dieselbe Middleware mehrfach mit verschiedenen Einstellungen anmelden. Und du weißt jetzt, warum praktisch jedes Express-Paket so aussieht.

Rechts steht /, die offene Seite. /intern/zahlen in der Adresszeile gibt einen 401, denn der Browser kennt den Schlüssel nicht. Im Terminal kannst du ihn mitschicken:

curl -i http://localhost:3000/intern/zahlen

curl -i -H "X-Schluessel: geheim" http://localhost:3000/intern/zahlen

Beachte auch die Zeile mit return res.status(401).... Die Middleware antwortet und ruft next() nicht auf. Das ist völlig in Ordnung: Eine Middleware darf die Kette abbrechen, sie muss nur eines von beiden tun. Was nicht geht, ist beides oder keines.

Was in eine Middleware gehört und was nicht

Hinein gehört, was querschnittlich ist, also für viele Routen gleich: Protokollierung, Zeitmessung, Anmeldung, Berechtigungen, das Lesen des Bodys.

Nicht hinein gehört die eigentliche Arbeit einer Route. Wenn eine Middleware anfängt, Bücher zu suchen oder Preise zu rechnen, ist das eine Route, die man an der falschen Stelle geschrieben hat. Die Frage, die dabei hilft: Würde ich das bei jeder zweiten Route wieder brauchen? Wenn ja, ist es Middleware.

Zum Mitnehmen

Ergebnisse gehören an req, nicht in eine Variable daneben. Die Variable wäre für alle gleichzeitigen Anfragen dieselbe, und das fällt erst unter Last auf.

Jetzt du

Basis Konto, kostenlos

Zu dieser Lektion gehört eine Aufgabe. Du schreibst den Code selbst, und nach jedem Lauf sagt dir eine Prüfliste, was schon stimmt.

Dafür brauchst du das Basis Konto. Es kostet nichts, und ein Passwort gibt es auch nicht.

In diesem Kurs läuft dein Code auf einem Server. Dafür hat das Basis Konto 1 Stunde im Monat, mehr Zeit gibt es mit dem Premium Konto.

Was in dieser Lektion steckt

  • Artikel mit 3 Beispielen zum Ausprobieren

    Steht hier, ohne Konto lesbar.

  • Aufgabe, dein Code läuft auf einem Server

    Öffnet sich mit dem Basis Konto.