mitmario.dev

Synthese: der Warenkorb

Node.js Sandbox 3 Min Lesezeit 3 BeispieleLektion 5 von 6

Der Warenkorb ist das Lehrbuchbeispiel für Sitzungen, und ausnahmsweise zu Recht. Er gehört genau einem Besucher, er soll den Wechsel zwischen Seiten überleben, und er entscheidet am Ende über Geld. Damit sind alle drei Eigenschaften beisammen, für die es Sitzungen gibt.

Technisch ginge es: Ein Korb mit drei Positionen passt problemlos in ein Cookie. Nach 13.3 weißt du aber, wem der Zettel gehört, und ein Warenkorb ist die eine Sache, bei der eine Fälschung sich unmittelbar auszahlt.

Also liegt im Cookie nur der Ausweis, und der Korb liegt in der Sitzung. Genau die Middleware aus 13.4 leistet das, hier unverändert als eigenes Modul. Deine Routen fassen req.sitzung.korb an und merken vom Cookie nichts mehr.

Der Preis kommt aus dem Katalog

Und damit zu der Entscheidung, die in dieser Lektion wirklich zählt.

Wer den Preis mitschickt, bestimmt ihn
import express from "express";

// Derselbe Katalog wie in der Challenge, hier fest im Code.
const katalog = new Map([[1, { name: "Kaffeebohnen", cent: 890 }]]);

const app = express();
app.use(express.urlencoded({ extended: true }));

// Zwei Formulare nebeneinander. Das erste schickt den Preis mit, das
// zweite nur die Artikelnummer.
app.get("/", (req, res) =>
  res.type("html").send(
    '<form method="post" action="/aus-dem-formular">' +
      "<p>Kaffeebohnen, 8,90 EUR</p>" +
      '<p><input type="hidden" name="preis" value="890"></p>' +
      '<p>Menge: <input name="menge" value="2"></p>' +
      "<p><button>aus dem Formular</button></p>" +
      "</form>" +
      '<form method="post" action="/aus-dem-katalog">' +
      '<p><input type="hidden" name="artikel" value="1"></p>' +
      '<p>Menge: <input name="menge" value="2"></p>' +
      "<p><button>aus dem Katalog</button></p>" +
      "</form>"
  )
);

// So nicht: der Preis kommt aus dem Formular.
app.post("/aus-dem-formular", (req, res) => {
  const cent = Number(req.body.preis) * Number(req.body.menge);
  res.type("text/plain").send(`Summe ${(cent / 100).toFixed(2).replace(".", ",")} EUR`);
});

// So: der Preis kommt aus dem Katalog, das Formular sagt nur, was und
// wie viel.
app.post("/aus-dem-katalog", (req, res) => {
  const artikel = katalog.get(Number(req.body.artikel));
  const cent = artikel.cent * Number(req.body.menge);
  res.type("text/plain").send(`Summe ${(cent / 100).toFixed(2).replace(".", ",")} EUR`);
});

app.listen(3000);

Rechts stehen zwei Formulare. Beide rechnen mit derselben Menge, und beide kommen auf denselben Betrag. Jetzt ändere im ersten Formular den versteckten Preis: Klick in die Seite, öffne die Entwicklerwerkzeuge deines eigenen Browsers und setz value="1" statt 890. Oder kürzer im Terminal:

curl -X POST -d "artikel=1&preis=1&menge=2" http://localhost:3000/aus-dem-formular

curl -X POST -d "artikel=1&preis=1&menge=2" http://localhost:3000/aus-dem-katalog

Beide Routen bekommen dieselbe Anfrage, in der ein Feld preis=1 steht. Die erste glaubt es und rechnet zwei Cent. Die zweite schaut im Katalog nach und rechnet 17,80 Euro.

Was aus dem Formular kommt, sagt nur, was der Besucher will, nie was es kostet. Die Kennung des Artikels und die Menge kommen von außen, alles andere holst du selbst. Das ist derselbe Gedanke wie bei rolle=admin im Cookie, nur mit einem Preisschild dran, und es ist ein Fehler, den man in echten Shops immer noch findet.

Dasselbe gilt für den Moment der Abrechnung. Ein Preis, der beim Hineinlegen galt, muss beim Bezahlen nicht mehr gelten. Deshalb steht im Korb die Kennung und die Menge, und der Preis wird geholt, wenn er gebraucht wird.

Geld rechnet man in ganzen Cent

Ein zweiter Punkt, der später teuer wird, wenn man ihn früh falsch macht.

Geld in ganzen Cent
// Warum Geld nicht als Kommazahl gerechnet wird.
console.log(`0.1 + 0.2        = ${0.1 + 0.2}`);
console.log(`19.90 * 3        = ${19.9 * 3}`);

// Dieselbe Rechnung in ganzen Cent, erst ganz am Ende umgewandelt.
const cent = 1990 * 3;
console.log(`1990 * 3         = ${cent} Cent`);
console.log(`als Text         = ${(cent / 100).toFixed(2).replace(".", ",")} EUR`);

Kommazahlen im Rechner sind Näherungen, und bei Geld summieren sich diese Näherungen zu Beträgen, die um einen Cent danebenliegen. In einer Bestellübersicht fällt das niemandem auf, in einer Abrechnung schon.

Der übliche Weg ist deshalb: intern ganze Zahlen in der kleinsten Einheit, also Cent, und erst bei der Ausgabe eine Kommazahl daraus machen. Genau das macht die Zeile mit toFixed(2) und dem Austausch von Punkt gegen Komma. Auch die Datenbank speichert cent als INTEGER.

Was im Korb steht

Bleibt die Frage, wie der Korb selbst aussieht. Eine Map von der Artikelkennung auf die Menge reicht völlig.

Der Korb über vier Schritte
const katalog = new Map([
  [1, { name: "Kaffeebohnen", cent: 890 }],
  [3, { name: "Kanne", cent: 1890 }],
]);

// Der Korb merkt sich nur Kennung und Menge, keine Namen und keine Preise.
const korb = new Map();

function legeDazu(id, menge) {
  korb.set(id, (korb.get(id) ?? 0) + menge);
}

function zeige(was) {
  const teile = [...korb].map(([id, menge]) => `${menge} x ${katalog.get(id).name}`);
  const cent = [...korb].reduce((summe, [id, menge]) => summe + katalog.get(id).cent * menge, 0);
  console.log(`${was.padEnd(22)} ${teile.join(", ") || "leer"} = ${(cent / 100).toFixed(2).replace(".", ",")} EUR`);
}

legeDazu(3, 1);
zeige("eine Kanne");
legeDazu(3, 1);
zeige("noch eine Kanne");
legeDazu(1, 3);
zeige("dazu drei Bohnen");
korb.delete(3);
zeige("Kanne entfernt");

Zweimal derselbe Artikel wird eine Zeile mit Menge zwei, nicht zwei Zeilen. Das ist keine Feinheit, sondern das, was ein Besucher erwartet, wenn er zweimal auf denselben Knopf drückt.

Namen und Preise stehen bewusst nicht darin. Sie kommen bei jeder Anzeige frisch aus dem Katalog, und damit stimmt der Korb auch dann noch, wenn sich in der Zwischenzeit ein Name oder ein Preis geändert hat. Ein Korb, der eine Kopie des Katalogs mit sich herumträgt, veraltet in dem Moment, in dem jemand im Backend etwas anfasst.

Zum Mitnehmen

Der Korb gehört in die Sitzung, nicht ins Cookie. Und der Preis kommt beim Abschicken aus dem Katalog, nicht aus dem Formular.

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.