Python3.4 & Django1.8な個人アプリを、Python3.7 & Django 2.1 へとアップデートした

以前、食べたリンゴの割合をグラフ化するHerokuアプリを作りました。
Python + Django + Highcharts + Herokuで食べたリンゴの割合をグラフ化してみた - メモ的な思考的な

 
Python3.4 & Django1.8な環境で作成した2015年以降、食べたりんごの種類を記載していたYAMLファイル以外は、ノーメンテナンスで過ごしてきました。

ただ、最近はパフォーマンスが悪化し、見るのがつらい状況でした。

 
メンテナンスをしようかと思いましたが、

  • Djangoにさわり始めた頃のアプリのため、Djangoプロジェクト構成があまりよくない
  • テストコードなし
  • Windowsの開発環境を無くした

などがあり、あまり気が進まない状況でした。

 
そんな中、Django2.1系のPythonサポートを見たところ、Python3.4系では動作しないことに気づきました。
FAQ: インストール | Django documentation | Django

 
さすがに今後使えなくなるのはマズイため、Python3.7系 & Django2.1系へとアップデートしました。

今回はその時のメモになります。

 
目次

 

環境

 

アップデート方針

PythonDjangoを最新化するに伴い、各種ライブラリや開発環境もアップデートすることにしました。

主な作業は以下でした。

  • 開発環境のDBとしてDockerを利用
  • テストコードを作成
  • 基盤を最新版 (Python 3.7.2 & Django 2.1.5) へアップデート
    • 合わせて、各種ライブラリも最新化
  • Djangoっぽいアプリケーション構成へと修正
  • jQuery + getJsonでの非同期データ取得を、Fetch API + async/await へと修正
  • パフォーマンスの改善

 
以降、各項目の内容を記載していきます。なお、記載する順番は上記と異なります。

 

開発環境のDBとしてDockerを利用

このアプリでは、DBとしてHeroku Postgresを使っています。また、集計処理ではPostgreSQLの独自関数を使っています。

そのため、開発環境にもPostgreSQLを用意する必要がありました。ただ、開発環境にはすでにPostgreSQLがいる & その環境を壊したくありませんでした。

そこで今回は、開発環境のPostgreSQLをDockerで用意することにしました。

 
まずは本番環境であるHeroku Postgresのバージョンを調べたところ、最新は10系でした。
Version support and legacy infrastructure | Heroku Postgres | Heroku Dev Center

 
そこで、PostgreSQL 10.6 のDockerイメージを使い、開発環境を構築しました。

# デフォルトのポート 5432はすでに使われているので、別のポート(19876)をDocker上の 5432 につなげる
$ docker run --name ringo_pg -p 19876:5432 -e POSTGRES_USER=ringo -e POSTGRES_PASSWORD=postgres -d postgres:10.6

# コンテナ起動
docker start ringo_pg

# データベースを作成
psql -U ringo -W -p 19876 -h localhost -c "CREATE DATABASE ringo_tabetter_py;"

 
次に、Heroku Postgresのデータをローカルに取り込みます。

# ローカルに ringo-tabetter のGitリポジトリを作成
$ git clone git@github.com:thinkAmi/dj_ringo_tabetter.git .

# settings.pyのDATABASESのポート番号を修正するのを忘れずに!!

# ローカルのDBに対し、マイグレーション
$ python manage.py migrate

# Heroku Postgresのデータを、ローカルの output.sql ファイルとしてダウンロード
# usernameやdbnameは、Heroku Postgresのページにて確認
$ pg_dump --host=ec2-xxx.compute-1.amazonaws.com --port=5432 --username=xxx --password --dbname=yyy > output.sql

# リストア
$ psql -d ringo_tabetter_py -h localhost -p 19876 -U ringo -f output.sql

参考:sql - How can I get a plain text postgres database dump on heroku? - Stack Overflow

 
ローカルで runserver したところ、問題なく動作しました。

 

テストコードを作成

Django1系から2系にアップデートすることもあり、まずはテストコードを整備しようと考えました。

