🐙

FastAPI+SQLAlchemy+Alembicでマイグレーションするまで

2022/06/29に公開

VSCode+DockerでFastAPIの開発環境を作るまで」でFastAPIの環境構築をしたのでSQLAlchemyとAlembicを使用して、MariaDBと連携するところまで記載する。

また「FastAPI x MySQL on Docker ~ マイグレーション・API作成 ~」を一部参考にした。

成果物

https://github.com/sato-dev1234/fastapi-vscode-sample

プロジェクト構成

.
├── .devcontainer
│   └── devcontainer.json
├── app
│   ├── db
│   │   ├── migrations
│   │   │   ├── versions
│   │   │   ├── env.py
│   │   │   ├── README
│   │   │   └── script.py.mako
│   │   └── alembic.ini
│   ├── models
│   │   ├── __init__.py
│   │   ├── address.py
│   │   ├── contact.py
│   │   ├── mixins.py
│   │   └── profile.py
│   ├── database.py
│   ├── env.py
│   └── main.py
├── docker
│   ├── api
│   │   ├── Dockerfile
│   │   └── requirements.txt
│   ├── db
│   │   ├── conf.d
│   │   │   └── my.cnf
│   │   └── Dockerfile
│   └── env
│       └── .env.local
├── scripts
│   └── run.sh
├── docker-compose.local.yml
└── docker-compose.yml

MariaDBのコンテナを追加

修正

docker-compose.yml(修正)
docker-compose.yml
version: "3.0"

services:
+  db:
+    container_name: "db"
+    build:
+      context: ./docker/db
+      dockerfile: Dockerfile
+    networks:
+      - backend
  api:
    container_name: "api"
    volumes:
      - ./app:/workspace/app:cached
      - ./scripts:/workspace/scripts:cached
    build:
      context: ./docker/api
      dockerfile: Dockerfile
    working_dir: /workspace/scripts
    command: bash -c "./run.sh"
+    networks:
+      - backend
+networks:
+  default:
+    external:
+      name: bridge
+  backend:
+    name: backend
+    driver: bridge
+    external: true
scripts/run.sh(修正)
run.sh
#!/bin/bash

+echo "Waiting for mysql to start..."
+until mysql -h"$MYSQL_HOST" -u"$MYSQL_USER" -p"$MYSQL_PASSWORD" &> /dev/null
+do
+    sleep 1
+done

cd /workspace/app && uvicorn main:app --reload --port=8080 --host=0.0.0.0
  • MariaDBへの接続処理を追加。
  • dbに接続してからapiが起動するようにする。

追加

docker-compose.local.yml(追加)
docker-compose.local.yml
version: "3.0"

services:
  db:
    expose:
      - "3306"
    environment:
      APP_ENV: "local"
      TZ: "Asia/Tokyo"
    env_file:
      - ./docker/env/.env.local
  api:
    ports:
      - 8080:8080
    environment:
      APP_ENV: "local"
      TZ: "Asia/Tokyo"
    env_file:
      - ./docker/env/.env.local
  • 環境依存になる設定を分割。Docker起動時にdocker-compose.ymlとあわせて読み込む。
docker/env/.env.local(追加)
.env.local
MYSQL_DATABASE=sample
MYSQL_USER=user
MYSQL_PASSWORD=password
MYSQL_ROOT_PASSWORD=root
MYSQL_HOST=db

PYTHONPATH=/workspace/app
  • MYSQL~はMariaDBの接続設定を記載する。
  • PYTHONPATHは任意のパッケージをインポートするために設定。
db/conf.d/my.cnf(追加)
my.cnf
[mysqld]
character-set-server=utf8mb4
collation-server=utf8mb4_unicode_ci
skip-character-set-client-handshake
default-storage-engine=INNODB
explicit-defaults-for-timestamp=1

[mysqldump]
default-character-set=utf8

[mysql]
default-character-set=utf8

[client]
default-character-set=utf8
  • MariaDBの設定ファイル。
db/Dockerfile(追加)
FROM mariadb:10.8

RUN apt update \
    && apt install --no-install-recommends -y tzdata \
    && apt clean

ADD ./conf.d/ /etc/mysql/conf.d/

RUN chmod 644 /etc/mysql/conf.d/my.cnf
  • db起動時に使用するDockerfile。
  • 「Warning: World-writable config file '/etc/mysql/conf.d/my.cnf' is ignored」というエラーが発生するので、my.cnfの権限を修正する。

APIのDockerの設定

修正

docker/api/Dockerfile(修正)
FROM python:3.9-slim

+RUN apt update \
+    && apt install -y default-mysql-client \
+    && apt-get -y install gcc libmariadb-dev \
+    && apt install --no-install-recommends -y tzdata \
+    && apt clean

