CUBE SUGAR CONTAINER

技術系のこと書きます。

Python: Alembic をプロジェクトの途中から導入する

今回は Python のデータベースマイグレーションツールの Alembic について。 Alembic を使うとデータベースのスキーマをマイグレーションスクリプトにもとづいて管理できる。 マイグレーションスクリプトというのは、スキーマのバージョンを現在の状態から進める・戻すのに必要な手順が書かれたスクリプトのこと。 このブログでも、以前に Alembic でマイグレーションスクリプトを自動生成するための方法について書いたことがある。

blog.amedama.jp

そして、今回はアプリケーションの実運用が始まってしまった後からスキーマの管理を Alembic に移行するための手順を書いてみる。 スケジュールが厳しいプロジェクトなんかだと、リリースまでにマイグレーションまで手が回らないなんてこともまあ考えられる。

今回使った環境は次の通り。 RDBMS には MySQL 5.7 を使った。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.5
BuildVersion:   15F34
$ python --version
Python 3.5.1
$ mysql --version
mysql  Ver 14.14 Distrib 5.7.13, for osx10.11 (x86_64) using  EditLine wrapper

下準備

まずは Homebrew で MySQL をインストールしておく。

$ brew install mysql

そして MySQL サーバを起動しよう。

$ mysql.server start

次に Python の O/R マッパーの SQLAlchemy と MySQL ドライバの mysqlclient をインストールしておく。

$ pip install sqlalchemy mysqlclient
$ pip list | egrep -i "(sqlalchemy|mysqlclient)"
mysqlclient (1.3.7)
SQLAlchemy (1.0.13)

今回、動作確認に使うためのデータベースを用意する。

$ mysql -u root -e "CREATE DATABASE IF NOT EXISTS migration CHARACTER SET utf8mb4"

「migration」という名前のデータベースだ。

$ mysql -u root -e "SHOW CREATE DATABASE migration"
+----------+--------------------------------------------------------------------+
| Database | Create Database                                                    |
+----------+--------------------------------------------------------------------+
| migration   | CREATE DATABASE `migration` /*!40100 DEFAULT CHARACTER SET utf8mb4 */ |
+----------+--------------------------------------------------------------------+

これで RDBMS に関しては準備ができた

テーブルとレコードを用意する

次に Alembic が導入されていない頃のプロジェクトを想定した状況を作る。 最初に SQLAlchemy でテーブル定義に対応するモデルを用意しよう。 このモデルは、ユーザを管理するための users というテーブルをひとつ持っている。

$ cat << 'EOF' > model.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sqlalchemy.ext.declarative.api import declarative_base
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import BigInteger
from sqlalchemy.sql.sqltypes import Text


Base = declarative_base()


class User(Base):
    __tablename__ = 'users'

    # 主キー
    id = Column(BigInteger, primary_key=True)
    # 名前
    name = Column(Text, nullable=False)
EOF

次に、上記のモデルを使って実際にデータベースにテーブルとレコードを追加するスクリプトを用意する。

$ cat << 'EOF' > insert.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sqlalchemy.engine import create_engine
from sqlalchemy.orm.session import sessionmaker

from model import Base
from model import User


def main():
    # エンジンを作る
    engine = create_engine(
        'mysql+mysqldb://root@localhost/migration?charset=utf8mb4',
        echo=True,
    )

    # テーブルを作る
    Base.metadata.create_all(engine)

    # セッション作成用のオブジェクトを作る
    SessionMaker = sessionmaker(
        bind=engine,
        autocommit=True,
        expire_on_commit=False,
    )

    # セッションを作る
    session = SessionMaker()
    # 最初に行をひとつ追加しておく
    with session.begin(subtransactions=True):
        user = User(name='foo')
        session.add(user)


if __name__ == '__main__':
    main()
EOF

上記のスクリプトを実行しよう。

$ python insert.py
2016-06-11 12:46:19,645 INFO sqlalchemy.engine.base.Engine SHOW VARIABLES LIKE 'sql_mode'
2016-06-11 12:46:19,646 INFO sqlalchemy.engine.base.Engine ()
2016-06-11 12:46:19,649 INFO sqlalchemy.engine.base.Engine SELECT DATABASE()
...(省略)...
2016-06-11 12:46:19,721 INFO sqlalchemy.engine.base.Engine INSERT INTO users (name) VALUES (%s)
2016-06-11 12:46:19,721 INFO sqlalchemy.engine.base.Engine ('foo',)
2016-06-11 12:46:19,726 INFO sqlalchemy.engine.base.Engine COMMIT

