学习ASP.NET Core(08)-过滤搜索与分页排序
- 2020 年 5 月 24 日
- 筆記
- .NET Core项目
上一篇我们介绍了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