Springとhtmx(その2：ページングを実装する)

SpringBootにhtmxを適用する第2回です。前回にて全件表示をしていたのを部分的に表示するページングに切り替えます。ページング部分はhtmxを使って非同期に実行します。

## ページングの実装：Spring-Data-Commons

Springプロジェクトの1つ：Spring Dataにて、データベースの種類に問わず汎用的に使える実装を提供する [Spring-Data-Commons](https://docs.spring.io/spring-data/commons/reference/index.html)を用いて、1ページごとに分割する処理を実装します。今回のサンプルではデータベースは用いていませんが、ページングの画面実装にも有効なのでこちらを採用しています。

## 一覧画面にページングのUIを適用する

[Bootstrap5のPagination](https://getbootstrap.jp/docs/5.3/components/pagination/) を使います。

具体的にはこちらですね。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13268/54342393-496f-dd1d-73de-f853f199ddff.png)

HTMLは以下です。

```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">&lt;</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">&gt;</span>
      </a>
    </li>
  </ul>
</nav>
```

このHTMLをもとにして、出力するデータとページングの機能を実装をします。

## 実装完了したHTMLテンプレート

完成した内容はこちらです。

```list.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">&lt;</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">&gt;</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ページに表示する件数と初期ページの番号を定義します。

```java:PaginationConfig.java
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`[^PageableHandlerMethodArgumentResolver]がリクエストパラメータの制御を担い、 `PageRequest`[^PageRequest] にページング用の値を格納します。

この設定にて、以下が決まります。

* 画面からページング用のリクエストパラメータ名を決める
    * 特に名前を設定していない場合は、次のデフォルト名になります
        *  ページ番号：`page` 
        *  1ページの表示件数：`size` 
* ページング用のパラメータがリクエストされなかったときの初期値
    * ページ番号：0
    * 1ページに表示する件数 : 5件

これを踏まえて、Controllerとその結果を出力するHTMLを実装しましょう。

### ItemController

Controllerクラスで実装する内容は、

* ItemServiceから検索結果を受け取り
* list.htmlで一覧表示する

です。Controllerで受け取るパラメータに Pageable クラスを指定し、検索結果を受け取るクラスに Page を使います。

```java:ItemController.java
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`クラス [^Page] でラップして返すよう実装し、このオブジェクトをHTMLテンプレートから参照します。

`Page`で返すオブジェクトの実装は、`Page`の同パッケージにあるページ操作を担う`PageImpl`[^PageImpl]にありますので今回はこちらを用いています。また、この`PageImpl`は前後のデータ有無を判定する`Chunk`[^Chunk]を継承していますので、SpringのCntrollerやHTMLテンプレートからはこの2クラスのメソッドを使います。

続いて、検索結果を返す `ItemService` と、前回紹介した検索結果の`ItemRepository`も編集します。

### Serviceクラス

今回のServiceクラスの実装では、Repositoryから得た検索結果をControllerへそのまま返します。前回紹介した全件返す実装をコメントアウトして残しています。

```java:ItemService.java
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クラスでは、検索結果を返します。前回までの実装は全データを返していましたが、ここではページングによって出力するデータを分割した結果を返すよう変更します。

```java:ItemRepository.java
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` メソッドについて解説します。

```java
    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テンプレート

ページングの部分だけを抜き出すと、以下のように実装できます。

```list.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">&lt;</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">&gt;</span>
            </a>
        </li>
    </ul>
</div>
```

(1) 検索結果 pages がある場合のみ、このページング用のHTMLを出力します。このpagesはControllerで実装した `Page<Item>` です。
(2) `pages.first` [^isFirst]  : 最初のページかを判定する`Chunk`クラスの`isFirst`メソッドの結果。このHTMLタグの内容は「ページを1つ戻る」であり、最初のページだった場合は表示したくないので、`pages.first` の否定(not)を使い、メソッドの結果を反転します。
(3) `pages.totalPages` で全ページ件数を返します。ただしページ番号は0から始まるため、`#numbers.sequence`では、0から開始～totalPagesの値から-1をしています。表示するだけならこの-1は不要になりますが、ページングを含めたURLを作るときに「ページ番号は0から始まる」ため、この実装をしています。
(4) 現在表示しているページ番号と同じ場合、ハイライト表示用のCSSクラスを追加します。
(5) `pages.last` [^isLast] : 表示するページが最後のページであるかを判定します。このHTMLタグの内容は「次のページへ進む」であり、最後のページだった場合は表示したくないため、`pages.last`の否定(not)を使って反転します。

一覧結果の表示部分は、実は前回と同様のものが使えます。

```list.html
<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()`メソッドで得られます。

ここまでの状態で一度起動してみましょう。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/13268/e7d05bed-0e05-9909-beb7-15189e668827.png)

ページングの内容と、1ページ目の先頭5件が出力されましたね！

### ページング用ボタンを押したときの画面遷移を実装する

ページ移動のボタンを押して、押したページ番号のページを表示するリクエストに変更します。
さらに、1ページ戻る、1ページ進むボタンの遷移先とその表示の可否も設定します。

```html
<!-- 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">&lt;</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">&gt;</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と組み合わせて行う方法を紹介します。

[^PageRequest]:org.springframework.data.domain.PageRequest
[^PageableHandlerMethodArgumentResolver]:org.springframework.data.web.PageableHandlerMethodArgumentResolver。デフォルト実装は同パッケージの PageableHandlerMethodArgumentResolverSupport にて、`DEFAULT_PAGE_PARAMETER = "page"` と、`DEFAULT_SIZE_PARAMETER = "size"` でページング用のパラメータ名が定義されている。
[^Page]:org.springframework.data.domain.Pageインタフェース。Pageインタフェースは同パッケージのSliceインタフェースを継承。
[^PageImpl]:org.springframework.data.domain.PageImplクラス。具体的な実装はPageImplとChunkを参照するとわかりやすいです。
[^Chunk]:org.springframework.data.domain.Chunk抽象クラス。
[^isFirst]:ChunkクラスのisFirst()。同クラスのhasPrevious()を反転する。前ページがない＝最初のページ。
[^isLast]:ChunkクラスのisLast()。実装はPageImplのhasNext()を反転する。次のページがない＝最後のページ。