You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

基于OData的.NET Core Web API自定义分页与nextlink生成问题

.NET Core Web API OData服务器端分页解决方案

问题梳理

  1. 自定义CustomODataSkipTokenHandler的GenerateNextLink方法无法获取总记录数,无法基于页码生成skipToken
  2. 默认OData依赖ID字段分页,ID不连续时会出现结果重复
  3. 需要实现支持skip/top及自定义排序的分页处理器
  4. 使用PageResult生成下一页链接后,$select查询失效且抛出Json自引用循环错误

解决方案

一、改用排序字段标记分页起点(解决重复问题+生成正确下一页链接)

默认OData的skipToken机制本质是基于最后一条数据的排序字段值定位下一页,而非页码,这种方式更适配数据库分页逻辑,能避免ID不连续导致的重复/遗漏。自定义SkipTokenHandler时,可基于当前查询的排序字段生成skipToken:

public class CustomSkipTokenHandler : DefaultSkipTokenHandler
{
    // 重写ApplyTo,基于排序字段处理分页逻辑
    public override IQueryable<T> ApplyTo<T>(IQueryable<T> query, SkipTokenQueryOption skipTokenOption, ODataQuerySettings settings, ODataQueryOptions queryOptions)
    {
        // 处理排序:无指定排序时默认按ID降序,避免分页混乱
        var orderByOption = queryOptions.OrderBy;
        if (orderByOption == null)
        {
            var idProp = typeof(T).GetProperty("Id");
            query = query.OrderByDescending(x => idProp.GetValue(x));
        }

        // 解析skipToken,生成下一页起始过滤条件
        if (skipTokenOption != null && !string.IsNullOrEmpty(skipTokenOption.RawValue))
        {
            var tokenParts = skipTokenOption.RawValue.Split(':');
            if (tokenParts.Length == 2)
            {
                var propName = tokenParts[0];
                var lastValue = tokenParts[1];
                var prop = typeof(T).GetProperty(propName);
                if (prop != null)
                {
                    var value = Convert.ChangeType(lastValue, prop.PropertyType);
                    // 根据排序方向生成过滤(此处默认升序,可通过orderByOption调整)
                    query = query.Where(x => Comparer.Default.Compare(prop.GetValue(x), value) > 0);
                }
            }
        }

        // 应用top限制
        if (queryOptions.Top != null)
        {
            query = query.Take(queryOptions.Top.Value);
        }

        return query;
    }

    // 重写GenerateNextPageLink,基于最后一条数据的排序字段生成skipToken
    public override Uri GenerateNextPageLink(Uri baseUri, int pageSize, object lastInstance, ODataSerializerContext context)
    {
        if (lastInstance == null) return null;

        // 获取排序字段:优先用查询中的OrderBy,否则默认ID
        string sortPropName = "Id";
        var orderByClause = context.Request.ODataFeature().QueryOptions?.OrderBy?.OrderByClauses.FirstOrDefault();
        if (orderByClause != null)
        {
            sortPropName = orderByClause.Expression.ToString().Split('.').Last();
        }

        var propValue = lastInstance.GetType().GetProperty(sortPropName).GetValue(lastInstance);
        var skipToken = $"{sortPropName}:{propValue}";

        var queryParams = HttpUtility.ParseQueryString(baseUri.Query);
        queryParams.Set("$skiptoken", skipToken);
        // 保留原$select、$orderBy等参数,按需移除$top(若固定页大小可保留)
        queryParams.Remove("$top");

        var builder = new UriBuilder(baseUri)
        {
            Query = queryParams.ToString()
        };

        return builder.Uri;
    }
}

二、注册自定义SkipTokenHandler

在Program.cs中注入自定义处理器:

builder.Services.AddControllers().AddOData(options =>
{
    options.Select().Filter().OrderBy().Expand().Count().SetMaxTop(100);
    options.AddRouteComponents("odata", GetEdmModel(), services =>
    {
        services.AddSingleton<SkipTokenHandler, CustomSkipTokenHandler>();
    });
});

