TypeSpec 标准库分页模式完全指南:从@list到客户端/服务端驱动分页
【免费下载链接】typespec项目地址: https://gitcode.com/GitHub_Trending/ty/typespec
TypeSpec 编译器内置了一整套分页(Pagination)模式的原生支持,通过@list、@pageItems、@offset、@nextLink、@continuationToken等装饰器即可声明"列表类"操作,无需为每种分页风格手写重复的模型。本文以 pagination.md 为骨架,结合编译器源码与测试用例,系统讲解客户端驱动、服务端驱动以及二者组合的分页建模方法,并揭示这些装饰器在编译器内部如何被校验、收集与暴露给下游发射器。
分页基础:@list与@pageItems
在 TypeSpec 中启用分页的第一步是两步组合:用@list装饰操作(Operation),并让操作返回类型中至少包含一个被@pageItems装饰的属性。@pageItems指向的必须是数组类型,它表示"本页返回的元素集合"。
@list op listPets(): { @pageItems pets: Pet[]; };在 decorators.tsp 中可以看到这两个装饰器的正式声明:
extern dec list(target: Operation):把操作标记为返回分页列表的 list 操作;extern dec pageItems(target: ModelProperty):指明包含分页元素的数组属性。
源码层面的约定更加严格。在 paging.ts 中,pageItemsDecorator的校验逻辑会检查目标属性类型是否确实为数组(isArrayModelType),否则报decorator-wrong-target诊断;而getPagingOperation(paging.ts)会强制要求返回类型中存在@pageItems属性,缺失时产生missing-paging-items错误。对应测试见 paging.test.ts。
从编译器视角看,@list只是"开启分页语义检查"的开关:validatePagingOperations会遍历程序中的每个操作,仅对标记了@list的操作调用validatePagingOperation做深度校验(paging.ts)。
客户端驱动分页:偏移量、页大小与页索引
客户端驱动(Client driven pagination)模式下,分页状态由客户端自行维护,客户端负责计算"下一页、上一页"应该请求什么参数。TypeSpec 提供 3 个装饰器标注操作参数:
| 装饰器 | 含义 | 类型要求 |
|---|---|---|
@pageSize | 每页返回的条目数量 | 数值类型(numeric) |
@offset | 跳过的条目数量 | 数值类型(numeric) |
@pageIndex | 页索引 | 数值类型(numeric) |
@offset与@pageIndex并非互斥,但用途相同——都是告诉服务器"从哪一页/第几条开始取"。两者的区别在于偏移语义:@offset基于"条数跳过",@pageIndex基于"页序号"。从源码看,这三个装饰器都由createMarkerDecorator结合createNumericValidation生成(paging.ts),createNumericValidation会通过isNumericType强制校验属性类型必须是数值,例如int32、int64、float64等。
示例 1:固定页大小 + 偏移量
@list op listPets(@offset skip?: int32 = 0): { @pageItems pets: Pet[]; };skip默认值为0,即默认从第一条开始。
示例 2:自定义页大小 + 偏移量
@list op listPets(@offset skip?: int32, @pageSize perPage?: int32 = 100): { @pageItems pets: Pet[]; };perPage默认100,客户端可通过传入不同值覆盖默认页大小。
示例 3:自定义页大小 + 页索引
@list op listPets(@pageIndex page?: int32 = 1, @pageSize perPage?: int32 = 100): { @pageItems pets: Pet[]; };与@offset的"跳过条数"不同,这里的page表示从第 1 页开始逐页翻取。
服务端驱动分页:链接与续传令牌
服务端驱动(Server driven pagination)模式下,服务器在响应中返回导航信息,客户端无需自行推算页码,只需跟随服务器给出的指引。TypeSpec 提供 5 个装饰器标注响应属性:
| 装饰器 | 含义 | 典型位置 |
|---|---|---|
@nextLink | 指向下一页的链接 | 响应体 |
@prevLink | 指向上一页的链接 | 响应体 |
@firstLink | 指向第一页的链接 | 响应体 |
@lastLink | 指向最后一页的链接 | 响应体 |
@continuationToken | 续传令牌:必须同时标注在请求参数和响应属性上 | 请求参数 + 响应体 |
关于@continuationToken有一个容易忽略的约束:它必须成对出现。请求参数上的@continuationToken标记"用哪个参数传递下一个令牌",响应中的@continuationToken标记"哪个属性携带服务器签发的下一个令牌"。该要求在 decorators.tsp 的注释中明确说明:"It MUST be specified both on the request parameter and the response."。
注:服务端驱动分页完全可以叠加在客户端驱动分页之上——例如既有
@pageIndex/@pageSize参数,又返回@nextLink/@prevLink链接。
示例 1:HTTP 服务使用续传令牌
@list op listPets(@query @continuationToken token?: string): { @pageItems pets: Pet[]; @continuationToken nextToken?: string; };此例中,服务器响应的 body 内有一个nextToken属性作为续传令牌。客户端取下一页时,把令牌作为 query 参数token传给下一个请求。
续传令牌也可以放在其他位置,例如 HTTP 响应头。下面的 spec 表明服务器可在响应头返回续传令牌,客户端请求下一页时仍通过 query 参数传递:
@list op listPets(@query @continuationToken token?: string): { @pageItems pets: Pet[]; @continuationToken @header nextToken?: string; };测试用例还证实:@continuationToken的属性类型可以放宽为可空(string | null)、可选(token?: string)、可空可选(token?: string | null)甚至非字符串类型(int32),编译器均不报错(paging.test.ts)。
示例 2:HTTP 服务使用链接
@list op listPets(): { @pageItems pets: Pet[]; links: { @nextLink next?: url; @prevLink prev?: url; @firstLink first?: url; @lastLink last?: url; }; };注意:对于 HTTP 服务,
@nextLink(以及@prevLink、@firstLink、@lastLink)默认应通过 GET 请求跟随。链接 URI 应被视作不透明 URL,其中已包含导航到对应页面所需的全部信息。
示例 3:客户端 + 服务端驱动组合(HTTP)
@list op listPets(@query @pageIndex page?: int32 = 1, @query @pageSize perPage?: int32 = 100): { @pageItems pets: Pet[]; // 链接中会解析出携带 page 与 perPage 的完整 URL links: { @nextLink next?: url; @prevLink prev?: url; @firstLink first?: url; @lastLink last?: url; }; };此处注释表明:链接返回的 URL 是"用当前page和perPage解析后的结果",即服务器在生成链接时会把分页参数编码进 URL。
附加参数的分页语义:哪些会带到下一页
分页操作可以携带与分页控制无关的附加参数(如过滤参数filter)。默认期望是:这些参数会被延续到下一页请求,唯一例外是链接(next/prev/first/last)场景——不同协议对"链接究竟代表什么"可能有不同解释。
对于 HTTP 链接:链接被期望为不透明的,已包含下一页 URL 所需的全部信息(query、path 参数应已编码进链接);而header 参数无法被编码进链接,因此客户端在跟随链接的后续请求中必须原样重发这些 header。
场景 1:HTTP 中的 next link 分页
@route("pets") @list op listPets( @query filter?: string, @query expand?: string, @query @pageIndex page?: int32 = 1, @query @pageSize perPage?: int32 = 100, @header specialHeader?: "x-special-value", ): { @pageItems pets: Pet[]; @nextLink next?: url; };对应的两次请求交互如下:
// 第一次请求 GET /pets?filter=dog Special-Header: x-special-value {"pets": [...], "nextLink": "/pets?filter=dog&page=2&perPage=100"} --- // 第二次请求 GET /pets?filter=dog&page=2&perPage=100 Special-Header: x-special-value {"pets": [...], "nextLink": "/pets?filter=dog&page=3&perPage=100"}可以看到:query 参数filter、page、perPage均被编码进nextLink;而specialHeader无法进入链接,因此客户端在第二次请求中显式重发该 header。
场景 2:HTTP 中的续传令牌分页
@route("pets") @list op listPets( @query filter?: string, @query expand?: string, @query @continuationToken token?: string, @header specialHeader?: "x-special-value", ): { @pageItems pets: Pet[]; @continuationToken next?: url; };// 第一次请求 GET /pets?filter=dog Special-Header: x-special-value {"pets": [...], "continuationToken": "token2"} --- // 第二次请求 GET /pets?filter=dog&token=token2 Special-Header: x-special-value {"pets": [...], "continuationToken": "token3"}这里filter由客户端自行延续(因为令牌模式没有服务器生成的链接),token携带服务器下发的令牌,specialHeader依旧被原样重发。
编译器内部:分页属性的收集与校验机制
理解这些装饰器如何工作,需要看编译器核心实现 paging.ts。所有分页装饰器(共 9 个)都由createMarkerDecorator工厂函数生成(paging.ts),本质是基于useStateSet的状态标记:装饰器执行时把目标属性/操作"打标",后续通过isList、isOffsetProperty、isNextLink等断言函数查询。
收集阶段由getPagingOperation完成,它把分页属性分为两组(paging.ts):
- input:
offset、pageIndex、pageSize、continuationToken(来自操作参数); - output:
pageItems、nextLink、prevLink、firstLink、lastLink、continuationToken(来自返回类型)。
关键行为:
- 嵌套属性收集:
navigateProperties会递归遍历模型、联合类型及基类(baseModel)的所有属性,因此分页属性可以嵌套在返回模型的任意层级中,路径由PagingProperty.path(ModelProperty[])记录,发射器可用path.map(p => p.name).join(".")还原属性路径(paging.ts)。测试见 paging.test.ts。 - 重复属性报错:同一操作内相同类型的分页标记(如两个
@nextLink)会触发duplicate-paging-prop诊断(paging.test.ts)。 - 冲突标记报错:同一属性被多个不同类型的分页装饰器同时标注(如
@nextLink @prevLink next: string)会触发incompatible-paging-props诊断(paging.test.ts)。 - 递归模型安全:
navigateProperties通过visited集合防止自引用模型(如selfRef?: MyPage)导致死循环(paging.test.ts)。 - 缺失
@pageItems报错:getPagingOperation在输出中没有pageItems时返回undefined并报告missing-paging-items。
这些能力通过公开 APIgetPagingOperation(program, op)暴露给下游发射器,返回PagingOperation结构化数据(输入/输出两侧的分页属性及其路径),是发射器生成 OpenAPI、客户端代码或文档时的主要数据来源。
与生态的衔接:OpenAPI3 转换与既有规范
分页装饰器并非孤立存在。在 openapi3 的转换工具 中可以看到:当把已有 OpenAPI 规范转换回 TypeSpec 时,x-ms-list-page-items扩展会被映射为@pageItems,x-ms-list-continuation-token扩展会被映射为@continuationToken,x-ms-list-next-link扩展映射为@nextLink。这意味着分页元数据可以在 OpenAPI 与 TypeSpec 之间往返转换,保证语义不丢失。
小结
TypeSpec 的分页模型用一套统一装饰器覆盖了两大主流分页范式:
- 客户端驱动:
@offset/@pageIndex+@pageSize,由客户端推算请求参数; - 服务端驱动:
@nextLink/@prevLink/@firstLink/@lastLink(不透明链接,HTTP 下默认 GET 跟随)与@continuationToken(请求参数与响应成对出现),由服务器下发导航信息; - 混合模式:两者可自由组合;附加参数默认延续到下一页,链接中已编码 query/path 参数,header 参数需客户端重发。
所有装饰器在编译器端经过严格的类型校验(数值类型、数组类型、重复与冲突检测、缺失@pageItems检测)与结构化收集,最终通过getPagingOperation供发射器消费。想要在自定义发射器中读取分页信息,直接调用getPagingOperation(program, op)即可获得完整的输入/输出分页属性清单。
【免费下载链接】typespec项目地址: https://gitcode.com/GitHub_Trending/ty/typespec
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考