SpringBootの静的リソース機能でSPAを配信する際のリソースハンドラ設定

SpringBootの静的リソース配信機能は非常に便利な機能ですが、トップページ以外はファイル名を拡張子まで指定しなければアクセスできません。
一般的なSPAにはHTML5 History APIを利用したルーティング機能があり、ページ単位に複数のURLが割り当てられる一方、用意するHTMLファイルとしてはindex.htmlのみとなります。
このため、SpringBootのデフォルト設定のまま静的リソースとしてSPAを配信した場合、トップページ以外へ直接アクセスした場合や、画面リフレッシュ時に正しくページが表示できません。

# 実現したいこと

下記の要件を満たす設定方法を考えていきます。

- トップページへ直接アクセスした場合でも目的のページが表示されること。
- 画面リフレッシュ時にも元のページが表示されること。
- jsやcssファイルなどへのアクセスは、対象のファイルが存在しない場合に404を返すこと。

アクセス対象がページかファイルかの判断には、拡張子の有無を利用します。拡張子のないURLに対するアクセスはページへのアクセスとみなし、index.htmlを返します。

# 対処法1: SPA側でhashによるルーティングを使用する
最も簡単な方法は、SPA側のrouter設定でhashモードを使用することです（下記はNuxt.jsの例です）。

``` js:nuxt.config.js
module.exports = {
  mode: 'spa',
  router: {
    mode: 'hash'
  },
  ......
```

SPA側のルーティングにかかわらず、サーバーへのリクエストは常にトップページへのアクセスになります。
SpringBootのデフォルトの設定では、WelcomePageとしてindex.htmlがレスポンスとして返るため、すべてのページURLが有効になります。

#対処法2: SpringBootのリクエストマッピングを設定する
historyモードをあきらめたくない場合は、SpringBoot側でコンフィギュレーションする必要があります。

## デフォルト設定の静的リソースの配信について
そもそもSpringBootの静的リソース配信について、デフォルトではどのように設定されているのか確認してみます。

