SpringBoot、Thymeleafでのページング機能の実装(Java初心者向け)

はじめに

 現在のプロジェクトでは、プログラムはJava(Spring-Boot)、フロントはthymeleaf、Bootstrapを使って開発をしています。
 開発する中で、時間を要したのがページネーション処理です。同環境での実装例も見当たらず、数日かかっての実装となりました。
 そこで、Spring-Boot、Tymeleaf環境でのページネーションの実装方法をご紹介します。同環境でページネーション処理につまずいている方に読んでいただきたいです。

環境

実装ページング機能概要

  • テーブルでData内容を表示する(3件づつ)
  • ページ数の表示、そのページへの遷移が可能
  • 現在のページの所在が分かる。
  • 「次へ」「前へ」を表出させて遷移できる。
  • 「最初」「最後」を表出させて遷移できる。
  • 表示するページ数を制限する(3ページ)

ディレクトリ構成

src/
├ java/
│ ├ sampleController.java
│ ├ sampleForm.java
│ ├ sampleDao.java
│ └ member.java
└ resourse/
└ html/
└ sample.html

1-1 Controller

package jp.sbworks.standard.web.controller;

import java.util.HashMap;
import java.util.List;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestMethod;
import org.springframework.web.bind.annotation.RequestParam;

import jp.sbworks.standard.web.dao.SampleDao;
import jp.sbworks.standard.web.db.single.pojo.SSample;

@Controller
public class SampleController {

    /** 1ページの表示数 */
    private final String limit = "3";

    /** ページネーションで表示するページ数 */
    private int showPageSize = 3;

    @Autowired
    private SampleDao sampleDao;


    @RequestMapping(value = "/sample",method = RequestMethod.GET)
    @Transactional(readOnly = true)
    public String index(Model model, @RequestParam HashMap<String, String> params) throws Exception {
        // パラメータを設定し、現在のページを取得する    
        String currentPage = params.get("page");

        // 初期表示ではパラメータを取得できないので、1ページに設定
        if (currentPage == null){
            currentPage = "1";
        }
        // データ取得時の取得件数、取得情報の指定
        HashMap<String, String> search = new HashMap<String, String>();
        search.put("limit", limit);
        search.put("page", currentPage);
    
        int total = 0;
        List<SSample> list = null;
        try {
            // データ総数を取得
            total = sampleDao.getMemberListCount();
            // データ一覧を取得
            list = sampleDao.getMemberList(search);
        } catch (Exception e) {
            return "error/fatal";
        }

        // pagination処理
        // "総数/1ページの表示数"から総ページ数を割り出す
        int totalPage = (total + Integer.valueOf(limit) -1) / Integer.valueOf(limit);
        int page = Integer.valueOf(currentPage);
        // 表示する最初のページ番号を算出(今回は3ページ表示する設定)
        // (例)1,2,3ページのstartPageは1。4,5,6ページのstartPageは4
        int startPage = page - (page-1)%showPageSize;
        // 表示する最後のページ番号を算出
        int endPage = (startPage+showPageSize-1 > totalPage) ? totalPage :    startPage+showPageSize-1;
        model.addAttribute("list", list);
        model.addAttribute("total", total);
        model.addAttribute("page", page);
        model.addAttribute("totalPage", totalPage);
        model.addAttribute("startPage", startPage);
        model.addAttribute("endPage", endPage);
        return "html/sample/index";
        }
    }

1-2 DAO

package jp.sbworks.standard.web.dao;

import java.io.IOException;
import java.sql.SQLException;
import java.util.HashMap;
import java.util.List;

import org.sql2o.Connection;
import org.sql2o.Query;
import org.sql2o.Sql2o;

import jp.sbworks.standard.web.db.single.pojo.SSample;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Repository;


@Repository
public class SampleDao {

    @Autowired
// データベースアクセス用のライブラリ
    private Sql2o sql2o;
 
    // リスト情報を取得する 
    public List<SSample> getMemberList(HashMap<String, String> search) throws SQLException, IOException {
        // Jdbcに接続する
        Connection con = sql2o.open();
        con.getJdbcConnection().setAutoCommit(false);
        // クエリの作成
        Query query = con.createQuery("select ID as id, NAME as name, DEPARTMENT as department, DAY_OF_JOINING as dayOfJoining from SAMPLE limit :limit offset :offset;");
        int limit = Integer.valueOf(search.get("limit"));
        int page = Integer.valueOf(search.get("page")) - 1;
        // 何件情報を取得するかの指定。
        query = query.addParameter("limit", limit);
        // 何件目からの情報を取得するかの指定(※コントローラからパラメータを使って現在のページ数が分かる。それによって何件目からの情報を取得すればいいのかが分かる。)
        query = query.addParameter("offset", limit * page);

        return query.executeAndFetch(SSample.class);
    }
    
