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

上一篇我們介紹了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

聲明

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章