これは何?
mixiグループアドベントカレンダー2日目です。
ミクシィグループが運営する「チケットキャンプ」は、サーバーサイドはPython, Djangoで開発をしています。
現在使っているDjangoのバージョンは1.7.11なのですが、これをDjango 1.8までアップデートする際に行った作業のメモです。
チケットキャンプ特有の部分もあるかとは思いますが、Djangoのアップデートを検討している方の何かの参考になれば幸いです。
チケットキャンプのバージョンアップのポリシー
そもそもチケットキャンプのフレームワーク・ライブラリのバージョンアップのポリシーがどうなっているかというと、多少僕の勘に依存している部分があり、明文化するのが難しいですが、概ね以下の3つのルールで説明できます。
- セキュリティアップデートは出来る限り早期に適用
- Djangoのメジャーアップデートには出来る限り追従
- それ以外に理由がない限りアップデートしない
三番目の「特に理由がない限り」というのが曖昧で、「Xという機能を使いたいが、その場合にはYというライブラリを最新版にする必要があります」のようなケースでは理由は明確でしょうが、「画像アップロードAPIを修正する案件があったので、ついでにPillowもアップデートします」のような「ついでに上げてみた」という以上の深い理由がないようなケースもあります。
ともあれ、強調しておきたいのは「バージョンアップ禁止」というようなポリシーでは開発していないということです。
二番目の「メジャーアップデートに出来る限り追従」というのは、Djangoが1.0になる以前から使ってきた身として、常に最新版に追従していった方がアプリケーションの健全性を保てるし、いざアップデートするとなった時に結局楽だからという経験則に基いています。
そういった観点から、Django 1.8のベータの段階からアップデートをしたかったのですが、いくつかアップデートの障害となる点があってずっと手付かずでいました。そうこうしているうちに、Django 1.9 RCまで出てしまったので、これはマズいということでようやく本気でバージョンアップに取り組み始めた次第です。
そして、この記事の公開ボタンを押そうというタイミングでDjangoのサイトを確認したら、もうDjango 1.9が公開されていました・・・
Django 1.8へのアップデートへ必要だった修正
Celeryを最新版にアップデート
チケットキャンプではジョブキューにPythonでは定番のCeleryを使っており、AWS SNS経由でのプッシュ通知といった外部とのネットワークI/Oが必要な処理や、チケットの出品・リクエストのマッチング処理など多少時間のかかる処理を、Celeryのワーカープロセスで処理するようにしています。
Django 1.8にしたところ、今まで使っていたcelery==3.1.7
ではエラーが発生したので、最新版のcelery==3.1.9
にアップデートしました。
pip install --upgrade celery==3.1.9
Celeryは非常に安定しており、ユーザーも多いので、マイナーアップデートに関してはあまりリスクを感じていません。
django_noseを最新版にアップデート
ユニットテストのテストランナーには、Django標準のものではなく、django_nose
のテストランナーを使っています。
こちらも少し古めのバージョンに固定されていたためエラーが発生したので、最新版のdjango_nose==1.4.2
にアップデートしました。
pip install --upgrade django_nose==1.4.2
TEST_NAME
をTEST
に変更
Django 1.7からテスト用データベースの設定方法がTEST_
プレフィックスからTEST
という独立したdict
を使うように変わったので、このタイミングで修正しておきます。
修正前。
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'USER': 'root',
'NAME': 'ticketcamp_dev',
'TEST_NAME': 'test_ticketcamp_dev',
},
}
修正後。
DATABASES = {
'default': {
'ENGINE': 'django.db.backends.mysql',
'USER': 'root',
'NAME': 'ticketcamp_dev',
'TEST': {
'NAME': 'test_ticketcamp_dev',
},
},
}
loader.get_template_from_string
が消えた
チケットキャンプにはdjango.template.loader.get_template_from_string
というメソッドを使っている箇所が一箇所だけありました。
最小限で示すと、次のようなコードです。
from django.template import loader, Context
tmpl = loader.get_template_from_string(content)
output = tmpl.render(Context(ctxt))
このようなコードが、
AttributeError: 'module' object has no attribute 'get_template_from_string'
というエラーを出すようになってしまいました。
どうやらdjango.template.loader.get_template_from_string
がDjango 1.8では消されてしまったようです。
このように、いきなりメソッドが消されることはDjangoでは珍しく、パブリックなAPIでは絶対に起こりません。通常は、Django 1.xで撤廃されるというDeprecatedWarning
が出力されるので、普段開発していて自ずと気づくようになっています。
ということは、このメソッドはパブリックメソッドではなく、プライベートメソッド扱いだったということなのでしょう。
幸いに今回のケースではloader.get_template_from_string
をTemplate
で置き換えれば簡単に解決できました。
from django.template import Template, Context
tmpl = Template(content)
output = tmpl.render(Context(ctxt))
ForeignKey(unique=True)
の警告が出るようになってしまった
チケットキャンプでは、ユーザー登録・ユーザー認証にdjango.contrib.auth
を使っていますが、Django 1.4の時代から開発してきた名残で、ユーザーに追加情報を持たせるためにProfileオブジェクトを使うパターンが今でも残っています。
以下のようなコードです。
# -*- coding: utf-8 -*-
# このコードには罠があるので真似しないこと!
from django.conf import settings
from django.db import models
class Profile(models.Model):
user = models.ForeignKey(settings.AUTH_USER_MODEL, unique=True)
# 省略
Django 1.8にアップデートしてCeleryのワーカーを動かすと、以下のような警告が出るようになってしまいました。
myapp.Profile.user: (fields.W342) Setting unique=True on a ForeignKey has the same effect as using a OneToOneField.
HINT: ForeignKey(unique=True) is usually better served by a OneToOneField.
ここに至って初めて知ったのですが、そもそも上のコードは、
# -*- coding: utf-8 -*-
from django.conf import settings
from django.db import models
class Profile(models.Model):
user = models.OneToOneField(settings.AUTH_USER_MODEL)
# 省略
のようにForeignKey(unique=True)
ではなく、OneToOneField()
を使うべきだったようです。しかも最初の段階で勘違いしたために、同じような警告が出る箇所が散見されました。
警告内のヒントにある通りにOneToOneField()
に変えればよさそうだったので、この機会に全部修正することができました。
DateTimeField
のdefault
, auto_now
, auto_now_add
が排他的になった
ここからがアップデートの困難の本番です。
チケットキャンプのモデルの定義は、概ね以下のようなパターンになっていて、created_at
, updated_at
としてモデルの作成日時、更新日時が自動的に更新されるようになっていました。
from django.db import models
class Ticket(models.Model):
price = models.IntegerField()
count = models.IntegerField()
created_at = models.DateTimeField(default=timezone.now, auto_now_add=True)
updated_at = models.DateTimeField(default=timezone.now, auto_now=True)
このようなモデル定義を含んだコードを、Django 1.8で動かそうとすると、次のようなエラーのためにCeleryのワーカーが起動しなくなってしまいました。
myapp.Ticket.created_at: (fields.E160) The options auto_now, auto_now_add, and default are mutually exclusive. Only one of these options may be present.
myapp.Ticket.updated_at: (fields.E160) The options auto_now, auto_now_add, and default are mutually exclusive. Only one of these options may be present.
原因はエラーメッセージにある通り、auto_now
, auto_now_add
, default
は相互に排他的なオプションになり、同時に指定できなくなったためです。
この問題の解決のために以下のような解決策を考えました。
- 以前と同じような、デフォルトで現在時刻が入り、モデル保存時に作成日時・更新日時が更新されるような
DateTimeField
を自前で作る。 - モデルの定義は
updated_at = models.DateTimeField(default=timezone.now)
とし、MySQL上のスキーマの定義をupdated_at DATETIME DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
のように強制する。 -
updated_at
の初期値がNone
になってしまうことを許容する。
一番目の選択肢は、自分で出来る限りコードを書きたくないという理由と、Djangoがこのような変更を加えたということはauto_now
, auto_now_add
, default
を同時に指定して辻褄が合うような実装を作るのは難しいに違いないという推測から、あまり深追いせず諦めました。
二番目の選択肢は、コードの変更が少なくて、アプリケーションへの影響も小さいだろうという予測があったのですが、「Djangoのモデルで表現できないスキーマは出来る限り定義しない」という今まで採ってきた設計原則から一旦保留にしました。
今は三番目の方針で修正して、アップデートのテストを行っています。
まず上のモデルの例だと、created_at
の方は安全にauto_now_add=True
のオプションは削除できます。
モデルのインスタンスを作った時点で、default
オプションのtimezone.now
が評価されてcreated_at
の値は現在時刻になり、save()
を呼び出した時にその値がDBに保存されることになるからです。(そう考えるとそもそもauto_now_add=True
は不要だったのではないかと思います。)
問題はupdated_at
の方で、
updated_at = models.DateTimeField(default=timezone.now)
にするか、
updated_at = models.DateTimeField(auto_now=True)
にするか選ばないといけません。
前者にした場合は、インスタンスを作った時にはupdated_at
は非None
になっていますが、save()
してもデーターベースのupdated_at
カラムは更新されません。
$ python manage.py shell
>>> from myapp.models import Ticket
>>> ticket = Ticket(price=1000, count=1)
>>> ticket.updated_at is not None
True
>>> ticket.save()
>>> ticket.price = 1500
>>> ticket.save() # 変更したけどDBのupdated_atは更新されない
post_save
シグナルを使ってupdated_at
を更新するような方法を検討してみましたが、原因を見つけるのが難しい不具合を生みそうだと思ったので見送ることにしました。
後者、DateTimeField(auto_now=True)
を選択した場合のデメリットは、インスタンスを作成した時点ではupdated_at
はNone
で、save()
を呼び出した時点で初めて現在時刻がインスタンスのupdated_at
プロパティにも反映されるような挙動に変わってしまうということです。
$ python manage.py shell
>>> from myapp.models import Ticket
>>> ticket = Ticket(price=1000, count=1)
>>> ticket.updated_at is not None
False # updated_atはまだNone
>>> ticket.save()
>>> ticket.updated_at is not None
True # save()を呼んだ後に初めて非Noneになる
つまり、save()
以前にインスタンスのupdated_at
を参照するようなコードがあると動作しなくなるケースがあるということです。
とはいえ、どの方法も一長一短があり、結局のところ最後の方法が、フレームワーク作成者が想定している使い方に則っていると思われたので、最後の方法を採用し、以下のような感じで変更することにしました。
from django.db import models
class Ticket(models.Model):
price = models.IntegerField()
count = models.IntegerField()
created_at = models.DateTimeField(default=timezone.now)
updated_at = models.DateTimeField(auto_now=True)
明らかなエラーが消えたので、動作確認を開始したところ、概ね正常に動作していそうに見えます。後はsave()
前にupdated_at
を参照してエラーになっているところを動かせば、なんとかアップデート完了かと思ったのですが、まだ落とし穴が残っていました・・・
未解決の問題
本当はこのアドベントカレンダーを公開するまでにアップデート完了というところまで行きたかったのですが、まだ未解決・原因不明の問題がいくつか残ってしまっています。
migrationsを追加する
ユニットテストを実行すると、
$ python manage.py test myapp
# 略
django.db.utils.IntegrityError: (1215, 'Cannot add foreign key constraint')
というエラーになるようになってしまいました。
原因調査にだいぶ手間取ったのですが、どうやら各Django Appディレクトリの下にmigrations
が存在しないために、ユニットテスト用データベース作成時にスキーマが正しく反映されないことが原因のようです。
チケットキャンプはDjango 1.7でMigrationsが導入される以前から開発しており、Django 1.7にアップデートした際にも「Migrationsは使う予定がないからいらない」と思ってmanage.py makemigrations
をしてこなかったのですが、いよいよMigrationsを使う必要が出てきたようです。
fixturesのYAMLを修正する
Django標準のテストランナーに戻し、手動でテスト用データベースを作った上で、
$ python manage.py test myapp --keepdb
のように--keepdb
オプション付きでテストを実行したところ、テストは実行できました。
ただし、事前に予測していた通り、updated_at
のモデルの定義が変わったので、updated_at
にNULL
をINSERTしようとしてfixturesを読み込めない状態になっていました。
この問題に関しては、
- すべてのfixturesのYAMLを修正する。
- テスト時だけ
pre_save
シグナルでupdated_at
が非NULLになるようにする。 - YAMLでfixturesを作るのをやめて、Factory Boyのようなライブラリに移行する。
といった修正方法を検討しています。
Django 1.9で撤廃される関数の警告
Django 1.8にしたら1.9で撤廃される予定の関数の警告が大量に出るようになったので、一つ一つ潰していっている最中です。
django.core.cache.get_cache
の警告。
RemovedInDjango19Warning: 'get_cache' is deprecated in favor of 'caches'
修正前。
from django.core.cache improt get_cache
get_cache('default').get('key')
修正後。これは機械的に置換しました。
from django.core.cache improt caches
caches['default'].get('key')
django.utils.functional.memoize
の警告。
RemovedInDjango19Warning: memoize wrapper is deprecated and will be removed in Django 1.9. Use django.utils.lru_cache instead.
django.utils.datastructures.SortedDict
の警告。これは自分たちで書いたコードというより、使っているdjango-redisのバージョンがかなり古いために起こっているので、バージョンアップが必要そうです。
RemovedInDjango19Warning: SortedDict is deprecated and will be $
emoved in Django 1.9.
Djangoのモデルをpickle
する時に発生する警告。
RuntimeWarning: Pickled model instance's Django version is not specified.
最後に
まだまだアップデートに道半ばといった感じですが、Djangoのリリースポリシーだと現在使っている1.7は2.0が出た時点でメンテナンス終了になってしまうため、次期LTS版である1.8には絶対に移行する必要があります。
また、Python 2.7からPython 3.5への移行が可能かどうかの調査も進めており、Python 3.5への移行のためにも最新版のDjangoへ追従していく必要性を感じています。
なんとか年内には決着をつけて、気持よく新年を迎えたいと思っています。
次はakkumaさんがAndroidのActionModeについて書く予定です。