完全指南:用 createCaller 直连过程并做集成测试)
tRPC 服务端调用Server Side Calls完全指南用 createCaller 直连过程并做集成测试【免费下载链接】trpc♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc本指南深入讲解 tRPC 项目中“服务端直接调用过程procedure”的两种官方方案——t.createCallerFactory()与router.createCaller()。你将掌握如何在同一个后端进程中同步调用自己的 router无需经过 HTTP 层从而服务于同进程的定制 API 端点、自动化集成测试、数据填充等场景并了解onError、AbortSignal等调用选项以及底层调用链原理。全文内容以官方文档 server-side-calls.md 为骨架辅以 trpc/server 源码 与 测试用例 进行印证。适用场景与核心原则在 tRPC 应用中通常通过 HTTP adapter如 Next.js、Express、Fastify 等对外暴露/api/trpc端点客户端经由网络发起调用。但存在两类需求无法依赖网络调用同进程服务端调用例如在其他后端路由处理器内部直接复用某个过程如读取帖子、写入日志集成测试无需启动真实 HTTP 服务直接对过程函数做断言速度更快且不依赖网络。这两类需求都可借助createCallerFactory()或router.createCaller()在进程内完成调用。警告不要在过程内部再套一层 createCaller官方文档给出了一条明确的工程红线createCaller不应在另一个 procedure 的内部被使用。原因是这会产生无谓开销——被调用的过程会可能再次创建 context、重新执行全部中间件、并重新校验输入参数而外层过程明明已经完成过这些工作了。正确的做法是把共享逻辑抽成独立函数让多个过程共同调用这个函数而不是互相“套娃”调用。这样既避免重复校验与重复执行中间件也让公共逻辑天然可复用、可单测。这与 tRPC 的中间件机制参见 middlewares.md相互呼应中间件只应存在于一次过程调用的边界上。方案一t.createCallerFactory创建服务端调用器官方推荐的入口是从 initTRPC 实例解构出的t.createCallerFactory。它的使用方式是“两步走”先以目标 router 为参数调用createCallerFactory(router)得到一个工厂函数再向该工厂函数传入Context得到一个类型安全的 caller 对象调用 caller 上各方法即等同于对应 procedure 的执行。基本示例查询与变更以下代码创建一个含post.listquery与post.addmutation两个过程的 router并在进程内直接调用它们完整示例见 server-side-calls.mdimport { initTRPC } from trpc/server; import { z } from zod; type Context { foo: string; }; const t initTRPC.contextContext().create(); const publicProcedure t.procedure; const { createCallerFactory, router } t; interface Post { id: string; title: string; } const posts: Post[] [ { id: 1, title: Hello world, }, ]; const appRouter router({ post: router({ add: publicProcedure .input( z.object({ title: z.string().min(2), }), ) .mutation((opts) { const post: Post { ...opts.input, id: ${Math.random()}, }; posts.push(post); return post; }), list: publicProcedure.query(() posts), }), }); // 1. 为你的 router 创建 caller 工厂函数 const createCaller createCallerFactory(appRouter); // 2. 传入 Context 生成 caller const caller createCaller({ foo: bar, }); // 3. 像调用普通异步函数一样调用过程 const addedPost await caller.post.add({ title: How to make server-side call in tRPC, }); const postList await caller.post.list();注意z.string().min(2)等输入校验更多校验器用法见 validators.md在此同样生效若传入非法输入caller 会同步抛出对应的 tRPC 校验错误与走 HTTP 时的行为一致。底层实现Recursive Proxy 路径解析从源码看caller 之所以能“像嵌套对象一样点出post.add”是因为它并非普通对象而是一个递归代理。在 router.ts 中createCallerFactory返回的函数会调用createRecursiveProxy把每一段属性访问拼成完整路径例如[post, add]→post.add随后通过getProcedureAtPath(router, fullPath)定位目标过程router.ts 中同时处理了懒加载 router 的情况若ctx传入的是函数则先执行取回 context支持异步直接以{ path, getRawInput, ctx, type, signal, batchIndex }调用 procedure 本体未命中过程时抛出code: NOT_FOUND的TRPCError。也就是说服务端调用与 HTTP 请求最终走到的是同一个过程执行函数差别仅在于省去了序列化、传输与反序列化的环节。整个过程依旧完整经过 parser输入校验、middleware中间件链与 resolver保证行为与线上请求一致。集成测试中的典型用法服务端调用最常见的落地场景就是测试。仓库在 packages/tests/server 下保存了大量此类测试例如 caller.test.ts 与 createCaller.test.ts均以“构造内存 context → 生成 caller → 断言返回值”的模式书写。官方文档还给出了一个可直接套用的完整测试范式取自 next-prisma-starter 示例的 router 测试// context.ts测试用的内层 context无请求对象仅注入必要依赖 export async function createContextInner(opts: {}) { return { user: undefined }; } // _app.ts被测 router 与导出型别 import { initTRPC, type inferProcedureInput } from trpc/server; import { z } from zod; import { createContextInner } from ./context; type Context AwaitedReturnTypetypeof createContextInner; const t initTRPC.contextContext().create(); const posts: { id: string; title: string; text: string }[] []; export const appRouter t.router({ post: t.router({ add: t.procedure .input(z.object({ title: z.string(), text: z.string() })) .mutation((opts) { const post { id: ${Math.random()}, ...opts.input }; posts.push(post); return post; }), byId: t.procedure .input(z.object({ id: z.string() })) .query((opts) posts.find((p) p.id opts.input.id)!), }), }); export type AppRouter typeof appRouter; export const createCaller t.createCallerFactory(appRouter); // test.ts测试主体 import { type inferProcedureInput } from trpc/server; import { createContextInner } from ./context; import { type AppRouter, createCaller } from ./_app; async function testAddAndGetPost() { const ctx await createContextInner({}); const caller createCaller(ctx); const input: inferProcedureInputAppRouter[post][add] { text: hello test, title: hello test, }; const post await caller.post.add(input); const byId await caller.post.byId({ id: post.id }); }这个范例里有两个值得重点学习的模式createContextInner把测试环境真正需要的依赖数据库连接、当前用户等与“来自 HTTP 请求的部分”解耦测试时注入假的依赖即可这也是 context.md 中推荐的 context 组织方式inferProcedureInput由过程类型反推出入参类型。当路由增加必填字段时测试代码会立刻出现类型错误从而保证测试与生产类型定义始终同步。类型推断能力的系统介绍见 routers.md。方案二router.createCaller()便捷写法除了通过t.createCallerFactory每个 router 自身也带有一个createCaller方法。调用router.createCaller(ctx)会直接返回一个RouterCaller实例。从源码看router 在创建时内部就是调用createCallerFactory生成的见 router.ts因此两者本质上是同一套机制只是写法上少了“先传 router”这一步。若你的 router 已经处处导出直接使用router.createCaller()更省事。Query 示例带输入import { initTRPC } from trpc/server; import { z } from zod; const t initTRPC.create(); const router t.router({ // 在路径 greeting 上创建过程 greeting: t.procedure .input(z.object({ name: z.string() })) .query((opts) Hello ${opts.input.name}), }); const caller router.createCaller({}); const result await caller.greeting({ name: tRPC }); // result 类型为 stringHello tRPCMutation 示例import { initTRPC } from trpc/server; import { z } from zod; const posts [One, Two, Three]; const t initTRPC.create(); const router t.router({ post: t.router({ add: t.procedure.input(z.string()).mutation((opts) { posts.push(opts.input); return posts; }), }), }); const caller router.createCaller({}); const result await caller.post.add(Four); // result 为 [One, Two, Three, Four]Context 中间件示例鉴权场景由于服务端调用同样会执行中间件链因此它能如实检验“上下文不足时过程应当被拒绝”。下面演示如何用一个protectedProcedure中间件做鉴权详见 authorization.mdimport { initTRPC, TRPCError } from trpc/server; type Context { user?: { id: string; }; }; const t initTRPC.contextContext().create(); const protectedProcedure t.procedure.use((opts) { const { ctx } opts; if (!ctx.user) { throw new TRPCError({ code: UNAUTHORIZED, message: You are not authorized, }); } return opts.next({ ctx: { // 类型上收紧此处之后 user 必为非空 user: ctx.user, }, }); }); const router t.router({ secret: protectedProcedure.query((opts) opts.ctx.user), }); // ❌ 未提供 user 的 context执行时抛 UNAUTHORIZED 错误 const caller router.createCaller({}); const result await caller.secret(); // throws // ✅ 提供符合要求的 context正常返回 ctx.user const authorizedCaller router.createCaller({ user: { id: KATT, }, }); const result await authorizedCaller.secret();注意与 HTTP 请求不同服务端调用不会在抛错时帮你做“返回 401 响应”之类的事——错误会直接抛出由调用方决定如何处理。中间件在过程之前执行这一点也决定了上述示例中未授权调用会抛错而不会进入 resolver。进阶场景在自定义 Next.js API 端点中调用过程tRPC 已经为 router 生成了对应的 API 端点但某些自定义端点如 Webhook、sitemap、独立 REST 路由仍需要主动调用某个过程并处理其错误。官方文档给出了一个 Next.js Pages Router 下的完整范式。读取单个帖子的过程定义在独立模块中并在自定义端点的 handler 中被调用// 文件server/routers/_app.ts import { initTRPC } from trpc/server; import { z } from zod; const t initTRPC.create(); export const appRouter t.router({ post: t.router({ byId: t.procedure .input(z.object({ id: z.string() })) .query((opts) { return { id: opts.input.id, title: Example Post }; }), }), });// 文件pages/api/post.ts —— 自定义端点中使用服务端调用 import { TRPCError } from trpc/server; import { getHTTPStatusCodeFromError } from trpc/server/http; import { appRouter } from ../../server/routers/_app; import type { NextApiRequest, NextApiResponse } from next; type ResponseData { data?: { postTitle: string; }; error?: { message: string; }; }; export default async ( req: NextApiRequest, res: NextApiResponseResponseData, ) { /** 故意选择一个数据库中不存在的帖子 ID用于模拟出错场景 */ const postId this-id-does-not-exist-${Math.random()}; const caller appRouter.createCaller({}); try { // 服务端调用 const postResult await caller.post.byId({ id: postId }); res.status(200).json({ data: { postTitle: postResult.title } }); } catch (cause) { // 若这是 tRPC 错误可提取额外信息 if (cause instanceof TRPCError) { // 由 tRPC 错误映射出 HTTP 状态码例如 NOT_FOUND 对应 404 const httpStatusCode getHTTPStatusCodeFromError(cause); res.status(httpStatusCode).json({ error: { message: cause.message } }); return; } // 非 tRPC 错误无特定信息统一返回 500 res.status(500).json({ error: { message: Error while accessing post with ID ${postId} }, }); } };这里的要点是getHTTPStatusCodeFromError(cause)它把 tRPC 错误码翻译成 HTTP 状态码例如NOT_FOUND→404、UNAUTHORIZED→401避免手写一长串switch。tRPC 在内部对所有错误包括 resolver 中抛出的普通Error统一包装为TRPCError这正是cause instanceof TRPCError判定的基础。错误格式化与错误码的系统介绍参见 error-handling.md 与 error-formatting.md。为服务端调用注入onError错误处理器createCallerFactory与createCaller均可通过第二个参数的onError选项接收错误处理器用于在抛出前响应/记录错误、处理未包装成TRPCError的异常。若在createCallerFactory与createCaller两处都传入了 handler工厂层的 handler 会先于调用层 handler 执行。处理器收到的参数与错误格式化器error formatter的参数一致唯一的区别是不包含shape字段。其类型签名在文档中定义为interface OnErrorShape { ctx: unknown; error: TRPCError; path: string | undefined; input: unknown; type: query | mutation | subscription | unknown; }源码侧提供了更精确的定义RouterCallerErrorHandlerTContext接受一个ErrorHandlerOptionsTContext对象其字段为error、type、path、input、ctx见 router.ts 与 procedure.ts。实际实现中caller 捕获到异常后会先调用opts?.onError?.(...)再把原始错误重新抛出router.ts所以onError不会吞掉错误仅作为观察/记录钩子。onError 使用示例import { initTRPC } from trpc/server; import { z } from zod; const t initTRPC .context{ foo?: bar; }() .create(); const router t.router({ greeting: t.procedure.input(z.object({ name: z.string() })).query((opts) { if (opts.input.name invalid) { throw new Error(Invalid name); } return Hello ${opts.input.name}; }), }); const caller router.createCaller( { /* context */ }, { onError: (opts) { console.error(An error occurred:, opts.error); }, }, ); // 以下调用会先打印 An error occurred: Error: Invalid name随后把错误抛出 await caller.greeting({ name: invalid });对测试而言这非常适合做“记录未预期的服务端错误后让测试失败”的统一收口。createCallerFactory 与 router.createCaller 对比小结维度t.createCallerFactory(appRouter)appRouter.createCaller(ctx)传参先传 router再在返回的工厂上传 context一次调用同时传 context 与可选 optionsContext 形态对象或返回 context 的函数可为异步同左返回实例RouterCaller递归代理同左内部即由 factory 生成适用场景router 定义与调用方分离、需复用工厂router 已导出且就近调用调用选项第二个参数支持{ onError, signal }同左两点补充提示若以函数形式传入 context建议传入带缓存的函数例如包装在React.cache中避免每次过程调用都重新计算 context该注释也直接写在RouterCaller类型的 JSDoc 中router.tssignal选项支持传入AbortSignal可将上层请求的中断信号透传给过程内部如流式/订阅类调用源码在 router.ts 处将其传入 procedure 调用选项。实践注意事项命名限制router 的过程名不能是保留字。createProxy.ts的递归代理机制要求then、call、apply等词不可用作 router/过程名否则会破坏 Promise/函数语义见 router.ts设计嵌套过程结构时应避开这些名称。服务端调用 ≠ 跳过校验输入校验、中间件、错误包装都会照常执行这是服务端调用能用于测试的原因也是“不要在过程内套过程”会浪费开销的原因。Context 的职责边界测试或同进程调用通常使用精简的createContextInner需要完整请求对象cookie、header 等时再补充真实依赖。若对两者的取舍有疑问参考 context.md 关于createContext/createContextInner拆分实践的部分。懒加载 routergetProcedureAtPath会先加载懒加载的 router 再查找过程因此服务端调用同样适用于拆分出的懒加载 router相关实现见 router.ts。服务端调用是把 tRPC 的类型安全边界延伸回“后端的后端”的最直接工具。无论是为自定义端点复用业务逻辑还是在无 HTTP 的条件下做高速集成测试掌握createCallerFactory/router.createCaller及其onError、context 注入与类型推断模式都能让同一套 router 定义在进程内外保持一致的行为与完整的类型保障。更多用法可继续参考示例工程如 examples/next-prisma-starter中的测试目录以及本仓库测试套件 packages/tests/server/createCaller.test.ts。【免费下载链接】trpc♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考