これで Alembic を導入する前のデータベースの状態が用意できた。

$ mysql -u root -D migration -e "DESC users"
+-------+------------+------+-----+---------+----------------+
| Field | Type       | Null | Key | Default | Extra          |
+-------+------------+------+-----+---------+----------------+
| id    | bigint(20) | NO   | PRI | NULL    | auto_increment |
| name  | text       | NO   |     | NULL    |                |
+-------+------------+------+-----+---------+----------------+
$ mysql -u root -D migration -e "SELECT * FROM users\G" 
*************************** 1. row ***************************
  id: 1
name: foo

Alembic を導入する

さて、ここからは Alembic を導入するフェーズに入る。

$ pip install alembic
$ pip list | grep -i alembic
alembic (0.8.6)

ここからの作業は、先ほど用意した Python スクリプトと同じ場所で進める。

$ ls
__pycache__ insert.py   model.py

まずは alembic コマンドを使って必要なファイルセットを一式用意しよう。

$ alembic init alembic

これでディレクトリには alembic.ini というファイルと alembic というディレクトリができるはず。

$ ls
__pycache__ alembic     alembic.ini insert.py   model.py

ここからは Alembic の設定ファイルを編集していくんだけど、その前に GNU sed が入っていなければインストールしておく。 Mac の sed はオプションが GNU 版と違うので。

$ brew install gnu-sed
$ alias sed='gsed'

まずは alembic.ini の中にある sqlalchemy.url という項目を編集する。 必要に応じて接続用 URL のアカウントなどは適宜変更する。

$ sed -i -e 's!^sqlalchemy\.url = .*$!sqlalchemy.url = mysql+mysqldb://root@localhost/migration?charset=utf8mb4!' alembic.ini
$ grep 'sqlalchemy\.url' alembic.ini
sqlalchemy.url = mysql+mysqldb://root@localhost/migration?charset=utf8mb4

次に Alembic に管理対象のモデルを教えてやる。 これには alembic/env.py の target_metadata にモデルが継承しているオブジェクトの metadata メンバを指定する。

$ sed -i -e '
  2i import model
  s:^\(target_metadata = \)None:\1model.Base.metadata:
' alembic/env.py
$ head -n 3 alembic/env.py
from __future__ import with_statement
import db
from alembic import context
$ grep ^target_metadata alembic/env.py
target_metadata = model.Base.metadata

これで Alembic の下準備ができた。

マイグレーションスクリプトを用意する

次は Alembic のマイグレーションスクリプトを用意する。 これはデータベースに何もないまっさらな状態から users テーブルがある状態にするためのスクリプト。

最初のリビジョンのマイグレーションスクリプトを生成する。

$ PYTHONPATH=. alembic revision -m "Initial"

これで、なんか適当なリビジョンが振られたスクリプトができる。

$ ls alembic/versions 
6b7569a12df9_initial.py __pycache__

生成されたスクリプトにマイグレーションの内容を記述する。

$ cat alembic/versions/6b7569a12df9_initial.py 
"""Initial

Revision ID: 6b7569a12df9
Revises: 
Create Date: 2016-06-11 13:39:23.954411

"""

# revision identifiers, used by Alembic.
revision = '6b7569a12df9'
down_revision = None
branch_labels = None
depends_on = None

from alembic import op
import sqlalchemy as sa


def upgrade():
    op.create_table('users',
        sa.Column('id', sa.Integer(), nullable=False),
        sa.Column('name', sa.Text(), nullable=False),
        sa.PrimaryKeyConstraint('id')
    )



def downgrade():
    op.drop_table('accounts')

ちなみに、マイグレーションスクリプトの内容はある程度まで Alembic で自動化で生成することもできる。 ただし、今回は既にデータベースの状態とモデルの状態が一致してしまっている。 そのため自動で生成するときはまっさらなデータベースを別に用意する必要がある。

blog.amedama.jp

スキーマの管理を Alembic に移行する

さて、ここまでで全ての下準備が整った。 いよいよデータベースのスキーマを Alembic に移行してみよう。

Alembic では、今のスキーマの状態を alembic_version というテーブルで管理している。 もちろん、現状ではスキーマを Alembic で管理していないので、このテーブルがない。 そこで、プロジェクトの途中から管理を Alembic に移すときは手動でこのテーブルを作ってやれば良い。

まずは、次のようにしてテーブルとレコードを追加する。 レコードに入れるリビジョン番号は、今のデータベースの状態が Alembic のマイグレーションスクリプトをどこまで適用したかを示している。 今のデータベースの状態は、先ほど用意したマイグレーションスクリプトが既に適用された状態と捉えることができる。 要するに、ここには先ほど用意したマイグレーションスクリプトのリビジョンを指定すれば良い。

