【Spring Boot】検索機能を実装する|簡単な掲示板アプリの作成
この記事では、Spring Bootで作成した掲示板アプリに検索機能を追加する方法を学びます。投稿のタイトルや内容をキーワードで検索できるようにし、部分一致、前方一致、後方一致といった多様な検索方法を実装します。Spring Data JPAを活用したデータ検索と、Thymeleafを使った検索フォームの作成手順を具体的に解説します。
開発環境
- 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
サンプルコード
/src/main/java/com/example/bbs/controller/PostController.java
/src/main/java/com/example/bbs/controller/PostController.java1 import com.example.bbs.service.PostService; 2 import com.example.bbs.service.UserService; 3 import com.example.bbs.service.CommentService; 4+ 5+import java.util.List; 6+ 7 import org.springframework.stereotype.Controller; 8 import org.springframework.ui.Model; 9 import org.springframework.web.bind.annotation.*; 10 11 // 一覧表示 12 @GetMapping 13- public String listPosts(Model model) { 14- // ログインユーザーを取得 15- User loggedInUser = userService.getCurrentUser(); 16+ public String listPosts( 17+ @RequestParam(value = "keyword", required = false) String keyword, 18+ @RequestParam(value = "matchType", required = false) String matchType, 19+ Model model) { 20 21- // モデルに渡す 22+ // ログインユーザーを取得してモデルに渡す 23+ User loggedInUser = userService.getCurrentUser(); 24 model.addAttribute("loggedInUserId", loggedInUser.getId()); 25 26- model.addAttribute("posts", postService.findAll()); 27+ // 検索フォームの入力値を保持 28+ List<Post> posts; 29+ if (keyword != null && !keyword.isEmpty() && matchType != null && !matchType.isEmpty()) { 30+ posts = postService.searchPosts(keyword, matchType); 31+ } else { 32+ posts = postService.findAll(); 33+ } 34+ 35+ model.addAttribute("posts", posts); 36+ 37 return "posts/list"; 38 } 39 40
/src/main/java/com/example/bbs/repository/PostRepository.java
/src/main/java/com/example/bbs/repository/PostRepository.java1 package com.example.bbs.repository; 2 3 import com.example.bbs.model.Post; 4+ 5+import java.util.List; 6+ 7 import org.springframework.data.jpa.repository.JpaRepository; 8 9 public interface PostRepository extends JpaRepository<Post, Long> { 10+ // 部分一致 11+ List<Post> findByTitleContainingOrContentContaining(String titleKeyword, String contentKeyword); 12+ 13+ // 前方一致 14+ List<Post> findByTitleStartingWithOrContentStartingWith(String titlePrefix, String contentPrefix); 15+ 16+ // 後方一致 17+ List<Post> findByTitleEndingWithOrContentEndingWith(String titleSuffix, String contentSuffix); 18 } 19
/src/main/java/com/example/bbs/service/PostService.java
/src/main/java/com/example/bbs/service/PostService.java1 return postRepository.findAll(); 2 } 3 4+ public List<Post> searchPosts(String keyword, String matchType) { 5+ switch (matchType) { 6+ case "startswith": // 前方一致 7+ return postRepository.findByTitleStartingWithOrContentStartingWith(keyword, keyword); 8+ case "endswith": // 後方一致 9+ return postRepository.findByTitleEndingWithOrContentEndingWith(keyword, keyword); 10+ case "contains": // 部分一致(デフォルト) 11+ default: 12+ return postRepository.findByTitleContainingOrContentContaining(keyword, keyword); 13+ } 14+ } 15+ 16 public Optional<Post> findById(Long id) { 17 return postRepository.findById(id); 18 } 19
/src/main/resources/templates/posts/list.html
/src/main/resources/templates/posts/list.html1 <h1>Post List</h1> 2 <h2>新規投稿</h2> 3 <a href="/posts/new" class="btn btn-primary mb-3">New Post</a> 4+ 5+ <!-- 検索フォーム --> 6+ <form th:action="@{/posts}" method="get" class="mb-3"> 7+ <div class="row"> 8+ <div class="col-md-6"> 9+ <input type="text" name="keyword" class="form-control" placeholder="Search" th:value="${param.keyword}" required> 10+ </div> 11+ <div class="col-md-2"> 12+ <select name="matchType" class="form-control"> 13+ <option value="contains" th:selected="${param.matchType?.toString() == 'contains'}">部分一致</option> 14+ <option value="startswith" th:selected="${param.matchType?.toString() == 'startswith'}">前方一致</option> 15+ <option value="endswith" th:selected="${param.matchType?.toString() == 'endswith'}">後方一致</option> 16+ </select> 17+ </div> 18+ <div class="col-md-2"> 19+ <button type="submit" class="btn btn-primary">検索</button> 20+ </div> 21+ </div> 22+ </form> 23+ 24 <h2>掲示板一覧</h2> 25 <table class="table table-striped"> 26 <thead> 27
コード解説
変更点: PostRepositoryに検索用メソッドを追加
1 package com.example.bbs.repository; 2 3 import com.example.bbs.model.Post; 4+ 5+import java.util.List; 6+ 7 import org.springframework.data.jpa.repository.JpaRepository; 8 9 public interface PostRepository extends JpaRepository<Post, Long> { 10+ // 部分一致 11+ List<Post> findByTitleContainingOrContentContaining(String titleKeyword, String contentKeyword); 12+ 13+ // 前方一致 14+ List<Post> findByTitleStartingWithOrContentStartingWith(String titlePrefix, String contentPrefix); 15+ 16+ // 後方一致 17+ List<Post> findByTitleEndingWithOrContentEndingWith(String titleSuffix, String contentSuffix); 18 }
PostRepositoryは、データベースから投稿(Post)のデータを取得したり保存したりする役割を担うインターフェースです。Spring Data JPAという機能を使うと、ここに特定の命名規則に従ってメソッドを宣言するだけで、Spring Bootが自動的にデータベースへの検索処理を生成してくれます。
今回追加されたメソッドは、以下の3種類です。
-
findByTitleContainingOrContentContaining(String titleKeyword, String contentKeyword)- このメソッドは、投稿のタイトル(
Title)または内容(Content)に、指定されたキーワード(Containing)が含まれる(部分一致)投稿を検索します。Orが入っているため、「タイトルにキーワードが含まれるか、内容にキーワードが含まれるか、どちらか一方でも当てはまれば」検索結果に含めます。
- このメソッドは、投稿のタイトル(
-
findByTitleStartingWithOrContentStartingWith(String titlePrefix, String contentPrefix)- このメソッドは、投稿のタイトルまたは内容が、指定された文字列(
StartingWith)で始まる(前方一致)投稿を検索します。
- このメソッドは、投稿のタイトルまたは内容が、指定された文字列(
-
findByTitleEndingWithOrContentEndingWith(String titleSuffix, String contentSuffix)- このメソッドは、投稿のタイトルまたは内容が、指定された文字列(
EndingWith)で終わる(後方一致)投稿を検索します。
- このメソッドは、投稿のタイトルまたは内容が、指定された文字列(
これらのメソッドは、それぞれタイトルと内容の両方に対して検索を行えるように、titleKeyword(またはtitlePrefix, titleSuffix)とcontentKeyword(またはcontentPrefix, contentSuffix)という2つの引数を受け取ります。
変更点: PostServiceに検索ロジックを追加
1 return postRepository.findAll(); 2 } 3 4+ public List<Post> searchPosts(String keyword, String matchType) { 5+ switch (matchType) { 6+ case "startswith": // 前方一致 7+ return postRepository.findByTitleStartingWithOrContentStartingWith(keyword, keyword); 8+ case "endswith": // 後方一致 9+ return postRepository.findByTitleEndingWithOrContentEndingWith(keyword, keyword); 10+ case "contains": // 部分一致(デフォルト) 11+ default: 12+ return postRepository.findByTitleContainingOrContentContaining(keyword, keyword); 13+ } 14+ } 15+ 16 public Optional<Post> findById(Long id) { 17 return postRepository.findById(id); 18 }
PostServiceは、アプリケーションのビジネスロジック、つまり「何をするか」という処理を担当するクラスです。ここでは、新しくsearchPostsというメソッドが追加されました。
このsearchPostsメソッドは、ユーザーが入力した検索キーワード(keyword)と、どの種類の検索を行うかを示す検索タイプ(matchType)の2つの情報を受け取ります。
メソッドの中ではswitch文が使われています。これは、matchTypeの値に応じて異なる処理を実行するためのものです。
case "startswith":matchTypeが"startswith"(前方一致)の場合、postRepository.findByTitleStartingWithOrContentStartingWith(keyword, keyword)を呼び出します。これは、タイトルまたは内容がキーワードで始まる投稿を検索する処理です。case "endswith":matchTypeが"endswith"(後方一致)の場合、postRepository.findByTitleEndingWithOrContentEndingWith(keyword, keyword)を呼び出します。これは、タイトルまたは内容がキーワードで終わる投稿を検索する処理です。case "contains"またはdefault:matchTypeが"contains"(部分一致)の場合、または上記以外の値の場合(デフォルト処理)には、postRepository.findByTitleContainingOrContentContaining(keyword, keyword)を呼び出します。これは、タイトルまたは内容にキーワードが含まれる投稿を検索する処理です。
このように、PostServiceは受け取った検索タイプに応じて、PostRepositoryに定義した適切な検索メソッドを呼び出し、その結果を返します。
変更点: PostControllerで検索パラメータを受け取り、検索処理を呼び出す
1 import com.example.bbs.service.PostService; 2 import com.example.bbs.service.UserService; 3 import com.example.bbs.service.CommentService; 4+ 5+import java.util.List; 6+ 7 import org.springframework.stereotype.Controller; 8 import org.springframework.ui.Model; 9 import org.springframework.web.bind.annotation.*; 10 11 // 一覧表示 12 @GetMapping 13- public String listPosts(Model model) { 14- // ログインユーザーを取得 15- User loggedInUser = userService.getCurrentUser(); 16+ public String listPosts( 17+ @RequestParam(value = "keyword", required = false) String keyword, 18+ @RequestParam(value = "matchType", required = false) String matchType, 19+ Model model) { 20 21- // モデルに渡す 22+ // ログインユーザーを取得してモデルに渡す 23 User loggedInUser = userService.getCurrentUser(); 24 model.addAttribute("loggedInUserId", loggedInUser.getId()); 25 26- model.addAttribute("posts", postService.findAll()); 27+ // 検索フォームの入力値を保持 28+ List<Post> posts; 29+ if (keyword != null && !keyword.isEmpty() && matchType != null && !matchType.isEmpty()) { 30+ posts = postService.searchPosts(keyword, matchType); 31+ } else { 32+ posts = postService.findAll(); 33+ } 34+ 35+ model.addAttribute("posts", posts); 36+ 37 return "posts/list"; 38 }
PostControllerは、Webブラウザからのリクエストを受け取り、適切な処理を行い、結果をWebブラウザに返す役割を担うクラスです。
変更点として、listPostsメソッドの引数に@RequestParamアノテーションが追加されました。
@RequestParam(value = "keyword", required = false) String keyword: これは、HTTPリクエストのURLに含まれるkeywordという名前のパラメータ(例:?keyword=テスト)をkeyword変数に受け取ることを意味します。required = falseは、このパラメータが必須ではないことを示しています。つまり、keywordがなくてもエラーにはなりません。@RequestParam(value = "matchType", required = false) String matchType: こちらも同様に、matchTypeという名前のパラメータをmatchType変数に受け取ります。
メソッドの内部では、受け取ったkeywordとmatchTypeが存在するかどうか(nullではなく、空ではないか)をチェックしています。
- もし
keywordとmatchTypeの両方が存在する場合、postService.searchPosts(keyword, matchType)を呼び出して、検索結果を取得します。 - どちらか一方でも存在しない場合、または両方存在しない場合は、
postService.findAll()を呼び出して、すべての投稿を取得します。
このようにして取得した投稿のリストは、model.addAttribute("posts", posts)を使って、表示用のThymeleafテンプレート(posts/list.html)に渡されます。これにより、テンプレートは検索結果または全投稿リストを表示できるようになります。
変更点: Thymeleafテンプレートに検索フォームを追加
1 <div class="container mt-4"> 2 <h1>Post List</h1> 3 <h2>新規投稿</h2> 4 <a href="/posts/new" class="btn btn-primary mb-3">New Post</a> 5+ 6+ <!-- 検索フォーム --> 7+ <form th:action="@{/posts}" method="get" class="mb-3"> 8+ <div class="row"> 9+ <div class="col-md-6"> 10+ <input type="text" name="keyword" class="form-control" placeholder="Search" th:value="${param.keyword}" required> 11+ </div> 12+ <div class="col-md-2"> 13+ <select name="matchType" class="form-control"> 14+ <option value="contains" th:selected="${param.matchType?.toString() == 'contains'}">部分一致</option> 15+ <option value="startswith" th:selected="${param.matchType?.toString() == 'startswith'}">前方一致</option> 16+ <option value="endswith" th:selected="${param.matchType?.toString() == 'endswith'}">後方一致</option> 17+ </select> 18+ </div> 19+ <div class="col-md-2"> 20+ <button type="submit" class="btn btn-primary">検索</button> 21+ </div> 22+ </div> 23+ </form> 24+ 25 <h2>掲示板一覧</h2> 26 <table class="table table-striped"> 27 <thead>
posts/list.htmlは、Webブラウザに表示されるHTMLテンプレートです。Thymeleafというテンプレートエンジンを使って、Javaコードから受け取ったデータを表示します。
今回の変更で、このHTMLファイルに検索フォームが追加されました。
-
<form th:action="@{/posts}" method="get" class="mb-3">:- これは検索用のフォームです。
th:action="@{/posts}"は、このフォームが/postsというURLに対してリクエストを送ることを指定しています。method="get"は、GETメソッドを使ってリクエストを送ることを意味し、これはPostControllerの@GetMappingメソッドに対応します。
- これは検索用のフォームです。
-
<input type="text" name="keyword" class="form-control" placeholder="Search" th:value="${param.keyword}" required>:- これは検索キーワードを入力するためのテキストボックスです。
name="keyword"と指定することで、この入力値がControllerで@RequestParam("keyword")として受け取れるようになります。 th:value="${param.keyword}"はThymeleafの機能で、もしURLにkeywordパラメータがあれば、その値をテキストボックスの初期値として設定します。これにより、検索後にページを再表示しても、入力したキーワードが消えずに残るようになります。required属性は、このフィールドが必須入力であることを意味します。
- これは検索キーワードを入力するためのテキストボックスです。
-
<select name="matchType" class="form-control"> ... </select>:- これは検索タイプ(部分一致、前方一致、後方一致)を選択するためのドロップダウンリストです。
name="matchType"と指定することで、選択された値がControllerで@RequestParam("matchType")として受け取れます。 <option value="contains" th:selected="${param.matchType?.toString() == 'contains'}">部分一致</option>のように、各optionタグにはvalue属性で検索タイプを示す文字列(containsなど)が設定されています。th:selected="${param.matchType?.toString() == 'contains'}"もThymeleafの機能で、もしURLにmatchTypeパラメータがあり、その値がこのoptionのvalueと一致すれば、そのoptionが選択された状態になります。これにより、検索後に前回選択した検索タイプが保持されます。
- これは検索タイプ(部分一致、前方一致、後方一致)を選択するためのドロップダウンリストです。
-
<button type="submit" class="btn btn-primary">検索</button>:- これは検索を実行するためのボタンです。このボタンをクリックすると、フォームに入力されたデータが
/postsにGETリクエストとして送信されます。
- これは検索を実行するためのボタンです。このボタンをクリックすると、フォームに入力されたデータが
おわりに
今回の記事では、Spring Bootで作成した掲示板アプリに検索機能を追加する手順を学びました。Spring Data JPAの命名規則を活用することで、部分一致や前方一致など多様な検索条件でデータを効率的に取得するRepositoryメソッドを定義できました。また、Controllerで検索パラメータを受け取り、Serviceで検索ロジックを振り分け、Thymeleafでユーザーが使いやすい検索フォームを作成する一連の流れを理解できたことと思います。これらの知識は、Webアプリケーション開発における検索機能実装の基礎として、今後の開発に役立つでしょう。