WORKDIR /workspace
COPY requirements.txt .

RUN pip install -U pip \
    && pip install --no-cache-dir --upgrade -r requirements.txt
docker/api/requirements.txt(修正)
requirements.txt
fastapi==0.78.0
uvicorn==0.18.1
flake8==4.0.1
black==22.3.0
+mysqlclient==2.1.1
+SQLAlchemy==1.4.39
+sqlalchemy_utils==0.38.2
+alembic==1.8.0
  • DBの接続およびマイグレーションに必要なライブラリを追加。

devcontainer.jsonの修正

.devcontainer/devcontainer.json(修正)
devcontainer.json
{
	"name": "api",
	"dockerComposeFile": [
+		"../docker-compose.yml",
+		"../docker-compose.local.yml"
	],
	"settings": {
		"python.linting.enabled": true,
		"python.linting.lintOnSave": true,
		// Pylance
		"python.languageServer": "Pylance",
		"python.analysis.completeFunctionParens": true,
		// Linter(flake8)
		"python.linting.flake8Path": "/usr/local/bin/flake8",
		"python.linting.pylintEnabled": false,
		"python.linting.flake8Enabled": true,
+		"python.linting.flake8Args": [
+			"--exclude=/workspace/app/db/migrations/versions/*"
+		],
		// Formatter(black)
		"python.formatting.blackPath": "/usr/local/bin/black",
		"python.formatting.provider": "black",
		"python.formatting.blackArgs": [
			"--line-length=79"
		],
		"[python]": {
			"editor.formatOnSave": true
		}
	},
	"extensions": [
		"ms-python.python",
		"ms-python.vscode-pylance",
		"njpwerner.autodocstring"
	],
	"service": "api",
	"workspaceFolder": "/workspace"
}
  • dockerComposeFiledocker-compose.ymlを分割したので、docker-compose.local.ymlも読み込むようにする。
  • python.linting.flake8Args:Alembicが自動生成するマイグレーションファイルにLinterを適用しない。(Alembicについては後述。)

ネットワークの作成

ネットワークを追加したので、Dockerを起動する前に以下のコマンドを実行してネットワークを作成すること(初回のみ)。

docker network create backend

SQLAlchemyの設定

/app/env.py(追加)
env.py
import os

APP_ENV = os.environ.get("APP_ENV")

DB_USER = os.environ.get("MYSQL_USER")
DB_PASSWORD = os.environ.get("MYSQL_PASSWORD")
DB_HOST = os.environ.get("MYSQL_HOST")
DB_NAME = os.environ.get("MYSQL_DATABASE")
  • Docker起動時に設定される環境変数からDBの接続設定を取得。
/app/database.py(追加)
database.py
from sqlalchemy import create_engine
from sqlalchemy.ext.declarative import declarative_base
from env import DB_USER, DB_PASSWORD, DB_HOST, DB_NAME

DATABASE = "mysql://%s:%s@%s/%s?charset=utf8" % (
    DB_USER,
    DB_PASSWORD,
    DB_HOST,
    DB_NAME,
)

engine = create_engine(DATABASE, encoding="utf-8", echo=True)

Base = declarative_base()

モデルの追加

テーブル定義

  • profile、contact、addressの3テーブルを追加する。
  • profile <--(one to one)--> contact
  • profile <--(one to many)--> address
profile
name default NULL type key comment
id NO char(32) PRI ID
first_name NO varchar(100)
last_name NO varchar(100)
created_at current_timestamp() NO timestamp 作成日時
updated_at current_timestamp() NO timestamp 更新日時
contact
name default NULL type key comment
id NO char(32) PRI ID
profile_id NO varchar(32) MUL プロフィールID
phone_number NO varchar(100) 電話番号
mail NO varchar(255) メールアドレス
created_at current_timestamp() NO timestamp 作成日時
updated_at current_timestamp() NO timestamp 更新日時
address
name default NULL type key comment
id NO char(32) PRI ID
profile_id NO varchar(32) MUL プロフィールID
prefacture NO varchar(100) 都道府県
municpality NO varchar(100) 市区町村
block_number YES varchar(100) 番地
building YES varchar(100) ビル/建物
created_at current_timestamp() NO timestamp 作成日時
updated_at current_timestamp() NO timestamp 更新日時

モデル

  • profile.py:プロフィール
  • contact.py:連絡先
  • address.py:お届け先

以下のドキュメントを参考にする。

app/models/mixins.py(追加)
mixins.py
from sqlalchemy import Column, text
from sqlalchemy.dialects.mysql import TIMESTAMP as Timestamp