$ mysql -u root -D migration -e "CREATE TABLE alembic_version(version_num varchar(32) NOT NULL)"
$ mysql -u root -D migration -e "INSERT INTO alembic_version(version_num) values ('6b7569a12df9')"

レコードを追加できたら alembic コマンドで upgrade head サブコマンドを実行してみよう。 これは Alembic のマイグレーションスクリプトを最新の状態までデータベースに適用することを意味している。

$ PYTHONPATH=. alembic upgrade head
INFO  [alembic.runtime.migration] Context impl MySQLImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.

ここで上記のように、特に何も実行されなければ上手くいっている。

ちなみに、間違えてレコードを追加する前に上記のコマンドを実行してしまっても問題はない。 まずは、何やら例外が出て焦るかもしれない。

$ PYTHONPATH=. alembic upgrade head
...(省略)...
sqlalchemy.exc.OperationalError: (_mysql_exceptions.OperationalError) (1050, "Table 'users' already exists") [SQL: '\nCREATE TABLE users (\n\tid INTEGER NOT NULL AUTO_INCREMENT, \n\tname TEXT NOT NULL, \n\tPRIMARY KEY (id)\n)\n\n']

上記は alembic_version テーブルがないことで Alembic がデータベースがまっさらな状態と考えてテーブルを作ろうとしているために発生したエラーだ。

alembic_version テーブル自体は上記のコマンドで作られる。

$ mysql -u root -D migration -e "CREATE TABLE alembic_version(version_num varchar(32) NOT NULL)"
ERROR 1050 (42S01) at line 1: Table 'alembic_version' already exists

つまり、落ち着いて現在のリビジョンをレコードに追加することで Alembic に今データベースがどの状態にあるかを教えてやれば良い。

$ mysql -u root -D migration -e "INSERT INTO alembic_version(version_num) values ('6b7569a12df9')"

スキーマを更新してみる

Alembic に管理が移行できたところで、試しにスキーマを更新してみよう。

例えば users テーブルに年齢 (age) を入れるカラムを追加してみよう。 既存のユーザがマイグレーションするときは null を入れることにする。

$ cat << 'EOF' > model.py
#!/usr/bin/env python
# -*- coding: utf-8 -*-

from sqlalchemy.ext.declarative.api import declarative_base
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import BigInteger
from sqlalchemy.sql.sqltypes import Integer
from sqlalchemy.sql.sqltypes import Text


Base = declarative_base()


class User(Base):
    __tablename__ = 'users'

    # 主キー
    id = Column(BigInteger, primary_key=True)
    # 名前
    name = Column(Text, nullable=False)
    # 年齢
    age = Column(Integer, nullable=True)
EOF

今度は Alembic のスキーマの自動検出機能を使ってみよう。

$ PYTHONPATH=. alembic revision --autogenerate -m "Add age column"

するとマイグレーションスクリプトが追加されたことがわかる。

$ ls alembic/versions 
6b7569a12df9_initial.py        e64c12b8698d_add_age_column.py
__pycache__

内容を確認すると users テーブルに age カラムを追加・削除するスクリプトが生成されている。

$ cat alembic/versions/e64c12b8698d_add_age_column.py 
"""Add age column

Revision ID: e64c12b8698d
Revises: 6b7569a12df9
Create Date: 2016-06-11 14:20:27.826977

"""

# revision identifiers, used by Alembic.
revision = 'e64c12b8698d'
down_revision = '6b7569a12df9'
branch_labels = None
depends_on = None

from alembic import op
import sqlalchemy as sa


def upgrade():
    ### commands auto generated by Alembic - please adjust! ###
    op.add_column('users', sa.Column('age', sa.Integer(), nullable=True))
    ### end Alembic commands ###


def downgrade():
    ### commands auto generated by Alembic - please adjust! ###
    op.drop_column('users', 'age')
    ### end Alembic commands ###

早速、このスクリプトを使ってデータベースを更新してみよう。

$ PYTHONPATH=. alembic upgrade head
INFO  [alembic.runtime.migration] Context impl MySQLImpl.
INFO  [alembic.runtime.migration] Will assume non-transactional DDL.
INFO  [alembic.runtime.migration] Running upgrade 6b7569a12df9 -> e64c12b8698d, Add age column

すると確かにデータベースのスキーマが更新されている。

