尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

基于 tRPC 的服务化架构:用自定义路由 Link 将一个后端拆分为多服务部署

基于 tRPC 的服务化架构:用自定义路由 Link 将一个后端拆分为多服务部署 基于 tRPC 的服务化架构用自定义路由 Link 将一个后端拆分为多服务部署【免费下载链接】trpc‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc导读本文以 tRPC v11 官方示例 examples/soaSOAService-Oriented Architecture为骨架讲解如何将一个庞大的 tRPC 后端拆分为多个独立进程运行的服务通过一个仅做类型拼接、从不真正运行的伪网关faux gateway以及客户端一条以过程路径第一段作为路由键的自定义结束型 Link让调用方用一份统一的AppRouter类型、一个createTRPCClient客户端即可透明地访问分布在多个 URL 上的后端服务。读完本文你将掌握多服务共享单一initTRPC实例的server-lib工程组织方式、每个服务独立挂载 standalone HTTP 服务器的方法、类型安全客户端 自定义 Link 的实现细节以及多服务拆分中最容易踩的路径路由误区。前置说明本主题对应的技能文档为 service-oriented-architecture SKILL示例工程见 examples/soa。该示例在 README 中明确标注除非你真的需要否则不推荐使用——SOA 拆分会带来显著的运维与协作成本在动手前请先评估必要性。适用边界什么时候才需要拆服务examples/soa 的 README 用一句话点明了 SOA 方案的定位Not recommendedunless you really need it; if youre not sure you need it, you definitely dont need it.即在没有强烈理由时不要拆服务。同时它也划清了该方案当前的硬性前提All routers currently need to have the same Context, error formatters, etc.也就是说参与拆分的所有服务其 procedure 的 Context 类型、错误格式化器error formatter、以及中间件语义必须保持一致——这正是下面server-lib存在的意义它不是一个业务包而是胶水代码glue code用于保证每个服务看到的是同一个被初始化过的 tRPC 根实例。架构总览四个角色示例工程将一次后端拆分落到 4 个职责清晰的目录对应关系见 examples/soa目录角色是否真的运行server-lib共享库唯一initTRPC实例否被引用server-a独立后端服务 A独立 Node 进程是监听 2021server-b独立后端服务 B独立 Node 进程是监听 2022faux-gateway类型网关只拼类型不跑进程否client类型安全客户端 自定义路由 Link否数据流是单向的客户端持有faux-gateway导出的AppRouter类型当调用client.serverA.greet.query(...)时自定义 Link 截获该操作把过程路径serverA.greet按.切分取第一段serverA决定目标服务 URL再把剩余路径greet作为真正的调用路径转发给http://localhost:2021。第一步搭建共享库 server-lib单一 initTRPC 实例SOA 方案要求所有服务共享同一份 Context 类型与格式化配置。SKILL 给出的做法是在server-lib中创建唯一的tRPC 根实例然后导出router、publicProcedure与mergeRouters各服务只从该包导入绝不各自initTRPC// server-lib/index.ts import { initTRPC } from trpc/server; type Context { requestId?: string; }; const t initTRPC.contextContext().create(); export const router t.router; export const publicProcedure t.procedure; export const mergeRouters t.mergeRouters;仓库中真实的 server-lib/index.ts 与此结构一致——它通过initTRPC.contextContext().create()建立实例Context 类型为{ foo?: bar }并导出同样的三个成员。这段代码的价值在于Context的类型签名、未来的errorFormatter、中间件等全部只在这里定义一次从类型系统层面杜绝了服务 A 的 Context 与服务 B 不一致这类 SOA 拆分中最隐蔽的错误。注意这里导出mergeRouters是为网关等场景预留而单个服务的路由定义下文的serviceARouter不依赖它。第二步独立服务 A / B各跑各的 standalone 服务器每个服务由两部分组成一个路由定义文件与一个服务入口文件。路由定义只关心本服务的 procedure服务 A 定义greet查询使用 zod 校验入参SKILL 教学版// services/service-a/router.ts import { publicProcedure, router } from myorg/server-lib; import { z } from zod; export const serviceARouter router({ greet: publicProcedure .input(z.object({ name: z.string() })) .query(({ input }) ({ greeting: Hello, ${input.name}! })), });服务 B 则定义无入参的status查询// services/service-b/router.ts import { publicProcedure, router } from myorg/server-lib; export const serviceBRouter router({ status: publicProcedure.query(() ({ status: ok })), });与 SKILL 的 zod 版略有差异server-a/router.ts 与 server-b/router.ts 在示例仓库里用的是最简校验器前者手写了一个要求入参为string的 guard后者直接返回bar as const教学目的与 SKILL 一致——每个服务文件内只存在本服务的路由。服务入口用 standalone 适配器起 HTTP 服务服务 A 监听 2021服务 B 监听 2022// services/service-a/index.ts import { createHTTPServer } from trpc/server/adapters/standalone; import { serviceARouter } from ./router; createHTTPServer({ router: serviceARouter, createContext() { return {}; }, }).listen(2021);// services/service-b/index.ts import { createHTTPServer } from trpc/server/adapters/standalone; import { serviceBRouter } from ./router; createHTTPServer({ router: serviceBRouter, createContext() { return {}; }, }).listen(2022);真实实现见 server-a/index.ts监听 2021与 server-b/index.ts监听 2022两文件均在启动后打印监听日志。这里用的是trpc/server/adapters/standalone提供的createHTTPServer无需 Express 等框架即可独立起服同理也可换成 Express / Fastify 适配器参见 www/docs/server/adapters-intro.md。值得强调的两点每个服务的createContext返回的对象是独立的但类型必须与server-lib中声明的一致若想在服务端拿到requestId之类上下文只需在createContext中从请求头读取x-request-id后按 Context 类型返回即可。第三步faux-gateway——只为类型而生不跑任何进程网关文件是 SOA 方案中最反直觉的部分它把两个服务路由合并成一个appRouter但其目的仅仅是类型推断注释明确指出它从不作为服务进程运行// gateway/index.ts import { router } from myorg/server-lib; import { serviceARouter } from ../services/service-a/router; import { serviceBRouter } from ../services/service-b/router; const appRouter router({ serviceA: serviceARouter, serviceB: serviceBRouter, }); export type AppRouter typeof appRouter;仓库对应实现是 faux-gateway/index.ts命名中的 faux法语假正是点睛之笔它把serverA_appRouter与serverB_appRouter挂到serverA/serverB命名空间下仅向外界导出AppRouter类型。你可能会问为什么不让网关真正跑起来统一转发因为那样会引入中心化的性能瓶颈与单点故障且转发层必须重复处理批处理、序列化等协议细节。SOA 方案选择把路由决策下沉到客户端客户端拿到AppRouter后在类型上看到的是serviceA.greet、serviceB.status这种整体 API而真正的网络分发由自定义 Link 完成——这也正是示例client初始化时导入的是类型import type { AppRouter }而非值的原因。第四步客户端自定义路由 Link本方案核心基础版按路径首段分发// client/client.ts import { createTRPCClient, httpBatchLink } from trpc/client; import type { AppRouter } from ../gateway; export const client createTRPCClientAppRouter({ links: [ (runtime) { const servers { serviceA: httpBatchLink({ url: http://localhost:2021 })(runtime), serviceB: httpBatchLink({ url: http://localhost:2022 })(runtime), }; return (ctx) { const { op } ctx; const pathParts op.path.split(.); const serverName pathParts.shift() as keyof typeof servers; const path pathParts.join(.); const link servers[serverName]; if (!link) { throw new Error( Unknown service: ${String(serverName)}. Known: ${Object.keys(servers).join(, )}, ); } return link({ ...ctx, op: { ...op, path }, }); }; }, ], });调用方式与普通但更大的单后端完全一致因为类型已由AppRouter提供// Usage const greeting await client.serviceA.greet.query({ name: World }); const status await client.serviceB.status.query();仓库中的 client/client.ts 完全复刻了这一结构外部工厂函数拿到runtime后一次性把每个目标服务的httpBatchLink({ url })(runtime)实例化好放进servers字典返回的内层函数针对每次操作取出op.path进行切分与重写。理解这段代码需要抓住两个 Link 概念类型定义见 packages/client/src/links/types.tsLink 是两层函数TRPCLink (opts: TRPCClientRuntime) OperationLink而OperationLink ({op, next}) Observable。所以外层(runtime) ...是一次性的初始化适合在这里构建httpBatchLink内层函数才处理单个op。它是一条结束型 Link本方案把分发 Link 放在links数组的最后一位内层函数不调用next把操作传下去而是直接调用目标服务的httpBatchLink结束链路——因此它本质上是一个路由型终结链接自身不发起网络请求真正的请求由被选中的httpBatchLink发出。操作路径的改写是本例最核心的技巧tRPC 客户端中每个操作的op.path是形如serviceA.greet的点分字符串它同时承担批处理分组与 HTTP URL 构造的职责。把pathParts.shift()出的第一段当作服务名消费掉再把剩余部分拼回作为发给目标服务的真实路径greet从而让远端服务 A 只感知到自己的 procedure 路径无需知道上层还有网关概念。深入为什么按路径首段分发能保持类型安全client.serviceA.greet.query(...)之所以能在编译期校验入参与返回值是因为AppRouter的类型树为{ serviceA: { greet: ... }, serviceB: { status: ... } }而运行时客户端把serviceA.greet编码进op.path。这条自定义 Link 恰好把类型树第一层翻译成了网络路由第一段二者一一对应因此类型安全与动态路由得以共存。若某团队把 procedure 名或服务挂载命名空间改掉而不同步更新本 Link就会进入下文常见误区描述的静默失效场景。进阶为所有服务注入共享请求头拆分后常见需求是链路追踪希望发往每个服务的请求都带上同一个x-request-id。由于路由在客户端完成只需在每个httpBatchLink的headers()回调里统一生成即可无需额外中间层(runtime) { const servers { serviceA: httpBatchLink({ url: http://localhost:2021, headers() { return { x-request-id: crypto.randomUUID() }; }, })(runtime), serviceB: httpBatchLink({ url: http://localhost:2022, headers() { return { x-request-id: crypto.randomUUID() }; }, })(runtime), }; return (ctx) { const [serverName, ...rest] ctx.op.path.split(.); return serversserverName as keyof typeof servers }, }); }; };结合前文server-lib中Context { requestId?: string }的设计一条完整的请求链路就闭环了客户端为每个请求生成x-request-id→ 服务 A/B 在createContext中读取该请求头 → 各服务日志与错误上报统一携带requestId便于跨进程排障。这与 www/docs/client/headers.md 中在 Link 层声明请求头的思路一致。运行与验证方式在仓库根目录pnpm workspace下进入 examples/soa其 package.json 提供了现成的脚本命令作用pnpm buildtsc编译全部 TSpnpm start:server-atsx server-a服务 A 监听 2021pnpm start:server-btsx server-b服务 B 监听 2022pnpm start:clienttsx client运行客户端调用脚本pnpm test-devstart-server-and-test依次拉起 2021、2022 后再跑客户端start同client/index.ts 展示了端到端调用结果await client.serverA.greet.query(tRPC)得到{ greeting: hello, tRPC }await client.serverB.foo.query()得到{ result: bar }。若两个服务都未就绪自定义 Link 的报错未知服务名或httpBatchLink的连接失败会立即暴露便于定位。常见误区路径路由假设第一段就是服务名SKILL 将该误区评级为MEDIUM。因为自定义 Link 完全依赖op.path第一段的约定一旦路由结构变化如引入嵌套命名空间、改名服务挂载键链路会静默失效或路由到错误服务。典型的错误写法——只取首段却不校验、不报错const serverName op.path.split(.).shift(); // Breaks if router structure changes or has nested namespaces推荐的正确写法——解构取出首段与剩余部分、对未知服务名抛清晰错误const [serverName, ...rest] op.path.split(.); const link servers[serverName as keyof typeof servers]; if (!link) { throw new Error(Unknown service: ${serverName}. Known: ${Object.keys(servers).join(, )}); } return link({ ...ctx, op: { ...op, path: rest.join(.) } });SKILL 特别强调第一段命名约定必须被文档化并在团队内强制执行服务名未命中时给出明确错误错误信息中带上已知服务名列表如示例中的Known: ${Object.keys(servers).join(, )}才能把配置漂移从奇怪的运行时错误变成一次性可定位的异常。该方案的核心设计取舍小结类型与运行时解耦faux-gateway 只贡献AppRouter类型网络分发完全交给客户端自定义 Link网关零运行成本、零单点共享根实例是关键约束server-lib用单一initTRPC实例强制所有服务使用一致的 Context 类型、校验与格式化配置批处理仍然有效每个服务对应的httpBatchLink独立负责其批处理聚合同一服务的多个调用仍会被合批参考 www/docs/client/links 目录下关于 batch link 的文档代价不容忽视路由约定需跨团队维护、每新增一个服务都要同步修改客户端servers字典与网关类型树因此示例本身也声明仅在有强需求如独立扩缩容、团队自治、合规边界时采用。继续深入学习可对照技能文档的关联主题server-setup SKILL单一initTRPC.create()实例的完整理由、adapter-standalone SKILL各服务独立服务器的启动细节、merging-routers 文档网关合并路由的类型语义以及客户端一侧的 links 文档 与 client-setup SKILL。【免费下载链接】trpc‍♀️ Move Fast and Break Nothing. End-to-end typesafe APIs made easy.项目地址: https://gitcode.com/GitHub_Trending/tr/trpc创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表