ロゴ

ASP.NET Core MVCで検索、列ソート、ページング機能付きの一覧を作成する。

作成日:2023年4月2日

目次

はじめに

ASP.netでページング機能を実装するチュートリアルの記事は多いですか、どこか助長で拡張性に欠ける実装が多かったので、普段私が使用しているクラスの構成を共有します。

環境

  • ASP.net Core 3.1
  • Bootstrap4
  • X.PagedList.Mvc.Core 8.4
  • EntityFrameWork Core
  • BootstrapIcon4


ヘルパークラスの実装

インターフェース

//ページング機能付き一覧ページ(ソート機能付き)
public interface IPaginationListViewModel<T> : IListViewRouteCondition
        where T : class 
{
      //一覧の表示結果
     public IPagedList<T> Results { get; set; }
   
}
//検索、ソート、ページングのパラメータ
public interface IListViewRouteCondition
{
  //ソートするフィールド名
    public string SortF { get; set; }

   //ソートの方向
     public string Dir { get; set; }

  //表示したいページ
     public int? Page { get; set; }

   //一覧に検索項目がある場合はその値をDictionaryで取得できるメソッドを実装
     public Dictionary<string, string> GetSearchKeyValues();
}

EFのOrderBy、OrderByDscendingをプロパティ名でソートできるように拡張メソッドを作成

Expressionを使用しています。
SQLを発行する前に指定のプロパティ名からプロパティのオブジェクトを取得するコードをコンパイルするイメージです。

/// <summary>
        /// プロパティ名で昇順ソート
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="source"></param>
        /// <param name="propertyName"></param>
        /// <returns></returns>
        public static IOrderedQueryable<T> OrderBy<T>(this IQueryable<T> source, string propertyName)
        {
            return source.OrderBy(ToLambda<T>(propertyName));
        }
        /// <summary>
        /// プロパティ名で降順ソート
        /// </summary>
        /// <typeparam name="T"></typeparam>
        /// <param name="source"></param>
        /// <param name="propertyName"></param>
        /// <returns></returns>
        public static IOrderedQueryable<T> OrderByDescending<T>(this IQueryable<T> source, string propertyName)
        {
            return source.OrderByDescending(ToLambda<T>(propertyName));
        }


        private static Expression<Func<T, object>> ToLambda<T>(string propertyName)
        {
            var parameter = Expression.Parameter(typeof(T));
            var property = Expression.Property(parameter, propertyName);
            var propAsObject = Expression.Convert(property, typeof(object));


            return Expression.Lambda<Func<T, object>>(propAsObject, parameter);
        }


抽象一覧作成用基底クラス

デフォルト一覧表示行数10、ソート順のパラメータは"asc"、"desc"としています。
特に変わったことはしていませんが、

public abstract class PagedListProcessBase<T, T_Row> where T: IPaginationListViewModel<T_Row> 
        where T_Row : class 
    {
        protected const int DEFAULT_PAGE_SIZE = 10;
        protected const int DEFAULT_PAGE = 1;
        protected const string DESC = "desc";//ソートの際に使用されるパラメータ(降順)
        protected const string ASC = "asc";//ソートの際に使用されるパラメータ(昇順)
        protected readonly AppDbContext _context;//EFのDbContext
        public PagedListProcessBase(AppDbContext context)
        {
            _context = context;
        }


        public abstract Task<T> GetDataAsync(T vm);
        /// <summary>
        /// 基本クエリ
        /// </summary>
        /// <returns></returns>
        protected virtual IQueryable<T_Row> GetBaseQuery() => _context.Set<T_Row>();
        /// <summary>
        /// ソート
        /// </summary>
        /// <param name="query"></param>
        /// <param name="sortfield"></param>
        /// <param name="descOrAsc"></param>
        protected virtual void SetFieldSortQuery(ref IQueryable<T_Row> query, string sortfield, string descOrAsc)
        {
            if (String.IsNullOrEmpty(sortfield)) return;
            if (descOrAsc == "desc")
            {
                query = query.OrderByDescending(sortfield);
            }
            else
            {
                query = query.OrderBy(sortfield);
            }
        }
        
    }

