情報応用演習Ⅰ(2024)

【T10a】簡易ブログソフトウェアの作成 Part.Ⅲ ~ 設定ファイルとクエリ文字列(5/5)

プロジェクトタイプ(注意: 本文参照)
プロジェクト名T10a
ソリューション名PIT10
注意
  • 本ページの作業内容は 前のページまでの続き になっていることに注意せよ.
    • 先に前のページまでをすべて読み,指示されている作業を済ませてから本ページを読むこと.
    • プロジェクトの作成作業については準備作業を参照せよ.

10a-5. ページング(ページネーション)

本節ではウェブアプリケーションでは一般的な処理であるページングについて学ぶ.ページング あるいは ページネーション とは 多数のデータの一覧を いくつかのページに分けて 表示することである.

本節ではテストデータとして80件前後の「記事」があらかじめ登録されている.現状のArticleコントローラーの Indexアクションではデフォルトではすべての記事が表示されてしまうため,非常に長いスクロールが発生してしまっていた(_).

Indexアクションの非常に長いスクロール

このような多数のデータの表示は,ページナビゲーションとして不便であるだけでなくクライアントに転送するデータ量の肥大にもつながってしまう. もしこのウェブブログサイトに1000件程度といった多数の「記事」データがあった場合,現状のIndexアクションはそのすべてを 転送しようとしてしまい,これにはおそらく表示に長い時間がかかるはずである.このような場合の対処が 冒頭で説明した ページング である(あるいは ページネーション とも呼ばれる).ページングはウェブアプリでは基本的な処理の一つであり,たとえば前節で 例として挙げたウェブ検索サイトの検索結果のページでも,その結果全てを表示するのではなく先頭から数件ずつ結果を表示するようにしている1(_).

ウェブ検索サイトの検索結果ページにおけるページングの例

ASP.NET Core でこのページングを実現するためには,LINQに対して.Skip()メソッド.Take()メソッドを使用すればよい. これらは第02回で説明のみ登場している.チュートリアル【T2c】の表2c-4-1_に再掲する.

LINQで使用できる補助用のメソッド(一部 再掲)
メソッド作用
.Min()結果のうち最小のものを返す.
.Max()結果のうち最大のものを返す.
.First()結果のうち最初の要素を返す.
.FirstOrDefault()結果のうち最初の要素を返す.結果が空ならばその型のデフォルト値を返す.
.Last()結果のうち最後の要素を返す.
.LastOrDefault()結果のうち最後の要素を返す.結果が空ならばその型のデフォルト値を返す.
.Skip()結果のうち最初のn個を飛ばす.
.Take()結果のうち最初のn個のみを受け取る.

.Skip()メソッドはLINQの結果セットの最初の何件かを読み飛ばすメソッドである.たとえばLINQの式.Skip(5)のように呼び出すと 元のLINQの式の最初の5件が読み飛ばされて(≒削除されて)取得される..Take()メソッドはLINQの結果セットの最初の何件かのみを 取得するメソッドである.たとえばLINQの式.Take(10)のように呼び出すと元のLINQの式の最初の10件のみを取り出すことができる. これらを組み合わせることでページングを実装することができる.

試しにこれらを使用してみよう.ArticlesコントローラーのIndexアクションに_に示す内容を追記してみよう.

Articlesコントローラーの追記内容(Index)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
// GET: Articles
public async Task<IActionResult> Index(string? author)
{
    BlogUser? currentUser = await _userManager.GetUserAsync(User);
    bool isAdminUser = await IsAdminUserAsync(currentUser);

    bool isLoggedIn = _signInManager.IsSignedIn(User) && currentUser != null;

    // ↓ var ではなく変数の型を明示的に IQueryable<T> と指定しておく必要がある.
    IQueryable<Article> articles = (from a in _context.Articles
                                    where (a.PublicationState == PublicationStateType.Published
                                       || (isLoggedIn && a.BlogUserId == currentUser!.Id)
                                       || isAdminUser)
                                       && (author == null || a.BlogUser!.UserName == author)
                                    orderby a.Modified descending
                                    select a)/*.Include(a => a.BlogUser)*/; // .Include()の呼び出しを↓に移動する.

    articles = articles.Skip(0).Take(5); // 0件スキップしてそこから5件を取得する.

    ViewBag.CurrentUser = currentUser;
    ViewBag.IsAdminUser = isAdminUser;
    ViewBag.IsLoggedIn = isLoggedIn;

    return View(await articles.Include(a => a.BlogUser).ToListAsync()); // ここで.Include()を呼び出す
}

