找回密码
 立即注册
首页 业界区 业界 开发实战:asp.net core + ef core 实现动态可扩展的分 ...

开发实战:asp.net core + ef core 实现动态可扩展的分页方案

威割 昨天 21:28
引言

欢迎阅读,这篇文章主要面向初级开发者。
在开始之前,先问你一个问题:你做的系统,是不是每次增加一个查询条件或者排序字段,都要去请求参数对象里加一个属性,然后再跑去改 EF Core 的查询逻辑?
如果是,那这篇文章应该对你有用。我会带你做一个统一的、扩展起来不那么麻烦的分页查询方案。整体思路是四件事:​统一入参、统一出参、动态排序、动态过滤​。
统一请求参数

先定义一个公共的 QueryParameters 解决这个问题:
  1. public class QueryParameters
  2. {
  3.     private const int MaxPageSize = 100;
  4.     private int _pageSize = 10;
  5.     public int PageNumber { get; set; } = 1;
  6.     // 限制最大值,防止前端传一个很大数值把数据库搞崩了
  7.     public int PageSize
  8.     {
  9.         get => _pageSize;
  10.         set => _pageSize = value > MaxPageSize ? MaxPageSize : value;
  11.     }
  12.     // 支持多字段排序,格式:"name desc,price asc"
  13.     public string? SortBy { get; set; }
  14.     // 通用关键词搜索
  15.     public string? Search { get; set; }
  16.     // 动态过滤条件
  17.     public List<FilterItem> Filters { get; set; } = [];
  18.     // 要返回的字段,逗号分隔:"id,name,price",不传则返回全部
  19.     public string? Fields { get; set; }
  20. }
复制代码
ASP.NET Core 的模型绑定会自动把 query string 映射到这个对象,不需要手动解析。后续如果某个接口有额外参数,继承它加字段就行,不用每次从头定义。
统一响应包装器

返回值也统一一下,把分页信息和数据放在一起,调用方就不用自己拼了:
  1. public class PagedResponse<T>
  2. {
  3.     // IReadOnlyList 防止外部随意修改集合
  4.     public IReadOnlyList<T> Data { get; init; } = [];
  5.     public int PageNumber { get; init; }
  6.     public int PageSize { get; init; }
  7.     public int TotalRecords { get; init; }
  8.     public int TotalPages => (int)Math.Ceiling(TotalRecords / (double)PageSize);
  9.     public bool HasNextPage => PageNumber < TotalPages;
  10.     public bool HasPreviousPage => PageNumber > 1;
  11. }
复制代码
Data 是任意类型的集合,用 IReadOnlyList 防止被意外修改。TotalPages、HasNextPage 和 HasPreviousPage 三个是计算属性,不需要单独赋值。
扩展方法

把分页、排序、过滤都做成 IQueryable 的扩展方法,用起来像链式调用,调用的地方看起来会很干净。
分页
  1. public static IQueryable<T> ApplyPagination<T>(
  2.     this IQueryable<T> query,
  3.     int pageNumber,
  4.     int pageSize)
  5. {
  6.     return query
  7.         .Skip((pageNumber - 1) * pageSize)
  8.         .Take(pageSize);
  9. }
复制代码
动态排序