ソートのリンク生成用タグヘルパー

IListViewSearchConditionで実装している検索パラメータ群をViewから取得し、ソート用のリンクを生成するコードです。
AchorTagHelperを継承していればRouteValuesプロパティにパラメータを追加するだけで、レンダリング後のhrefの値を編集できます。
ソートの順番やアイコンのタグなどは適宜調整してください。

public class SortTagHelper : AnchorTagHelper
    {
    //PagedListProcessBaseと同じ値を使用するので、どこかに静的な変数として保管しておくとBetter
        const string DESC = "desc";
        const string ASC = "asc";
        public SortTagHelper(IHtmlGenerator generator ) : base(generator)
        {
        }
        [HtmlAttributeName("asp-for")]
        //現在のソート、ページング、検索条件
        public IListViewRouteCondition SortAndSearch { get; set; }
        /// <summary>
        /// リンクを作成する対象のフィールド(プロパティ名)
        /// </summary>
        public string PropertyName { get; set; }
        
        public async override Task ProcessAsync(TagHelperContext context, TagHelperOutput output)
        {
            //検索条件を設定
            foreach (var searchVerb in SortAndSearch.GetSearchKeyValues())
            {
                if(!String.IsNullOrEmpty(searchVerb.Value))RouteValues.Add($"Con.{searchVerb.Key}",searchVerb.Value);
            }
            //ページング設定を設定
            if (SortAndSearch.Page != null)
            { 
                RouteValues.Add(nameof(SortAndSearch.Page), SortAndSearch.Page.ToString());
            }
            if (SortAndSearch.PageSize != null)
            {
                RouteValues.Add(nameof(SortAndSearch.PageSize), SortAndSearch.PageSize.ToString());
            }
            //ソート
            var descOrAscClassName ="";
            var sortmode = "";
            //現在ソート対象のフィールドである場合、ソート順を参照して次回ソート順のリンクを作成
            //ソートの順序:降順=>昇順=>ソート無し=>降順...
            if (SortAndSearch.SortF == PropertyName)
            {
                descOrAscClassName = $"dir-{SortAndSearch.Dir}";//viewに反映されるクラス名
                if (SortAndSearch.Dir == DESC)
                {
                    sortmode = DESC;
                    RouteValues.Add(nameof(SortAndSearch.SortF), PropertyName);
                    RouteValues.Add(nameof(SortAndSearch.Dir), ASC);
                }
                else if (SortAndSearch.Dir == ASC)
                {
                    sortmode = ASC;


                }
            }
            //ソート対象でない場合降順ソートのリンクを作成
            else
            {
                RouteValues.Add(nameof(SortAndSearch.SortF), PropertyName);
                RouteValues.Add(nameof(SortAndSearch.Dir), DESC);
            }
            await base.ProcessAsync(context,output);
            output.TagName = "a";
            output.Attributes.Add("class", $"table-header {descOrAscClassName} text-nowrap sort-link");
            //昇順、降順それぞれの場合に表示するアイコン
            if (sortmode == ASC)
            {
                output.PostContent.SetHtmlContent($"<i class=\"bi bi-caret-down-fill\"></i>");
            }
            else if(sortmode == DESC)
            { 
                output.PostContent.SetHtmlContent($"<i class=\"bi bi-caret-up-fill\"></i>");
            }
        }
    }


ヘルパークラスを利用して実装してみる

検索条件クラス

 /// <summary>
    /// 顧客一覧検索条件
    /// </summary>
    public class GuestListSearchCondition
    {
        /// <summary>
        /// 顧客名
        /// </summary>
        public string Name { get; set; }
        /// <summary>
        /// 会社名
        /// </summary>
        public string CompanyName { get; set; }
    }


一覧(ViewModel)クラス 

