今回のテーマ・課題
EntityFrameworkを利用したASP.NET Core MVCアプリケーションにおいてデータの一覧表示画面にページング機能を実装する。
1. 作業概要上のポイント
-
一覧表示画面に表示する任意のモデルにページング機能を実装するリストクラスを作成して配置する。
このリストクラスにはページングを実行可能にするために以下のようなプロパティーを持たせる。- 1ページ当たりの表示レコード数。
- 全体のページ数
- 現在のページインデックス
- 現在のページインデックスの前方にまだ表示可能なページが存在するか?
- 現在のページインデックスの後方にまだ表示可能なページが存在するか?
- 母集団となる再絞り込み可能なモデルリストに対して与えられたページインデックスのレコードを取り出すためのメソッド。
この拡張リストを使用してカレントページに表示するレコードを取り出す静的メソッドを用意する。
このメソッドに引き渡される母集団となるデータリストはページング処理により再クエリーされるため、IQuerableインターフェイスを実装する必要がある。 -
View側にはページングされたモデルのリストを表示するための以下のようなコントロールが必要になる。
- トータルページ数と現在のページインデックスを表示するラベル。
- 前方・後方にページをめくるボタン
あるいは・・・ - 「1, 2, 3, 4, ・・・」のように先頭からのページインデックスを(それらのページへの)リンクタグ付きで示すリスト。
ポイントとなるのはこれらの<ページング用のビューコントロールが元の一覧表示画面の検索機能や並べ替え機能に最小限の影響しか与えないようにすることである。
【説明の進め方】
このドキュメントの前提として、既にアプリケーションには一覧表示画面(Index.cshtml)が用意されており、そこには検索機能とソート機能が実装されているものとする。
そのサンプルとして「検索+ソート機能付き一覧画面(MVC)・・・ASP.NET Core開発ノウハウ 4-2」にて実装したPersonモデルの検索・ソート条件付き一覧表示画面にページング機能を実装する手順を示していく。(https://qiita.com/TR-MF/items/e5ba963be872ecdfe928)
2. ページング実装拡張リストクラス
プロジェクト内の\Commonまたは\Utility等の任意のフォルダに**拡張リスト用クラス「PaginatingList<T>」**クラスを作成する。
\Common\PaginatedList.cs
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
namespace ANCEntry_EFMvcApp.Common
{
/// <summary>
/// 一覧表示用UIに任意のモデルデータを表示する際にPaging機能を与えるList
/// </summary>
/// <typeparam name="T">UIに一覧表示するデータのモデルクラス</typeparam>
public class PaginatedList<T>: List<T>
{
/// <summary>
/// 現在のページのインデックス
/// </summary>
public int PageIndex { get; private set; }
/// <summary>
/// トータルページ数
/// </summary>
public int TotalPages { get; private set; }
/// <summary>
/// コンストラクタ
/// </summary>
/// <param name="items">当該ページに表示するレコードリスト</param>
/// <param name="count">元々リストに含まれていた総レコード数</param>
/// <param name="pageIndex">現在表示するページのインデックス</param>
/// <param name="pageSize">1ページに表示するサイズ</param>
public PaginatedList(List<T> items, int count, int pageIndex, int pageSize)
{
this.PageIndex = pageIndex;
this.TotalPages = (int)Math.Ceiling(count / (double)pageSize);
this.AddRange(items);
}
/// <summary>
/// 現在表示されているページの前にまだ表示するページが存在するか?
/// </summary>
public bool HasPreviousPage
{
get
{
return (PageIndex > 1);
}
}
/// <summary>
/// 現在表示されているページの後方にまだ表示するページが存在するか?
/// </summary>
public bool HasNextPage
{
get
{
return (PageIndex < this.TotalPages);
}
}
/// <summary>
/// PagenatedListのコアメソッド。
/// ソースリストをページサイズによりページ分けして、指定された
/// インデックスのページを表示するリストを返す。
/// </summary>
/// <param name="source">指定されたページ用のリストを取り出す元となるリスト</param>
/// <param name="pageIndex">取得するページ番号</param>
/// <param name="pageSize">1ページに表示するレコード数</param>
/// <returns>ページに表示するデータリスト</returns>
public static async Task<PaginatedList<T>> CreateAsync (
IQueryable<T> source, int pageIndex, int pageSize
)
{
var count = await source.CountAsync().ConfigureAwait(false);
var items = await source.Skip((pageIndex - 1) * pageSize)
.Take(pageSize)
.ToListAsync()
.ConfigureAwait(false);
return new PaginatedList<T>(items, count, pageIndex, pageSize);
}
}
}
【解説】
- PaginatingListのメインは静的な非同期メソッドCreateAsyncである。これが唯一コントローラの一覧画面用アクションメソッド(Index)からコールされるメソッドである。
- CreateAsyncメソッドは内部でIQuerableな母集団レコードに対して、まずSkipメソッドでカレントページの前方にある表示しないレコードをスキップし、次にTakeメソッドで当該ページに表示するコードを抽出する。
- async Task メソッドにする場合のセオリーとして、awaitする非同期メソッドを ConfigureAwait(false) としてデッドロックを回避する。
ConfigureAwait(false)はVisual Studioのコードヒントによりコード追加をレコメンドされる。
- CreateAsyncメソッドは内部でIQuerableな母集団レコードに対して、まずSkipメソッドでカレントページの前方にある表示しないレコードをスキップし、次にTakeメソッドで当該ページに表示するコードを抽出する。
- コンストラクタはCreateAsyncからのみコールされる。従って静的メソッドCreateAsyncは指定されたページに表示するモデルデータのリストと、現在のページインデックス(=PageIndex)、総ページ数(=TotalPages)、現在のページの前後に表示するページがあるか?(=HasPreviousPage、HasNextPage)といった情報を統合して有するモデルを返すことになる。
3. モデルへのページング拡張リストの組み込み
前提より一覧表示画面(Index.cshtml)には検索機能とソート機能が実装されているので、モデルはこれらの設定をUIとやり取りするためのプロパティーやメソッドを有している。
このモデルのメンバープロパティーである一覧出力対象モデルリストをPagenatedListにセットする。
\Models\PersonSearchModel.cs
using Models_EFMvcApp.Common;
using Microsoft.AspNetCore.Mvc.Rendering;
using System;
namespace ANCEntry_EFMvcApp.Models
{
public class PersonSearchModel
{
// public List<Person> People { get; set; }
/// <summary>
/// ビュー側に一覧表示するモデル(=Person)のリスト。
/// これをページング対応クラス(PaginatedList)にする。
/// </summary>
public PaginatedList<Person> People { get; set; }
/// <summary>
/// ビュー側で選択されたソート対象項目
/// </summary>
public string SortField { get; set; }
/// <summary>
/// 名前(FirstName/ LastNameの何れか)の検索値
/// </summary>
public string Crt_Name { get; set; }
/// <summary>
/// 年齢(Age)の検索値
/// </summary>
public string Crt_Age { get; set; }
// ・・・ 以下省略 ・・・
}
}
【解説】
- ここでの改修はごく僅か。元々Listであった出力対象リストの型をPaginatedListに変更するだけ。
- モデル自体にはページング機能を備えるのではなく、モデルメンバーの一覧表示対象リストにページング機能を実装すると考える。
- 一覧表示画面は1画面あたり何レコード表示可能で、トータル何ページになり、現在何ページ目が表示されているか?といった事項は全てPagenatedList側に保持される。
4. コントローラでのページング拡張リストの使用
前提より一覧表示画面(Index.cshtml)には検索機能とソート機能が実装されているので、コントローラのIndexアクションメソッドは既に引数として検索値やソート条件を引数として受け付けている。
ここにページングを行うための引数としてページインデックス、即ち「何ページ目を表示するか?」を追加する。
\Controllers\PersonController.cs
/// <summary>
/// 検索ソート機能付き一覧画面表示用メソッド。
/// 2項目用の検索値とソート条件指定を受け取る。
/// </summary>
/// <param name="crt_name">名前(FirstName/LastName共通)の検索値。一致条件は一部一致とする。</param>
/// <param name="crt_age">年齢の検索条件。一致条件は前後5歳の幅を持たせる。</param>
/// <param name="sortfield">ソート条件。項目名に所定の区切り文字で昇順・逆順指定を加える。</param>
/// <param name="pageIndex">ページングを行う場合何ページ目を表示するか指定する。</param>
/// <returns>引数の検索+ソート条件で抽出されたレコードのリスト。更にLINQ式により変換が可能</returns>
[HttpGet]
public async Task<IActionResult> Index(string crt_name, string crt_age,
string sortfield, int? pageIndex)
{
IQueryable<Person> selected;
// ・・・ 途中省略 ・・・
// この部分にIQuerable<Person>selectedに、引数crt_nameとcrt_ageで指定された検索条件でデータを検索し、
// sortfiledに指定されたソート条件で並べ替えを行った結果をセットする処理が記載されている。
// ・・・
var people_pagelist = await PaginatedList<Person>.CreateAsync(
selected.AsNoTracking(), pageIndex ?? 1, PageSize
).ConfigureAwait(false);
var myModel = new PersonSearchModel
{
People = people_pagelist,
Crt_Name = crt_name,
Crt_Age = crt_age,
SortField = sortfield,
};
return View(myModel);
}
【解説】
- 追加する引数indexは未指定の場合もあるためint?(=Null許可型)とする。
ここに値がセットされないのは検索条件やソート条件が変更されたときで、その場合には必ず1ページ目を表示する。(そのためこの値がnullであれば1が指定される。)
- 検索条件+ソート条件の指定で抽出と並べ替えが完了しているIQuerableを引数にしてPaginatedListのコンストラクタを呼び出す。
- selected.AsNoTracking()はこれに続くページング処理が、データの更新を行わないため、別のスレッドにこれ以降の当該データリストのトラッキングを行わなくても良いことを伝え、パフォーマンスを改善する。
- ConfigureAwait(false)はコードアシスタントによりレコメンドされた処理で、リソースのデッドロックを回避するためのもの。
- selected.AsNoTracking()はこれに続くページング処理が、データの更新を行わないため、別のスレッドにこれ以降の当該データリストのトラッキングを行わなくても良いことを伝え、パフォーマンスを改善する。
- 最後にページ用モデル生成時に、上のコンストラクタ呼び出しで生成されたページング機能付きリストをメンバーとして引き渡す。後はViewとPaginatedListクラスそのものが全部やってくれる。
4. ビューでのページング拡張リストの使用
元となるビュー(Index.cshtml)では、検索条件入力エリア及び、一覧画面表示用テーブルのヘッダーにソート条件設定用のリンクを設定していた。これに新たにページングを実装するためのコントロールを加える。
と言っても元々実装されていた検索条件・ソート条件設定用のコントロールには一切手を加えない。
\Views\People\Index.cshtmls
@model ANCEntry_EFMvcApp.Models.PersonSearchModel
@{
ViewData["Title"] = "Find";
}
<h1>Find</h1>
<p>
<a asp-action="Index">一覧へ</a>
</p>
@* 検索条件入力エリア *@
<form asp-controller="People" asp-action="Index" method="get">
・・・ (検索条件入力用のコントロールが書かれている) ・・・
</form>
<table class="table">
<thead>
<tr>
<th>
<a asp-controller="People" action="Index"
asp-route-Crt_Name="@Model.Crt_Name"
asp-route-Crt_Age="@Model.Crt_Age"
asp-route-SortField="@Model.GetSortFieldParamValue(Html.DisplayNameFor(model => model.People[0].PersonID))">
@Model.GetSortFieldDisplayName(Html.DisplayNameFor(model => model.People[0].PersonID))
</a>
</th>
・・・ 以下、上の例に倣ってFirstName, LastName, EMail, Ageの各項目にソート条件設定用の
・・・ タグヘルパー付きの<a>を内包する<th![4-3-1.Index先頭ページ.jpg](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/583684/d8e5afd4-7df1-7d01-2fc1-6e726791727d.jpeg)
>を書いていく(記載省略)
</tr>
</thead>
<tbody>
@foreach (var item in Model.People)
{
<tr>
<td>
@Html.DisplayFor(modelItem => item.PersonID)
</td>
<td>
@Html.DisplayFor(modelItem => item.FirstName)
</td>
<td>
@Html.DisplayFor(modelItem => item.LastName)
</td>
<td>
@Html.DisplayFor(modelItem => item.EMail)
</td>
<td>
@Html.DisplayFor(modelItem => item.Age)
</td>
<td>
<a asp-action="Edit" asp-route-id="@item.PersonID">Edit</a> |
<a asp-action="Details" asp-route-id="@item.PersonID">Details</a> |
<a asp-action="Delete" asp-route-id="@item.PersonID">Delete</a>
</td>
</tr>
}
</tbody>
</table>
・・・ ここまでは何も手を加えない。
@* モデルより現在表示されているページの前後移動が可能であるかをコードセクションの変数に格納 *@
@{
var prevDisabled = !Model.People.HasPreviousPage ? "disabled" : "";
var nextDisabled = !Model.People.HasNextPage ? "disabled" : "";
}
<div>
@* 先頭ページへ移動するボタン *@
<a asp-controller="People" asp-action="Find2"
asp-route-Crt_Name="@Model.Crt_Name"
asp-route-Crt_Age="@Model.Crt_Age"
asp-route-SortField="@Model.SortField"
asp-route-pageIndex="1"
class="btn btn-primary @prevDisabled">
Top
</a>
@* 前ページへ移動するボタン *@
<a asp-controller="People" asp-action="Find2"
asp-route-Crt_Name="@Model.Crt_Name"
asp-route-Crt_Age="@Model.Crt_Age"
asp-route-SortField="@Model.SortField"
asp-route-pageIndex="@(Model.People.PageIndex - 1)"
class="btn btn-primary @prevDisabled">
Previous
</a>
@* 後ページへ移動するボタン *@
<a asp-controller="People" asp-action="Find2"
asp-route-Crt_Name="@Model.Crt_Name"
asp-route-Crt_Age="@Model.Crt_Age"
asp-route-SortField="@Model.SortField"
asp-route-pageIndex="@(Model.People.PageIndex + 1)"
class="btn btn-primary @nextDisabled">
Next
</a>
@* 最後のページへ移動するボタン *@
<a asp-controller="People" asp-action="Find2"
asp-route-Crt_Name="@Model.Crt_Name"
asp-route-Crt_Age="@Model.Crt_Age"
asp-route-SortField="@Model.SortField"
asp-route-pageIndex="@(Model.People.TotalPages)"
class="btn btn-primary @nextDisabled">
Last
</a>
@* 「カレントページインデックス / トータルページ数」を表示する *@
<span class="border">@Model.People.PageIndex / @Model.People.TotalPages</span>
</div>
【解説】
- Viewの後半の前後のページ移動用リンク<a>とカレントページ表示用の|、及び直前の前後ページ移動可否を取得するコードブロックが全て。
- 前後ページ移動リンクにのみasp-route-pageIndexタグヘルパーにて、コントローラーのIndexアクションメソッドに引き渡すページインデックスがパラメータ名=pageIndexとして指定される。
やることはたったこれだけ!
5. 実行結果
1. 先頭のページ
検索・ソート条件なしの場合。先頭ページが表示される。Top/Previousリンクは使用不可。
2. 中間のページ
次へボタンを2回クリックして3ページ目を表示したところ。前後へのページ遷移可能。
3. ソートを実行した直後
この状態でテーブルヘッダー行の「LastName」をクリックして姓でソートを実行した直後。並べ替えが実行され、先頭ページに移動する。
6. 今後の課題
以下のような機能を実装すること
- 前後のページに移動するボタンではなく、表示可能なページ番号を列挙して、それぞれのページへのリンクを実装できるようなPaginatedListを作成する。
- ページ数が10を超えたら表示しきれないページは「・・・」で表す。