上一篇我們介紹了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、一般實現
不考慮上述說明,進行最簡單的排序方法
- 這裡我們還是使用ArticleController進行演示,先在Model層Parameters資料夾的ArticleParameters檔案中新增排序屬性,這裡我們新增一項為建立時間,如下:
public string Orderby { get; set; } = "CreateTime";
- 在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
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