Web アプリでデータベースのレコード一覧を表示する場合、レコード数が多い場合はページングが必要になってくると思いますが、ASP.NET Core MVC でそれをどのように実装するかという話を書きます。
.NET Frammwork 版の MVC5 アプリでのページングの実装方法は Microsoft のドキュメント「チュートリアル: ASP.NET MVC アプリケーションでの Entity Framework を使用した並べ替え、フィルター処理、およびページングの追加」に書かれています。NuGet から PagedList パッケージをインストールしてそれを利用するというものです。
Core 版の MVC アプリではどうすればよいかですが、これも Microsoft のドキュメント「チュートリアル: 並べ替え、フィルター処理、ページングを追加する - ASP.NET MVC と EF Core」に説明がありました。
というわけで、そのチュートリアルを見ればこの記事の話は終わりなのですが、それではブログの記事としては面白くないので、ページャーの機能を拡張し、Microsoft のドキュメントにはページャーとして Previous と Next の機能しかなかったものに、上の画像のように First と Last およびページ番号のリンクを追加しました。
上の画像を表示したサンプルコードは以下の通りです。表示するのを Microsoft のサンプルデータベース Northwind の Products テーブルとした点とページャー機能の拡張以外は Microsoft のチュートリアルと同じです。
サンプルは .NET 5.0 ベースで作りましたが、肝心な部分は .NET Core 3.1 でも同じだと思います。(.NET 5.0 ベースにしたのは EF Core 5.0 で導入されたシンプルなログで SQL Server に渡される SQL 文を見ることができるという理由です)
コンテキストクラス、エンティティクラス
Microsoft のサンプルデータベース Northwind からリバースエンジニアリングで生成したものを利用しました。ここでの話にはほとんど関係ないのですが、参考までに自動生成されたエンティティクラス Product のコードを以下にアップしておきます。
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.ComponentModel.DataAnnotations.Schema;
using Microsoft.EntityFrameworkCore;
#nullable disable
namespace MvcCore5App.Models
{
[Index(nameof(CategoryId), Name = "CategoriesProducts")]
[Index(nameof(CategoryId), Name = "CategoryID")]
[Index(nameof(ProductName), Name = "ProductName")]
[Index(nameof(SupplierId), Name = "SupplierID")]
[Index(nameof(SupplierId), Name = "SuppliersProducts")]
public partial class Product
{
[Key]
[Column("ProductID")]
public int ProductId { get; set; }
[Required]
[StringLength(40)]
public string ProductName { get; set; }
[Column("SupplierID")]
public int? SupplierId { get; set; }
[Column("CategoryID")]
public int? CategoryId { get; set; }
[StringLength(20)]
public string QuantityPerUnit { get; set; }
[Column(TypeName = "money")]
public decimal? UnitPrice { get; set; }
public short? UnitsInStock { get; set; }
public short? UnitsOnOrder { get; set; }
public short? ReorderLevel { get; set; }
public bool Discontinued { get; set; }
[ForeignKey(nameof(CategoryId))]
[InverseProperty("Products")]
public virtual Category Category { get; set; }
[ForeignKey(nameof(SupplierId))]
[InverseProperty("Products")]
public virtual Supplier Supplier { get; set; }
}
}
PaginatedList.cs
Microsoft のチュートリアルのコードと全く同じですが、少しコメントを加えて以下にアップしておきます。
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;
namespace MvcCore5App.Services
{
public class PaginatedList<T> : List<T>
{
// 表示するページのページ番号
public int PageIndex { get; private set; }
// ページ総数
public int TotalPages { get; private set; }
// コンストラクタ。下の CreateAsync メソッドから呼ばれる
public PaginatedList(List<T> items,
int count,
int pageIndex,
int pageSize)
{
PageIndex = pageIndex;
TotalPages = (int)Math.Ceiling(count / (double)pageSize);
this.AddRange(items);
}
// 表示するページの前にページがあるか?
public bool HasPreviousPage
{
get
{
return (PageIndex > 1);
}
}
// 表示するページの後にページがあるか?
public bool HasNextPage
{
get
{
return (PageIndex < TotalPages);
}
}
// 下の静的メソッドがコントローラーから呼ばれて戻り値がモデルとして
// ビューに渡される。引数の pageSize は 1 ページに表示するレコード
// 数でコントローラーから渡される
public static async Task<PaginatedList<T>> CreateAsync(IQueryable<T> source,
int pageIndex,
int pageSize)
{
var count = await source.CountAsync();
var items = await source.Skip((pageIndex - 1) * pageSize).
Take(pageSize).
ToListAsync();
return new PaginatedList<T>(items, count, pageIndex, pageSize);
}
}
}
このクラスがページングの中枢を担うもので、コントローラーが並べ替え、フィルター処理を追加した後の IQueryable<Product> オブジェクトを CreateAsync メソッドが受け取って、Skip, Take メソッドでページに表示する部分のみをデータベースから取得し、PaginatedList<Product> オブジェクトを生成して戻り値として返すようにしています。
上の Linq to Entities で使われている Skip, Take メソッドですが、全レコードを取得した後でページに表示するレコードのみを切り取るのかと思っていましたがそうではなく、SQL Server には以下のような SQL 文が渡されてました。
SELECT p.ProductID, ...
FROM Products AS p
ORDER BY p.ProductID ASC
OFFSET {Skip 数} ROWS
FETCH NEXT {Take 数} ROWS ONLY
Web Forms アプリの SqlDataSource + GridView とかですと全レコード取得後に表示する部分を切り取るということになるのですが、そのあたりは進化しているようです。
Controller / Action Method
データの取得先を Microsoft のサンプルデータベース Northwind の Products テーブルとした点以外はチュートリアルのコードと同じです。
using System;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.EntityFrameworkCore;
using MvcCore5App.DAL;
using MvcCore5App.Models;
using MvcCore5App.Services;
namespace MvcCore5App.Controllers
{
public class ProductsController : Controller
{
private readonly NorthwindContext _context;
public ProductsController(NorthwindContext context)
{
_context = context;
}
public async Task<IActionResult> PagedIndex(string sortOrder,
string currentFilter,
string searchString,
int? pageNumber)
{
ViewData["CurrentSort"] = sortOrder;
ViewData["NameSortParm"] =
String.IsNullOrEmpty(sortOrder) ? "name_desc" : "";
ViewData["PriceSortParm"] =
sortOrder == "Price" ? "price_desc" : "Price";
if (searchString != null)
{
pageNumber = 1;
}
else
{
searchString = currentFilter;
}
ViewData["CurrentFilter"] = searchString;
var products = from p in _context.Products.Include(p => p.Category)
select p;
if (!String.IsNullOrEmpty(searchString))
{
products = products.Where(p => p.ProductName.Contains(searchString));
}
switch (sortOrder)
{
case "name_desc":
products = products.OrderByDescending(p => p.ProductName);
break;
case "Price":
products = products.OrderBy(p => p.UnitPrice);
break;
case "price_desc":
products = products.OrderByDescending(p => p.UnitPrice);
break;
default:
products = products.OrderBy(p => p.ProductName);
break;
}
int pageSize = 5; // 1 ページに表示するレコードの数
return View(await PaginatedList<Product>.CreateAsync(products.AsNoTracking(),
pageNumber ?? 1,
pageSize));
}
}
}
View
ページャーの機能を拡張し、Microsoft のドキュメントにあった Previous と Next に、First と Last およびページ番号のリンクを追加しました。結果は上の画像のようになります。
@model MvcCore5App.Services.PaginatedList<Product>
@{
ViewData["Title"] = "PagedIndex";
Layout = "~/Views/Shared/_Layout.cshtml";
}
<h1>PagedIndex</h1>
<p>
<a asp-action="Create">Create New</a>
</p>
<form asp-action="PagedIndex" method="get">
<div class="form-actions no-color">
<p>
Find by Product Name:
<input type="text" name="SearchString"
value="@ViewData["CurrentFilter"]" />
<input type="submit" value="Search" class="btn btn-primary" /> |
<a asp-action="PagedIndex">Back to Full List</a>
</p>
</div>
</form>
<table class="table">
<thead>
<tr>
<th>
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["NameSortParm"]"
asp-route-currentFilter="@ViewData["CurrentFilter"]">
Product Name
</a>
</th>
<th>
Category
</th>
<th>
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["PriceSortParm"]"
asp-route-currentFilter="@ViewData["CurrentFilter"]">
Unit Price
</a>
</th>
<th>
Discontinued
</th>
<th></th>
</tr>
</thead>
<tbody>
@foreach (var item in Model)
{
<tr>
<td>
@Html.DisplayFor(modelItem => item.ProductName)
</td>
<td>
@Html.DisplayFor(modelItem => item.Category.CategoryName)
</td>
<td>
@Html.DisplayFor(modelItem => item.UnitPrice)
</td>
<td>
@Html.DisplayFor(modelItem => item.Discontinued)
</td>
<td>
<a asp-action="Edit" asp-route-id="@item.ProductId">Edit</a> |
<a asp-action="Details" asp-route-id="@item.ProductId">Details</a> |
<a asp-action="Delete" asp-route-id="@item.ProductId">Delete</a>
</td>
</tr>
}
</tbody>
</table>
@{
var prevDisabled = !Model.HasPreviousPage ? "disabled" : "";
var nextDisabled = !Model.HasNextPage ? "disabled" : "";
}
<span>Page @Model.PageIndex of @Model.TotalPages</span>
<br />
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="1"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="btn btn-secondary @prevDisabled">
First
</a>
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="@(Model.PageIndex - 1)"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="btn btn-secondary @prevDisabled">
Prev
</a>
@{
// ページ総数が 5 を超える場合は Prev と Next の間に 1 2 3 4 5 と
// いうようなリンクを表示し、その番号のページに飛べるようにした
if (Model.TotalPages > 5)
{
if (Model.PageIndex <= 3)
{
for (int i = 1; i <= 5; i++)
{
var disabled = (Model.PageIndex == i) ? "disabled" : "";
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="@i"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="btn btn-secondary @disabled">
@i
</a>
}
}
else if (Model.PageIndex < (Model.TotalPages - 2))
{
for (int i = Model.PageIndex - 2; i <= Model.PageIndex + 2; i++)
{
var disabled = (Model.PageIndex == i) ? "disabled" : "";
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="@i"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="btn btn-secondary @disabled">
@i
</a>
}
}
else
{
for (int i = Model.TotalPages - 4; i <= Model.TotalPages; i++)
{
var disabled = (Model.PageIndex == i) ? "disabled" : "";
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="@i"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="btn btn-secondary @disabled">
@i
</a>
}
}
}
}
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="@(Model.PageIndex + 1)"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="btn btn-secondary @nextDisabled">
Next
</a>
<a asp-action="PagedIndex"
asp-route-sortOrder="@ViewData["CurrentSort"]"
asp-route-pageNumber="@(Model.TotalPages)"
asp-route-currentFilter="@ViewData["CurrentFilter"]"
class="btn btn-secondary @nextDisabled">
Last
</a>
まず、最初のページおよび最後のページに飛べるよう First, Last というリンクを追加しました。
さらに、コード内のコメントにも書きましたように、ページ総数が 5 を超える場合は Prev と Next の間に 1 2 3 4 5 というようなページ番号のリンクを表示し、それをクリックすることによりその番号のページに飛べるようにしてみました。
ページング機能は Html ヘルパーにまとめ、全ページ数が 5 を超える場合の 5 という数字は任意の数を外部から設定できるようにした方が良さそうですが、それは今後の検討課題ということで・・・