以前、食べたリンゴの割合をグラフ化するHerokuアプリを作りました。
Python + Django + Highcharts + Herokuで食べたリンゴの割合をグラフ化してみた - メモ的な思考的な
Python3.4 & Django1.8な環境で作成した2015年以降、食べたりんごの種類を記載していたYAMLファイル以外は、ノーメンテナンスで過ごしてきました。
ただ、最近はパフォーマンスが悪化し、見るのがつらい状況でした。
メンテナンスをしようかと思いましたが、
などがあり、あまり気が進まない状況でした。
そんな中、Django2.1系のPythonサポートを見たところ、Python3.4系では動作しないことに気づきました。
FAQ: インストール | Django documentation | Django
さすがに今後使えなくなるのはマズイため、Python3.7系 & Django2.1系へとアップデートしました。
今回はその時のメモになります。
目次
環境
アップデート方針
PythonとDjangoを最新化するに伴い、各種ライブラリや開発環境もアップデートすることにしました。
主な作業は以下でした。
- 開発環境の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系にアップデートすることもあり、まずはテストコードを整備しようと考えました。
ただ、ツイートを読み込んで集計・表示するだけのアプリという性格上、テストコードは正常系だけにしました。
また、テストについては
- テストランナーは、慣れている
pytest
- DBデータを作成するファクトリライブラリは、
factory-boy
- 自分しかメンテナンスしないため、テストメソッド名は日本語
としました。
なお、テストメソッド名を日本語にすると、PyCharmでは怒られている気分になります。
そこで、以下の設定を行い、ちょっと怒られているだけにしてみました。
Editor > Inspections > Internationalization - Non-ASCII characters のチェックを外す
もし、より良い方法をご存知でしたら、教えていただけるとありがたいです。
テンプレートを表示するだけの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カスタムコマンドについては、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()
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:
call_command('gather_tweets')
mock_get.assert_called_with()
mock_gather.assert_called_with()
mock_save.assert_called_with('foo')
このアプリでは以下を行っていました。
ただ、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
基盤を最新版へアップデート
テストコードができたので、PythonとDjangoを最新版へアップデートします。
手元の環境には 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設定も変更となっていました。
url(r'^admin/', include(admin.site.urls)),
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アプリとしては、他にも以下の点が気になったので、修正しました。
- settingsの参照を
from django.conf import settings
形式に変更
- 合計数量の計算をmodelへと移動
templates
ディレクトリを、apps/highcharts
から、プロジェクト直下へと移動
INSTALL_APPS
で、AppConfigを使うように修正
- JavaScript/CSSのライブラリ(BootstrapやHighcharts)を、
static/js/vendor/<ライブラリ名>/ライブラリ.js
として保存し、参照するように修正
また、趣味の範囲ですが、以下も行いました。
トップページにアクセスした時のリダイレクトを追加
トップページにアクセスした場合、今まではエラーになっていました。URLを忘れてしまった時など、意外とつらかったです。
そこで、トップページにアクセスした場合は、Highchartsの集計ページへリダイレクトするよう、Djangoプロジェクトの urls.py に追加しました。
urlpatterns = [
...
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.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',
],
'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
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
- BootstrapはCSSだけ使うだけでも十分だったため、jQueryへの依存も削除
- BootstrapとHighchartsはCDNではなく、手元に保存したものを見るように変更
Heroku向けの設定変更
いくつかHeroku向けの設定を変更しました。
- settings.py
ALLOWED_HOSTS = ['127.0.0.1', 'ringo-tabetter.herokuapp.com']
- ローカルとHeroku上、両方ともアクセスできるようにするため
- runtime.txt
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/
今回やらなかったこと
今のところはまだいいかなと考え、以下の内容は行いませんでした。
- プロジェクトディレクトリ名を
config
などに修正すること
- settings.pyを、開発用・本番用などに分割すること
GitHubに上げました。今回の修正したブランチ feature/migrate-to-django2
は残してあります*2。
https://github.com/thinkAmi/dj_ringo_tabetter