☰
ABP框架分页完整指南:从DTO参数到服务端实现与前端对接
2026/10/17 11:23:17 网站建设 项目流程

做后台管理系统的时候,最容易被忽略又绕不开的一件事,就是把列表数据一页一页地展示给用户。ABP 框架里其实已经把分页相关的类型和约定都准备好了,但很多刚入门的同学看到PagedResultRequestDto、PagedAndSortedResultRequestDto、PagedResultDto这一串名字时,第一反应往往不是直接上手用,而是先怀疑自己是不是找错了模块。这篇博客是这个入门系列的第 7 篇,我把 ABP 里分页从参数定义、服务端实现到前端对接的完整链路拆开讲一遍,并结合我实际写业务时踩过的坑,说明白每一步为什么要这样做。适合正在学 ABP、或者已经能跑通基本增删改查但想把列表页做规范的同学参考。

1. 分页到底在解决什么问题:从用户界面到数据库的全链路压力

1.1 用户侧:一次渲染几千条数据是反人类的

很多人在刚写后台的时候都经历过这个阶段:查询列表直接返回全部数据,前端拿到之后用表格渲染,感觉也没什么问题。等到数据量从几百条涨到几千条,问题就来了——页面滚动明显变卡,搜索框输入文字时表格跟着一顿一顿,排序操作要等好几秒浏览器才反应过来。这其实不是电脑性能不行,而是 DOM 元素太多,浏览器在布局和重绘上消耗了大量时间。一次性渲染 5000 行表格,每一个单元格都是真实的节点,即便只是做一次全表刷新,浏览器也要重新计算布局。

正确的做法是让用户只看当前这一页。后台管理场景下,一页展示 10 条、20 条或者 50 条是比较常见的,用户有需要再通过页码、上一页下一页去切换。这样前端每次拿到的数据量小,渲染速度自然快,交互也能保持流畅。分页首先是为了用户体验,这是最直观的动机。

1.2 数据库侧:全表查询才是最贵的操作

在很多简单的演示项目里,列表接口全表返回,数据量不大时确实看不出问题。但真实业务里,数据表上了十万行之后,每一次全表查询都在浪费数据库的 IO、内存和网络带宽。尤其是后台系统经常要同时服务好几个管理员,每个人都打开同一个大列表页面,等于把同一张大表反复从磁盘读出来、序列化、通过网络传给浏览器。数据库的连接池很快会被占满,其他正常业务也跟着变慢。

分页能够把每次查询的数据量控制在一个固定范围内,在数据库侧最重要的事情不是“少查了几条”,而是减少了单次查询触达的数据页数和网络传输量。配合适当的索引,数据库可以用更短的时间定位到需要的那一页数据。当然,分页并不能解决所有性能问题,如果查询条件本身没有索引支撑,随便怎么分页,数据库都还是得扫描一大片数据。但分页至少保证了“无论列表多大,单次响应的大小是有限的”。

1.3 ABP 在分页这件事上的约定

ABP 最典型的特征就是“约定优先”。它不会要求你每次写列表接口的时候重新发明一套分页参数,而是直接提供了一组基类和扩展方法,让你在几分钟内把分页接好。这套约定主要包含三个部分:输入参数用PagedResultRequestDto或它的派生类,服务端用PageBy扩展方法完成 Skip/Take,输出统一包装成PagedResultDto<T>。这些类型之间的关系,用一句话概括:输入告诉服务端要跳多少条、拿多少条,服务端执行查询并单独统计总数,输出把当前页数据和总数打包交给前端。

理解了这个协议,后面所有代码都只是在往这个框架里填充具体业务逻辑。所以这一节先把“为什么要分页”这个底层逻辑理清,接下来就可以放心地动手写代码了。

2. 从 DTO 开始:分页入参到底该继承哪一个类

2.1 先搞清楚两个基类的区别

ABP 里最常见的两个分页入参基类是PagedResultRequestDto和PagedAndSortedResultRequestDto。前者只包含两个属性:SkipCount表示跳过的条数,MaxResultCount表示本页最大返回条数。这其实就是最经典的 Skip/Take 模型,对应 SQL 里的OFFSET和FETCH。

