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