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

## これは何？

[mixiグループアドベントカレンダー](http://qiita.com/advent-calendar/2015/mixi)2日目です。

ミクシィグループが運営する[「チケットキャンプ」](https://ticketcamp.net/)は、サーバーサイドはPython, [Django](https://www.djangoproject.com/)で開発をしています。

現在使っているDjangoのバージョンは1.7.11なのですが、これをDjango 1.8までアップデートする際に行った作業のメモです。

チケットキャンプ特有の部分もあるかとは思いますが、Djangoのアップデートを検討している方の何かの参考になれば幸いです。

## チケットキャンプのバージョンアップのポリシー

そもそもチケットキャンプのフレームワーク・ライブラリのバージョンアップのポリシーがどうなっているかというと、多少僕の勘に依存している部分があり、明文化するのが難しいですが、概ね以下の3つのルールで説明できます。

- セキュリティアップデートは出来る限り早期に適用
- Djangoのメジャーアップデートには出来る限り追従
- それ以外に理由がない限りアップデートしない

三番目の「特に理由がない限り」というのが曖昧で、「Xという機能を使いたいが、その場合にはYというライブラリを最新版にする必要があります」のようなケースでは理由は明確でしょうが、「画像アップロードAPIを修正する案件があったので、ついでにPillowもアップデートします」のような「ついでに上げてみた」という以上の深い理由がないようなケースもあります。

ともあれ、強調しておきたいのは「バージョンアップ禁止」というようなポリシーでは開発していないということです。

二番目の「メジャーアップデートに出来る限り追従」というのは、Djangoが1.0になる以前から使ってきた身として、常に最新版に追従していった方がアプリケーションの健全性を保てるし、いざアップデートするとなった時に結局楽だからという経験則に基いています。

そういった観点から、[Django 1.8のベータの段階](https://www.djangoproject.com/weblog/2015/feb/25/releases/)からアップデートをしたかったのですが、いくつかアップデートの障害となる点があってずっと手付かずでいました。そうこうしているうちに、[Django 1.9 RCまで出てしまった](https://www.djangoproject.com/weblog/2015/nov/16/django-19rc1-released/)ので、これはマズいということでようやく本気でバージョンアップに取り組み始めた次第です。

そして、この記事の公開ボタンを押そうというタイミングでDjangoのサイトを確認したら、[もうDjango 1.9が公開されていました](https://www.djangoproject.com/weblog/2015/dec/01/django-19-released/)・・・

## Django 1.8へのアップデートへ必要だった修正
### Celeryを最新版にアップデート
チケットキャンプではジョブキューにPythonでは定番の[Celery](http://www.celeryproject.org/)を使っており、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`を使うように変わった](https://docs.djangoproject.com/en/1.8/ref/settings/#test)ので、このタイミングで修正しておきます。

修正前。

```py
DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.mysql',
        'USER': 'root',
        'NAME': 'ticketcamp_dev',
        'TEST_NAME': 'test_ticketcamp_dev',
    },
}
```

修正後。

```py
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オブジェクトを使うパターン](https://docs.djangoproject.com/en/1.4/topics/auth/#storing-additional-information-about-users)が今でも残っています。

以下のようなコードです。

```py
# -*- 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.
```

ここに至って初めて知ったのですが、そもそも上のコードは、

```py
# -*- 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`としてモデルの作成日時、更新日時が自動的に更新されるようになっていました。

```py
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`カラムは更新されません。

```py
$ 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`シグナル](https://docs.djangoproject.com/en/1.8/ref/signals/#post-save)を使って`updated_at`を更新するような方法を検討してみましたが、原因を見つけるのが難しい不具合を生みそうだと思ったので見送ることにしました。

後者、`DateTimeField(auto_now=True)`を選択した場合のデメリットは、インスタンスを作成した時点では`updated_at`は`None`で、`save()`を呼び出した時点で初めて現在時刻がインスタンスの`updated_at`プロパティにも反映されるような挙動に変わってしまうということです。

```py
$ 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`を参照するようなコードがあると動作しなくなるケースがあるということです。

とはいえ、どの方法も一長一短があり、結局のところ最後の方法が、フレームワーク作成者が想定している使い方に則っていると思われたので、最後の方法を採用し、以下のような感じで変更することにしました。

```py
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を追加する
ユニットテストを実行すると、

```shell-session
$ python manage.py test myapp
# 略
django.db.utils.IntegrityError: (1215, 'Cannot add foreign key constraint')
```

というエラーになるようになってしまいました。

原因調査にだいぶ手間取ったのですが、どうやら各Django Appディレクトリの下に`migrations`が存在しないために、ユニットテスト用データベース作成時にスキーマが正しく反映されないことが原因のようです。

チケットキャンプはDjango 1.7で[Migrations](https://docs.djangoproject.com/en/1.8/topics/migrations/)が導入される以前から開発しており、Django 1.7にアップデートした際にも「Migrationsは使う予定がないからいらない」と思って`manage.py makemigrations`をしてこなかったのですが、いよいよMigrationsを使う必要が出てきたようです。

### fixturesのYAMLを修正する
Django標準のテストランナーに戻し、手動でテスト用データベースを作った上で、

```shell-session
$ 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](https://factoryboy.readthedocs.org/en/latest/)のようなライブラリに移行する。

といった修正方法を検討しています。

### 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](https://pypi.python.org/pypi/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へ追従していく必要性を感じています。

- [Python 2.7で稼働中のDjangoアプリケーションをPython 3.5へ移行する](http://qiita.com/csakatoku/items/0ccbee436f11c68a9cec)

なんとか年内には決着をつけて、気持よく新年を迎えたいと思っています。

次は[akkumaさん](http://qiita.com/akkuma)がAndroidのActionModeについて書く予定です。