ただ、ツイートを読み込んで集計・表示するだけのアプリという性格上、テストコードは正常系だけにしました。

また、テストについては

としました。

 
なお、テストメソッド名を日本語にすると、PyCharmでは怒られている気分になります。

f:id:thinkAmi:20190215000307p:plain:w300

 
そこで、以下の設定を行い、ちょっと怒られているだけにしてみました。

Editor > Inspections > Internationalization - Non-ASCII characters のチェックを外す

f:id:thinkAmi:20190215000325p:plain:w300

 
もし、より良い方法をご存知でしたら、教えていただけるとありがたいです。

 

テンプレートを表示するだけのViewのテスト

ステータスコードを見ているだけです。

テスト対象のURLを @pytest.mark.parametrize で渡していますが、単に使いたかっただけですので、深い意味はありません。

class TestViewForHighcharts:
    """ HighchartsのViewテスト """

    @pytest.mark.parametrize('url, expected_status_code', [
        (reverse('highcharts:total'), 200),
        (reverse('highcharts:total_by_month'), 200)
    ])
    def test_it(self, client, url, expected_status_code):
        actual = client.get(url)
        assert actual.status_code == expected_status_code

 

JSONをレスポンスするViewのテスト

どんな形のJSONが返ってくるのか忘れていたため、JSON文字列自体をハードコーディングしました。

また、使い回しが聞くように、 pytest.fixture で定義しておきました。

@pytest.fixture
def total_apples_expected():
    for i in range(3):
        TweetsFactory(name='フジ')
    for i in range(2):
        TweetsFactory(name='シナノドルチェ')
    for i in range(5):
        TweetsFactory(name='シナノゴールド')

    return '''[
  {
    "name": "シナノドルチェ",
    "y": 2,
    "color": "AntiqueWhite"
  },
  {
    "name": "シナノゴールド",
    "y": 5,
    "color": "Gold"
  },
  {
    "name": "フジ",
    "y": 3,
    "color": "Red"
  }
]'''

 
あとは、レスポンスボディに対して、期待したJSONかのassertするテストを書きました。

エラーが起きたらassertで落ちるだろうと思い、ステータスコードチェックは省略しました。

@pytest.mark.django_db(transaction=True)
class TestTotalApples:
    """ total_apples() のテスト """

    def test_get(self, client, total_apples_expected):
        actual = client.get('/api/v1/total/')
        assert actual.content.decode('utf-8') == total_apples_expected

 
なお、テスト対象のURLは、上記のような reverse() は使わずにハードコーディングしています。

テスト中のURLはどちらで書くのが一般的なのか、ご存知でしたら教えていただけるとありがたいです。

 

Djangoカスタムコマンドのテスト

Djangoカスタムコマンドについては、2種類のテストを書きました。

1つは、Djangoコマンド内部で呼ばれている各メソッドのテストです。

fixtureやfactory-boy、pytestプラグインなどを使ってテストを書きました。

Status = namedtuple('Status', ('id', 'text', 'created_at'))


@pytest.fixture
def cultivars():
    return [
        {'Name': 'シナノゴールド', 'Color': 'Gold'},
        {'Name': 'シナノドルチェ', 'Color': 'Red'},
        {'Name': '王林', 'Color': 'Yellow'},
    ]


@pytest.mark.freeze_time("2019-01-01")
@pytest.mark.django_db(transaction=True)
class TestGatherTweets:
    def test_save_with_transaction_該当ツイート無し_LastSearchありの場合(self, cultivars):
        from apps.tweets.management.commands.gather_tweets import Command
        from apps.tweets.models import Tweets, LastSearch

        sut = Command()
        sut.cultivars = cultivars
        sut.last_search = LastSearchFactory()

        # Twitterの仕様では、新しいものから順に取得できる(添字が小さいほど、最近の投稿になる)
        statuses = [
            Status(id=3, text='リンゴ 今日は `シナノゴールド` を食べた', created_at=datetime.now()),
            Status(id=2, text='[リンゴ] 今日はシナノゴールドを食べた', created_at=datetime.now()),
            Status(id=1, text='[りんご] 今日は `シナノゴールド` を食べた', created_at=datetime.now()),
        ]
        sut.save_with_transaction(statuses)

        assert Tweets.objects.count() == 0

        actual = LastSearch.objects.all()
        assert len(actual) == 1
        assert actual.first().prev_since_id == 3

 
