學習ASP.NET Core(08)-過濾搜尋與分頁排序

Jscroop發表於2020-05-24

上一篇我們介紹了AOP的基本概覽,並使用動態代理的方式新增了服務日誌;本章我們將介紹過濾與搜尋、分頁與排序並新增對應的功能


注:本章內容大多是基於solenovex的使用 ASP.NET Core 3.x 構建 RESTful Web API視訊內容,若想進一步瞭解相關知識,請檢視原視訊

一、過濾與搜尋

1、定義

1、什麼是過濾?意思就是把某個欄位的名字及希望匹配的值傳遞給系統,系統根據條件限定返回的集合內容;

按點外賣的例子來說,食物類別、店鋪評分、距離遠近等過濾條件提供給你,您自個兒根據需求篩選,系統返回過濾後的內容給你;

2、什麼是搜尋?意思就是把需要搜尋的值傳遞給系統,系統按照其內部邏輯查詢符合條件的資料,完成後將資料新增到集合中返回;

還是按點外賣的例子來說,一哥們張三特別喜歡吃燒烤,他在搜尋欄中搜尋燒烤,會出現什麼?食物類別是燒烤的,店鋪名稱是燒烤的,甚至會有商品名稱包含燒烤的,當然具體出現什麼還要看系統的內部邏輯;

3、相同點及差異

  • 相同點:過濾和搜尋的引數並不是資源的一部分,而是使用者根據實際需求自行新增的;

  • 差異:過濾一般是一個完整的集合,根據條件把匹配或不匹配的資料移除;

    ​ 搜尋一般是一個空集合,根據條件把匹配或不匹配的資料往裡面新增

2、實際應用

1、在前面的章節我們有提到過資料模型的概覽,即使用者看到的和儲存在資料庫的可能不是一個欄位,所以在實際進行過濾或搜尋操作時,使用者只能針對他看到的資源的欄位進行過濾或搜尋操作,所以內部邏輯要考慮到這一點;

2、在實際開發中,會有新增欄位的情況,那就意味著過濾/搜尋的條件是會變化的,為了適應這種不確定性,我們可以針對過濾/搜尋條件建立對應的類,在類的內部新增過濾/搜尋條件。

3、實際應用時過濾和搜尋經常會配合使用

3、基於專案的新增

3.1、新增引數類

我們在Model層新增一個Parameters資料夾,這裡計劃以文章作為演示,所以我們新增一個ArticleParameters類,新增過濾欄位和搜尋欄位,如下:

using System;

namespace BlogSystem.Model.Parameters
{
    public class ArticleParameters
    {
        //過濾條件——距離時間
        public DistanceTime DistanceTime { get; set; }

        //搜尋條件
        public string SearchStr { get; set; }
    }

    public enum DistanceTime
    {
        Week = 1,
        Month = 2,
        Year = 3,
    }
}

3.2、新增介面

在IBLL層的IArticleService中新增對應的過濾搜尋方法,其返回值是文章集合,如下:

        /// <summary>
        /// 文章過濾及搜尋
        /// </summary>
        /// <param name="parameters"></param>
        /// <returns></returns>
        Task<List<ArticleListViewModel>> GetArticles(ArticleParameters parameters);

3.3、方法實現

