惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

D
Docker
爱范儿
爱范儿
人人都是产品经理
人人都是产品经理
博客园 - 司徒正美
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
量子位
罗磊的独立博客
cs.CL updates on arXiv.org
cs.CL updates on arXiv.org
小众软件
小众软件
C
Cybersecurity and Infrastructure Security Agency CISA
Cyberwarzone
Cyberwarzone
大猫的无限游戏
大猫的无限游戏
Threat Intelligence Blog | Flashpoint
Threat Intelligence Blog | Flashpoint
雷峰网
雷峰网
Simon Willison's Weblog
Simon Willison's Weblog
The Cloudflare Blog
博客园 - 三生石上(FineUI控件)
D
Darknet – Hacking Tools, Hacker News & Cyber Security
C
Cyber Attacks, Cyber Crime and Cyber Security
博客园_首页
博客园 - 叶小钗
V
Vulnerabilities – Threatpost
T
The Exploit Database - CXSecurity.com
T
Tailwind CSS Blog
IT之家
IT之家
博客园 - 聂微东
Spread Privacy
Spread Privacy
V2EX - 技术
V2EX - 技术
S
Security Affairs
宝玉的分享
宝玉的分享
V
V2EX
C
Cisco Blogs
博客园 - Franky
美团技术团队
酷 壳 – CoolShell
酷 壳 – CoolShell
月光博客
月光博客
S
Securelist
J
Java Code Geeks
Webroot Blog
Webroot Blog
让小产品的独立变现更简单 - ezindie.com
让小产品的独立变现更简单 - ezindie.com
P
Proofpoint News Feed
Last Week in AI
Last Week in AI
L
LINUX DO - 热门话题
NISL@THU
NISL@THU
WordPress大学
WordPress大学
W
WeLiveSecurity
T
Threatpost
freeCodeCamp Programming Tutorials: Python, JavaScript, Git & More
腾讯CDC
阮一峰的网络日志
阮一峰的网络日志

博客园 - 邓磊Lei

为什么 Go 一个 HTTP 服务可以同时处理数万连接 gin怎么处理一次http请求的 我的经验:git提交信息分别用什么icon C# 也能像 Python 一样写脚本 | .NET 10 构建基于文件的应用 .NET 10 使用 Microsoft.AspNetCore.OpenApi 实现 API 版本管理 ASP.NET Core 内存缓存实战:一篇搞懂该怎么配、怎么避坑 Microsoft Agent Framework + Kimi API 实战:控制台应用跑通单次与多轮 Agent 对话 聊聊 ASP.NET Core 中间件和过滤器的区别 Python 入门:从“其他语言”到 Pythonic 思维的完整迁移手册 .NET 进阶之路:异步、并发与内存管理的系统性认知 Redis:延迟双删的适用边界与落地细节 Serilog:从结构化日志认知到 .NET 工程落地 EF Core 原生 SQL 实战:FromSql、SqlQuery 与对象映射边界 EF Core 拦截器实战:SaveChangesInterceptor、CommandInterceptor 与审计落地 ASP.NET Core 外部依赖调用治理实战:HttpClientFactory、Polly 与幂等边界 .NET .Result 避坑指南:不同框架下的死锁与线程池饥饿 EF Core 慢查询排查实战:TagWith、OpenTelemetry、执行计划,30 分钟定位性能瓶颈 EF Core 并发冲突实战:乐观锁、RowVersion 与 DbUpdateConcurrencyException 怎么处理 EF Core 写入链路深拆:从 ChangeTracker 到 SQL Batch 的性能诊断与优化 ASP.NET Core 认证鉴权实战:JWT、Policy 与权限边界怎么落地 C# 异步编程深水区:Task、ValueTask、线程池饥饿与背压设计 EF Core 查询性能黑洞:Include、投影与跟踪策略的边界 从 IApplicationBuilder 到 RequestDelegate:ASP.NET Core 请求管线的性能与可观测性实战 如何在 rider 中配置 SonarQube
开发实战:asp.net core + ef core 实现动态可扩展的分页方案
邓磊Lei · 2026-04-05 · via 博客园 - 邓磊Lei

引言

欢迎阅读,这篇文章主要面向初级开发者。

在开始之前,先问你一个问题:你做的系统,是不是每次增加一个查询条件或者排序字段,都要去请求参数对象里加一个属性,然后再跑去改 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
}

ok,你学会了吗?