$ mysql -u root -D migration -e "DESC users"
+-------+------------+------+-----+---------+----------------+
| Field | Type       | Null | Key | Default | Extra          |
+-------+------------+------+-----+---------+----------------+
| id    | bigint(20) | NO   | PRI | NULL    | auto_increment |
| name  | text       | NO   |     | NULL    |                |
| age   | int(11)    | YES  |     | NULL    |                |
+-------+------------+------+-----+---------+----------------+

既存のユーザについては age に null が入った。

$ mysql -u root -D migration -e "SELECT * FROM users"
+----+------+------+
| id | name | age  |
+----+------+------+
|  1 | foo  | NULL |
+----+------+------+

もし、マイグレーションしたときに特定の値を入れなおすみたいな処理がしたければマイグレーションスクリプトにそれを記述すれば良い。 Alembic のマイグレーションスクリプトはあくまで、ただの Python モジュールに過ぎない。 だから、どんな処理を書いても構わない。

まとめ

  • プロジェクトの途中からでも Alembic を導入してスキーマ管理を移行できる
  • それには alembic_version というテーブルのレコードを手作業で用意すれば良い

Python: (今のところ) Flask で Request#get_data(as_text=True) は使わない方が良い

今回は最近見つけた Flask (正確には、その中で使われている WSGI ツールキットの Werkzeug) のバグについて。 先にざっくりと概要を説明しておくと Flask の Request#get_data() の引数として as_text=True を渡したときの挙動に問題がある。 このメソッドは Content-Type に含まれる charset 指定にもとづいてマルチバイト文字をデコードできない。 デコードに使われる文字コードが UTF-8 に固定されてしまっているため、それ以外の文字コードを扱うことができない。

このエントリでは、上記の問題について詳しく見ていくことにする。

今回使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.5
BuildVersion:   15F34
$ python --version
Python 3.5.1
$ echo $LANG
ja_JP.UTF-8

下準備

まずは Flask をインストールしておく。

$ pip install Flask
$ pip list | grep -i flask
Flask (0.11.1)

Request#charset メンバについて

Flask (正確には Werkzeug) の Request オブジェクトには charset というメンバがある。 これは、おそらくは Content-Body をエンコードした文字コードを格納するためのものだろう。 ただし、「おそらく」と言ったように、実際にはそのようには動作しない。 このメンバの値は、今のところ UTF-8 に固定されてしまっているためだ。

動作を確認するために、次のようなサンプルコードを用意しよう。 このサンプルコードでは Request#charset の内容をレスポンスとして返す。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
   return request.charset

上記のサンプルコードを実行する。 Flask v0.11 からは Flask のテストサーバの推奨される起動方法が少し変わった。

$ export FLASK_APP=charset.py
$ flask run
 * Serving Flask app "charset"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

まずは Content-Type に charset の指定がないとき。 これは「utf-8」になる。 まあ、猫も杓子も UTF-8 の昨今、これは特に違和感のない挙動だと思う。

$ curl -X POST -H "Content-Type: text/plain" -d "こんにちは" http://localhost:5000
utf-8

次に UTF-8 以外の文字コードを扱ってみることにしよう。 今回は EUC-JP を使うことにして、それでエンコードされたテキストファイルを用意する。 先ほどのサンプルコードでは Content-Body の内容を読みこんだりはしないけど、一応ね。

$ cat << 'EOF' > greeting.txt
こんにちは
EOF
$ nkf -e greeting.txt > greeting.euc.txt
$ nkf --guess greeting.euc.txt
EUC-JP (LF)

ちなみに Mac OS X に nkf はデフォルトではインストールされていないので Homebrew でインストールしよう。

$ brew install nkf

次は Content-Type に charset をつけてリクエストする。 Content-Body も、それに合わせて EUC-JP でエンコードされたテキストファイルを使って送る。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
utf-8

Content-Type の charset で EUC-JP と指定しているんだけど utf-8 になってしまっている。

このように Flask (正確には Werkzeug) の Request#charset は、今のところ正しく動作しない。

MIMETYPE の文字コードは何処に格納されるのか

じゃあ Flask では Content-Type で指定された文字コードを正しく扱うことはできないのか?というと、そうではない。 実は Request#mimetype_params という辞書の中に入っている。

これもサンプルコードを用意して、動作を確認してみよう。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
   return str(request.mimetype_params.get('charset'))

そしてアプリケーションを起動する。

$ export FLASK_APP=mimetype.py
$ flask run
 * Serving Flask app "mimetype"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

先ほどと同じように EUC-JP を指定したリクエストを送ってみよう。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
EUC-JP

今度はちゃんと EUC-JP になっている!

ちなみに、この値にはデフォルト値が入るわけではない。 指定がないときは空になっている。

