Boring Stack


3 Min. Lesezeit

Das SQL, das man liest, ist das SQL, das läuft

sqlx und handgeschriebene Abfragen statt eines ORM

Teil fünf des Boring Stack. Die Daten liegen in SQLite. Dieser Text handelt vom Code zwischen App und Datenbank.

Die übliche Wahl ist ein ORM: Man beschreibt seine Tabellen als Klassen oder Structs, und die Bibliothek schreibt das SQL. Das fühlt sich produktiv an bis zur ersten langsamen Seite, wenn man herausfinden muss, welche der vierzig erzeugten Abfragen das Problem ist und wie man das ORM dazu bringt, eine andere zu erzeugen.

Ich schreibe das SQL selbst.

sqlx: gerade genug Hilfe

Die Go-Standardbibliothek hat database/sql, das Abfragen ausführt und Zeilen zurückgibt. Diese Zeilen von Hand in Structs zu lesen ist mühsam. sqlx ergänzt genau das, und nicht viel mehr:

var items []model.Item
err := r.db.SelectContext(ctx, &items,
    `SELECT id, title, created_at FROM items WHERE owner_id = ? ORDER BY created_at DESC`,
    ownerID)

Die Abfrage steht direkt da. Ist sie langsam, kopiere ich sie in die Datenbankkonsole und schaue mir den Abfrageplan an. Ist sie falsch, sehe ich, warum. Dazwischen liegt keine Schicht mit eigener Meinung.

Ein Repository pro Tabelle

Jede Tabelle bekommt ein Repository: ein Interface und eine nicht exportierte Struct, die es umsetzt, mit handgeschriebenen Abfragen. Der Rest der App spricht nur mit dem Interface. Services enthalten die Geschäftsregeln und rufen Repositories auf. Handler rufen Services auf.

So hat jede Abfrage genau ein Zuhause. Wenn ich eine Tabelle ändere, weiss ich, welche Datei ich öffnen muss.

Schreibzugriffe, die sich wiederholen dürfen

Manche Schreibzugriffe passieren absichtlich mehr als einmal: ein Webhook, der doppelt geliefert wird, ein Formular, das erneut abgeschickt wird, ein Job, der nach einem Absturz wiederholt wird. Solche Zugriffe müssen beim selben Ergebnis ankommen, statt zu scheitern oder Daten zu verdoppeln.

In SQL lässt sich das einfach ausdrücken und einfach sehen:

  • INSERT … ON CONFLICT DO NOTHING für «sorg dafür, dass das existiert».
  • Löschen und Einfügen in einer Transaktion für «ersetze die ganze Liste».
  • Webhook-Handler übernehmen den vollständigen aktuellen Zustand des Anbieters, nicht nur die Änderung. So dürfen Lieferungen doppelt oder in falscher Reihenfolge kommen, und das Ergebnis stimmt trotzdem.

Bei einem ORM stecken dieselben Garantien oft hinter Optionen, die in jeder Bibliothek anders aussehen.

Nichts speichern, was man berechnen kann

Eine Regel gilt in jedem meiner Schemas: Nie einen Wert speichern, der sich aus anderen Daten berechnen lässt. Keine gespeicherten Zähler, keine gespeicherten «ist offen»-Flags, keine gespeicherten Positionsnummern. Sie werden beim Lesen berechnet. Eine gespeicherte Kopie widerspricht irgendwann den Daten, aus denen sie kopiert wurde, und dann gibt es zwei Wahrheiten.

SQL ist sehr gut darin, so etwas im Moment zu berechnen. COUNT(*), eine Window-Funktion, ein CASE: Meistens ist es eine Zeile.

Was es kostet

  • Mehr Tipparbeit bei einfachem CRUD. Eine Struct einzufügen heisst, ihre Spalten aufzuzählen. Das nehme ich in Kauf, weil in diesen Listen Fehler sichtbar werden.
  • Keine automatischen Beziehungen. Einen Eintrag mit seinen Kommentaren zu laden sind zwei Abfragen oder ein Join, den ich schreibe. Das Lazy Loading eines ORM würde das für mich erledigen und nebenbei das klassische N+1-Problem erzeugen, ohne es mir zu sagen.
  • Man muss SQL können. Ich finde, das sollte jeder, der mit einer Datenbank arbeitet. Das ORM nimmt einem das nicht ab, es verschiebt es nur auf den schlechtesten Moment.

Wann ich anders entscheiden würde

Für einen Prototyp, der weggeworfen wird, kann ein ORM schneller zum Start führen. Für ein Team, das eines gut kennt, lohnt sich der Wechsel vielleicht nicht. Für alles, was ich jahrelang pflege, will ich die Abfragen lesen können.

Als Nächstes: wie sich das Schema über die Zeit ändert.

Mehr in Boring Stack

Teilen