Djangoでcache busting (ファイルを更新した時だけ読込強制する)

# はじめに
Djangoで本格的にwebアプリ開発をするようになると、CSSやJSなどの静的(static)ファイルを扱うことが増えてきますが、その時に悩みの種になるのが静的ファイルのキャッシュです。
ブラウザはページの通信・ロード時間を減らすために、しばしば静的ファイルをキャッシュに保存しておき、再アクセスした時に使い回します。そのため、開発者側が静的ファイルを書き換えても、ブラウザ側で最新のデータで読み込まれずに表示が崩れたり、予期しない挙動をする可能性があります。

cache bustingはhtml上の静的ファイルのパスに対しクエリ文字列を付加し、パスを変更することで、閲覧ユーザーに常に最新のファイルを読み込ませるテクニックとして知られています。

https://netamame.com/cache-busting/

Djangoではsettingsに特定の定数を追加したり、 `django-static-md5url` というライブラリを用いたりすることで、静的ファイルのパスにハッシュ文字列を付加し、毎回強制読み込みさせることができます。

https://webty.jp/staffblog/production/post-1181/

ただ、毎回読み込ませるようにしてしまうと、ページのロード時間が無駄に長くなったり、キャッシュが無駄に蓄積されていくというユーザー側のデメリットが出てきます。
そこで今回は、ハッシュ文字列ではなくファイル自体の更新日時を付加することで、ファイルを更新した時だけ強制読み込みさせるようにします。

# 環境
|項目|内容|
|:--|:--|
|Python|3.9.1|
|Django|3.2.9|
|使用ブラウザ|Safari 15.1|

# 実際のコード

## 前座: 環境構築
最初にプロジェクト・アプリを作成します。

```sh:shell
pip install django
django-admin startproject sample_proj
cd sample_proj
django-admin startapp sample_app
```

### フォルダ構造
追加と書いてあるファイルはデフォルトで存在しないファイルです。
不要と書いてあるファイルは今回は使いません。

```
sample_proj/
├── sample_app/
│   ├── static/
│   │   ├── css/
│   │   │   └── index.css: 追加
│   ├── templates/
│   │   └── index.html: 追加
│   ├── templatetags/
│   │   └── cache_busting.py: 追加
│   ├── migrations/
│   ├── admin.py: 不要
│   ├── apps.py
│   ├── models.py: 不要
│   ├── tests.py: 不要
│   ├── urls.py: 追加
│   └── views.py
├── sample_proj/
│   ├── asgi.py
│   ├── settings.py
│   ├── urls.py
│   └── wsgi.py
└── manage.py
```

### 導入する前の各種ファイルの中身
まずは表示するページのデータを作成します。

```html:sample_app/templates/index.html(追加)
{% load static %}
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <link href="{% static 'css/index.css' %}" rel="stylesheet" type="text/css">
    <title>Cache busting test</title>
  </head>
  <body>
    <p class="test-text">hogehogefugoooo</p>
  </body>
</html>
```

```css:sample_app/static/css/index.css(追加)
.test-text {
  color: #ff0000;
}
```

views.pyはhtmlをレンダリングするだけの簡単なコード。

```python:sample_app/views.py(編集)
from django.shortcuts import render


def index(request, **kwargs):
    return render(request, 'index.html')
```

urls.pyは多くのDjango開発でそうするように、sample_projとsample_appそれぞれに作成しておきます。

```python:sample_app/urls.py(追加)
from django.urls import path

from . import views


urlpatterns = [
    path('', views.index, name='index'),
]
```

```python:sample_proj/urls.py(編集)
from django.contrib import admin
from django.urls import path, include  # includeを追加


urlpatterns = [
    path('admin/', admin.site.urls),
    path('', include('sample_app.urls')),  # 追加
]
```

最後に、settings.pyを編集します。 `INSTALLED_APPS` に `'sample_app'` を追加して認識できるようにします。

```python:sample_proj/settings.py(編集)
INSTALLED_APPS = [
    'django.contrib.admin',
    'django.contrib.auth',
    'django.contrib.contenttypes',
    'django.contrib.sessions',
    'django.contrib.messages',
    'django.contrib.staticfiles',
    'sample_app',  # 追加
]
```

### 試しに起動する
今回はmodels.pyを定義していませんが、migrateしておきましょう。
collectstaticでエラーが出る場合は、大抵 `STATIC_ROOT` の設定し忘れです。

