Spring Bootでページネーションを実装する

# はじめに
Spring Bootアプリケーションで一覧表示を行う際のページ分割を実装する。

# 環境
#### 開発環境は以下の通り。
Eclipse IDE Version: 2023-12 (4.30.0)
PostgreSQL 16.0,

#### 関連記事
[Spring Securityでログイン機能を実装する](https://qiita.com/KuboYoshinori/items/07837942161bb5bd4144)

[Spring Bootで検索と一覧表示を行う](https://qiita.com/KuboYoshinori/items/deb11fe0287aeeef3dfb)

##### サンプルソース
[Git Hub](https://github.com/bond-baga/demo_Postgre/releases/tag/demo_Postgre)

#### アプリケーションプロパティ
関連記事と同じ。

#### build.gradle
関連記事と同じ。

# サンプルプログラム
#### 一覧
以下のアドレスにアクセスするとログイン画面が表示される。
http://localhost:8080
ログイン完了後に一覧画面に遷移すると表示するデータ（行数）により以下のようにページが分割される。

###### ２８ページの場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/fac1a07f-724b-4995-92bb-671d8145dbe5.png)

###### ２ページの場合
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3985017/d92482b9-d555-4a2d-ba55-f7ab40057276.png)

本サンプルのページネーションの仕様。
* 一ページの表示行数は１０行
* 先頭ページ（１ページ）と最終ページを常に表示
* ページ選択の両端に「<<」と「>>」を表示してページ番号クリック以外でもページ移動が可能
* ４ページ括りで表示する。１～４ページのレイアウトで５ページ目は５～８ページのレイアウト
* 先頭ページと最終ページの間に省略しているページがある場合は「・・」を表示

# サンプルの解説
* 指定されたページのデータの取得はSQLのROW_NUMBER()関数で１０件ずつ取得
* 一覧表示のHTMLにnavタグを配置してリスト項目をJavascriptで動的に作成
* navタグの中のリスト項目タグのスタイルシートはBootstrapのページネーションを使用
* ページ情報は一覧表示のHTMLにhidden属性で設定してコントローラーと受け渡しを行う

###### DTO
ページネーションで利用するのは以下の項目になる。
* private Integer count：    データ件数
* private Integer page：     現在のページ
* private Integer maxpage：　最大ページ数

~~~UserForm.java
@Data
public class UserForm implements Serializable{
	private String code;
	private String name;
	private String kana;
	private String password;
	private Integer statecode;
	private Integer statuscode;
	private String divisionname;
	// 写真
	private MultipartFile imgfile;
	private String contenttype;
	private String imgString;
	
	private String searchname;
	private String searchkana;
	// 状態（複数）
	private List<Integer> statekeys;

	// 雇用形態
	private Integer statusKey;
	
	// 検索で状態と雇用形態の選択肢を保持する
	private Map<String, Map<Integer, String>> mapitems;
	private String mapstring;	
	private Map<String, Object> msg;
	
	// 検索結果
	private Integer count;
	private Integer page;
	private Integer maxpage;
	
	public UserForm() {
		code = "";
		name = "";
		kana= "";
		password = "";
		statecode = 0;
		statuscode = 0;
		divisionname = "";
		imgfile = null;
		contenttype = "";
		imgString = "";
		
		searchname = "";
		searchkana= "";
		statekeys = new ArrayList<Integer>();
		statusKey = 0;
		mapitems = new HashMap<String, Map<Integer, String>>();
		mapstring = "";
		msg = new HashMap<String, Object>();
		
		count = 0;
		page = 0;
		maxpage = 0;
	}
}
~~~

###### CSS
以下のスタイルはbootstrap.min.cssからコピーしている。
* paginationはulタグ（リスト）のスタイル
* page-itemはliタグ（リスト項目）のスタイル
* page-linkはaタグ（リンク）のスタイル

コピーしないで以下のようにヘッダーに参照を入れるとすべてのスタイルが使える。
~~~List.html
<head>
  <meta charset="UTF-8">
　<link rel="stylesheet" href="./css/example.css">
   <!-- Bootstrap CSS -->
  <link href="https://cdn.jsdelivr.net/npm/bootstrap@5.0.0-beta1/dist/css/bootstrap.min.css" rel="stylesheet"
    integrity="sha384-giJF6kkoqNQ00vy+HMDP7azOuL0xtbfIcaT9wjKHr8RbDVddVHyTfAAsrekwKmP1" crossorigin="anonymous">
  <title>list</title>
  
</head>
~~~
実際やってみるとintegrityの部分が意味不明であるのと原因不明の改行が発生したのでページネーションの部分のみを自前のCSSにコピーしている。

:::note info
Bootstrap
BootstrapはWeb開発用のフロントエンドのフレームワークでその圧縮版がbootstrap.min.css。HTMLのヘッダーでリンクするとボタン、ナビゲーションバー、フォーム要素などのスタイリングが利用できる。
:::

~~~example.css
--省略--
.pagination{display:flex; justify-content:center;list-style:none}

.page-link{position:relative;display:block;color:#0d6efd; font-size:  medium; text-decoration:none;background-color:#fff;border:1px solid #dee2e6;transition:color .15s ease-in-out,background-color .15s ease-in-out,border-color .15s ease-in-out,box-shadow .15s ease-in-out}
.page-item.active 
.page-link{z-index:3;color:#fff;background-color:#0d6efd;border-color:#0d6efd}
.page-item.disabled 
.page-link{color:#6c757d;pointer-events:none;background-color:#fff;border-color:#dee2e6}
.page-link{padding:.375rem .75rem}
--省略--
~~~

###### HTML

~~~List.html
<!DOCTYPE html>
--省略--
<form class="form-center" id="form" method="post" th:object="${UserForm}" th:action="@{/search}">
	<input type="hidden" th:field=*{mapstring}>
	<input type="hidden" id="code" th:field=*{code}>
	<input type="hidden" id="page" th:field=*{page}>
	<input type="hidden" id="maxpage" th:field=*{maxpage}>

--省略--    
</form>
<nav><ul class="pagination" id="pagination"></ul></nav>
<footer class="footer">
    <p>© All rights reserved by PALM</p>
</footer>

<script th:inline="javascript">
window.onload =  function() {
	let title = /*[[${title}]]*/"title";
	let user = /*[[${session.user['fullname']}]]*/"user";
		
    let element_title = document.getElementById("title");	
	element_title.textContent = title;
    let element_user = document.getElementById("user");	
	element_user.textContent = "ユーザー：" + user;
	localStorage.clear();

   	// ページネーション
   	let pageElement = document.getElementById("page");
	let page = pageElement.value;
	const pages = 4;
	// 現在のページから4ページ括りの数（親ページ）を求める
    let sec = Math.floor((page-1)/pages) + 1;
   	let maxpageElement = document.getElementById("maxpage");
	let maxpage = maxpageElement.value;
    
    html = '';
    for (i = (sec-1) * pages + 1; i <= sec * pages && i <= maxpage; i++) {
     	if (i ==(sec-1) * pages + 1) {
			if (page > 1){ 
				// ページが１ページから始まらない場合は「<<」を表示する
    			html += `<li class="page-item"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${parseInt(page)-1})">&laquo;</a></li>`;
    		}
    		if (sec > 1){
				//　４ページずつ表示するセクションが１でない場合（５ページ目が先頭ページの場合）１ページ目へのリンクと「・・」を表示。
    			html += `<li class="page-item"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${1})">1</a></li>`;
    			if (i != 2){
    				html += `<li class="page-item disabled"><a class="page-link" href="javascript:void(0);">・・</a></li>`;
 				}
 			}
 		}
 		if (i == page){
  			html += `<li class="page-item active"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${i})">${i}</a></li>`;			 
		}else{
  			html += `<li class="page-item"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${i})">${i}</a></li>`;
  		}
  		if (i == sec * pages || i == maxpage) {
			// 表示する右端  
			if (sec != Math.floor((maxpage-1)/pages) + 1){	
				//　４ページずつ表示するセクションが最終でない場合、最終ページへのリンクと「・・」を表示。
    			if (maxpage != pages+1){
    				html += `<li class="page-item disabled"><a class="page-link" href="javascript:void(0);">・・</a></li>`;
				}
    			html += `<li class="page-item"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${maxpage})">${maxpage}</a></li>`;
			}
			if (page != maxpage){	  
				// ページが最終ページで終わらない場合は「>>」を表示する
				html += `<li class="page-item"><a class="page-link" href="javascript:void(0);" onclick="pageclick(${parseInt(page)+1})">&raquo;</a></li>`;
  			}
  		}
  	}
    //ナビゲーションバーの中身を設定する。
 	document.getElementById('pagination').innerHTML = html;	
}
--省略--
~~~

* navタグ：他の文書へのナビゲーションリンクを提供するためのセクション
* ulタグ：項目の順序なしリスト
* liタグ：リストの項目
* aタグ：href属性を用いてリンクを作成する

本サンプルではnavタグ内にulタグをid="pagination"で定義してwindow.onloadイベントでliタグ以下を動的に作成している。
~~~
<nav><ul class="pagination" id="pagination"></ul></nav>	
~~~

Javascriptで動的に作成たHTMLをulタグの中身に設定している。
~~~
<script th:inline="javascript">
window.onload =  function() {
    //ナビゲーションバーの中身を設定する。
 	document.getElementById('pagination').innerHTML = html;	
}
~~~

###### ページ番号クリック
ページ番号が変更された場合はページ番号を引数とするpageclickメソッドが起動する。
コントローラーのメソッドに渡す引数のフォームのpageプロパティにページ番号を設定して"/page"にマッピングされたpostメソッドが呼ばれる。
~~~List.html
<script th:inline="javascript">
function pageclick(num) {
	// 検索項目を変更してからページ変更した場合は
	// 検索条件を元にもどしてから変更されたページのデータ検索を行う
    let form = document.getElementById("form");
	form.reset();
	
	let page = document.getElementById("page");
	page.value = num;
		
    form.action="/page";
    form.submit();
}  
~~~~

###### コントローラー
ページ番号をクリックした場合は"/page"にマッピングされたpostメソッドが起動される。Postメソッドからリダイレクトで"/list"にマッピングされたGetメソッドが起動される。

~~~ UserController.java
@Controller
public class UserController {
	
	@GetMapping(value={"/list"})
    public String getList(@ModelAttribute("UserForm") UserForm form, Model model, HttpSession session) {
    	model.addAttribute("title", "一覧"); 
        setMapItems(form);
        setSearchKey(form, session);
        // 職員データの取得
        Map<String, List<Map<String, Object>>> data = new HashMap<>();
        Integer count = userService.count(form);
        form.setCount(count);
        form.setMaxpage((int) (Math.floor((count - 1) / 10) + 1));
        if (form.getPage() == 0) form.setPage(1);
        data.put("users", userService.select(form));
        model.addAttribute("data", data);
        model.addAttribute("UserForm", form);
        return "list";
    }
    
    @PostMapping(value={"/page"})
    public String postPage(UserForm form, BindingResult result, RedirectAttributes redirectAttributes, HttpSession session){
    	redirectAttributes.addFlashAttribute("UserForm", form); 
		session.setAttribute("StateKeys", form.getStatekeys()); 
		session.setAttribute("StatusKey", form.getStatusKey()); 
        return "redirect:/list";
    }
}
~~~
###### public String getList()メソッドの処理
引数であるformにcount（データ件数）,page（現在のページ）,maxpage（最大ページ）が設定されているがここでもデータ件数と最大ページを再設定している。
以下のcount()メソッドでformの検索条件を渡してデータ件数を取得してその値をformのデータ件数のプロパティに設定している。データ件数から最大ページを求めてformの最大ページのプロパティに設定している。
（10はページ当たりの行数）
~~~
Integer count = userService.count(form);
form.setCount(count);
form.setMaxpage((int) (Math.floor((count - 1) / 10) + 1));
~~~

###### サービス
以下のメソッドで一覧データを取得する。
~~~
public List<Map<String, Object>> select(UserForm form) {
~~~
以下でnoが指定ページの先頭行から＋１０行のデータを取得している。
~~~
WHERE no BETWEEN " + Integer.toString((form.getPage()-1)*10+1) + " AND " + Integer.toString(form.getPage()*10);
~~~
「no」はSELECTで定義されている。
~~~
String query = 
"SELECT"+
" ROW_NUMBER() OVER(ORDER BY a.code) no"+ 
~~~

public Integer count()メソッドでデータ件数を取得している。
~~~
public Integer count(UserForm form) {
	--省略--
    int tmp = (Integer) jdbcTemplate.queryForObject(query, Integer.class);
	form.setCount(tmp);
~~~

~~~ UserService.java
@Service
public class UserService {
	
	@Autowired
	private JdbcTemplate jdbcTemplate;
	
	public List<Map<String, Object>> select(UserForm form) {
		String query = 
		"SELECT"+
		" ROW_NUMBER() OVER(ORDER BY a.code) no"+ 
		",a.code"+
		",a.name"+
		",a.kana"+
		",b.name statename"+
		",c.name statusname"+
		",a.divisionname"+
		",a.remarks"+
		",d.image"+
		" FROM members a"+
		" LEFT JOIN name b"+
		" ON a.statecode=b.code AND b.category=2"+
		" LEFT JOIN name c"+
		" ON a.statuscode=c.code AND c.category=1"+
　		" LEFT JOIN image d"+
		" ON a.code=d.code"+
		" WHERE a.name IS NOT null";
		
		if (form.getSearchname() != null && form.getSearchname() != "") {
			query += " AND a.name LIKE '" + form.getSearchname() + "%'";
		}
		if (form.getSearchkana() != null && form.getSearchkana() != "") {
			query += " AND a.kana LIKE '" + form.getSearchkana() + "%'";
		}
		
		if (form.getStatekeys().size() != 0) {
			//状態　通常　休職　退職
			query += " AND (";
			for (int i = 0; i < form.getStatekeys().size(); i++) {
				if (i == 0) {
					query += " a.statecode = '" + form.getStatekeys().get(i) + "'";
				}else{
					query += " OR a.statecode = '" + form.getStatekeys().get(i) + "'";				
				}
			}
			query += ")";
		}

		if (form.getStatusKey() != 0) {
			query += " AND (a.statuscode = '" + form.getStatusKey() + "')";			
		}
		query += " ORDER BY code";		
		query = "SELECT no,code,name,kana,statename,statusname,divisionname,remarks,image" +
		" FROM (" + query +") WHERE no BETWEEN " + Integer.toString((form.getPage()-1)*10+1) + " AND " + Integer.toString(form.getPage()*10);

		List<Map<String, Object>> tmp = jdbcTemplate.queryForList(query);
		for (int i=0; i<tmp.size(); i++) {
			if (tmp.get(i).get("image") != null) {
				StringBuffer encoded = new StringBuffer();
				encoded.append("data:"+ form.getContenttype()+ ";base64,");
				encoded.append(Base64.getEncoder().encodeToString((byte[])tmp.get(i).get("image")));
				tmp.get(i).put("imgString",encoded.toString());
			}else {
				tmp.get(i).put("imgString",null);
			}
		}
		return tmp;
	}
	
	public Integer count(UserForm form) {
		String query = 
		"SELECT count(*)"+
		" FROM members a"+
		" LEFT JOIN name b"+
		" ON a.statecode=b.code AND b.category=2"+
		" LEFT JOIN name c"+
		" ON a.statuscode=c.code AND c.category=1"+
		" WHERE a.name IS NOT null";
		
		if (form.getSearchname() != null && form.getSearchname() != "") {
			query += " AND a.name LIKE '" + form.getSearchname() + "%'";
		}
		if (form.getSearchkana() != null && form.getSearchkana() != "") {
			query += " AND a.kana LIKE '" + form.getSearchkana() + "%'";
		}
		
		if (form.getStatekeys().size() != 0) {
			//状態　通常　休職　退職
			query += " AND (";
			for (int i = 0; i < form.getStatekeys().size(); i++) {
				if (i == 0) {
					query += " a.statecode = '" + form.getStatekeys().get(i) + "'";
				}else{
					query += " OR a.statecode = '" + form.getStatekeys().get(i) + "'";				
				}
			}
			query += ")";
		}

		if (form.getStatusKey() != 0) {
			query += " AND (a.statuscode = '" + form.getStatusKey() + "')";			
		}
		int tmp = (Integer) jdbcTemplate.queryForObject(query, Integer.class);
		return tmp;
	}
}
~~~

# おわりに
ネットの記事を探りながらページネーションの機能を実装してみた。本記事に掲載している画像処理については回を改めて投稿したい。