もう1つは、Djangoカスタムコマンド自体を実行して、期待する動作をしているかのテストです。
testing - How to test custom django-admin commands - Stack Overflow

こちらはテストデータを用意するのが手間でした。そのため、モックを使って、想定したメソッドが呼ばれているかをチェックするようなテストを書きました*1

def test_call_command_Djangoコマンドを直接呼ぶ(self):
    from apps.tweets.management.commands import gather_tweets

    with patch.object(
            gather_tweets, 'Apple', return_value=Mock()) as apple, \
        patch.object(
            gather_tweets.Command, 'get_last_search') as mock_get, \
        patch.object(
            gather_tweets.Command, 'gather_tweets', return_value='foo') as mock_gather, \
        patch.object(
            gather_tweets.Command, 'save_with_transaction') as mock_save:

        # 作成したDjangoカスタムコマンドを直接呼ぶ
        call_command('gather_tweets')

        # コマンドの内部で呼ばれるはずのメソッドが、想定通り呼ばれているかをチェック
        mock_get.assert_called_with()
        mock_gather.assert_called_with()
        mock_save.assert_called_with('foo')  # mockで foo を返しているので、それが使われるか

 

TwitterやSlack APIのテスト

このアプリでは以下を行っていました。

  • Twitter APIを使って、ツイートを取得
  • Slack APIを使って、エラー情報をポスト

 
ただ、pytestを実行すると毎回APIを呼んでしまうのは良くなさそうです。

そのため、以前の記事を参考にしつつ、pytestのコマンドラインオプションで指定された場合のみ、テストを実行するようなfixtureを conftest.py に書きました。
Python + pytestにて、pytestに独自のコマンドラインオプションを追加する - メモ的な思考的な

import pytest

def pytest_addoption(parser):
    parser.addoption('--twitter', action='store', type=int,
                     help='Twitterのテストを実行する(要: Twitterの設定)。値は取得を開始する status_id。'
                     '最新の status_id だとテストがコケるので、3ツイートよりも前の status_id をセットする'
                     )

@pytest.fixture(scope='session')
def twitter(request):
    status_id = request.config.getoption('--twitter')
    if not status_id:
        pytest.skip()
    return status_id

 

基盤を最新版へアップデート

テストコードができたので、PythonDjangoを最新版へアップデートします。

手元の環境には pyenv が入っていたので、Pythonのバージョンを変更した上で、再度 venv で仮想環境を作成しました。

その後、各ライブラリの最新版をインストールしました。

 

Djangoを1.8.5から2.1.5へアップデートした後の対応

Django1系から2系への切替なので、いくつか不具合が発生しています。

テストコードを実行しつつ、その不具合を1つずつ潰していきます。

コミットはこのあたりです。
https://github.com/thinkAmi/dj_ringo_tabetter/commit/ed3d5ee86fa0f67d5129331ed29097a725ee6873

 

urls.pyの修正

Django1.8系では次のような書き方でした。

urlpatterns = patterns('',
                       url(r'^v1/total/$', views.total_apples),
                       url(r'^v1/month/$', views.total_apples_by_month),
                       ) 

 
Django2.1.5では、

  • patterns()url() が廃止
  • app_name が必要

なため、以下のように修正しました。

app_name = 'api'

urlpatterns = [
    path('v1/total/', views.total_apples, name='total'),
    path('v1/month/', views.total_apples_by_month, name='total_by_month'),
]

 

また、Djangoプロジェクトで admin ページのURL設定も変更となっていました。

# 1.8.5
url(r'^admin/', include(admin.site.urls)),

