基于OData的.NET Core Web API自定义分页与nextlink生成问题
.NET Core Web API OData服务器端分页解决方案
问题梳理
- 自定义
CustomODataSkipTokenHandler的GenerateNextLink方法无法获取总记录数,无法基于页码生成skipToken - 默认OData依赖ID字段分页,ID不连续时会出现结果重复
- 需要实现支持
skip/top及自定义排序的分页处理器 - 使用
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
相关产品推荐
相关产品推荐