デフォルトの設定はspring-boot-autoconfigureモジュールの[WebMvcAutoConfiguration.java](https://github.com/spring-projects/spring-boot/blob/v2.0.2.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java)に記述されています。このクラスでは、下記の静的リソースに対するマッピングが設定されます。

- [welcome page](https://github.com/spring-projects/spring-boot/blob/v2.0.2.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java#L345)
- [favicon.ico](https://github.com/spring-projects/spring-boot/blob/v2.0.2.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java#L398)
- [webjars](https://github.com/spring-projects/spring-boot/blob/v2.0.2.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java#L322)
- [それ以外の静的リソース](https://github.com/spring-projects/spring-boot/blob/v2.0.2.RELEASE/spring-boot-project/spring-boot-autoconfigure/src/main/java/org/springframework/boot/autoconfigure/web/servlet/WebMvcAutoConfiguration.java#L330)

`org.springframework.web`のログレベルをDEBUGに設定しておくと、起動時のログ出力で下記の通り設定されていることが確認できます。

``` log:app.log
2018-06-05 23:17:29.654  INFO 18345 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/webjars/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2018-06-05 23:17:29.655  INFO 18345 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2018-06-05 23:17:29.732  INFO 18345 --- [           main] o.s.b.a.w.s.WelcomePageHandlerMapping    : Adding welcome page: class path resource [static/index.html]
2018-06-05 23:17:29.915 DEBUG 18345 --- [           main] o.s.w.s.resource.ResourceUrlProvider     : Looking for resource handler mappings
2018-06-05 23:17:29.916 DEBUG 18345 --- [           main] o.s.w.s.resource.ResourceUrlProvider     : Found resource handler mapping: URL pattern="/**/favicon.ico", locations=[class path resource [META-INF/resources/], class path resource [resources/], class path resource [static/], class path resource [public/], ServletContext resource [/], class path resource []], resolvers=[org.springframework.web.servlet.resource.PathResourceResolver@3b4ef7]
2018-06-05 23:17:29.916 DEBUG 18345 --- [           main] o.s.w.s.resource.ResourceUrlProvider     : Found resource handler mapping: URL pattern="/webjars/**", locations=[class path resource [META-INF/resources/webjars/]], resolvers=[org.springframework.web.servlet.resource.PathResourceResolver@1af05b03]
2018-06-05 23:17:29.916 DEBUG 18345 --- [           main] o.s.w.s.resource.ResourceUrlProvider     : Found resource handler mapping: URL pattern="/**", locations=[class path resource [META-INF/resources/], class path resource [resources/], class path resource [static/], class path resource [public/], ServletContext resource [/]], resolvers=[org.springframework.web.servlet.resource.PathResourceResolver@5987e932]
```

## 設定方法
### 静的リソースのマッピングパターンを変更する
拡張子のついたパスに対してのみ、静的リソースを返すようにapplication.ymlに設定を追加します。デフォルトの静的リソースへのマッピングパターンは、`spring.mvc.static-path-pattern`に定義されているため、これを上書きします。

``` yaml:application.yml
spring:
  mvc:
    static-path-pattern: /**/*.*
```

### ページURLに対するリソースハンドラを追加する
上記の設定により、ページURLへのアクセスはデフォルトの静的リソースハンドラのマッピング設定からは外れます。
そこで、ページURLへのアクセスに対して対象リソースが存在しない場合にindex.htmlを返すように、WebMvcAutoConfiguration.javaを参考に、下記のConfigurationを定義します。

``` java:Html5HistoryModeResourceConfig.java
@Configuration
public class Html5HistoryModeResourceConfig implements WebMvcConfigurer {

    @Autowired
    private ResourceProperties resourceProperties;

    @Override
    public void addResourceHandlers(ResourceHandlerRegistry registry) {
        registry
            .addResourceHandler("/**")
            .addResourceLocations(resourceProperties.getStaticLocations())
            .resourceChain(false)
            .addResolver(new SpaPageResourceResolver());
    }

    public static class SpaPageResourceResolver extends PathResourceResolver {
        @Override
        protected Resource getResource(String resourcePath, Resource location) throws IOException {
            Resource resource = super.getResource(resourcePath, location);
            return resource != null ? resource : super.getResource("index.html", location);
        }
    }
}
```

デフォルト設定が優先して動作するので、それ以外のすべてのパスに対してリソースハンドラを設定しています。
静的リソースの格納パスについては`spring.resources.static-locations`の設定をそのまま使えるようにするため、ResourcePropertiesのインスタンスから取得しています。
最後の`addResolver`で登録している`SpaPageResourceResolver`で、ルートパスのindex.htmlのレスポンスを返しています。

上記のConfigurationクラスが起動時に読み込まれれば、起動時のログに下記の通り表示されるはずです。

``` log:app.log
2018-06-05 23:42:22.481  INFO 18576 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/webjars/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2018-06-05 23:42:22.481  INFO 18576 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**/*.*] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2018-06-05 23:42:22.481  INFO 18576 --- [           main] o.s.w.s.handler.SimpleUrlHandlerMapping  : Mapped URL path [/**] onto handler of type [class org.springframework.web.servlet.resource.ResourceHttpRequestHandler]
2018-06-05 23:42:22.781 DEBUG 18576 --- [           main] o.s.w.s.resource.ResourceUrlProvider     : Looking for resource handler mappings
2018-06-05 23:42:22.782 DEBUG 18576 --- [           main] o.s.w.s.resource.ResourceUrlProvider     : Found resource handler mapping: URL pattern="/**/favicon.ico", locations=[class path resource [META-INF/resources/], class path resource [resources/], class path resource [static/], class path resource [public/], ServletContext resource [/], class path resource []], resolvers=[org.springframework.web.servlet.resource.PathResourceResolver@1af1347d]
2018-06-05 23:42:22.782 DEBUG 18576 --- [           main] o.s.w.s.resource.ResourceUrlProvider     : Found resource handler mapping: URL pattern="/webjars/**", locations=[class path resource [META-INF/resources/webjars/]], resolvers=[org.springframework.web.servlet.resource.PathResourceResolver@632aa1a3]
2018-06-05 23:42:22.782 DEBUG 18576 --- [           main] o.s.w.s.resource.ResourceUrlProvider     : Found resource handler mapping: URL pattern="/**/*.*", locations=[class path resource [META-INF/resources/], class path resource [resources/], class path resource [static/], class path resource [public/], ServletContext resource [/]], resolvers=[org.springframework.web.servlet.resource.PathResourceResolver@20765ed5]
2018-06-05 23:42:22.782 DEBUG 18576 --- [           main] o.s.w.s.resource.ResourceUrlProvider     : Found resource handler mapping: URL pattern="/**", locations=[class path resource [META-INF/resources/], class path resource [resources/], class path resource [static/], class path resource [public/]], resolvers=[com.test.spa.Html5HistoryModeResourceConfig$SpaPageResourceResolver@3b582111]
```

# まとめ

SPAのデプロイについては、本格的な運用をやSSRを考えると他にももっと良い方法があるかと思いますが、Jenkinsなどのようにjarファイル単体で起動すれば画面も一緒についてくるようなアプリを作りたい場合は、今回の設定は有効かと思います。

# 参考リンク

Spring Boot Reference Guide - 27.1.5 Static Content
https://docs.spring.io/spring-boot/docs/current/reference/html/boot-features-developing-web-applications.html#boot-features-spring-mvc-static-content

Spring MVC(+Spring Boot)上での静的リソースへのアクセスを理解する - Qiita https://qiita.com/kazuki43zoo/items/e12a72d4ac4de418ee37

Goslings開発メモ - その5: Spring Boot最終編 (静的リソース処理) | To Be Decided https://www.kaitoy.xyz/2017/01/24/goslings-development-memo5-spring-boot-static-resources/

html5 - Spring Boot with AngularJS html5Mode - Stack Overflow https://stackoverflow.com/questions/24837715/spring-boot-with-angularjs-html5mode

spring - Springboot/Angular2 - How to handle HTML5 urls? - Stack Overflow https://stackoverflow.com/questions/38516667/springboot-angular2-how-to-handle-html5-urls