Chapter 11

データベースの接続とDBモデル(Models)

smithonisan
smithonisan
2021.07.25に更新

本章では、データベースとしてMySQLのDockerコンテナを立ち上げ、TODOアプリからデータベースに接続します。

MySQLコンテナの立ち上げ

3章 Docker環境のインストール で説明したように、docker-composeを利用することによってMySQLも簡単にインストールすることができます。

ローカルにMySQLがインストールされている方はそちらを使っても構いませんが、TODOアプリのコンテナとの接続や、ローカルのMySQLのデータを汚さずに利用可能なので、これから説明する手順に従ってコンテナを立てることをおすすめします。

SQLite

公式ドキュメントなどやチュートリアルなどではMySQLの代わりにファイルベースで簡単に利用できるSQLiteがデータベースとして紹介されることがよくあります。

しかし、SQLiteは基本的なSQLには対応しているのですが、データ型の種類が少なかったり、そもそもファイルベースなので、分散が必要になった場合などリアルなWebアプリケーションのデータベースとして採用されるケースは多いとは言えません。

本書でもこのあとの 13章 ユニットテスト ではSQLiteを利用しますが、productionコードでは より実践的な アプリの開発を目指し、そのままサービスにスケールできるようにMySQLを利用することとしましょう。

demo-app と並列に、 demo という名前のデータベースを持つ db サービスを追加します。

docker-compose.yaml
version: '3'
services:
  demo-app:
    build: .
    volumes:
      - .dockervenv:/src/.venv
      - .:/src
    ports:
      - 8000:8000  # ホストマシンのポート8000を、docker内のポート8000に接続する
  db:
    image: mysql:8.0
    platform: linux/x86_64  # M1 Macの場合必要
    environment:
      MYSQL_ALLOW_EMPTY_PASSWORD: 'yes'  # rootアカウントをパスワードなしで作成
      MYSQL_DATABASE: 'demo'  # 初期データベースとしてdemoを設定
      TZ: 'Asia/Tokyo'  # タイムゾーンを日本時間に設定
    volumes:
      - mysql_data:/var/lib/mysql
    command: --default-authentication-plugin=mysql_native_password  # MySQL8.0ではデフォルトが"caching_sha2_password"で、ドライバが非対応のため変更
    ports:
      - 33306:3306  # ホストマシンのポート33306を、docker内のポート3306に接続する
volumes:
  mysql_data:

もし既に docker-compose up によりFastAPIが立ち上がっている状態であれば、一度停止し、再度 docker-compose up を実行します。

以下のように、TODOアプリとMySQLが同時に立ち上がります。

shell
$ docker-compose up

Pulling db (mysql:8.0)...
8.0: Pulling from library/mysql
69692152171a: Pull complete
...
cd90f92aa9ef: Pull complete
Digest: sha256:d50098d7fcb25b1fcb24e2d3247cae3fc55815d64fec640dc395840f8fa80969
Status: Downloaded newer image for mysql:8.0
Creating fastapi-book_demo-app_1 ... done
Creating fastapi-book_db_1       ... done
Attaching to fastapi-book_db_1, fastapi-book_demo-app_1
db_1        | 2021-06-23 03:16:28+09:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.25-1debian10 started.
db_1        | 2021-06-23 03:16:28+09:00 [Note] [Entrypoint]: Switching to dedicated user 'mysql'
db_1        | 2021-06-23 03:16:28+09:00 [Note] [Entrypoint]: Entrypoint script for MySQL Server 8.0.25-1debian10 started.
db_1        | 2021-06-23 03:16:28+09:00 [Note] [Entrypoint]: Initializing database files
db_1        | 2021-06-22T18:16:28.374822Z 0 [System] [MY-013169] [Server] /usr/sbin/mysqld (mysqld 8.0.25) initializing of server in progress as process 42
db_1        | 2021-06-22T18:16:28.381385Z 1 [System] [MY-013576] [InnoDB] InnoDB initialization has started.
db_1        | 2021-06-22T18:16:28.835616Z 1 [System] [MY-013577] [InnoDB] InnoDB initialization has ended.
db_1        | 2021-06-22T18:16:30.040286Z 6 [Warning] [MY-010453] [Server] root@localhost is created with an empty password ! Please consider switching off the --initialize-insecure option.
demo-app_1  | INFO:     Uvicorn running on http://0.0.0.0:8000 (Press CTRL+C to quit)
demo-app_1  | INFO:     Started reloader process [1] using watchgod
demo-app_1  | INFO:     Started server process [11]
demo-app_1  | INFO:     Waiting for application startup.
demo-app_1  | INFO:     Application startup complete.
...

