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: SQLAlchemy + mysqlclient (MySQLdb) でマルチバイト文字を扱う

今回は Python の O/R マッパーである SQLAlchemy と MySQL ドライバの mysqlclient を使ってマルチバイト文字を扱うときの注意点について書いてみる。 ただし RDBMS については MySQL ではなく、代わりに MariaDB を使った。 注意点というのを先に書いてしまうと、接続先 URL で charset を指定するのを忘れないようにしましょうというところ。

使った環境は次の通り。

$ cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)
$ uname -r
3.10.0-327.18.2.el7.x86_64

MariaDB をセットアップする

まずは下準備として MariaDB をインストールして設定する。

CentOS 7 では標準の RDBMS が MySQL ではなく MariaDB になっている。

$ sudo yum -y install mariadb-server

インストールされたバージョンは次の通り。 現在 MariaDB の安定版には 5.5 系、10.0 系、10.1 系がある。

$ rpm -qa | grep -i mariadb-server
mariadb-server-5.5.47-1.el7_2.x86_64

MariaDB のサービスを起動する。

$ sudo systemctl start mariadb
$ sudo systemctl enable mariadb

デフォルトでは文字コードがクライアントは utf8 でサーバは latin-1 になっている。

$ mysql -u root -e "show variables like 'character%'"
+--------------------------+----------------------------+
| Variable_name            | Value                      |
+--------------------------+----------------------------+
| character_set_client     | utf8                       |
| character_set_connection | utf8                       |
| character_set_database   | latin1                     |
| character_set_filesystem | binary                     |
| character_set_results    | utf8                       |
| character_set_server     | latin1                     |
| character_set_system     | utf8                       |
| character_sets_dir       | /usr/share/mysql/charsets/ |
+--------------------------+----------------------------+

サーバについては、このままだと不都合があるので utf8mb4 にしたい。 これは 4 バイト対応の UTF-8 文字コードセットで、ただの utf8 のスーパーセットになっている。

$ mysql -u root -e "show character set where Charset = 'utf8mb4'"
+---------+---------------+--------------------+--------+
| Charset | Description   | Default collation  | Maxlen |
+---------+---------------+--------------------+--------+
| utf8mb4 | UTF-8 Unicode | utf8mb4_general_ci |      4 |
+---------+---------------+--------------------+--------+

サーバの文字コードが utf8mb4 になるよう設定ファイルを書き換える。

$ sudo sed -i -e "/^\[server\]$/a character-set-server=utf8mb4" /etc/my.cnf.d/server.cnf
$ grep -A 1 "\[server\]" /etc/my.cnf.d/server.cnf
[server]
character-set-server=utf8mb4

ついでにクライアントについても書き換えておく。

$ sudo sed -i -e "/^\[client\]$/a default-character-set=utf8mb4" /etc/my.cnf.d/client.cnf
$ grep -A 1 "\[client\]" /etc/my.cnf.d/client.cnf
[client]
default-character-set=utf8mb4

設定できたら MariaDB のサービスを再起動する。

$ sudo systemctl restart mariadb

文字コードが utf8mb4 になっていることを確認しよう。 character_set_system が utf8 のままだけど、ここは固定値らしいので気にしなくても良さそう。

$ mysql -u root -e "show variables like 'character%'"
+--------------------------+----------------------------+
| Variable_name            | Value                      |
+--------------------------+----------------------------+
| character_set_client     | utf8mb4                    |
| character_set_connection | utf8mb4                    |
| character_set_database   | utf8mb4                    |
| character_set_filesystem | binary                     |
| character_set_results    | utf8mb4                    |
| character_set_server     | utf8mb4                    |
| character_set_system     | utf8                       |
| character_sets_dir       | /usr/share/mysql/charsets/ |
+--------------------------+----------------------------+

今回の動作確認に使うデータベースを sample という名前で用意する。

$ mysql -uroot -e "CREATE DATABASE IF NOT EXISTS sample"

ちゃんとデフォルトの文字コードである utf8mb4 を使うようになっている。

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

もし、グローバルの設定を変更せずに utf8mb4 を使いたいときはデータベースを作るときにも指定できる。

$ mysql -uroot -e "CREATE DATABASE IF NOT EXISTS sample CHARACTER SET utf8mb4"

あるいは、既にあるデータベースを変更しても良い。

$ mysql -uroot -e "ALTER DATABASE sample DEFAULT CHARACTER SET utf8mb4"

以上で MariaDB の設定はおわり。

SQLAlchemy と mysqlclient をインストールする

次は Python パッケージの設定に入る。

まずは CentOS7 にデフォルトで yum 管理の setuptools が入っていたら、それをアンインストールしておこう。

$ sudo yum -y remove python-setuptools

代わりに get-pip.py を使って最新版の pip と setuptools をインストールしてしまう。

$ curl https://bootstrap.pypa.io/get-pip.py | sudo python

次に pip を使って SQLAlchemy をインストールする。

$ sudo pip install SQLAlchemy

mysqlclient の拡張モジュールをビルドするのに必要なパッケージをインストールする。

$ sudo yum -y install mariadb-devel python-devel

そして mysqlclient をインストールする。

$ sudo pip install mysqlclient

マルチバイト文字を含むレコードを追加する

さて、ここからやっと本題に入れる。 SQLAlchemy を使った Python スクリプトを書いてマルチバイト文字を含むレコードを追加したい。

最初のサンプルコードとして、次のようなものを用意した。 User クラスが RDB のテーブルのモデルとなる。 そのインスタンスに「山田太郎」という文字列を入れて永続化を試みている。 ちなみに、これはまだ問題を抱えたプログラムになっている。

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

from __future__ import unicode_literals

from sqlalchemy.engine import create_engine
from sqlalchemy.ext.declarative.api import declarative_base
from sqlalchemy.orm.session import sessionmaker
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import Integer
from sqlalchemy.sql.sqltypes import Text

Base = declarative_base()


class User(Base):
    __tablename__ = 'users'

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