在BLL層的ArticleService中實現上一步新增的介面方法,如下:

        /// <summary>
        /// 文章過濾及搜尋
        /// </summary>
        /// <param name="parameters"></param>
        /// <returns></returns>
        public async Task<List<ArticleListViewModel>> GetArticles(ArticleParameters parameters)
        {
            if (parameters == null) throw new ArgumentNullException(nameof(parameters));

            var resultList = _articleRepository.GetAll();

            var dateTime = DateTime.Now;

            //過濾條件,判斷列舉是否引用
            if (Enum.IsDefined(typeof(DistanceTime), parameters.DistanceTime))
            {
                switch (parameters.DistanceTime)
                {
                    case DistanceTime.Week:
                        dateTime = dateTime.AddDays(-7);
                        break;
                    case DistanceTime.Month:
                        dateTime = dateTime.AddMonths(-1);
                        break;
                    case DistanceTime.Year:
                        dateTime = dateTime.AddYears(-1);
                        break;
                }
                resultList = resultList.Where(m => m.CreateTime > dateTime);
            }
            
            //搜尋條件,暫時新增標題和內容
            if (!string.IsNullOrWhiteSpace(parameters.SearchStr))
            {
                parameters.SearchStr = parameters.SearchStr.Trim();
                resultList = resultList.Where(m =>
                    m.Title.Contains(parameters.SearchStr) || m.Content.Contains(parameters.SearchStr));
            }

            //返回最終結果
            return await resultList.Select(m => new ArticleListViewModel
            {
                ArticleId = m.Id,
                Title = m.Title,
                Content = m.Content,
                CreateTime = m.CreateTime,
                Account = m.User.Account,
                ProfilePhoto = m.User.ProfilePhoto
            }).ToListAsync();
        }

3.4、控制層呼叫

在BlogSystem.Core專案的ArticleController中新增篩選/搜尋方法,如下:

        /// <summary>
        /// 通過過濾/搜尋查詢符合條件的文章
        /// </summary>
        /// <param name="parameters"></param>
        /// <returns></returns>
        [HttpGet]
        public async Task<IActionResult> GetArticles(ArticleParameters parameters)
        {
            var list = await _articleService.GetArticles(parameters);
            return Ok(list);
        }

3.5、問題與功能實現

執行後選擇對應的篩選條件,輸入對應的查詢欄位,查詢發現出現如下錯誤:TypeError: Failed to execute 'fetch' on 'Window': Request with GET/HEAD method c,還記得上一節提到的物件繫結嗎?跳轉檢視,這裡我們需要手動指定查詢引數的來源為[FromQuery],修改並編譯後重新執行,輸入過濾和搜尋條件,成功執行

二、分頁

1、分頁說明

  • 通常在集合資源比較大的情況下,會進行翻頁查詢,來避免可能出現的效能問題;
  • 系統預設情況下就應該進行分頁,且操作物件應該是底層的資料;
  • 一般情況下查詢引數分為每頁的個數PageSize和頁碼PageNumber,且會通過QueryString傳遞;

2、實際應用

  • 我們應該對每頁的個數PageSize進行控制,防止使用者錄入一個比較大的數字;
  • 我們應該設定一個預設值,使用者不指定頁碼和數量的情況下則按預設數值進行查詢
  • 分頁應該在過濾和搜尋之後進行,否則結果會不準確

3、一般實現

3.1、新增預設引數

在新增過濾和搜尋功能時,我們新增了一個ArticleParameters類用來放置條件引數,同樣我們可以把分頁相關的引數放置在這個類裡面,如下:

3.2、邏輯方法調整

我們選擇過濾與搜尋時新增的ArticleService類中的GetArticles方法,在最終tolist前進行分頁操作,如下:

4、進階實現

4.1、說明

除了資料集合外,我們可以將前後頁的連結,當前頁碼,當前頁面的數量,總記錄數,總頁數等資訊一併返回

返回的資訊放在哪裡也是一個問題,部分開發者習慣將上述資訊放置在Http響應的Body中,雖然使用上沒有任何問題,但是翻頁資訊不是資源表述的一部分,所以從RESTful風格看它破壞了自我描述性資訊約束,API的消費者不知到如何使用application/json這個媒體型別來解釋響應內容,而針對這類問題我們一般將此類資訊放在Http響應Header的X-Pagination中

4.2、實現

1、首先我們在BlogSystem.Model專案中新建一個Helpers資料夾,並在其中新建一個PageList類,先將需要返回的翻頁資訊宣告為屬性,並在建構函式中初始化這些資訊,資訊對應的資料則由一個非同步的靜態方法提供, 具體實現如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.EntityFrameworkCore;

