코딩 연습: nodejs의 데이터베이스 마이그레이션 도구

DDD
풀어 주다: 2024-09-25 20:18:22
원래의
536명이 탐색했습니다.

Coding exercise: database migration tool in nodejs

요구사항

다음 속성을 가진 데이터베이스 마이그레이션 도구를 갖고 싶습니다.

  1. 모든 마이그레이션은 "위" 부분과 "아래" 부분을 모두 의미하는 단일 SQL 파일로 작성됩니다. 이렇게 하면 Copilot이 롤백 마이그레이션을 완료할 수 있습니다. 또한 베어 SQL이라는 사실은 가장 유연하고 지원되는 솔루션이기도 합니다.
  2. 현재 적용된 버전은 도구로 관리해야 합니다. 도구가 자급자족했으면 좋겠습니다.
  3. 이 도구가 Postgres, MySQL, SQL Server 등과 같은 다양한 데이터베이스를 지원하길 원하므로 그런 의미에서 확장이 가능해야 합니다.
  4. 크기가 너무 커지는 것을 원하지 않으므로 필요에 따라 필요한 데이터베이스용 드라이버만 설치하는 것이 가장 좋습니다.
  5. 내가 작업하는 대부분의 프로젝트가 자바스크립트 생태계의 일부이기 때문에 자바스크립트 생태계의 일부가 되기를 바랍니다.
  6. 모든 마이그레이션은 트랜잭션 내부에서 수행되어야 합니다.

소개

이러한 점 중 상당수는 tern이라는 멋진 도구를 사용한 경험에서 비롯되었습니다. 자바스크립트에는 그런 기능이 없다는 게 슬펐어요! (아니면 제가 구글링을 잘 못해서 그럴 수도 있겠네요...) 그래서 나 자신에게는 좋은 코딩 연습이 될 수 있고, 다른 사람에게는 흥미로울 수 있는 이야기가 될 수 있겠다고 결심했습니다 :)

개발

1부. 도구 설계

CLI 도구를 훔쳐 디자인하자!

  1. 모든 마이그레이션의 이름 지정 체계는 _.sql입니다. 여기서 숫자는 마이그레이션 버전 번호를 나타냅니다(예: 001_initial_setup.sql).
  2. 모든 마이그레이션은 단일 디렉토리에 위치합니다.
  3. 데이터베이스 드라이버는 사전 번들 패키지 또는 일종의 npm install 를 실행하여 요청 시 다운로드됩니다.

따라서 도구의 구문은 다음과 같습니다. martlet up --database-url --드라이버 <드라이버> --dir

또는 martlet down .

여기서 "up"은 아직 적용되지 않은 모든 마이그레이션을 적용해야 하고, down은 지정된 버전으로 롤백해야 합니다.
옵션의 의미와 기본값은 다음과 같습니다.

  • database-url - 데이터베이스에 대한 연결 문자열, 기본값은 환경 변수 DATABASE_URL을 조회하는 것입니다.
  • driver - 사용할 데이터베이스 드라이버입니다. 첫 번째 버전에서는 "pg"라는 옵션이 있는 Postgres만 지원합니다.
  • dir - 마이그레이션이 있는 디렉터리, 기본값은 마이그레이션입니다.

보시다시피 저는 실제 코드를 작성하기 전에 도구를 호출하는 방법을 알아내는 것부터 시작했습니다. 이는 요구 사항을 실현하고 개발 주기를 줄이는 데 도움이 되는 좋은 방법입니다.

2부. 구현

2.1 파싱 옵션

알겠습니다. 우선 중요한 일부터 하세요! 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만큼 이동합니다.

2.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})`);
});
로그인 후 복사

2.3 주문형 드라이버 설치

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),
  );
로그인 후 복사

2.4 마이그레이션 로직 구현

데이터베이스에 연결하고 현재 버전을 가져오는 것부터 시작합니다.

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.

3. Testing

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" },
]);
로그인 후 복사

4. Conclusion

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.

5. References

  • martlet repo
  • tern
  • postgres driver

위 내용은 코딩 연습: nodejs의 데이터베이스 마이그레이션 도구의 상세 내용입니다. 자세한 내용은 PHP 중국어 웹사이트의 기타 관련 기사를 참조하세요!

원천:dev.to
본 웹사이트의 성명
본 글의 내용은 네티즌들의 자발적인 기여로 작성되었으며, 저작권은 원저작자에게 있습니다. 본 사이트는 이에 상응하는 법적 책임을 지지 않습니다. 표절이나 침해가 의심되는 콘텐츠를 발견한 경우 admin@php.cn으로 문의하세요.
인기 튜토리얼
더>
최신 다운로드
더>
웹 효과
웹사이트 소스 코드
웹사이트 자료
프론트엔드 템플릿