    // リスト情報件数を取得する 
    public int getMemberListCount() throws SQLException, IOException {
        Connection con = sql2o.open();
        con.getJdbcConnection().setAutoCommit(false);
        Query query =  con.createQuery("select count(1) from SAMPLE");
        return query.executeAndFetchFirst(Integer.class);
     }
}

1-3 DB

package jp.sbworks.standard.web.db.single.pojo;

import java.io.Serializable;
import java.time.LocalDate;

public class SSample implements Serializable {

    // ID 
    public Integer id;
    // 名前 
    public String name;
    // 部署 
    public Integer department;
    // 入社日
    public LocalDate dayOfJoining;
    // 登録者 
    public String regName;

}

1-4 html

<!DOCTYPE html>
<html lang="ja">
    <head>
        <script type="text/javascript" th:src="@{/webpack-bundled/Sample/bundled.js}"></script>
    </head>
<div class="table-scroll mb-2">
    <table class="table table-bordered table-sm mt-1">
        <thead>
            <tr>
                <th>社員ID</th>
                <th>名前</th>
                <th>部署</th>
                <th>入社日</th>
                <th>登録者</th>
            </tr>
        </thead>
        <tbody>
            <th:block th:each="item, status : ${list}">
                <tr>
                    <td th:text="${item.id}"></td>
                    <td th:text="${item.name}"></td>
                    <td th:text="${item.department}"></td>
                    <td th:text="${item.dayofjoining}"></td>
                    <td th:text="${item.regname}"></td>
                </tr>
            </th:block>
        </tbody>
    </table>
</div>
<!-- ここからページング処理 -->
<nav>
    <ul class="pagination pg-blue justify-content-center">
        <li th:if="${startPage} > 1" class="page-item ">
            <a class="page-link" th:href="@{'/sample?page=1'}" tabindex="-2">最初</a>
        </li>
        <li th:if="${page} > 1" class="page-item ">
            <a class="page-link" th:href="@{'/sample?page=' + ${page-1}}" tabindex="-1">前へ</a>
        </li>
        <th:block th:if="${endPage}<=0">
            <li class="page-item " th:classappend="${i == page} ? active" th:each="i : ${startPage}">
                <a class="page-link" th:href="@{'/sample?page=' + ${i}}" th:text="${i}"></a>
            </li>
        </th:block>
        <!-- StartPageからEndPageまでのページ数を表示する -->
        <th:block th:if="${endPage}>0">
            <li class="page-item " th:classappend="${i == page} ? active" th:each="i : ${#numbers.sequence(startPage, endPage)}">
                <a class="page-link" th:href="@{'/sample?page=' + ${i}}" th:text="${i}"></a>
            </li>
        </th:block>
        <li th:if="${page} < ${totalPage}" class="page-item ">
            <a class="page-link" th:href="@{'/sample?page=' + ${page+1}}">次へ</a>
        </li>
        <li th:if="${endPage} < ${totalPage}" class="page-item ">
            <a class="page-link" th:href="@{'/sample?page=' + ${totalPage}}">最後</a>
        </li>
    </ul>
</nav>

現在いるページの所在を分かりやすくするために、Bootstrapを使って色付けをしています。下記は現在表示しているページとして青色で表示する場合の処理を簡単に書きました。

<nav>
    <ul class="pagination pg-blue">
…
<li th:classappend="${i == page} ? active" >

結果

下記のように実装できました。

まとめ

ページングの処理は煩雑なりがちですが、基本的な動きはJavaで実装し、Thymeleafで条件分岐、繰り返し処理を活用することで、より可読性が高く、シンプルなプログラムとなります。
 私がつまづいた箇所は、2ページ以降のデータの取得です。コントローラでパラメータを使って現在のpage番号を取得することで、取得を始めるデータを特定でき、ページごとに正しいデータを取得することに成功しました。
 1ページに表示するデータ数や、表示ページ数の指定をプロジェクト用に沿って替えることで、活用いただければ幸いです。

author/A.Nishihara

DBの環境差異調査(mysql編)

システム開発において、
 ・本番
 ・検品(ステージング)
 ・開発
と環境が別れている事は通常よくあることであり、それにともないDBの環境もそれぞれ存在することとなります。

