Spring Boot3.2 SPA組み込み時の想定外の404エラーを解決する方法
はじめに
SPA(シングルページアプリケーション)を作成して、それを公開しようとした場合、方法はいくつもありますが、バックエンドの処理のためにSpring Bootを使用する場合には、Spring BootにSPAのリソースを組み込んで、公開することがあります。
この方法には、SPAのリソースを組み込んだSpring Bootのプロジェクトをビルドし、jarファイルにパッケージ化すれば、SPAのリソースを別途配置する必要がなくなり、公開手順を簡素化できるといったメリットや、Spring Securityを利用して、組み込んだリソースのアクセスに制限をかけるといったことも可能となるといったメリットがあります。
しかしながら、メリットだけかといえば、そうとも言えず、SPA単体では生じない問題が生じる場合もあります。
今回はSPAを組み込んだSpring Bootプロジェクトの問題点の一つである想定外の404エラーと、その解決方法を紹介したいと思います。
サンプルプロジェクトの準備
とりあえず、Spring Bootプロジェクトを用意しないと説明ができないので、まずはSpring BootのプロジェクトをSpring Initializrで用意します。
今回はウェブサイトのSpring Initializrで作成しましたが、EclipseなどのIDEでは新規でプロジェクトを作成するメニューの中にSpring Initializrを利用してプロジェクトを作成するメニューがあるため、そちらを利用しても問題ありません。
Spring Initializrでの設定は、Projectを「Maven」、Languageを「Java」、Spring Bootを「3.2.4」、Packagingを「jar」、Javaを「21」とし、Dependenciesは「Spring Web」のみとしています。
次に、今回はSPAとしてVueを利用するため、VS Codeで以下のコマンドを実行して、プロジェクトを作成します。
npm init vue@latest
設定はTypeScriptとVue Routerの項目だけ「Yes」で、他は「No」としました。
あとはcdコマンドでプロジェクトのフォルダに移動するか、プロジェクトのフォルダを開き直して、以下のコマンドでパッケージのインストールとビルドを行います。
npm install
npm run build
ビルドが完了するとVueのプロジェクトフォルダ内のdistフォルダにファイルが生成されますので、これをSpring Bootのプロジェクトフォルダ内のsrc/main/resourcesにあるstaticフォルダに、コピーします。
これでSpring Bootのプロジェクトの準備が完了しました!
サンプルプロジェクトの問題点 想定外の404エラー
準備が完了しましたので、実際に動かしてみましょう。
IDEはSpring BootのMavenプロジェクトが実行できれば、何でも問題ありませんが、ここでは「Eclipse 2024」に先ほど作成したSpring Bootのプロジェクトをインポートしています。
プロジェクトのインポートができたら、Mavenプロジェクトの更新(maven install)を行った後、Spring Bootアプリケーションの実行(maven spring-boot:run)を行います。
※Mavenプロジェクトの更新などのEclipse上の各メニューの詳細については、ここでは割愛します。
Spring Bootアプリケーションの実行に成功すると、以下のようにEclipseのコンソールに表示されます。
. ____ _ __ _ _
/\\ / ___'_ __ _ _(_)_ __ __ _ \ \ \ \
( ( )\___ | '_ | '_| | '_ \/ _` | \ \ \ \
\\/ ___)| |_)| | | | | || (_| | ) ) ) )
' |____| .__|_| |_|_| |_\__, | / / / /
=========|_|==============|___/=/_/_/_/
[32m :: Spring Boot :: [39m [2m (v3.2.4) [0;39m
[2m2024-04-05T15:45:21.101+09:00 [0;39m [32m INFO [0;39m [35m5728 [0;39m [2m--- [0;39m [2m[not-found-page-avoidance-backend] [ main] [0;39m [2m [0;39m [36m.NotFoundPageAvoidanceBackendApplication [0;39m [2m: [0;39m Starting NotFoundPageAvoidanceBackendApplication using Java 17.0.10 with PID 5728 (C:\pleiades2024\workspace\not-found-page-avoidance-backend\target\classes started by y-kashima in C:\pleiades2024\workspace\not-found-page-avoidance-backend)
[2m2024-04-05T15:45:21.101+09:00 [0;39m [32m INFO [0;39m [35m5728 [0;39m [2m--- [0;39m [2m[not-found-page-avoidance-backend] [ main] [0;39m [2m [0;39m [36m.NotFoundPageAvoidanceBackendApplication [0;39m [2m: [0;39m No active profile set, falling back to 1 default profile: "default"
[2m2024-04-05T15:45:21.628+09:00 [0;39m [32m INFO [0;39m [35m5728 [0;39m [2m--- [0;39m [2m[not-found-page-avoidance-backend] [ main] [0;39m [2m [0;39m [36mo.s.b.w.embedded.tomcat.TomcatWebServer [0;39m [2m: [0;39m Tomcat initialized with port 8080 (http)
[2m2024-04-05T15:45:21.644+09:00 [0;39m [32m INFO [0;39m [35m5728 [0;39m [2m--- [0;39m [2m[not-found-page-avoidance-backend] [ main] [0;39m [2m [0;39m [36mo.apache.catalina.core.StandardService [0;39m [2m: [0;39m Starting service [Tomcat]
[2m2024-04-05T15:45:21.644+09:00 [0;39m [32m INFO [0;39m [35m5728 [0;39m [2m--- [0;39m [2m[not-found-page-avoidance-backend] [ main] [0;39m [2m [0;39m [36mo.apache.catalina.core.StandardEngine [0;39m [2m: [0;39m Starting Servlet engine: [Apache Tomcat/10.1.19]
[2m2024-04-05T15:45:21.675+09:00 [0;39m [32m INFO [0;39m [35m5728 [0;39m [2m--- [0;39m [2m[not-found-page-avoidance-backend] [ main] [0;39m [2m [0;39m [36mo.a.c.c.C.[Tomcat].[localhost].[/] [0;39m [2m: [0;39m Initializing Spring embedded WebApplicationContext
[2m2024-04-05T15:45:21.675+09:00 [0;39m [32m INFO [0;39m [35m5728 [0;39m [2m--- [0;39m [2m[not-found-page-avoidance-backend] [ main] [0;39m [2m [0;39m [36mw.s.c.ServletWebServerApplicationContext [0;39m [2m: [0;39m Root WebApplicationContext: initialization completed in 531 ms
[2m2024-04-05T15:45:21.753+09:00 [0;39m [32m INFO [0;39m [35m5728 [0;39m [2m--- [0;39m [2m[not-found-page-avoidance-backend] [ main] [0;39m [2m [0;39m [36mo.s.b.a.w.s.WelcomePageHandlerMapping [0;39m [2m: [0;39m Adding welcome page: class path resource [static/index.html]
[2m2024-04-05T15:45:21.894+09:00 [0;39m [32m INFO [0;39m [35m5728 [0;39m [2m--- [0;39m [2m[not-found-page-avoidance-backend] [ main] [0;39m [2m [0;39m [36mo.s.b.w.embedded.tomcat.TomcatWebServer [0;39m [2m: [0;39m Tomcat started on port 8080 (http) with context path ''
[2m2024-04-05T15:45:21.910+09:00 [0;39m [32m INFO [0;39m [35m5728 [0;39m [2m--- [0;39m [2m[not-found-page-avoidance-backend] [ main] [0;39m [2m [0;39m [36m.NotFoundPageAvoidanceBackendApplication [0;39m [2m: [0;39m Started NotFoundPageAvoidanceBackendApplication in 1.057 seconds (process running for 1.408)
「Tomcat initialized with port 8080 (http)」とあるように、Spring Boot内のTomcatがポート番号8080で起動して、以下のURLにアクセスできるようになります。
http://localhost:8080
ブラウザで上記のURLにアクセスしてみましょう。
「Homeページ」が表示されることが確認できました。
次に「About」リンクをクリックして「Aboutページ」に遷移してみます。
一見、問題ないように見えますが…
ここでF5キーを打鍵して画面を更新してみましょう。
えぇ………なにこれ………
想定外の404エラーが発生し、「Whitelabel Web Page」というページが表示されてしまいました…
「Whitelabel Web Page」はどこから来ているのか?
この「Whitelabel Web Page」ですが、いったいどこから来ているのでしょうか?
「Whitelabel Web Page」が出る原因の前に、まずこれがどこから来ているのかを探ってみましょう。
Spring Bootのプロジェクトを探ると以下のAbstractErrorWebExceptionHandler.javaのrenderDefaultErrorViewメソッドでhtmlが生成されていることを発見することができます。
/**
* Render a default HTML "Whitelabel Error Page".
* <p>
* Useful when no other error view is available in the application.
* @param responseBody the error response being built
* @param error the error data as a map
* @return a Publisher of the {@link ServerResponse}
*/
protected Mono<ServerResponse> renderDefaultErrorView(ServerResponse.BodyBuilder responseBody,
Map<String, Object> error) {
StringBuilder builder = new StringBuilder();
Date timestamp = (Date) error.get("timestamp");
Object message = error.get("message");
Object trace = error.get("trace");
Object requestId = error.get("requestId");
builder.append("<html><body><h1>Whitelabel Error Page</h1>")
.append("<p>This application has no configured error view, so you are seeing this as a fallback.</p>")
.append("<div id='created'>")
.append(timestamp)
.append("</div>")
.append("<div>[")
.append(requestId)
.append("] There was an unexpected error (type=")
.append(htmlEscape(error.get("error")))
.append(", status=")
.append(htmlEscape(error.get("status")))
.append(").</div>");
if (message != null) {
builder.append("<div>").append(htmlEscape(message)).append("</div>");
}
if (trace != null) {
builder.append("<div style='white-space:pre-wrap;'>").append(htmlEscape(trace)).append("</div>");
}
builder.append("</body></html>");
return responseBody.bodyValue(builder.toString());
}
この事から「Whitelabel Web Page」はSpring Bootが出しているものと考えることができます。
なぜ「Whitelabel Web Page」が出てしまうのか?
なぜSpring Bootは画面更新で「Whitelabel Web Page」を出したのでしょうか?
「Whitelabel Web Page」が表示されるまでの手順を詳しく検証してみましょう。
まず、ブラウザで「http://localhost:8080
」にアクセスしたとき、Spring Bootはsrc/main/resourcesにあるstaticフォルダ内のindex.htmlを返します。このとき、Vue Routerでpathが「/」のときは「Homeページ」に遷移することになっているため、「Homeページ」が表示されます。
次に「About」リンクをクリックしたとき、今度はSpring Bootを介さず、Vue Routerで「Aboutページ」に遷移しています。
最後に「Aboutページ」でF5キーを打鍵して画面を更新すると、Spring Bootは「http://localhost:8080/about
」というURLを取得し、このURLを元にsrc/main/resourcesにあるstaticフォルダ内にaboutというファイルを探しに行きます。
ここで、src/main/resourcesにあるstaticフォルダを確認してみましょう。
aboutというファイルもフォルダもありません。
Vue Routerに「/about」というパスが設定されていても、それをSpring Bootは知りません。
Spring Bootからしたら「そのパスにファイルないんですけど…」という状態です。
このため、Spring Bootは404(Not Found)エラーを返し、かつ404エラー用のページをSpring Bootに設定していないため、Spring Bootがデフォルトとして用意してくれている「Whitelabel Web Page」が出ていたというわけです。
「Whitelabel Web Page」の解決方法①
画面の更新で「Whitelabel Web Page」が出るのは、適切な状態とは言えません。
これから「Whitelabel Web Page」が出る状態を修正していきましょう。
まず、フロントエンド側の修正で何とかしてみましょう。
フロントエンド側での修正方法は簡単でrouter/index.tsをcreateWebHistory関数からcreateWebHashHistory関数に変更するだけです。
import { createRouter, createWebHashHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHashHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue')
}
]
})
export default router
変更後、ビルドを実行して、distフォルダのファイルで、Spring Bootのプロジェクトフォルダ内のsrc/main/resourcesにあるstaticフォルダを置き換えます。
置き換え後、再度、Spring Bootアプリケーションを実行して、「Aboutページ」に遷移後、「Aboutページ」でF5キーを打鍵して画面を更新してみましょう。
今度は「Whitelabel Web Page」にならず、「Aboutページ」が表示されています。
なぜ「Whitelabel Web Page」が出なくなったのでしょうか?
URLが「http://localhost:8080/about
」ではなく、「http://localhost:8080/#/about
」になっていること気づいたかと思いますが、#が付いたことにより、#以降がパスではなく、ページ内の位置を指し示すURLフラグメントとして扱われるようになります。
このため、URLが「http://localhost:8080/#/about
」のときに画面の更新をしても、Spring Bootがsrc/main/resourcesにあるstaticフォルダ内のindex.htmlを参照するようになり、Vue Routerで「Aboutページ」に遷移するようになります。
これで「Whitelabel Web Page」が出る状態を修正できました!
「Whitelabel Web Page」の解決方法②
さて、「Whitelabel Web Page」が出る状態は修正できました…できましたが………
今度はURLに#が付くようになってしまいました…
#が付くのは誤りではありませんが、一般的なURLで#が付いたURLはあまりないので、少々違和感があります。
可能であれば#が付かないURLで「Whitelabel Web Page」が出る状態を修正したいところです。
今度はバックエンド側で修正できないか考えてみましょう。
URLが「http://localhost:8080/about
」のとき、index.htmlを参照できないので、「Whitelabel Web Page」が出てしまっているのであれば、404エラーのときもindex.htmlを参照するようにしてしまえばよさそうです。
いくつか修正方法はありそうですが、今回は、Spring Bootのエラーページのカスタマイズ方法を参考にErrorControllerインターフェイスを実装するクラスを以下のように作成します。
package com.example.notfoundpageavoidancebackend.controller;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class CustomErrorController implements ErrorController {
@GetMapping(value = "/error")
public String handleError(HttpServletRequest request, HttpServletResponse response) {
// ステータスコード取得
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
if (status != null) {
Integer statusCode = Integer.valueOf(status.toString());
// ステータスコードが404のとき
if (statusCode == HttpServletResponse.SC_NOT_FOUND) {
return "/index.html";
}
}
return "error";
}
}
内容は簡単で、ステータスが404のときに"/index.html"を返しているだけです。
staticフォルダ内のリソースを元に戻してから、再度、Spring Bootアプリケーションを実行して、「Aboutページ」でF5キーを打鍵して画面を更新してみましょう。
「Whitelabel Web Page」にならず、「Aboutページ」が表示されました!
一見、これで問題なさそうですが…実は問題が残っています。
Chromeの開発者ツールで見るとステータスが404になっています。
要件によっては問題ない場合もあるかとは思いますが、Googleなどのクローラはステータスが404のページはインデックス登録しませんし、メールにリンクを記載していて、そのリンクがステータスとして404を返すような場合は、リンクがおかしいと判断されて迷惑メール扱いになるといった問題も発生するため、ほとんどの場合、この状態は許容されません。
修正方法は単純で、先ほどのソースでステータス200を返すようにするだけです。
package com.example.notfoundpageavoidancebackend.controller;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class CustomErrorController implements ErrorController {
@GetMapping(value = "/error")
public String handleError(HttpServletRequest request, HttpServletResponse response) {
// ステータスコード取得
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
if (status != null) {
Integer statusCode = Integer.valueOf(status.toString());
// ステータスコードが404のとき
if (statusCode == HttpServletResponse.SC_NOT_FOUND && uri != null) {
// ステータスコードを200に変更
response.setStatus(HttpServletResponse.SC_OK);
return "/index.html";
}
}
return "error";
}
}
もう一度、「Aboutページ」の画面更新時のステータスをChromeの開発者ツールで見てみましょう。
「Whitelabel Web Page」にならず、かつステータスで200が返ってくるようになりました!
本当に問題ないのか
バックエンド側で対処する「Whitelabel Web Page」の解決方法②を紹介しましたが、本当にこれで問題ないでしょうか?
「Whitelabel Web Page」の解決は達成されているので、これで問題はないといえばそうなのですが、もう少しURLを変えて確認してみましょう。
「http://localhost:8080/home
」でアクセスしても、「http://localhost:8080/user
」でアクセスしても以下のような画面となり、ステータスは200が返ってきます。
「別に問題ないのでは?」と思うかもしれませんが、クローラの性質を考慮すると問題が見えてきます。
リンクとして、誰かが誤ったURLを使用していた場合、クローラにインデックス登録はしてほしくないところですが、「/home
」でも「/user
」でもインデックス登録されてしまう可能性があります。「/home
」や「/user
」ぐらいならまだよいですが、もっと変なURLでインデックス登録されることもあるかもしれません…
フロントエンド側で404ページを実装する
変なURLでインデックス登録をされないようにするために、まずはフロントエンド側で以下のように404ページを実装します。
<template>
<div class="notfound">
<h1>This is a 404(not found) page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.notfound {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
次にVue Routerで実装した404ページに遷移できるようにします。
パスは「'/:pathMatch(.*)*'
」としており、これで他に一致するパスがなければ、404ページに遷移するようになります。
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '../views/HomeView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{
path: '/',
name: 'home',
component: HomeView
},
{
path: '/about',
name: 'about',
// route level code-splitting
// this generates a separate chunk (About.[hash].js) for this route
// which is lazy-loaded when the route is visited.
component: () => import('../views/AboutView.vue')
},
{
path: '/:pathMatch(.*)*',
name: 'notfound',
component: () => import('../views/NotFoundView.vue')
}
]
})
export default router
実際に動かしてみましょう。
「http://localhost:8080/home
」で404ページが表示されるようになりました!
ただ、これだけではインデックス登録される問題は完全には解決されていません。
index.htmlに<meta name="robots" content="all">
を追加し、NotFoundView.vueにはscriptタグを追加して、metaタグを書き換える処理を加えます。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta name="robots" content="all">
<title>Vite App</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>
<script setup lang="ts">
// エラー画面がインデックス登録されないようにする
document.querySelector("meta[name='robots']")?.setAttribute("content", "noindex");
</script>
<template>
<div class="notfound">
<h1>This is a 404(not found) page</h1>
</div>
</template>
<style>
@media (min-width: 1024px) {
.notfound {
min-height: 100vh;
display: flex;
align-items: center;
}
}
</style>
これで404ページがインデックス登録されないようになります。
ちなみにindex.htmlの方を"noindex"として、HomeView.vueなどで"all"に置き換えるようにしても問題ないように思われるかもしれませんが、これはやってはいけません。
"noindex"が検出された場合、その時点で、GoogleのクローラはレンダリングとJavaScriptの実行を止めてしまいます。JavaScriptで"all"に置き換える処理が入っていても、先に"noindex"が検出された場合は、Googleのクローラは"all"を検知しないため、注意が必要です。
404ページのステータスは404にしたい
404ページのステータスが200でも前章で説明したクローラにインデックス登録させない対応を入れておけば問題ないかと思いますが、404ページのステータスは200ではなく404にしたいという方もいるかと思います。
その場合には以下のように分岐させて、存在するページのときだけ200を返すようにすれば対応可能です。
package com.example.notfoundpageavoidancebackend.controller;
import jakarta.servlet.RequestDispatcher;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.boot.web.servlet.error.ErrorController;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@Controller
public class CustomErrorController implements ErrorController {
private static final String[] existPathList = new String[] { "(?i)^\\/about\\/?$" };
@GetMapping(value = "/error")
public String handleError(HttpServletRequest request, HttpServletResponse response) {
// ステータスコード取得
Object status = request.getAttribute(RequestDispatcher.ERROR_STATUS_CODE);
// URI取得
Object uri = request.getAttribute(RequestDispatcher.ERROR_REQUEST_URI);
if (status != null) {
Integer statusCode = Integer.valueOf(status.toString());
// ステータスコードが404のとき
if (statusCode == HttpServletResponse.SC_NOT_FOUND) {
if (uri != null ) {
for (String path : existPathList) {
if (uri.toString().matches(path)) {
// ステータスコードを200に変更
response.setStatus(HttpServletResponse.SC_OK);
}
}
}
return "/index.html";
}
}
return "error";
}
}
正規表現"(?i)^\\/about\\/?$"
とマッチした場合のみ、ステータスとして200を返すようにしています。
(?i)
で大文字小文字を区別しないようにしていて、\\/?$
で末尾に/がつく場合とつかない場合、どちらもマッチするようにしている点は失念しやすいため、注意してください。
404ページでステータス404を返すことができました!
(頭痛が痛いみたいだな…)
ただ、この方法はページ数が少ないうちはよいですが、ページ数が多くなってきたときに注意しないと、404ページ以外でもステータス404を返すページが発生してしまう可能性があります。
この方法を採用する場合には、ページ数が増えていったときに不備が生じないようにする工夫、例えばE2Eテストでステータスをチェックすることなども合わせて検討しておく必要があります。
まとめ
以上がSpring BootにSPAを組み込んだ際の想定外の404エラーを解決する方法になります。
ちなみに画面更新時に404エラーが出るというのは、AWSのCloudFrontとS3の組み合わせでSPAを公開したときなど、他の環境でも度々遭遇する事象です。
修正方法は環境によってもちろん違いますが、index.htmlを参照するようにすることやステータス200を返すようにするといった考え方自体は同じなので、考え方を覚えておくと他の環境でも役に立ちます。
今回の最終的なソースコードは以下に上げていますので、参考としてください。
参考文献
この記事は以下の情報を参考にして執筆しました。
Discussion