【Spring Boot】非同期処理(Ajax)を実装する|簡単な掲示板アプリの作成
Spring BootとJavaScriptを使い、ページを再読み込みしない「いいね」機能を実装します。@RestControllerでJSON形式のデータを返すAPIを作成し、JavaScriptのfetch APIで非同期通信を行う方法が学べます。CSRF対策にも対応し、サーバーからの応答に応じていいねの数やボタンの表示を動的に更新する実践的な手法を解説します。
開発環境
- OS: Windows10
- Visual Studio Code: 1.73.0
- Java: OpenJDK 23
- Spring Boot: 3.4.0
- Lombok: 1.18.30
- Thymeleaf Layout Dialect: 3.3.0
- Spring Security: 6.1.1
- Thymeleaf Extras Spring Security: 3.1.2.RELEASE
- maven-compiler-plugin: 3.8.1
- Spring Boot Starter Mail: 3.4.0
- Spring Boot Starter Validation: 3.4.0
サンプルコード
/src/main/java/com/example/bbs/controller/LikeController.java
/src/main/java/com/example/bbs/controller/LikeController.java1 import com.example.bbs.service.PostService; 2 import com.example.bbs.service.UserService; 3 4-import org.springframework.stereotype.Controller; 5+import java.util.HashMap; 6+ 7 import org.springframework.web.bind.annotation.PathVariable; 8 import org.springframework.web.bind.annotation.PostMapping; 9 import org.springframework.web.bind.annotation.RequestMapping; 10+import org.springframework.web.bind.annotation.RestController; 11+ 12+import java.util.HashMap; 13+import java.util.Map; 14 15-@Controller 16+@RestController 17 @RequestMapping("/posts") 18 public class LikeController { 19 20 } 21 22 @PostMapping("/{postId}/like") 23- public String toggleLike(@PathVariable Long postId) { 24+ public Map<String, Object> toggleLike(@PathVariable Long postId) { 25+ // レスポンスの変数を定義 26+ Map<String, Object> response = new HashMap<>(); 27+ 28 // ログインユーザーを取得 29 User loggedInUser = userService.getCurrentUser(); 30 31 // いいねの切り替え処理 32 likeService.toggleLike(loggedInUser, post); 33 34- return "redirect:/posts/" + postId; 35+ // いいねの判定 36+ boolean isLiked = likeService.isLikedByUser(post, loggedInUser); 37+ 38+ // 最新のいいね数を取得 39+ int likeCount = likeService.countLikesForPost(post); 40+ 41+ // JSON レスポンスを返す 42+ response.put("isLiked", isLiked); 43+ response.put("likeCount", likeCount); 44+ return response; 45 } 46 } 47
/src/main/resources/templates/layout/layout.html
/src/main/resources/templates/layout/layout.html1 <head> 2 <meta charset="UTF-8"> 3 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 4+ <meta name="_csrf" th:content="${_csrf.token}"/> 5+ <meta name="_csrf_header" th:content="${_csrf.headerName}"/> 6 <title layout:fragment="title">掲示板</title> 7 <!-- Bootstrap CSS --> 8 <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-9ndCyUaIbzAi2FUVXJi0CjmCapSmO7SnpJef0486qhLnuZ2cdeRhO02iuK6FUUVM" crossorigin="anonymous"> 9
/src/main/resources/templates/posts/detail.html
/src/main/resources/templates/posts/detail.html1 </form> 2 </span> 3 <!-- いいねボタン --> 4- <form th:action="@{/posts/{id}/like(id=${post.id})}" method="POST"> 5- <button type="submit" th:class="${isLiked ? 'btn btn-danger' : 'btn btn-outline-danger'}"> 6- <i th:class="${isLiked ? 'bi bi-heart-fill' : 'bi bi-heart'}"></i> 7- </button> 8- </form> 9+ <button id="like-button" th:class="${isLiked ? 'btn btn-danger' : 'btn btn-outline-danger'}" th:data-post-id="${post.id}"> 10+ <i id="like-icon" th:class="${isLiked ? 'bi bi-heart-fill' : 'bi bi-heart'}"></i> 11+ </button> 12 13 <!-- いいね数の表示 --> 14- <p><span th:text="${likeCount}"></span> 件のいいね</p> 15+ <p><span id="like-count" th:text="${likeCount}"></span> 件のいいね</p> 16 17 <h2>Comments</h2> 18 <div class="mb-3"> 19 </div> 20 <button type="submit" class="btn btn-success">Add Comment</button> 21 </form> 22+ 23+ <script> 24+ document.addEventListener("DOMContentLoaded", function () { 25+ const likeButton = document.getElementById("like-button"); 26+ const likeIcon = document.getElementById("like-icon"); 27+ const likeCountElement = document.getElementById("like-count"); 28+ 29+ // CSRFトークンの取得 30+ const csrfToken = document.querySelector("meta[name='_csrf']").getAttribute("content"); 31+ const csrfHeader = document.querySelector("meta[name='_csrf_header']").getAttribute("content"); 32+ 33+ likeButton.addEventListener("click", function () { 34+ const postId = likeButton.getAttribute("data-post-id"); 35+ 36+ fetch(`/posts/${postId}/like`, { 37+ method: "POST", 38+ headers: { 39+ "Content-Type": "application/json", 40+ [csrfHeader]: csrfToken 41+ }, 42+ }) 43+ .then(response => response.json()) 44+ .then(data => { 45+ if (data.error) { 46+ alert(data.error); 47+ return; 48+ } 49+ 50+ // いいね状態の更新 51+ if (data.isLiked) { 52+ likeButton.classList.remove("btn-outline-danger"); 53+ likeButton.classList.add("btn-danger"); 54+ likeIcon.classList.remove("bi-heart"); 55+ likeIcon.classList.add("bi-heart-fill"); 56+ } else { 57+ likeButton.classList.remove("btn-danger"); 58+ likeButton.classList.add("btn-outline-danger"); 59+ likeIcon.classList.remove("bi-heart-fill"); 60+ likeIcon.classList.add("bi-heart"); 61+ } 62+ 63+ // いいね数の更新 64+ likeCountElement.textContent = data.likeCount; 65+ }) 66+ .catch(error => console.error("Error:", error)); 67+ }); 68+ }); 69+ </script> 70 </main> 71
コード解説
変更点: APIコントローラーへの変更
/src/main/java/com/example/bbs/controller/LikeController.java1-import org.springframework.stereotype.Controller; 2+import org.springframework.web.bind.annotation.RestController; 3 4-@Controller 5+@RestController 6 @RequestMapping("/posts") 7 public class LikeController {
これまで使っていた@Controllerは、主にHTMLなどの画面を返す役割を持っていました。しかし、今回の非同期処理では画面全体を返すのではなく、データ(今回はJSON形式)だけを返す必要があります。
そこで、@RestControllerに変更しています。@RestControllerは、@Controllerの機能に加え、メソッドの戻り値を自動的にJSONなどのデータ形式に変換してレスポンスとして返す役割を持っています。これにより、JavaScriptとデータをやり取りするAPI(Application Programming Interface)を簡単に作成できます。
変更点: メソッドの戻り値をJSONデータに変更
/src/main/java/com/example/bbs/controller/LikeController.java1 @PostMapping("/{postId}/like") 2- public String toggleLike(@PathVariable Long postId) { 3+ public Map<String, Object> toggleLike(@PathVariable Long postId) { 4+ // レスポンスの変数を定義 5+ Map<String, Object> response = new HashMap<>(); 6+ 7 // ... (中略) ... 8 9- return "redirect:/posts/" + postId; 10+ // いいねの判定 11+ boolean isLiked = likeService.isLikedByUser(post, loggedInUser); 12+ 13+ // 最新のいいね数を取得 14+ int likeCount = likeService.countLikesForPost(post); 15+ 16+ // JSON レスポンスを返す 17+ response.put("isLiked", isLiked); 18+ response.put("likeCount", likeCount); 19+ return response; 20 } 21 }
いいねボタンを押した後の処理を変更しています。
以前は"redirect:/posts/" + postIdという文字列を返し、ブラウザにページを再読み込み(リダイレクト)させていました。
非同期処理ではページを再読み込みせず、JavaScriptで画面を更新するために必要なデータだけを返します。そのために、メソッドの戻り値をMap<String, Object>型に変更しました。Mapはキーと値のペアでデータを保持できるもので、ここに「現在のいいねの状態(isLiked)」と「最新のいいね数(likeCount)」を格納しています。
@RestControllerのおかげで、このMapオブジェクトは自動的に { "isLiked": true, "likeCount": 10 } のようなJSON形式のデータに変換されて、ブラウザに返されます。
変更点: CSRF対策用の情報をHTMLに埋め込み
/src/main/resources/templates/layout/layout.html1 <head> 2 <meta charset="UTF-8"> 3 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 4+ <meta name="_csrf" th:content="${_csrf.token}"/> 5+ <meta name="_csrf_header" th:content="${_csrf.headerName}"/> 6 <title layout:fragment="title">掲示板</title>
JavaScriptから安全にサーバーへリクエストを送信するため、CSRF(クロスサイトリクエストフォージェリ)というセキュリティ攻撃への対策情報をHTMLに埋め込んでいます。
Spring Securityが自動で生成するCSRFトークン(一種の合言葉)とそのヘッダー名を、<meta>タグとしてHTMLの<head>部分に追加しました。th:contentはThymeleafの機能で、サーバー側で実際のトークン情報に置き換えられます。
こうすることで、後述するJavaScriptコードがこの<meta>タグを読み取り、リクエストに正しいCSRFトークンを含めることができるようになります。
変更点: いいねボタンをフォームから単純なボタンへ変更
/src/main/resources/templates/posts/detail.html1 <!-- いいねボタン --> 2- <form th:action="@{/posts/{id}/like(id=${post.id})}" method="POST"> 3- <button type="submit" th:class="${isLiked ? 'btn btn-danger' : 'btn btn-outline-danger'}"> 4- <i th:class="${isLiked ? 'bi bi-heart-fill' : 'bi bi-heart'}"></i> 5- </button> 6- </form> 7+ <button id="like-button" th:class="${isLiked ? 'btn btn-danger' : 'btn btn-outline-danger'}" th:data-post-id="${post.id}"> 8+ <i id="like-icon" th:class="${isLiked ? 'bi bi-heart-fill' : 'bi bi-heart'}"></i> 9+ </button>
以前は<form>を使っていいねの情報をサーバーに送信していましたが、今回はJavaScriptで通信処理を制御するため、<form>を削除し、単純な<button>タグに変更しました。
この変更に伴い、JavaScriptからこのボタンやアイコンを簡単に特定できるように、それぞれid="like-button"とid="like-icon"というID属性を追加しています。
また、th:data-post-id="${post.id}"という属性を追加しました。これは、JavaScriptが「どの投稿に対するいいねボタンか」を識別するための投稿IDを、ボタンの属性として保持させておくためのものです。
変更点: いいね数の表示要素にIDを追加
/src/main/resources/templates/posts/detail.html1 <!-- いいね数の表示 --> 2- <p><span th:text="${likeCount}"></span> 件のいいね</p> 3+ <p><span id="like-count" th:text="${likeCount}"></span> 件のいいね</p>
いいね数を表示している<span>タグにid="like-count"というID属性を追加しました。
これにより、JavaScriptはサーバーから最新のいいね数を受け取った後、このIDを目印にして<span>タグを見つけ、表示されている数値を新しいものに書き換えることができます。画面の一部だけを動的に更新するために必要な準備です。
変更点: 非同期通信を行うJavaScriptコードの追加
/src/main/resources/templates/posts/detail.html1+ <script> 2+ document.addEventListener("DOMContentLoaded", function () { 3+ // ... (要素の取得など) ... 4+ likeButton.addEventListener("click", function () { 5+ // ... (非同期通信処理) ... 6+ }); 7+ }); 8+ </script> 9 </main>
ページの最後に<script>タグを追加し、いいね機能を実現するためのJavaScriptコードを記述しています。全体の流れは以下の通りです。
DOMContentLoaded: HTMLページの読み込みが完了したタイミングで、中のコードが実行されるようにします。- 要素の取得:
getElementByIdなどを使って、操作対象のボタンやアイコン、いいね数の表示部分をあらかじめ取得しておきます。 addEventListener: いいねボタンに「クリックされたとき」の処理を登録します。ボタンがクリックされると、登録された関数が実行されます。- 非同期通信:関数の中で、後述する
fetchを使ってサーバーと通信し、いいねの状態や数を更新します。
変更点: fetch APIによる非同期通信とCSRF対策
/src/main/resources/templates/posts/detail.html1+ // CSRFトークンの取得 2+ const csrfToken = document.querySelector("meta[name='_csrf']").getAttribute("content"); 3+ const csrfHeader = document.querySelector("meta[name='_csrf_header']").getAttribute("content"); 4+ 5+ likeButton.addEventListener("click", function () { 6+ const postId = likeButton.getAttribute("data-post-id"); 7+ 8+ fetch(`/posts/${postId}/like`, { 9+ method: "POST", 10+ headers: { 11+ "Content-Type": "application/json", 12+ [csrfHeader]: csrfToken 13+ }, 14+ }) 15+ // ... (レスポンス処理) ... 16+ });
この部分が非同期通信の中核です。
まず、先ほどHTMLに埋め込んだ<meta>タグからCSRFトークンとヘッダー名を取得しています。
次に、fetch関数を使ってサーバーへリクエストを送信します。
- URL:
likeButtonから投稿IDを取得し、リクエスト先のURL(例:/posts/1/like)を組み立てています。 method: "POST": POSTメソッドでリクエストを送信することを指定します。headers: リクエストヘッダーを設定します。ここで取得したCSRFトークンをヘッダーに含めることで、サーバーは正規のリクエストであると判断し、安全に処理を実行できます。
fetchを使うことで、ブラウザはページ全体を再読み込みすることなく、裏側でサーバーと通信できます。これがAjax(非同期通信)の実現方法です。
変更点: サーバーからの応答に応じた画面更新処理
/src/main/resources/templates/posts/detail.html1+ .then(response => response.json()) 2+ .then(data => { 3+ if (data.error) { 4+ alert(data.error); 5+ return; 6+ } 7+ 8+ // いいね状態の更新 9+ if (data.isLiked) { 10+ likeButton.classList.remove("btn-outline-danger"); 11+ likeButton.classList.add("btn-danger"); 12+ likeIcon.classList.remove("bi-heart"); 13+ likeIcon.classList.add("bi-heart-fill"); 14+ } else { 15+ likeButton.classList.remove("btn-danger"); 16+ likeButton.classList.add("btn-outline-danger"); 17+ likeIcon.classList.remove("bi-heart-fill"); 18+ likeIcon.classList.add("bi-heart"); 19+ } 20+ 21+ // いいね数の更新 22+ likeCountElement.textContent = data.likeCount; 23+ }) 24+ .catch(error => console.error("Error:", error));
fetchによる通信が成功した後、サーバーからのレスポンスを処理する部分です。
.then(response => response.json())で、サーバーから返されたJSON形式のレスポンスを、JavaScriptが扱えるオブジェクト形式に変換します。
次の.then(data => { ... })で、変換されたデータ(data)を使って画面を更新します。
- いいね状態の更新:
data.isLikedがtrueかfalseかに応じて、if文で処理を分岐させます。CSSのクラス(btn-dangerやbi-heart-fillなど)を付けたり外したりすることで、ボタンの色やハートのアイコン(塗りつぶし/枠線)を切り替えています。 - いいね数の更新:
data.likeCountに入っている最新のいいね数を、id="like-count"を持つ<span>タグのテキストとして設定し、画面の表示を書き換えます。
これにより、ページを再読み込みすることなく、ユーザーの操作結果が即座に画面に反映される対話的な機能が実現できています。
おわりに
お疲れ様でした。今回はSpring BootとJavaScriptを使い、ページを再読み込みしない「いいね」機能を実装する方法を学びました。サーバー側では@RestControllerを使ってHTMLではなくJSON形式でデータを返すAPIを作成し、フロントエンドではJavaScriptのfetch APIでそのAPIと通信しました。サーバーからの応答に応じてボタンの色やいいね数を動的に書き換えることで、ユーザー体験の良いインタラクティブな機能が実現できることを理解できたはずです。