flask-sqlalchemy/flask-migrate/mysql-connector-pythonでMySQL連携を実装する

はじめに

PythonのWebアプリケーションフレームワークFlaskを使って、「消耗品買い物リスト」を作ってみるシリーズ第10回目です。

今回はMySQLとの連携をした上でのページ作成/表示機能を作ります。

前回まで

今回やること

前回までで新規ページの作成とページ表示の原型を作ったので、DBへの書き込み・読み込み部分を実装していきます。

  • 新規ページを作成すると、テーブルにレコードが追加される
  • uuidでアクセスすると、テーブルから該当するuuidのレコードを検索し、結果を返す

DBはMySQLを使用します。SQLiteとどちらにするか迷いましたが、私自身がMySQLに慣れていること、規模が大きくなるとMySQLに移行するであろうこと、得た知識を今後活用しやすそうなことから、MySQLを使用することにしました。バージョンは8.0です。

流れ

  • docker-composeでFlask+MySQL環境を構築する
    • docker-composeをインストール
    • ディレクトリ構成を見直す
    • docker-compose.ymlを書く
    • MySQLの初期設定ファイルを作成
    • MySQLベースイメージ取得
    • docker-composeをbuild + up
  • SQLAlchemy/Flask-SQLAlchemy/MySQLConnectorのインストール
    • インストール + requirements.txtに追記
    • アプリ構成の見直し
    • 接続設定
  • モデル作成
    • user_pagesテーブルのスキーマ
    • Migrate
  • 登録/表示の実装

docker-composeでFlask+MySQL環境を構築する

まずはDB連携ができるようにします。
ここまでのコンテナはFlaskサーバ用の一つだけだったので、MySQLコンテナを立てます。

docker-composeをインストール

インストールします。

$ curl -L https://github.com/docker/compose/releases/download/1.21.2/docker-compose-$(uname -s)-$(uname -m) -o /usr/local/bin/docker-compose
$ chmod +x /usr/local/bin/docker-compose

$ docker-compose --version
docker-compose version 1.21.2, build a133471

ディレクトリ構成を見直す

docker-composeを使うので、Flask用、DB用のディレクトリとDockerfileを作成します。また、後ほどFlask内の構成も見直します。

以下のような構成にしました。

.
├── app
│   ├── Dockerfile
│   ├── requirements.txt
│   └── src
├── docker-compose.yml
├── mysql
│   ├── Dockerfile
│   ├── logs
│   ├── my.cnf
│   ├── mysql_data
│   └── sqls
│       └── initialize.sql
.

docker-compose.ymlを書く

docker-compose.yml

version: '3'
services:
  db:
    build: ./mysql/
    volumes:
      - ./mysql/mysql_data:/var/lib/mysql # データの永続化
      - ./mysql/sqls:/docker-entrypoint-initdb.d # 初期データ投入
      - ./mysql/logs:/var/log/mysql # ログ記録
    environment:
      - MYSQL_ROOT_PASSWORD=password
  appserver:
    build: ./app/
    ports:
      - 8080:5000
    volumes:
      - ./app/src:/workdir
    environment:
      TZ: Asia/Tokyo
      FLASK_APP: run.py
      FLASK_ENV: development
    command: flask run -h 0.0.0.0
    tty: true
    links:
      - db

mysql/Dockerfile

FROM mysql:8.0
EXPOSE 3306

ADD ./my.cnf /etc/mysql/conf.d/my.cnf

CMD ["mysqld"]

app/Dockerfile

# ベースイメージ作成
FROM python:3.7

# requirements.txtをコピー
COPY requirements.txt .

# pipアップグレード
RUN pip install --upgrade pip
RUN pip install -r requirements.txt

# 作業ディレクトリ指定
WORKDIR /workdir

# 公開ポート指定
EXPOSE 8080

MySQLの初期設定を書く

mysql/my.cnf

[mysqld]
# 文字コード/照合順序の設定
character-set-server = utf8mb4
collation-server = utf8mb4_bin