コンテナ内のMySQLデータベースにアクセスできることを確認してみましょう。

docker-compose up されている状態で、別のコンソールを開き、プロジェクトディレクトリで docker-compose exec db mysql demo を実行します。

以下のように、MySQLクライアントが実行され、DBに接続できているのが確認できます。

shell
# "db" コンテナの中で "mysql demo" コマンドを発行
$ docker-compose exec db mysql demo

Welcome to the MySQL monitor.  Commands end with ; or \g.
Your MySQL connection id is 11
Server version: 8.0.25 MySQL Community Server - GPL

Copyright (c) 2000, 2021, Oracle and/or its affiliates.

Oracle is a registered trademark of Oracle Corporation and/or its
affiliates. Other names may be trademarks of their respective
owners.

Type 'help;' or '\h' for help. Type '\c' to clear the current input statement.

mysql>

アプリからDB接続する準備

mysqlクライアントのインストール

FastAPIでは、MySQLとの接続のために sqlalchemy というORMライブラリ(Object-Relational Mapper)を利用します。 sqlalchemy はFlaskなど他のWebフレームワークでも利用されるPythonではかなりポピュラーなライブラリです。

ORMとは

ORMでは、PythonのオブジェクトをMySQLのようなリレーショナルデータベース(RDBMS)のデータ構造への変換を担います。MySQLの場合具体的に、テーブル構造をクラスとして定義することで、これを読み込んだり保存したりするSQLを発行してくれます。

本書では扱いませんが、FastAPIではその他に、 Peewee というORMにも対応しています。詳しくは公式ドキュメントをご参照ください。

sqlalchemy はそのバックエンドに様々なデータベースを利用でき、MySQLを利用する場合のクライアントとして、今回は aiomysql も同時にインストールします。 aiomysql はMySQL向けに非同期IO処理を提供するライブラリで、依存するMySQLクライアントの pymysql も同時にインストールされます。

SQLAlchemyと非同期処理

通常のSQLAlchemyの使用方法では、非同期処理に対応しておらず、FastAPIのasync/awaitによるイベントループを活用した高速なDB処理を行うことができません。ORMは遅延読み込みを多用するため非同期処理に対応するのが難しく、元来SQLAlchemyの低レイヤ実装であるSQLAlchemy Coreによる、ORMよりもprimitiveな書き方(クエリを直接書き下すのに近い記法)でしか非同期処理に対応していませんでした。

SQLAlchemyのバージョン1.4からは、2.0 Styleという新しい書き方によりORMとしてクラスを定義した場合でも、非同期処理がサポートされています。本書ではこちらの書き方に基づき、高速なDBアクセスを可能にします。

5章 FastAPIのインストール
と同様、 demo-app が立ち上がった状態で poetry add を実行し、これら2つの依存パッケージをインストールします。

shell
# "demo-app" コンテナの中で "poetry add sqlalchemy aiomysql" を実行
$ docker-compose exec demo-app poetry add sqlalchemy aiomysql

インストールにより、 pyproject.tomlpoetry.lock も中身が変更されているのが確認できます。

pyproject.toml
[tool.poetry.dependencies]
python = "^3.9"
fastapi = "^0.65.1"
uvicorn = {extras = ["standard"], version = "^0.13.4"}
SQLAlchemy = "^1.4.20"
aiomysql = "^0.0.21"

DB接続クラス

プロジェクトルートに、以下のように api/db.py を追加します。

api/db.py
from sqlalchemy.ext.asyncio import create_async_engine, AsyncSession
from sqlalchemy.orm import sessionmaker, declarative_base

ASYNC_DB_URL = "mysql+aiomysql://root@db:3306/demo?charset=utf8"