后者在继承前者的基础上增加了Sorting字符串属性,用来接收排序表达式。为什么要单独设计一个带排序的基类?因为后台列表几乎不可能不需要排序,用户点一下表头就要按某个字段升序或降序排列,如果把排序字段写死在业务类里,每个接口都要重复定义一模一样的东西。ABP 把这种高频属性抽到基类里,让你用的时候只需要继承一个类,少写一堆重复代码。

实际开发中,我建议直接继承PagedAndSortedResultRequestDto,哪怕这个接口暂时不需要排序。原因很简单:需求总是会变的,等列表做到一半突然要加排序,你还要回头改 DTO 继承关系,虽然不难,但能避免的麻烦尽量一开始就避免。

2.2 自定义请求 DTO 时要注意什么

假设我们现在要做用户管理列表,输入对象大概长这样:

public class GetUserListInput : PagedAndSortedResultRequestDto { public string Filter { get; set; } }

先不急着写查询逻辑,只看这个类,你已经可以看出 ABP 的设计哲学:把跟分页无关的业务条件作为附加属性加到继承类上,分页参数则统一从基类拿。Filter这里用来接收前端传过来的搜索关键字,可以匹配用户名、邮箱之类的字段。

这里有一个很多人会忽略的点:不要把分页参数定义成自己的同名属性。比如有的同学不知道基类里已经有SkipCount,就在子类里又写了一个,结果前端传参数时基类属性有值、子类属性也有值,服务端读取的时候又用错了,导致分页效果时好时坏。继承之后先看一下基类有哪些属性再用,能省去很多低级问题。

2.3 MaxResultCount 默认值的那些事

PagedResultRequestDto的MaxResultCount在不同版本里有不同的默认值,常见的规则是 10 或者 20,并且通常会配合一个上限校验:如果前端传入的页码大小超过了允许的最大值,就直接按最大值处理。这背后是一个很实际的需求——防止有人一次请求拿几万条数据,把服务端压垮。

初学的时候,很多人会因为“我只传了 SkipCount 没传 MaxResultCount”而拿到的数据比预期少,觉得是框架出了问题。记住一个原则:在前端调用接口时,显式传maxResultCount参数,不要依赖默认值。尤其是在写集成测试的时候,测试请求里最好也明确传上maxResultCount和skipCount,这样测试结果才是可预期、可复现的。

3. 服务端核心逻辑:Skip/Take 与 TotalCount 的正确姿势

3.1 一个最小可用的分页服务方法

有了输入 DTO,接下来是应用服务里真正干活的部分。下面这个例子是典型的分页查询方法:

public async Task<PagedResultDto<UserListDto>> GetUsersAsync(GetUserListInput input) { var query = _userRepository.GetAll() .WhereIf(!string.IsNullOrWhiteSpace(input.Filter), x => x.UserName.Contains(input.Filter) || x.Email.Contains(input.Filter)); var totalCount = await query.CountAsync(); var users = await query .OrderBy(input.Sorting ?? "UserName") .Skip(input.SkipCount) .Take(input.MaxResultCount) .ToListAsync(); var userDtos = ObjectMapper.Map<List<UserListDto>>(users); return new PagedResultDto<UserListDto>(totalCount, userDtos); }

这段代码虽然短,但包含了分页在服务端的全部关键步骤:先得到IQueryable,套过滤条件,统计总数,再排序和分页,最后映射成 DTO 返回。这里我故意让CountAsync和ToListAsync分别执行,因为它们的执行语义完全不同,先聊清楚这一点,后面才不会踩坑。

3.2 PageBy 扩展方法内部做了什么

ABP 提供了PageBy扩展方法,可以把 Skip 和 Take 两步合并成一个方法调用。它的简化实现大致是:

public static IQueryable<T> PageBy<T>(this IQueryable<T> query, IPagedResultRequest request) { return query .Skip(request.SkipCount) .Take(request.MaxResultCount); }

