Fanbin.EntityFrameworkCore.CursorPagination
10.0.0
dotnet add package Fanbin.EntityFrameworkCore.CursorPagination --version 10.0.0
NuGet\Install-Package Fanbin.EntityFrameworkCore.CursorPagination -Version 10.0.0
<PackageReference Include="Fanbin.EntityFrameworkCore.CursorPagination" Version="10.0.0" />
<PackageVersion Include="Fanbin.EntityFrameworkCore.CursorPagination" Version="10.0.0" />
<PackageReference Include="Fanbin.EntityFrameworkCore.CursorPagination" />
paket add Fanbin.EntityFrameworkCore.CursorPagination --version 10.0.0
#r "nuget: Fanbin.EntityFrameworkCore.CursorPagination, 10.0.0"
#:package Fanbin.EntityFrameworkCore.CursorPagination@10.0.0
#addin nuget:?package=Fanbin.EntityFrameworkCore.CursorPagination&version=10.0.0
#tool nuget:?package=Fanbin.EntityFrameworkCore.CursorPagination&version=10.0.0
Fanbin.EntityFrameworkCore.CursorPagination
一个高性能、防篡改的 Entity Framework Core 游标分页库,支持强类型 Lambda 排序与动态字符串排序,并提供 DTO 自动映射。
特性
- 游标分页:基于
WHERE条件实现 O(1) 性能翻页,避免OFFSET带来的性能问题。 - 双向翻页:支持向前/向后翻页,自动生成
NextCursor和PreviousCursor。 - 强类型排序:使用 Lambda 表达式定义排序,编译时类型安全。
- 动态排序:支持前端传递字段名进行动态排序(
SortInfo[])。 - DTO 映射:内置 AutoMapper 支持,直接返回 DTO 分页结果。
- 防篡改游标:基于 HMAC-SHA256 签名,防止客户端篡改游标值。
- 密钥轮换:支持备用密钥,实现无缝密钥更新。
- 多数据库支持:SQL Server、PostgreSQL、SQLite、MySQL。
安装
dotnet add package Fanbin.EntityFrameworkCore.CursorPagination
依赖项
- .NET 8.0 或更高版本
- Microsoft.EntityFrameworkCore
- AutoMapper(如需 DTO 映射)
快速开始
注册服务
在 Program.cs 或 Startup.cs 中添加服务注册:
using Microsoft.EntityFrameworkCore.CursorPagination;
var builder = WebApplication.CreateBuilder(args);
// 1. 注册 DbContext
builder.Services.AddDbContext<AppDbContext>(options =>
options.UseSqlServer("你的数据库连接字符串"));
// 2. 注册 AutoMapper 服务:这是启用分页结果自动转换为 DTO 功能的必要依赖。
builder.Services.AddAutoMapper(typeof(Program));
// 3. 注册游标分页(核心!必须配置 HMAC 密钥)
builder.Services.AddCursorPagination(options =>
{
options.HmacSecretKey = "your-32-byte-or-longer-secret-key-here!";
options.HmacBackupKey = "backup-key-for-rotation"; // 可选,用于密钥轮换
options.DefaultPageSize = 20;
options.MaxPageSize = 200;
options.CursorMaxAge = TimeSpan.FromDays(5); // 游标7天过期
options.PreSortingHandling = PreSortingHandling.Throw; // 遇到预先排序直接抛异常
});
或
services.AddCursorPagination(options =>
{
Configuration.GetSection("CursorPagination").Bind(options);
});
appsettings.json 示例配置:
{
"CursorPagination": {
// ==============================================
// 游标分页核心安全配置
// ==============================================
// HMAC 签名密钥(必填):必须 >=32 字节字符串,用于游标防篡改、验签
// 生产环境禁止硬编码,建议通过环境变量/密钥 vault 注入
// 如果需要密钥轮换,务必同时配置 HmacBackupKey 以避免过渡期内游标失效
// 在密钥轮换期间,旧密钥仍可验证旧游标,直到所有旧游标过期后才完全切换到新密钥
// 轮换步骤:
// 1. 配置新密钥到 HmacSecretKey,旧密钥移到 HmacBackupKey(即:将原来的旧密钥复制到HmacBackupKey)
// 2. 等待所有旧游标过期(根据 CursorMaxAge 设置的值)
// 3. 清除旧密钥(即:将HmacBackupKey设为null)
// 即便不用备份密钥,其实也无需担心,因为旧游标会根据 CursorMaxAge 设置的过期时间自动失效,但使用备用密钥可以实现无缝过渡,避免在密钥轮换期间所有旧游标立即失效导致用户体验问题。
// 不使用备份密钥,即便旧游标失效 也不会有安全问题,因为攻击者无法伪造有效的游标(需要知道密钥)
// 不使用备份密钥,即便旧游标失效,其实也没有多大问题,最多导致用户从中间页突然跳转到第一页,从第一页重新浏览后使用的也是新密钥生成的游标了。
"HmacSecretKey": "Your-32-byte-long-security-key-here",
// 备用 HMAC 密钥(可选):用于密钥平滑轮换,无需可设为 null
"HmacBackupKey": null,
// ==============================================
// 分页大小限制配置
// ==============================================
// 默认每页条数:客户端未指定时使用
"DefaultPageSize": 20,
// 最大允许每页条数:防止恶意大分页耗尽服务器资源
"MaxPageSize": 1000,
// ==============================================
// 排序与空值配置
// ==============================================
// NULL 值排序规则:true=NULL在前, false=NULL在后, null=使用数据库默认
"DefaultNullsFirst": null,
// ==============================================
// 游标安全过期配置
// ==============================================
// 游标最大有效期:超时后游标自动失效,null=永不过期
// 格式:天.小时:分钟:秒 例:"7.00:00:00"=7天 / ""1.00:00:00""=1天 / "01:00:00"=1小时 / "00:30:00"=30分钟 /永不过期:null
"CursorMaxAge": null,
// ==============================================
// 排序冲突处理策略
// ==============================================
// 分页前已存在排序时的处理方式:
// LogOnly=仅记录日志(默认) | Throw=抛异常 | Override=覆盖排序 | Ignore=忽略原有排序
"PreSortingHandling": "LogOnly"
}
}
重要提示:
HmacSecretKey必须是一个长度至少为 32 字节的密钥。请勿将密钥硬编码在代码中,建议使用用户机密或环境变量管理。
重要提示:
关于排序千万不要在分页前对查询进行排序,否则会导致游标生成错误的过滤条件,进而导致分页结果不正确。 分页方法内部会根据你提供的排序规则自动生成正确的游标过滤条件。如果你确实需要在分页前对查询进行排序,请务必将PreSortingHandling配置为IgnoreWithWarning或Throw来避免潜在问题。 所有的排序必须在分页方法的orderBy参数中指定,或者使用动态字符串排序的方式传递,否则会导致游标生成错误的过滤条件,进而导致分页结果不正确。
正确使用案例
1. 使用强类型排序分页(即Lambda排序:推荐)
// 实体版本
var page = await dbContext.Users.Where(u => u.IsActive).ToCursorPageAsync(
p: new CursorPaginationParams { PageSize = 10 },
orderBy: q => q.OrderBy(u => u.CreatedAt).ThenBy(u => u.Id)
);
// 返回结果
Console.WriteLine($"当前页数据量: {page.Items.Count}");
Console.WriteLine($"下一页游标: {page.NextCursor}");
Console.WriteLine($"上一页游标: {page.PreviousCursor}");
Console.WriteLine($"是否有下一页: {page.HasNext}");
Console.WriteLine($"是否有上一页: {page.HasPrevious}");
2. 使用动态字符串排序 (适用于前端传递动态排序字段的场景)
var sorts = new[]
{
new SortInfo { PropertyName = "CreatedAt", IsAscending = false },
new SortInfo { PropertyName = "Id", IsAscending = true }
};
var page = await dbContext.Users.Where(u => u.IsActive).ToCursorPageWithSortAsync(
p: new CursorPaginationParams { PageSize = 10 },
explicitSorts: sorts
);
3. DTO 映射版本
如果你需要返回 DTO 而不是实体类型,请确保已注册 AutoMapper 的 IMapper 服务:
var page = await dbContext.Products.ToCursorPageAsync<Products, ProductsDto>(
p: new CursorPaginationParams { PageSize = 10 },
orderBy: q => q.OrderByDescending(p => p.Price).ThenBy(p => p.CreateTime)
);
// page.Items 的类型是 List<ProductsDto>
4. 前后翻页
// 第一页(无需游标)
var firstPage = await dbContext.Users.ToCursorPageAsync(p: new CursorPaginationParams { PageSize = 10 });
// 下一页(使用上一页返回的 NextCursor)
var nextPage = await dbContext.Users.ToCursorPageAsync(
p: new CursorPaginationParams
{
PageSize = 10,
Cursor = firstPage.NextCursor,
Direction = PageDirection.Next
},
q => q.OrderBy(p => p.CreateTime));
// 上一页(使用上一页返回的 PreviousCursor)
var prevPage = await dbContext.Users.ToCursorPageAsync(
p: new CursorPaginationParams
{
PageSize = 10,
Cursor = nextPage.PreviousCursor,
Direction = PageDirection.Previous
},
q => q.OrderBy(p => p.CreateTime));
5、自定义游标键(使用唯一索引,而非主键)
// 场景:实体有一个唯一索引(如 Email),希望用 Email 作为游标分页的稳定键
// 前提:数据库中 Email 列有唯一约束
var p = new CursorPaginationParams
{
PageSize = 10,
CustomCursorKeys = new[] { "Email" } // 必须保证是主键或唯一索引组合
};
var result = await dbContext.Users.ToCursorPageAsync(
p,
orderBy: q => q.OrderBy(u => u.Email), // 排序字段必须与自定义游标键一致(或包含它)
cancellationToken: cancellationToken);
错误使用案例
1、在调用前手动 OrderBy,然后再游标分页
// ❌ 错误:手动提前排序
var query = db.Products.OrderBy(p => p.Price);
var result = await query.ToCursorPageAsync();
2、使用 Select 投影后再分页(破坏 DbContext 获取)
// ❌ 错误:先投影再分页,无法获取 DbContext
var query = db.Products.Select(p => new { p.Id, p.Name });
var result = await query.ToCursorPageAsync();
3、使用嵌套属性作为游标键(如 Parent.Id)
// ❌ 错误:游标键不支持嵌套属性
var p = new CursorPaginationParams
{
CustomCursorKeys = new[] { "Category.Id" }
};
var result = await db.Products.ToCursorPageAsync(p);
4、同时传 orderBy + explicitSorts,两种排序混用
// ❌ 错误:同时给强类型排序和动态排序
var sorts = new[] { new SortInfo { PropertyName = "Price" } };
var result = await db.Products
.ToCursorPageWithSortAsync(p, sorts)
.ToCursorPageAsync(orderBy: q => q.OrderBy(p => p.CreateTime));
5、定义游标键不是主键 / 唯一索引
// ❌ 错误:Name 不是唯一键
var p = new CursorPaginationParams
{
CustomCursorKeys = new[] { "Name" }
};
var result = await db.Products.ToCursorPageAsync(p);
游标工作原理
- 客户端发送游标值(Base64 编码的 JSON)。
- 服务端验证 HMAC 签名,解码得到排序字段的值。
- 根据翻页方向和排序规则构建
WHERE过滤条件(如(CreatedAt > '2023-01-01') OR (CreatedAt = '2023-01-01' AND Id > 5))。 - 执行查询并返回结果及新游标。
配置选项说明
| 选项 | 类型 | 说明 |
|---|---|---|
HmacSecretKey |
string |
必填,至少 32 字节的密钥,用于游标签名防篡改 |
HmacBackupKey |
string? |
可选,用于密钥轮换期间平滑过渡 |
DefaultPageSize |
int |
默认每页大小,当请求未指定时使用(默认 20) |
MaxPageSize |
int |
允许的最大每页大小,防止恶意请求(默认 1000) |
DefaultNullsFirst |
bool? |
全局 NULL 值排序规则。true = NULL 排在最前,false = NULL 排在最后,null 则按数据库默认行为 |
CursorMaxAge |
TimeSpan? |
游标有效期,超时后游标失效。为空则表示永不过期 |
PreSortingHandling |
enum |
当查询在调用分页前已包含排序时的处理策略:<br>• LogOnly - 仅记录警告日志(默认)<br>• IgnoreWithWarning - 忽略预先排序并记录警告<br>• Throw - 抛出异常阻止执行 |
许可证
MIT License
作者
Fanbin
反馈与贡献
欢迎通过 GitHub Issues 提交问题和建议,也欢迎贡献代码。
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- AutoMapper (>= 16.1.1)
- Microsoft.EntityFrameworkCore (>= 10.0.7)
- Microsoft.Extensions.DependencyInjection (>= 10.0.7)
- Microsoft.Extensions.Logging (>= 10.0.7)
- Microsoft.Extensions.Options (>= 10.0.7)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.