$ curl -X POST -H "Content-Type: text/plain" -d @greeting.euc.txt http://localhost:5000
None

Request#get_data(as_text=True) への副作用

先ほどの Request#charset が UTF-8 で固定される問題は、別のメソッドにも影響を与えている。 それが、今回のエントリのタイトルにもなっている Request#get_data() メソッドだ。 このメソッドはリクエストから Content-Body として送られたデータを取り出すためのメソッドになっている。

この Request#get_data() というメソッドには as_text という引数がある。 これは Content-Body をデコードした内容を受け取るためのオプションで、デフォルトでは False になっている。 つまり、デフォルトでは Request#get_data() を実行したとき得られるものはバイト列 (bytes) ということになる。 そして、この引数を True にすると、バイト列をデコードしたユニコード文字列 (Python3: str, Python2: unicode) になる。

問題は、この as_text オプションを True にしたとき使われる文字コードだ。 ここまで語ってきたように Request#charset は utf-8 に固定されてしまっている。 だから、このメンバにもとづいてデコードしているとアウトなんだけど、今 (v0.11.10) の Wekzeug は見事にそれをやってしまっている。

github.com

この挙動を確認するため、次のようなサンプルコードを用意しよう。 このサンプルコードでは Request#get_data(as_text=True) で取得した内容を、そのままレスポンスとして返す。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
    return request.get_data(as_text=True)

上記を実行する。

$ export FLASK_APP=getdata1.py
$ flask run
 * Serving Flask app "getdata1"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

先ほどと同じように EUC-JP を含むリクエストを送ってみよう。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
����ˤ���

文字化けしてしまった…。

何が起こったのか

これはつまり、以下のようなことが起こっている。

[EUC-JP 文字列] -> (UTF-8 デコード) -> [Python ユニコード文字列] -> (UTF-8 エンコード) -> [UTF-8 文字列 (文字化け)]

HTTP クライアントから送られてきた EUC-JP のバイト列を UTF-8 でデコードしてしまっているのが間違い。 結果的にめちゃくちゃなユニコード文字列が生成されて、それをエンコードしたところで文字化けしてしまう、という寸法だ。

じゃあ、どうすればいいのか

Wekzeug のバグが修正されるまではワークアラウドでしのぐしかない。 Werkzeug 任せにすると、リクエストに含まれるバイト列が UTF-8 固定でデコードされてしまうのが根本的な原因だ。 つまり、デコードを自分でやれば問題は起きなくなる。

次のサンプルコードを見てほしい。 このコードではリクエストからバイト列でデータを取り出した上で、それを自分でデコードしている。 デコードに使う文字コードは Request#mimetype_params に入っている値で、それがなければ UTF-8 を使うようにした。

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from flask import Flask
from flask import request

app = Flask(__name__)


@app.route('/', methods=['POST'])
def post():
    data = request.get_data()
    charset = request.mimetype_params.get('charset') or 'UTF-8'
    return data.decode(charset, 'replace')

実行してみる。

$ export FLASK_APP=getdata2.py
$ flask run
 * Serving Flask app "getdata2"
 * Running on http://127.0.0.1:5000/ (Press CTRL+C to quit)

今度も同じように EUC-JP を含むリクエストを送ってみよう。

$ curl -X POST -H "Content-Type: text/plain; charset=EUC-JP" -d @greeting.euc.txt http://localhost:5000
こんにちは

今度は文字化けしていない!

今度は、次のようなことが起こっている。

[EUC-JP 文字列] -> (EUC-JP デコード) -> [Python ユニコード文字列] -> (UTF-8 エンコード) -> [UTF-8 文字列]

EUC-JP 文字列が正しい文字コードでデコードされて、本来のユニコード文字列になっている。 それを UTF-8 でエンコードしてレスポンスとして返した。 そして、ターミナルの文字コードも UTF-8 なので文字化けは起きない。

実は、このやり方は Flask の Request#get_json() を真似している。 このメソッドでも、同じように Request#mimetype_params に入っている charset にもとづいてデコードしているからだ。 つまり Request#get_json() はバグっていない。 github.com

最近は猫も杓子も JSON だし、UTF-8 以外の文字コードを使う機会も少ないから今回のバグを踏む人は少ないのかもしれない。 とはいえ、こういう問題があるので Flask のアプリケーションでマルチバイト文字を扱うときは注意しよう。

ちなみに

この不具合については Wekzeug にバグレポートした。 将来的には、いつか直るかもしれない。

Request#get_data(as_text=True) does not work with Content-Type/charset · Issue #947 · pallets/werkzeug · GitHub