Flaskで毎回キャッシュを使わずに読み込ませる

2021-06-25

Flask1では静的なコンテンツは指定したディレクトリからしか引き出せないようになっています。デフォルトはstaticです。

/static/css/main.cssのようにアクセスすることでディレクトリ内のファイルが使えます。

この時、ヘッダーに自動的にlast_modifiedを設定しています。そのため、ユーザーの2回目以降のアクセス時にif_modifiedつきでアクセスされ304になり、キャッシュ(Cache)が利用されます。

last_modifiedを自分で設定することで、キャッシュを使わずに毎回読み込ませることが可能になります。

解決策

WSGIアプリケーションのクラスFlaskのインスタンスを作る際に、static_folderを無効にします。

これで、/staticのURLにview関数を登録する部分がスキップされます。

app = Flask(__name__, static_folder=None)

/staticのURLにview関数を登録します。

この時、任意の条件にマッチした場合にlast_modifiedを指定します。今回はアクセスされるたびに現在の時刻を指定しています。

@app.route('/static/<path:filename>')
def static(filename):
    directory_path = os.path.join(app.root_path, 'static')

    last_modified = None
    if '.css' in filename:
        last_modified = datetime.datetime.now(datetime.timezone.utc)

    max_age = app.get_send_file_max_age(filename)
    return send_from_directory(
        directory_path, filename, last_modified=last_modified, max_age=max_age
    )

これで、キャッシュを利用せず毎回読み込ませることが可能になりました。


view関数(static)の補足

endpointを設定してあげることで、view関数の名前(static)を変えてもurl_forが使えるようになります。

@app.route('/static/<path:filename>', endpoint='static')
def my_static(filename):
    ...
<link rel="stylesheet" href="{{url_for('static', filename='main.css')}}"/>

実装を追う

/staticにアクセスした際のview関数をどこで設定しているのかを確認します。

(コード上で重要でなさそうなところは...で省略しています。)


Flaskのapp.py__init__の中で設定されていることがわかります。

# Flask/app.py

class Flask(Scaffold):
        def __init__(self, ...):
            ...

            if self.has_static_folder:
                ...

                self.add_url_rule(
                    f"{self.static_url_path}/<path:filename>",
                    endpoint="static",
                    host=static_host,
                    view_func=lambda **kw: self_ref().send_static_file(**kw),
                )

lambda式でsend_static_fileを使用していますが、この関数は 結局のところflask.send_from_directoryです。

# Flask/scaffold.py

def send_static_file(self, filename: str) -> "Response":
    ...
    return send_from_directory(
        t.cast(str, self.static_folder), filename, max_age=max_age
    )

そして結局のところwerkzeug.utils.send_from_directoryです。

# Flask/helpers.py

def send_from_directory(
    directory: t.Union[os.PathLike, str],
    path: t.Union[os.PathLike, str],
    filename: t.Optional[str] = None,
    **kwargs: t.Any,
) -> "Response":
 
    ...

    return werkzeug.utils.send_from_directory(
        directory, path, **_prepare_send_file_kwargs(**kwargs)
    )

そして結局のところwerkzeug.utils.send_fileです。

# werkzeug/utils.py

def send_from_directory(
    directory: t.Union[os.PathLike, str],
    path: t.Union[os.PathLike, str],
    environ: "WSGIEnvironment",
    **kwargs: t.Any,
) -> "Response":
    
    path = safe_join(os.fspath(directory), os.fspath(path))

    ...

    return send_file(path, environ, **kwargs)

werkzeug.utils.send_fileはDocstringとコメントを除いて150行くらいの関数です。

**kwargsを使って上から値を渡し続けているので、 flask.send_from_directorylast_modified=を設定することで解決できることがわかりました。

# werkzeug/utils.py

def send_file(
    ...
    last_modified: t.Optional[t.Union[datetime, int, float]] = None,
    ...
) -> "Response":

        ...
    
    if response_class is None:
        from .wrappers import Response

        response_class = Response
      
    rv = response_class(
        data, mimetype=mimetype, headers=headers, direct_passthrough=True
    )

    ...

    if last_modified is not None:
        rv.last_modified = last_modified
    elif mtime is not None:
        rv.last_modified = mtime

    ...

    return rv

補足

last_modifiedが設定されていない場合はos.stat()を使用し、ファイルの最終内容更新日時を採用しているようです。

# werkzeug/utils.py

def send_file(...) -> "Response":
    if isinstance(path_or_file, (os.PathLike, str)) or hasattr(
            path_or_file, "__fspath__"
        ):
            path_or_file = t.cast(t.Union[os.PathLike, str], path_or_file)

            if _root_path is not None:
                path = os.path.join(_root_path, path_or_file)
            else:
                path = os.path.abspath(path_or_file)
        
            stat = os.stat(path)
            size = stat.st_size
            mtime = stat.st_mtime
        else:
            file = path_or_file

        ...

おまけ1 - Cache Busting

更新したファイルがある場合、ファイル名にクエリ文字を追加することでキャッシュは利用されなくなります。

<link rel="stylesheet" href="main.css?v=1.0.2"/>

おまけ2 - /static

self.static_url_pathは本当にデフォルトでstaticなのかを確認します。

# Flask/app.py

class Flask(Scaffold):
        def __init__(
        self,
        ...
        static_url_path: t.Optional[str] = None,
        static_folder: t.Optional[str] = "static",
        ...
    ):
        super().__init__(
            ...
            static_folder=static_folder,
            static_url_path=static_url_path,
            ...
        )

この時Scaffoldに渡されてself.static_url_pathstaticだとわかりました。

# Flask/scaffold.py

class Scaffold:
    ...

    @property
    def static_url_path(self) -> t.Optional[str]:
        if self._static_url_path is not None:
            return self._static_url_path

        if self.static_folder is not None:
            basename = os.path.basename(self.static_folder)
            return f"/{basename}".rstrip("/")

        return None

    @static_url_path.setter
    def static_url_path(self, value: t.Optional[str]) -> None:
        if value is not None:
            value = value.rstrip("/")

        self._static_url_path = value

参考文献