async_engine = create_async_engine(ASYNC_DB_URL, echo=True)
async_session = sessionmaker(
    autocommit=False, autoflush=False, bind=async_engine, class_=AsyncSession
)

Base = declarative_base()


async def get_db():
    async with async_session() as session:
        yield session

ASYNC_DB_URL に定義したMySQLのdockerコンテナに対して接続するセッションを作成しています。

ルーターでは、 get_db() 関数を通してこのセッションを取得し、DBへのアクセスを可能にします。

SQLAlchemyのDBのモデル(Models)の定義

TODOアプリのために、以下の2つのテーブルを定義しましょう。

Table: tasks

カラム名 Type 備考
id INT primary, auto increment
title VARCHAR(1024)

Table: dones

カラム名 Type 備考
id INT primary, auto increment, foreign key(task.id)

tasks のレコードはTODOタスクのタスク一つ一つに対応し、 dones は、 tasks のうち完了したものだけ該当の task と同じ id のレコードを持ちます。

ここで、 tasksiddonesid は1:1のマッピングとしています。

通常1:1のマッピングの場合正規化の観点から1つのテーブルとすることが多いと思いますが、本書ではtaskとdoneのリソースを明確に分離し、わかりやすくするため、別々のテーブルとして定義します。

以下のように、 api/models/task.py を作成しましょう。

api/models/task.py
from sqlalchemy import Column, Integer, String, ForeignKey
from sqlalchemy.orm import relationship

from api.db import Base


class Task(Base):
    __tablename__ = "tasks"

    id = Column(Integer, primary_key=True)
    title = Column(String(1024))

    done = relationship("Done", back_populates="task")


class Done(Base):
    __tablename__ = "dones"

    id = Column(Integer, ForeignKey("tasks.id"), primary_key=True)

    task = relationship("Task", back_populates="done")

Column がテーブルの1つ1つのカラムを表します。第一引数にカラムの型を渡します。第2引数以降にカラムの設定を書いていきます。上記の primary_key=TrueForeignKey("tasks.id") の他に例えば、Null制約( nullable=False )、 Unique制約( unique=True )などに対応しています。

relationship はテーブル(モデルクラス)同士の関係性を定義します。これにより、 Task オブジェクトから Done オブジェクトを参照したり、その逆が可能になります。

ORMとしてはこれを定義しておくと便利ですが、本書ではこうした相互呼び出しを行わないため、実際はこれらの定義がなくても動作します。

DBマイグレーション

作成したORMモデルをもとに、DBにテーブルを作成していきましょう。DBマイグレーション用のスクリプトを作成します。

api/migrate_db.py
from sqlalchemy import create_engine

from api.models.task import Base

DB_URL = "mysql+pymysql://root@db:3306/demo?charset=utf8"
engine = create_engine(DB_URL, echo=True)


def reset_database():
    Base.metadata.drop_all(bind=engine)
    Base.metadata.create_all(bind=engine)


if __name__ == "__main__":
    reset_database()

先程の db.py は async/await による非同期処理を行っていましたが、テーブルの作成をワンタイムで行うのに非同期処理は必要ありませんので、ここでは通常の(同期的な)engineを作成しています。

以下のようにスクリプトを実行することで、DockerコンテナのMySQLにテーブルを作成します。既に同名のテーブルがある場合は、削除してから作成されます。

shell
# api モジュールの migrate_db スクリプトを実行する
$ docker-compose exec demo-app poetry run python -m api.migrate_db
/src/.venv/lib/python3.9/site-packages/pymysql/cursors.py:170: Warning: (3719, "'utf8' is currently an alias for the character set UTF8MB3, but will be an alias for UTF8MB4 in a future release. Please consider using UTF8MB4 in order to be unambiguous.")
  result = self._query(query)
