文系プログラマによるTIPSブログ

文系プログラマ脳の私が開発現場で学んだ事やプログラミングのTIPSをまとめています。

Spring bootで静的リソースにキャッシュ回避のための「?xxx=xxx」を自動追加する

できるんだな、これが。
f:id:treeapps:20180802010416p:plain

引き続き真面目な記事です。

今回のお題は「静的リソースのキャッシュを回避するための古典的な方法をSpring bootで行う。しかも自動で。」というものです。

何らかの理由で spring.resources.chain.strategy.fixed でのリソースバージョニングを行えない環境の場合に、従来の古典的な「ビルド毎にリソースの末尾にQueryStringを追加する」という手法を取らざるを得ない場合に使えるかもしれません。

キャッシュ回避の方法

正直もっと他にやり方があるだろうし、力技なのであまりいい方法とは思えないのですが、とりあえず実装する事はできます。

何をカスタマイズして実装するか

Spring MVCは、静的リソースにアクセスするためのHandlerとしてorg.springframework.web.servlet.resource.ResourceHttpRequestHandlerというクラスを提供しています。
ResourceHttpRequestHandlerを利用すると、
DispatcherServletを経由での静的リソースへのアクセス
リソース毎(リソースのパターン毎)のキャッシュ制御
クラスパスや任意のディレクトリに格納されているファイルへのアクセス
バージョン付きのパス経由での静的リソースへのアクセス
Gzip化された静的リソースへのアクセス
WebJar内の静的リソースへのアクセス
などを実現することができます。

http://qiita.com/kazuki43zoo/items/e12a72d4ac4de418ee37

こちらの解説にSpringには静的リソースのハンドラがあるとの事なので、これをカスタマイズします。
spring-framework/ResourceUrlEncodingFilter.java at 4.3.x · spring-projects/spring-framework · GitHub

この中の encodeURL というメソッドがあるので、ここで返すURLにQueryStringを追加してしまいます。

Let's カスタマイズ

まず、application.yml で spring.resources.chain.strategy で fixed か content を設定している場合はコメントアウトして無効にしておきます。

続いて、自分のプロジェクトに「org.springframework.web.servlet.resource」というパッケージを作ります。そこに ResourceUrlEncodingFilter と ResourceUrlEncodingResponseWrapper というクラスを作ります。(jar側の実装ではResourceUrlEncodingFilter内にResourceUrlEncodingResponseWrapperがありますが、DIしづらいので分けます)

それぞれ以下のように実装します。

ResourceUrlEncodingFilter.java
package org.springframework.web.servlet.resource;

import java.io.IOException;

import javax.servlet.FilterChain;
import javax.servlet.ServletException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.env.Environment;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;

@Component
public class ResourceUrlEncodingFilter extends OncePerRequestFilter {

    @Autowired
    private Environment env;

    @Override
    protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain) throws ServletException, IOException {
        filterChain.doFilter(request, new ResourceUrlEncodingResponseWrapper(request, response, env));
    }
}
ResourceUrlEncodingResponseWrapper.java

※ Eclipse Collectionsとlombok を使用しています。

package org.springframework.web.servlet.resource;

import java.util.Optional;

import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;

import org.eclipse.collections.api.list.ImmutableList;
import org.eclipse.collections.impl.factory.Lists;
import org.springframework.core.env.Environment;
import org.springframework.web.servlet.resource.ResourceUrlProvider;
import org.springframework.web.servlet.resource.ResourceUrlProviderExposingInterceptor;
import org.springframework.web.util.UrlPathHelper;

import lombok.extern.slf4j.Slf4j;

@Slf4j
public class ResourceUrlEncodingResponseWrapper extends HttpServletResponseWrapper {

    private Environment env;

    private final HttpServletRequest request;

    /*
     * Cache the index and prefix of the path within the DispatcherServlet
     * mapping
     */
    private Integer indexLookupPath;

    private String prefixLookupPath;

    public ResourceUrlEncodingResponseWrapper(HttpServletRequest request, HttpServletResponse wrapped,
            Environment env) {
        super(wrapped);
        this.request = request;
        this.env = env;
    }

