tRPC 拦截器在 React 全栈架构中的应用:实现自动化的请求埋点、审计与性能监控

嘿,大家下午好!欢迎来到“如何让你的全栈应用像瑞士军刀一样精密且自省”的讲座。

我知道,你们都在用 tRPC。你们爱它,你们恨它——好吧,可能只是爱它。TypeScript 的类型安全,一键生成的 API 文档,前后端数据契约的完美统一。这简直就是前端开发者的圣杯,对吧?如果不把 npm install 写进墓志铭里,那绝对算是一种遗憾。

但是,有没有那么一瞬间,你的产品经理(PM)拍着桌子问你:“为什么这个查询在开发环境慢得像蜗牛,在生产环境却快得像闪电?”或者,那个总是抱怨“我的数据被删了”的用户,坚持说他是手滑了,不是故意的?

这时候,你抓了抓头,开始像在沙滩上写代码一样,在路由函数里塞 console.log,或者在 API 层里到处写 try-catch。代码变得像意大利面一样乱。如果有一个地方,能自动在所有请求发生的时候,“咔嚓”一声把所有信息都记录下来,那该多好?

这就是今天我们要聊的主角——tRPC Interceptors(拦截器)。这不仅仅是中间件,这是你的代码的“眼睛”和“大脑”。今天,我们就来聊聊如何用 tRPC 拦截器,给你的 React 全栈架构装上“自动埋点”、“审计追踪”和“性能监控”。

准备好了吗?让我们开始吧。

一、 拦截器的本质:你不在场的“旁听生”

首先,我们要搞清楚 tRPC 的运行机制。想象一下,你的前端是一个穿着西装的推销员(用户),你的后端是一个精密的工厂车间。

如果没有任何拦截器,请求就像直来直去的快递员,把包裹(数据)直接扔进工厂(数据库),完事就走。没有记录,没有反馈,没有质量检查。

而 tRPC 的中间件,就是那个躲在工厂大门旁边的保安

它有三个位置:

  1. Router 级别:就像工厂的入口,所有进出的货物都要经过这里安检。
  2. Procedure 级别:就像进入具体车间的通道,这里可以做更细的检查。
  3. 客户端:虽然我们主要讨论服务端监控,但客户端拦截器可以防止用户作弊(比如直接改前端代码把价格改成 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;

看,这就是魔法。现在,不管你在代码里写了多少个 deleteUserupdateSettings 或者 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 的 onSuccessonError 钩子来与 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 上看到完整的调用堆栈,以及当时的输入参数。这比看前端那一堆红屏白字要爽多了。

八、 总结实战经验:别把中间件写成瑞士奶酪

讲了这么多,我想分享几个在实战中踩过的坑:

  1. 不要在中间件里做耗时操作:千万不要在中间件里去查询数据库、去下载大文件。中间件是流水线上的质检员,不是重工业车间。如果必须查数据库,先把数据缓存起来。
  2. 类型安全是双向的:利用 TypeScript 的 infer,你可以根据中间件修改后的 ctx,生成精确的类型定义。这意味着你在前端写 ctx.user.role 的时候,TypeScript 会自动告诉你这个字段是否存在。这是作弊般的感觉。
  3. 分层设计:不要把所有功能都塞进一个 auditMiddleware 里。
    • 顶层:认证、日志、性能监控。
    • 中层:权限检查、限流。
    • 底层:业务逻辑。
      这样代码结构会非常清晰。
  4. 不要过度审计:不要记录每一个 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 修改你的业务代码了。去拥抱中间件吧,让你的架构自动学会自省。现在的你,比起那些还在到处粘贴代码的开发者,已经领先了一个版本。而下一个版本,就是用代码说话,用数据证明。

祝大家编码愉快,请求通顺,服务器永远不崩!

发表回复

您的邮箱地址不会被公开。 必填项已用 * 标注