首页
/ tRPC(v10)服务端 Procedure 完整指南:理解 Query / Mutation 与可复用 Base Procedure 的不可变构建模式

tRPC(v10)服务端 Procedure 完整指南:理解 Query / Mutation 与可复用 Base Procedure 的不可变构建模式

2026-09-08 16:45:02作者:齐冠琰

本文基于当前仓库中 tRPC 10.x 版本文档 www/versioned_docs/version-10.x/server/procedures.md,讲解 tRPC 服务端最核心的抽象——Procedure(过程)。你将学会:一个 Procedure 的三种形态(Query、Mutation、Subscription)分别在什么场景使用;如何使用 t.procedure 编写第一个接口;以及如何通过"不可变构建器"模式抽出 publicProcedureauthorizedProcedure 这类可复用的 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:从 initTRPCt.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 服务端最基础、也最常用的四个动作:

  1. initTRPC 初始化:通过链式调用 .context<...>().create() 生成根对象 t。其中 .context<...>() 只是把 context 的类型形状作为泛型注入,用于让编辑器与 TypeScript 在后续所有 procedure 中推导出 opts.ctx 的正确类型。若你不关心 ctx,直接 initTRPC.create() 即可(仓库中的 examples/standalone-server/src/server.ts 展示了带 createContext 的真实用法)。
  2. 导出两个基础件export const router = t.routerexport const publicProcedure = t.procedure 是 tRPC 官方推荐的惯例——把 router 与"最宽松的 Procedure 起点"暴露出去,之后所有带约束的 Procedure 都由 publicProcedure 派生。
  3. router({...}) 组装接口:一个对象里放多个 Procedure,对象键(hellogoodbye)就是暴露给客户端的路径。
  4. 用结尾方法结束构建.query(resolver).mutation(resolver).subscription(resolver) 是构建器链的三个"终结符",传入的 resolver 才是真正执行的业务函数,它接收一个 opts 对象——例如 opts.ctx 能取到本次请求的上下文(数据库连接、当前登录用户等)。

从源码看,initTRPC 初始化完成后,t.procedure 正是在 packages/server/src/unstable-core-do-not-import/initTRPC.ts 中通过 createBuilder(...) 创建的空构建器;而 routermiddleware 等则由同一 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!',
    };
  }),
});

这个例子的要点有三个:

  1. 输入校验先于业务执行.input(z.object({ townName: z.string() })) 在中间件运行前先解析并校验客户端输入。校验失败时 tRPC 会自动抛出解析错误;校验通过后,.use() 中间件里就能以类型安全的方式读取 opts.input.townName
  2. 中间件决定放行还是拦截.use((opts) => {...}) 中若不满足条件就抛出 TRPCError(此处使用 FORBIDDEN),请求链随即终止,业务 resolver 不会执行;满足条件则调用 return opts.next() 把控制权交给链上的下一个中间件(最终是 resolver)。
  3. 派生即复用authorizedProcedure 派生自 publicProcedure,而 hellogoodbye 又都派生自 authorizedProcedure,于是"检查 townName"的逻辑被两个接口共享。未来如需新增受保护接口,只需在 router 里继续使用 authorizedProcedure 即可。

3.1 请把示例当"语法演示",别当"安全方案"

文档特别强调:上面这个 contrived 例子只是为了简化展示语法,并不是在真实应用中做用户授权的恰当方式。要正确地对真实用户做"认证 + 授权",实践中应组合使用以下机制(均为仓库内 v10 文档):

另外,当授权失败时抛出的 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 to return 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 合并进 appRoutergreetingpost 两个命名空间)后同时挂载 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 层通常长这样:

  1. 初始化一次 initTRPC(全后端只此一次);
  2. 导出 routerpublicProcedure 两个基础件;
  3. 需要公共能力时,从 publicProcedure 派生命名构建器(authorizedProcedureprotectedProcedure 等)——这就是 Base Procedure 模式;
  4. router({...}) 中,根据语义用 .query() / .mutation() / .subscription() 收尾成具体接口;
  5. 真正面向用户时,把认证与授权放在 Headers → Context → Middleware → Metadata 的组合链路里完成,而不是写死在单个 Procedure 中。

Procedure 是理解 tRPC 全链路(router、中间件、上下文、订阅乃至客户端类型推导)的钥匙。当你掌握了本文的不可变构建器心智模型后,再去看 routersmiddlewaressubscriptions 的进阶文档,就会非常顺畅。

登录后查看全文
热门项目推荐
相关项目推荐

项目优选

收起
kernelkernel
deepin linux kernel
C
33
18
ops-transformerops-transformer
本项目是CANN提供的transformer类大模型算子库,实现网络在NPU上加速计算。
C++
1.14 K
2.75 K
pytorchpytorch
作为 Ascend for PyTorch 社区的核心组件,TorchNPU 是昇腾专为 PyTorch 打造的深度学习适配插件,使 PyTorch 框架能够直接调用昇腾 NPU,为开发者提供昇腾 AI 处理器的超强算力。
Python
857
1.35 K
docsdocs
暂无描述
Markdown
899
5.82 K
ops-nnops-nn
本项目是CANN提供的神经网络类计算算子库,实现网络在NPU上加速计算。
C++
920
1.85 K
jiuwenswarmjiuwenswarm
JiuwenSwarm 是一款基于openJiuwen开发的智能AI Agent,它能够将大语言模型的强大能力,通过你日常使用的各类通讯应用,直接延伸至你的指尖。
Python
3.8 K
1.02 K
kernelkernel
openEuler内核是openEuler操作系统的核心,既是系统性能与稳定性的基石,也是连接处理器、设备与服务的桥梁。
C
532
596
cann-learning-hubcann-learning-hub
CANN 学习中心仓,支持在线互动运行、边学边练,提供教程、示例与优化方案,一站式助力昇腾开发者快速上手。
Jupyter Notebook
1.02 K
521
ops-mathops-math
本项目是CANN提供的数学类基础计算算子库,实现网络在NPU上加速计算。
C++
1.36 K
1.46 K
AscendNPU-IRAscendNPU-IR
AscendNPU-IR是基于MLIR(Multi-Level Intermediate Representation)构建的,面向昇腾亲和算子编译时使用的中间表示,提供昇腾完备表达能力,通过编译优化提升昇腾AI处理器计算效率,支持通过生态框架使能昇腾AI处理器与深度调优
C++
548
392