K Squad

宣言的DBテーブル定義のススメ


Web開発プロジェクトでのDBマイグレーションの運用面の課題

代表のkomiです。

Webアプリを開発するとき、一般的にWebフレームワークを使うことが多いと思います。 RubyならRuby on Rails, JavaならSpring Boot、PythonではFastAPIやDjangoなど。

ビジネスがいざ動き始めてプロジェクトが走り始めたとき、ビジネスサイドの状況次第でプロジェクトの要件は頻繁に変わります。 例えばユーザーに管理画面でもっと情報見せたいからフロントエンドに渡すJSONのフィールドを増やすなどが起こり得ます。

そうした要件の変更の中で、テーブルを新しく生やしたり既存テーブルに新しくカラムを追加するなどDBに変更を入れることも頻繁に起きます。

DBに変更を入れる際、マイグレーションを行う必要がありますね。

Django等でマイグレーションをする際、モデルのコードとDBの状態を見て差分を検知した上でマイグレーション用にSQLを生成します。 プロジェクトが長期化してマイグレーションを複数行っていくと、マイグレーションの際に生成したファイルがたくさん増えていきます。

K Squadでは様々なお客さまへ技術顧問を行っているのですが、先日Djangoプロジェクトにおいて複数のメンバーが同じテーブルに対して異なる変更をするケースが発生し、マイグレーションファイルがコンフリクトしてマイグレーションが失敗するという事件が発生していました。

なぜそんなことが起きていたかというと、マイグレーションファイルが増えるのを嫌がって過去のマイグレーションファイルをgitignoreしていたというのが原因でした。

こうした事件を避けるべく、過去のマイグレーションファイルをGitで管理し、同時にIssue分担を上手にやることによってカバーすることができます。

というように基本的にマイグレーションでの事故は運用で努力すれば問題ないのですが、ところで過去のマイグレーションファイルが増えることが嫌だと感じる人は一定数いるのではないでしょうか?

そこで今回このような手続的なマイグレーション手法ではなく宣言的なマイグレーション手法を提案しようと思います。

宣言的マイグレーション

手続的マイグレーションと宣言的マイグレーションは記述された通りにDBの定義を更新するという観点で、本質的には同じです。

しかし手続的なアプローチでは現在のコードと現在のテーブルの状態に差分があるかに着目するのに対し、宣言的なアプローチでは現在のコードの通りになっているかに着目するという観点で異なります。

具体例を出すと、

CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  name TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

の通りにマイグレーションをしたあとスキーマの状態を

CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  name TEXT,
  prefecture TEXT NOT NULL,  -- <-- これが追加された
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

と変更し(prefectureカラムを加えた)、その後

CREATE TABLE users (
  id BIGSERIAL PRIMARY KEY,
  name TEXT,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

CREATE TABLE user_addresses (
  id BIGSERIAL PRIMARY KEY,
  user_id BIGINT PRIMARY KEY REFERENCES users (id) ON DELETE CASCADE,
  prefecture TEXT NOT NULL,
  created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
  updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);

としたとします(prefectureカラムをusersテーブルに生やさず別のテーブルに切り出した)。

このとき最初と最後の状態を比較すると結果的にuser_addressesテーブルが生えただけなのですが、手続的なアプローチだと

  • ALTER TABLE users ADD prefecture .... としてprefectureカラムを生やす
  • ALTER TABLE users DROP COLUMN prefecture ....とテーブルからprefectureカラムを削除したあとCREATE TABLE user_addresses ....とテーブル作成DDL

というように無駄な過去のマイグレーションファイルが生成されます。

宣言的マイグレーションでは、現在のコードの状態と現在のDBの状態を比較して内部的に変更を検知した上でマイグレーションを行うので無駄なマイグレーションファイルが生成されることはありません。

宣言的マイグレーションを行うツール

有名どころだとatlassqldefがあります。

それぞれSQLファイルにてテーブル定義を記述することができますが、atlasはHCLでも記述することができ、同時にエラーメッセージがわかりやすいところがポイントです。

一方でsqldefはSQL Serverに対応しているところがポイントで(ZOZOのエンジニアの方が対応してくれたようです)、開発者がk0kubunさんという日本人の方なので困ったらTwitterでふらっと相談できるというのが良さだと思います。

これらのツールを利用してマイグレーションおよびスキーマ管理を行います。

モデルのコードへ出力

Webフレームワークを利用しているとモデル(DBに保存されているデータの部分)のコードを記述しなくてはいけません。

しかしスキーマをSQLで管理していて、ここでまたPython等でモデルのコードを書くのはDBの定義を2回しているようなもので無駄だし楽しい仕事ではありません。

つまりモデルのコードを自動生成したいですよね?

Djangoのプロジェクトの場合、inspectdbコマンドというのが用意されており、DBをスキャンしてモデルのコードを自動生成してくれます。

また、PythonでORMとしてSQLAlchemyを利用している場合はsqlacodegenというCLIツールが提供されており、FastAPI等ではこちらを使うのが良いでしょう。

GoではGensqlcいったモデルジェネレータが存在します。

Rubyについては筆者は詳しくないので良いツールがあれば教えてください。

まとめ

今回は宣言的マイグレーションを紹介させていただきました。

マイグレーション自体についてはatlasといった外部ツールを利用するのでアプリケーションコード生成などやることは多少増えます。

一方でテーブルの状態を宣言的に管理することによってDBの状態の変更履歴をGitの履歴と同一化することができ、DBの管理をアプリケーションコードと同様の形式にすることができます。 つまりロールバックするときはgit revertすれば良いのです。

もし手続的マイグレーションにツラさを感じた場合は宣言的マイグレーションを試してみてください。