如何用ASP.NET Core和EF Core打造灵活可扩展的动态分页系统?

摘要:引言 欢迎阅读,这篇文章主要面向初级开发者。 在开始之前,先问你一个问题:你做的系统,是不是每次增加一个查询条件或者排序字段,都要去请求参数对象里加一个属性,然后再跑去改 EF Core 的查询逻辑? 如果是,那这篇文章应该对你有用。我会带
引言 欢迎阅读,这篇文章主要面向初级开发者。 在开始之前,先问你一个问题:你做的系统,是不是每次增加一个查询条件或者排序字段,都要去请求参数对象里加一个属性,然后再跑去改 EF Core 的查询逻辑? 如果是,那这篇文章应该对你有用。我会带你做一个统一的、扩展起来不那么麻烦的分页查询方案。整体思路是四件事:​统一入参、统一出参、动态排序、动态过滤​。 统一请求参数 先定义一个公共的 QueryParameters 解决这个问题: public class QueryParameters { private const int MaxPageSize = 100; private int _pageSize = 10; public int PageNumber { get; set; } = 1; // 限制最大值,防止前端传一个很大数值把数据库搞崩了 public int PageSize { get => _pageSize; set => _pageSize = value > MaxPageSize ? MaxPageSize : value; } // 支持多字段排序,格式:"name desc,price asc" public string? SortBy { get; set; } // 通用关键词搜索 public string? Search { get; set; } // 动态过滤条件 public List<FilterItem> Filters { get; set; } = []; // 要返回的字段,逗号分隔:"id,name,price",不传则返回全部 public string? Fields { get; set; } } ASP.NET Core 的模型绑定会自动把 query string 映射到这个对象,不需要手动解析。后续如果某个接口有额外参数,继承它加字段就行,不用每次从头定义。 统一响应包装器 返回值也统一一下,把分页信息和数据放在一起,调用方就不用自己拼了: public class PagedResponse<T> { // IReadOnlyList 防止外部随意修改集合 public IReadOnlyList<T> Data { get; init; } = []; public int PageNumber { get; init; } public int PageSize { get; init; } public int TotalRecords { get; init; } public int TotalPages => (int)Math.Ceiling(TotalRecords / (double)PageSize); public bool HasNextPage => PageNumber < TotalPages; public bool HasPreviousPage => PageNumber > 1; } Data 是任意类型的集合,用 IReadOnlyList 防止被意外修改。TotalPages、HasNextPage 和 HasPreviousPage 三个是计算属性,不需要单独赋值。 扩展方法 把分页、排序、过滤都做成 IQueryable<T> 的扩展方法,用起来像链式调用,调用的地方看起来会很干净。 分页 public static IQueryable<T> ApplyPagination<T>( this IQueryable<T> query, int pageNumber, int pageSize) { return query .Skip((pageNumber - 1) * pageSize) .Take(pageSize); } 动态排序 解析 "name desc,price asc" 这样的字符串,动态生成排序表达式。用反射就能做到,不需要额外的库: public static IQueryable<T> ApplySort<T>( this IQueryable<T> query, string? sortBy) { if (string.IsNullOrWhiteSpace(sortBy)) return query; var orderParams = sortBy.Split(',', StringSplitOptions.RemoveEmptyEntries); var isFirst = true; foreach (var param in orderParams) { var parts = param.Trim().Split(' '); var propertyName = parts[0]; var isDesc = parts.Length > 1 && parts[1].Equals("desc", StringComparison.OrdinalIgnoreCase); // 用反射找属性,找不到就跳过,避免抛异常 var prop = typeof(T).GetProperty( propertyName, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); if (prop == null) continue; // 构建表达式树:x => x.PropertyName var paramExpr = Expression.Parameter(typeof(T), "x"); var body = Expression.Property(paramExpr, prop); var lambda = Expression.Lambda(body, paramExpr); var methodName = isFirst ? (isDesc ? "OrderByDescending" : "OrderBy") : (isDesc ? "ThenByDescending" : "ThenBy"); var method = typeof(Queryable).GetMethods() .First(m => m.Name == methodName && m.GetParameters().Length == 2) .MakeGenericMethod(typeof(T), prop.PropertyType); query = (IQueryable<T>)method.Invoke(null, [query, lambda])!; isFirst = false; } return query; } 也可以考虑 System.Linq.Dynamic.Core 这个库。 动态过滤 这是扩展性最强的一块。前端传字段名 + 操作符 + 值,后端用表达式树动态拼 Where 条件,不需要每加一个筛选项就改后端代码。 先定义过滤条件的数据结构: public class FilterItem { // 字段名,对应实体属性,不区分大小写 public string Field { get; set; } = string.Empty; // 操作符:eq、neq、contains、startswith、endswith、 // gt、gte、lt、lte、between、in、isnull、isnotnull public string Op { get; set; } = "eq"; // 值,between 用逗号分隔两个值,in 用逗号分隔多个值 public string? Value { get; set; } } 然后实现过滤扩展方法: public static IQueryable<T> ApplyFilters<T>( this IQueryable<T> query, IEnumerable<FilterItem> filters) { foreach (var filter in filters) { var prop = typeof(T).GetProperty( filter.Field, BindingFlags.IgnoreCase | BindingFlags.Public | BindingFlags.Instance); // 找不到属性,或者没有 [Filterable] 标记,就跳过 if (prop == null || !prop.IsDefined(typeof(FilterableAttribute), false)) continue; var param = Expression.Parameter(typeof(T), "x"); var member = Expression.Property(param, prop); Expression? condition = null; switch (filter.Op.ToLower()) { case "eq": condition = Expression.Equal(member, ParseConstant(filter.Value, prop.PropertyType)); break; case "neq": condition = Expression.NotEqual(member, ParseConstant(filter.Value, prop.PropertyType)); break; case "gt": condition = Expression.GreaterThan(member, ParseConstant(filter.Value, prop.PropertyType)); break; case "gte": condition = Expression.GreaterThanOrEqual(member, ParseConstant(filter.Value, prop.PropertyType)); break; case "lt": condition = Expression.LessThan(member, ParseConstant(filter.Value, prop.PropertyType)); break; case "lte": condition = Expression.LessThanOrEqual(member, ParseConstant(filter.Value, prop.PropertyType)); break; case "contains": condition = Expression.Call( member, typeof(string).GetMethod("Contains", [typeof(string)])!, Expression.Constant(filter.Value ?? string.Empty)); break; case "startswith": condition = Expression.Call( member, typeof(string).GetMethod("StartsWith", [typeof(string)])!, Expression.Constant(filter.Value ?? string.Empty)); break; case "endswith": condition = Expression.Call( member, typeof(string).GetMethod("EndsWith", [typeof(string)])!, Expression.Constant(filter.Value ?? string.Empty)); break; case "between": // value 格式:"10,100" var rangeParts = filter.Value?.Split(',') ?? []; if (rangeParts.Length == 2) { var lower = ParseConstant(rangeParts[0].Trim(), prop.PropertyType); var upper = ParseConstant(rangeParts[1].Trim(), prop.PropertyType); condition = Expression.AndAlso( Expression.GreaterThanOrEqual(member, lower), Expression.LessThanOrEqual(member, upper)); } break; case "in": // value 格式:"1,2,3",最多取 50 个,防止 OR 链过长 var inValues = filter.Value?.Split(',').Take(50) .Select(v => ParseConstant(v.Trim(), prop.PropertyType)) .ToList() ?? []; if (inValues.Count > 0) { condition = inValues .Select(v => (Expression)Expression.Equal(member, v)) .Aggregate(Expression.OrElse); } break; case "isnull": condition = Expression.Equal(member, Expression.Constant(null, prop.PropertyType)); break; case "isnotnull": condition = Expression.NotEqual(member, Expression.Constant(null, prop.PropertyType)); break; } if (condition == null) continue; var lambda = Expression.Lambda<Func<T, bool>>(condition, param); query = query.Where(lambda); } return query; } // 把字符串值转成对应类型的常量表达式 private static ConstantExpression ParseConstant(string? value, Type targetType) { var underlyingType = Nullable.GetUnderlyingType(targetType) ?? targetType; if (value == null) return Expression.Constant(null, targetType); var converted = Convert.ChangeType(value, underlyingType); return Expression.Constant(converted, targetType); } contains/startswith/endswith 应对字符串,gt/lt/between 应对对数值和日期。类型不匹配时会抛异常,生产代码里可以在这里加 try-catch,捕获后根据情况进行处理。 动态返回字段 有时候列表页只需要 id 和 name,详情页才需要全量字段。与其写两个接口,不如让前端自己说想要哪些字段(我经历的项目都是后端定义好给前端哈,不是前段自己拿,前段自己也不想拿)。 思路是:查出完整的实体,然后用反射把指定字段打包成字典返回,JSON 序列化后就只有这些字段。 public static class FieldSelectorExtensions { public static IDictionary<string, object?> SelectFields<T>( this T obj, IEnumerable<string> fields) { var result = new Dictionary<string, object?>(StringComparer.OrdinalIgnoreCase); var props = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance); foreach (var fieldName in fields) { var prop = props.FirstOrDefault(p => p.Name.Equals(fieldName, StringComparison.OrdinalIgnoreCase)); if (prop != null) result[prop.Name] = prop.GetValue(obj); } return result; } public static IEnumerable<IDictionary<string, object?>> SelectFields<T>( this IEnumerable<T> items, string? fields) { if (string.IsNullOrWhiteSpace(fields)) { var allProps = typeof(T).GetProperties(BindingFlags.Public | BindingFlags.Instance) .Select(p => p.Name); return items.Select(item => item.SelectFields(allProps)); } var fieldList = fields .Split(',', StringSplitOptions.RemoveEmptyEntries) .Select(f => f.Trim()); return items.Select(item => item.SelectFields(fieldList)); } } 安全性:字段白名单 动态过滤和动态返回字段功能很方便,但不是所有字段都该暴露出去,比如密码、证件号、客户姓名这类。用一个自定义 Attribute 来标记哪些字段允许外部操作: [AttributeUsage(AttributeTargets.Property)] public class FilterableAttribute : Attribute { } public class Product { public int Id { get; set; } [Filterable] public string Name { get; set; } = string.Empty; [Filterable] public decimal Price { get; set; } [Filterable] public int Stock { get; set; } // 不加 [Filterable],外部无法通过 filters 参数过滤这个字段 public string InternalRemark { get; set; } = string.Empty; } ApplyFilters 里已经加了这个检查(prop.IsDefined(typeof(FilterableAttribute), false)),找到属性之后会先验证标记,没有就跳过。也可以反着来设计,加一个 FilterIgnore 特性,检查的地方做相应的调整。 接到 Controller 里 有了这些扩展方法,Controller 里的逻辑就很平: [HttpGet] public async Task<ActionResult> GetProducts([FromQuery] QueryParameters parameters) { var query = _context.Products.AsQueryable(); // 动态过滤 if (parameters.Filters.Count > 0) query = query.ApplyFilters(parameters.Filters); // 先算总数(必须在分页之前) var totalRecords = await query.CountAsync(); // 排序 + 分页 var items = await query .ApplySort(parameters.SortBy) .ApplyPagination(parameters.PageNumber, parameters.PageSize) .Select(p => new ProductDto { Id = p.Id, Name = p.Name, Price = p.Price, Stock = p.Stock }) .ToListAsync(); // 按需返回字段 var data = items.SelectFields(parameters.Fields).ToList(); return Ok(new { data, pageNumber = parameters.PageNumber, pageSize = parameters.PageSize, totalRecords, totalPages = (int)Math.Ceiling(totalRecords / (double)parameters.PageSize), hasNextPage = parameters.PageNumber < (int)Math.Ceiling(totalRecords / (double)parameters.PageSize), hasPreviousPage = parameters.PageNumber > 1 }); } 前端请求示例: # 查价格在 100-500 之间、名字包含"手机",只返回 id 和 name,按价格升序 GET /api/products ?filters[0].field=price&filters[0].op=between&filters[0].value=100,500 &filters[1].field=name&filters[1].op=contains&filters[1].value=手机 &fields=id,name &sortBy=price asc &pageNumber=1&pageSize=20 返回结果: { "data": [ { "Id": 1, "Name": "iPhone 16" }, { "Id": 2, "Name": "小米 15" } ], "pageNumber": 1, "pageSize": 20, "totalRecords": 2, "totalPages": 1, "hasNextPage": false, "hasPreviousPage": false } ok,你学会了吗?