普通の Spring Boot 2.0 Web Applicatrion 〜 Bootstrap と Thymeleaf でページネーション 〜

Spring Boot 2 で、なるべく標準的なやり方で、トラディショナルな Spring MVC による Web Application を作成するチュートリアルを数回に分けて。

の4回目です。


目次

今回は 「Bootstrap と Thymeleaf でページネーション」の回となります。


前回までで以下のような登録更新画面を作成しました。

f:id:Naotsugu:20180523221345p:plain

一覧画面から遷移可能とし、一覧画面のページネーション表示をやっていきましょう。


一覧画面の修正

ここまでの一覧画面は以下のようになっています。

f:id:Naotsugu:20180516232409p:plain

登録ボタンと編集用のリンクを追加しましょう。

src/main/resources/templates/members/membersList.html を以下のように変更します。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Members</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css"
          integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB"
          crossorigin="anonymous">
</head>

<body>
<div class="container">
    <h2>Members</h2>
    <table class="table table-striped table-sm">
        <thead>
        <tr>
            <th>ID</th>
            <th>name</th>
            <th>e-mail</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="member : ${members}">
            <td th:text="${member.id}"/>
            <td>
                <a th:href="@{/members/__${member.id}__/edit}" th:text="${member.name}"/></a>
            </td>
            <td th:text="${member.email}"/>
        </tr>
        </tbody>
    </table>

    <a class="btn btn-outline-primary" th:href="@{/members/new}" role="button">Add Member</a>

</div>
</body>
</html>

th:href<a> タグのhref属性に値を設定します。 値にはリンク式 @{} を使います。

リンク式では / で始まる相対URLにコンテキストパスを自動で付与します。

リンク式中に変数を定義でき、ここでは __${member.id}__" という形で Member の id をパス指定しています。 二重のアンダースコアで囲まれた式は、通常の式より先に評価されるプリプロセッシング式となります。

リンク式としての評価の前に id を埋め込む形となります。


新規登録用のボタンも定義し、th:href="@{/members/new}" のようにしました。こちらは単純なリンクです。


画面はこのようになりました。

f:id:Naotsugu:20180604214831p:plain

では、この一覧画面をページングできるように変更していきます。


Pagination の基本

まず、Bootstrap のPagination は以下形が基本になります。

<ul class="pagination">
  <li class="page-item">
    <a class="page-link" href="#" aria-label="Previous"><span>&laquo;</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="#" aria-label="Next"><span>&raquo;</span></a>
  </li>
</ul>

これで以下のような Pagination 表示になります。

f:id:Naotsugu:20180604215039p:plain


Previous とNext のリンクは、ページが存在しない場合は非活性にします。

これは クラスに disabled を付け、リンクには tabindex="-1" を指定してタブ選択できないようにします。

<li class="page-item disabled">
  <a class="page-link" href="#" aria-label="Previous" tabindex="-1">
    <span>&laquo;</span>
  </a>
</li>


また、現在ページのリンクはクラスに active をつけることで選択状態にします。

<li class="page-item active">
  <a class="page-link" href="#">2</a>
</li>


Previous リンクの作成

最初に Previous 部分(と Next部分)を作成しましょう。

該当ページが存在しない場合にリンクを非活性化する処理になります。

<li th:class="|page-item ${page.first ? 'disabled' : ''}|">
  <a class="page-link" aria-label="Previous" 
     th:href="@{''(page=${page.number - 1}, size=5)}">
    <span>&laquo;</span>
  </a>
</li>
・・・
<li th:class="|page-item ${page.last ? 'disabled' : ''}|">
  <a class="page-link" aria-label="Next"
     th:href="@{''(page=${page.number + 1}, size=5)}">
    <span>&raquo;</span>
  </a>
</li>

Thymeleaf では、"|・・・|" とすることでリテラル置換ができます。

${} で書くこともできますが、クラス指定などはリテラル置換で書くと読みやすくなります。

上記では、先頭ページと最終ページの場合に disabled を付けてリンクを非活性にしています。


th:href にはリンク式で前後のページへのリンクURLを出力しています。リンクURL中で (page=999) のように記載することで、クエリパラメータとして展開されます。 複数パラメータを付ける場合にはカンマ区切りで記載すれば良いです。

@{''(page=${page.number - 1}, size=5)}'' の部分には、通常リンク先のURLを書きますが、空にすることで現在のURLとして補完されます。


ページリンクの作成

ページリンク部分を以下のように作成します。

現在ページから前後に3ページ分のリンクを表示する処理になります(先頭ページにいる場合や最終ページにいる場合などがあるので少し面倒です)。

<div th:with="s = ${(page.number + 3 > page.totalPages - 1)
                    ? (page.totalPages - 1 - 6)
                    : (page.number - 3)}">

    <ul class="pagination pagination-sm">
    ・・・
        <li th:each='i : ${#numbers.sequence((s < 0 ? 0 : s),
                              ((s < 0 ? 0 : s) + 6) > (page.totalPages - 1)
                             ? (page.totalPages - 1)
                             : ((s < 0 ? 0 : s) + 6))}'
            th:class="|page-item ${(page.number == i) ? 'active' : ''}|">
            <a class="page-link" th:href="@{''(page=${i})}">
                <span th:text='${i + 1}'>1</span>
            </a>
        </li>
    ・・・
    </ul>
