33分4秒で理解できるDjangoアプリのキャッシュ実装 - LT対策と賞味期限という話 - Lean Baseball

Lean Baseball

No Engineering, No Baseball.

33分4秒で理解できるDjangoアプリのキャッシュ実装 - LT対策と賞味期限という話

NHK.一応フラグ回収しときますねw*1

Django Advent Calendar 2018、14日目の記事です.

Python(と他の色々なモノ)を駆使して野球をしています、 @shinyorkeと申します.

PyLadies Tokyo - 4周年記念パーティのLTで、Djangoのアプリを披露したのですが、

そのときに触れることができなかったDjango REST Framework(DRF)のキャッシュ機構と、どういう考えで設計・実装したか?をメモ的に残したいと思っています.

Djangoアプリのパフォーマンスや負荷対策をしたい方の参考になれば幸いです.

TL;DR

  • Django REST Framework(DRF)のキャッシュ実装はDjangoのキャッシュ実装そのまま
  • データの賞味期限および、目的(LTのちょっとしたスパイス)に合わせていい感じにキャッシュを使おう*2
  • APIの場合、HATEOAS的にうまく分けると実装がしやすいぞ!

なお、参考としてこの辺を読むと理解が進むと思います(最後の方に登場します).

Web API: The Good Parts

Web API: The Good Parts

元ネタ

こちらのLTとなります.

なお、このエントリーの中では野球の話はほぼ登場しませんあしからず.*3

どこの話

サーバーサイド・キャッシュの話です.具体的にはDBまわりのModel層処理の話となります(ので、クライアント側は出ません).

f:id:shinyorke:20181204090249p:plain
Django APIとCacheで使ってるRedisの話です

おしながき

Who am I ?(お前誰よ)

Pythonで野球の人です.

  • @shinyorke(しんよーく、と読みます)
  • 野球データ解析・分析のベンチャー「ネクストベース」の野球エンジニア兼CTO
  • バックエンドにインフラ、データサイエンスにフロントと何でもやってるが基本Pythonの人

Django REST FrameworkでRedisを使ったキャッシュを実装する

結論から言うと、Djangoには標準の状態でキャッシュ機構あります.

Django REST Framework(以降、DRFと略す)とありますが、実際の所Djangoアプリと同じです.

公式ドキュメントにある、Django's cache frameworkを参考にすすめると理解と実践は33分4秒くらいで行けると思います.

Django's cache framework

settings.pyにバックエンドで使うもの(BACKEND)と場所(LOCATION)を書いてあげると利用可能になります.

ちなみにLOCATIONはlistで渡すと分散とかもいい感じにしてくれるっぽいです.

実際に使うときはcahce_pageというmethodを使います.

以下のコードはDjango's cache frameworkのページからの引用です.

setting.py

BACKENDの種類によって、別途コネクターの役割をするライブラリをインストールする必要があります.

以下はmemchachedの例ですが、ページ中にある通りpython-memcachedが必要となります.

CACHES = {
    'default': {
        'BACKEND': 'django.core.cache.backends.memcached.MemcachedCache',
        'LOCATION': '127.0.0.1:11211',
    }
}

使い方(view)

view関数に書く場合はデコレーターでイケます.

引数は生存期間(有効期限、秒数指定)です.

from django.views.decorators.cache import cache_page

@cache_page(60 * 15)
def my_view(request):
    ...

他にもrouter的な所(urls.py)にかます方法もありますがこれは後述します(理由含めて).

【実例】Redisを使った場合

というわけで、本題(私がやったこと)の話です.

バックエンドをRedisにした場合の話を書きます.

f:id:shinyorke:20181204090249p:plain
【再掲】Django + Redisキャッシュの話

django-redis

バックエンドの実装は、django-redisを使いました.

公式ドキュメントが丁寧に書かれており、結構使いやすかったです.

使う場合はpip install django-redisでおしまいで、実際のコード(settings.py)は、

CACHES = {
    "default": {
        "BACKEND": "django_redis.cache.RedisCache",
        "LOCATION": "redis://localhost:6379/baseball",
        "OPTIONS": {
            "CLIENT_CLASS": "django_redis.client.DefaultClient"
        }
    }
}

