tRPC(v10)服务端 Procedure 完整指南:理解 Query / Mutation 与可复用 Base Procedure 的不可变构建模式
本文基于当前仓库中 tRPC 10.x 版本文档 www/versioned_docs/version-10.x/server/procedures.md,讲解 tRPC 服务端最核心的抽象——Procedure(过程)。你将学会:一个 Procedure 的三种形态(Query、Mutation、Subscription)分别在什么场景使用;如何使用
t.procedure编写第一个接口;以及如何通过"不可变构建器"模式抽出publicProcedure、authorizedProcedure这类可复用的 Base Procedure,让鉴权、日志、限流等公共逻辑只写一次、处处复用。文章同时结合仓库内@trpc/server的真实实现源码,帮助你从"会写"进阶到"懂原理"。
一、什么是 Procedure:暴露给客户端的"最小函数单元"
在 tRPC 中,Procedure 是一个暴露给客户端调用的函数,它可以是以下三种类型之一:
| 类型 | 用途 | 典型场景 |
|---|---|---|
| Query | 获取数据,一般不改变任何数据 | 查询列表、读取详情、拉取配置 |
| Mutation | 提交数据,通常用于 create / update / delete | 写数据库、发消息、变更状态 |
| Subscription | 订阅持续推送的数据流 | 实时通知、聊天消息、进度推送 |
在 10.x 中,Subscription 的使用方式与 WebSocket 服务端深度绑定,你很可能并不需要它——官方为此准备了专门的文档(见下文"四"节的链接与示例)。
从设计哲学上看,Procedure 是 tRPC 提供的"非常灵活的、用来创建后端函数的基础构件"。它的关键特性是使用不可变(immutable)构建器模式:每调用一次 .input()、.use()、.query() 等链式方法,都会产生一个新的 Procedure 定义,而不会修改已有对象。正是这一点,让你可以放心地创建"携带公共能力"的 Base Procedure,再由多个具体 Procedure 在其之上叠加自己的逻辑。
二、编写第一个 Procedure:从 initTRPC 到 t.procedure
在你完成 tRPC 初始化(initTRPC)后,初始化对象会返回一个初始的 t.procedure,所有其它 Procedure 都以它为起点被构建出来。下面的例子完整演示了它的用法(原文示例):
import { initTRPC } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<{ signGuestBook: () => Promise<void> }>().create();
export const router = t.router;
export const publicProcedure = t.procedure;
const appRouter = router({
// Queries are the best place to fetch data
hello: publicProcedure.query(() => {
return {
message: 'hello world',
};
}),
// Mutations are the best place to do things like updating a database
goodbye: publicProcedure.mutation(async (opts) => {
await opts.ctx.signGuestBook();
return {
message: 'goodbye!',
};
}),
});
拆解这个例子,可以得到 tRPC 服务端最基础、也最常用的四个动作:
initTRPC初始化:通过链式调用.context<...>().create()生成根对象t。其中.context<...>()只是把 context 的类型形状作为泛型注入,用于让编辑器与 TypeScript 在后续所有 procedure 中推导出opts.ctx的正确类型。若你不关心ctx,直接initTRPC.create()即可(仓库中的 examples/standalone-server/src/server.ts 展示了带createContext的真实用法)。- 导出两个基础件:
export const router = t.router与export const publicProcedure = t.procedure是 tRPC 官方推荐的惯例——把router与"最宽松的 Procedure 起点"暴露出去,之后所有带约束的 Procedure 都由publicProcedure派生。 - 用
router({...})组装接口:一个对象里放多个 Procedure,对象键(hello、goodbye)就是暴露给客户端的路径。 - 用结尾方法结束构建:
.query(resolver)、.mutation(resolver)、.subscription(resolver)是构建器链的三个"终结符",传入的resolver才是真正执行的业务函数,它接收一个opts对象——例如opts.ctx能取到本次请求的上下文(数据库连接、当前登录用户等)。
从源码看,initTRPC 初始化完成后,t.procedure 正是在 packages/server/src/unstable-core-do-not-import/initTRPC.ts 中通过 createBuilder(...) 创建的空构建器;而 router、middleware 等则由同一 create() 一并产出。
2.1 为什么是"不可变构建器"
你可能会好奇:链式调用 .input() / .use() 到底做了什么?查看实现 packages/server/src/unstable-core-do-not-import/procedureBuilder.ts 可以看到核心逻辑:
- 每次调用都会把当前构建器的
def(定义)与新的配置合并出一个全新定义; - 新增的 input 校验器会追加到
inputs数组尾部; - 新增的中间件会追加到
middlewares数组尾部; meta会被浅合并;- 最终基于新定义调用
createBuilder生成新构建器返回。
也就是说,链上任何一步都不会就地改动旧的构建器,旧对象依然可用、可继续派生,这正是"Base Procedure 可被多个 Procedure 安全共享"的根基。当链式调用走到 .query() / .mutation() / .subscription() 时,内部会把 resolver 包装成最后一个中间件,随后执行"逐层调用"的逻辑(见下文 3.2)。
三、可复用的 Base Procedure:把公共逻辑沉淀为命名构建器
直接在所有接口上重复书写鉴权、日志代码显然不可维护。tRPC 给出的通用模式是:
将
t.procedure重命名并导出为publicProcedure,以此为"最底层起点",再为特定场景派生出其它带名字的 Procedure 并一并导出。这个模式被称为 Base Procedure(基础过程),是 tRPC 中实现代码与行为复用的关键模式——几乎每个应用都会用到它。
下面的示例对用户输入做"授权检查"(作者自嘲:这像村民在保护自己的小镇),它演示了 .input() 与 .use() 的叠加用法:
import { initTRPC, TRPCError } from '@trpc/server';
import { z } from 'zod';
const t = initTRPC.context<{ signGuestBook: () => Promise<void> }>().create();
export const publicProcedure = t.procedure;
// ---cut---
export const authorizedProcedure = publicProcedure
.input(z.object({ townName: z.string() }))
.use((opts) => {
if (opts.input.townName !== 'Pucklechurch') {
throw new TRPCError({
code: 'FORBIDDEN',
message: "We don't take kindly to out-of-town folk",
});
}
return opts.next();
});
export const appRouter = t.router({
hello: authorizedProcedure.query(() => {
return {
message: 'hello world',
};
}),
goodbye: authorizedProcedure.mutation(async (opts) => {
await opts.ctx.signGuestBook();
return {
message: 'goodbye!',
};
}),
});
这个例子的要点有三个:
- 输入校验先于业务执行:
.input(z.object({ townName: z.string() }))在中间件运行前先解析并校验客户端输入。校验失败时 tRPC 会自动抛出解析错误;校验通过后,.use()中间件里就能以类型安全的方式读取opts.input.townName。 - 中间件决定放行还是拦截:
.use((opts) => {...})中若不满足条件就抛出TRPCError(此处使用FORBIDDEN),请求链随即终止,业务 resolver 不会执行;满足条件则调用return opts.next()把控制权交给链上的下一个中间件(最终是 resolver)。 - 派生即复用:
authorizedProcedure派生自publicProcedure,而hello与goodbye又都派生自authorizedProcedure,于是"检查townName"的逻辑被两个接口共享。未来如需新增受保护接口,只需在 router 里继续使用authorizedProcedure即可。
3.1 请把示例当"语法演示",别当"安全方案"
文档特别强调:上面这个 contrived 例子只是为了简化展示语法,并不是在真实应用中做用户授权的恰当方式。要正确地对真实用户做"认证 + 授权",实践中应组合使用以下机制(均为仓库内 v10 文档):
- 通过 HTTP Headers 读取认证凭证;
- 在 Context 中装载会话/用户信息;
- 在 Middleware 中做统一的鉴权判定;
- 用 Metadata 为不同 Procedure 声明所需权限。
另外,当授权失败时抛出的 TRPCError 会携带可枚举的错误码(如 FORBIDDEN),如何把错误映射为客户端可读的结构,可参考 v10 的 错误处理文档。
3.2 源码视角:中间件链是怎么串起来的
为了不把中间件当"黑盒",可以看 @trpc/server 内部的两个实现细节(当前仓库 packages/server 源码,其架构与 10.x 文档描述一致):
- 构建期:
createBuilder暴露的.input()、.use()、.query()等方法都定义在 procedureBuilder.ts 中。.input(schema)会把解析器包装成"输入中间件",.use(fn)会把中间件函数追加到middlewares数组,.query()/.mutation()则把 resolver 同样包装成一个"解析中间件"并标记type: 'query' | 'mutation'。 - 运行期:执行时走 callRecursive——它从下标 0 开始递归取出
middlewares[index]并执行,中间件调用opts.next()即触发下标+1的下一层,直到最后一个(resolver)执行完毕。因此,如果某个中间件忘记return opts.next(),调用链会中断;运行时若最终没有任何结果,还会抛出带有 "did you forget toreturn next()?" 提示的INTERNAL_SERVER_ERROR(见 procedureBuilder.ts)。中间件返回结构(ok: true/false+data/error)可在 middleware.ts 中看到。
四、Query、Mutation 与 Subscription:如何选择与它们能做什么
回到最根本的三分法,官方在代码注释里给出的指引是:
- Query——取数据的最佳场所:语义上不产生副作用,客户端也会据此推断缓存与刷新策略(例如 tRPC 10.x 客户端把 query 当作可缓存读取)。
- Mutation——做"写操作"的最佳场所:如更新数据库、发送表单、触发事件;10.x 客户端会把它映射为写请求并做相应的错误处理。
- Subscription——当且仅当你需要"服务端主动推送"时。文档明确写道"你可能并不需要它",其完整实现涉及 WebSocket 传输层与可观察流(observable),因此 tRPC 为其准备了专门的订阅 / WebSockets 文档。
订阅的典型形态(节选自 v10 订阅文档与仓库示例)是:在 subscription() resolver 中返回一个 observable,通过 emit.next(data) 向客户端推送数据,并返回清理函数用于退订时释放事件监听:
import { observable } from '@trpc/server/observable';
import { EventEmitter } from 'events';
const ee = new EventEmitter(); // 可用 Redis 等替代
export const appRouter = t.router({
onAdd: t.procedure.subscription(() => {
return observable<Post>((emit) => {
const onAdd = (data: Post) => emit.next(data);
ee.on('add', onAdd);
// 客户端断开 / 停止订阅时的清理
return () => {
ee.off('add', onAdd);
};
});
}),
});
完整可运行的最小化示例位于 examples/standalone-server/src/server.ts——它同时把 query(hello)、mutation(createPost)、subscription(randomNumber)放进同一 router,并演示了如何把子 router 合并进 appRouter(greeting、post 两个命名空间)后同时挂载 HTTP 与 WebSocket 服务。需要理解 Query / Mutation / Subscription 三者对"服务端调用方式"影响的读者,可以继续阅读 v10 的 RPC 概念文档。
五、深入 resolver:opts 里到底有什么
写业务函数时,你会拿到一个 opts 参数。tRPC 内部为 resolver 定义了明确的选项结构 ProcedureResolverOptions(见 procedureBuilder.ts),最常用到的字段包括:
| 字段 | 含义 |
|---|---|
ctx |
本次请求的上下文对象,由服务端 createContext 在每个请求时创建 |
input |
经过 .input() 解析校验后的输入;若未声明 input 则为 undefined |
signal |
请求的 AbortSignal,可用于支持请求取消(如配合 fetch) |
path |
该 procedure 在 router 中的路径字符串 |
batchIndex |
当请求被批处理(batch)调用时,标识该调用在批次中的序号 |
理解 ctx 的作用非常重要:tRPC 的服务端上下文贯穿所有中间件与 resolver,是承载"当前用户、数据库句柄、请求级日志"的标准通道。想深入掌握它的定义与创建时机,请阅读 v10 的 Context 文档;若需要为不同类型 Procedure 声明不同的输入约束,可参考 Validators 文档 与 Merging Routers 文档。
六、本文小结:一套可以在所有项目里复用的骨架
把上面所有要点收拢,一个"干净"的 tRPC 服务端 Procedure 层通常长这样:
- 初始化一次
initTRPC(全后端只此一次); - 导出
router与publicProcedure两个基础件; - 需要公共能力时,从
publicProcedure派生命名构建器(authorizedProcedure、protectedProcedure等)——这就是 Base Procedure 模式; - 在
router({...})中,根据语义用.query()/.mutation()/.subscription()收尾成具体接口; - 真正面向用户时,把认证与授权放在 Headers → Context → Middleware → Metadata 的组合链路里完成,而不是写死在单个 Procedure 中。
Procedure 是理解 tRPC 全链路(router、中间件、上下文、订阅乃至客户端类型推导)的钥匙。当你掌握了本文的不可变构建器心智模型后,再去看 routers、middlewares 与 subscriptions 的进阶文档,就会非常顺畅。
atomcodeClaude Code 的开源替代方案。连接任意大模型,编辑代码,运行命令,自动验证 — 全自动执行。用 Rust 构建,极致性能。 | An open-source alternative to Claude Code. Connect any LLM, edit code, run commands, and verify changes — autonomously. Built in Rust for speed. Get StartedRust0631
MiniCPM5-2BMiniCPM5-2B 是一款面向端侧、本地部署和资源受限场景的 2B 稠密 Transformer,能够达到同尺寸开源模型 SOTA 水平。Markdown00
GLM-5.3GLM-5.3 与 GLM-5.2 使用相同的基座模型——所有提升均来自后训练。与 GLM-5.2 相比,它在复杂编程和长程任务上的表现显著提升。Jinja00
HivisionIDPhotos⚡️HivisionIDPhotos: a lightweight and efficient AI ID photos tools. 一个轻量级的AI证件照制作算法。Python09
DragonOSDragonOS is an operating system developed from scratch using Rust, with Linux compatibility. It is designed for **Serverless** scenarios. 使用Rust从0自研内核,具有Linux兼容性的操作系统,面向云计算Serverless场景而设计。Rust00
Spark-X2.5-1.7BSpark-X2.5-1.7B 旨在让强大的 AI 更加实用、高效且易于获取。这些模型在广泛的日常任务中表现出色,涵盖对话、写作、翻译、推理、编程、工具调用和智能体工作流,并在同等规模的开源模型中取得领先结果。Spark-X2.5 将面向效率的架构与最高 1M tokens 的原生上下文窗口相结合,并支持 200 多种语言。Python00