// 示例Edm模型定义
private static IEdmModel GetEdmModel()
{
    var builder = new ODataConventionModelBuilder();
    builder.EntitySet<Product>("Products");
    return builder.GetEdmModel();
}

三、解决PageResult的$select失效和自引用错误

方式1:禁用Metadata,返回精简分页结果

手动处理OData查询,返回仅包含数据、下一页链接和总条数的对象,避免Metadata引发的序列化问题:

[HttpGet]
[EnableQuery]
public async Task<IActionResult> Get(ODataQueryOptions<Product> options)
{
    var query = _dbContext.Products.AsQueryable();
    // 应用OData查询(包含$select投影)
    var result = options.ApplyTo(query, new ODataQuerySettings()) as IQueryable<Product>;
    var data = await result.ToListAsync();
    
    // 获取总记录数
    var totalCount = await _dbContext.Products.CountAsync();
    
    // 生成下一页链接
    var nextLink = GenerateNextLink(Request, data.LastOrDefault(), options.Top?.Value ?? 10);
    
    return Ok(new
    {
        Value = data,
        NextLink = nextLink?.ToString(),
        Count = totalCount
    });
}

// 自定义下一页链接生成方法
private Uri GenerateNextLink(HttpRequest request, Product lastItem, int pageSize)
{
    if (lastItem == null) return null;
    
    var baseUri = new Uri(request.Scheme + "://" + request.Host + request.Path);
    var queryParams = HttpUtility.ParseQueryString(request.QueryString.Value);
    
    // 基于排序字段生成skipToken
    var sortProp = request.Query.ContainsKey("$orderby") 
        ? request.Query["$orderby"].ToString().Split(' ')[0] 
        : "Id";
    var propValue = lastItem.GetType().GetProperty(sortProp).GetValue(lastItem);
    queryParams.Set("$skiptoken", $"{sortProp}:{propValue}");
    
    var builder = new UriBuilder(baseUri)
    {
        Query = queryParams.ToString()
    };
    
    return builder.Uri;
}

方式2:配置Json序列化忽略循环引用

在Program.cs中调整Newtonsoft.Json的序列化规则:

builder.Services.AddControllers().AddNewtonsoftJson(options =>
{
    options.SerializerSettings.ReferenceLoopHandling = Newtonsoft.Json.ReferenceLoopHandling.Ignore;
}).AddOData(...);

四、skip/top分页的极简实现(无需自定义SkipTokenHandler)

若不需要skipToken,直接用skip/top分页,可在Controller中手动处理并保留OData查询能力:

[HttpGet]
[EnableQuery(AllowedQueryOptions = AllowedQueryOptions.Select | AllowedQueryOptions.OrderBy | AllowedQueryOptions.Top | AllowedQueryOptions.Skip)]
public async Task<IActionResult> Get(ODataQueryOptions<Product> options)
{
    var query = _dbContext.Products.AsQueryable();
    
    // 获取总记录数
    var totalCount = await query.CountAsync();
    
    // 应用OData查询
    var result = options.ApplyTo(query, new ODataQuerySettings()) as IQueryable<Product>;
    var data = await result.ToListAsync();
    
    // 生成下一页链接
    int skip = options.Skip?.Value ?? 0;
    int top = options.Top?.Value ?? 10;
    Uri nextLink = null;
    if (skip + top < totalCount)
    {
        var baseUri = new Uri(Request.Scheme + "://" + Request.Host + Request.Path);
        var queryParams = HttpUtility.ParseQueryString(Request.QueryString.Value);
        queryParams.Set("$skip", (skip + top).ToString());
        
        var builder = new UriBuilder(baseUri)
        {
            Query = queryParams.ToString()
        };
        nextLink = builder.Uri;
    }
    
    return Ok(new PageResult<Product>(data, nextLink, totalCount));
}

内容的提问来源于stack exchange,提问作者ntn

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.07.22 12:50:44