def main():
    engine = create_engine(
        'mysql://root@localhost/sample',
        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='山田太郎')
        session.add(user)


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

また、最初に __future__ パッケージの unicode_literals をインポートしているので、文字列リテラルはすべて unicode 型になっている点に注意が必要。 CentOS 7 の Python はバージョンが 2.7 系なのでマルチバイト文字を扱うときは、これをインポートしておいた方が良い。 プログラムの中で扱う文字列を unicode 型に統一しておくと後からの手間が色々と少なくなる。

さて、それでは上記を実行してみよう。 しかし、これは例外になる。

$ python multibyte0.py
...(省略)...
File "/usr/lib/python2.7/site-packages/sqlalchemy/engine/default.py", line 450, in do_execute
  cursor.execute(statement, parameters)
File "/usr/lib64/python2.7/site-packages/MySQLdb/cursors.py", line 207, in execute
  args = tuple(map(db.literal, args))
File "/usr/lib64/python2.7/site-packages/MySQLdb/connections.py", line 304, in literal
  s = self.escape(o, self.encoders)
File "/usr/lib64/python2.7/site-packages/MySQLdb/connections.py", line 222, in unicode_literal
  return db.literal(u.encode(unicode_literal.charset))
UnicodeEncodeError: 'latin-1' codec can't encode characters in position 0-3: ordinal not in range(256)

上記ではマルチバイト文字が含まれる unicode 型の文字列を latin-1 (ASCII) でエンコードしようとして例外になっている。

であれば、エンコード済みの UTF-8 を書き込めば良いのでは、という発想で行ってみる。 今度は SQLAlchemy の Engine を作る段階で convert_unicode=True をつけてみよう。 ちなみに、この修正でもまだ問題が残っている。

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

from __future__ import unicode_literals

from sqlalchemy.engine import create_engine
from sqlalchemy.ext.declarative.api import declarative_base
from sqlalchemy.orm.session import sessionmaker
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import Integer
from sqlalchemy.sql.sqltypes import Text

Base = declarative_base()


class User(Base):
    __tablename__ = 'users'

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


def main():
    engine = create_engine(
        'mysql://root@localhost/sample',
        echo=True,
        convert_unicode=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='山田太郎')
        session.add(user)


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

実行してみよう。 今度は例外にならない。

$ python multibyte1.py
...(省略)...
2016-06-07 23:20:43,771 INFO sqlalchemy.engine.base.Engine BEGIN (implicit)
2016-06-07 23:20:43,772 INFO sqlalchemy.engine.base.Engine INSERT INTO users (name) VALUES (%s)
2016-06-07 23:20:43,772 INFO sqlalchemy.engine.base.Engine ('\xe5\xb1\xb1\xe7\x94\xb0\xe5\xa4\xaa\xe9\x83\x8e',)
2016-06-07 23:20:43,773 INFO sqlalchemy.engine.base.Engine COMMIT

ちなみに、上記で使われたバイト列は「山田太郎」を UTF-8 でエンコードした内容と一致する。

$ python
>>> from __future__ import unicode_literals
>>> name = u'山田太郎'
>>> name.encode('utf-8')
'\xe5\xb1\xb1\xe7\x94\xb0\xe5\xa4\xaa\xe9\x83\x8e'

これでバッチリでしょうと思いながら MariaDB に入った内容を確認すると、見事に文字化けしている。

$ mysql -u root -D sample -e "SELECT * FROM users\G"
*************************** 1. row ***************************
  id: 1
name: 山田太郎

これは、後述する観測結果から、どうやら mysqlclient と MariaDB の接続に使われる文字コードが latin-1 (ASCII) になっているからのように思える。

じゃあ、どうしたら良いのかというと接続に使う URL の指定に一工夫が必要だった。 データベース名の末尾に「?charset=utf8mb4」という形で文字コードを指定してやらなきゃいけない。

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

from __future__ import unicode_literals

from sqlalchemy.engine import create_engine
from sqlalchemy.ext.declarative.api import declarative_base
from sqlalchemy.orm.session import sessionmaker
from sqlalchemy.sql.schema import Column
from sqlalchemy.sql.sqltypes import Integer
from sqlalchemy.sql.sqltypes import Text

Base = declarative_base()


class User(Base):
    __tablename__ = 'users'

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