```sh:shell
python manage.py migrate
python manage.py collectstatic
python manage.py runserver
```

無事に起動されたら、ブラウザで http://127.0.0.1:8000 にアクセスしてみます。以下のような画面が表示されるはずです。
![スクリーンショット 2021-12-20 11.01.58.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982526/13fc4d27-933a-eaf5-2d67-26d789ef89a5.png)

これで最低限の準備が整いました。

## 本題: cache bustingの実装
さて、ここからcache bustingのコードを実装していきます。今回はテンプレートエンジンとの親和性が高い、テンプレートタグ(simple_tag)を用いて実装します。

templatetagについて知らない方は、以下のサイトを見ると良いと思います。

https://djangobrothers.com/blogs/custom_template_tags_filters/

先ほどのフォルダ構造で追加した `static_cache.py`に書き込みます。

```python:sample_app/templatetags/cache_busting.py(追加)
from pathlib import Path

from django import template
from django.conf import settings
from django.templatetags.static import static


register = template.Library()


@register.simple_tag
def static_cache(filepath) -> str:
    """静的ファイルのURLにファイルの更新日時クエリを付加する

    """
    # Django標準機能を使ってhtml埋め込み用のパスを取得
    res_path = static(filepath)
    # mtimeを取得する
    full_filepath = Path(getattr(settings, 'STATIC_ROOT', '')).joinpath(filepath)
    file_mtime = str(int(full_filepath.stat().st_mtime))
    # パスに結合
    res_path += '?v=' + file_mtime
    return res_path
```

### 解説
<details>
<summary>余談なので折りたたみました</summary>
<div>
もともとDjangoには静的ファイルのルーティングをやってくれる `static` というテンプレートタグが標準機能として存在します。今回はそのテンプレートタグの仕組みを `static()` という関数を通して利用しています。手順を示すと以下の通り。

1. staticタグを経由して静的ファイルのパスを取得
2. 実際のファイルの更新日時をpathlibライブラリ(python標準)で取得
3. 更新日時を整数に切り捨て(=秒単位に変換)、クエリ文字列として追記
4. 編集したパスを返す

厳密には、テンプレートタグの `static` はコード中では `do_static()` という関数になっていて、今回使ったものとは異なります。詳しく知りたい方は以下のソースコードをご覧ください。

https://github.com/django/django/blob/stable/3.2.x/django/templatetags/static.py

</div>
</details>

実装したテンプレートタグを実際に使ってみます。先ほど作成した `index.html` を編集します。

```html:sample_app/templates/index.html(編集)
{% load cache_busting %}  <!--変更-->
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="utf-8">
    <link href="{% static_cache 'css/index.css' %}" rel="stylesheet" type="text/css">  <!--変更-->
    <title>Cache busting test</title>
  </head>
  <body>
    <p class="test-text">hogehogefugoooo</p>
  </body>
</html>
```

編集したら、試しにアプリを再起動し、最初と同じページ内容が表示されることを確認してください。
開発者ツールを見ると、cssのパスに以下のように更新日時のクエリが追加されています。

```html
<link href="/static/css/index.css?v=1639965343" rel="stylesheet" type="text/css">
```

# 動作確認
最後に、実装したcache bustingが正常に動作することを確認します。

index.cssを以下のように変更します。

```css:sample_app/static/css/index.css(編集)
.test-text {
  color: #00ff00;
}
```

編集したらcollectstaticしておきます。

```sh:shell
python manage.py collectstatic
```

ページを(通常のF5)リロードすると、ページに表示されていた文字列の色が変わりました。
![スクリーンショット 2021-12-20 11.30.12.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/982526/41729d96-1dfb-ede2-a641-73975e85083d.png)

開発者ツールでcssファイルのパスを確認すると、先ほどと違うクエリ文字列になっていることがわかります。

```html
<!-- 先ほどのクエリ文字列は 1639965343 だった -->
<link href="/staticfiles/css/index.css?v=1639967407" rel="stylesheet" type="text/css">
```

# 最後に
cache bustingを用いることで、ユーザーが毎回キャッシュを削除してリロードしなくても、サーバー側が使わせたい静的ファイルを強制読み込みさせることが可能になります。
静的ファイルを利用する際には、ぜひ導入してみましょう！