FastAPI を使ってWEBアプリを作ってみる その2
前回はDockerを使用してFastAPIコンテナを立ち上げるところまで作成し、HelloWorldっぽいことを実践しました。
API構築とデータベースは切ってもきれない関係です、Django REST framework ではDjangoに内包されているORMを使用することで settings.py
に接続情報を記載するだけで簡単に接続することができました。
FastAPIのドキュメントではチュートリアルに SQLAlchemy を使用しています。
今回の投稿では我々も例に倣いSQLAlchemyを使用してPostgreSQLに接続し、alembic を使用してマイグレートしテーブルを作成してみましょう。
PostgreSQL コンテナを作成する
前回作成した docker-compose.yml
ファイルを追記していきます。
version: '3.7'
services:
server:
build:
context: ./backend
dockerfile: Dockerfile
volumes:
- ./backend/:/backend/
- /var/run/docker.sock:/var/run/docker.sock
command: uvicorn app.api.server:app --reload --workers 1 --host 0.0.0.0 --port 8000
env_file:
- ./backend/.env
ports:
- 8000:8000
depends_on:
- db
db:
image: postgres:13.1-alpine
volumes:
- postgres_data:/var/lib/postgresql/data/
env_file:
- ./backend/.env
ports:
- 5432:5432
volumes:
postgres_data:
dbという新しいサービスを追加し postgres_data
ボリュームを追加しました。
続いて、この PostgreSQL コンテナに接続するためのパッケージを requirements.txt
に加えます。
fastapi==0.63.0
uvicorn==0.13.3
databases[postgresql]==0.4.1 # 追加
SQLAlchemy==1.3.22 # 追加
alembic==1.5.2 # 追加
psycopg2==2.8.6 # 追加
- databases 多くの一般的なデータベースへの非同期インターフェースを提供するパッケージ
- SQLAlchemy 今回は生のSQLでクエリを発行するのでテーブル管理に使用します
- alembic SQLAlchemyと併せて使用するマイグレートパッケージ
- psycopg2 クエリの実行に使用します。
Dockerfile を psycopg2 のビルドに必要な依存関係をインストールするように変更します。
FROM python:3.9-alpine
WORKDIR /backend
ENV PYTHONDONTWRITEBYTECODE 1
ENV PYTHONBUFFERED 1
COPY ./requirements.txt /backend/requirements.txt
RUN apk add --no-cache postgresql-libs \
&& apk add --no-cache --virtual .build-deps gcc musl-dev postgresql-dev \
&& python3 -m pip install -r /backend/requirements.txt --no-cache-dir \
&& apk --purge del .build-deps
COPY . /backend
早速追加した依存関係を反映させるためコンテナを再作成します。
docker-compose up --build
コンテナのビルドが完了するとパスワードが設定されていないと警告が表示され、postgresコンテナが終了してしまいます。
これは後ほど修正するのでまずは先にFastAPI側でDB接続する準備を進めます。
.envファイルの設定
.envファイルは機密情報に該当するパラメータを記載しアプリケーションに引き渡すために使用します。
よってこのファイルはGitの管理対象から除外する必要があります。
.env
注意: サンプルのために私のGitHubリポジトリでは .env を除外設定していません
続いて .envファイルに構成情報を記載しましょう。
SECRET_KEY=IT_MUST_BE_CHANGED
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_SERVER=db
POSTGRES_PORT=5432
POSTGRES_DB=postgres
この .envファイルの構成情報を使用してアプリケーションをpostgresコンテナに接続させるため、config.py ファイルを作成し読み込ませてあげる必要があります。
$ touch backend/app/core/config.py
from databases import DatabaseURL
from starlette.config import Config
from starlette.datastructures import Secret
config = Config(".env")
PROJECT_NAME = "Hedgehog Reservation"
VERSION = "1.0.0"
API_PREFIX = "/api"
SECRET_KEY = config("SECRET_KEY", cast=Secret, default="CHANGEME")
POSTGRES_USER = config("POSTGRES_USER", cast=str)
POSTGRES_PASSWORD = config("POSTGRES_PASSWORD", cast=Secret)
POSTGRES_SERVER = config("POSTGRES_SERVER", cast=str, default="db")
POSTGRES_PORT = config("POSTGRES_PORT", cast=str, default="5432")
POSTGRES_DB = config("POSTGRES_DB", cast=str)
DATABASE_URL = config(
"DATABASE_URL",
cast=DatabaseURL,
default=f"postgresql://{POSTGRES_USER}:{POSTGRES_PASSWORD}@{POSTGRES_SERVER}:{POSTGRES_PORT}/{POSTGRES_DB}"
)
アプリケーションをpostgresに接続するためのデータベース接続文字列を組み立てました。
ついでに server.py で直接定義していたプロジェクト名やバージョン、APIのルートになるPrefixの定義を移します。
config = Config(".env")
そしてこの部分では FastAPI のベースになっている Starlette によって Config
ファイルを指定するオブジェクトを提供しています。
ここで注意点があり、configオブジェクトの引数に default を渡さない場合、 .envファイルにも値が存在しない時は例外が発生してしまいます。
また、cast で渡すデータ型に一致しない際もエラーが投げられます。
FastAPI を postgres に接続する
いよいよ実際に接続させていきます。
手始めにデータベース関連のファイルを格納するディレクトリを用意し、その中に tasks.py ファイルを作成しましょう。
$ mkdir backend/app/db
$ touch backend/app/db/__init__.py backend/app/db/tasks.py
このtasks.pyファイルではデータベースとの接続を確立してもらいます。
コードを書く前に、core ディレクトリにも同様に tasks.py ファイルを作成しアプリケーションの起動イベントとシャットダウンイベントをラップしておきましょう。
$ touch backend/app/core/tasks.py
from typing import Callable
from fastapi import FastAPI
from app.db.tasks import connect_to_db, close_db_connection
def create_start_app_handler(app: FastAPI) -> Callable:
async def start_app() -> None:
await connect_to_db(app)
return start_app
def create_stop_app_handler(app: FastAPI) -> Callable:
async def stop_app() -> None:
await close_db_connection(app)
return stop_app
開始・停止時でそれぞれのハンドラを定義しました。
これはデータベース接続の作成とシャットダウンを担当する非同期関数を返します。
続いてここにインポートしてくる connect_to_db, close_db_connection を先に作成していたもう一つの tasks.py に定義します。
from fastapi import FastAPI
from databases import Database
from app.core.config import DATABASE_URL
import logging
logger = logging.getLogger(__name__)
async def connect_to_db(app: FastAPI) -> None:
database = Database(DATABASE_URL, min_size=2, max_size=5)
try:
await database.connect()
app.state._db = database
except Exception as e:
logger.warn("--- DATABASE CONNECTION ERROR ---")
logger.warn(e)
logger.warn("--- DATABASE CONNECTION ERROR ---")
async def close_db_connection(app: FastAPI) -> None:
try:
await app.state._db.disconnect()
except Exception as e:
logger.warn("--- DATABASE DISCONNECT ERROR ---")
logger.warn(e)
logger.warn("--- DATABASE DISCONNECT ERROR ---")
connect_to_db
関数では、databasesパッケージを使用し core/config.py
ファイルで構成したDATABASE_URLを使用しPostgreSQLへの接続を確立しています。
これは async/await によって非同期に行われ、接続が正常に完了するまで待機したのちに FastAPI オブジェクトである app の state に _db
としてアタッチしています。
そして close_db_connection
関数では、アプリがシャットダウンした際にデータベースから切断します。
それでは最後に作成したイベントハンドラを server.py ファイルに登録します。
先ほど core/config.py に移したプロジェクト名なども同時に差し替えておきましょう。
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from app.core import config, tasks # 追加
from app.api.routes import router as api_router
def get_application():
app = FastAPI(title=config.PROJECT_NAME, version=config.VERSION) # 変更
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_credentials=True,
allow_methods=["*"],
allow_headers=["*"],
)
app.add_event_handler("startup", tasks.create_start_app_handler(app)) # 追加
app.add_event_handler("shutdown", tasks.create_stop_app_handler(app)) # 追加
app.include_router(api_router, prefix="/api")
return app
app = get_application()
改めてコンテナを立ち上げ直しましょう。
$ docker-compose down
$ docker-compose up -d --build
$ docker ps
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
698ba1bfec5d server "uvicorn app.api.ser…" 12 seconds ago Up 11 seconds 0.0.0.0:8000->8000/tcp server_1
280bd3cde944 postgres:13.1-alpine "docker-entrypoint.s…" 12 seconds ago Up 11 seconds 0.0.0.0:5432->5432/tcp db_1
今度は無事に2つのコンテナが立ち上がったことが確認できました。
データベースのマイグレート
Djangoでは統合されたORMによって makemigrations と migrate の2つのコマンドによって簡単にデータベースをマイグレートすることができました。
マイクロフレームワークであるFastAPIではこのマイグレートに関しても最初に自分で設定していく必要があります。
一般的にDBマイグレートに使用する定義ファイルはDBスキーマの変更をトラッキングする重要なドキュメントとして活用できるため、分割して作成するケースが実務では多いですがここでは単一のファイルで作成し後の投稿で分割してくことにします。
最初にマイグレートファイルを取り扱うディレクトリとスキーマを扱うディレクトリを作成し、マイグレートに使用する alembic 固有の設定ファイルを配置しましょう。
$ mkdir backend/app/db/migrations backend/app/db/repositories
$ touch backend/app/db/migrations/script.py.mako backend/app/db/migrations/env.py
$ touch backend/app/db/repositories/__init__.py backend/app/db/repositories/base.py
script.py.mako
このファイルはテンプレートエンジンである Mako を使用しています。
新規のマイグレートファイルを作成するときはこの .mako ファイルをベースに py ファイルが作成されることになります。
env.py
このファイルは alembic が実行される時に毎回呼び出されるスクリプトとして機能します。
次に script.py.mako
によって作成される pyファイルを配置するディレクトリが必要なので作成しておきます。
$ mkdir backend/app/db/migrations/versions
下準備はこれでOKです。
alembic の設定ファイルを用意しましょう。
$ touch backend/alembic.ini
# A generic, single database configuration.
[alembic]
# path to migration scripts
script_location = ./app/db/migrations
# version location specification; this defaults
# to alembic/versions. When using multiple version
# directories, initial revisions must be specified with --version-path
version_locations = ./app/db/migrations/versions
[loggers]
keys = root,sqlalchemy,alembic
[handlers]
keys = console
[formatters]
keys = generic
[logger_root]
level = WARN
handlers = console
qualname =
[logger_sqlalchemy]
level = WARN
handlers =
qualname = sqlalchemy.engine
[logger_alembic]
level = INFO
handlers =
qualname = alembic
[handler_console]
class = StreamHandler
args = (sys.stderr,)
level = NOTSET
formatter = generic
[formatter_generic]
format = %(levelname)-5.5s [%(name)s] %(message)s
datefmt = %H:%M:%S
設定値の殆どは 公式ドキュメント に記載されているテンプレートをそのまま使用していますが次の2点を変更しています。script_location
ここで script.py.mako
ファイルの配置先を指定しています。version_locations
ここで指定したディレクトリにマイグレートファイルが出力されます。
続いてキモとなる script.py.mako
と env.py
を編集します。
"""${message}
Revision ID: ${up_revision}
Revises: ${down_revision | comma,n}
Create Date: ${create_date}
"""
from alembic import op
import sqlalchemy as sa
${imports if imports else ""}
# revision identifiers, used by Alembic
revision = ${repr(up_revision)}
down_revision = ${repr(down_revision)}
branch_labels = ${repr(branch_labels)}
depends_on = ${repr(depends_on)}
def upgrade() -> None:
${upgrades if upgrades else "pass"}
def downgrade() -> None:
${downgrades if downgrades else "pass"}
import pathlib
import sys
from logging.config import fileConfig
import alembic
from sqlalchemy import engine_from_config, pool
sys.path.append(str(pathlib.Path(__file__).resolve().parents[3]))
from app.core.config import DATABASE_URL
config = alembic.context.config
fileConfig(config.config_file_name)
config.set_main_option("sqlalchemy.url", str(DATABASE_URL))
def run_migrations_online() -> None:
connectable = engine_from_config(
config.get_section(config.config_ini_section),
prefix="sqlalchemy.",
poolclass=pool.NullPool,
)
with connectable.connect() as connection:
alembic.context.configure(
connection=connection,
target_metadata=None
)
with alembic.context.begin_transaction():
alembic.context.run_migrations()
run_migrations_online()
色々記述していますが、一旦このタイミングで一度マイグレートを実行してみましょう。
作業を行うために一度FastAPIコンテナのシェルに入ります。
$ docker exec -it container_name /bin/sh
コンテナの中に入れたら alembic コマンドを使用しマイグレート用のスクリプトファイルを scrpt.py.mako
から生成します。
(internal container)$ alembic revision -m "create_first_tables"
無事にファイルをセットアップ出来ていれば、iniファイルで指定した backend/app/db/migrations/versions の中に次のような pyファイルが作成されたはずです。
"""create_first_tables
Revision ID: 12056735bd4e
Revises:
Create Date: 2021-01-22 18:27:50.234519
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic
revision = '12056735bd4e'
down_revision = None
branch_labels = None
depends_on = None
def upgrade() -> None:
pass
def downgrade() -> None:
pass
注意: ファイル名及び、ファイル内の Revision ID は環境ごとに異なります。
このファイルにテーブル定義と切り戻し用の関数を追記していきます。
"""create_first_tables
Revision ID: 12056735bd4e
Revises:
Create Date: 2021-01-22 18:27:50.234519
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic
revision = '12056735bd4e'
down_revision = None
branch_labels = None
depends_on = None
def create_hedgehogs_table() -> None:
op.create_table(
"hedgehogs",
sa.Column("id", sa.Integer, primary_key=True),
sa.Column("name", sa.Text, nullable=False, index=True),
sa.Column("description", sa.Text, nullable=True),
sa.Column("color_type", sa.Text, nullable=False),
sa.Column("age", sa.Numeric(10, 1), nullable=False),
)
def upgrade() -> None:
create_hedgehogs_table()
def downgrade() -> None:
op.drop_table("hedgehogs")
それっぽいカラムを用意してみました。
- id: Auto-increment される主キー
- name: ハリネズミさんのお名前
- description: ハリネズミさんの説明欄
- color_type: ハリネズミさんの針の色
- age: ハリネズミさんの年齢
各カラムのデータ型はPythonのものではなく、SQLAlchemy を使用して定義していることに注意してください。
実際にこのスクリプトを使用してマイグレートを実行し、postgresにテーブルを作成してみましょう。
(internal container)$ alembic upgrade head
INFO [alembic.runtime.migration] Context impl PostgresqlImpl.
INFO [alembic.runtime.migration] Will assume transactional DDL.
INFO [alembic.runtime.migration] Running upgrade -> 12056735bd4e, create_first_tables
正常に実行されると上記のようなログが出てくるはずです。
テーブルを確認してみる
本投稿の最後に先ほどマイグレートし作成されたテーブルが本当に存在しているか確認をしてみたいと思います。
次のコマンドをホスト側(FastAPIコンテナの外)で実行し、今度はpostgresコンテナに入ります。
$ docker-compose exec db psql -h localhost -U postgres --dbname=postgres
※ db の部分をご自身のコンテナ名に変更してください
コンテナの中に入れたら次のコマンドを実行することで作成されたテーブルを確認できます。
postgres=# \d hedgehogs
Table "public.hedgehogs"
Column | Type | Collation | Nullable | Default
-------------+---------------+-----------+----------+---------------------------------------
id | integer | | not null | nextval('hedgehogs_id_seq'::regclass)
name | text | | not null |
description | text | | |
color_type | text | | not null |
age | numeric(10,2) | | not null |
Indexes:
"hedgehogs_pkey" PRIMARY KEY, btree (id)
"ix_hedgehogs_name" btree (name)
無事に作成されていました!
まとめ
今回はPostgreSQLコンテナを構成に追加し、FastAPIコンテナから接続してマイグレートするところまで実践しました。
次回の投稿では、APIエンドポイントをpostgresに接続しクエリを発行して取得したデータを返すところまで作成していきます。
作成したコードはGitHubにアップしています。
その2はpart2ブランチが対応しています。