# 2.1.5
path('admin/', admin.site.urls),

 
なお、 include() まわりは、過去記事を参照して修正しています。
Django2.0のプロジェクトのurls.pyにおける、include()での引数namespaceについて調べてみた - メモ的な思考的な

 

settings.pyの修正

修正量が多いような気がしたので、

  • 別環境に、キレイなDjango 2.1.5 プロジェクトを作成
  • 上記で作成した settings.py に、Django1.8.5のsettingsの内容を反映

という手段を取りました。

テストがあることと、たいしたアプリではないことから、思い切ってやれました。

大きな変化は、

  • MIDDLEWARE_CLASSES が廃止、 MIDDLEWARE へと変更
    • MIDDLEWARE の先頭に、django.middleware.security.SecurityMiddleware を指定
  • AUTH_PASSWORD_VALIDATORS が新設

あたりでした。

 

Whitenoise v4.0の破壊的変更に対応

DjangoをHerokuで使う時の静的ファイルの配信に、 Whitenoise を使っていました。

ただ、v4.0より破壊的変更が入りました。
http://whitenoise.evans.io/en/stable/changelog.html#v4-0

 
そのため、v4.0以降でも動作するよう、以下の修正を行いました。

  • ミドルウェアwhitenoise.middleware.WhiteNoiseMiddleware を追加
  • settings.pyで STATICFILES_STORAGE = 'whitenoise.storage.CompressedManifestStaticFilesStorage' に差し替え
  • wsgi.pyの以下の設定を削除
from whitenoise.django import DjangoWhiteNoise
application = DjangoWhiteNoise(application)

 

render_to_response() を render() に差し替え

実行ログを見ていると、以下のような RemovedInDjango30Warning が出ていました。

RemovedInDjango30Warning: render_to_response() is deprecated in favor of render(). It has the same signature except that it also requires a request.

 
そのため、 render_to_response() の箇所を render() に変えました。

両者の関数の違いは、 request オブジェクトの有無が大きいようです。
Django - Why should I ever use the render_to_response at all? - Stack Overflow

 

docstring や type hints の整備

細かいところですが、今までの書き方では情報が不足していたため追加しました。

なお、APIの戻り値など、動的に型を生成しているところは、type hints を記述しませんでした。

こんな感じです。

from typing import List

def render_json_response(request, data: List[dict], status=None) -> HttpResponse:
    ...

 

リファクタリング

テストがパスすることを確認しながら今までの作業を行ってきました。

そこで次は、リファクタリングをして、よりメンテナンスしやすい作りにします。

主な作業は以下のコミットにまとまっていますが、やったことを簡単にメモしていきます。
https://github.com/thinkAmi/dj_ringo_tabetter/commit/71d11ea7f3ada8b39c678451c010630031abe6ce

 

表示パフォーマンスの改善

動作が重くて仕方なかったため、まずはパフォーマンス改善に着手しました。

怪しいと思っていた箇所は2つです。

  • DB側で集計処理をするところ
  • DBから取得したデータをHighchartsで処理するところ

 
計測してみたところ、DBの集計処理は特に問題ありませんでした。一方、Highchartsの方は問題でした。

問題があったのは、以下の作りです。

$.getJSON('/api/v1/total', function(res){
    $.each(res, function (i, json) {
        chart.series[0].addPoint({
            name: json['name'],
            y: json['quantity'],
            color: json['color']
        });
    });
});

取得したJSONデータに含まれる配列を $.each() して、 addPoint() でチャートに追加していました。

 
そこで、 $.each()addPoint() するのではなく、取得したJSONを直接設定するように変更したところ、劇的に改善しました。

series: [{
    data: jsonData
}]

 

libs.cultivars を apps に移動

libs.cultivars は各Djangoアプリから呼び出すために作ったライブラリです。

ただ、Djangoプロジェクトの作法に従い、Djangoアプリケーションとして扱うようにしました。