    @Override
    public String encodeURL(String url) {
        ResourceUrlProvider resourceUrlProvider = getResourceUrlProvider();
        if (resourceUrlProvider == null) {
            log.debug("Request attribute exposing ResourceUrlProvider not found");
            return super.encodeURL(url);
        }

        // ▼▼▼▼▼▼ ここでカスタマイズ ▼▼▼▼▼▼
        try {
            ImmutableList<String> staticDirs = Lists.immutable.of("/js/", "/css/", "/font/", "/images/");
            boolean isStatic = false;
            for (String dir : staticDirs) {
                if (!url.contains("/static/") || !url.contains(dir))
                    continue;
                isStatic = true;
                break;
            }
            if (isStatic) {
                String version = Optional.ofNullable(env.getProperty("STATIC_VERSION"))
                        .orElse(String.valueOf(System.currentTimeMillis()));
                if (url.contains("?"))
                    url += "&t=" + version;
                else
                    url += "?t=" + version;
            }
        } catch (Exception e) {
            log.error(e.getMessage(), e);
        }
        // ▲▲▲▲▲▲ ここでカスタマイズ ▲▲▲▲▲▲

        initLookupPath(resourceUrlProvider);
        if (url.startsWith(this.prefixLookupPath)) {
            int suffixIndex = getQueryParamsIndex(url);
            String suffix = url.substring(suffixIndex);
            String lookupPath = url.substring(this.indexLookupPath, suffixIndex);
            lookupPath = resourceUrlProvider.getForLookupPath(lookupPath);
            if (lookupPath != null) {
                return super.encodeURL(this.prefixLookupPath + lookupPath + suffix);
            }
        }

        return super.encodeURL(url);
    }

    private ResourceUrlProvider getResourceUrlProvider() {
        return (ResourceUrlProvider) this.request
                .getAttribute(ResourceUrlProviderExposingInterceptor.RESOURCE_URL_PROVIDER_ATTR);
    }

    private void initLookupPath(ResourceUrlProvider urlProvider) {
        if (this.indexLookupPath == null) {
            UrlPathHelper pathHelper = urlProvider.getUrlPathHelper();
            String requestUri = pathHelper.getRequestUri(this.request);
            String lookupPath = pathHelper.getLookupPathForRequest(this.request);
            this.indexLookupPath = requestUri.lastIndexOf(lookupPath);
            this.prefixLookupPath = requestUri.substring(0, this.indexLookupPath);

            if ("/".equals(lookupPath) && !"/".equals(requestUri)) {
                String contextPath = pathHelper.getContextPath(this.request);
                if (requestUri.equals(contextPath)) {
                    this.indexLookupPath = requestUri.length();
                    this.prefixLookupPath = requestUri;
                }
            }
        }
    }

    private int getQueryParamsIndex(String url) {
        int index = url.indexOf("?");
        return (index > 0 ? index : url.length());
    }
}

↑これを見て皆さんこう思うことでしょう・・・


こりゃひでえ・・・


酷いですね。

酷いですが一応解説しておくと、URLのエンコード処理がされる前に、URLにQueryStringが付いている事にしてそのまま処理させています。jsやcssだけでなく、自分で指定した静的リソース全てに対して横断的にQueryStringを付ける事ができます。

実行結果は以下のようになります。common.jsは「th:src="@{/static/js/common.js}"」で読み込み、index.jsは「src="/static/js/index.js"」で読み込んでいます。thymeleafのth:xxxで読み込んだ場合だけQueryStringが追加されます。

<script type="application/javascript" src="/static/js/common.js?t=1477121888209"></script>
<script type="application/javascript" src="/static/js/index.js"></script>

Environmentを使って環境変数STATIC_VERSIONから任意のバージョン番号を取得し、値が取得できたらそれを使い続ける、無ければアクセス毎に変化するタイムスタンプ値を返す(ローカル環境の場合は常にキャッシュクリアして欲しいので画面リロード毎にQueryString値を変える)、という事をやっています。

環境変数を使う事で、ビルドした時にバージョン番号生成し、以降は生成されたバージョン番号を使いまわす、という事が可能になります。キャッシュがクリアされるタイミングはビルドしたタイミングのみ(バージョンが新規に振られた時のみ)です。

これで以下のhtmlをビルド時にAnt等のReplaceTokenでVERSIONをリテラルに置換する仕込み作業は不要になります。

<script type="application/javascript" src="/static/js/index.js${VERSION}"></script>

今後はバージョニングを意識せず「th:src="@{/static/js/index.js}"」と書けば自動的にQueryStringが追加されるようになります。ReplaceToken方式だとhtmlに手動で${VERSION}を追加する人力作業が必要なので、うっかり付け忘れてクライアントから「おい画面崩れてる(cssが古い)し画像が更新されてねーぞ!!」と怒られる心配も減りそうです。

Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発

Spring徹底入門 Spring FrameworkによるJavaアプリケーション開発