2021-06-30 17:06:25,810 INFO sqlalchemy.engine.Engine SHOW VARIABLES LIKE 'sql_mode'
2021-06-30 17:06:25,810 INFO sqlalchemy.engine.Engine [raw sql] {}
2021-06-30 17:06:25,819 INFO sqlalchemy.engine.Engine SHOW VARIABLES LIKE 'lower_case_table_names'
2021-06-30 17:06:25,819 INFO sqlalchemy.engine.Engine [generated in 0.00021s] {}
2021-06-30 17:06:25,822 INFO sqlalchemy.engine.Engine SELECT DATABASE()
2021-06-30 17:06:25,822 INFO sqlalchemy.engine.Engine [raw sql] {}
2021-06-30 17:06:25,824 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2021-06-30 17:06:25,825 INFO sqlalchemy.engine.Engine SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = %(table_schema)s AND table_name = %(table_name)s
2021-06-30 17:06:25,825 INFO sqlalchemy.engine.Engine [generated in 0.00019s] {'table_schema': 'demo', 'table_name': 'tasks'}
2021-06-30 17:06:25,836 INFO sqlalchemy.engine.Engine SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = %(table_schema)s AND table_name = %(table_name)s
2021-06-30 17:06:25,836 INFO sqlalchemy.engine.Engine [cached since 0.0113s ago] {'table_schema': 'demo', 'table_name': 'dones'}
2021-06-30 17:06:25,838 INFO sqlalchemy.engine.Engine
DROP TABLE dones
2021-06-30 17:06:25,838 INFO sqlalchemy.engine.Engine [no key 0.00018s] {}
2021-06-30 17:06:25,943 INFO sqlalchemy.engine.Engine COMMIT
2021-06-30 17:06:25,945 INFO sqlalchemy.engine.Engine BEGIN (implicit)
2021-06-30 17:06:25,945 INFO sqlalchemy.engine.Engine SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = %(table_schema)s AND table_name = %(table_name)s
2021-06-30 17:06:25,946 INFO sqlalchemy.engine.Engine [cached since 0.1205s ago] {'table_schema': 'demo', 'table_name': 'tasks'}
2021-06-30 17:06:25,948 INFO sqlalchemy.engine.Engine SELECT COUNT(*) FROM information_schema.tables WHERE table_schema = %(table_schema)s AND table_name = %(table_name)s
2021-06-30 17:06:25,948 INFO sqlalchemy.engine.Engine [cached since 0.1232s ago] {'table_schema': 'demo', 'table_name': 'dones'}
2021-06-30 17:06:25,952 INFO sqlalchemy.engine.Engine
CREATE TABLE tasks (
	id INTEGER NOT NULL AUTO_INCREMENT,
	title VARCHAR(1024),
	PRIMARY KEY (id)
)


2021-06-30 17:06:25,953 INFO sqlalchemy.engine.Engine [no key 0.00019s] {}
2021-06-30 17:06:25,983 INFO sqlalchemy.engine.Engine
CREATE TABLE dones (
	id INTEGER NOT NULL,
	PRIMARY KEY (id),
	FOREIGN KEY(id) REFERENCES tasks (id)
)


2021-06-30 17:06:25,983 INFO sqlalchemy.engine.Engine [no key 0.00019s] {}
2021-06-30 17:06:26,002 INFO sqlalchemy.engine.Engine COMMIT

これでDBにテーブルが作成されました 🎉

確認

本当にテーブルが作成されているか確認してみましょう。 docker-compose up でコンテナが起動している状態で、MySQLクライアントを起動します。

shell
$ docker-compose exec db mysql demo

以下のようにSQLを打ち、DBの中身を確認します。

mysql> SHOW TABLES;
+----------------+
| Tables_in_demo |
+----------------+
| dones          |
| tasks          |
+----------------+
2 rows in set (0.01 sec)

mysql> DESCRIBE tasks;
+-------+---------------+------+-----+---------+----------------+
| Field | Type          | Null | Key | Default | Extra          |
+-------+---------------+------+-----+---------+----------------+
| id    | int           | NO   | PRI | NULL    | auto_increment |
| title | varchar(1024) | YES  |     | NULL    |                |
+-------+---------------+------+-----+---------+----------------+
2 rows in set (0.05 sec)

mysql> DESCRIBE dones;
+-------+------+------+-----+---------+-------+
| Field | Type | Null | Key | Default | Extra |
+-------+------+------+-----+---------+-------+
| id    | int  | NO   | PRI | NULL    |       |
+-------+------+------+-----+---------+-------+
1 row in set (0.00 sec)

それでは次章で、いよいよDBの書き込みや読み込みの処理を書き、APIと繋げてみましょう。