解析 "name desc,price asc" 这样的字符串,动态生成排序表达式。用反射就能做到,不需要额外的库:
  1. public static IQueryable<T> ApplySort<T>(
  2.     this IQueryable<T> query,
  3.     string? sortBy)
  4. {
  5.     if (string.IsNullOrWhiteSpace(sortBy))
  6.         return query;
  7.     var orderParams = sortBy.Split(',', StringSplitOptions.RemoveEmptyEntries);
  8.     var isFirst = true;
  9.     foreach (var param in orderParams)
  10.     {
  11.         var parts = param.Trim().Split(' ');
  12.         var propertyName = parts[0];
  13.         var isDesc = parts.Length > 1
  14.             && parts[1].Equals("desc", StringComparison.OrdinalIgnoreCase);
  15.         // 用反射找属性,找不到就跳过,避免抛异常
  16.         var prop = typeof(T).GetProperty(
  17.             propertyName,
  18.             BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
  19.         if (prop == null) continue;
  20.         // 构建表达式树:x => x.PropertyName
  21.         var paramExpr = Expression.Parameter(typeof(T), "x");
  22.         var body = Expression.Property(paramExpr, prop);
  23.         var lambda = Expression.Lambda(body, paramExpr);
  24.         var methodName = isFirst
  25.             ? (isDesc ? "OrderByDescending" : "OrderBy")
  26.             : (isDesc ? "ThenByDescending" : "ThenBy");
  27.         var method = typeof(Queryable).GetMethods()
  28.             .First(m => m.Name == methodName && m.GetParameters().Length == 2)
  29.             .MakeGenericMethod(typeof(T), prop.PropertyType);
  30.         query = (IQueryable<T>)method.Invoke(null, [query, lambda])!;
  31.         isFirst = false;
  32.     }
  33.     return query;
  34. }
复制代码
也可以考虑 System.Linq.Dynamic.Core 这个库。
动态过滤

这是扩展性最强的一块。前端传字段名 + 操作符 + 值,后端用表达式树动态拼 Where 条件,不需要每加一个筛选项就改后端代码。
先定义过滤条件的数据结构:
  1. public class FilterItem
  2. {
  3.     // 字段名,对应实体属性,不区分大小写
  4.     public string Field { get; set; } = string.Empty;
  5.     // 操作符:eq、neq、contains、startswith、endswith、
  6.     //         gt、gte、lt、lte、between、in、isnull、isnotnull
  7.     public string Op { get; set; } = "eq";
  8.     // 值,between 用逗号分隔两个值,in 用逗号分隔多个值
  9.     public string? Value { get; set; }
  10. }
复制代码
然后实现过滤扩展方法:
  1. public static IQueryable<T> ApplyFilters<T>(
  2.     this IQueryable<T> query,
  3.     IEnumerable<FilterItem> filters)
  4. {
  5.     foreach (var filter in filters)
  6.     {
  7.         var prop = typeof(T).GetProperty(
  8.             filter.Field,
  9.             BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance);
  10.         // 找不到属性,或者没有 [Filterable] 标记,就跳过
  11.         if (prop == null || !prop.IsDefined(typeof(FilterableAttribute), false))
  12.             continue;
  13.         var param = Expression.Parameter(typeof(T), "x");
  14.         var member = Expression.Property(param, prop);
  15.         Expression? condition = null;
  16.         switch (filter.Op.ToLower())
  17.         {
  18.             case "eq":
  19.                 condition = Expression.Equal(member, ParseConstant(filter.Value, prop.PropertyType));
  20.                 break;
  21.             case "neq":
  22.                 condition = Expression.NotEqual(member, ParseConstant(filter.Value, prop.PropertyType));
  23.                 break;
  24.             case "gt":
  25.                 condition = Expression.GreaterThan(member, ParseConstant(filter.Value, prop.PropertyType));
  26.                 break;
  27.             case "gte":
  28.                 condition = Expression.GreaterThanOrEqual(member, ParseConstant(filter.Value, prop.PropertyType));
  29.                 break;
  30.             case "lt":
  31.                 condition = Expression.LessThan(member, ParseConstant(filter.Value, prop.PropertyType));
  32.                 break;
  33.             case "lte":
  34.                 condition = Expression.LessThanOrEqual(member, ParseConstant(filter.Value, prop.PropertyType));
  35.                 break;
  36.             case "contains":
  37.                 condition = Expression.Call(
  38.                     member,
  39.                     typeof(string).GetMethod("Contains", [typeof(string)])!,
  40.                     Expression.Constant(filter.Value ?? string.Empty));
  41.                 break;
  42.             case "startswith":
  43.                 condition = Expression.Call(
  44.                     member,
  45.                     typeof(string).GetMethod("StartsWith", [typeof(string)])!,
  46.                     Expression.Constant(filter.Value ?? string.Empty));
  47.                 break;
  48.             case "endswith":
  49.                 condition = Expression.Call(
  50.                     member,
  51.                     typeof(string).GetMethod("EndsWith", [typeof(string)])!,
  52.                     Expression.Constant(filter.Value ?? string.Empty));
  53.                 break;
  54.             case "between":
  55.                 // value 格式:"10,100"
  56.                 var rangeParts = filter.Value?.Split(',') ?? [];
  57.                 if (rangeParts.Length == 2)
  58.                 {
  59.                     var lower = ParseConstant(rangeParts[0].Trim(), prop.PropertyType);
  60.                     var upper = ParseConstant(rangeParts[1].Trim(), prop.PropertyType);
  61.                     condition = Expression.AndAlso(
  62.                         Expression.GreaterThanOrEqual(member, lower),
  63.                         Expression.LessThanOrEqual(member, upper));
  64.                 }
  65.                 break;
  66.             case "in":
  67.                 // value 格式:"1,2,3",最多取 50 个,防止 OR 链过长
  68.                 var inValues = filter.Value?.Split(',').Take(50)
  69.                     .Select(v => ParseConstant(v.Trim(), prop.PropertyType))
  70.                     .ToList() ?? [];
  71.                 if (inValues.Count > 0)
  72.                 {
  73.                     condition = inValues
  74.                         .Select(v => (Expression)Expression.Equal(member, v))
  75.                         .Aggregate(Expression.OrElse);
  76.                 }
  77.                 break;
  78.             case "isnull":
  79.                 condition = Expression.Equal(member, Expression.Constant(null, prop.PropertyType));
  80.                 break;
  81.             case "isnotnull":
  82.                 condition = Expression.NotEqual(member, Expression.Constant(null, prop.PropertyType));
  83.                 break;
  84.         }
  85.         if (condition == null) continue;
  86.         var lambda = Expression.Lambda<Func<T, bool>>(condition, param);
  87.         query = query.Where(lambda);
  88.     }
  89.     return query;
  90. }
  91. // 把字符串值转成对应类型的常量表达式
  92. private static ConstantExpression ParseConstant(string? value, Type targetType)
  93. {
  94.     var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType;
  95.     if (value == null)
  96.         return Expression.Constant(null, targetType);
  97.     var converted = Convert.ChangeType(value, underlyingType);
  98.     return Expression.Constant(converted, targetType);
  99. }
复制代码
contains/startswith/endswith 应对字符串,gt/lt/between 应对对数值和日期。类型不匹配时会抛异常,生产代码里可以在这里加 try-catch,捕获后根据情况进行处理。
动态返回字段

有时候列表页只需要 id 和 name,详情页才需要全量字段。与其写两个接口,不如让前端自己说想要哪些字段(我经历的项目都是后端定义好给前端哈,不是前段自己拿,前段自己也不想拿)。
思路是:查出完整的实体,然后用反射把指定字段打包成字典返回,JSON 序列化后就只有这些字段。
  1. public static class FieldSelectorExtensions
  2. {
  3.     public static IDictionary<string, object?> SelectFields<T>(
  4.         this T obj,
  5.         IEnumerable<string> fields)
  6.     {
  7.         var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase);
  8.         var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance);
  9.         foreach (var fieldName in fields)
  10.         {
  11.             var prop = props.FirstOrDefault(p =>
  12.                 p.Name.Equals(fieldName, StringComparison.OrdinalIgnoreCase));
  13.             if (prop != null)
  14.                 result[prop.Name] = prop.GetValue(obj);
  15.         }
  16.         return result;
  17.     }
  18.     public static IEnumerable<IDictionary<string, object?>> SelectFields<T>(
  19.         this IEnumerable<T> items,
  20.         string? fields)
  21.     {
  22.         if (string.IsNullOrWhiteSpace(fields))
  23.         {
  24.             var allProps = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance)
  25.                 .Select(p => p.Name);
  26.             return items.Select(item => item.SelectFields(allProps));
  27.         }
  28.         var fieldList = fields
  29.             .Split(',', StringSplitOptions.RemoveEmptyEntries)
  30.             .Select(f => f.Trim());
  31.         return items.Select(item => item.SelectFields(fieldList));
  32.     }
  33. }
