【Spring Boot】ページネーション機能を実装する|簡単な掲示板アプリの作成
この記事では、Spring BootとSpring Data JPAを活用し、簡単な掲示板アプリにページネーション機能を実装する方法を解説します。大量のデータを効率的に表示するため、`Page`や`Pageable`インターフェースを用いたデータ取得と、HTML(Thymeleaf)でのページングUIの構築方法を学べます。
開発環境
- 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.UserService; 2 import com.example.bbs.service.CommentService; 3 4-import java.util.List; 5- 6+import org.springframework.data.domain.Page; 7 import org.springframework.stereotype.Controller; 8 import org.springframework.ui.Model; 9 import org.springframework.web.bind.annotation.*; 10 @RequestParam(value = "matchType", required = false, defaultValue = "contains") String matchType, 11 @RequestParam(value = "sortBy", required = false, defaultValue = "createdAt") String sortBy, 12 @RequestParam(value = "sortOrder", required = false, defaultValue = "asc") String sortOrder, 13+ @RequestParam(value = "page", required = false, defaultValue = "0") int page, 14 Model model) { 15 16 // ログインユーザーを取得してモデルに渡す 17 User loggedInUser = userService.getCurrentUser(); 18 model.addAttribute("loggedInUserId", loggedInUser.getId()); 19 20+ // サイズを固定 (例: 10件ずつ表示) 21+ int size = 10; 22+ 23 // 検索フォームの入力値を保持 24- List<Post> posts; 25+ Page<Post> posts; 26 if (keyword != null && !keyword.isEmpty() && matchType != null && !matchType.isEmpty()) { 27- posts = postService.searchPosts(keyword, matchType, sortBy, sortOrder); 28+ posts = postService.searchPosts(keyword, matchType, sortBy, sortOrder, page, size); 29 } else { 30- posts = postService.findAll(sortBy, sortOrder); 31+ posts = postService.findAll(sortBy, sortOrder, page, size); 32 } 33 34 // 検索結果をモデルに追加 35
/src/main/java/com/example/bbs/repository/PostRepository.java
/src/main/java/com/example/bbs/repository/PostRepository.java1 2 import com.example.bbs.model.Post; 3 4-import java.util.List; 5- 6 import org.springframework.data.jpa.repository.JpaRepository; 7-import org.springframework.data.domain.Sort; 8+import org.springframework.data.domain.Page; 9+import org.springframework.data.domain.Pageable; 10 11 public interface PostRepository extends JpaRepository<Post, Long> { 12 // 部分一致 13- List<Post> findByTitleContainingOrContentContaining(String titleKeyword, String contentKeyword, Sort sort); 14+ Page<Post> findByTitleContainingOrContentContaining(String titleKeyword, String contentKeyword, Pageable pageable); 15 16 // 前方一致 17- List<Post> findByTitleStartingWithOrContentStartingWith(String titlePrefix, String contentPrefix, Sort sort); 18+ Page<Post> findByTitleStartingWithOrContentStartingWith(String titlePrefix, String contentPrefix, 19+ Pageable pageable); 20 21 // 後方一致 22- List<Post> findByTitleEndingWithOrContentEndingWith(String titleSuffix, String contentSuffix, Sort sort); 23+ Page<Post> findByTitleEndingWithOrContentEndingWith(String titleSuffix, String contentSuffix, Pageable pageable); 24 } 25
/src/main/java/com/example/bbs/service/PostService.java
/src/main/java/com/example/bbs/service/PostService.java1 import com.example.bbs.model.User; 2 import com.example.bbs.repository.PostRepository; 3 import org.springframework.stereotype.Service; 4- 5+import org.springframework.data.domain.Page; 6+import org.springframework.data.domain.PageRequest; 7+import org.springframework.data.domain.Pageable; 8 import org.springframework.data.domain.Sort; 9 10 import java.util.List; 11 this.postRepository = postRepository; 12 } 13 14- public List<Post> findAll(String sortBy, String sortOrder) { 15+ public Page<Post> findAll(String sortBy, String sortOrder, int page, int size) { 16 // Sort.Orderを使ってソート順を設定 17 Sort.Order order; 18 if (sortOrder.equals("asc")) { 19- order = new Sort.Order(Sort.Direction.ASC, sortBy); 20+ order = new Sort.Order(Sort.Direction.ASC, sortBy); // 昇順 21 } else { 22- order = new Sort.Order(Sort.Direction.DESC, sortBy); 23+ order = new Sort.Order(Sort.Direction.DESC, sortBy); // 降順 24 } 25 26 // Sortオブジェクトを作成 27 Sort sort = Sort.by(order); 28 29- return postRepository.findAll(sort); 30+ // ページ情報を作成 31+ Pageable pageable = PageRequest.of(page, size, sort); 32+ 33+ return postRepository.findAll(pageable); 34 } 35 36- public List<Post> searchPosts(String keyword, String matchType, String sortBy, String sortOrder) { 37+ public Page<Post> searchPosts(String keyword, String matchType, String sortBy, String sortOrder, int page, 38+ int size) { 39 // Sort.Orderを使ってソート順を設定 40 Sort.Order order; 41 if (sortOrder.equals("asc")) { 42 // Sortオブジェクトを作成 43 Sort sort = Sort.by(order); 44 45+ // ページ情報を作成 46+ Pageable pageable = PageRequest.of(page, size, sort); 47+ 48 // 検索条件に基づいて投稿を取得 49 switch (matchType) { 50 case "startswith": // 前方一致 51- return postRepository.findByTitleStartingWithOrContentStartingWith(keyword, keyword, sort); 52+ return postRepository.findByTitleStartingWithOrContentStartingWith(keyword, keyword, pageable); 53 case "endswith": // 後方一致 54- return postRepository.findByTitleEndingWithOrContentEndingWith(keyword, keyword, sort); 55+ return postRepository.findByTitleEndingWithOrContentEndingWith(keyword, keyword, pageable); 56 case "contains": // 部分一致(デフォルト) 57 default: 58- return postRepository.findByTitleContainingOrContentContaining(keyword, keyword, sort); 59+ return postRepository.findByTitleContainingOrContentContaining(keyword, keyword, pageable); 60 } 61 } 62 63
/src/main/resources/templates/posts/list.html
/src/main/resources/templates/posts/list.html1 </tr> 2 </tbody> 3 </table> 4+ 5+ <!-- ページネーション --> 6+ <nav aria-label="Page navigation"> 7+ <ul class="pagination justify-content-center"> 8+ <!-- 前のページ --> 9+ <li th:class="${posts.number == 0} ? 'page-item disabled' : 'page-item'"> 10+ <a class="page-link" th:href="@{/posts(page=${posts.number - 1}, sortBy=${param.sortBy}, sortOrder=${param.sortOrder}, matchType=${param.matchType}, keyword=${param.keyword})}"> 11+ Previous 12+ </a> 13+ </li> 14+ 15+ <!-- ページ番号 --> 16+ <li th:each="i : ${#numbers.sequence(0, posts.totalPages - 1)}" 17+ th:class="${i == posts.number} ? 'page-item active' : 'page-item'"> 18+ <a class="page-link" th:href="@{/posts(page=${i}, sortBy=${param.sortBy}, sortOrder=${param.sortOrder}, matchType=${param.matchType}, keyword=${param.keyword})}" th:text="${i + 1}"></a> 19+ </li> 20+ 21+ <!-- 次のページ --> 22+ <li th:class="${posts.number + 1 >= posts.totalPages} ? 'page-item disabled' : 'page-item'"> 23+ <a class="page-link" th:href="@{/posts(page=${posts.number + 1}, sortBy=${param.sortBy}, sortOrder=${param.sortOrder}, matchType=${param.matchType}, keyword=${param.keyword})}"> 24+ Next 25+ </a> 26+ </li> 27+ </ul> 28+ </nav> 29 </main> 30 </html> 31
コード解説
変更点: コントローラーでのページ情報受け取りとデータ型変更
/src/main/java/com/example/bbs/controller/PostController.java1 import com.example.bbs.service.UserService; 2 import com.example.bbs.service.CommentService; 3 4-import java.util.List; 5- 6+import org.springframework.data.domain.Page; 7 import org.springframework.stereotype.Controller; 8 import org.springframework.ui.Model; 9 import org.springframework.web.bind.annotation.*; 10 @RequestParam(value = "matchType", required = false, defaultValue = "contains") String matchType, 11 @RequestParam(value = "sortBy", required = false, defaultValue = "createdAt") String sortBy, 12 @RequestParam(value = "sortOrder", required = false, defaultValue = "asc") String sortOrder, 13+ @RequestParam(value = "page", required = false, defaultValue = "0") int page, 14 Model model) { 15 16 // ログインユーザーを取得してモデルに渡す 17 User loggedInUser = userService.getCurrentUser(); 18 model.addAttribute("loggedInUserId", loggedInUser.getId()); 19 20+ // サイズを固定 (例: 10件ずつ表示) 21+ int size = 10; 22+ 23 // 検索フォームの入力値を保持 24- List<Post> posts; 25+ Page<Post> posts; 26 if (keyword != null && !keyword.isEmpty() && matchType != null && !matchType.isEmpty()) { 27- posts = postService.searchPosts(keyword, matchType, sortBy, sortOrder); 28+ posts = postService.searchPosts(keyword, matchType, sortBy, sortOrder, page, size); 29 } else { 30- posts = postService.findAll(sortBy, sortOrder); 31+ posts = postService.findAll(sortBy, sortOrder, page, size); 32 } 33 34 // 検索結果をモデルに追加
ここでは、ウェブブラウザからのリクエストを受け取る部分(コントローラー)に、ページネーションに必要な情報を受け取るための変更を加えました。
まず、import java.util.List;が削除され、import org.springframework.data.domain.Page;が追加されています。これは、これまで投稿のリストをList型で扱っていたのを、ページネーション情報を含むPage型で扱うように変更したことを意味します。Page型は、現在のページにあるデータだけでなく、「全部で何ページあるか」「現在のページ番号は何か」「1ページあたりのデータ数」といったページネーションに必要な情報もまとめて持っている便利な型です。
次に、@RequestParam(value = "page", required = false, defaultValue = "0") int page,という行が追加されています。これは、URLのクエリパラメータ(例: ?page=1)から、現在のページ番号を受け取るための設定です。もしpageパラメータが指定されなかった場合(required = false)、デフォルトで0(最初のページ)として扱われます。
また、int size = 10;という行が追加され、1ページあたりに表示する投稿の数を10件に固定しています。このpageとsizeの情報を、次に説明するサービス層に渡すことで、指定されたページと件数でデータを取得できるようになります。
そして、List<Post> posts;がPage<Post> posts;に変更され、postService.searchPostsやpostService.findAllメソッドを呼び出す際に、新しく追加したpageとsizeの引数を渡すよう変更しました。これにより、コントローラーからサービス層へ、どのページのデータを何件取得したいのかという指示が正確に伝わるようになります。
変更点: リポジトリでのページネーション対応メソッドシグネチャ変更
/src/main/java/com/example/bbs/repository/PostRepository.java1 2 import com.example.bbs.model.Post; 3 4-import java.util.List; 5- 6 import org.springframework.data.jpa.repository.JpaRepository; 7-import org.springframework.data.domain.Sort; 8+import org.springframework.data.domain.Page; 9+import org.springframework.data.domain.Pageable; 10 11 public interface PostRepository extends JpaRepository<Post, Long> { 12 // 部分一致 13- List<Post> findByTitleContainingOrContentContaining(String titleKeyword, String contentKeyword, Sort sort); 14+ Page<Post> findByTitleContainingOrContentContaining(String titleKeyword, String contentKeyword, Pageable pageable); 15 16 // 前方一致 17- List<Post> findByTitleStartingWithOrContentStartingWith(String titlePrefix, String contentPrefix, Sort sort); 18+ Page<Post> findByTitleStartingWithOrContentStartingWith(String titlePrefix, String contentPrefix, 19+ Pageable pageable); 20 21 // 後方一致 22- List<Post> findByTitleEndingWithOrContentEndingWith(String titleSuffix, String contentSuffix, Sort sort); 23+ Page<Post> findByTitleEndingWithOrContentEndingWith(String titleSuffix, String contentSuffix, Pageable pageable); 24 }
この部分では、データベースからのデータ取得を担当するリポジトリインターフェースに変更を加えました。
まず、ここでもimport java.util.List;が削除され、import org.springframework.data.domain.Page;とimport org.springframework.data.domain.Pageable;が追加されています。これにより、リポジトリが返すデータの型と、データ取得の条件にページネーション情報を加えるための準備が整いました。
既存のデータ取得メソッド(findByTitleContainingOrContentContainingなど)の戻り値の型がList<Post>からPage<Post>に変更されています。これは、データベースから取得した投稿データだけでなく、そのデータが何ページ目にあたるのか、全体で何ページあるのかといったページネーション情報も一緒に返すようにするためです。
また、これらのメソッドの引数もSort sortからPageable pageableに変更されました。Pageableは、Spring Data JPAが提供するインターフェースで、ページ番号、1ページあたりのデータ件数、そしてソート順(昇順か降順か、どの項目でソートするか)といったページネーションに必要なすべての情報がまとめられています。リポジトリのメソッドがPageableを受け取るようにすることで、Spring Data JPAが自動的にデータベースへの問い合わせ(SQL)を生成し、ページネーションされた結果を取得してくれるようになります。開発者は複雑なSQLを手書きする必要がなくなり、非常に効率的です。
変更点: サービス層でのページネーションロジック導入
/src/main/java/com/example/bbs/service/PostService.java1 import com.example.bbs.model.User; 2 import com.example.bbs.repository.PostRepository; 3 import org.springframework.stereotype.Service; 4- 5+import org.springframework.data.domain.Page; 6+import org.springframework.data.domain.PageRequest; 7+import org.springframework.data.domain.Pageable; 8 import org.springframework.data.domain.Sort; 9 10 import java.util.List; 11 this.postRepository = postRepository; 12 } 13 14- public List<Post> findAll(String sortBy, String sortOrder) { 15+ public Page<Post> findAll(String sortBy, String sortOrder, int page, int size) { 16 // Sort.Orderを使ってソート順を設定 17 Sort.Order order; 18 if (sortOrder.equals("asc")) { 19- order = new Sort.Order(Sort.Direction.ASC, sortBy); 20+ order = new Sort.Order(Sort.Direction.ASC, sortBy); // 昇順 21 } else { 22- order = new Sort.Order(Sort.Direction.DESC, sortBy); 23+ order = new Sort.Order(Sort.Direction.DESC, sortBy); // 降順 24 } 25 26 // Sortオブジェクトを作成 27 Sort sort = Sort.by(order); 28 29- return postRepository.findAll(sort); 30+ // ページ情報を作成 31+ Pageable pageable = PageRequest.of(page, size, sort); 32+ 33+ return postRepository.findAll(pageable); 34 } 35 36- public List<Post> searchPosts(String keyword, String matchType, String sortBy, String sortOrder) { 37+ public Page<Post> searchPosts(String keyword, String matchType, String sortBy, String sortOrder, int page, 38+ int size) { 39 // Sort.Orderを使ってソート順を設定 40 Sort.Order order; 41 if (sortOrder.equals("asc")) { 42 // Sortオブジェクトを作成 43 Sort sort = Sort.by(order); 44 45+ // ページ情報を作成 46+ Pageable pageable = PageRequest.of(page, size, sort); 47+ 48 // 検索条件に基づいて投稿を取得 49 switch (matchType) { 50 case "startswith": // 前方一致 51- return postRepository.findByTitleStartingWithOrContentStartingWith(keyword, keyword, sort); 52+ return postRepository.findByTitleStartingWithOrContentStartingWith(keyword, keyword, pageable); 53 case "endswith": // 後方一致 54- return postRepository.findByTitleEndingWithOrContentEndingWith(keyword, keyword, sort); 55+ return postRepository.findByTitleEndingWithOrContentEndingWith(keyword, keyword, pageable); 56 case "contains": // 部分一致(デフォルト) 57 default: 58- return postRepository.findByTitleContainingOrContentContaining(keyword, keyword, sort); 59+ return postRepository.findByTitleContainingOrContentContaining(keyword, keyword, pageable); 60 } 61 }
この変更は、ビジネスロジックを担当するサービス層で行われました。コントローラーから受け取ったページネーション情報を処理し、リポジトリへ渡す役割を担います。
まず、import org.springframework.data.domain.Page;、import org.springframework.data.domain.PageRequest;、import org.springframework.data.domain.Pageable;が追加されています。これにより、サービス層でPage型のデータを扱い、Pageableオブジェクトを作成するための準備ができました。
findAllメソッドとsearchPostsメソッドのシグネチャ(メソッドの名前、引数、戻り値の定義)が変更されています。具体的には、引数にint pageとint sizeが追加され、戻り値の型がList<Post>からPage<Post>になりました。これにより、これらのメソッドがページ番号と1ページあたりの件数を受け取り、ページネーション情報を含む投稿データの集合を返すようになります。
最も重要な変更点は、Pageable pageable = PageRequest.of(page, size, sort);という行の追加です。
PageRequest.of()メソッドは、引数として渡されたpage(ページ番号)、size(1ページあたりの件数)、sort(ソート順)を使って、Pageableインターフェースの実装クラスのインスタンスを作成します。- ここで作成された
pageableオブジェクトは、ページ番号が0から始まることに注意してください。ブラウザからのリクエストでは1から始まることが多いため、必要に応じて調整することもありますが、Spring Data JPAの内部では0から数えます。 sortオブジェクトはこれまで通りソート順を定義しており、ページネーションと組み合わせて「Xページ目のデータをY件、Zの項目でソートして取得する」という複合的なリクエストを生成できます。
最後に、return postRepository.findAll(sort);やreturn postRepository.findBy...のように直接Sortオブジェクトを渡していた箇所が、return postRepository.findAll(pageable);のようにPageableオブジェクトを渡すように変更されています。これにより、サービス層で作成したページネーションとソートの情報をまとめてリポジトリ層に渡し、データベースから適切なデータが取得されるように指示しています。
変更点: HTMLテンプレートでのページネーションUIの表示
/src/main/resources/templates/posts/list.html1 </tr> 2 </tbody> 3 </table> 4+ 5+ <!-- ページネーション --> 6+ <nav aria-label="Page navigation"> 7+ <ul class="pagination justify-content-center"> 8+ <!-- 前のページ --> 9+ <li th:class="${posts.number == 0} ? 'page-item disabled' : 'page-item'"> 10+ <a class="page-link" th:href="@{/posts(page=${posts.number - 1}, sortBy=${param.sortBy}, sortOrder=${param.sortOrder}, matchType=${param.matchType}, keyword=${param.keyword})}"> 11+ Previous 12+ </a> 13+ </li> 14+ 15+ <!-- ページ番号 --> 16+ <li th:each="i : ${#numbers.sequence(0, posts.totalPages - 1)}" 17+ th:class="${i == posts.number} ? 'page-item active' : 'page-item'"> 18+ <a class="page-link" th:href="@{/posts(page=${i}, sortBy=${param.sortBy}, sortOrder=${param.sortOrder}, matchType=${param.matchType}, keyword=${param.keyword})}" th:text="${i + 1}"></a> 19+ </li> 20+ 21+ <!-- 次のページ --> 22+ <li th:class="${posts.number + 1 >= posts.totalPages} ? 'page-item disabled' : 'page-item'"> 23+ <a class="page-link" th:href="@{/posts(page=${posts.number + 1}, sortBy=${param.sortBy}, sortOrder=${param.sortOrder}, matchType=${param.matchType}, keyword=${param.keyword})}"> 24+ Next 25+ </a> 26+ </li> 27+ </ul> 28+ </nav> 29 </main> 30 </html>
最後に、ユーザーインターフェース(HTMLテンプレート)に変更を加え、実際にページネーションのリンクが表示されるようにします。ここではThymeleafというテンプレートエンジンを使用して、動的にページネーションUIを生成しています。
<!-- ページネーション -->以下の<nav>タグと<ul>タグが追加された部分がページネーションのUIです。これはBootstrapなどのCSSフレームワークと組み合わせて使われる一般的な形式です。
各要素について説明します。
-
前のページへのリンク:
<li th:class="${posts.number == 0} ? 'page-item disabled' : 'page-item'">posts.numberは現在のページ番号(0から始まる)を表します。- もし現在のページが最初のページ(
posts.number == 0)なら、page-item disabledというCSSクラスが適用され、ボタンが無効化されます。それ以外の場合はpage-itemクラスが適用されます。
<a class="page-link" th:href="@{/posts(page=${posts.number - 1}, ...)}">Previous</a>th:hrefは、リンクのURLを動的に生成するためのThymeleafの属性です。@{/posts(...)}の形式で、/postsというURLに対して、ページネーションに必要な各種パラメータ(page,sortBy,sortOrder,matchType,keyword)を渡しています。page=${posts.number - 1}は、現在のページ番号から1を引くことで、前のページのURLを生成しています。他のパラメータ(sortBy,sortOrderなど)は、param.を使って現在のURLのパラメータ値をそのまま引き継いでいます。これにより、ソート順や検索キーワードを維持したままページ移動ができるようになります。
-
ページ番号のリンク:
<li th:each="i : ${#numbers.sequence(0, posts.totalPages - 1)}"th:eachはThymeleafの繰り返し処理を行う属性です。#numbers.sequence(0, posts.totalPages - 1)は、0からposts.totalPages - 1までの連続した数字のシーケンス(例: 0, 1, 2, ...)を生成します。posts.totalPagesはコントローラーから渡されたPageオブジェクトが持つ総ページ数です。これにより、すべてのページ番号分のリンクを動的に作成できます。
th:class="${i == posts.number} ? 'page-item active' : 'page-item'"- 繰り返し処理中の現在のページ番号
iが、実際に表示されているページposts.numberと一致する場合、activeクラスが適用され、現在のページが強調表示されます。
- 繰り返し処理中の現在のページ番号
<a ... th:href="@{/posts(page=${i}, ...)}" th:text="${i + 1}"></a>- 各ページ番号のリンクを作成します。
page=${i}で、現在の繰り返し回数iを新しいページ番号として設定します。 th:text="${i + 1}"で、表示されるページ番号をi + 1としています。これは、内部ではページ番号が0から始まるため、ユーザーには1から始まる番号として見せるための工夫です。
- 各ページ番号のリンクを作成します。
-
次のページへのリンク:
<li th:class="${posts.number + 1 >= posts.totalPages} ? 'page-item disabled' : 'page-item'">- 現在のページが最後のページ(
posts.number + 1が総ページ数以上)の場合、disabledクラスが適用され、ボタンが無効化されます。
- 現在のページが最後のページ(
<a class="page-link" th:href="@{/posts(page=${posts.number + 1}, ...)}">Next</a>page=${posts.number + 1}で、次のページのURLを生成します。
このように、ThymeleafとPageオブジェクトが持つ情報(posts.number、posts.totalPages)を組み合わせることで、動的でインタラクティブなページネーションUIを簡単に実装することができます。
おわりに
本記事では、Spring Bootでページネーション機能を実装する具体的な手順を学びました。コントローラー、サービス、リポジトリの各層で、List型からPage型へと変更し、Pageableインターフェースを使ってページ情報やソート条件を効率的に扱えるようになりました。特に、PageRequest.of()でPageableオブジェクトを作成し、Spring Data JPAのリポジトリメソッドに渡すことで、データベースからのデータ取得が簡潔に記述できることを確認しました。最終的に、ThymeleafテンプレートでPageオブジェクトの情報を活用し、動的なページ番号リンクを含む、使いやすいページネーションUIを構築しました。これで、大量のデータを効率的に表示し、ユーザーが快適にコンテンツを閲覧できるアプリケーションを開発する基礎が身についたことと思います。