開発体制が小規模であったり、管理ルールが整っていない時などに、それぞれのDB環境に差異が発生している事が多々あります。
その差異を視覚化し、容易に把握するための方法をご紹介します。

※今回使用した環境
 ・mysql : 5.6.40
      https://dev.mysql.com/downloads/windows/installer/5.6.html
 ・ローカル環境:windows 10
 ・比較ツール:WinMerge 2.14.0
        https://winmerge.org/downloads/?lang=ja

1.差分抽出SQL の用意


SELECT '### COLUMN情報 #########################' AS '';
SELECT TABLE_NAME
     , COLUMN_NAME
--   , ORDINAL_POSITION
     , COLUMN_TYPE
     , COLUMN_DEFAULT
     , IS_NULLABLE
     , COLUMN_KEY
     , IFNULL( COLLATION_NAME , '')      COLLATION_NAME
     , IFNULL( CHARACTER_SET_NAME , '')  CHARACTER_SET_NAME
     , COLUMN_COMMENT 
  FROM INFORMATION_SCHEMA.COLUMNS
 WHERE TABLE_SCHEMA = @DB_DATABASE
 ORDER BY TABLE_NAME , COLUMN_NAME
;

SELECT '### INDEX情報 ##########################' AS '';
SELECT TABLE_NAME
     , INDEX_NAME
     , SEQ_IN_INDEX
     , COLUMN_NAME
  FROM INFORMATION_SCHEMA.STATISTICS
 WHERE TABLE_SCHEMA = @DB_DATABASE
 ORDER BY TABLE_NAME, INDEX_NAME, SEQ_IN_INDEX 
;

mysql では、DBの情報を INFORMATION_SCHEMA 内に保持しており、その情報を出力しています。
※上記例では、カラムの順番を考慮せず、カラム名順で評価しています。

2.差分抽出SQL の実行

上記 SQL をファイルに保存して、コマンドで実行します。
※ここでは、「db-columns.sql」と命名します。

mysql -e "set @DB_DATABASE='DB名'; source db-columns.sql ;" > 環境.txt

※接続情報等は、省略させて頂いています。

こちらの実行した結果は以下の通りとなります。
※ mysql のバージョンに関わらず、全てのバージョンで稼働すると思います。


### COLUMN情報 #########################
TABLE_NAME	COLUMN_NAME	COLUMN_TYPE	COLUMN_DEFAULT	IS_NULLABLE	COLUMN_KEY	COLLATION_NAME	CHARACTER_SET_NAME	COLUMN_COMMENT
hoge	birthday	date	NULL	YES				
hoge	key	int(11)	NULL	NO	PRI			
hoge	name	varchar(50)	NULL	YES	MUL	utf8_general_ci	utf8	
hoge	sex	char(1)	0	YES		utf8_general_ci	utf8	

### INDEX情報 ##########################
TABLE_NAME	INDEX_NAME	SEQ_IN_INDEX	COLUMN_NAME
hoge	hoge_i1	1	name
hoge	hoge_i1	2	birthday
hoge	PRIMARY	1	key

3.SQL実行結果の比較

上記、環境毎に出力し、その結果を winmerge 等で比較する事が出来ます。
例えば、ローカルの環境と比較。

別のタブで画像を開くと、大きな画像で参照できます。

上記は、以下の テーブルを比較した結果なのですが、

-- 左のDDL
CREATE TABLE `hoge` (
	`key` INT(11) NOT NULL AUTO_INCREMENT,
	`name` VARCHAR(50) NULL DEFAULT NULL,
	`sex` CHAR(1) NULL DEFAULT '0',
	`birthday` DATE NULL DEFAULT NULL,
	PRIMARY KEY (`key`),
	INDEX `hoge_i1` (`name`, `birthday`)
)

-- 右のDDL
CREATE TABLE `hoge` (
	`key` INT(11) NOT NULL AUTO_INCREMENT,
	`name` VARCHAR(50) NULL DEFAULT NULL,
	`sex` VARCHAR(1) NULL DEFAULT '0',
	`birthday` TIMESTAMP NULL DEFAULT NULL,
	PRIMARY KEY (`key`),
	INDEX `hoge_i1` (`name`)
)

WinMarge 上で、差分行が色分けされているのが判るかと思います。
・DATE型 と TIMESTAMP 型 の差異
・CHAR型 と VARCHAR型 の差異
・INDEX の差異

※Oracle ですと、「USER_TAB_COLUMNS」・「USER_IND_COLUMNS」テーブルを使用すると、同じ情報が取得できます。

author k.tomita