はじめに
今どきな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 INITIALIZRで生成したプロジェクトが
- 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
- バックエンドのソースファイル用ディレクトリ
- バックエンドのコードを一箇所にまとめつつ、フロントエンドと混在しないようにするためディレクトリを分ける
- gradle(主だったもののみ)
- rootディレクトリに以下のようにgradleとnpmの構成ファイル類を配置する
- spring bootのjarにSPAを同梱する
サンプルコード
バックエンド
仕様
-
text/plain
を返す簡単なWEB APIがある - SPAをホストする
- 今回のメイン
- 未ログインでAPIにアクセスすると401をレスポンス
- 未ログインでPAGEにアクセスすると
/login
にリダイレクト - ログアウトは
/logout
にPOST
設計
- バックエンド用のソースとわかりやすいようにsrcフォルダをsrc_backに変更
- 存在しないパスへのアクセスはすべて
classpath:static/index.html
にフォワードして、SPAにアクセスさせる- 今回のメイン
-
/api/hello
にGETすると、一郎
,次郎
,三郎
のいずれかの名前をtext/plain
で返す - 未ログインで
/api/**
にアクセスした場合、401
レスポンスを返す -
/login
のパスでログイン可能- credentialsはuser/password
-
/api/**
以外のパスにアクセスした場合、/login
にリダイレクトする - ログアウトは
/logout
にPOST - 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のホストがメインのため、以降は蛇足です。
開発サーバ起動時のログインとログアウトをやりやすいように工夫したので、そこを解説します
仕様というか設計というか
- topとotherページの2種類を用意する
- topアクセス時、
/api/hello
にGETし、返ってきた文字列を画面に表示する- reloadボタンで再度
/api/hello
にアクセス
- reloadボタンで再度
- otherにアクセスした場合、固定ページを表示する
- バックエンドから401が返ってきた場合、
/login
にリダイレクトする - SPA上のログアウトボタンでログアウト可能
設計
- ログイン用のコンポーネントを用意する
- ログアウト用のコンポーネントを定義する
- ログアウト用のコンポーネントを読み込ませる
- 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="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する場合
このようにログアウトボタンを表示するだけです
<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から起動して下さい。
参考にしたサイトなど
-
SpringBootの静的リソース機能でSPAを配信する際のリソースハンドラ設定
- リソースハンドラを参考にしました。感謝です!