class TimestampMixin(object):
    created_at = Column(
        Timestamp,
        nullable=False,
        server_default=text("current_timestamp"),
        comment="作成日時",
    )
    updated_at = Column(
        Timestamp,
        nullable=False,
        server_default=text("current_timestamp on update current_timestamp"),
        comment="更新日時",
    )
  • レコードの作成日時と更新日時を定義する。各モデルに継承する。
app/models/profile.py(追加)
profile.py
from database import Base
from sqlalchemy import Column, String
from sqlalchemy.orm import relationship, backref
from sqlalchemy_utils import UUIDType
from uuid import uuid4
from .mixins import TimestampMixin


class Profile(Base, TimestampMixin):
    __tablename__ = "profile"
    __table_args__ = {"comment": "プロフィール"}

    id = Column(
        UUIDType(binary=False), primary_key=True, default=uuid4, comment="ID"
    )
    first_name = Column(String(length=100), nullable=False, comment="姓")
    last_name = Column(String(length=100), nullable=False, comment="名")

    contact = relationship("Contact", back_populates="profile", uselist=False)
    addresses = relationship("Address", back_populates="profile")
app/models/contact.py(追加)
contact.py
from database import Base
from sqlalchemy import Column, String, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType
from uuid import uuid4
from .mixins import TimestampMixin


class Contact(Base, TimestampMixin):
    __tablename__ = "contact"
    __table_args__ = {"comment": "連絡先"}

    id = Column(
        UUIDType(binary=False), primary_key=True, default=uuid4, comment="ID"
    )
    profile_id = Column(
        UUIDType(binary=False),
        ForeignKey("profile.id"),
        nullable=False,
        comment="プロフィールID",
    )
    phone_number = Column(String(length=100), nullable=False, comment="電話番号")
    mail = Column(String(length=255), nullable=False, comment="メールアドレス")

    profile = relationship("Profile", back_populates="contact")
app/models/address.py(追加)
address.py
from database import Base
from sqlalchemy import Column, String, ForeignKey
from sqlalchemy.orm import relationship
from sqlalchemy_utils import UUIDType
from uuid import uuid4
from .mixins import TimestampMixin


class Address(Base, TimestampMixin):
    __tablename__ = "address"
    __table_args__ = {"comment": "お届け先"}

    id = Column(
        UUIDType(binary=False), primary_key=True, default=uuid4, comment="ID"
    )
    profile_id = Column(
        UUIDType(binary=False),
        ForeignKey("profile.id"),
        nullable=False,
        comment="プロフィールID",
    )
    prefacture = Column(String(length=100), nullable=False, comment="都道府県")
    municpality = Column(String(length=100), nullable=False, comment="市区町村")
    block_number = Column(String(length=100), nullable=True, comment="番地")
    building = Column(String(length=100), nullable=True, comment="ビル/建物")

    profile = relationship("Profile", back_populates="addresses")

マイグレーション

以下のドキュメントを参考にする。

テンプレート作成

VSCodeでAPIコンテナにアクセスして、alembic initを実行する。

cd /workspace/app/db/
alembic init migrations

実行すると以下のようなテンプレートが作成される。

db
├── migrations
│   ├── versions
│   ├── env.py
│   ├── README
│   └── script.py.mako
└── alembic.ini

テンプレートの修正

  • 作成したテンプレートにいくつか修正を加える。
app/db/alembic.ini(修正)
alembic.ini
# are written from script.py.mako
# output_encoding = utf-8

+# sqlalchemy.url = driver://user:pass@localhost/dbname

[post_write_hooks]
  • sqlalchemy.urlをコメントアウトする。env.pyの方で設定する。
app/db/migrations/env.py(修正)
env.py
+import os
+import sqlalchemy_utils
+import models

from logging.config import fileConfig

from sqlalchemy import engine_from_config
from sqlalchemy import pool

from alembic import context

# this is the Alembic Config object, which provides
# access to the values within the .ini file in use.
config = context.config

+# connect to database
+DB_USER = os.environ.get("MYSQL_USER")
+DB_PASSWORD = os.environ.get("MYSQL_PASSWORD")
+DB_ROOT_PASSWORD = os.environ.get("MYSQL_ROOT_PASSWORD")
+DB_HOST = os.environ.get("MYSQL_HOST")
+DB_NAME = os.environ.get("MYSQL_DATABASE")
+
+DATABASE = "mysql://%s:%s@%s/%s?charset=utf8" % (
+    DB_USER,
+    DB_PASSWORD,
+    DB_HOST,
+    DB_NAME,
+)
+config.set_main_option("sqlalchemy.url", DATABASE)

