SpringBootにhtmxを適用する第2回です。前回にて全件表示をしていたのを部分的に表示するページングに切り替えます。ページング部分はhtmxを使って非同期に実行します。
ページングの実装:Spring-Data-Commons
Springプロジェクトの1つ:Spring Dataにて、データベースの種類に問わず汎用的に使える実装を提供する Spring-Data-Commonsを用いて、1ページごとに分割する処理を実装します。今回のサンプルではデータベースは用いていませんが、ページングの画面実装にも有効なのでこちらを採用しています。
一覧画面にページングのUIを適用する
Bootstrap5のPagination を使います。
具体的にはこちらですね。
HTMLは以下です。
<nav aria-label="Page navigation example">
<ul class="pagination">
<li class="page-item">
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true"><</span>
</a>
</li>
<li class="page-item"><a class="page-link" href="#">1</a></li>
<li class="page-item"><a class="page-link" href="#">2</a></li>
<li class="page-item"><a class="page-link" href="#">3</a></li>
<li class="page-item"><a class="page-link" href="#">4</a></li>
<li class="page-item"><a class="page-link" href="#">5</a></li>
<li class="page-item"><a class="page-link" href="#">6</a></li>
<li class="page-item">
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">></span>
</a>
</li>
</ul>
</nav>
このHTMLをもとにして、出力するデータとページングの機能を実装をします。
実装完了したHTMLテンプレート
完成した内容はこちらです。
<!doctype html>
<html lang="ja" xmlns:th="http://www.thymeleaf.org">
<!-- Pager -->
<div th:if="${pages}">
<ul class="pagination" th:with="listurl='/list'">
<li class="page-item" th:if="${! pages.first}">
<a class="page-link" href="#" aria-label="Previous"
th:hx-get="${listurl + '?page=' + pages.previousPageable().pageNumber}" hx-trigger="click" hx-target="#result-table">
<span aria-hidden="true"><</span>
</a>
</li>
<li class="page-item" th:each="seq : ${#numbers.sequence(0, pages.totalPages-1)}">
<a class="page-link" href="#"
th:hx-get="${listurl + '?page=' + seq}" hx-trigger="click" hx-target="#result-table"
th:text="${seq + 1}"
th:classappend="${seq == pages.pageable.pageNumber ? 'active' : ''}"></a>
</li>
<li class="page-item" th:if="${! pages.last}">
<a class="page-link" href="#" aria-label="Next"
th:hx-get="${listurl + '?page=' + pages.nextPageable().pageNumber}" hx-trigger="click" hx-target="#result-table">
<span aria-hidden="true">></span>
</a>
</li>
</ul>
</div>
概要:
- ページング用のボタンを押すと、
-
/list/?page=(表示するページ番号)
のリクエストを投げて、指定したページ番号の結果を受け取り - その結果を
hx-target="#result-table"
にて指定した要素へ書き換える
を実装しています。
#result-table
は、このHTMLテンプレートを呼び出している index.html
に定義しています。
詳細な実装の解説
以下にSpringBoot(SpringMVC)でページング機能を実装する具体例を記載します。
ページングの実装にSpringDataCommonsを使い、BootstrapのPaginationと組み合わせる
ページング表示に必要な値は、
- 表示するデータの全件数
- 1ページ当たりの表示件数
- 現在表示しているページ番号。(最初のページ番号は0。つまり0から開始する連番)
です。
以下のSpringBootの設定クラスは、ページング用のリクエストパラメータを設定します。ページング用のパラメータは、SpringDataCommonsに実装されている PageRequest
クラスを使って、1ページに表示する件数と初期ページの番号を定義します。
package com.github.apz.sample;
import org.springframework.context.annotation.Configuration;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.web.PageableHandlerMethodArgumentResolver;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import java.util.List;
/**
* ページング用の初期設定やリクエストパラメータの設定。
*/
@Configuration
public class PaginationConfig implements WebMvcConfigurer {
@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
PageableHandlerMethodArgumentResolver resolver = new PageableHandlerMethodArgumentResolver();
resolver.setFallbackPageable(PageRequest.of(0, 5));
resolvers.add(resolver);
}
}
SpringDataCommonsを使ったページング用パラメータは
PageableHandlerMethodArgumentResolver
1がリクエストパラメータの制御を担い、 PageRequest
2 にページング用の値を格納します。
この設定にて、以下が決まります。
- 画面からページング用のリクエストパラメータ名を決める
- 特に名前を設定していない場合は、次のデフォルト名になります
- ページ番号:
page
- 1ページの表示件数:
size
- ページ番号:
- 特に名前を設定していない場合は、次のデフォルト名になります
- ページング用のパラメータがリクエストされなかったときの初期値
- ページ番号:0
- 1ページに表示する件数 : 5件
これを踏まえて、Controllerとその結果を出力するHTMLを実装しましょう。
ItemController
Controllerクラスで実装する内容は、
- ItemServiceから検索結果を受け取り
- list.htmlで一覧表示する
です。Controllerで受け取るパラメータに Pageable クラスを指定し、検索結果を受け取るクラスに Page を使います。
package com.github.apz.sample.controller;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.servlet.ModelAndView;
import com.github.apz.sample.model.Item;
import com.github.apz.sample.service.ItemService;
import lombok.AllArgsConstructor;
@Controller
@RequestMapping("/")
@AllArgsConstructor
public class ItemController {
ItemService itemService;
@GetMapping("/")
public ModelAndView index(ModelAndView mnv) {
mnv.setViewName("index");
return mnv;
}
@GetMapping("/list")
public ModelAndView list(ModelAndView mnv, Pageable pageable) {
Page<Item> pageItems = itemService.getPageItems(pageable);
mnv.addObject("pages", pageItems);
mnv.setViewName("list");
return mnv;
}
}
ページング処理に必要なパラメータは Pageable
へ常に格納されます。このページングの値をもとに商品を検索するよう ItemService
を作っていきます。
この検索結果は、ページングを盛り込んだ検索結果を格納する、Page
クラス 3 でラップして返すよう実装し、このオブジェクトをHTMLテンプレートから参照します。
Page
で返すオブジェクトの実装は、Page
の同パッケージにあるページ操作を担うPageImpl
4にありますので今回はこちらを用いています。また、このPageImpl
は前後のデータ有無を判定するChunk
5を継承していますので、SpringのCntrollerやHTMLテンプレートからはこの2クラスのメソッドを使います。
続いて、検索結果を返す ItemService
と、前回紹介した検索結果のItemRepository
も編集します。
Serviceクラス
今回のServiceクラスの実装では、Repositoryから得た検索結果をControllerへそのまま返します。前回紹介した全件返す実装をコメントアウトして残しています。
package com.github.apz.sample.service;
import java.util.List;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import com.github.apz.sample.model.Item;
import com.github.apz.sample.repository.ItemRepository;
import lombok.AllArgsConstructor;
@Service
@AllArgsConstructor
public class ItemService {
ItemRepository itemRepository;
public Page<Item> getPageItems(Pageable pageable) {
return itemRepository.getPageItems(pageable);
}
// public List<Item> getAllItems() {
// return itemRepository.getAll();
// }
}
ページングを使った検索条件 Pageable
をそのまま受け取り、その検索結果を Page<Item>
にします。前回紹介した全件のデータを返す itemRepository.getAll()
が List<Item>
を返すのと同様に、Page
も繰り返し要素を扱えますので返す型も Page<Item>
で宣言します。
Repositoryクラス
Repositoryクラスでは、検索結果を返します。前回までの実装は全データを返していましたが、ここではページングによって出力するデータを分割した結果を返すよう変更します。
package com.github.apz.sample.repository;
import java.math.BigDecimal;
import java.util.List;
import java.util.function.Predicate;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageImpl;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Repository;
import com.github.apz.sample.model.Item;
import com.github.apz.sample.model.Items;
import jakarta.annotation.PostConstruct;
import lombok.extern.slf4j.Slf4j;
@Repository
@Slf4j
public class ItemRepository {
Items items;
@PostConstruct
public void initialize() {
items = createItems();
}
public Page<Item> getPageItems(Pageable pageable) {
int pageNumber = pageable.getPageNumber();
int pageSize = pageable.getPageSize();
Predicate<Item> pagedStart = item -> item.getId()-1 >= pageNumber * pageSize;
Predicate<Item> pagedEnd = item -> item.getId() <= (pageNumber + 1) * pageSize;
List<Item> values = items.getValues();
List<Item> paged = values.stream().filter(pagedStart.and(pagedEnd)).toList();
return new PageImpl<>(paged, pageable, values.size());
}
// public List<Item> getAll() {
// return items.getValues();
// }
Items createItems() {
items = new Items();
items.addItem(Item.of(1, "apple", new BigDecimal("100"), 10))
.addItem(Item.of(2, "banana", new BigDecimal("250"), 20))
// ここから下はCopilot君が作ってくれたので、適当なデータです
.addItem(Item.of(3, "cherry", new BigDecimal("100"), 5))
.addItem(Item.of(4, "dragon fruit", new BigDecimal("300"), 2))
.addItem(Item.of(5, "egg", new BigDecimal("100"), 50))
.addItem(Item.of(6, "fig", new BigDecimal("200"), 30))
.addItem(Item.of(7, "grape", new BigDecimal("150"), 40))
...(中略)...
.addItem(Item.of(25, "yam", new BigDecimal("100"), 10))
.addItem(Item.of(26, "zucchini", new BigDecimal("150"), 10))
;
return items;
}
}
PageImpl
を使う際に、以下の3つを指定しています。
- 検索結果を持つ
List<Item>
- ページング用パラメータ
Pageable
- 全データ件数
作成したPageImpl
を返す getPageItems
メソッドについて解説します。
public Page<Item> getPageItems(Pageable pageable) {
int pageNumber = pageable.getPageNumber();
int pageSize = pageable.getPageSize();
Predicate<Item> pagedStart = item -> item.getId()-1 >= pageNumber * pageSize; // (1)
Predicate<Item> pagedEnd = item -> item.getId() <= (pageNumber + 1) * pageSize; // (2)
List<Item> values = items.getValues();
List<Item> paged = values.stream().filter(pagedStart.and(pagedEnd)).toList(); // (3)
return new PageImpl<>(paged, pageable, values.size()); // (4)
}
(1) Item
クラスのidを取得し、表示する開始条件を定義しています。idは1から開始するため、例えば1ページ目に表示するItem
のidは先頭である1です。ページ番号は0から始まるので、(idの値-1)は0です。2ページ目なら、(idの値-1) >= ページ番号 × 1ページの表示件数 ですから、id - 1 >= 1 * 5 で、id >= 6 を満たすItemが対象です。
(2) 1ページの最後に表示するItem
クラスのidを(1)同様に設定します。id <= (ページ番号 + 1) × 1ページの表示件数ですから、1ページ目は id <= 5 を満たすItemで、2ページ目は、id <= (1+1) * 5 なので id <= 10を満たすItemです。
(3) (1)(2)で指定した範囲のItem
を作成します。(1)と(2)の条件に合致する、つまり表示するページの先頭から終わりまでのidを持つItem
を、Item
の一覧から取り出します。
(4) ページング分割した結果をPageImpl
に格納します。
以上でJavaクラスの実装が完了です。
次にページングした検索結果から、ページング用のHTMLを構築します。
HTMLテンプレート
ページングの部分だけを抜き出すと、以下のように実装できます。
<!-- Pager -->
<div th:if="${pages}"> ・・・(1)
<ul class="pagination">
<li class="page-item" th:if="${! pages.first}"> ・・・(2)
<a class="page-link" href="#" aria-label="Previous">
<span aria-hidden="true"><</span>
</a>
</li>
<li class="page-item" th:each="seq : ${#numbers.sequence(0, pages.totalPages-1)}"> ・・・(3)
<a class="page-link" href="#"
th:text="${seq + 1}"
th:classappend="${seq == pages.pageable.pageNumber ? 'active' : ''}"></a> ・・・(4)
</li>
<li class="page-item" th:if="${! pages.last}"> ・・・(5)
<a class="page-link" href="#" aria-label="Next">
<span aria-hidden="true">></span>
</a>
</li>
</ul>
</div>
(1) 検索結果 pages がある場合のみ、このページング用のHTMLを出力します。このpagesはControllerで実装した Page<Item>
です。
(2) pages.first
6 : 最初のページかを判定するChunk
クラスのisFirst
メソッドの結果。このHTMLタグの内容は「ページを1つ戻る」であり、最初のページだった場合は表示したくないので、pages.first
の否定(not)を使い、メソッドの結果を反転します。
(3) pages.totalPages
で全ページ件数を返します。ただしページ番号は0から始まるため、#numbers.sequence
では、0から開始~totalPagesの値から-1をしています。表示するだけならこの-1は不要になりますが、ページングを含めたURLを作るときに「ページ番号は0から始まる」ため、この実装をしています。
(4) 現在表示しているページ番号と同じ場合、ハイライト表示用のCSSクラスを追加します。
(5) pages.last
7 : 表示するページが最後のページであるかを判定します。このHTMLタグの内容は「次のページへ進む」であり、最後のページだった場合は表示したくないため、pages.last
の否定(not)を使って反転します。
一覧結果の表示部分は、実は前回と同様のものが使えます。
<table class="table table-bordered table-striped text-nowrap" th:if="${pages}">
<thead>
<tr>
<th>#番号</th>
<th>名称</th>
<th>価格</th>
<th>在庫数</th>
</tr>
</thead>
<tbody>
<tr th:each="item : ${pages.getContent()}">
<td th:text="${item.id}">1</td>
<td th:text="${item.name}">name</td>
<td th:text="${item.price}" class="text-end">100</td>
<td th:text="${item.stock}" class="text-end">10</td>
</tr>
</tbody>
</table>
Page
クラスに格納したページング済みの一覧結果は、getContent()
メソッドで得られます。
ここまでの状態で一度起動してみましょう。
ページングの内容と、1ページ目の先頭5件が出力されましたね!
ページング用ボタンを押したときの画面遷移を実装する
ページ移動のボタンを押して、押したページ番号のページを表示するリクエストに変更します。
さらに、1ページ戻る、1ページ進むボタンの遷移先とその表示の可否も設定します。
<!-- Pager -->
<div th:if="${pages}">
<ul class="pagination" th:with="listurl='/list'"> ・・・(1)
<!-- 前のページへ戻る -->
<li class="page-item" th:if="${! pages.first}">
<a class="page-link" href="#" aria-label="Previous"
th:hx-get="${listurl + '?page=' + pages.previousPageable().pageNumber}" hx-trigger="click" hx-target="#result-table"> ・・・(2)
<span aria-hidden="true"><</span>
</a>
</li>
<!-- 各ページへ移動する -->
<li class="page-item" th:each="seq : ${#numbers.sequence(0, pages.totalPages-1)}">
<a class="page-link" href="#"
th:hx-get="${listurl + '?page=' + seq}" hx-trigger="click" hx-target="#result-table"
th:text="${seq + 1}"
th:classappend="${seq == pages.pageable.pageNumber ? 'active' : ''}"></a> ・・・(3)
</li>
<!-- 次のページへ進む -->
<li class="page-item" th:if="${! pages.last}">
<a class="page-link" href="#" aria-label="Next"
th:hx-get="${listurl + '?page=' + pages.nextPageable().pageNumber}" hx-trigger="click" hx-target="#result-table"> ・・・(4)
<span aria-hidden="true">></span>
</a>
</li>
</ul>
</div>
(1) th:with
で遷移先の基本となるURLを変数に格納します。これは複数のボタン(戻るボタン・進むボタン・ページ番号を指定する)で使いまわせるURLなので、 /list?page=ページ番号
とページ番号をURLのクエリパラメータに設定できるようにここで準備しています。
(2) 前ページのページ番号は pages.previousPageable()
の pageNumber
で取得できます。
(3) ページ番号を指定して表示させます。すでにHTMLへ seq の値を使って表示していますので、これをこのまま クエリパラメータのページ番号に指定します。
(4) 次ページのページ番号は pages.nextPageable()
の pageNumber
で取得できます
まとめ
Bootstrap5.3のPaginationコンポーネントを使い、SpringDataCommonsを使ってページング処理を実装し、htmxと組み合わせて実装する方法をまとめました。
次回は、Bootstrap5.3のモーダルダイアログ(ModelDialog)の表示をhtmxと組み合わせて行う方法を紹介します。
-
org.springframework.data.web.PageableHandlerMethodArgumentResolver。デフォルト実装は同パッケージの PageableHandlerMethodArgumentResolverSupport にて、
DEFAULT_PAGE_PARAMETER = "page"
と、DEFAULT_SIZE_PARAMETER = "size"
でページング用のパラメータ名が定義されている。 ↩ -
org.springframework.data.domain.PageRequest ↩
-
org.springframework.data.domain.Pageインタフェース。Pageインタフェースは同パッケージのSliceインタフェースを継承。 ↩
-
org.springframework.data.domain.PageImplクラス。具体的な実装はPageImplとChunkを参照するとわかりやすいです。 ↩
-
org.springframework.data.domain.Chunk抽象クラス。 ↩
-
ChunkクラスのisFirst()。同クラスのhasPrevious()を反転する。前ページがない=最初のページ。 ↩
-
ChunkクラスのisLast()。実装はPageImplのhasNext()を反転する。次のページがない=最後のページ。 ↩