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

    声明

  • 相关阅读:
    [Noi2011]阿狸的打字机
    Bzoj3530: [Sdoi2014]数数
    Bzoj2037: [Sdoi2008]Sue的小球
    Bzoj4869: [Shoi2017]相逢是问候
    Bzoj1899: [Zjoi2004]Lunch 午餐
    Bzoj3884: 上帝与集合的正确用法
    UVA10692:Huge Mods
    Bzoj1009: [HNOI2008]GT考试
    Bzoj1212: [HNOI2004]L语言
    【国家集训队2012】tree(伍一鸣)
  • 原文地址:https://www.cnblogs.com/Jscroop/p/12953252.html
Copyright © 2020-2023  润新知