如何在tRPC中合理使用上下文?多路由DRY实现方案咨询
问题描述
我有一个基于tRPC的基础API,包含两组端点:
- 用于查询和修改User属性的端点,需要
username参数 - 用于查询和修改Post属性的端点,需要
post ID参数
暂不考虑认证,我尝试为每组路由设置独立Context,但合并路由时出现上下文报错,tRPC文档也未提及该场景的实现方法。用中间件的话,每个查询/ mutation都要重复定义{ input: z.string() }或{ input: z.number() },不符合DRY原则。请问最优解决方案是什么?
尝试的代码示例:
// post.ts export async function createContext( opts?: trpcExpress.CreateExpressContextOptions ) { // pass through post id, throw if not present } type Context = trpc.inferAsyncReturnType<typeof createContext>; const router = trpc .router() .query("get", { resolve(req) { // get post from database return post; }, }); // similar thing in user.ts // server.ts const trpcRouter = trpc .router() .merge("post.", postRouter) .merge("user.", userRouter); app.use( "/trpc", trpcExpress.createExpressMiddleware({ router: trpcRouter, createContext, }) );
最优解决方案
采用路由级中间件+复用输入Schema的方式,既能实现路由专属的上下文注入,又能避免重复定义输入验证逻辑,完全符合DRY原则。
步骤1:定义通用输入Schema
把User和Post的基础输入规则抽离成可复用的Zod Schema:
// schemas.ts import { z } from "zod"; // Post路由的基础输入(必传postId) export const postBaseInput = z.object({ postId: z.string().uuid(), // 可根据实际ID格式调整 }); // User路由的基础输入(必传username) export const userBaseInput = z.object({ username: z.string().min(3), });
步骤2:创建路由专属中间件
为Post和User路由分别编写中间件,负责验证输入、查询对应资源并注入上下文:
// post.ts import { trpc } from "../trpc"; import { postBaseInput } from "../schemas"; import { db } from "../db"; // 你的数据库实例 // Post路由专属中间件:验证postId并查询Post注入上下文 const postMiddleware = trpc.middleware(async ({ input, next }) => { // 验证输入,自动抛出验证错误 const { postId } = postBaseInput.parse(input); // 查询Post资源 const post = await db.post.findUnique({ where: { id: postId }, }); if (!post) throw new Error("Post不存在"); // 将Post注入上下文,传递给后续路由处理函数 return next({ ctx: { post, }, }); }); // 创建Post路由,挂载专属中间件 export const postRouter = trpc .router() .middleware(postMiddleware) // 查询Post:直接使用上下文的post,无需重复查询 .query("get", { resolve({ ctx }) { return ctx.post; }, }) // 更新Post:合并基础输入和额外字段,无需重复定义postId .mutation("updateContent", { input: postBaseInput.extend({ content: z.string().min(1), }), resolve({ ctx, input }) { return db.post.update({ where: { id: ctx.post.id }, data: { content: input.content }, }); }, });
User路由的实现逻辑完全一致:
// user.ts import { trpc } from "../trpc"; import { userBaseInput } from "../schemas"; import { db } from "../db"; const userMiddleware = trpc.middleware(async ({ input, next }) => { const { username } = userBaseInput.parse(input); const user = await db.user.findUnique({ where: { username }, }); if (!user) throw new Error("用户不存在"); return next({ ctx: { user, }, }); }); export const userRouter = trpc .router() .middleware(userMiddleware) .query("get", { resolve({ ctx }) { return ctx.user; }, }) .mutation("updateEmail", { input: userBaseInput.extend({ email: z.string().email(), }), resolve({ ctx, input }) { return db.user.update({ where: { username: ctx.user.username }, data: { email: input.email }, }); }, });
步骤3:合并路由并启动服务
主路由直接合并两个子路由,基础上下文只需返回空对象即可,子路由的中间件会自动补充专属上下文:
// server.ts import { trpc } from "./trpc"; import { postRouter } from "./post"; import { userRouter } from "./user"; import { trpcExpress } from "@trpc/server/adapters/express"; import express from "express"; const app = express(); const appRouter = trpc .router() .merge("post.", postRouter) .merge("user.", userRouter); app.use( "/trpc", trpcExpress.createExpressMiddleware({ router: appRouter, createContext: () => ({}), // 基础上下文为空,路由中间件补充专属内容 }) ); app.listen(3000, () => { console.log("服务运行在 http://localhost:3000"); });
方案优势
- 符合DRY原则:基础输入Schema只定义一次,路由中间件统一处理验证和资源查询,子路由无需重复代码。
- 路由专属上下文:每个子路由通过中间件注入专属的资源(Post/User),避免上下文混乱。
- 类型安全:Zod验证保证输入合法性,TypeScript自动推导上下文类型,避免类型错误。
内容的提问来源于stack exchange,提问作者Christian Legge
相关产品推荐
相关产品推荐

