Raphael Pigulla

Lesezeit: 11 Minuten

Techblog

Migrationen mit Node.js und PostgreSQL

Jedes Projekt, das Daten persistiert, muss unweigerlich mit der Tatsache umgehen, dass die Struktur der Daten nicht statisch ist und angepasst werden muss. Die Gründe dafür sind vielfältig: Geschäftsanforderungen ändern sich, Entitäten werden zu groß und müssen zerlegt werden, oder Performanceprobleme machen zusätzliche Indizes oder Denormalisierung sinnvoll. Heute werden wir uns im Kontext von Node.js…

Techblog

Jedes Projekt, das Daten persistiert, muss unweigerlich mit der Tatsache umgehen, dass die Struktur der Daten nicht statisch ist und angepasst werden muss. Die Gründe dafür sind vielfältig: Geschäftsanforderungen ändern sich, Entitäten werden zu groß und müssen zerlegt werden, oder Performanceprobleme machen zusätzliche Indizes oder Denormalisierung sinnvoll. Heute werden wir uns im Kontext von Node.js und PostgreSQL ansehen, wie man die Verwaltung von strukturellen Änderungen angehen kann.

Es ist essenziell, Modifikationen an der Datenbank sicher auszuführen und versioniert zu verwalten – eine fehlgeschlagene Migration kann zu Inkonsistenzen, Datenverlust oder sogar zum Ausfall des gesamten Systems führen. Aus diesem Grund legen wir die folgenden Einschränkungen fest:

  • Alles oder nichts
    Wie bei den meisten Geschäftsvorgängen muss eine Migration in einer Alles-oder-Nichts-Modus durchgeführt werden: entweder wird der gesamte Datenbestand migriert, oder es dürfen keinerlei Änderungen vorgenommen werden. Während praktisch alle relationalen Datenbanken ACID-konform sind und Transaktionen unterstützen, ist dies bei strukturellen Änderungen nicht immer der Fall. Das weit verbreitete MySQL tut dies zum Beispiel nicht.
  • Rollback möglich
    Auch wenn eine Migration erfolgreich durchgeführt wurde, kann es notwendig sein, die vorgenommenen Änderungen schnell wieder rückgängig zu machen. Wenn etwa durch eine Migration ein Constraint eingeführt wurde, welcher nicht richtig mit den geschäftlichen Anforderungen oder den Annahmen in anderen Teilen des Codes übereinstimmt, können in der Folge Benutzer möglicherweise einige Datensätze nicht abspeichern. Die Wiederherstellung der Datenbank auf ein kürzlich erstelltes Backup ist eine riskante Option, da sie wahrscheinlich zum Verlust von Daten führt, die nichts mit dem eigentlichen Problem zu tun haben. Stattdessen ist eine Abwärtsmigration vorzuziehen, welche die vorgenommenen Änderungen sauber rückgängig macht.
  • Infrastruktur als Code
    Die Struktur der Datenbank – also Tabellen, Spalten, Fremdschlüssel, Constraints, usw. – ist sehr eng mit der Anwendung selbst gekoppelt. Auch wenn objektrelationales Mapping (ORM) verwendet wird und die Persistenz sauber vom Rest der Codebasis getrennt ist, erfordert eine strukturelle Änderung der Datenbank typischerweise eine zumindest kleine Änderung der Anwendung oder ihrer Konfiguration. Aus diesem Grund sollten die Migrationen Teil der Codebasis sein, damit der (gewünschte) Zustand der Datenbank zu jedem Zeitpunkt mit der Implementierung übereinstimmt.

Die Waffe der Wahl

Ein ORM wie Sequelize hat in der Regel ein Migrationsframework mit an Bord, und auch Database Abstraction Layer (DBAL) oder Query Builder wie knex haben häufig Unterstützung für Migrationen mit eingebaut. Wenn jedoch die Unterstützung mehrerer Datenbanken nicht eine tatsächliche Anforderung oder ein dediziert gewünschtes Feature der Anwendung ist, verlangsamt dies die Entwicklung für wenig greifbaren Nutzen. Der Austausch der Datenbank im laufenden Projekt kommt selten vor. Und wenn, dann ist er nie so reibungslos, wie uns die Marketingfolien der Abstraktionsschichten glauben machen wolen.