IPaginationListViewModelを実装しておくだけでいいです。
※Guestsで定義しているのはEFでバインドされる対象のモデルです。

 /// <summary>
    /// 顧客一覧ViewModel(検索、ソート、ページングあり)
    /// </summary>
    public class GuestListViewModel : IPaginationListViewModel<Guests>
    {
        /// <summary>
        /// 検索条件
        /// </summary>
        public GuestListSearchCondition Con { get; set; } = new GuestListSearchCondition();
        /// <summary>
        /// 一覧
        /// </summary>
        public IPagedList<Guests> Results { get; set; }
        /// <summary>
        /// ソートするカラム
        /// </summary>
        public string SortF { get; set; }
        /// <summary>
        /// ソートの方向
        /// </summary>
        public string Dir { get; set; }
        /// <summary>
        /// 現在のページ
        /// </summary>
        public int? Page { get; set; }
        /// <summary>
        /// ページサイズ
        /// </summary>
        public int? PageSize { get; set; }

    /// <summary>
        /// 検索条件をDictionaryで取得
        /// </summary>
        public Dictionary<string, string> GetSearchKeyValues()
        {
            return new Dictionary<string, string>
            {
                { nameof(Con.Name),Con.Name}
            };
        }
    }


一覧データ取得用クラスの実装

PagedListProcessBaseを継承します。
検索、ソート、データ取得部分で特殊な動きを実現したい場合は検索、ソートメソッドをオーバーライドしてください。

public class GuestListProcess: PagedListProcessBase<GuestListViewModel, Guests>
    {
        public GuestListProcess(AppDbContext context):base(context)
        {
        }
        /// <summary>
        /// 表示データ取得
        /// </summary>
        /// <param name="vm"></param>
        /// <returns></returns>
        public async override Task<GuestListViewModel> GetDataAsync(GuestListViewModel vm)
        {
            //基本クエリ生成
            var query = GetBaseQuery();
            //ソート
            SetFieldSortQuery(ref query, vm.SortF, vm.Dir);
            //検索
            SetSearchQuery(ref query, vm.Con);
            //データ取得
            vm.Results = await query.ToPagedListAsync(vm.Page ?? DEFAULT_PAGE, vm.PageSize ?? DEFAULT_PAGE_SIZE);
            return vm;
        }
      
        /// <summary>
        /// 検索
        /// </summary>
        /// <param name="query"></param>
        /// <param name="con"></param>
        private void SetSearchQuery(ref IQueryable<T_Guest> query, GuestListSearchCondition con)
        {
            //従業員名
            query = query.WhereEx(!String.IsNullOrEmpty(con.Name), x => x.Name.Contains(con.Name));
            //会社名
            query = query.WhereEx(!String.IsNullOrEmpty(con.CompanyName), x => x.CompanyName.Contains(con.CompanyName));
        }
}

Controller

/// <summary>
/// 従業員一覧
/// </summary>
/// <returns></returns>
[HttpGet]
public async Task<IActionResult> List(GuestListViewModel vm)
    {
        var proc = new GuestListProcess(_context);
        return View(await proc.GetDataAsync(vm));
    }

View(ソート用のリンク部分)

自作のタグヘルパーを利用し、asp-forにIListViewRouteConditionを実装しているModelを指定し、PropertyNameにはソート対象のプロパティ名を指定します。

<th scope="col">
     <sort asp-for="@Model" property-name="@nameof(Guests.Name)">従業員名</sort>
</th>


まとめ

今回は検索、ソート、ページング機能付きの一覧画面を実装しました。
この実装をしてから、一覧のViewとデータ取得処理部分の実装、保守工数が削減できました。
XpagedListを凌駕する便利なページングライブラリが出るまではこの方法で一覧画面を作っていく予定です。

関連記事

記事画像

2022年7月3日

Yakan

田舎育ちのアナログ人間ですが、システム作ってます。
趣味の制作はWebアプリ中心です。
仕事は業務系なのでC#メインで書いてます。

Yakan

田舎育ちのアナログ人間ですが、システム作ってます。
趣味の制作はWebアプリ中心です。
仕事は業務系なのでC#メインで書いてます。

© 2022 - 2023 Yakan.