AlembicとasyncpgでDBスキーマ管理

Posted by rhoboro on 2021-06-26

先日書いたFastAPI+SQLAlchemyで非同期WebAPIで紹介したrhoboro/async-fastapi-sqlalchemyをより本格的に使えるようにAlembicを導入しました。 変更内容はPR:DBスキーマの管理をAlembicで行うにありますが、行ったことをざっとまとめておきます。

Alembicの導入

インストールはpip installで入るので特筆することはありません。

(venv) $ pip install alembic

インストールできたら、initコマンドを実行して設定ファイルなどを作成します。 今回はasyncpgを使いたいので--template asyncオプションをつけて実行しました。 最後の引数は各種スクリプトの格納先です。 デフォルトはalembic/ですが、わたしはいつもmigrations/で管理しています。

(venv) $ alembic init --template async migrations

initコマンドを実行するとalembic.iniと下記のmigrations/が作成されます。(このコミット

Alembicでは全体的な設定をalembic.iniで管理し、細かな挙動はenv.pyでカスタマイズします。 マイグレーションファイルはversions/配下に格納していき、script.py.makoがそれらのマイグレーションファイルのテンプレートになっています。

(venv) $ ls alembic.ini
alembic.ini
(venv) $ tree migrations
migrations
├── README
├── env.py
├── script.py.mako
└── versions

1 directory, 3 files

設定ファイルの更新

設定ファイルが作成できたら、今回のプロジェクト内容に沿って更新します。(このコミットこのコミット

alembic.ini

ローカルでの動作確認やマイグレーションファイルの生成にはDockerのpostgresイメージで立てたコンテナを使っているので、sqlalchemy.urlの値をそのDockerコンテナ用の接続情報にしています。
ただし、この値はAlembicをオフラインモードで実行したときのみ利用されます。 実際にはローカルのコンテナとアプリケーション本体で使うengineオブジェクトを使ってオンラインモードを利用するため、わたしはオフラインモードはほとんど利用していません。

そのほか、マイグレーションファイルの生成時に自動でblackによるフォーマットが実行されるようにしています。

migrations/script.py.mako

こちらの変更はわたしがいつも行っているものです。
Alambicはマイグレーションファイルを自動生成できますが、「# ### commands auto generated by Alembic - please adjust! ###」というコメントがあるように、その処理内容は最終的には開発者に委ねられています。 チーム開発では複数人がマイグレーションファイルを生成すると思いますが、その際に少しでも統一感を保てるようにマイグレーションの前後で行う処理を書くpre_upgrade(), post_upgrade(), pre_downgrade(), post_downgrade()を追加しています。 これらは、マイグレーション前にデータ整合性を整えたり、マイグレーション後に初期データを投入する際などに利用しています。

migrations/env.py

env.pyには複数の変更がはいっているので、それぞれまとめました。

まず、target_metadataにはSQLAlchemy ORMのmetadataオブジェクトを渡します。 このmetadataからORMの各モデルの情報、すなわち各テーブルのスキーマ情報が得られるため、マイグレーションファイルの自動生成が可能になります。

include_object()では、自動生成の対象外としたいモデルクラスがある場合にモデル側で__table_args__ = {"info": {"skip_autogen": True}}と宣言するだけで対象外にできるようにしています。 これは、VIEWなどの参照専用で使いたいモデルクラスを定義する際に利用しています。

compare_typeはカラムの型変更を自動で検知するためのフラグです。 compare_type=Falseの場合、マイグレーションファイルの自動生成時にカラムの変更が検知されないので注意が必要です。

Naming Convention

これは制約やインデックスにつける名前の設定です。 実際の設定内容はこちらにあります。

これ自体は設定ファイルの更新ではないのですが、デフォルトではSQLAlchemyもAlembicも制約に名前をつけないため、ダウングレードなどがうまく動かないことがあります。 そのため、新規アプリケーションなどでは最初から設定しておくのがオススメです。 詳細はConfiguring a Naming Convention for a MetaData Collectionにあります。

マイグレーションファイルの生成

ここまで設定できたら、alembic revisionコマンドでマイグレーションファイルを生成できます。 初回の実行時のみオフラインモードで空のマイグレーションファイルを生成しておくと、そのリビジョンを指定してダウングレードするだけでDBを初期化できて便利です。

なお、ここでは環境変数APP_CONFIG_FILEでDB接続情報などの設定値を切り替えています。 マイグレーションファイルの生成はローカルで行い、開発環境や本番環境のDBにマイグレーションを適用する場合には、APP_CONFIG_FILE=devやAPP_CONFIG_FILE=prodを指定しています。

# 以降では、このコマンドで起動したpostgresコンテナを使っています
# (venv) $ docker run -d --name db \
#   -e POSTGRES_PASSWORD=password \
#   -e PGDATA=/var/lib/postgresql/data/pgdata \
#   -v $(pwd)/pgdata:/var/lib/postgresql/data \
#   -p 5432:5432 \
#   postgres:13.3

# DB接続なしで空のマイグレーションファイルを用意
(venv) $ APP_CONFIG_FILE=local alembic revision -m 'initial_empty'
 Generating /Users/rhoboro/go/src/github.com/rhoboro/async-fastapi-sqlalchemy/migrations/versions/a8483365f505_initial_empty.py ... done
 Running post write hook "black" ...
reformatted /Users/rhoboro/go/src/github.com/rhoboro/async-fastapi-sqlalchemy/migrations/versions/a8483365f505_initial_empty.py
All done!  🍰 1 file reformatted.
 done

空のマイグレーションファイルを作成できたら、それをDBに適用します。 マイグレーションファイルがある場合、DBの状態を最新にするまで次のマイグレーションファイルの自動生成はできません。

# 最新状態までのマイグレーション適用
(venv) $ APP_CONFIG_FILE=local alembic upgrade head
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> a8483365f505, initial_empty

続いて、モデルクラスからマイグレーションファイルを生成していきます。 alembic revisionコマンドに--autogenerateオプションをつけることでオンラインモードとなり、DBの状態とモデルクラスとの差分からマイグレーションファイルを自動生成してくれます。

# マイグレーションの自動生成
(venv) $ APP_CONFIG_FILE=local alembic revision --autogenerate -m 'add_tables'
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.autogenerate.compare] Detected added table 'notebooks'
INFO [alembic.autogenerate.compare] Detected added table 'notes'
 Generating /Users/rhoboro/go/src/github.com/rhoboro/async-fastapi-sqlalchemy/migrations/versions/24104b6e1e0c_add_tables.py ... done
 Running post write hook "black" ...
reformatted /Users/rhoboro/go/src/github.com/rhoboro/async-fastapi-sqlalchemy/migrations/versions/24104b6e1e0c_add_tables.py
All done!  🍰 1 file reformatted.
 done

# 最新状態までのマイグレーション適用
(venv) $ APP_CONFIG_FILE=local alembic upgrade head
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade a8483365f505 -> 24104b6e1e0c, add_tables

ここまでエラーなく進むと、テーブルが作成された状態になっています。 コンテナであればエラーが出ても何度でも作って壊して試せるので便利ですね。

(venv) $ psql -h localhost -U postgres postgres
Password for user postgres:
psql (13.2, server 13.3 (Debian 13.3-1.pgdg100+1))
Type "help" for help.

postgres=# \d
        List of relations
 Schema |    Name    |  Type  | Owner
--------+------------------+----------+----------
 public | alembic_version | table  | postgres
 public | notebooks    | table  | postgres
 public | notebooks_id_seq | sequence | postgres
 public | notes      | table  | postgres
 public | notes_id_seq   | sequence | postgres
(5 rows)