</div>

テンプレートでやる範囲を超えていますね。。

th:with でローカル変数を定義できます。 上記では、ページネーションで表示する開始ページを s としてローカル変数定義しています。

th:each で当該のタグを繰り返し出力します。

#numbers.sequence(x, y) は xからyまでの整数のシーケンス(配列)を作成するユーティリティオブジェクトです。 ページのインデックスを生成して th:each でリンクを生成しています。


先頭ページの表示

f:id:Naotsugu:20180604223730p:plain


中間ページの表示

f:id:Naotsugu:20180604223822p:plain


最終ページの表示

f:id:Naotsugu:20180604223852p:plain

といった具合に表示されます。


全体は以下のようになります。

<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
<head>
    <title>Members</title>
    <meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
    <link rel="stylesheet" href="https://stackpath.bootstrapcdn.com/bootstrap/4.1.1/css/bootstrap.min.css"
          integrity="sha384-WskhaSGFgHYWDcbwN70/dfYBj47jz9qbsMId/iRN3ewGhXQFZCSftd1LZCfmhktB"
          crossorigin="anonymous">
</head>

<body>
<div class="container" th:with="page = ${members}">
    <h2>TEST3</h2>
    <h2>Members</h2>
    <table class="table table-striped table-sm">
        <thead>
        <tr>
            <th>ID</th>
            <th>name</th>
            <th>e-mail</th>
        </tr>
        </thead>
        <tbody>
        <tr th:each="member : ${members}">
            <td th:text="${member.id}"/>
            <td>
                <a th:href="@{/members/__${member.id}__/edit}" th:text="${member.name}"/></a>
            </td>
            <td th:text="${member.email}"/>
        </tr>
        </tbody>
    </table>

<div class="float-right"
     th:with="s = ${(page.number + 3 > page.totalPages - 1)
                    ? (page.totalPages - 1 - 6)
                    : (page.number - 3)}">

    <ul class="pagination pagination-sm">
        <li th:class="|page-item ${page.first ? 'disabled' : ''}|">
            <a class="page-link" aria-label="Previous"
               th:href="@{''(page=${page.number - 1})}">
                <span>&laquo;</span>
            </a>
        </li>


        <li th:each='i : ${#numbers.sequence((s < 0 ? 0 : s),
                            ((s < 0 ? 0 : s) + 6) > (page.totalPages - 1)
                           ? (page.totalPages - 1)
                           : ((s < 0 ? 0 : s) + 6))}'
            th:class="|page-item ${(page.number == i) ? 'active' : ''}|">
            <a class="page-link" th:href="@{''(page=${i})}">
                <span th:text='${i + 1}'>1</span>
            </a>
        </li>


        <li th:class="|page-item ${page.last ? 'disabled' : ''}|">
            <a class="page-link" aria-label="Next"
               th:href="@{''(page=${page.number + 1})}">
                <span>&raquo;</span>
            </a>
        </li>
    </ul>
</div>

    <a class="btn btn-outline-primary" th:href="@{/members/new}" role="button">Add Member</a>

</div>
</body>
</html>

コントローラの変更

MemberController を変更し、ページ要求を受け取れるようにします。

    @GetMapping("/members")
    public String members(Model model, @PageableDefault(size = 10, sort = "id") Pageable pageable) {
        Page<Member> results = service.finaAll(pageable);
        model.addAttribute("members", results);
        return "members/membersList";
    }

@PageableDefault(size = 10, sort = "id") Pageable pageable を引数に追加すれば、ページのクエリがマッピングされます。

そして、@PageableDefault アノテーションでページ要求のデフォルト値を定義できます。ここではページサイズ(1ページに何レコード表示するか)とソート条件をデフォルト指定しました。

実行結果

ページングするように初期データを増やしておきましょう。

@SpringBootApplication
public class Main {

    private static final Logger log = LoggerFactory.getLogger(Main.class);

    public static void main(String[] args){
        SpringApplication.run(Main.class, args);
    }

    @Bean
    public CommandLineRunner demo(MemberRepository repository) {
        return (args) -> {
            Arrays.asList(
                "jack", "david", "james", "helen", "linda","rafael", "henry","sharon","george","betty",
                "eduardo", "harold", "peter", "jean", "jeff", "maria", "carlos", "charlie", "mickey", "dan",
                "thom", "teddy", "gordon", "elvis", "colin", "joseph", "hayden", "lionel", "jeff", "tristan")
            .stream().forEach(name ->
                    repository.save(new Member(name, name + "@example.com")));

            for (Member member : repository.findAll()) {
                log.info(member.toString());
            }
        };
    }
}

実行してみましょう。

$ ./gradlew bootRun

起動したら http://localhost:8080/members へアクセスすると以下のような表示となります。

f:id:Naotsugu:20180604225206p:plain



今回はここまでで、次回に続きます。