# タイムゾーンの設定
default-time-zone = SYSTEM
log_timestamps = SYSTEM

# デフォルト認証プラグインの設定
default-authentication-plugin = mysql_native_password

# エラーログの設定
log-error = /var/log/mysql/mysql-error.log

# スロークエリログの設定
slow_query_log = 1
slow_query_log_file = /var/log/mysql/mysql-slow.log
long_query_time = 5.0
log_queries_not_using_indexes = 0

# 実行ログの設定
general_log = 1
general_log_file = /var/log/mysql/mysql-query.log

# mysqlオプションの設定
[mysql]
# 文字コードの設定
default-character-set = utf8mb4

# mysqlクライアントツールの設定
[client]
# 文字コードの設定
default-character-set = utf8mb4

mysql/sqls/initialize.sql

CREATE DATABASE app;
use app;

MySQLベースイメージ取得

docker pull mysql

docker-composeをbuild + up

ここまで書いて、起動します。

# docker-compose.ymlのあるディレクトリに移動した上で
$ docker-compose build
$ docker-compose up -d

# 起動確認(UpとなっていればOK)
$ docker-compose ps

docker-compose起動までの紆余曲折は、別記事(docker-composeでFlask+MySQL環境構築してハマった話)にまとめています。

SQLAlchemy/Flask-SQLAlchemy/MySQLConnectorのインストール

無事コンテナを起動できました。次はFlaskサーバに必要なパッケージをインストールしていきます。

インストール + requirements.txtに追記

以下のパッケージをインストールします。

  • SQLAlchemy : ORマッパー。SQLを書かずにDBスキーマやDB操作ができるもの。
  • Flask-SQLAlchemy : SQLAlchemyをFlaskで使いやすいようするパッケージ
  • MySQLConnector : SQLAlchemyでMySQLに接続するためのコネクタ
  • Flask-Migrate : DBのマイグレーションを行ってくれるパッケージ

pip installで各パッケージをインストールしていきますが、最終的にpip freezeしてrequirements.txtに書き出し、コンテナをビルドし直します。

もちろん、pip installせずに直接requirements.txtにインストールするパッケージを記載することもできますが、pip install XXXとしたときに何が入るかわからなかったので、今回はpip installでインストールする形にしました。

# インストール
$ pip install SQLAlchemy
$ pip install flask-sqlalchemy
$ pip install mysql-connector-python
$ pip insatll flask-migrate

最終的なrequirements.txt

以下のようになりました。

alembic==1.4.3
astroid==2.4.2
click==7.1.2
Flask==1.1.2
Flask-Migrate==2.5.3
Flask-SQLAlchemy==2.4.4
isort==4.3.21
itsdangerous==1.1.0
Jinja2==2.11.2
lazy-object-proxy==1.4.3
Mako==1.1.3
MarkupSafe==1.1.1
mccabe==0.6.1
mysql-connector-python==8.0.22
protobuf==3.13.0
pylint==2.5.3
python-dateutil==2.8.1
python-editor==1.0.4
six==1.15.0
SQLAlchemy==1.3.20
toml==0.10.1
typed-ast==1.4.1
Werkzeug==1.0.1
wrapt==1.12.1

アプリ構成の見直し

アプリ構成を見直します。具体的には、以下のようなことをしました。

  • tbl(to-buy-list)フォルダをつくり、ソースはそこに入れていくようにした
  • 起動用スクリプト(run.py)とアプリ本体(tbl/app.py)を分けた

app/直下のディレクトリ構成

.
├── Dockerfile
├── requirements.txt
├── shells
│   ├── deploy.sh
│   └── sample.sh
└── src
    ├── migrations
    ├── run.py
    ├── tbl
    │   ├── app.py
    │   ├── config.py
    │   ├── database.py
    │   ├── models
    │   │   ├── __init__.py
    │   │   └── models.py
    │   └── templates
    │       ├── application.html
    │       ├── complete.html
    │       ├── index.html
    │       └── user_page.html
    └── tests
        └── test_app.py