namespace BlogSystem.Model.Helpers
{
    public class PageList<T> : List<T>
    {
        //當前頁碼
        public int CurrentPage { get; }
        //總頁碼數
        public int TotalPages { get; }
        //每頁數量
        public int PageSize { get; }
        //結果數量
        public int TotalCount { get; }
        //是否有前一頁
        public bool HasPrevious => CurrentPage > 1;
        //是否有後一頁
        public bool HasNext => CurrentPage < TotalPages;

        //初始化翻頁資訊
        public PageList(List<T> items, int count, int pageNumber, int pageSize)
        {
            TotalCount = count;
            PageSize = pageSize;
            CurrentPage = pageNumber;
            TotalPages = (int)Math.Ceiling(count / (double)pageSize);
            AddRange(items);
        }

        //建立分頁資訊
        public static async Task<PageList<T>> CreatePageMsgAsync(IQueryable<T> source, int pageNumber, int pageSize)
        {
            var count = await source.CountAsync();
            var items = await source.Skip((pageNumber - 1) * pageSize).Take(pageSize).ToListAsync();
            return new PageList<T>(items, count, pageNumber, pageSize);
        }
    }
}

2、我們將IArticleService中的GetArticles方法的返回值,以及ArticelService中的GetArticle方法修改如下:

Task<PageList<ArticleListViewModel>> GetArticles(ArticleParameters parameters);

3、當前頁的前一頁和後一頁的連結資訊如何獲得?我們可以藉助Url類的link方法,前提是對應的方法有它自身的名字,將ArticleController中的GetArticles方法命個名,並新增名字為CreateArticleUrl的方法來生成link,具體實現如下;其中UriType是一個列舉型別,我們將它放在了Model層的Helpers資料夾下

namespace BlogSystem.Model.Helpers
{
    public enum UrlType
    {
        PreviousPage,
        NextPage
    }
}
        //返回前一頁面,後一頁,以及當前頁的url資訊
        private string CreateArticleUrl(ArticleParameters parameters, UrlType type)
        {
            var isDefined = Enum.IsDefined(typeof(DistanceTime), parameters.DistanceTime);

            switch (type)
            {
                case UrlType.PreviousPage:
                    return Url.Link(nameof(GetArticles), new
                    {
                        pageNumber = parameters.PageNumber - 1,
                        pageSize = parameters.PageSize,
                        distanceTime = isDefined ? parameters.DistanceTime.ToString() : null,
                        searchStr = parameters.SearchStr
                    });
                case UrlType.NextPage:
                    return Url.Link(nameof(GetArticles), new
                    {
                        pageNumber = parameters.PageNumber + 1,
                        pageSize = parameters.PageSize,
                        distanceTime = isDefined ? parameters.DistanceTime.ToString() : null,
                        searchStr = parameters.SearchStr
                    });
                default:
                    return Url.Link(nameof(GetArticles), new
                    {
                        pageNumber = parameters.PageNumber,
                        pageSize = parameters.PageSize,
                        distanceTime = isDefined ? parameters.DistanceTime.ToString() : null,
                        searchStr = parameters.SearchStr
                    });
            }
        }

對應的Controller方法修改如下:

        /// <summary>
        /// 過濾/搜尋文章資訊並返回list和分頁資訊
        /// </summary>
        /// <param name="parameters"></param>
        /// <returns></returns>
        [HttpGet("search", Name = nameof(GetArticles))]
        public async Task<IActionResult> GetArticles([FromQuery]ArticleParameters parameters)
        {
            var list = await _articleService.GetArticles(parameters);

            var previousPageLink = list.HasPrevious ? CreateArticleUrl(parameters, UrlType.PreviousPage) : null;

            var nextPageLink = list.HasNext ? CreateArticleUrl(parameters, UrlType.NextPage) : null;

            var paginationX = new
            {
                totalCount = list.TotalCount,
                pageSize = list.PageSize,
                currentPage = list.CurrentPage,
                totalPages = list.TotalPages,
                previousPageLink,
                nextPageLink
            };

            Response.Headers.Add("Pagination-X", JsonSerializer.Serialize(paginationX));

            return Ok(list);
        }