# Interpret the config file for Python logging.
# This line sets up loggers basically.
if config.config_file_name is not None:
    fileConfig(config.config_file_name)

# add your model's MetaData object here
# for 'autogenerate' support
# from myapp import mymodel
# target_metadata = mymodel.Base.metadata
+target_metadata = models.Base.metadata

# other values from the config, defined by the needs of env.py,
# can be acquired:
# my_important_option = config.get_main_option("my_important_option")
# ... etc.


+def render_item(type_, obj, autogen_context):
+    """Apply custom rendering for selected items."""
+
+    if type_ == "type" and isinstance(
+        obj, sqlalchemy_utils.types.uuid.UUIDType
+    ):
+        autogen_context.imports.add("import sqlalchemy_utils")
+        autogen_context.imports.add("import uuid")
+        return "sqlalchemy_utils.types.uuid.UUIDType(binary=False)\
+            , default=uuid.uuid4"
+
+    return False


def run_migrations_offline() -> None:
    """Run migrations in 'offline' mode.

    This configures the context with just a URL
    and not an Engine, though an Engine is acceptable
    here as well.  By skipping the Engine creation
    we don't even need a DBAPI to be available.

    Calls to context.execute() here emit the given string to the
    script output.

    """
    url = config.get_main_option("sqlalchemy.url")
    context.configure(
        url=url,
        target_metadata=target_metadata,
        literal_binds=True,
        dialect_opts={"paramstyle": "named"},
    )

    with context.begin_transaction():
        context.run_migrations()


def run_migrations_online() -> None:
    """Run migrations in 'online' mode.

    In this scenario we need to create an Engine
    and associate a connection with the context.

    """
    connectable = engine_from_config(
        config.get_section(config.config_ini_section),
        prefix="sqlalchemy.",
        poolclass=pool.NullPool,
    )

    with connectable.connect() as connection:
        context.configure(
            connection=connection,
            target_metadata=target_metadata,
+            render_item=render_item,
        )

        with context.begin_transaction():
            context.run_migrations()


if context.is_offline_mode():
    run_migrations_offline()
else:
    run_migrations_online()
  • sqlalchemy.urlを環境変数で設定した接続先につなぐようにする。
  • target_metadata:/app/database.pyで設定したBaseのメタデータを設定する。
  • render_item:UUIDを使用したカラムを反映する関数。
  • connectable.connect:render_itemを追加する。

マイグレーション(初回)

以下の手順が基本になる。

  1. モデルの変更
  2. cd /workspace/app/db/
  3. alembic revision --autogenerate -m '変更内容'
  4. alembic upgrade head

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

alembic revision --autogenerate -m 'create profile, contact, address'

  • モデルの内容を検知してマイグレーションファイルを自動生成する。
  • -m 'create profile, contact, address'は任意。変更内容を記載する。

マイグレーションファイル実行

alembic upgrade head

  • 最新化。

ロールバックした場合は以下のコマンドを使用する。

alembic downgrade -1

  • -Nを指定した場合現在のバージョンからN回ロールバックする。

テーブルを確認

  1. 左パネルのRemote Explorerを選択する。
  2. workspaceを右クリックする。
  3. Attach to Containerを選択する。
  4. dbのコンテナに入れるのでDBにログインしてテーブルができているかを確認する。

Remote Explorer
Remote Explorer

Database Clientで確認した場合
Database Clientで確認した場合

備考

その他Alembicで用意されているコマンドを紹介する。

アップグレード

alembic upgrade 047eb326649f

  • 自動生成されたマイグレーションファイルのRevision IDを指定すると特定のファイルのみ実行。

alembic upgrade +2

  • +Nを指定した場合現在のバージョンからN回進める。

alembic upgrade 047eb326649f+2

  • 複合型。047eb326649fから2回進める。

参照

alembic current

  • 現在のバージョンを取得する。

alembic history --verbose

  • マイグレーションの実行履歴を出力。
  • verboseを指定すると詳細が出力される。
    • Revision ID:リビジョンID
    • Revises:改訂元のリビジョンID
    • Create Date:作成日

alembic history -r1975ea:ae1027

  • リビジョンIDを使用した範囲指定の履歴を出力。-r[start]:[end]の形式でリビジョンIDを指定する。

alembic history -r1975ea:

  • 特定のバージョンからheadまでの履歴を出力。[end]を省略して-r[start]:の形式でリビジョンIDを指定する。

alembic history -r-3:current or alembic history --rev-range=-3:current

  • 現在のバージョンを使用した範囲指定の履歴を出力。-r[-N]:currentの形式で何回遡るかを指定する。

ダウングレード(ロールバック)

alembic downgrade base

  • 初期化

Discussion