嘿,大家下午好!欢迎来到“如何让你的全栈应用像瑞士军刀一样精密且自省”的讲座。
我知道,你们都在用 tRPC。你们爱它,你们恨它——好吧,可能只是爱它。TypeScript 的类型安全,一键生成的 API 文档,前后端数据契约的完美统一。这简直就是前端开发者的圣杯,对吧?如果不把 npm install 写进墓志铭里,那绝对算是一种遗憾。
但是,有没有那么一瞬间,你的产品经理(PM)拍着桌子问你:“为什么这个查询在开发环境慢得像蜗牛,在生产环境却快得像闪电?”或者,那个总是抱怨“我的数据被删了”的用户,坚持说他是手滑了,不是故意的?
这时候,你抓了抓头,开始像在沙滩上写代码一样,在路由函数里塞 console.log,或者在 API 层里到处写 try-catch。代码变得像意大利面一样乱。如果有一个地方,能自动在所有请求发生的时候,“咔嚓”一声把所有信息都记录下来,那该多好?
这就是今天我们要聊的主角——tRPC Interceptors(拦截器)。这不仅仅是中间件,这是你的代码的“眼睛”和“大脑”。今天,我们就来聊聊如何用 tRPC 拦截器,给你的 React 全栈架构装上“自动埋点”、“审计追踪”和“性能监控”。
准备好了吗?让我们开始吧。
一、 拦截器的本质:你不在场的“旁听生”
首先,我们要搞清楚 tRPC 的运行机制。想象一下,你的前端是一个穿着西装的推销员(用户),你的后端是一个精密的工厂车间。
如果没有任何拦截器,请求就像直来直去的快递员,把包裹(数据)直接扔进工厂(数据库),完事就走。没有记录,没有反馈,没有质量检查。
而 tRPC 的中间件,就是那个躲在工厂大门旁边的保安。
它有三个位置:
- Router 级别:就像工厂的入口,所有进出的货物都要经过这里安检。
- Procedure 级别:就像进入具体车间的通道,这里可以做更细的检查。
- 客户端:虽然我们主要讨论服务端监控,但客户端拦截器可以防止用户作弊(比如直接改前端代码把价格改成 0)。
拦截器函数接收 ctx(上下文)和 next(下一个执行步骤)。如果你要处理逻辑,你就调用 next(),让真正的业务逻辑跑完;如果你不想让它跑,你就可以直接抛出错误。
二、 实现审计追踪:那个总是被删数据的用户
先说个场景。你的应用里有个功能叫“删除用户”。这个操作很危险。一旦执行,这个用户就消失了,而且数据库里的记录也没了。如果不记录是谁在什么时候删除了谁,将来出了纠纷,你拿什么赔?拿头去赔吗?
这时候,我们就需要一个 auditMiddleware。
1. 准备工作
首先,我们需要一个地方存审计日志。可以是数据库表,可以是专门的日志服务,甚至是文件。为了演示简单,我们假设我们有一个简单的日志服务。
// utils/logger.ts
export const logger = {
info: (message: string, data?: any) => {
console.log(`[AUDIT] ${new Date().toISOString()} - ${message}`, data || '');
}
};
2. 编写审计中间件
我们要在 Router 上定义它。这个中间件的目标是:不管你调用了什么 Procedure,只要我执行了,我就得知道。
// server/router/_app.ts
import { z } from "zod";
import { initTRPC, TRPCError } from "@trpc/server";
import { logger } from "../utils/logger";
const t = initTRPC.create();
/**
* 这是一个通用的审计中间件
* 它会在任何 Procedure 执行前和执行后运行
*/
const auditMiddleware = t.middleware(async ({ ctx, next }) => {
// 记录开始时间,为了性能监控
const start = Date.now();
// 记录请求的基本信息
// 注意:这里我们假设 ctx 里已经包含了用户信息(比如通过 JWT 解析后注入)
// 如果没有,我们可能需要在这里做额外的处理
const userId = ctx.user?.id || "anonymous";
const procedureName = ctx.meta?.procedureName || "unknown";
logger.info(`Request Started`, {
userId,
procedure: procedureName,
input: ctx.input // 仅用于调试,生产环境可能不记录敏感输入
});
try {
// 调用 next() 执行实际的业务逻辑
const result = await next();
// 业务逻辑执行完毕
const duration = Date.now() - start;
// 记录请求成功
logger.info(`Request Completed`, {
userId,
procedure: procedureName,
status: "success",
duration: `${duration}ms`
});
return result;
} catch (err) {
// 业务逻辑抛出错误
const duration = Date.now() - start;
logger.error(`Request Failed`, {
userId,
procedure: procedureName,
error: err instanceof Error ? err.message : String(err),
duration: `${duration}ms`
});
// 重要:拦截器捕获错误后,需要重新抛出,否则业务逻辑不执行
throw err;
}
});
// 创建 router 实例,应用中间件
const appRouter = t.router({
// 这里声明了所有 Procedures 的基线设置
procedure: t.procedure.use(auditMiddleware),
// 我们的业务逻辑...
deleteUser: t.procedure
.input(z.object({ id: z.string() }))
.meta({ procedureName: "deleteUser" }) // 帮助我们在日志里区分是哪个接口
.query(async ({ ctx, input }) => {
// ... 删除逻辑
console.log(`Deleting user ${input.id}`);
return { success: true };
})
});
export type AppRouter = typeof appRouter;
看,这就是魔法。现在,不管你在代码里写了多少个 deleteUser、updateSettings 或者 exportReport,只要它们经过了 appRouter,它们都会被自动记录。你不需要在每个路由函数里都写 console.log,也不需要去记事本里粘贴日志。这就是关注点分离的美妙之处。
三、 性能监控:揪出那个慢吞吞的 API
光有日志还不够。有时候,系统并不报错,但是就是慢。慢到用户开始疯狂点击刷新按钮,甚至以为死机了。我们需要监控的是耗时。
我们可以利用 tRPC 的中间件特性,在 next() 调用前后打点。
// 进阶版:带性能分析的中间件
const performanceMiddleware = t.middleware(async ({ next, ctx }) => {
const start = performance.now(); // 使用浏览器/Node.js 的 performance API,更精准
const result = await next();
const duration = performance.now() - start;
// 如果某个操作超过 1000ms,我们可以记为“警告”
if (duration > 1000) {
logger.warn(`Slow Operation Detected`, {
duration: `${duration.toFixed(2)}ms`,
procedure: ctx.meta?.procedureName
});
}
return result;
});
// 将其应用到 Router 上
const appRouter = t.router({
procedure: t.procedure.use(performanceMiddleware),
// ... 其他逻辑
});
但是,仅仅记录时间是不够的。我们往往想知道是数据库查询慢,还是计算慢。
在 tRPC 中,我们可以在 Procedure 内部手动打点,然后通过上下文传递回中间件。这需要一点技巧,但这能让你洞察到代码的每一个角落。
// server/router/billing.ts
const billingRouter = t.router({
processPayment: t.procedure
.input(z.object({ amount: z.number() }))
.query(async ({ ctx }) => {
const startTime = Date.now();
// 模拟一个数据库查询
await db.query("SELECT 1");
const dbTime = Date.now() - startTime;
// 计算逻辑
const fee = amount * 0.05; // 假设 5% 手续费
const processTime = Date.now() - startTime - dbTime;
// 将性能数据注入到上下文返回给中间件
// 注意:这里需要在 tRPC 的 context 初始化时允许这种数据传递
ctx.meta = { ...ctx.meta, dbTime, processTime };
return { success: true, fee };
})
});
// 在 _app.ts 中,我们需要修改中间件来读取这个 meta
const appRouter = t.router({
procedure: t.procedure.use(async ({ next, ctx }) => {
const start = Date.now();
const result = await next();
const totalDuration = Date.now() - start;
// 细分时间
const dbTime = ctx.meta?.dbTime || 0;
const processTime = ctx.meta?.processTime || 0;
if (totalDuration > 500) {
logger.warn("Heavy Load", {
total: `${totalDuration}ms`,
db: `${dbTime}ms`,
process: `${processTime}ms`
});
}
return result;
})
});
通过这种“手术刀式”的监控,你很快就会发现,原来是那个 5% 的手续费计算逻辑里有个死循环,或者是数据库查询没加索引。这时候,你就可以优雅地去优化了。
四、 自动化埋点:数据驱动的产品
现在,你的应用有了“记忆”。
所谓的“埋点”,本质上就是收集数据。在 tRPC 中,我们可以在中间件里直接写入数据到数据库或分析服务(如 Mixpanel, Google Analytics, PostHog)。
假设我们有一个 Analytics 服务:
// services/analytics.ts
class AnalyticsService {
trackEvent(eventName: string, properties: Record<string, any>) {
// 这里可以是调用第三方 API
// 也可以是写入你的消息队列
console.log(`[ANALYTICS] Event: ${eventName}`, properties);
// 实际生产中,这应该是一个非阻塞的 HTTP 请求
}
}
export const analytics = new AnalyticsService();
现在,让我们把这个服务注入到 tRPC 的中间件里。
const analyticsMiddleware = t.middleware(async ({ ctx, next }) => {
// 记录页面访问或 API 调用
analytics.trackEvent("api_call", {
endpoint: ctx.meta?.procedureName,
userId: ctx.user?.id,
timestamp: new Date().toISOString()
});
return next();
});
const appRouter = t.router({
procedure: t.procedure
.use(authMiddleware) // 先认证,防止未登录用户乱刷数据
.use(analyticsMiddleware), // 再埋点
// ... routes
});
这样一来,你的每一笔交易、每一次点击、每一次数据请求都被记录在案。
更高级的玩法是转化漏斗埋点。你可以在特定的 Procedure(比如 completeCheckout)里标记为“转化节点”,在中间件里计算转化率。这比前端埋点要准确得多,因为后端的数据是真实的,不可篡改的。
五、 审计与权限:守门人的职责
这部分非常重要。不要以为前端加了 role: "admin" 的字段就安全了。黑客可以禁用 JS,也可以直接调用 tRPC 的 API 端点(如果他们猜到了 URL)。
真正的审计和权限控制,必须发生在服务器端。
tRPC 允许我们在中间件里修改 ctx,并且可以基于 ctx 来决定是否允许请求继续。
const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
// 1. 检查用户是否存在
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED", message: "You must be logged in" });
}
// 2. 检查权限
// 假设我们在 User 对象里存了 role
if (ctx.user.role !== "ADMIN" && ctx.meta?.requireAdmin) {
throw new TRPCError({ code: "FORBIDDEN", message: "Access Denied" });
}
// 3. 审计前置检查
// 比如:记录这个用户今天已经调用了 50 次 API,如果超过,就封禁
const apiCount = await redis.get(`api_count:${ctx.user.id}`);
if (parseInt(apiCount || "0") > 100) {
throw new TRPCError({ code: "TOO_MANY_REQUESTS", message: "Rate limit exceeded" });
}
// 一切正常,允许通过
return next();
});
const appRouter = t.router({
procedure: t.procedure.use(protectedProcedure),
deleteUser: t.procedure
.meta({ requireAdmin: true }) // 声明这个接口需要管理员权限
.input(z.object({ id: z.string() }))
.query(async ({ ctx, input }) => {
// 只有在这里,你才能百分之百确信 ctx.user 是 Admin
await deleteUserFromDB(input.id);
return { success: true };
})
});
这种设计模式非常优雅。你在定义路由的时候,只需要在 .meta() 里声明需要什么权限,中间件会自动处理逻辑。
六、 React 全栈架构中的协同:客户端拦截
既然是全栈架构,前端也不能闲着。我们需要在前端建立一个连接,让中间件里产生的性能数据(比如 API 响应时间)能够反馈到 UI 上。
这就用到了 React Context。
1. 创建 Context
// contexts/PerformanceContext.tsx
import { createContext, useContext, ReactNode } from 'react';
interface PerformanceContextType {
// 记录一个 API 调用的开始
startCall: (operation: string) => void;
// 记录一个 API 调用的结束
endCall: (operation: string, duration: number, success: boolean) => void;
}
const PerformanceContext = createContext<PerformanceContextType | undefined>(undefined);
export const PerformanceProvider = ({ children }: { children: ReactNode }) => {
// 这里可以存储正在进行的请求,用于前端显示“加载中”
const [activeCalls, setActiveCalls] = useState<string[]>([]);
const startCall = (operation: string) => {
console.log(`[UI] Starting ${operation}`);
setActiveCalls(prev => [...prev, operation]);
};
const endCall = (operation: string, duration: number, success: boolean) => {
console.log(`[UI] Finished ${operation} in ${duration}ms`, success ? '✅' : '❌');
setActiveCalls(prev => prev.filter(call => call !== operation));
};
return (
<PerformanceContext.Provider value={{ startCall, endCall }}>
{children}
</PerformanceContext.Provider>
);
};
export const usePerformance = () => {
const context = useContext(PerformanceContext);
if (!context) throw new Error("usePerformance must be used within PerformanceProvider");
return context;
};
2. 连接 React Query (TanStack Query)
通常我们使用 React Query 来管理 tRPC 的数据获取。我们可以利用 React Query 的 onSuccess 和 onError 钩子来与 Context 通信。
// hooks/useTRPC.ts
import { useQuery, useMutation, type UseQueryResult, type UseMutationResult } from '@tanstack/react-query';
import { trpc } from '../client/trpc'; // 你的 tRPC 客户端实例
export const useTRPCQuery = <TInput, TOutput>(
queryKey: any[],
queryFn: () => Promise<TOutput>
) => {
const { startCall, endCall } = usePerformance();
return useQuery(queryKey, queryFn, {
onSuccess: (data) => {
// 这里我们假设后端返回的数据结构里带元数据,或者我们手动计算
// 注意:如果 queryFn 很复杂,获取时间可能不准确,但足够了
const startTime = (queryFn as any)._startTime; // hacky way for demo
const duration = performance.now() - startTime;
endCall('Query', duration, true);
},
onError: (err) => {
const duration = (queryFn as any)._duration;
endCall('Query', duration || 0, false);
}
});
};
等等,上面的方法有点绕。更好的方法是直接在 tRPC 客户端的中间件里处理。
// client/trpc.ts
import { initTRPC, type CreateTRPCClientOptions } from "@trpc/client";
import superjson from "superjson";
// ... 初始化配置
export const client = createTRPCClient<AppRouter>({
transformer: superjson,
links: [
// 这里可以添加 WebSocket 或 HTTP 链接
httpLink({ url: 'http://localhost:3000/api/trpc' })
],
// 关键点:客户端拦截器
transformer: superjson, // 处理日期、JSON等序列化
});
// 自定义 Hook
export const useTrpc = () => {
const { setPerformanceData } = usePerformance();
// 覆盖 trpc 对象的方法以添加客户端埋点
// 这是一个高级技巧,实际项目中可能需要使用 tRPC 的 hooks 的高级配置
};
实际上,最简单且最常用的方式是直接使用 tRPC 的 createTRPCReact,并配合 React Query。我们不需要手动拦截客户端调用,因为 React Query 本身就是请求生命周期管理的大师。
我们只需要在服务端的中间件里做文章,然后在 React 组件里用 trpc.something.useQuery,React Query 就会自动处理轮询、缓存和错误处理,并把结果带给我们的 UI。
七、 错误监控与用户反馈
除了性能和审计,拦截器还是捕获异常的最佳场所。
假设有一个未处理的错误:
const appRouter = t.router({
procedure: t.procedure.use(async ({ next }) => {
try {
return await next();
} catch (err) {
// 捕获错误
logger.error("Unhandled Procedure Error", err);
// 发送到错误监控服务
Sentry.captureException(err);
// 可以选择将错误重新抛出,让 tRPC 返回标准的 JSON 错误响应
throw err;
}
})
});
这样,你就能在 Sentry 或 LogRocket 上看到完整的调用堆栈,以及当时的输入参数。这比看前端那一堆红屏白字要爽多了。
八、 总结实战经验:别把中间件写成瑞士奶酪
讲了这么多,我想分享几个在实战中踩过的坑:
- 不要在中间件里做耗时操作:千万不要在中间件里去查询数据库、去下载大文件。中间件是流水线上的质检员,不是重工业车间。如果必须查数据库,先把数据缓存起来。
- 类型安全是双向的:利用 TypeScript 的
infer,你可以根据中间件修改后的ctx,生成精确的类型定义。这意味着你在前端写ctx.user.role的时候,TypeScript 会自动告诉你这个字段是否存在。这是作弊般的感觉。 - 分层设计:不要把所有功能都塞进一个
auditMiddleware里。- 顶层:认证、日志、性能监控。
- 中层:权限检查、限流。
- 底层:业务逻辑。
这样代码结构会非常清晰。
- 不要过度审计:不要记录每一个 API 调用。比如获取公开文章列表,就不需要记录“谁看了什么”。只记录关键操作(删除、修改、购买)。否则,你的日志服务会爆炸。
九、 完整代码示例:一个“点数商城”应用
最后,我们用一个完整的、稍微有点复杂点的例子来收尾。这是一个“赚取 Karma(点数)”的商城。
// server/router/points.ts
import { z } from "zod";
import { initTRPC } from "@trpc/server";
const t = initTRPC.create();
// 1. 全局中间件:审计 + 性能
const monitoringMiddleware = t.middleware(async ({ ctx, next }) => {
const start = Date.now();
logger.info(`Request: ${ctx.meta?.procedure}`, { userId: ctx.user?.id });
try {
const result = await next();
const duration = Date.now() - start;
// 性能告警
if (duration > 200) {
logger.warn(`Slow Request: ${ctx.meta?.procedure}`, { duration });
}
return result;
} catch (error) {
const duration = Date.now() - start;
logger.error(`Failed Request: ${ctx.meta?.procedure}`, { duration, error });
throw error;
}
});
const protectedProcedure = t.procedure.use(async ({ ctx, next }) => {
if (!ctx.user) {
throw new TRPCError({ code: "UNAUTHORIZED" });
}
return next({ ctx: { ...ctx, user: ctx.user } });
});
const router = t.router({
procedure: t.procedure.use(monitoringMiddleware).use(protectedProcedure),
// 赚取点数
earnPoints: t.procedure
.input(z.object({ taskId: z.string() })) // 比如完成了一个任务
.mutation(async ({ ctx, input }) => {
const user = ctx.user!;
// 更新数据库
await db.karma.update({
where: { id: user.id },
data: { points: { increment: 10 } }
});
// 记录详细的埋点
await analytics.track("karma_earned", {
userId: user.id,
amount: 10,
taskId: input.taskId
});
return { success: true, newPoints: user.points + 10 };
}),
// 消耗点数(购买)
redeemReward: t.procedure
.input(z.object({ rewardId: z.string(), pointsCost: z.number() }))
.mutation(async ({ ctx, input }) => {
const user = ctx.user!;
// 审计前置检查:余额是否足够?
// 注意:这里必须做检查,因为并发请求可能导致余额超支
const currentPoints = await db.karma.findUnique({ where: { id: user.id } });
if (currentPoints!.points < input.pointsCost) {
throw new TRPCError({ code: "BAD_REQUEST", message: "Points insufficient" });
}
// 开始事务
await db.$transaction(async (tx) => {
// 扣款
await tx.karma.update({
where: { id: user.id },
data: { points: { decrement: input.pointsCost } }
});
// 记录日志
await tx.auditLog.create({
data: {
userId: user.id,
action: "redeem_reward",
details: JSON.stringify({ rewardId: input.rewardId, cost: input.pointsCost })
}
});
// 发放物品
await tx.inventory.create({ data: { userId: user.id, item: input.rewardId } });
});
return { success: true };
})
});
在这个例子中:
- 自动埋点:每次 earnPoints 或 redeemReward,中间件都会记录。
- 审计:redeemReward 里我们用了事务,并且手动插入了一条审计日志。这比单纯依赖中间件更安全,因为事务失败了,日志也不会写进去。
- 性能监控:如果 redeemReward 超过 200ms,日志里就会警告。
结语
好了,各位听众,这就是 tRPC 拦截器的威力。
它不仅仅是代码优化工具,它是你全栈应用的神经系统。通过在 tRPC Router 上构建中间件,你可以在不侵入业务代码的情况下,实现几乎所有的监控需求。
当你把这套东西搭好之后,你会发现自己手里握有一张“上帝视角”的地图。你可以清晰地看到数据是怎么流动的,谁在什么时间点了哪里,哪里是瓶颈,哪里是安全的。
不要再为了几个 console.log 修改你的业务代码了。去拥抱中间件吧,让你的架构自动学会自省。现在的你,比起那些还在到处粘贴代码的开发者,已经领先了一个版本。而下一个版本,就是用代码说话,用数据证明。
祝大家编码愉快,请求通顺,服务器永远不崩!