他のキャッシュの場合とかわりません.

なお、django-redis-cacheという、紛らわしい名前のやつもあり、こっちも使えそうだったのですが、最初に探して試したdjango-redisで事足りたので試してはいません.

今思えば比較しても良かったかも...比較したことある人いましたらレポートよろしくです汗

使う

そもそもcacheをかけるつもりの場所をurlレベルで決めていた(決めるような事ができる設計にしていた)ため、

  • urls.pyから使うキャッシュ用のmethodを加える
  • urls.pyのpathにたいしてキャッシュの利用を定義する

という方針でまとめました.

from django.urls import path
from django.conf import settings
from django.views.decorators.cache import cache_page as view_cach_page

from yakiu.views import (
    PlayerAPIView,
    SalaryAPIListView,
    FieldingAPIListView,
    AppearancesAPIListView,
    BattingStatsAPIListView,
    PitchingStatsAPIListView,
)


def cache_page(view, timeouts=86400):
    # キャッシュする用のmethod
    return view_cach_page(timeouts)(view)

app_name = 'yakiu'
urlpatterns = [
    path(
        'player/<str:player_name>/',
        cache_page(PlayerAPIView.as_view()), name='player_profile'
    ),
    path(
        'pos/<str:player_id>/',
        cache_page(FieldingAPIListView.as_view()), name='player_position'
    ),
    path(
        'career/<str:player_id>/',
        cache_page(AppearancesAPIListView.as_view()), name='player_career'
    ),
    path(
        'stats/batting/career/<str:player_id>/',
        cache_page(BattingStatsAPIListView.as_view()), name='player_career_batting'
    ),
    path(
        'stats/pitching/career/<str:player_id>/',
        cache_page(PitchingStatsAPIListView.as_view()), name='player_career_pitching'
    ),
]

目的のキャッシュはできたのと、思ったより手数が少ない方法でできてよかったです.

サーバーサイドキャッシュを使う・使わない判断について

「サーバーサイドのキャッシュは、アプリの負荷対策にレスポンス云々...の為に必要だ!」は当然あるとして.

ここまで「Django(もしくはDRF)でキャッシュを実装」する例の話を書きましたが、そもそもキャッシュってどういう時に必要なんでしたっけ?

と、(主にWebに馴染みが無かったりプログラミング初心者だったりする方は)思うかもしれません.

自分の考えを整理する上でもも、「どういう基準でサーバーサイドでキャッシュする・しない」って決めるんでしたっけ?」っていう事でちょっと書いてみます.

データの「賞味期限」がそこそこある場合

30分・1時間・半日・1日・一週間...てな感じで、明確に有効期限(賞味期限)がある場合、サーバーサイドのキャッシュは検討していいかなと思います.

例えば(私が仕事にしている)野球の場合、

  • 試合が行われている間、試合のデータは常に更新される(投球、打球、選手交代etc...)
  • 一方、チームや選手単位の情報はすべての試合が終わってから更新→翌日の試合がはじまるまでそうそう変わらない
  • チームや球場ってシーズン中に増えたり減ったりするものじゃないですよね???

とかそんな整理ができます.

という所まで導かれると、

  • 試合中の成績データのキャッシュは短い単位にする(せいぜい数分)
  • チームや選手の年間成績みたいなモノは日次の集計なので12〜24時間くらいキャッシュしちゃって大丈夫
  • チームや球場は年単位でいいでしょう!

という感じに、賞味期限毎にキャッシュを切るようなことができます.

さっきのコードを賞味期限毎に分けるとこんなかんじです.

from django.urls import path
from django.conf import settings
from django.views.decorators.cache import cache_page as view_cach_page

from yakiu.views import (
    PlayerAPIView,
    SalaryAPIListView,
    FieldingAPIListView,
    AppearancesAPIListView,
    BattingStatsAPIListView,
    PitchingStatsAPIListView,
)

TIME_OUTS_1DAY = 60 * 60 * 24
TIME_OUTS_1MONTH = TIME_OUTS_1DAY * 30

# キャッシュ期間のデフォルトは1日(60s * 60m * 24h)
def cache_page(view, timeouts=TIME_OUTS_1DAY):
    # キャッシュする用のmethod
    return view_cach_page(timeouts)(view)