ここまで書けたら実行してみよう._に示す通り,記事の一覧画面では 5件の記事のみが表示されていることが分かるはずである.また_.Skip()メソッドの引数を0から5, 10, 15,...と書き換えて実行してみよう(_). そのつど表示される5件の内容が変化することが分かるはずである(_).

実行結果

このように「スキップする件数」と「何件表示するか」をプログラム的に調整することでページングを実現することが可能である. それではこれらを使ってページングを実装してみよう.このためにまず「1ページに表示する件数」を決めておこう. 決め打ちでもよいが設定ファイルの利用で学んだ,設定ファイルから読み出す方法を応用することにしよう.

appsettings.json および SiteSettings.cs にそれぞれ__に示す内容を追記する.

appsettings.jsonの追記内容
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
{
  "Logging": {
    "LogLevel": {
      "Default": "Information",
      "Microsoft.AspNetCore": "Warning"
    }
  },
  "AllowedHosts": "*",
  "ConnectionStrings": {
    "T10aContext": "Host=localhost;Port=5432;Username=t10a_user;Database=t10a_db;Password=Xbi!YzNm5"
  },
  "SiteSettings": { 
    "SiteName": "私のサイト",
    "SiteOwnerName": "名無しの権兵衛",
    "SiteContactAddress": "g-nanashi@thcu.ac.jp", // ←ここカンマを追記するの忘れないように!
    "NumberOfArticlesEachPage": 5 // 1ページに何件の記事を表示するか
  }
}
サイト設定のためのクラス
1
2
3
4
5
6
7
8
public class SiteSettings
{
    public string SiteName { get; set; } = "MySite";
    public string SiteOwnerName { get; set; } = "John Smith";
    public string SiteContactAddress { get; set; } = "john@example.com";

    public int NumberOfArticlesEachPage { get; set; } // 1ページに何件の記事を表示するか
}

これらの設定をArticlesコントローラーで読み出すことができるように, Controllers/ArticlesController.cs に _に示す内容を追記しよう.

