当前位置: 首页 > news >正文

sp.net 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防止被意外修改。TotalPagesHasNextPageHasPreviousPage三个是计算属性,不需要单独赋值。


扩展方法

把分页、排序、过滤都做成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,捕获后根据情况进行处理。


动态返回字段

有时候列表页只需要idname,详情页才需要全量字段。与其写两个接口,不如让前端自己说想要哪些字段(我经历的项目都是后端定义好给前端哈,不是前段自己拿,前段自己也不想拿)。

思路是:查出完整的实体,然后用反射把指定字段打包成字典返回,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
}
http://www.cnnetsun.cn/news/3112154.html

相关文章:

  • 无真实标签时如何评估模型性能:CBPE校准监控实战
  • MCP与Spring AI整合实战:云原生与AI技术融合指南
  • HunterPie终极指南:5分钟掌握《怪物猎人世界》最强数据覆盖层
  • FPGA与STM32的SPI通信 - FPGA主 STM32从
  • Android 7系统日志(五)日志读取—logcat源码深度分析
  • AI科研效率革命:用Claude技能包重构论文写作与数据分析流程
  • 海外短剧平台技术架构与运营实战指南
  • 本地部署AI Agent,6G显存跑Qwen3.6-35B-A3B 从入门到实战全流程
  • 科技融匠心!康姿百德学生床垫筑牢成长睡眠防线
  • 嵌套 H5 的跨端通信:iOS / Android / 小程序 / 浏览器
  • 第【48期】-- 通信问题的cvx教程之基础篇【一】-- MU-MIMO下行功率分配问题
  • Node.js Promise.all 并行查询实战:性能提升与错误处理详解
  • RAG 是什么?让大模型读懂私有知识库的关键技术
  • 多项式回归实战:用3阶曲线拟合替代线性模型
  • 180火龙传奇打金搬砖三天测试表:新手怎么判断有没有跑顺
  • tModCodeAssist:泰拉瑞亚模组开发者的智能代码助手终极指南
  • KWM转MP3:从酷我加密容器到通用格式,5种技术方案完全解析
  • AzurLaneAutoScript:碧蓝航线自动化脚本的最佳实践与技术架构解析
  • Normal Equation实战指南:线性回归闭式解的稳定实现与工程落地
  • 从代码到参数:2026年AI前沿技术深度拆解
  • 铁客流智能监控:YOLOv8姿态识别数据集全解析,从训练到部署实战指南10761期
  • 电商运营Agent
  • 微软在2002年推出了第一个版本的 .NET Framework,这是一个主要面向Windows 桌面(Windows Forms)和服务器(ASP.NET Web Forms)的基础框架。在此之后,
  • 鼓浪屿:鹭江之上的琴音与时光
  • 【Java课程设计/毕业设计】基于 SpringBoot 的课程评分分析与智能推荐平台的设计与实现 智慧校园个性化教学资源服务推荐系统【附源码、数据库、万字文档】
  • 让大模型拿到实时搜索结果:SERP API 的一个实现方案
  • 智能动效检查:AI 可以看节奏,但标准要由人定义
  • 投机解码技术解析:如何用DSpark实现大模型推理85%加速
  • ASP.NET 8 Cookie身份验证实现与安全实践
  • MetaTube插件:Jellyfin/Emby媒体库的终极元数据自动刮削解决方案