Ich möchte ein Datenbankmigrationstool haben, das die folgenden Eigenschaften hat:
Viele dieser Punkte sind aus meiner Erfahrung mit diesem tollen Tool namens Tern entstanden. Ich war traurig, dass Javascript nicht das Gleiche hat! (Oder vielleicht ist mir das Googeln scheiße...). Also habe ich beschlossen, dass dies eine schöne Programmierübung für mich selbst und eine Geschichte sein könnte, die für jemand anderen interessant sein könnte :)
Lassen Sie uns das CLI-Tool stehlen!
Die Syntax für das Tool wäre also die folgende: martlet up --database-url
Wobei „up“ alle Migrationen anwenden sollte, die noch nicht angewendet wurden, und „down“ auf die angegebene Version zurücksetzen sollte.
Optionen haben die folgende Bedeutung und Standardeinstellungen:
Wie Sie sehen, habe ich zunächst herausgefunden, wie ich das Tool aufrufen würde, bevor ich tatsächlichen Code schreibe. Dies ist eine gute Vorgehensweise, sie hilft, Anforderungen zu realisieren und Entwicklungszyklen zu verkürzen.
Ok, das Wichtigste zuerst! Lassen Sie uns eine index.js-Datei erstellen und die Hilfemeldung ausgeben. Es würde ungefähr so aussehen:
function printHelp() { console.log( "Usage: martlet up --driver--dir --database-url ", ); console.log( " martlet down --driver --dir --database-url ", ); console.log( " is a number that specifies the version to migrate down to", ); console.log("Options:"); console.log(' --driver Driver to use, default is "pg"'); console.log(' --dir Directory to use, default is "migrations"'); console.log( " --database-url Database URL to use, default is DATABASE_URL environment variable", ); } printHelp();
Jetzt analysieren wir die Optionen:
export function parseOptions(args) { const options = { dir: "migrations", driver: "pg", databaseUrl: process.env.DATABASE_URL, }; for (let idx = 0; idxWie Sie sehen, verwende ich keine Bibliothek zum Parsen; Ich gehe einfach die Argumentliste durch und verarbeite jede Option. Wenn ich also eine boolesche Option habe, würde ich den Iterationsindex um 1 verschieben, und wenn ich eine Option mit einem Wert habe, würde ich ihn um 2 verschieben.
2.2 Implementierung des Treiberadapters
Um mehrere Treiber zu unterstützen, benötigen wir eine universelle Schnittstelle für den Zugriff auf eine Datenbank. So könnte es aussehen:
interface Adapter { connect(url: string): Promise; transact(query: (fn: (text) => Promise )): Promise ; close(): Promise ; } Ich denke, Verbinden und Schließen sind ziemlich offensichtliche Funktionen. Lassen Sie mich die Transaktionsmethode erklären. Es sollte eine Funktion akzeptieren, die mit einer Funktion aufgerufen würde, die einen Abfragetext akzeptiert und ein Versprechen mit einem Zwischenergebnis zurückgibt. Diese Komplexität ist erforderlich, um über eine allgemeine Schnittstelle zu verfügen, die die Möglichkeit bietet, mehrere Abfragen innerhalb einer Transaktion auszuführen. Es ist leichter zu verstehen, wenn man sich das Anwendungsbeispiel ansieht.
So sucht der Adapter also nach dem Postgres-Treiber:
class PGAdapter { constructor(driver) { this.driver = driver; } async connect(url) { this.sql = this.driver(url); } async transact(query) { return this.sql.begin((sql) => ( query((text) => sql.unsafe(text)) )); } async close() { await this.sql.end(); } }Und das Anwendungsbeispiel könnte sein:
import postgres from "postgres"; const adapter = new PGAdapter(postgres); await adapter.connect(url); await adapter.transact(async (sql) => { const rows = await sql("SELECT * FROM table1"); await sql(`INSERT INTO table2 (id) VALUES (${rows[0].id})`); });2.3 On-Demand-Treiberinstallation
const PACKAGES = { pg: "[email protected]", }; const downloadDriver = async (driver) => { const pkg = PACKAGES[driver]; if (!pkg) { throw new Error(`Unknown driver: ${driver}`); } try { await stat(join(process.cwd(), "yarn.lock")); const lockfile = await readFile(join(process.cwd(), "yarn.lock")); const packagejson = await readFile(join(process.cwd(), "package.json")); spawnSync("yarn", ["add", pkg], { stdio: "inherit", }); await writeFile(join(process.cwd(), "yarn.lock"), lockfile); await writeFile(join(process.cwd(), "package.json"), packagejson); return; } catch {} spawnSync("npm", ["install", "--no-save", "--legacy-peer-deps", pkg], { stdio: "inherit", }); };Wir versuchen zunächst, den Treiber mit Garn zu installieren, möchten aber keine Diffs im Verzeichnis generieren, daher behalten wir die Dateien Yarn.lock und Package.json bei. Sollte Garn nicht verfügbar sein, greifen wir auf npm zurück.
Wenn wir sichergestellt haben, dass der Treiber installiert ist, können wir einen Adapter erstellen und ihn verwenden:
export async function loadAdapter(driver) { await downloadDriver(driver); return import(PACKAGES[driver].split("@")[0]).then( (m) => new PGAdapter(m.default), );2.4 Implementierung der Migrationslogik
Wir beginnen damit, eine Verbindung zur Datenbank herzustellen und die aktuelle Version abzurufen:
await adapter.connect(options.databaseUrl); console.log("Connected to database"); const currentVersion = await adapter.transact(async (sql) => { await sql(`create table if not exists schema_migrations ( version integer primary key )`); const result = await sql(`select version from schema_migrations limit 1`); return result[0]?.version || 0; }); console.log(`Current version: ${currentVersion}`);Dann lesen wir das Migrationsverzeichnis und sortieren sie nach Version. Danach wenden wir jede Migration an, deren Version größer als die aktuelle ist. Ich werde die tatsächliche Migration nur im folgenden Snippet vorstellen:
await adapter.transact(async (sql) => { await sql(upMigration); await sql( `insert into schema_migrations (version) values (${version})` ); await sql(`delete from schema_migrations where version != ${version}`); });Die Rollback-Migration ist ähnlich, aber wir sortieren die Migrationen in umgekehrter Reihenfolge und wenden sie an, bis wir die gewünschte Version erreichen.
3. Testen
Ich habe mich entschieden, kein bestimmtes Test-Framework zu verwenden, sondern die integrierten NodeJS-Testfunktionen zu verwenden. Dazu gehören der Testläufer und das Assertion-Paket.
import { it, before, after, describe } from "node:test"; import assert from "node:assert";Und um Tests auszuführen, würde ich node --test --test-concurrency=1 ausführen.
Eigentlich habe ich den Code auf eine Art TDD-Art geschrieben. Ich habe nicht manuell überprüft, ob mein Migrationscode funktioniert, aber ich habe ihn zusammen mit Tests geschrieben. Aus diesem Grund habe ich entschieden, dass End-to-End-Tests am besten zu diesem Tool passen würden.
Für einen solchen Ansatz müssten Tests eine leere Datenbank booten, einige Migrationen durchführen, prüfen, ob die Datenbankinhalte korrekt sind, und dann zum Ausgangszustand zurückkehren und bestätigen, dass die Datenbank leer ist.
Um eine Datenbank auszuführen, habe ich die Bibliothek „testcontainers“ verwendet, die einen schönen Wrapper für Docker bietet.
before(async () => { console.log("Starting container"); container = await new GenericContainer("postgres:16-alpine") .withExposedPorts(5432) .withEnvironment({ POSTGRES_PASSWORD: "password" }) .start(); }); after(async () => { await container.stop(); });Ich habe einige einfache Migrationen geschrieben und getestet, ob sie wie erwartet funktionieren. Hier ist ein Beispiel für eine Datenbankstatusvalidierung:
const sql = pg(`postgres://postgres:password@localhost:${port}/postgres`); const result = await sql`select * from schema_migrations`; assert.deepEqual(result, [{ version: 2 }]); const tables = await sql`select table_name from information_schema.tables where table_schema = 'public'`; assert.deepEqual(tables, [ { table_name: "schema_migrations" }, { table_name: "test" }, ]);4. Fazit
Dies war ein Beispiel dafür, wie ich die Entwicklung eines einfachen CLI-Tools im Javascript-Ökosystem angehen würde. Ich möchte anmerken, dass das moderne Javascript-Ökosystem ziemlich umfangreich und leistungsstark ist und ich es geschafft habe, das Tool mit einem Minimum an externen Abhängigkeiten zu implementieren. Ich habe einen Postgres-Treiber verwendet, der bei Bedarf heruntergeladen wurde, und Testcontainer für Tests. Ich denke, dass dieser Ansatz Entwicklern die größte Flexibilität und Kontrolle über die Anwendung bietet.
5. Referenzen
Haftungsausschluss: Alle bereitgestellten Ressourcen stammen teilweise aus dem Internet. Wenn eine Verletzung Ihres Urheberrechts oder anderer Rechte und Interessen vorliegt, erläutern Sie bitte die detaillierten Gründe und legen Sie einen Nachweis des Urheberrechts oder Ihrer Rechte und Interessen vor und senden Sie ihn dann an die E-Mail-Adresse: [email protected] Wir werden die Angelegenheit so schnell wie möglich für Sie erledigen.
Copyright© 2022 湘ICP备2022001581号-3