4.3、實現效果

如下圖,可以看到Header中多了一行名為Pagination-X的key,且對應value中存在下一頁的url,但是系統預設進行了轉義&符號無法正常顯示,所以這裡我們在傳入Header時做如下處理

Response.Headers.Add("Pagination-X", JsonSerializer.Serialize(paginationX, new JsonSerializerOptions
{
    Encoder = JavaScriptEncoder.UnsafeRelaxedJsonEscaping
}));

三、排序

1、排序說明

通常情況下我們會使用QueryString針對多個欄位為集合資源進行排序,欄位預設為正序,也可以新增desc改變為倒序

2、實際應用

  • 實際應用中欄位對應的通常是Dto或ViewModel的欄位而非資料庫欄位,所以可能會存在資料對映的問題;
  • 目前我們只能使用屬性名所對應的字串進行排序,而不是使用lambda表示式;這裡我們可以藉助Linq的擴充套件庫來解決這個問題,只要Dto/VieModel中存在這個欄位,就進行排序,避免我們手動去匹配字串的對應關係
  • 此外我們需要考慮複用性的問題,可以針對IQueryable新增一個排序的擴充套件方法

3、一般實現

不考慮上述說明,進行最簡單的排序方法

  1. 這裡我們還是使用ArticleController進行演示,先在Model層Parameters資料夾的ArticleParameters檔案中新增排序屬性,這裡我們新增一項為建立時間,如下:public string Orderby { get; set; } = "CreateTime";
  2. 在BLL層的ArticleService的GetArticles方法中新增實如下邏輯,即可完成一般排序

4、進階實現

一般方法只能實現最簡單的一種排序且無法複用,不靈活,下面我們自定義方法實現第一二點中的功能

1、先來看一下具體的實現思路,左邊為層級關係,右邊為需要實現的類;如果有點暈可以先敲完再完再回頭看

2、由於邏輯相對複雜,所以在BlogSystem.Common層的Helpers資料夾中再建立一個SortHelper資料夾;

3、在SortHelper資料夾下建立PropertyMapping類,用來定義屬性之間的對映關係,如下:

using System;
using System.Collections.Generic;

namespace BlogSystem.Common.Helpers.SortHelper
{
    //定義屬性之間的對映關係
    public class PropertyMapping
    {
        //針對可能出現的一對多的情況——如name對應的是firstName+lastName
        public IEnumerable<string> DestinationProperties { get; set; }

        //針對出生日期靠前但是對應年齡大的情況
        public bool Revert { get; set; }

        public PropertyMapping(IEnumerable<string> destinationProperties, bool revert = false)
        {
            DestinationProperties = destinationProperties ?? throw new ArgumentNullException(nameof(destinationProperties));
            Revert = revert;
        }
    }
}

4、在SortHelper資料夾下建立ModelMapping類,用來定義兩個類之間的對映關係,如下:

using System;
using System.Collections.Generic;

namespace BlogSystem.Common.Helpers.SortHelper
{
    //定義模型物件之間的對映關係,如xxx對應xxxDto
    public class ModelMapping<TSource, TDestination> 
    {
        public Dictionary<string, PropertyMapping> MappingDictionary { get; private set; }

        public ModelMapping(Dictionary<string, PropertyMapping> mappingDictionary)
        {
            MappingDictionary = mappingDictionary ?? throw new ArgumentNullException(nameof(mappingDictionary));
        }
    }
}

5、在SortHelper資料夾下建立PropertyMappingService類,裡面是針對屬性對映情況的邏輯處理;但在使用ModelMapping時發現無法解析泛型型別,所以我們需要使用一個空的介面來為其打上標籤。 如下,新增空介面,新增ModelMapping繼承此介面