app_name = 'yakiu'
urlpatterns = [
    # 選手のプロフィールはそうそう変わらないので30日
    path(
        'player/<str:player_name>/',
        cache_page(PlayerAPIView.as_view(), timeouts=TIME_OUTS_1MONTH), name='player_profile'
    ),
    # 出場位置(potision)、成績(career)は日々変わるので一日(デフォルト値)
    path(
        'pos/<str:player_id>/',
        cache_page(FieldingAPIListView.as_view()), name='player_position'
    ),
    path(
        'career/<str:player_id>/',
        cache_page(AppearancesAPIListView.as_view()), name='player_career'
    ),
    path(
        'stats/batting/career/<str:player_id>/',
        cache_page(BattingStatsAPIListView.as_view()), name='player_career_batting'
    ),
    path(
        'stats/pitching/career/<str:player_id>/',
        cache_page(PitchingStatsAPIListView.as_view()), name='player_career_pitching'
    ),
    # 試合情報は常に変わるので1分はとりましょう
    path(
        'game/stats/<str:date>/',
        cache_page(GameStatsView.as_view(), timeouts=60), name='game_stats'
    ),
]

おわかりいただけたかな?

デモやLTを手早く見せたい時の調味料

見せたい機能・デモをキビキビと、いい感じに動いてる(っていう体で)見せたい時にキャッシュしとけ!ということです.

そもそもこのエントリーの元ネタDjangoアプリは、PyLadies Tokyo 3周年のLTで野球ネタ(Shohei Ohtani)を披露するために作った*4のですが、

  • 全球団・リーグ全体のサマリーを取るようなAPIを作って動かしたら劇的に重くなった
  • SQLのインデックスとかクエリの工夫でどうにでもなるレベル...ではなかった(万策尽きていた)
  • バックエンド(バッチ)で集計テーブルを作るような処理も考えたが設計&実装の余裕がなかった*5

という事象が発生した結果、(暫定処置として)キャッシュを入れることにしました.

この突貫工事で気がついたところとしては、

  • 手っ取り早くデモをサクサク動くようにするならキャッシュ利かせるの悪くない
  • 適当な作りでも、LT前に暖機運転(予め見せる予定のページや機能を動か)しておくと、いい感じに進む

もちろん、アプリもいい感じになり*6、アドベントカレンダーに書くような知見も残ってよかったと思っています.

なお、デモやLTで見せる場合は「賞味期限」をLT中もたせる感じであればいいと思います.

商用サービスじゃないので、極端な話1日くらいはとっても平気かなと.

まとめ - RESTful APIとキャッシュで幸せになる方法(HATEOAS)

有効期限ごとにキャッシュ期間を管理したり、デモとか見せたいものをいい感じにしたりと、いいことばかり書きましたが、

  • API・Webサイトの構造上、うまくできない作りなのだが?
  • 複数のModel・Service実装が絡んでるのでそもそもやれな(ry

的なご意見もあるかと思います.

今あるものをいい感じに...は難しいとしても、新しく作る機能やアプリについては、

  • リソース(データ)毎にAPIを区分けした上で
  • API側に「次の動作」を示す

ような、HATEOASを意識した設計・実装でやるとこの辺のメリットを享受できるかなと思っています.

今回のLT・サンプルコードも、HATEOAS化した結果、キャッシュをキレイに入れることができるようになったので、設計パターンの一つとして試してみてはどうでしょう?

shinyorke.hatenablog.com

最後はDjangoというより設計の話になりましたがこれにてお開き.

次回は@keinumaさんです.

*1:もちろん渋谷の放送局ではない、ホント便利な表現だと思う、辛いけど

*2:これも一応NHKやで()

*3:ちなみに元ネタはSHOWTIME(大谷翔平)さんマジすごい、という話でした(こなみ)

*4:さらに厳密に言うとPyCon JP 2018で話した時のアプリをもう一段階ブラッシュアップした

*5:ちなみにこれは後日、LT後にLuigiとSQLAlchemyでエイッと作りました.いつか披露したい.

*6:このアプリ自体はお遊びではなく、いずれ仕事などに展開可能な用に作ってます(なので全体のコードは非公開)