Django 1.7で稼働中のアプリケーションをDjango 1.8にアップデートする

  • 19
    いいね
  • 0
    コメント
この記事は最終更新日から1年以上が経過しています。

これは何?

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_NAMETESTに変更

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_stringTemplateで置き換えれば簡単に解決できました。

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()に変えればよさそうだったので、この機会に全部修正することができました。

DateTimeFielddefault, 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_atNoneで、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_atNULLを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について書く予定です。

この投稿は mixiグループ Advent Calendar 20152日目の記事です。