FastAPI+SQLAlchemy+Alembicでマイグレーションするまで
「VSCode+DockerでFastAPIの開発環境を作るまで」でFastAPIの環境構築をしたのでSQLAlchemyとAlembicを使用して、MariaDBと連携するところまで記載する。
また「FastAPI x MySQL on Docker ~ マイグレーション・API作成 ~」を一部参考にした。
成果物
プロジェクト構成
.
├── .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(修正)
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
- MariaDBのコンテナ「db」を追加。
- dbとapiをつなぐためにbackendという名前のネットワークを作成。
scripts/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(追加)
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(追加)
MYSQL_DATABASE=sample
MYSQL_USER=user
MYSQL_PASSWORD=password
MYSQL_ROOT_PASSWORD=root
MYSQL_HOST=db
PYTHONPATH=/workspace/app
- MYSQL~はMariaDBの接続設定を記載する。
- PYTHONPATHは任意のパッケージをインポートするために設定。
-
docker-compose.local.yml
から読み込まれる。
db/conf.d/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
-
apt-get -y install gcc libmariadb-dev
:mysqlclientインストール時にエラーが発生するため追加。
docker/api/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(修正)
{
"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"
}
-
dockerComposeFile
:docker-compose.yml
を分割したので、docker-compose.local.yml
も読み込むようにする。 -
python.linting.flake8Args
:Alembicが自動生成するマイグレーションファイルにLinterを適用しない。(Alembicについては後述。)
ネットワークの作成
ネットワークを追加したので、Dockerを起動する前に以下のコマンドを実行してネットワークを作成すること(初回のみ)。
docker network create backend
SQLAlchemyの設定
/app/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(追加)
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()
- SQLAlchemyでつなぐDBの設定を行う。ここで定義された
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) | 電話番号 | ||
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
:お届け先
以下のドキュメントを参考にする。
- データタイプ:Column and Data Types
- リレーションシップ:Basic Relationship Patterns
- UUID:UUIDType
app/models/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(追加)
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(追加)
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(追加)
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(修正)
# 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(修正)
+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
を追加する。
マイグレーション(初回)
以下の手順が基本になる。
- モデルの変更
cd /workspace/app/db/
alembic revision --autogenerate -m '変更内容'
alembic upgrade head
マイグレーションファイル作成
alembic revision --autogenerate -m 'create profile, contact, address'
- モデルの内容を検知してマイグレーションファイルを自動生成する。
-
-m 'create profile, contact, address'
は任意。変更内容を記載する。
マイグレーションファイル実行
alembic upgrade head
- 最新化。
ロールバックした場合は以下のコマンドを使用する。
alembic downgrade -1
- -Nを指定した場合現在のバージョンからN回ロールバックする。
テーブルを確認
- 左パネルのRemote Explorerを選択する。
- workspaceを右クリックする。
- Attach to Containerを選択する。
- dbのコンテナに入れるのでDBにログインしてテーブルができているかを確認する。
Remote Explorer
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