In Umgebungen, in denen die Unterstützung von oder der Wechsel zwischen Datenbanken kein unmittelbares Anliegen ist, übersetzt sich die Verwendung nicht-generischer Werkzeuge direkt zu weniger Abstraktionsebenen. Das wiederum führt zu Code, der leichter zu verstehen und einfacher zu debuggen ist. Darüber hinaus sind datenbankspezifische Funktionen, die bei der Verwendung eines DBALs in der Regel einen Rückgriff auf natives SQL erfordern, weniger umständlich zu verwenden.

In unserem Beispiel verwenden wir PostgreSQL, welches nicht nur ein sehr ausgereiftes RDBMS ist, sondern auch als managed- oder sogar serverless-Variante auf allen wichtigen Cloud-Plattformen verfügbar ist. Selbstverständlich erfüllt es alle zuvor definierten Einschränkungen und ist damit eine grundsolide Wahl für unseren Anwendung. Softwareseitig entscheiden wir uns für pg-migrate, das sich gut in unsere fiktive TypeScript-Anwendung integrieren lässt.

Einrichten von pg-migrate

Nach der Installation der Bibliothek müssen wir zunächst konfigurieren, wie sie sich mit der Datenbank verbindet. Dies kann auf verschiedene Arten erfolgen, z. B. durch das Definieren einer DATABASE_URL-Umgebungsvariable, die Verwendung von node-config oder dotenv. Auch wenn pg-migrate über eine eingebaute Unterstützung für die beiden letztgenannten verfügt, müssen diese doch explizit installiert werden. Leider beschwert sich die Bibliothek nicht, wenn das nicht passiert ist, sondern ignoriert die entsprechenden Dateien einfach stillschweigend.

Im Folgenden verwenden wir node-config und einen PostgreSQL-Docker-Container mit den Standardeinstellungen und postgres als Passwort:

config/default.json

{
    "db": {
        "url": "postgres://postgres:postgres@localhost:5432/postgres",
        "tsconfig": "./tsconfig.json",
        "migration-filename-format": "utc"
    }
}

Das Wesentliche hier ist die Datenbank-URL (natürlich würden wir in der Praxis die Anmeldedaten nicht im Klartext in einer Konfigurationsdatei speichern, aber das ist eine andere Geschichte). Glücklicherweise unterstützt pg-migrate TypeScript bereits von Haus aus, wir müssen es nur auf die richtige Konfigurationsdatei verweisen. Zum Schluss, und nur als kosmetische Änderung, möchten wir, dass den Migrationsdateien ein menschenlesbarer Datums- und Zeitstring vorangestellt wird, anstatt des standardmäßigen Unix-Zeitstempels.

Das Setup

Im Kern ist die Art und Weise, wie Migrationen funktionieren, ziemlich einfach:

  • Jede Migration ist eine Datei im Verzeichnis /migrations, die eine up()– und optional eine down()-Funktion exportiert.
  • Eine spezielle pgmigrations-Tabelle in der Datenbank (die automatisch erstellt wird) hält fest, welche Migrationen bereits angewendet wurden.
  • Das Ausführen oder Rückgängigmachen von Migrationen bedeutet einfach das sequenzielle Aufrufen der entsprechenden up()– bzw. down()-Methoden.

Hinter den Kulissen kümmert sich pg-migrate um das Finden und Laden der Migrationsdateien, erkennt, welche von ihnen (wenn überhaupt) ausgeführt werden müssen, und packt alles in eine Transaktion. Sobald das Setup abgeschlossen ist, wird das Erstellen von Migrationen zu einer sehr einfachen Aufgabe.

Unsere Anwendung benötigt eine einfache Benutzertabelle. Wir richten diese ein, indem wir unsere erste Migration erstellen:

$ ./node_modules/.bin/node-pg-migrate create user-table

Hinweis: Mit diesem Befehl wird lediglich ein Gerüst für unsere Migration erstellt, so dass wir eine neue Datei im Ordner /migrations sehen sollten. Das Argument user-table wird nur für die Generierung des Dateinamens verwendet, und wir können es noch beliebig ändern, bis die Migration tatsächlich ausgeführt wird. In der Tat hat pg-migrate zu diesem Zeitpunkt noch gar nicht mit der Datenbank gesprochen!

Nach dem Entfernen von einigem Boilerplate-Code wird unsere Migrationsdatei etwa so aussehen:

import { MigrationBuilder } from 'node-pg-migrate';

export async function up(pgm: MigrationBuilder): Promise<void> {
}

Die eingerüstete down()-Funktion wurde absichtlich entfernt, die Gründe dafür werden später noch erläutert. Wie wir sehen können, dreht sich alles um den MigrationBuilder, den uns die Bibliothek zur Verfügung stellt. Dieser kommt mit vielen Features daher, wir starten zunächst aber mit etwas sehr Grundlegendem:

export async function up(pgm: MigrationBuilder): Promise<void> {
    pgm.createTable('users', {
        id: 'id',
        email: { type: 'string', notNull: true, unique: true },
        date_of_birth: { type: 'date' },
    })
}

Dies erzeugt eine neue user-Tabelle mit drei Spalten. Der Wert id ist eine bequeme Abkürzung für das Anlegen einer Primärschlüsselspalte vom Typ serial. Wird notNull nicht explizit auf true gesetzt, ist für date_of_birth standardmäßig der Wert null erlaubt. Es ist wichtig zu verstehen, dass der Befehl die Bibliothek nur anweist, eine Tabelle zu erstellen, die entsprechende Anweisung aber noch nicht direkt ausgeführt werden (weshalb auch kein await erforderlich ist).

Nun starten wir einen Docker-Container und testen die Migration:

$ docker run --detach --env POSTGRES_PASSWORD=postgres --publish 5432:5432 postgres
$ ./node_modules/.bin/node-pg-migrate up
> Migrating files:
> - 20210309072213194_user-table
### MIGRATION 20210309072213194_user-table (UP) ###
CREATE TABLE "users" (
"id" serial PRIMARY KEY,
"email" text UNIQUE NOT NULL,
"date_of_birth" date
);
INSERT INTO "public"."pgmigrations" (name, run_on) VALUES ('20210309072213194_user-table', NOW());

Migrations complete!

Der Befehl up führt alle ausstehenden Migrationen aus, was in unserem Fall nur eine ist. Wenn wir versuchen, ihn erneut auszuführen, stellt pg-migrate fest, dass er bereits angewendet wurde und tut nichts:

$ ./node_modules/.bin/node-pg-migrate up
No migrations to run!
Migrations complete!

Hoch hinaus…

Nehmen wir an, das Business-Team beschließt nach dem Deployment in Produktion, den Benutzern die Möglichkeit zu geben, optional ihren Namen zu ihren Profilen hinzuzufügen. Für uns bedeutet das lediglich eine kleine neue Migration:

$ ./node_modules/.bin/node-pg-migrate create add-user-name

Und so implementieren wir die Änderung:

export async function up(pgm: MigrationBuilder): Promise<void> {
    pgm.addColumn('users', {
        name: { type: 'string' },
    })
}

Nach dem Anwenden der neuen Migration – genau, wir es im Schritt davor getan haben – haben wir nun eine zusätzliche name-Spalte, die wir verwenden können. Hurra!

…und wieder zurück

Beim Testen der neuen Funktion in der Staging-Umgebung stellte das Business-Team fest, dass es viel besser wäre, Vor- und Nachname getrennt zu speichern. Wie sich herausgetellt hat, ist dies nicht trivial, so dass die gesamte Funktionalität vorerst verworfen wurde. Glücklicherweise ist es kinderleicht, dies in der Datenbank wiederherzustellen:

$ ./node_modules/.bin/node-pg-migrate down
> Migrating files:
> - 20210312130143080_add-user-name
### MIGRATION 20210312130143080_add-user-name (DOWN) ###
ALTER TABLE "users"
DROP "name";
DELETE FROM "public"."pgmigrations" WHERE name='20210312130143080_add-user-name';


Migrations complete!

Ganz wichtig dabei: up führt alle anstehenden Migrationen aus, während down nur die jüngste zurücknimmt.

Aber Moment mal: Wir haben die Funktion down() doch nie implementiert!

Wie sich herausstellt, “wissen” viele Methoden wie pgm.createTable() oder pgm.addColumn(), wie ihre eigenen Änderungen rückgängig zu machen sind. Das Versetzt was pg-migrate in die Lage, bequem die Down-Migration für uns zu erzeugen. Leider fallen viele der häufiger verwendeten Funktionen, vor allem pgm.alterColumn() und pgm.alterTable(), nicht in diese Kategorie. Der Versuch, nach unten zu migrieren, schlägt fehl. In diesem Fall muss man die Funktion down() manuell implementieren:

export async function up(pgm: MigrationBuilder): Promise<void> {
    pgm.alterColumn('users', 'name', { type:'varchar(255)' })
}

export async function down(pgm: MigrationBuilder): Promise<void> {
    pgm.alterColumn('users', 'name', { type: 'text' })
}

Ein Sonderfall ist hier pgm.addConstraint(), welches die Abwärtsmigration nicht unterstützt, wenn wir uns auf den automatisch generierten Constraint-Namen verlassen. Bei explizit vergebenem Namen funktioniert dies hingegen problemlos. Es gibt auch einige Funktionen, die vom MigrationBuilder noch nicht unterstützt werden (zum Beispiel das Erteilen von Berechtigungen oder das Einfügen, Löschen oder Aktualisieren von Daten). In diesem Fall müssen wir auf native SQL-Anweisungen mit pgm.sql() zurückgreifen. Das Ausführen von beliebigem SQL bedeutet natürlich, dass es für die Bibliothek unmöglich ist, die Abwärtsmigration automatisch herzuleiten. Sie wieder daher einen Fehler werfen und uns dahingehend informieren, sollten wir sie dennoch darum bitten. Eine Randbemerkung an der Stelle: pg-migrate bietet keine Unterstützung für Escaping, daher wird die Verwendung einer zusätzlichen Bibliothek wie pg-escape empfohlen.

Manchmal ist es schlichtweg unmöglich, eine Migration rückgängig zu machen, zum Beispiel wenn eine Spalte oder eine Tabelle wegfällt und es keine Möglichkeit gibt, die Daten zu rekonstruieren. In diesem Fall können wir die Bibliothek wie folgt darüber informieren:

export const down = false;

Fazit

Setzt man im Projekt voll auf PostgreSQL und verwendet kein ORM, welches ohnehin ein Migrations-Framework bereitstellt, so ist pg-migrate eine solide, leichtgewichtige und einfach zu verwendende Bibliothek. In der Praxis gibt es einige Dinge zu beachten – zum Beispiel die Frage, wie und wann die Migrationen tatsächlich ausgeführt werden sollen. Hierfür gibt es keine allgemeingültige Empfehlung, aber eine praktische Lösung ist im konkreten Projekt mit bekannter Architektur in der Regel ziemlich einfach zu finden.


Über den Autor

Raphael Pigulla

Senior Lead IT Architect

Raphael hat mehr als fünfundzwanzig Jahre Erfahrung, vor allem im Bereich der Web-Technologien. Sein derzeitiger Schwerpunkt liegt auf sauberen und skalierbaren Architekturen in TypeScript. Er gibt seine Erfahrungen gerne als Coach, Sprecher und iSAQB-zertifizierter Ausbilder weiter. Raphael ist ein Kaffeeliebhaber, begeisterter Brettspieler und verpasst nie ein schlechtes Wortspiel.

raphael.pigulla@maibornwolff.de, GitHub: https://github.com/pigulla