Articlesコントローラーの追記内容(IConfigurationの追加)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class ArticlesController : Controller
{
    private readonly T10aContext _context;
    private readonly UserManager<BlogUser> _userManager;
    private readonly SignInManager<BlogUser> _signInManager;

    private readonly IConfiguration _configuration;
    private SiteSettings _siteSettings;

    public ArticlesController(T10aContext context, UserManager<BlogUser> userManager, SignInManager<BlogUser> signInManager, IConfiguration configuration)
    {
        _context = context;
        _userManager = userManager;
        _signInManager = signInManager;

        _configuration = configuration;

        _siteSettings = new SiteSettings();
        _configuration.GetSection(nameof(SiteSettings)).Bind(_siteSettings);
    }
    
    // ..(略)..

このウェブログソフトウェアにおけるページングは,記事の一覧の 「何ページ目」を取得するかをIndexアクションのクエリ文字列として受け取る , という形式をとることにしよう.ここでは?pageNumber=何ページ目というクエリ文字列で指定することにする. このためにArticlesコントローラーのIndexアクションに_に示す内容を追記しよう.

Articlesコントローラーの追記内容(Indexアクション(GETのみ))
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
// GET: Articles
public async Task<IActionResult> Index(string? author, int? pageNumber) // ?pageNumber=xxx というクエリが来ることを想定している.
{
    BlogUser? currentUser = await _userManager.GetUserAsync(User);
    bool isAdminUser = await IsAdminUserAsync(currentUser);

    bool isLoggedIn = _signInManager.IsSignedIn(User) && currentUser != null;

    IQueryable<Article> articles = (from a in _context.Articles
                                    where (a.PublicationState == PublicationStateType.Published
                                       || (isLoggedIn && a.BlogUserId == currentUser!.Id)
                                       || isAdminUser)
                                       && (author == null || a.BlogUser!.UserName == author)
                                    orderby a.Modified descending
                                    select a)/*.Include(a => a.BlogUser)*/; // .Include()の呼び出しを↓に移動する.

    // pageNumberが未指定,すなわちnullのときは 1 として扱う.
    pageNumber ??= 1;

    // 全部で何ページ分あるかを計算する.
    int pageCount = (int)Math.Ceiling(await articles.CountAsync() / (double)_siteSettings.NumberOfArticlesEachPage);

    if (pageCount > 1) 
    {
        // 何件スキップするかを計算する.
        int numberOfSkip = _siteSettings.NumberOfArticlesEachPage * Math.Max(pageNumber.Value - 1, 0);

        articles = articles.Skip(numberOfSkip).Take(_siteSettings.NumberOfArticlesEachPage);
    }//if

    ViewBag.CurrentUser = currentUser;
    ViewBag.IsAdminUser = isAdminUser;
    ViewBag.IsLoggedIn = isLoggedIn;           

    return View(await articles.Include(a => a.BlogUser).ToListAsync()); // ここで.Include()を呼び出す
}

21行目では全部で何ページ分になるかを計算し,全ページの合計が2ページ以上になるときのみページングの処理を行っている. 26行目でMath.Max()メソッドを使用しているのは,pageNumber.Value - 1の計算結果がマイナスにならないようにするための対処である (pageNumberにゼロや負数が入力された場合の対処).

ここまで書けたら実行してみよう.先ほどと同じように,記事の一覧画面では 5件の記事のみが表示されていることが分かるはずである.アドレスバーのURLの末尾を /Articles/Index?pageNumber=何ページ目のように 書き換えてアクセスしてみよう.何ページ目の部分を1,2,3,...と書き換える都度, 表示される5件の内容が変化することが分かるはずである(_).

実行結果

このままでもよいが,ページ下部に「前へ」「次へ」のようなリンクを作ってよりアクセスしやすくしてみよう. ArticlesコントローラーのIndexアクションとそのビューにそれぞれ__に示す内容を反映してみよう.

Articlesコントローラーの追記内容(Index)
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
// GET: Articles
public async Task<IActionResult> Index(string? author, int? pageNumber) // ?pageNumber=xxx というクエリが来ることを想定している.
{
    BlogUser? currentUser = await _userManager.GetUserAsync(User);
    bool isAdminUser = await IsAdminUserAsync(currentUser);

    bool isLoggedIn = _signInManager.IsSignedIn(User) && currentUser != null;

    IQueryable<Article> articles = (from a in _context.Articles
                                    where (a.PublicationState == PublicationStateType.Published
                                       || (isLoggedIn && a.BlogUserId == currentUser!.Id)
                                       || isAdminUser)
                                       && (author == null || a.BlogUser!.UserName == author)
                                    orderby a.Modified descending
                                    select a)/*.Include(a => a.BlogUser)*/; // .Include()の呼び出しを↓に移動する.

    pageNumber ??= 1;

    int pageCount = (int)Math.Ceiling(await articles.CountAsync() / (double)_siteSettings.NumberOfArticlesEachPage);

    ViewBag.PageNumber = pageNumber; // 現在のページ番号と
    ViewBag.PageCount = pageCount;   // 最大ページ数を ViewBag に入れておく
    ViewBag.ShowPages = false;       // ページのリンクを表示するかどうか(デフォルトでは非表示)

    if (pageCount > 1) 
    {
        int numberOfSkip = _siteSettings.NumberOfArticlesEachPage * Math.Max(pageNumber.Value - 1, 0);

        articles = articles.Skip(numberOfSkip).Take(_siteSettings.NumberOfArticlesEachPage);

        ViewBag.ShowPages = true; // 全ページ数が1より大きいときだけページのリンクを表示する.
    }//if

    ViewBag.CurrentUser = currentUser;
    ViewBag.IsAdminUser = isAdminUser;
    ViewBag.IsLoggedIn = isLoggedIn;           

    return View(await articles.Include(a => a.BlogUser).ToListAsync());
}
Views/Articles/Index.cshtmlの内容
 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
@model IEnumerable<T10a.Models.Article>
 
@{
    ViewData["Title"] = "記事一覧";
 
    var currentUser = (BlogUser?)ViewBag.CurrentUser;
    var isAdminUser = (bool)ViewBag.IsAdminUser;
    var isLoggedIn = (bool)ViewBag.IsLoggedIn;
 
    if (Model is null) throw new ArgumentNullException(nameof(Model)); // ad-hoc! 非null保証のための回避策
}
 
@foreach(var a in Model)
{    
    <section class="article">
        <h3><a asp-action="Details" asp-route-id="@a.ArticleId">@a.Title</a></h3>
 
        <p><a asp-action="Index" asp-route-author="@a.BlogUser?.UserName">@(a.BlogUser?.Nickname ?? a.BlogUser?.UserName)</a> による投稿</p> @* ←ユーザー名に ?author=xxx のリンクを張る *@
 
        <pre>@a.Body</pre>
 
        @if (isLoggedIn && (isAdminUser || (currentUser != null && a.BlogUserId == currentUser.Id)))
        {
            <p>@Html.DisplayNameFor(_ => a.PublicationState):@Html.DisplayFor(_ => a.PublicationState)</p>
 
            <p>
                 <a asp-action="Edit" asp-route-id="@a.ArticleId">編集</a>
               | <a asp-action="Delete" asp-route-id="@a.ArticleId">削除</a>
            </p>
        }
    </section>
}

@if (ViewBag.ShowPages)
{
    var pageNumber = (int)ViewBag.PageNumber; // 指定されているページと
    var pageCount = (int)ViewBag.PageCount;   // 全ページ数をとりだす.

    // 「最初へ」のリンク(1ページ目への固定リンク)
    <a asp-action="Index" asp-route-pageNumber="1">最初へ</a>

    // 「前へ」のリンク((現在指定されているページ - 1)ページ目へのリンク)
    if (1 < pageNumber) { <a asp-action="Index" asp-route-pageNumber="@(pageNumber - 1)">前へ</a> }
    else { <span>前へ</span> }

    // 1~(全ページ数)までのリンクを生成する 
    for (int i = 1; i <= pageCount; ++i)
    {
        if ( i == pageNumber )
        {
            @i
        }
        else
        {
            <a asp-action="Index" asp-route-pageNumber="@i">@i</a>
        }
        <span>&nbsp;</span>
    }

    // 「次へ」のリンク((現在指定されているページ + 1)ページ目へのリンク) 
    if (pageNumber < pageCount){ <a asp-action="Index" asp-route-pageNumber="@(pageNumber + 1)">次へ</a> }
    else { <span>次へ</span> }

    // 「最後へ」のリンク((全ページ数)ページ目への固定リンク) 
    <a asp-action="Index" asp-route-pageNumber="@pageCount">最後へ</a>

    <br />
}

@if (isLoggedIn)
{
    <a asp-action="Create">新規作成</a>
}

_Indexアクションでは,現在のページ番号と最大ページ数,それからページのリンクを表示するかどうかを ViewBagに格納している(22~24行目).このリンクは複数ページが存在するときだけ表示するものであるため,全ページ数が1より大きいときのみ 表示するように指示している(32行目).

_の Index.cshtml では,ページングのためのリンクを生成している(31~48行目).

ここまで書けたら実行してみよう._に示す通り,記事の一覧画面のページ下部に, 「最初へ」「前へ」「1 2 3...」「次へ」「最後へ」といったリンクが表示されていることが分かるはずである. このリンクを使ってページを移動できることを確認しておこう(__).

実行結果

ここまでの動作を確認したら1つ目のチュートリアルは完了である. 次に進む前に,混乱を防ぐため Visual Studio のエディタをすべて閉じておこう.Visual Studio のいずれかのエディタのタブを右クリックして 「すべててのドキュメントを閉じる」をクリックすれば,エディタをすべて閉じることができる


  1. 最近では Lazy Loading といい,スクロールの底にたどり着くたびに動的にページの続きをロードするという方法で表示することもある.2024年3月19日時点の Google の検索結果ページではこちらの表示方法を採用していた(非ログイン状態). ↩︎

Last updated on 2024-06-18
Published on 2024-06-18

Powered by Hugo. Theme by TechDoc. Designed by Thingsym.