namespace BlogSystem.Common.Helpers.SortHelper
{
    //標記介面,只用來給物件打上標籤
    public interface IModelMapping
    {
    }
}

PropertyMappingService類的實現如下:

using BlogSystem.Model;
using BlogSystem.Model.ViewModels;
using System;
using System.Collections.Generic;
using System.Linq;

namespace BlogSystem.Common.Helpers.SortHelper
{
    //屬性對映處理
    public class PropertyMappingService
    {
        //一個只讀屬性的字典,裡面是Dto和資料庫表欄位的對映關係
        private readonly Dictionary<string, PropertyMapping> _articlePropertyMapping
            = new Dictionary<string, PropertyMapping>(StringComparer.OrdinalIgnoreCase) //忽略大小寫
            {
                {"Id",new PropertyMapping(new List<string>{"Id"}) },
                {"Title",new PropertyMapping(new List<string>{"Title"}) },
                {"Content",new PropertyMapping(new List<string>{"Content"}) },
                {"CreateTime",new PropertyMapping(new List<string>{"CreateTime"}) }
            };

        //需要解決ModelMapping泛型關係無法建立問題,可新增的一個空的標誌介面
        private readonly IList<IModelMapping> _propertyMappings = new List<IModelMapping>();

        //建構函式——內部新增的是類和類的對映關係以及屬性和屬性的對映關係
        public PropertyMappingService()
        {
            _propertyMappings.Add(new ModelMapping<ArticleListViewModel, Article>(_articlePropertyMapping));
        }

        //通過兩個類的型別獲取對映關係
        public Dictionary<string, PropertyMapping> GetPropertyMapping<TSource, TDestination>()
        {
            var matchingMapping = _propertyMappings.OfType<ModelMapping<TSource, TDestination>>();
            var propertyMappings = matchingMapping.ToList();
            if (propertyMappings.Count == 1)
            {
                return propertyMappings.First().MappingDictionary;
            }
            throw new Exception($"無法找到唯一的對映關係:{typeof(TSource)},{typeof(TDestination)}");
        }
    }
}

6、最後由於需要通過依賴注入的方式進行使用,所以需要新增一個介面,新增PropertyMappingService繼承此介面

using System.Collections.Generic;

namespace BlogSystem.Common.Helpers.SortHelper
{
    //實現依賴注入新建的介面——對應的是屬性對映服務
    public interface IPropertyMappingService
    {
        Dictionary<string, PropertyMapping> GetPropertyMapping<TSource, TDestination>();
    }
}

7、針對IQueryable新增一個排序的擴充套件方法IQueryableExtensions,放在Common層的SortHelper資料夾中,最後orderby時需要使用NuGet包安裝System.Linq.Dynamic.Core,並引入名稱空間,如下:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Linq.Dynamic.Core;

namespace BlogSystem.Common.Helpers.SortHelper
{
    //排序擴充套件方法
    public static class IQueryableExtensions
    {
        public static IQueryable<T> ApplySort<T>(this IQueryable<T> source, string orderBy, Dictionary<string, PropertyMapping> mappingDictionary)
        {
            if (source == null)
            {
                throw new ArgumentNullException(nameof(source));
            }

            if (mappingDictionary == null)
            {
                throw new ArgumentNullException(nameof(mappingDictionary));
            }

            if (string.IsNullOrWhiteSpace(orderBy))
            {
                return source;
            }

            //分隔orderby欄位
            var orderByAfterSplit = orderBy.Split(",");
            foreach (var orderByClause in orderByAfterSplit.Reverse())
            {
                var trimmedOrderByClause = orderByClause.Trim();
                //判斷是否以倒序desc結尾
                var orderDescending = trimmedOrderByClause.EndsWith(" desc");
                //獲取空格的索引
                var indexOfFirstSpace = trimmedOrderByClause.IndexOf(" ", StringComparison.Ordinal);
                //根據有無空格獲取屬性
                var propertyName = indexOfFirstSpace ==
                    -1 ? trimmedOrderByClause : trimmedOrderByClause.Remove(indexOfFirstSpace);
                //不含對映則丟擲錯誤
                if (!mappingDictionary.ContainsKey(propertyName))
                {
                    throw new ArgumentNullException($"沒有找到Key為{propertyName}的對映");
                }
                //否則取出屬性對映關係
                var propertyMappingValue = mappingDictionary[propertyName];
                if (propertyMappingValue == null)
                {
                    throw new ArgumentNullException(nameof(propertyMappingValue));
                }

                //一次取出屬性值進行排序
                foreach (var destinationProperty in propertyMappingValue.DestinationProperties.Reverse())
                {
                    if (propertyMappingValue.Revert)
                    {
                        orderDescending = !orderDescending;
                    }
                    //orderby需要安裝System.Linq.Dynamic.Core庫
                    source = source.OrderBy(destinationProperty + (orderDescending ? " descending" : " ascending"));
                }
            }

            return source;
        }
    }
}