复制代码
安全性:字段白名单

动态过滤和动态返回字段功能很方便,但不是所有字段都该暴露出去,比如密码、证件号、客户姓名这类。用一个自定义 Attribute 来标记哪些字段允许外部操作:
  1. [AttributeUsage(AttributeTargets.Property)]
  2. public class FilterableAttribute : Attribute { }
  3. public class Product
  4. {
  5.     public int Id { get; set; }
  6.     [Filterable]
  7.     public string Name { get; set; } = string.Empty;
  8.     [Filterable]
  9.     public decimal Price { get; set; }
  10.     [Filterable]
  11.     public int Stock { get; set; }
  12.     // 不加 [Filterable],外部无法通过 filters 参数过滤这个字段
  13.     public string InternalRemark { get; set; } = string.Empty;
  14. }
复制代码
ApplyFilters 里已经加了这个检查(prop.IsDefined(typeof(FilterableAttribute), false)),找到属性之后会先验证标记,没有就跳过。也可以反着来设计,加一个 FilterIgnore 特性,检查的地方做相应的调整。
接到 Controller 里

有了这些扩展方法,Controller 里的逻辑就很平:
  1. [HttpGet]
  2. public async Task GetProducts([FromQuery] QueryParameters parameters)
  3. {
  4.     var query = _context.Products.AsQueryable();
  5.     // 动态过滤
  6.     if (parameters.Filters.Count > 0)
  7.         query = query.ApplyFilters(parameters.Filters);
  8.     // 先算总数(必须在分页之前)
  9.     var totalRecords = await query.CountAsync();
  10.     // 排序 + 分页
  11.     var items = await query
  12.         .ApplySort(parameters.SortBy)
  13.         .ApplyPagination(parameters.PageNumber, parameters.PageSize)
  14.         .Select(p => new ProductDto
  15.         {
  16.             Id = p.Id,
  17.             Name = p.Name,
  18.             Price = p.Price,
  19.             Stock = p.Stock
  20.         })
  21.         .ToListAsync();
  22.     // 按需返回字段
  23.     var data = items.SelectFields(parameters.Fields).ToList();
  24.     return Ok(new
  25.     {
  26.         data,
  27.         pageNumber = parameters.PageNumber,
  28.         pageSize = parameters.PageSize,
  29.         totalRecords,
  30.         totalPages = (int)Math.Ceiling(totalRecords / (double)parameters.PageSize),
  31.         hasNextPage = parameters.PageNumber < (int)Math.Ceiling(totalRecords / (double)parameters.PageSize),
  32.         hasPreviousPage = parameters.PageNumber > 1
  33.     });
  34. }
复制代码
前端请求示例:
  1. # 查价格在 100-500 之间、名字包含"手机",只返回 id 和 name,按价格升序
  2. GET /api/products
  3.   ?filters[0].field=price&filters[0].op=between&filters[0].value=100,500
  4.   &filters[1].field=name&filters[1].op=contains&filters[1].value=手机
  5.   &fields=id,name
  6.   &sortBy=price asc
  7.   &pageNumber=1&pageSize=20
复制代码
返回结果:
  1. {
  2.   "data": [
  3.     { "Id": 1, "Name": "iPhone 16" },
  4.     { "Id": 2, "Name": "小米 15" }
  5.   ],
  6.   "pageNumber": 1,
  7.   "pageSize": 20,
  8.   "totalRecords": 2,
  9.   "totalPages": 1,
  10.   "hasNextPage": false,
  11.   "hasPreviousPage": false
  12. }
复制代码
ok,你学会了吗?

来源:程序园用户自行投稿发布,如果侵权,请联系站长删除
免责声明:如果侵犯了您的权益,请联系站长,我们会及时删除侵权内容,谢谢合作!

相关推荐

您需要登录后才可以回帖 登录 | 立即注册