def main():
    engine = create_engine(
        'mysql://root@localhost/sample?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='山田太郎')
        session.add(user)


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

ちなみに Engine を作るときの convert_unicode=True については、あってもなくても良さそう。

実行してみる。 今度はユニコード文字列がそのまま書き込まれているようだ。

$ python multibyte2.py
...(省略)...
2016-06-07 23:24:03,562 INFO sqlalchemy.engine.base.Engine BEGIN (implicit)
2016-06-07 23:24:03,563 INFO sqlalchemy.engine.base.Engine INSERT INTO users (name) VALUES (%s)
2016-06-07 23:24:03,563 INFO sqlalchemy.engine.base.Engine (u'\u5c71\u7530\u592a\u90ce',)
2016-06-07 23:24:03,564 INFO sqlalchemy.engine.base.Engine COMMIT

永続化された内容を確認しておこう。 今度はちゃんと文字化けせずにレコードが入っている。

$ mysql -u root -D sample -e "SELECT * FROM users\G"
*************************** 1. row ***************************
  id: 1
name: 山田太郎
*************************** 2. row ***************************
  id: 2
name: 山田太郎

めでたしめでたし、と行きたいところだけど原因をもうちょっと調べておかないとね。 「?charset=utf8mb4」があるときとないときで、何が変わっているのかを見てみよう。 おそらく SQLAlchemy が操作する MySQL ドライバ (mysqlclient) の設定が変わるんだろうなとアタリをつけて調べていった。

ここからは mysqlclient のソースコードについて。 どうやら mysqlclient では mysqlclient.connections.Connection をインスタンス化するときに charset の設定があるらしい。 ちゃんと細部までソースコードを追うことはできていないけど、たぶんこれかな。

https://github.com/PyMySQL/mysqlclient-python/blob/master/MySQLdb/connections.py#L130,L133

Python の REPL で確認してみよう。 まずは「?charset=utf8mb4」がないとき。 作成した SQLAlchemy の Engine からは raw_connection() メソッドでドライバの生のコネクションをラップしたオブジェクトが得られる。

$ python
>>> from MySQLdb.constants import FIELD_TYPE
>>> engine = create_engine('mysql://root@localhost/sample')
>>> raw_conn = engine.raw_connection()

そこからさらに connection メンバを参照すると、ここに生のドライバが入っている。

>>> mysqldb_conn = raw_conn.connection

文字コードは character_set_name() というメソッドで取得した内容が使われる雰囲気。 それを確認すると latin1 となっている。 たしかに、これだとマルチバイト文字は扱えなさそう。 根本的な問題はこれだったはず。

>>> mysqldb_conn.character_set_name()
'latin1'

次にあるとき。 こちらはちゃんと utf8mb4 が指定されている。 やはり接続先 URL に charset の指定があるかないかでドライバの設定が変わっていた。

>>> engine = create_engine('mysql://root@localhost/sample?charset=utf8mb4')
>>> raw_conn = engine.raw_connection()
>>> mysqldb_conn = raw_conn.connection
>>> mysqldb_conn.character_set_name()
'utf8mb4'

めでたしめでたし。

オチ

で、まあここらへんの話って SQLAlchemy の公式ドキュメントにもちゃんと書かれているんだよね。

MySQL — SQLAlchemy 1.1 Documentation

まとめ

  • SQLAlchemy と mysqlclient (MySQLdb) を組み合わせて使うときマルチバイト文字を扱いたいなら接続先 URL で charset を指定しよう
  • 公式ドキュメントはちゃんと読もう

Python: インターネットと疎通がない環境に Python パッケージをインストールする (改)

このブログでは、以前にインターネットと疎通のない環境で Python パッケージをインストールする方法について書いたことがある。 具体的には Wheel でパッケージングした配布物を pip を使ってインストールしていた。

blog.amedama.jp

ただ、上記の記事にはひとつ問題点が残されていた。 そもそも Wheel をインストールするのに必要な pip を OS のパッケージ管理システム (yum) を通してインストールしていたことだ。 このやり方では、OS が提供するパッケージをそのまま使った場合には古いバージョンの pip しか使うことができない。 それに pip 自体も Python のパッケージなので、その管理が yum と pip というふたつのシステムにまたがることになる。

今回は、その解決策がわかったので紹介することにした。 ポイントは get-pip.py を pip コマンドの代わりとして使うことだ。

使った環境は次の通り。

$ cat /etc/redhat-release
CentOS Linux release 7.2.1511 (Core)
$ uname -r
3.10.0-327.18.2.el7.x86_64

下準備

もし OS の提供する pip や setuptools がインストールされているときは、あらかじめ削除しておこう。

$ sudo yum remove -y python-setuptools python-pip

パッケージング (インターネットと疎通のあるホストで実行する)

ここからはインストールしたい Python パッケージなどのパッケージングを行う。

まずは、普段なら pip をインストールするために使う get-pip.py をダウンロードしておこう。

$ wget https://bootstrap.pypa.io/get-pip.py

次に Wheel を入れるディレクトリを作っておく。

$ mkdir wheelhouse

これは単に一箇所にまとめておくために作っている。

そして pip の Wheel ファイルを作って先ほど作ったディレクトリの中に入れる。

$ python get-pip.py wheel pip --no-cache-dir --download wheelhouse

上記の操作で get-pip.py を Python スクリプトとして実行している。 実は get-pip.py は一部の機能は絞られるものの pip コマンドの代わりとして使うことができる。

pip 以外にも、インストールしたい Python パッケージの例として requests の Wheel を用意しておこう。

$ python get-pip.py wheel requests --no-cache-dir --download wheelhouse

用意した Wheel ファイルは次の通り。

$ ls wheelhouse | grep \.whl$
pip-8.1.2-py2.py3-none-any.whl
requests-2.10.0-py2.py3-none-any.whl
setuptools-22.0.0-py2.py3-none-any.whl
wheel-0.29.0-py2.py3-none-any.whl

上記のパッケージをインストールする (インターネットと疎通のない) ホストには、これらと先ほどダウンロードした get-pip.py を持っていく。

インストール (インターネットと疎通のないホストで実行する)

ここからは、先ほど作った Wheel ファイルをインストールする先の話。 本来はインターネットと疎通のないホストで実行することになるけど、ここではさっきビルドしたのと同じところでやってしまうことにする。

インストールにはパッケージングと同じように get-pip.py を使う。 get-pip.py は pip コマンドの代わりになるので、これを使って pip の Wheel パッケージをインストールする。 このとき --no-index オプションと --find-links オプションを併用することで、パッケージの解決が指定したディレクトリに閉じて実行される。

$ sudo python get-pip.py --no-index --find-links=wheelhouse pip

これで pip がインストールできた。

$ pip list | egrep "(pip|setuptools|wheel)"
pip (8.1.2)
setuptools (22.0.0)
wheel (0.29.0)

pip さえインストールできてしまえばこちらのものなので、あとはふつうに pip コマンドで Wheel ファイルをインストールしていく。

$ sudo pip --no-index --find-links=wheelhouse requests

もちろん get-pip.py を使い続けても良いけどね。

無事に requests もインストールできた。

$ pip list | grep requests
requests (2.10.0)

ばっちり。

まとめ

  • Wheel ファイルにはブートストラップ問題がある
  • Wheel ファイルをインストールするための pip をどうインストールするか
  • この問題は get-pip.py を使って解決できる
  • get-pip.py は pip コマンドの代わりとして使える
  • pip の Wheel ファイルを get-pip.py を使ってインストールしよう

Python: skflow を使ってディープラーニングで FizzBuzz 問題を解いてみる

最近 TensorFlow を使ってディープラーニングで FizzBuzz 問題を解くっていうブログ記事を読んだんだけど、これが面白かった。

joelgrus.com

そこで、自分でも同じようにディープラーニングを使って FizzBuzz 問題を解いてみることにした。 ただし、アレンジとして TensorFlow を直接使うのではなく、代わりに skflow を使ってみる。 skflow というのは TensorFlow を scikit-learn と同じインターフェースで扱えるようにしたラッパーだ。 これなら使い慣れた scikit-learn と同じ雰囲気で TensorFlow を使うことができる。

使った環境は次の通り。

$ sw_vers 
ProductName:    Mac OS X
ProductVersion: 10.11.5
BuildVersion:   15F34
$ python --version
Python 3.5.1

下準備

まずは TensorFlow と skflow を pip でインストールする。

$ pip install -U https://storage.googleapis.com/tensorflow/mac/tensorflow-0.8.0-py3-none-any.whl
$ pip install -U git+https://github.com/tensorflow/skflow.git

解いてみる

で、いきなりソースコードなんだけど次のようになった。

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

import skflow
from sklearn import metrics

import numpy as np


def fizzbuzz_encode(n):
    """整数を FizzBuzz の各答えに対応する整数に変換する"""
    # FizzBuzz
    if n % 3 == 0 and n % 5 == 0:
        return 0

    # Fizz
    if n % 3 == 0:
        return 1

    # Buzz
    if n % 5 == 0:
        return 2

    return 3


def fizzbuzz_decode(n, prediction):
    """各答えに対応する整数を文字列の答えに変換する"""
    mappings = ['FizzBuzz', 'Fizz', 'Buzz', str(n)]
    return mappings[prediction]


def vector_encode(n, digits):
    """整数を特徴ベクトルに変換する"""
    return np.array([
        # リストの d 番目の要素として n の d ビット目の値を取り出す
        n >> d & 1
        for d in range(digits)
    ])


def dataset(dataset_len, vector_len, offset=1):
    """FizzBuzz の入力と答えが含まれるデータセットを作成する"""
    x = np.array([
        vector_encode(n, vector_len)
        for n in range(offset, dataset_len)
    ], dtype=np.int32)
    y = np.array([
        fizzbuzz_encode(n)
        for n in range(offset, dataset_len)
    ], dtype=np.int32)

    return x, y


def main():
    # 特徴ベクトルの次元数
    DIGITS = 14
    # ホールドアウト検証に使うテストデータ点数
    HOLDOUT_LEN = 100

    # (2 ^ DIGITS) のサイズでデータセットを作成する
    x, y = dataset(2 ** DIGITS, DIGITS)

    # データセットを学習用データと検証用データに分ける
    train_x, train_y = x[HOLDOUT_LEN:], y[HOLDOUT_LEN:]
    test_x, test_y = x[:HOLDOUT_LEN], y[:HOLDOUT_LEN]

    # DNN 分類器
    classifier = skflow.TensorFlowDNNClassifier(
        hidden_units=[100],
        n_classes=4,
        batch_size=128,
        steps=100000,
        learning_rate=0.05,
    )

    # 学習用データを使って学習する
    classifier.fit(train_x, train_y)

    # 検証用データを使った正答率 (汎化性能) を調べる
    score = metrics.accuracy_score(classifier.predict(test_x), test_y)
    msg = '正答率: {percent:.2f}%'.format(
        percent=score * 100,
    )
    print(msg)

    # ニューラルネットワークの出した答えを表示する
    vectorizer = np.vectorize(fizzbuzz_decode)
    answers = classifier.predict(test_x)
    numbers = np.arange(1, HOLDOUT_LEN + 1)
    output = vectorizer(numbers, answers)
    print(output)


if __name__ == '__main__':
    main()

最初のポイントとしては skflow.TensorFlowDNNClassifier かな。 これが scikit-learn でおなじみの API をもった分類器になっている。 具体的には fit() メソッドで学習したり、predict() メソッドで学習した内容を元に分類できたりするところ。

コンセプトは元ネタのブログとほとんど同じ。 FizzBuzz 問題の入力となる特徴ベクトルはビットが入った長さ N のリストで、例えば 1 => [0, ... 0, 0, 1], 2 => [0, ... 0, 1, 0] みたいなかんじになっている。 そして学習データのラベルには FizzBuzz 問題の答えとして FizzBuzz => 0, Fizz => 1, Buzz => 2, それ以外 => 3 という風にしている。 この入力と答えをデータセットとしてあらかじめたくさん用意しておいて、それを学習用データと検証用データに分けて扱っている。

また、最終的に学習には使わなかった 1 ~ 100 の検証用 (ホールドアウト) データを入力したときの汎化性能 (未知のデータに対する対処能力) を調べている。 ついでに学習したニューラルネットワークが出した答えも出力するようにした。

インスパイア元からの変更点は色々とあるけど、パラメータでいえば特徴ベクトルが 10 次元から 14 次元になっていたり、学習ステップ数が 10 倍になっていたりする。 反面、ニューラルネットワークの隠れ層のユニット数なんかは同じ (100) かな。

動かしてみる

早速実行してみよう。 学習に結構な時間がかかる。

$ python fizzbuzz.py
Step #99, avg. train loss: 1.19849
Step #200, epoch #1, avg. train loss: 1.14770
Step #300, epoch #2, avg. train loss: 1.14635
Step #400, epoch #3, avg. train loss: 1.14502
Step #500, epoch #3, avg. train loss: 1.14393
...
Step #99500, epoch #777, avg. train loss: 0.10758
Step #99600, epoch #778, avg. train loss: 0.11312
Step #99700, epoch #778, avg. train loss: 0.10891
Step #99800, epoch #779, avg. train loss: 0.10846
Step #99900, epoch #780, avg. train loss: 0.10930
Step #100000, epoch #781, avg. train loss: 0.10990
正答率: 100.00%
['1' '2' 'Fizz' '4' 'Buzz' 'Fizz' '7' '8' 'Fizz' 'Buzz' '11' 'Fizz' '13'
 '14' 'FizzBuzz' '16' '17' 'Fizz' '19' 'Buzz' 'Fizz' '22' '23' 'Fizz'
 'Buzz' '26' 'Fizz' '28' '29' 'FizzBuzz' '31' '32' 'Fizz' '34' 'Buzz'
 'Fizz' '37' '38' 'Fizz' 'Buzz' '41' 'Fizz' '43' '44' 'FizzBuzz' '46' '47'
 'Fizz' '49' 'Buzz' 'Fizz' '52' '53' 'Fizz' 'Buzz' '56' 'Fizz' '58' '59'
 'FizzBuzz' '61' '62' 'Fizz' '64' 'Buzz' 'Fizz' '67' '68' 'Fizz' 'Buzz'
 '71' 'Fizz' '73' '74' 'FizzBuzz' '76' '77' 'Fizz' '79' 'Buzz' 'Fizz' '82'
 '83' 'Fizz' 'Buzz' '86' 'Fizz' '88' '89' 'FizzBuzz' '91' '92' 'Fizz' '94'
 'Buzz' 'Fizz' '97' '98' 'Fizz' 'Buzz']

やったー、元ネタと同じように汎化性能で 100% の精度が得られたー。

これはようするに、色んなデータでニューラルネットワークを学習させたら、教えていないデータについても正しい答えが出せるようになったということ。 こちらは FizzBuzz 問題というものが何なのか一切説明していない。 なのに、ただ色んなデータを突っ込んだら、あたかもそれを理解しているように見えるのは面白いね。

といっても、上記は検証用データの点数を 100 に絞ったとき、たまたま全部当たっていたというだけ。 例えば点数を 1024 まで使うと精度は 97% ほどだった。

$ python fizzbuzz.py
Step #99, avg. train loss: 1.20100
Step #200, epoch #1, avg. train loss: 1.14943
Step #300, epoch #2, avg. train loss: 1.14813
Step #400, epoch #3, avg. train loss: 1.14763
Step #500, epoch #4, avg. train loss: 1.13771
...
Step #99500, epoch #829, avg. train loss: 0.09799
Step #99600, epoch #830, avg. train loss: 0.10067
Step #99700, epoch #830, avg. train loss: 0.09853
Step #99800, epoch #831, avg. train loss: 0.10148
Step #99900, epoch #832, avg. train loss: 0.09875
Step #100000, epoch #833, avg. train loss: 0.09879
正答率: 97.07%
['1' '2' 'Fizz' ..., '1022' 'Fizz' '1024']

まあ、ほどほどかな? 問題の難易度に比べると低すぎるような気もする。 学習・検証曲線を見ながら最適なパラメータを選択できれば、もっと上がるのかな。

まとめ

  • skflow は TensorFlow のラッパになっていて scikit-learn と同じ API が使えて便利
  • ニューラルネットワークが FizzBuzz 問題を理解していくさまを見るのは面白い
  • FizzBuzz 問題は分類問題と見なせるので、別に機械学習の分類器なら何でもいけそう

Python: 明示的な相対インポートの使い方

Python のインポートには、次のように3つの種類がある。

  • 暗黙の相対インポート
  • 明示的な相対インポート
  • 絶対インポート

このうち、暗黙の相対インポートについては Python 3 で廃止されたので使ってはいけない。 となると、残るは明示的な相対インポートか絶対インポートのどちらかを使うことになる。 ただ、これまで明示的な相対インポートは使わずに、もっぱら絶対インポートだけを使ってきた。 今回は、これまで明示的な相対インポートを避けていた理由と、その解決策が分かったのでそれについて書いてみる。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.5
BuildVersion:   15F34
$ python --version
Python 3.5.1

下準備

今回は mypackage というパッケージを用意して、その中で作業する。 Python におけるモジュールとパッケージという概念については、このブログでも記事にしたことがあるのでそちらを参照のこと。

blog.amedama.jp

まずは mypackage パッケージを作る。

$ mkdir mypackage
$ touch mypackage/__init__.py

次に、先ほど作った mypackage の中に mymodb というモジュールを追加する。 このモジュールには greet() という関数が用意されている。

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


def greet():
    print('Hello, World!')
EOF

以上で下準備は完了。 今回のポイントは、上記の mymodb モジュールをどのようにインポートして使うべきか、ということ。

絶対インポート

先ほど用意した mymodb モジュールは、同じパッケージに新たに追加する mymoda モジュールから使うことにする。 まずは mymodb モジュールを絶対インポートでインポートするパターンを確認する。

絶対インポートでは、トップレベルのパッケージ名からインポートを記述する。 要するにインポートは from mypackage から始まることになる。

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

from mypackage import mymodb


def main():
    mymodb.greet()


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

絶対インポートを使うときは、次のようにして mymoda モジュールを実行できる。 というか、これまではこうやってきた。 PYTHONPATH は、そのままではパッケージの検索パスが mymoda.py を実行したディレクトリになってしまうので指定している。 これがないと Python インタプリタが mypackage を見つけることができない。

$ PYTHONPATH=. python mypackage/mymoda.py
Hello, World!

ただし、この記事を最後まで読むと上記の実行の仕方は汎用性が低いイマイチなやり方ということがわかる。

明示的な相対インポート

次は、明示的な相対インポートを使うパターン。 明示的な相対インポートでは、その名の通りインポートするモジュールを相対パスのように指定する。 これには、パッケージ名など途中のパスの名前が変わっても変更の手間が少ないというメリットがある。

例えば同じパッケージ以下にあるモジュールであればインポートは from . から始まる。

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

from . import mymodb


def main():
    mymodb.greet()


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

では、これも先ほどの絶対インポートと同じように実行してみよう。

$ PYTHONPATH=. python mypackage/mymoda.py
Traceback (most recent call last):
  File "mypackage/mymoda.py", line 4, in <module>
    from . import modb
ValueError: Attempted relative import in non-package

と、何やら例外になってしまった。

これが、これまで明示的な相対インポートを使うことを避けてきた理由。 開発中に、パッケージ内にある特定のモジュールをどのように実行すれば良いのか分からなかった。

解決策

上記の例外を回避する方法としては python コマンドの -m オプションを使うのが良さげ。 python コマンドの -m オプションを使うと特定のモジュールを指定して実行できる。

つまり、先ほどの例であれば mypackage.mymoda を指定するということ。

$ python -m mypackage.mymoda
Hello, World!

そして、これならパッケージの検索パスがカレントディレクトリになるので PYTHONPATH の指定もいらない。

別の解法としては REPL を起動して呼び出すっていうのもあるかな? でも、これはちょっとめんどくさいね。

$ python
>>> from mypackage import mymoda
>>> mymoda.main()
Hello, World!

ちなみに python コマンドの -m オプションを使うやり方は絶対インポートを使っているときにも通用する。 もう一度 mymoda モジュールを絶対インポートに入れ替えてみよう。

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

from mypackage import mymodb


def main():
    mymodb.greet()


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

先ほどと同様に python コマンドの -m オプションで実行するモジュールを指定する。

$ python -m mypackage.mymoda
Hello, World!

ばっちりだ。

だとすると?

普段から Python で書いたプログラムは python コマンドの -m オプションで実行するクセをつけた方が良いのかも。

例えば次のような単独の Python モジュールがあったとする。

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


def main():
    print('Hello, World!')


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

このときも、もちろん python -m オプションで実行することができる。

$ python -m helloworld
Hello, World!

たぶん、ふつうは Python ファイル (.py) を指定して実行されることが多いんじゃないかな。

$ python helloworld.py
Hello, World!

前者の方が、パッケージまで考えたときに汎用性がありそうだなと。

まとめ

  • Python には3種類のインポートがある

    • 暗黙の相対インポート
    • 絶対インポート
    • 明示的な相対インポート
  • 暗黙の相対インポート

    • Python 3 で使えないので選択してはいけない
  • 絶対インポート

    • トップレベルのパッケージ名からインポートする
    • パッケージ名とか途中の名前が変わると変更に手間がかかる
  • 明示的な相対インポート

    • 今のパッケージから相対的なパスでインポートする
    • パッケージ名とか途中の名前が変わっても変更の手間が少ない
  • 明示的な相対インポートを使うと Python ファイル (.py) を指定して実行できない

    • 代わりに python -m <package.module> を使う
    • むしろ絶対インポートでも上記は使える
    • 普段からこれで実行すると良い気がする

C: 静的ライブラリと共有ライブラリについて

C 言語で書かれた静的ライブラリと共有ライブラリについて、いまいち理解がちゃんとしていなかったのでまとめておく。 ライブラリというのは、複数のアプリケーションで使われるような共通の機能をまとめたものをいう。

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

$ cat /etc/lsb-release 
DISTRIB_ID=Ubuntu
DISTRIB_RELEASE=20.04
DISTRIB_CODENAME=focal
DISTRIB_DESCRIPTION="Ubuntu 20.04.3 LTS"
$ uname -rm
5.11.0-1021-gcp x86_64
$ gcc --version
gcc (Ubuntu 9.3.0-17ubuntu1~20.04) 9.3.0
Copyright (C) 2019 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
$ ar --version
GNU ar (GNU Binutils for Ubuntu) 2.34
Copyright (C) 2020 Free Software Foundation, Inc.
This program is free software; you may redistribute it under the terms of
the GNU General Public License version 3 or (at your option) any later version.
This program has absolutely no warranty.
$ ldd --version
ldd (Ubuntu GLIBC 2.31-0ubuntu9.2) 2.31
Copyright (C) 2020 Free Software Foundation, Inc.
This is free software; see the source for copying conditions.  There is NO
warranty; not even for MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.
Written by Roland McGrath and Ulrich Drepper.

もくじ

下準備

まずはコンパイルやリンクに必要なパッケージを一通りインストールしておく。

$ sudo apt-get -y install build-essential

次に、ライブラリにするソースコードを用意する。 今回は、次のように greet() という関数を提供するライブラリを作ることにした。

$ cat << 'EOF' > greet.c     
#include <stdio.h>
#include <stdlib.h>

int greet(void)
{
    printf("Hello, World!\n");
    return EXIT_SUCCESS;
}
EOF

上記のライブラリのヘッダファイルを用意する。

$ cat << 'EOF' > greet.h 
extern int greet(void);
EOF

そして、そのライブラリを使うアプリケーションのソースコードも用意しておく。 こちらもシンプルに、ライブラリの提供する greet() 関数を呼び出すだけのものにした。

$ cat << 'EOF' > main.c
#include <stdlib.h>
#include "greet.h"

int main(int argc, char *argv[])
{
    greet();
    return EXIT_SUCCESS;
}
EOF

静的ライブラリ

まずは静的ライブラリから作ってみる。 静的ライブラリというのは、それを使うアプリケーションに同梱する形で使われるライブラリのことをいう。

まずはライブラリのソースコードをコンパイルする。

$ gcc -c greet.c

コンパイルするとオブジェクトファイル (.o) ができる。

$ ls | grep \.o$
greet.o
$ file greet.o 
greet.o: ELF 64-bit LSB relocatable, x86-64, version 1 (SYSV), not stripped

静的ライブラリを作るには ar コマンドを使ってオブジェクトファイルをまとめる。 ライブラリのファイル名は lib から始まるようにする。 今回の場合は libgreet になる。

$ ar rsv libgreet.a greet.o
ar: creating libgreet.a
a - greet.o

これで静的ライブラリ (.a) ができた。

$ ls | grep \.a$
libgreet.a
$ file libgreet.a 
libgreet.a: current ar archive

中にまとめられているオブジェクトファイルは ar コマンドの t オプションで確認できる。

$ ar tv libgreet.a
rw-r--r-- 0/0   1688 Jan  1 00:00 1970 greet.o

静的ライブラリを使って実行ファイルを作る

静的ライブラリの用意ができたので、それを使って実行ファイルを作ってみよう。

静的ライブラリを使うには、それを使うアプリケーションをコンパイルするときに -l オプションを指定する。 指定するのは lib を除いたライブラリの名前、つまり今回の場合は greet になる。 先ほど作った静的ライブラリはカレントディレクトリにあるので、その場所を -L オプションで指定するのもお忘れなく。

$ gcc -o main main.c -L. -lgreet

これで静的ライブラリを使った実行ファイルができた。

$ file main
main: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=cf903474bb67cf497ea476b664d78fb2f703709b, for GNU/Linux 3.2.0, not stripped

実行すると、ちゃんと greet() 関数の結果が出力される。

$ ./main
Hello, World!

静的ライブラリをリンカのパスが通った場所に置く

先ほどの例では、静的ライブラリがカレントディレクトリにあったので -L オプションでその場所を指定しなければいけなかった。 ただ、これを毎回やっていてはめんどくさい。 なので、作った静的ライブラリをリンカのパスの通った場所に置いてしまおう。

パスの場所は ldconfig コマンドに -v オプションを指定して実行すればわかる。 静的ライブラリを見つけて、それを使うのはリンカ (ld) なので、その設定をするのが ldconfig コマンドということになる。

$ sudo ldconfig -v | grep -v $'\t'
/sbin/ldconfig.real: Can't stat /usr/local/lib/x86_64-linux-gnu: No such file or directory
/sbin/ldconfig.real: Path `/usr/lib/x86_64-linux-gnu' given more than once
/sbin/ldconfig.real: Path `/lib/x86_64-linux-gnu' given more than once
/sbin/ldconfig.real: Path `/usr/lib/x86_64-linux-gnu' given more than once
/sbin/ldconfig.real: Path `/usr/lib' given more than once
/usr/lib/x86_64-linux-gnu/libfakeroot:
/usr/local/lib:
/lib/x86_64-linux-gnu:
/sbin/ldconfig.real: /lib/x86_64-linux-gnu/ld-2.31.so is the dynamic linker, ignoring

/lib:

少し話が脱線するけど、リンカがライブラリを見つける場所は /etc/ld.so.conf にもとづいて決められる。 見ると、どうやら /etc/ld.so.conf.d にある *.conf ファイルを読み込むようになっているようだ。

$ cat /etc/ld.so.conf
include /etc/ld.so.conf.d/*.conf

例えば、この中で libc.conf というのも見ると /usr/local/lib という場所をリンカが見るように設定している。

$ cat /etc/ld.so.conf.d/libc.conf
# libc default configuration
/usr/local/lib

その内容にもとづいて、先ほど作った静的ライブラリを /usr/local/lib ディレクトリに移動してみよう。

$ sudo mv libgreet.a /usr/local/lib/

今度は gcc コマンドを実行するときに -L オプションをつけない。 パスが通っている場所に静的ライブラリを置いているので、リンカは libgreet.a を自動的に見つけることができる。

$ gcc -o main main.c -lgreet

もちろん、これでうまく実行ファイルを作ることができる。

$ ./main
Hello, World!

完成した実行ファイルの依存ライブラリを確認するには ldd コマンドを使う。 静的ライブラリは実行ファイルに同梱されるため、ここには表示されないことを確認しておこう。

$ ldd main
    linux-vdso.so.1 (0x00007fff5a6b0000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f38e2463000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f38e2663000)

共有ライブラリ

次は共有ライブラリを使ってみる。 共有ライブラリは静的ライブラリと違って、実行ファイルに通常であれば同梱されない。 静的ライブラリの内容は、実行ファイルを実行するときにライブラリの場所を解決した上で呼び出される。

共有ライブラリを作るときは -shared オプションをつけてコンパイルする。 -fPIC は、つけておくと色々と有利になるらしい。

$ gcc -shared -fPIC -o libgreet.so greet.c

これで共有ライブラリ (.so) ができる。

$ ls | grep \.so$
libgreet.so
$ file libgreet.so 
libgreet.so: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, BuildID[sha1]=2ef213e24e03355a619ea26e40baa94c255c9aa9, not stripped

共有ライブラリを使って実行ファイルを作る

共有ライブラリを使って実行ファイルを作る方法は、静的ライブラリを使ったときと何ら変わらない。 -l オプションでライブラリ名を指定して -L オプションで静的ライブラリの場所を指定する。

$ gcc -o main main.c -L. -lgreet

これで実行ファイルができる。

$ file main
main: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=bb243320b69318e19e0b45975686aedba26c2b62, for GNU/Linux 3.2.0, not stripped

それでは、できあがった実行ファイルを実行してみよう。 が、これは何やらエラーになる。

$ ./main
./main: error while loading shared libraries: libgreet.so: cannot open shared object file: No such file or directory

これは実行ファイルが必要とする共有ライブラリをリンカが見つけることができないためだ。 ldd コマンドを使って実行ファイルが依存する共有ライブラリを調べると、たしかに libgreet.so を見つけることができていない。

$ ldd main
    linux-vdso.so.1 (0x00007ffd3efa5000)
    libgreet.so => not found
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007fd038c7d000)
    /lib64/ld-linux-x86-64.so.2 (0x00007fd038e7d000)

これは例えば LD_LIBRARY_PATH という変数でライブラリの場所を指定することで解決できる。

$ LD_LIBRARY_PATH=. ./main
Hello, World!

この変数を指定すると共有ライブラリの場所が解決できる。

$ LD_LIBRARY_PATH=. ldd main
    linux-vdso.so.1 (0x00007ffed05ef000)
    libgreet.so => ./libgreet.so (0x00007f14dc07d000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f14dbe84000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f14dc089000)

共有ライブラリをリンカのパスが通った場所に置く

そうはいっても毎回 LD_LIBRARY_PATH を指定するのはありえない。 なので、リンカのパスが通った場所に共有ライブラリを置いてやろう。

$ sudo mv libgreet.so /usr/local/lib/

しかし、ただ置いただけでは認識されない。

$ ldd ./main
    linux-vdso.so.1 (0x00007fff31ffd000)
    libgreet.so => not found
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f2a03d2f000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f2a03f2f000)

ldconfig コマンドに -p オプションを指定すると、ロード済みの共有ライブラリが得られる。 こちらにも表示されていない。

$ ldconfig -p | grep libgreet

リンカに共有ライブラリを認識させるには ldconfig をルート権限で実行する必要がある。

$ sudo ldconfig

これで libgreet.so が解決できるようになった。

$ ldconfig -p | grep libgreet
    libgreet.so (libc6,x86-64) => /usr/local/lib/libgreet.so
$ ldd ./main
    linux-vdso.so.1 (0x00007ffd64ac5000)
    libgreet.so => /usr/local/lib/libgreet.so (0x00007f08499aa000)
    libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f08497b8000)
    /lib64/ld-linux-x86-64.so.2 (0x00007f08499be000)

これで晴れて先ほどの実行ファイルが実行できるようになる。

$ ./main
Hello, World!

また、リンカに -L オプションでライブラリの場所を伝えなくても実行ファイルがコンパイルできるようになる。

$ gcc -o main main.c -lgreet

このように、実行時に共有ライブラリを解決するやり方を動的リンクという。

共有ライブラリを静的リンクして使う

共有ライブラリの内容を、あらかじめ実行ファイルに同梱してしまう方法もある。 これなら、実行時に共有ライブラリの場所を解決する必要がなくなる。 これは静的リンクという。

やり方は、実行ファイルをコンパイルするときに --static オプションをつける。

$ gcc --static -o main main.c -lgreet

すると実行ファイルは何の共有ライブラリにも依存しなくなる。

$ ldd main
    not a dynamic executable

この実行ファイルは、いずれの共有ライブラリが入っていなくても実行できる。

$ ./main
Hello, World!

ただし、弱点もある。 実行ファイルに共有ライブラリを同梱する関係で、ファイルサイズが大きくなってしまう。 例えば、今回であれば 852kB になった。

$ du -h main
852K    main

共有ライブラリを使う場合と比べてみよう。

$ gcc -o main main.c -lgreet

共有ライブラリを使った実行ファイルは 20kB で済んでいる。

$ du -h main
20K main

また、LGPL などのように、静的リンクしてしまうとアプリケーションの全てのソースコードがそのライセンスに適用されてしまうケースもあるので注意が必要だ。

まとめ

  • アプリケーションの共通機能はライブラリにまとめることができる
  • ライブラリには静的ライブラリと共有ライブラリがある
  • 静的ライブラリはオブジェクトファイルをまとめて作る
  • 静的ライブラリは実行ファイルに同梱される
  • 共有ライブラリは実行時に解決する使い方 (動的リンク) とコンパイル時に解決する使い方 (静的リンク) がある
  • 静的リンクするときはファイルサイズやライセンスの扱いに注意が必要となる

いじょう。

Python: pipdeptree でパッケージの依存関係を調べる

Python のパッケージ同士が、どのように依存しているかを調べるのは意外と面倒くさい。 そんなときは今回紹介する pipdeptree を使うと楽ができそう。

使った環境は次の通り。

$ sw_vers
ProductName:    Mac OS X
ProductVersion: 10.11.5
BuildVersion:   15F34
$ python --version
Python 3.5.1

下準備

まずは pip を使って pipdeptree をインストールしておく。

$ pip install pipdeptree

使ってみる

インストールすると pipdeptree コマンドが使えるようになる。 このコマンドを使うと、今の Python 実行環境にインストールされているパッケージの依存関係が得られる。

$ pipdeptree
wheel==0.29.0

今回は virtualenv を使って仮想環境を作った直後にインストールしている。 先ほど wheel しか表示されなかったのはそのため。

$ pip list
pip (8.1.2)
pipdeptree (0.6.0)
setuptools (21.2.1)
wheel (0.29.0)

次に、依存パッケージがそれなりに多いものとして、試しに Flask をインストールしてみよう。

$ pip install Flask

もう一度 pipdeptree を実行すると、インストールした Flask の依存関係が表示される。

$ pipdeptree
Flask==0.10.1
  - itsdangerous [required: >=0.21, installed: 0.24]
  - Jinja2 [required: >=2.4, installed: 2.8]
    - MarkupSafe [installed: 0.23]
  - Werkzeug [required: >=0.7, installed: 0.11.9]
wheel==0.29.0

要求するバージョンとインストールされているバージョンが表示されるのが親切だね。

バージョンの衝突を検出する

Python を扱っていて、たまに悩ましいのがパッケージが使っているバージョンがそれぞれ違ったりするところ。 pipdeptree では、それらの衝突も検出できる。

例えば Flask はテンプレートエンジンとして Jinja2 を依存パッケージとしている。 そして Flask のバージョンが 0.10.1 であれば Jinja2 はバージョン 2.4 以上が必要だ。 そこで、試しに Jinja2 のバージョンを 2.4 未満にダウングレードさせてみよう。

$ pip install -U "Jinja2<2.4"

この状況で改めて pipdeptree を実行してみる。 すると、バージョンがコンフリクトしていることが警告として得られた。

$ pipdeptree
Warning!!! Possibly conflicting dependencies found:
* Flask==0.10.1
 - Jinja2 [required: >=2.4, installed: 2.3.1]
------------------------------------------------------------------------
Flask==0.10.1
  - itsdangerous [required: >=0.21, installed: 0.24]
  - Jinja2 [required: >=2.4, installed: 2.3.1]
  - Werkzeug [required: >=0.7, installed: 0.11.9]
wheel==0.29.0

まとめ

  • pipdeptree を使うとパッケージの依存関係がわかる
  • さらに、依存しているパッケージのバージョンがコンフリクトしていないかもチェックできる