以下の方法で、新規 Djangoアプリケーションを作成し、その中に移動しました。
windows - Django, can't create app in subfolder - Stack Overflow

# ディレクトリを作る
mkdir ./apps/cultivars

# startappする
$ python manage.py startapp cultivars ./apps/cultivars

 

Djangoっぽいアプリ化

Djangoアプリとしては、他にも以下の点が気になったので、修正しました。

 
また、趣味の範囲ですが、以下も行いました。

  • JSONを返すViewをクラスベースView化
    • Viewを継承して作成

 

トップページにアクセスした時のリダイレクトを追加

トップページにアクセスした場合、今まではエラーになっていました。URLを忘れてしまった時など、意外とつらかったです。

そこで、トップページにアクセスした場合は、Highchartsの集計ページへリダイレクトするよう、Djangoプロジェクトの urls.py に追加しました。

urlpatterns = [
    ...
    # トップにアクセスした時は、Highchartsの合計ページへリダイレクト
    path('', RedirectView.as_view(url=reverse_lazy('highcharts:total'))),
]

 

GitHubのSecurity alertへ対応

GitHubを見たところ、CVE-2017-18342 のSecurity alertが出ていました。
https://github.com/thinkAmi/dj_ringo_tabetter/network/alert/requirements.txt/pyyaml/open

 
PyYAMLのGitHubを見ると、すでにいくつかのissueとコメントがありました。

 
そのコメントより、 yaml.load() ではなく yaml.safe_load() を使えば良いとのことだったため、差し替えを行いました。

 
とはいえ、GitHub上からはalertが消えないため、PyYAMLの次のバージョンのリリースがあるといいなという状況です...

 

django-jinjaへの依存を削除

このアプリを作った当初は Jinja2 テンプレートがDjangoでは扱えなかったため、 django-jinja を追加して使っていました。

 
その後、Djangoでも Jinja2 テンプレートを扱えるようになり、Django2系からは context_processors の指定も可能になりました。
https://docs.djangoproject.com/en/2.1/topics/templates/#django.template.backends.jinja2.Jinja2

 
そこで今回、django-jinjaへの依存を削除してみました。

settings.pyの TEMPLATES では、以下を行いました。

  • BACKEND を差し替え
  • match_extension を削除
  • environment を追加
TEMPLATES = [
    {
        # 差し替え
        # 'BACKEND': 'django_jinja.backend.Jinja2',
        'BACKEND': 'django.template.backends.jinja2.Jinja2',
        
        'DIRS': [os.path.join(BASE_DIR, 'templates')],
        'APP_DIRS': True,
        'OPTIONS': {
            'context_processors': [
                'django.template.context_processors.debug',
                'django.template.context_processors.request',
                'django.contrib.auth.context_processors.auth',
                'django.contrib.messages.context_processors.messages',
            ],
            
            # 削除
            # 'match_extension': '.jinja2',
            
            # 追加
            'environment': 'dj_ringo_tabetter.jinja2.environment',
        }
    },
]

 
次にDjangoプロジェクトディレクトリ (dj_ringo_tabetter/) の中に、 jinja2.py を作成します(上記の environment で指定したファイル)。

そのファイルに、Jinja2の設定を行います。何もしないと static などのテンプレートタグが扱えないためです。

作業としては、必要なテンプレートタグを jinja2.py の env.globals に追加します。

なお、テンプレート側では、 {% load static %} などの記述は不要です。

from django.templatetags.static import static
from django.urls import reverse

from jinja2 import Environment


# Djangoのtemplatetagsを利用できるように設定
# https://docs.djangoproject.com/en/2.1/topics/templates/#django.template.backends.jinja2.Jinja2
def environment(**options):
    env = Environment(**options)
    env.globals.update({
        'static': static,
        'url': reverse,
    })
    return env

 

$.getJson()から、async/awaitなfetch()へと変更

Fetch API & async/awaitを使うことで、jQueryなしに非同期でJSONを取得する処理が書けます。

基本的にモダンブラウザでしか見ないので、今回修正してしまいます。

