SPA(Nuxt.js)をSpring Bootからホストする方法

## はじめに
今どきなWEBアプリを作ろうとすると、画面はSPA、データ入出力（CRUDな操作）や複雑なビジネスロジックはWEB APIという構成が選ばれがちだと思います。

現在開発中の弊社のサービスでもフロントエンドのSPAにNuxt.js、バックエンドにSpring Bootといった構成をとることになりそうな気配を察知したため、個人的に検証作業を行いました。

他社様でも有用な情報になるかと思うので公開します。

SPAをSpring Bootからホストする方法のみが知りたい方は、[サンプルコードのここ](https://github.com/kogayushi/nuxtjs-spring-boot/blob/master/src_back/main/java/com/example/nuxtspringboot/Html5HistoryModeResourceConfig.java)を参照していただければブログ全文読む必要はないです（たぶん）。

## 目的
- 画面はSPA(Nuxt.js)で作りたい
- バックエンドはSpring Bootで作りたい
- フロントエンドの開発はHMRを利用して開発したい
- バックエンドの開発はspring bootのdevtools使って開発したい

## 状況
- フロントエンドもバックエンドも同じ人（チーム）が作る
- 同じ人（チーム）が並行して作るからデプロイタイミングも同じ（にしてもいい）
	- フロントエンドに修正が入る場合は大抵バックエンドも修正が入る
	- フロントエンドとバックエンドで別々にデプロイできたほうが柔軟ではあるが、少人数チームでアプリも小規模で高頻度リリースが可能なら別に同時でも良い
		- 別々にデプロイする手順や環境の構築が手間だったりする
		- フロント専門チームなどができたタイミングでSPAをSpring Bootのアプリから分離すると良いかもしれない

## 技術スタック(主なところを抜粋)
### バックエンド
- Java 8
	- SPRING INITIALIZRで生成したプロジェクトが`8`だからそのままにしてるだけ
- Spring Boot 2.1.1
	- Spring MVC
	- Spring Security
- gradle 4.10.2

### フロントエンド
- nuxt 2.3.4
- @nuxtjs/axios 5.3.6

## 設計及び実装
### 全体設計
- ディレクトリ構成
	- rootディレクトリに以下のようにgradleとnpmの構成ファイル類を配置する
		- gradle(主だったもののみ)
			- build
			- build.gradle
			- settings.gradle
		- npm(主だったもののみ)
			- node_modules
			- nuxt.config.js
			- package.json
			- package-lock.json
		- src_front
			- フロントエンドのソースファイル用ディレクトリ
			- フロントエンドのコードを一箇所にまとめつつ、バックエンドのものと混在しないようにするためディレクトリを分ける
		- src_back
			- バックエンドのソースファイル用ディレクトリ
			- バックエンドのコードを一箇所にまとめつつ、フロントエンドと混在しないようにするためディレクトリを分ける
- spring bootのjarにSPAを同梱する

### サンプルコード
https://github.com/kogayushi/nuxtjs-spring-boot

## バックエンド
### 仕様
1. `text/plain`を返す簡単なWEB APIがある
2. SPAをホストする
	- 今回のメイン
3. 未ログインでAPIにアクセスすると401をレスポンス
4. 未ログインでPAGEにアクセスすると`/login`にリダイレクト
5. ログアウトは`/logout`にPOST

### 設計
1. バックエンド用のソースとわかりやすいようにsrcフォルダをsrc_backに変更
2. 存在しないパスへのアクセスはすべて`classpath:static/index.html`にフォワードして、SPAにアクセスさせる
	- 今回のメイン
3. `/api/hello`にGETすると、`一郎`,`次郎`,`三郎`のいずれかの名前を`text/plain`で返す
4. 未ログインで`/api/**`にアクセスした場合、`401`レスポンスを返す
5. `/login`のパスでログイン可能
	- credentialsはuser/password
6. `/api/**`以外のパスにアクセスした場合、`/login`にリダイレクトする
7. ログアウトは`/logout`にPOST
8. CSRF TOKENはcookieに出力する
	- SPAからのajaxで利用するため

### 実装及び説明
#### バックエンド用のソースとわかりやすいようにsrcフォルダをsrc_backに変更
build.gradleを以下のように変更して、ソースファイル用ディレクトリを変更します

```groovy
sourceSets {
  main {
    java {
      srcDir 'src_back/main/java'
    }
    resources {
      srcDir 'src_back/main/resources'
    }
  }
  test {
    java {
      srcDir 'src_back/test/java'
    }
    resources {
      srcDir 'src_back/test/resources'
    }
  }
}
```
#### `/api/hello`にGETすると、`一郎`,`次郎`,`三郎`のいずれかの名前を`text/plain`で返す
簡単なので割愛します
[サンプルコード](https://github.com/kogayushi/nuxtjs-spring-boot/blob/master/src_back/main/java/com/example/nuxtspringboot/SampleResource.java)を参照して下さい
#### `/api/**` 以外のパスへのアクセスは、すべて`index.html`にフォワードして、SPAにアクセスさせる
`PathResourceResolver`を実装して、アクセスされた静的リソースが取得できなかった場合、`index.html`をレスポンス

以下ソースコードと解説です

```java
@RequiredArgsConstructor
@Configuration
public class Html5HistoryModeResourceConfig implements WebMvcConfigurer {

  private final ResourceProperties resourceProperties;

  @Override
  public void addResourceHandlers(ResourceHandlerRegistry registry) {
    registry.addResourceHandler("/**") // 全パスをこのリソースハンドラーの処理対象にする
            .addResourceLocations(resourceProperties.getStaticLocations()) // 静的リソース配置先のパスを指定する
            .resourceChain(resourceProperties.getChain().isCache()) // 開発時はfalse、本番はtrueが望ましい。trueにしておくとメモリ上にキャッシュされるためI/Oが軽減される
            .addResolver(new SpaPageResourceResolver()); // 拡張したPathResourceResolverを読み込ませる
  }

  public static class SpaPageResourceResolver extends PathResourceResolver {
    @Override
    protected Resource getResource(String resourcePath, Resource location) throws IOException {
      Resource resource = super.getResource(resourcePath, location); // まずはPathResourceResolverで静的リソースを取得する
      return resource != null ? resource : super.getResource("index.html", location); // 取得できなかった場合は、index.htmlを返す
    }
  }
}
```

#### Spring SecurityのJava Configuration
Java Configurationで以下を設定していきます

- 未ログインで`/api/**`にアクセスした場合、`401`レスポンスを返す
- `/login`のパスでログイン可能
	- credentialsはuser/password
- `/api/**`以外のパスにアクセスした場合、`/login`にリダイレクトする
- ログアウトは`/logout`にPOST
- CSRF TOKENはcookieに出力する

なお、ログインページはデフォルトのものを使用します

以下ソースコードと解説です。

```java
@Configuration
public class SpringSecurityConfiguration extends WebSecurityConfigurerAdapter {
  @Override
  protected void configure(HttpSecurity http) throws Exception {
    // loginとlogoutを設定する。基本的な設定なので詳細は割愛
    http
      .authorizeRequests()
      .anyRequest().authenticated()
      .and()
      .formLogin()
      .permitAll()
      .and()
      .logout()
      .permitAll();

    http.exceptionHandling()
        // '/api/**'へ未認証状態でのアクセスには401を返す
        .defaultAuthenticationEntryPointFor(new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), new AntPathRequestMatcher("/api/**"))
        // 上記パス以外への未認証状態へのアクセスは302リダイレクトで'/login'へ遷移させる
        .defaultAuthenticationEntryPointFor(new LoginUrlAuthenticationEntryPoint("/login"), AnyRequestMatcher.INSTANCE);

    CookieCsrfTokenRepository cookieCsrfTokenRepository = new CookieCsrfTokenRepository(); // ajaxでcsrf tokenを利用するのでcookieに出力する
    cookieCsrfTokenRepository.setCookieHttpOnly(false); // ajaxでも利用するため、httpOnlyはfalseにしておく
    http.csrf().csrfTokenRepository(cookieCsrfTokenRepository);

    // RESTful APIを公開する場合、攻撃されやすくなるのでcorsの設定をしておく
    CorsConfiguration corsConfiguration = new CorsConfiguration();
    corsConfiguration.addAllowedMethod(CorsConfiguration.ALL);
    corsConfiguration.addAllowedHeader(CorsConfiguration.ALL);
    corsConfiguration.addAllowedOrigin("http://localhost:3000"); // 実際は環境ごとにドメインが変わるはずなので、設定で動的に変更でき料にする
    UrlBasedCorsConfigurationSource corsSource = new UrlBasedCorsConfigurationSource();
    corsSource.registerCorsConfiguration("/**", corsConfiguration); // すべてのパスを対象にする
    http.cors().configurationSource(corsSource);
  }

  // デモ用設定
  @Bean
  @Override
  public UserDetailsService userDetailsService() {
    UserDetails user =
      User.withDefaultPasswordEncoder()
          .username("user")
          .password("password")
          .roles("USER")
          .build();

    return new InMemoryUserDetailsManager(user);
  }
```



## フロントエンド
バックエンドでやったSPAのホストがメインのため、以降は蛇足です。
開発サーバ起動時のログインとログアウトをやりやすいように工夫したので、そこを解説します

### 仕様というか設計というか
1. topとotherページの2種類を用意する
2. topアクセス時、`/api/hello`にGETし、返ってきた文字列を画面に表示する
	- reloadボタンで再度`/api/hello`にアクセス
3. otherにアクセスした場合、固定ページを表示する
4. バックエンドから401が返ってきた場合、`/login`にリダイレクトする
5. SPA上のログアウトボタンでログアウト可能

### 設計
1. ログイン用のコンポーネントを用意する
2. ログアウト用のコンポーネントを定義する
3. ログアウト用のコンポーネントを読み込ませる
4. topとotherページを用意する


#### ログイン用のコンポーネントを用意する
コンポーネント表示時に、即座にバックエンドのログインページにリダイレクトさせます

未ログイン時`/login`にリダイレクトするが、開発中は`http://localhost:3000/login`に遷移することになります

そのため、SPA上に`/login`ページを用意しておき、アクセスされた瞬間に`beforeCreate`ライフサイクルフックを使って`http://localhost:8080/login`にリダイレクトするようにしています

以下ソースコードと解説です

```javascript
<template>
  <div>redirecting...</div> <!-- 一瞬表示されてしまうので、何かダミーを表示しておく -->
</template>

<script>
export default {
  beforeCreate() {
    // 表示された瞬間、バックエンドのログインページにリダイレクトさせる
    // 本番運用時は`/login`とログインページは一致しているのでこのコンポーネントは動かない
    window.location = 'http://localhost:8080/login'
  }
}
</script>

```

#### ログアウト用のコンポーネントを定義する
このコンポーネントではログアウトページにPOSTするためのcsrf tokenをformにセットするための一工夫をしています。

本番運用でログアウトする時は、`/logout`にPOSTすることになります。
しかし、開発中は`http://localhost:3000/logout`にPOSTすることになります。

このままではログアウトできません（バックエンドサーバからログアウトできない）。

そのため、SPA上に`/logout`ページを用意しておき、そのページ遷移した場合は、遷移直後にバックエンドサーバのログアウトに自動でPOSTするようにしておきます。

一瞬ログアウト用のページがちらつきますが、開発時のみなのでそこはご愛嬌ということで…。

またsubmit時にcsrf tokenを贈る必要があるため、jsでcookieからtokenを取得し、formにセットしています。

以下ソースコードと解説です

```javascript
<template>
  <form
    :action="url"
    method="post">
    <input
      ref="button"
      class="button--grey"
      type="submit"
      value="Sign Out">
    <input
      :value="xsrfToken"
      type="hidden"
      name="_csrf">
  </form>
</template>

<script>
export default {
  name: 'Logout',
  props: {
    // ログアウトURLをpropsで受け取る
    url: {
      type: String,
      required: true
    },
    // 自動submit有無のフラグ
    logoutOnLoad: {
      type: Boolean,
      default: false
    }
  },
  data: function() {
    return {
      xsrfToken: ''
    }
  },
  created() {
    // ログアウトページにPOSTするために、cookieからcsrf tokenを取得する
    this.xsrfToken = ((document.cookie + ';').match('XSRF-TOKEN=([^¥S;]*)') ||
      [])[1]

    // 開発時用機能、コンポーネントが作られたときにログアウトボタンを自動submitさせる
    if (this.logoutOnLoad) {
      // コンポーネントが描画されてないとsubmitできないので、描画を待つ
      this.$nextTick(function() {
        // refを利用してボタンを参照
        let button = this.$refs.button
        // submitする
        button.click()
      })
    }
  }
}
</script>
```

このコンポーネントの呼び出し方次第で、`/logout`にPOSTするか、ローカル開発中のバックエンドのログアウトにPOSTするかを制御します。

##### 単に`/logout`にPOSTする場合
このようにログアウトボタンを表示するだけです

```html
<logout url="/logout"/>
```

##### ローカルのバックエンドのログアウトにPOSTする
自動遷移するような見え方になるのでボタンを表示する必要がないため非表示にします

また、POST先がバックエンドになるため、完全なURIを渡すようにします

以下ソースコードと解説です

```html
    <logout
      v-show="false"
      :logout-on-load="true"
      url="http://localhost:8080/logout"/>
```


#### topとotherページを用意する
簡単なので、サンプルコードの[ここ](https://github.com/kogayushi/nuxtjs-spring-boot/blob/master/src_front/pages/index.vue)と[ここ](https://github.com/kogayushi/nuxtjs-spring-boot/blob/master/src_front/pages/other/index.vue)を参照して下さい

## 全体
### SPAをjarに同梱する
build.gradleで簡単に設定できます。

以下をbuild.gradleの最後の方に定義するだけです。

```groovy
task generateNuxtJs(type: NpmTask, dependsOn: 'npm_install') {
  args = ['run', 'generate']
}

generateNuxtJs.mustRunAfter compileJava
bootJar.dependsOn generateNuxtJs
```

## 完成
これで完成です。
IDEからフロントエンドアプリを`npm run dev`で、バックエンドアプリはSpring Boot Devtoolsから起動して下さい。


## 参考にしたサイトなど
- [SpringBootの静的リソース機能でSPAを配信する際のリソースハンドラ設定](https://qiita.com/clomie/items/bdda0a3467bcf6709e1c)
	- リソースハンドラを参考にしました。感謝です！