Djangoのキャッシュ・Herokuの環境

ページ単位と関数単位の主に2つの部分でキャッシュを使いました。

ページ単位のキャッシュ

Djangoのキャッシュ機能を使って画面表示をもっと高速化する Memcached編 - Qiita https://qiita.com/shonansurvivors/items/f2681aebfd8b08cee994

ページ単位のキャッシュでは、memcachedというキャッシュサーバーを利用してキャッシュをしました。具体的には上のサイトの通りに実装をしました。

ローカル環境の場合

ローカル環境では、Docker環境を利用していたので、必要なPythonライブラリはrequirements.txtに記述し、ミドルウェアはdocker-compose.ymlでイメージを用いました。

# Djangoアプリ
  web:
    build: .
    command: python3 manage.py runserver 0.0.0.0:8000
    volumes:
      - .:/code
    ports:
      - "8000:8000"
    depends_on:
      - db
      - redis
      - memcached

memcached:
    image: memcached

Herokuの環境の場合

Herokuの環境でもやることは同じです。

Memcache を使用した Django アプリケーションのスケーリング | Heroku Dev Center https://devcenter.heroku.com/ja/articles/django-memcache#add-caching-to-django

上の公式ドキュメントで、書かれているようにアドオンを導入してキャッシュをします。アドオンを導入すると、自動で環境変数を作成してくれるのでそれらをsettings.pyにもローカル環境と分離して書きます。

関数単位のキャッシュ(2022年8月22日追記)

Pythonの標準ライブラリにもキャッシュをする関数(デコレータ)は存在しますが、任意のタイミングで部分的にキャッシュをクリアしたりなどをしたかったので、今回は次のサイトで書かれている通りに自分で実装しました。

Python】関数の結果をキャッシュして高速化する|ゆうまるブログ https://yumarublog.com/python/functools-cache/

消去機能を含めて以下のようになっています。

def cache(func):
    c = {}

    def _wrapper(*args):
        # 最後にキャッシュした時間を見る
        # 一定時間後にキャッシュをすべてクリアする
        if time.time() - views.cache_time > views.CACHE_LIMIT_TIME:
            views.cache_time = time.time()
            # キャッシュをクリアする
            c.clear()

        # 引数の最後は識別子とする(その関数の引数が一意に定まる値)
        if len(args) > 0:
            if func.__name__ in views.delete_cache:
                # 任意のキャッシュを消去
                if args[-1] in views.delete_cache[func.__name__]:
                    views.delete_cache[func.__name__].remove(args[-1])
                    del c[args[-1]]
            if args[-1] not in c:
                c[args[-1]] = func(*args)
            return c[args[-1]]
        else:
            if args not in c:
                c[args] = func(*args)

            return c[args]

    return _wrapper

def get_ready_delete_cache(function_name, identifier):
    # キャッシュを消去
    # 関数と識別子を格納する
    if function_name in views.delete_cache and views.delete_cache[function_name] is not None:
        # append は破壊的メソッドなことに注意
        views.delete_cache[function_name].append(identifier)
    else:
        views.delete_cache[function_name] = [identifier]

大きく分けて3つの機能があります。

1つ目は、上のサイトでもあるようにキャッシュを保存する機能です。キャッシュはcという変数に格納されています。cは関数ごとに別々の値を保存し、状態を保持します。この辺りは、詳しくは以下の記事に書いてあります。

Pythonクロージャ(関数閉方)とは - Qiita https://qiita.com/naomi7325/items/57d141f2e56d644bdf5f

Pythonクロージャと nonlocal ってなに? | 民主主義に乾杯 https://python.ms/closure-and-nonlocal/

今回の場合は、引数を持っているものと、持っていないもので保存の仕方を分けています。引数を持っていない関数は、シンプルにキャッシュに保存しています。引数を持っているものは、識別子を取り出してそれをキーとして保存しています。識別子については2つ目のところで書きます。キャッシュをする前提として、引数が同じ関数は同じ結果を返すということに注意してください。例えば、現在の時間を返す関数などは基本的にキャッシュをするべきではないと思います。もう一つ注意する必要があることは、引数がself、つまり自分のインスタンスである時です。この時はキャッシュには引数がキーとして保存されますが、毎回インスタンスを定義してその関数を呼び出していると、selfのアドレスも毎回変わります。そのため毎回新しいキャッシュとして保存され、既存のキャッシュが使わないことになります(つまりキャッシュされない)。解決策として、selfの後の引数に識別子を追加するか、インスタンスを一定にするかのどちらかをする必要があります。

2つ目は、特定のキャッシュを消去する機能です。例えば、あまりデータベースの内容が更新されない場合に、高速化のためデータベースにアクセスする処理をキャッシュしたとします。この場合、もしデータベースに更新があったとしてもキャッシュされていて、変更がページに反映されないので、こういう場合(更新された時)にはその部分のキャッシュを消去できるようにしたいです。そのためdelete_cacheというグローバル変数を作って、そこに消去したいキャッシュの情報を格納するようにしました。delete_cacheは以下のような構造になっています。

delete_cache = {func_name:[識別子...]}

cacheデコレータが呼ばれたときに、delete_cacheに基づいてデータを消去(キャッシュを消去したい関数をキーとする)します。関数名をキーとするので関数名の衝突に注意する必要があります。ここで識別子とは、その関数の引数の一つで、どの引数でその関数が呼ばれたのかを一意に定めることができるものです。例えば、get_company_data(cid)という関数を実行する時にcidという引数は企業情報を取り出すキーとなります(同じcidなら同じデータが取り出されるはず)。このようなデータを使ってキャッシュを識別し、消去しています。注意する点は、args[-1]という箇所です。これは識別子を取り出しているのですが、それが引数の最後であることを前提としています。そのためキャッシュをしたい関数で、尚且つ消去機能を組み込みたい関数は、引数群の最後に識別子を指定する必要があります。(例:get_company_data(引数1, 引数2, … , cid))

3つ目は、一定時間後にキャッシュをすべて消去する機能です。今回はcache_timeというグローバル変数を作って、そこにtime.time()でUNIX時間(エポック秒)を保存しています。UNIX時間とは1970年1月1日0時0分0秒からの経過秒数です。

Pythonで経過時間や日時(日付・時刻)の差分を測定・算出 | note.nkmk.mehttps://note.nkmk.me/python-datetime-timedelta-measure-time/

cacheデコレータが呼び出されるとcache_timeに保存されている時間と現在の時間(UNIX時間)を比較してそれがCACHE_LIMIT_TIME以上であるなら、キャッシュをすべて消去しています。ここでCACHE_LIMIT_TIMEとはキャッシュを消去したい秒数を保持している定数です。

追記

調べてみたら関数単位でキャッシュを消去するライブラリがありました。やはり自作よりはライブラリを探して使う方が良いですね。

🍺Python3でLRUキャッシュを用いてプログラムを高速化 - Qiita https://qiita.com/kotaroooo0/items/4d471932e299edd08b24

消去するには foo.cache_clear() 使う。fooは関数。

How do I use cache_clear() on python @functools.lru_cache - Stack Overflow https://stackoverflow.com/questions/37653784/how-do-i-use-cache-clear-on-python-functools-lru-cache

公式ドキュメント

10.2. functools --- 高階関数と呼び出し可能オブジェクトの操作 — Python 3.6.15 ドキュメント https://docs.python.org/ja/3.6/library/functools.html#functools.lru_cache