修正前

$.getJSON('/api/v1/total', function(res){
    // ...
});

修正後

const renderChart = async (url) => {
    const res = await fetch(url);
    return await res.json();
};

renderChart('/api/v1/month')
    .then(jsonData => {
        // ...
    });

参考:イマドキのJavaScriptの書き方2018 - Qiita

 

その他JavaScriptまわりの修正
  • BootstrapはCSSだけ使うだけでも十分だったため、jQueryへの依存も削除
  • BootstrapとHighchartsはCDNではなく、手元に保存したものを見るように変更

 

Heroku向けの設定変更

いくつかHeroku向けの設定を変更しました。

  • settings.py
    • ALLOWED_HOSTS = ['127.0.0.1', 'ringo-tabetter.herokuapp.com']
      • ローカルとHeroku上、両方ともアクセスできるようにするため
  • runtime.txt
    • python-3.7.2
      • Python3.7.2で動作させるため

 

Herokuへデプロイする準備

MacではHeroku経でプロイする準備をしていなかったため、セットアップを行いました。

まずは、以下の内容に従い、Heroku CLIをインストールします。 https://devcenter.heroku.com/articles/heroku-cli

今回は、homebrewでインストールしました。実行後、以下が表示され、問題なくインストールできました。

To use the Heroku CLI's autocomplete --
  Via homebrew's shell completion:
    1) Follow homebrew's install instructions https://docs.brew.sh/Shell-Completion
        NOTE: For zsh, as the instructions mention, be sure compinit is autoloaded
              and called, either explicitly or via a framework like oh-my-zsh.
    2) Then run
      $ heroku autocomplete --refresh-cache

  OR

  Use our standalone setup:
    1) Run and follow the install steps:
      $ heroku autocomplete

Bash completion has been installed to:
  /usr/local/etc/bash_completion.d

zsh completions have been installed to:
  /usr/local/share/zsh/site-functions

 
続いて、Heroku CLIからログインします。

実行後、何かキーを押すとブラウザが開くので、ブラウでログインすればOKでした。

$ heroku login

 

次に、git remoteを追加します。

$ heroku git:remote -a ringo-tabetter
set git remote heroku to https://git.heroku.com/ringo-tabetter.git

 
念のため、git remoteを確認します。

$ git remote -v
heroku  https://git.heroku.com/ringo-tabetter.git (fetch)
heroku  https://git.heroku.com/ringo-tabetter.git (push)
origin  https://github.com/thinkAmi/dj_ringo_tabetter (fetch)
origin  https://github.com/thinkAmi/dj_ringo_tabetter (push)

 
これで準備が整いました。

今回はブランチを別にして開発していたため、以下を参考にHerokuへブランチをpushしました。
https://devcenter.heroku.com/articles/git#deploying-from-a-branch-besides-master

git push heroku feature/migrate-to-django2:master

 
ただ、以前のアプリはCeder-14を使っていたため、Python3.7系のアプリがデプロイできませんでした。
HerokuのCedar-14に、Python3.7系アプリをデプロイしたらエラーになった - メモ的な思考的な

 
そこで、上記の記事にある通り、HerokuのStackをアップデートしたところ、無事にデプロイできました。

 
以上が今回行った作業となります。

Heroku上のアプリが寝ている時以外は快適であり、個人的に満足しています。
https://ringo-tabetter.herokuapp.com/

f:id:thinkAmi:20190214235021p:plain

 

今回やらなかったこと

今のところはまだいいかなと考え、以下の内容は行いませんでした。

  • プロジェクトディレクトリ名を config などに修正すること
  • settings.pyを、開発用・本番用などに分割すること

 

ソースコード

GitHubに上げました。今回の修正したブランチ feature/migrate-to-django2 は残してあります*2
https://github.com/thinkAmi/dj_ringo_tabetter

*1:モックを使いたかっただけかもしれません...

*2:PyYAMLのCVE-2017-18342への対応は、気づくのが遅れたため、このブランチには含まれていません