接続設定

Flaskサーバ->MySQLへの接続設定をしていきます。

# このへんを触ります
    ├── tbl
    │   ├── app.py
    │   ├── config.py
    │   ├── database.py

Dockerのネットワークを調べて、HOST情報を追加する

$ docker network ls

NETWORK ID          NAME                  DRIVER              SCOPE
7eff8a1341cb        bridge                bridge              local
a710bda8998d        host                  host                local
9ced2f2c77d5        none                  null                local
6c4409c5f00d        to-buy-list_default   bridge              local

# to-buy-list_defaultが今回作ったコンテナなので、その中身を確認
$ docker network inspect to-buy-list_default

[
    {
        "Name": "to-buy-list_default",
        (省略)
        "Containers": {
            "c4f97f85d99cb6b1484a82c1e7727b091fb80690e2574638c2f765fe977ba804": {
                "Name": "to-buy-list_appserver_1",
                "EndpointID": "4a8064bfce34036728be13a50107c86d70f967c7363288b8aa4c511c9b1a7fa8",
                "MacAddress": "02:42:ac:12:00:03",
                "IPv4Address": "172.18.0.3/16",
                "IPv6Address": ""
            },
            "d57745ca561effb401d72cc50780f035c3321a937374f6fb75b6d9ae80e7b540": {
                "Name": "to-buy-list_db_1",
                "EndpointID": "a82abfe7a0344cc7ac4558351732e02f7b452c750c6c0611d31d8a4872c163e3",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""
            }
        },
        "Options": {},
        "Labels": {
            "com.docker.compose.network": "default",
            "com.docker.compose.project": "to-buy-list",
            "com.docker.compose.version": "1.21.2"
        }
    }
]

DBコンテナのIPがIPv4Addressにあたります。このIPを次項のconfig.pyで使います。

                "Name": "to-buy-list_db_1",
                "EndpointID": "a82abfe7a0344cc7ac4558351732e02f7b452c750c6c0611d31d8a4872c163e3",
                "MacAddress": "02:42:ac:12:00:02",
                "IPv4Address": "172.18.0.2/16",
                "IPv6Address": ""

config.py

import os

class DevelopmentConfig:
    DEBUG = True

    SQLALCHEMY_DATABASE_URI = 'mysql+mysqlconnector://{user}:{password}@{host}/{database}?charset=utf8mb4'.format(
        **{
            'user': os.getenv('DB_USER', 'root'),
            'password': os.getenv('DB_PASSWORD', 'password'),
            'host': os.getenv('DB_HOST', '172.18.0.2/16'),
            'database': os.getenv('DB_DATABASE', 'app')
        }
    )
    SQLALCHEMY_TRACK_MODIFICATIONS = False
    SQLALCHEMY_ECHO = False

Config = DevelopmentConfig

database.py

from flask_sqlalchemy import SQLAlchemy

from flask_migrate import Migrate

db = SQLAlchemy()

def init_db(app):
    db.init_app(app)
    Migrate(app, db)

app.py

from flask import Flask, render_template, request, redirect, url_for
from markupsafe import escape
from tbl.database import init_db, db
import tbl.models as models

def create_app():
    app = Flask(__name__)
    app.config.from_object('tbl.config.Config') # config.pyの設定を取得
    init_db(app)

    return app

app = create_app()

(省略)

これで接続設定はひとまず完了です。続いてモデルを作っていきます。

モデル作成

専用URLを持ったユーザーページのモデルを作成します。
models/models.pyというスクリプトに、UserPageクラスの実装をしていきます。

user_pagesテーブルのスキーマ

テーブルのスキーマは以下のようになります。

カラム名 概要
id int 連番
uuid varchar 専用URL用の文字列
status varchar ページの状態(active/archived)
created_at datetime 作成日
updated_at datetime 更新日

新規ページ作成時は新たにレコードが振られる。page/XXXにアクセスした場合はuuidカラムがXXXのレコードを取得する。という処理になります。

  • models/models.py
