学习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

声明