다음 속성을 가진 데이터베이스 마이그레이션 도구를 갖고 싶습니다.
이러한 점 중 상당수는 tern이라는 멋진 도구를 사용한 경험에서 비롯되었습니다. 자바스크립트에는 그런 기능이 없다는 게 슬펐어요! (아니면 제가 구글링을 잘 못해서 그럴 수도 있겠네요...) 그래서 나 자신에게는 좋은 코딩 연습이 될 수 있고, 다른 사람에게는 흥미로울 수 있는 이야기가 될 수 있겠다고 결심했습니다 :)
CLI 도구를 훔쳐 디자인하자!
따라서 도구의 구문은 다음과 같습니다. martlet up --database-url
여기서 "up"은 아직 적용되지 않은 모든 마이그레이션을 적용해야 하고, down은 지정된 버전으로 롤백해야 합니다.
옵션의 의미와 기본값은 다음과 같습니다.
보시다시피 저는 실제 코드를 작성하기 전에 도구를 호출하는 방법을 알아내는 것부터 시작했습니다. 이는 요구 사항을 실현하고 개발 주기를 줄이는 데 도움이 되는 좋은 방법입니다.
알겠습니다. 우선 중요한 일부터 하세요! index.js 파일을 생성하고 도움말 메시지를 출력해 보겠습니다. 다음과 같이 보일 것입니다:
function printHelp() { console.log( "Usage: martlet up --driver <driver> --dir <dir> --database-url <url>", ); console.log( " martlet down <version> --driver <driver> --dir <dir> --database-url <url>", ); console.log( " <version> is a number that specifies the version to migrate down to", ); console.log("Options:"); console.log(' --driver <driver> Driver to use, default is "pg"'); console.log(' --dir <dir> Directory to use, default is "migrations"'); console.log( " --database-url <url> Database URL to use, default is DATABASE_URL environment variable", ); } printHelp();
이제 옵션을 분석하겠습니다.
export function parseOptions(args) { const options = { dir: "migrations", driver: "pg", databaseUrl: process.env.DATABASE_URL, }; for (let idx = 0; idx < args.length; ) { switch (args[idx]) { case "--help": case "-h": { printHelp(); process.exit(0); } case "--dir": { options.dir = args[idx + 1]; idx += 2; break; } case "--driver": { options.driver = args[idx + 1]; idx += 2; break; } case "--database-url": { options.databaseUrl = args[idx + 1]; idx += 2; break; } default: { console.error(`Unknown option: ${args[idx]}`); printHelp(); process.exit(1); } } } return options; }
보시다시피 저는 구문 분석을 위해 어떤 라이브러리도 사용하지 않습니다. 나는 단순히 인수 목록을 반복하고 모든 옵션을 처리합니다. 따라서 부울 옵션이 있으면 반복 인덱스를 1만큼 이동하고, 값이 있는 옵션이 있으면 2만큼 이동합니다.
여러 드라이버를 지원하려면 데이터베이스에 액세스할 수 있는 범용 인터페이스가 필요합니다. 그 모습은 다음과 같습니다:
interface Adapter { connect(url: string): Promise<void>; transact(query: (fn: (text) => Promise<ResultSet>)): Promise<ResultSet>; close(): Promise<void>; }
Connect와 Close는 꽤 당연한 기능인 것 같은데, 거래 방법을 설명하겠습니다. 쿼리 텍스트를 받아들이고 중간 결과가 포함된 Promise를 반환하는 함수와 함께 호출되는 함수를 받아들여야 합니다. 트랜잭션 내에서 여러 쿼리를 실행하는 기능을 제공하는 일반 인터페이스를 갖추려면 이러한 복잡성이 필요합니다. 사용법 예시를 보시면 이해가 더 쉽습니다.
어댑터가 Postgres 드라이버를 찾는 방법은 다음과 같습니다.
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(); } }
사용 예는 다음과 같습니다.
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})`); });
const PACKAGES = { pg: "postgres@3.4.4", }; 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", }); };
처음에는 Yarn을 사용하여 드라이버를 설치하려고 시도하지만 디렉터리에 차이점이 생성되는 것을 원하지 않으므로 Yarn.lock 및 package.json 파일을 보존합니다. 원사를 사용할 수 없는 경우 npm으로 돌아갑니다.
드라이버가 설치되었는지 확인한 후 어댑터를 만들어 사용할 수 있습니다.
export async function loadAdapter(driver) { await downloadDriver(driver); return import(PACKAGES[driver].split("@")[0]).then( (m) => new PGAdapter(m.default), );
데이터베이스에 연결하고 현재 버전을 가져오는 것부터 시작합니다.
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}`);
Then, we read the migrations directory and sort them by version. After that, we apply every migration that has a version greater than the current one. I will just present the actual migration in the following snippet:
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}`); });
The rollback migration is similar, but we sort the migrations in reverse order and apply them until we reach the desired version.
I decided not to use any specific testing framework but use the built-in nodejs testing capabilities. They include the test runner and the assertion package.
import { it, before, after, describe } from "node:test"; import assert from "node:assert";
And to execute tests I would run node --test --test-concurrency=1.
Actually, I was writing the code in a sort of TDD manner. I didn't validate that my migrations code worked by hand, but I was writing it along with tests. That's why I decided that end-to-end tests would be the best fit for this tool.
For such an approach, tests would need to bootstrap an empty database, apply some migrations, check that database contents are correct, and then roll back to the initial state and validate that the database is empty.
To run a database, I used the "testcontainers" library, which provides a nice wrapper around docker.
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(); });
I wrote some simple migrations and tested that they worked as expected. Here is an example of a database state validation:
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" }, ]);
This was an example of how I would approach the development of a simple CLI tool in the javascript ecosystem. I want to note that the modern javascript ecosystem is pretty charged and powerful, and I managed to implement the tool with a minimum of external dependencies. I used a postgres driver that would be downloaded on demand and testcontainers for tests. I think that approach gives developers the most flexibility and control over the application.
위 내용은 코딩 연습: nodejs의 데이터베이스 마이그레이션 도구의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!