有些同学可能觉得“既然只是包了一层,那我每次手写 Skip/Take 不就行了”。确实可以,但我更推荐用PageBy。原因不只是少写两行代码,而是它让你的意图更清晰——代码阅读者一眼就知道这里是在应用分页规则。而且 ABP 内部很多公共逻辑和扩展方法都是围绕IPagedResultRequest这个接口写的,后续如果你需要做统一的分页拦截处理,用这个扩展方法的位置会更好识别。

需要注意的是,PageBy的工作是建立在查询已经确定的基础上的。也就是说,它默认你在调用它之前已经把过滤和排序都处理好了。一旦你把PageBy放到前面,后面又追加了Where条件,结果是过滤条件会作用在已经分页完的数据上,造成很隐蔽的数据错误。

3.3 TotalCount 必须单独查询,这是很多人会犯的错

刚接触 LINQ 的同学很容易写出下面这种代码:

var users = await query.ToListAsync(); var totalCount = users.Count; var pageUsers = users.Skip(input.SkipCount).Take(input.MaxResultCount).ToList();

这段代码的问题在于:它先把满足条件的全部数据都加载到了内存里,然后再“人工分页”。数据量小的时候看不出什么,一旦数据量上来,性能立刻崩坏,等于把一个分页接口写成了全量查询接口。数据库负担没有减轻,分页的意义也就失去了。

正确做法是先对IQueryable执行CountAsync()统计总数,再执行ToListAsync()取出当前页的数据。由于IQueryable的延迟执行特性,这两步其实对应数据库里的两个 SQL 查询:一个是SELECT COUNT(*) ...,一个是SELECT ... OFFSET x ROWS FETCH NEXT y ROWS ONLY。两次查询会略有性能开销,但换来的代价是数据传输量可控,这在绝大多数业务场景里是划算的。

还有一点提醒:不要在统计总数之后,又用同一个IQueryable对象继续查询时不小心把它改变了。比如有人在CountAsync和ToListAsync之间给同一个查询变量加了排序条件,这没问题;但如果有人在两次操作之间修改了共享变量里的过滤值,就可能出现总数和当前页数据对不上的情况。写代码时尽量让统计和分页读取使用同一个查询变量,减少状态不一致的可能。

4. 给分页加上排序和过滤:分页不只是“切一刀”

4.1 Sorting 字符串到底怎么用

PagedAndSortedResultRequestDto里的Sorting是一个字符串,常见格式比如"UserName DESC"、"CreationTime ASC",甚至可以是多个字段用逗号连接。服务端拿到这个字符串之后,可以直接用动态 LINQ 之类的库来执行排序:

query = query.OrderBy(input.Sorting);

如果没有传入排序字符串,上面例子中的?? "UserName"就会兜底用默认字段排序。我给分页查询一个经验原则:永远要有一个确定的默认排序字段。如果查询结果顺序不稳定,跳页就会出现重复数据或者漏数据。比如用户按 “下一页” 之后,数据库返回结果顺序变了,你可能会看到上一页已经出现过的记录,或者发现某条记录消失了。ABP 的默认Sorting本身是可空的,所以服务端必须自己兜底。

在 ABP 里你还可以用IOrderedQueryable和多个.ThenBy来处理二级排序。比如先按创建时间倒序,再按用户名升序,这能让翻页时数据顺序更稳定。建议给列表页加一个稳定的主键排序兜底,例如主键 ID,这样即使业务字段完全相同,顺序也不会漂移。

4.2 过滤条件必须放在分页之前

这里值得单独强调一下执行顺序。正确顺序是:过滤 - 排序 - 分页。过滤必须在最前面,因为你要先确定“总数据集”到底包含哪些记录,然后才能统计总数、取出某一页。

比如你要搜索用户名包含 “admin” 的用户:

var query = _userRepository.GetAll() .WhereIf(!string.IsNullOrWhiteSpace(input.Filter), x => x.UserName.Contains(input.Filter)); var totalCount = await query.CountAsync(); var pageData = await query .OrderBy(input.Sorting ?? "UserName") .Skip(input.SkipCount) .Take(input.MaxResultCount) .ToListAsync();

如果换成下面这种写法:

var pageData = query .Skip(input.SkipCount) .Take(input.MaxResultCount); var filtered = pageData.Where(x => x.UserName.Contains(input.Filter));

那么统计总数和过滤都会作用在已经被截断的数据上。前端第一页显示可能是对的,但到了第二页、第三页,结果完全不是用户想要的。这种错误在代码审查里其实很容易漏掉,因为第一眼看上去它只是多写了几行,实际却是地道的逻辑错误。

4.3 动态过滤时要注意 EF 的可翻译性

WhereIf是 ABP 提供的简化扩展方法,本质上等价于 “如果条件成立,就追加一个 Where”。用起来很方便,但有一个隐藏问题:当你把复杂的判断条件写进 Lambda 表达式时,要确保 EF Core 能把它翻译成 SQL。比如有些写法和本地方法调用、属性访问可能会让 EF Core 报错说无法翻译,这时候就需要先ToListAsync再在内存里过滤,但这会破坏分页的意义。

所以,动态过滤的核心经验是:尽量让Where里的表达式保持简单,使用数据库能直接处理的比较和字符串方法;如果确实需要用自定义规则,要考虑在数据库端先做初步过滤,再把少量结果集拿到内存里做补充处理。后台系统的列表页一般不会有特别变态的搜索逻辑,大多数情况下Contains、StartsWith、Equals已经够用。

5. 返回给前端:PagedResultDto 是如何跟表格组件对接的

5.1 为什么标准包装是 Items + TotalCount

ABP 的PagedResultDto<T>里主要有两个属性:Items是当前页的数据列表,TotalCount是满足过滤条件的总记录数。这个看似简单的设计,实际上解决了前后端协作时最核心的信息不对称:前端既需要当前页的数据,也需要知道一共有多少条,才能渲染正确的分页按钮。

有些新手在刚开始写接口时会只返回一个List<T>,前端拿不到总数,为了显示页码还得额外调用一个统计接口,或者干脆在前端把所有数据都加载一遍。相比之下,PagedResultDto把“当前页数据”和“总数”打包,一次请求就能拿到所有需要的信息,更符合主流表格组件的使用习惯。

5.2 前端怎么消费这个结构

用一段简单的 JavaScript 示例说明。假设你已经通过 ABP 的标准 HTTP API 暴露了这个分页接口,前端请求时会带上skipCount和maxResultCount:

async function loadUsers(pageIndex, pageSize) { const response = await fetch( `/api/app/user?skipCount=${pageIndex * pageSize}&maxResultCount=${pageSize}` ); const result = await response.json(); // result 结构 // { totalCount: 103, items: [{ id: 1, userName: "admin", ... }] } renderTable(result.items); renderPager(result.totalCount, pageSize); }

需要说明的是,ABP 默认生成的 API 会把输入 DTO 的属性展开成查询字符串参数,所以SkipCount和MaxResultCount是分开传的。前端在切换页码时,核心动作就是重新计算skipCount并重新请求。这里很容易出现的一个问题是:前端把页码和页大小直接写成了page和pageSize,而后端期望的却是skipCount和maxResultCount,导致请求参数对应不上,接口默认值生效,拿到的数据内容和预期不一致。

5.3 表格组件配合时的注意事项

如果你用的表格组件支持服务端分页,通常会有两个回调参数对应“当前页码”和“每页条数”。你需要在请求前把它们换算成skipCount和maxResultCount。我的习惯是在前端封装一个通用的请求函数,专门负责这种转换,避免每个页面都重复计算。

另外,拿到result.items之后不要再在前端做二次分页。我见过一些项目把整个result.items塞进表格组件,然后还把表格组件的本地分页功能也开了,结果一页明明只有 20 条数据,前端又切成 10 条一页,翻页逻辑直接混乱。后端已经分好页再交给前端,前端就不要再“帮忙”了。

还有一点跟用户体验相关:如果接口比较慢,翻页时最好给表格一个 loading 状态,不然用户连续点击页码,请求顺序错乱,会出现“我先点了第 5 页,结果第 3 页的请求后返回,把第 5 页的数据覆盖掉”的尴尬场景。这是接口对接之外的工程问题,但实际开发中很常见,值得注意。

6. 踩坑记录:这些坑我都试过,说下排查思路

6.1 MaxResultCount 校验导致集成测试突然拿不到数据

有段时间我在写集成测试的时候,总是发现某个列表接口只返回很少的数据,一开始以为是数据库种子数据没配好。排查了半天才意识到,ABP 对MaxResultCount是有默认上限的,测试请求里没有显式传maxResultCount,于是被默认值截断了。解决办法很简单:在测试请求中显式传入maxResultCount,比如 100。这里真正的教训是:当你发现接口返回的数据量“不对”时,先确认请求参数里分页参数传对了没有,再怀疑业务逻辑和数据库数据。分页参数是最容易被忽略的“隐形截断器”。

后来我在项目里做了一个专门的测试基类方法,统一构造分页请求,把所有需要用到的测试用例里的MaxResultCount都固定传一个足够大的值,避免每个测试都踩一遍同样的坑。

6.2 TotalCount 在延迟执行里的“重复计算”问题

还有一个坑和IQueryable的延迟执行有关。如果你在服务方法里把query作为参数传给一个辅助方法,辅助方法内部执行了CountAsync之后又执行了ToListAsync,而这两次操作之间可能触发了很多地方共用同一个IQueryable实例,最终在调试时看到整整四五个 SQL 查询。这不是框架的问题,而是你对IQueryable的复用情况没有控制好。

IQueryable并不是一个已经执行过的结果集,而是一棵“查询表达式树”,每次真正取值都有可能重新生成 SQL。所以在服务端写分页时,我心里始终绷着一根弦:同一个IQueryable变量,预期的取值次数应当是可控的。默认情况是两次:一次 Count,一次数据。如果不小心写出了循环里反复取值,或者把IQueryable传到其他方法里又取了一次值,要么重新整理代码结构,要么用ToList把结果固化下来,避免出现多次查询互相干扰。

6.3 大偏移量分页越来越慢:Skip 不是银弹

前面说的都是常规分页。当表数据量增长到几十万甚至百万级,并且用户习惯翻到很后面的页码时,Skip的偏移量会越来越大,数据库为了定位到第skipCount行,需要扫描并丢弃前面很多行,速度会明显下降。原因是 SQL 里的OFFSET本身要做大量行计数工作。

这种情况我通常建议改用 Keyset 分页,也就是游标分页。基本思路是:不依赖页码和偏移,而是通过“上一页最后一条记录的某个唯一字段值”来定位下一页的起始位置。比如按照 ID 分页,下一页就是查ID > lastId的前 N 条。这样的查询在ID主键索引的支持下,无论翻到多深,性能都能保持稳定。

不过 ABP 默认提供的PageBy并没有直接封装 Keyset 分页,它是标准的 Offset 分页。我个人的经验是:业务列表规模没有到很明显出现性能问题时,先不要为了“未来可能很大”去过度设计。等真的遇到深度翻页慢的问题,再单独写一个支持游标参数的接口也不迟。过度优化同样会带来不必要的复杂度。

另外,如果发现分页查询本身很慢,不要先怀疑 Skip 不行,先看过滤条件有没有索引、排序字段有没有索引、有没有在查询里不小心触发了全表扫描。很多时候,一个where条件缺了索引,比分页方式本身的影响大得多。

最后再分享一个日常习惯:写分页功能时,我会专门准备一份数据量不同的测试数据,分别验证第一页、中间页、最后一页、超出最后一页这几种情况。因为分页逻辑的 bug 往往不会在第一页暴露,却会在“刚好跨过边界”的时候突然出现。把这些边界情况查一遍,能心安不少。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询