8、在BlogSystem.BLL中的ArticleService類建構函式中注入IPropertyMappingService介面,如下:

9、在ArticleService中使用新增的IQueryable擴充套件方法實現排序邏輯,如下:

10、在BlogSystem.Core的StartUp類的ConfigureServices方法進行註冊,這裡我新增在的位置是方法內部的最後位置:

//自定義判斷屬性隱射關係
services.AddTransient<IPropertyMappingService, PropertyMappingService>();

11、執行後可以通過QueryString的形式,比如:?orderby=createtime或者?orderby=createtime desc或者orderby=createtime desc,title之類的形式進行排序查詢(實際上createtime和title的組合無意義,可根據實際情況使用),如下:

5、進階問題解決

1、在進行分頁操作時我們有新增前後頁的資訊,但是在排序後,前後頁面資訊是不包括排序資訊的,所以我們需要解決這一問題,在ArticleController中的CreateArticleUrl建立的3個Url中新增 orderBy = parameters.Orderby即可;

2、此外我們發現,輸入一個不存在的排序欄位時雖然彈出了我們預先新增的錯誤提示,錯誤程式碼卻是500,但是這一錯誤並不是服務端引起的,在Common層的PropertyMappingService類中新增判斷欄位是否存在的邏輯。此外需要在PropertyMappingService對應的介面IPropertyMappingService中新增這一方法,方法邏輯如下:

 		//判斷字串是否存在
        public bool PropertyMappingExist<TSource, TDestination>(string fields)
        {
            var propertyMapping = GetPropertyMapping<TSource, TDestination>();
            if (string.IsNullOrWhiteSpace(fields))
            {
                return true;
            }

            //查詢字串逗號分隔
            var fieldAfterSplit = fields.Split(",");
            foreach (var field in fieldAfterSplit)
            {
                var trimmedFields = field.Trim();//欄位去空
                var indexOfFirstSpace = trimmedFields.IndexOf(" ", StringComparison.Ordinal);//獲取欄位中第一個空格的索引
                //空格不存在,則屬性名為其本身,否則移除空格
                var propertyName = indexOfFirstSpace == -1 ? trimmedFields : trimmedFields.Remove(indexOfFirstSpace);
                //只要有一個欄位對應不上就返回fasle
                if (!propertyMapping.ContainsKey(propertyName))
                {
                    return false;
                }
            }

            return true;
        }

3、完成上述操作後,在ArticleController的建構函式中注入該服務,並在GetArticles方法中新增判斷

4、再次執行,可以發現前後頁面資訊中已經包括了排序資訊,且遇到不存在的欄位時也是正常返回客戶端異常

本章完~


本人知識點有限,若文中有錯誤的地方請及時指正,方便大家更好的學習和交流。

本文部分內容參考了網路上的視訊內容和文章,僅為學習和交流,視訊地址如下:

solenovex,ASP.NET Core 3.x 入門視訊

solenovex,使用 ASP.NET Core 3.x 構建 RESTful Web API

宣告

相關文章