spring-boot
nuxt.js

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

はじめに

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

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

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

SPAをSpring Bootからホストする方法のみが知りたい方は、サンプルコードのここを参照していただければブログ全文読む必要はないです(たぶん)。

目的

  • 画面は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を以下のように変更して、ソースファイル用ディレクトリを変更します

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で返す

簡単なので割愛します
サンプルコードを参照して下さい

/api/** 以外のパスへのアクセスは、すべてindex.htmlにフォワードして、SPAにアクセスさせる

PathResourceResolverを実装して、アクセスされた静的リソースが取得できなかった場合、index.htmlをレスポンス

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

@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に出力する

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

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

@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にリダイレクトするようにしています

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

<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にセットしています。

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

<template>
  <form
    :action="url"
    method="post">
    <input
      ref="button"
      class="button--grey"
      type="submit"
      value="Sing 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する場合

このようにログアウトボタンを表示するだけです

<logout url="/logout"/>
ローカルのバックエンドのログアウトにPOSTする

自動遷移するような見え方になるのでボタンを表示する必要がないため非表示にします

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

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

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

topとotherページを用意する

簡単なので、サンプルコードのここここを参照して下さい

全体

SPAをjarに同梱する

build.gradleで簡単に設定できます。

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

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

generateNuxtJs.mustRunAfter compileJava
bootJar.dependsOn generateNuxtJs

完成

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

参考にしたサイトなど