from datetime import datetime
from tbl.database import db
import uuid as _uuid

class UserPage(db.Model):
    __tablename__ = 'user_pages'

    id = db.Column(db.Integer, primary_key=True, autoincrement=True)
    uuid = db.Column(db.String(255), index=True, unique=True, nullable=False)
    status = db.Column(db.String(10), nullable=False)
    created_at = db.Column(db.DateTime, nullable=False, default=datetime.now)
    updated_at = db.Column(db.DateTime, nullable=False, default=datetime.now, onupdate=datetime.now)

    def __init__(self):
        self.uuid = str(_uuid.uuid4())
        self.status = 'active'

    def searchBy(uuid):
        return db.session.query(UserPage)\
            .filter(UserPage.uuid == uuid)\
            .one()
  • models/init.py
from .models import UserPage

__all__ = [
    UserPage,
]
  • app.py
import tbl.models as models # appがロードされたときに、UserPageクラスもロード

...

Migrate

モデルを作成したので、Flask-Migrateを使ってMigrationします。
これにより、Modelsに記述された内容をもとにテーブルが作成されます。

# 初期化
$ flask db init

# migrationの準備
$ flask db migrate

# migration実行
$ flask db upgrade

DBコンテナに接続して確認してみると、テーブルが作成されています。

mysql> desc user_pages;
+------------+--------------+------+-----+---------+----------------+
| Field      | Type         | Null | Key | Default | Extra          |
+------------+--------------+------+-----+---------+----------------+
| id         | int          | NO   | PRI | NULL    | auto_increment |
| created_at | datetime     | NO   |     | NULL    |                |
| updated_at | datetime     | NO   |     | NULL    |                |
| status     | varchar(10)  | NO   |     | NULL    |                |
| uuid       | varchar(255) | NO   | UNI | NULL    |                |
+------------+--------------+------+-----+---------+----------------+

以降、Models.UserPageの中身を修正してテーブル定義を変更した時などは、
flask db migrate -> flask db upgrade を行ってテーブルの更新をするようにします。

ここまででモデルの作成とテーブル定義ができたので、次で登録/表示を実装していきます。

登録/表示の実装

基本的には前回9回で作った処理の中身を、UserPageクラスで置き換えていきます。

  • app.py
(省略)

@app.route('/')
def index():
    title = "to buy list"
    return render_template('index.html', title=title)

@app.route('/page/<uuid>/')
def show_user_page(uuid):
    title = 'Your page'
    # show user_page from uuid
    uuid = '%s' % escape(uuid)

    page = select_page_info(uuid)

    return render_template('user_page.html', title=title, page=page)


def select_page_info(uuid):
    """uuidから対象ページのデータを取得
    Return:
        Page : ページクラスのインスタンス
    """
    user_page = models.UserPage.searchBy(uuid)
    return user_page


@app.route('/page/create')
def create_new_page():
    """新規ページを作成する
    """
    # レコード生成
    user_page = models.UserPage()
    uuid = user_page.uuid

    try:
        db.session.add(user_page)
        db.session.commit()        

        title = "新規リストの作成に成功しました"
        # ページ表示
        return render_template('complete.html', title=title, uuid=uuid)

    except Exception as e:
        print(e)
        raise e
  • templates/complete.html
{% extends "application.html" %}
{% block body %}
<div>
    <h1>新規リストの作成完了</h1>
    <div class="field">
        <a href='{{ uuid }}'>Open the created page.</a>
    </div>
</div>
{% endblock %}

/page/createした時の挙動を、作成完了ページを表示し、作成したページのリンクを貼るようにしています。

動作確認

  • トップページ

  • Create new page をクリック

  • Open the createdpage をクリック

無事遷移しました。

最後に

これでページの作成と表示がDBを使ってできるようになりました。
「専用URLの発行」という機能はこれで完了になります。

次回からは、消耗品アイテムの作成/更新の機能を作っていきます。

参考