Chapter 10

生产最佳实践

路由拆分、可观测性、性能优化与渐进式迁移策略

10.1 路由拆分:按领域组织

生产级项目应将 Router 按业务领域拆分,每个领域一个文件。这样能保持文件大小可控,便于团队协作和 Code Review:

server/
├── trpc.ts                    # initTRPC,procedure 类型
├── context.ts                 # createContext
├── router.ts                  # 根 Router,合并所有子 Router
└── routers/
    ├── auth.ts               # 注册、登录、登出
    ├── user.ts               # 用户资料、设置
    ├── post.ts               # 文章 CRUD
    ├── comment.ts            # 评论
    ├── notification.ts       # 通知(含 Subscription)
    └── admin/
        ├── index.ts          # 管理员根 Router
        ├── users.ts          # 用户管理
        └── content.ts        # 内容审核
STRUCTURE
// server/router.ts — 根 Router
import { router } from './trpc';
import { authRouter } from './routers/auth';
import { userRouter } from './routers/user';
import { postRouter } from './routers/post';
import { adminRouter } from './routers/admin';

export const appRouter = router({
  auth: authRouter,
  user: userRouter,
  post: postRouter,
  admin: adminRouter,
});

export type AppRouter = typeof appRouter;
TS

10.2 统一错误格式化

生产环境需要统一的错误响应格式,方便前端统一处理,同时屏蔽服务器内部错误细节:

import { initTRPC } from '@trpc/server';
import { ZodError } from 'zod';

const t = initTRPC.context<Context>().create({
  errorFormatter({ shape, error }) {
    return {
      ...shape,
      data: {
        ...shape.data,
        // 提取 Zod 验证错误,前端可以精确展示字段错误
        zodError:
          error.cause instanceof ZodError
            ? error.cause.flatten()
            : null,
        // 生产环境屏蔽堆栈信息
        stack: process.env.NODE_ENV === 'development'
          ? shape.data.stack
          : undefined,
      },
    };
  },
});
TS

前端统一错误处理

// lib/error-handler.ts
import { TRPCClientError } from '@trpc/client';
import type { AppRouter } from '@/server/router';

export function handleTRPCError(error: unknown) {
  if (error instanceof TRPCClientError<AppRouter>) {
    const { data } = error;

    // 字段级验证错误
    if (data?.zodError) {
      const fieldErrors = data.zodError.fieldErrors;
      return Object.entries(fieldErrors)
        .map(([field, errors]) => `${field}: ${errors?.join(', ')}`)
        .join('\n');
    }

    // 业务错误(UNAUTHORIZED、FORBIDDEN 等)
    return error.message;
  }

  return '发生未知错误,请稍后重试';
}
TS

10.3 日志与可观测性:OpenTelemetry

import { trace, SpanStatusCode } from '@opentelemetry/api';

const tracer = trace.getTracer('trpc-server');

const otelMiddleware = t.middleware(async ({ path, type, next }) => {
  const span = tracer.startSpan(`trpc.${type}.${path}`, {
    attributes: {
      'trpc.path': path,
      'trpc.type': type,
    },
  });

  try {
    const result = await next();
    if (!result.ok) {
      span.setStatus({ code: SpanStatusCode.ERROR });
      span.recordException(result.error);
    }
    return result;
  } finally {
    span.end();
  }
});
TS

10.4 批量请求优化

httpBatchLink(tRPC 默认)会将同一 tick 内的多个请求合并成一个 HTTP 请求,大幅减少网络往返次数:

// 默认行为:3个 useQuery 合并为 1 次 HTTP 请求
// GET /api/trpc/user.getMe,post.list,notification.count?batch=1&input={...}

// 自定义批量配置
httpBatchLink({
  url: '/api/trpc',
  maxURLLength: 2083,  // URL 超长时自动拆分为多个请求
})

// 调试时禁用批量(更易查看单个请求)
httpLink({ url: '/api/trpc' })

// 按条件切换(开发用 httpLink,生产用 httpBatchLink)
process.env.NODE_ENV === 'development'
  ? httpLink({ url: '/api/trpc' })
  : httpBatchLink({ url: '/api/trpc' })
TS

10.5 文件上传方案

tRPC 基于 JSON 传输,不直接支持文件上传。生产推荐方案是 Presigned URL:前端从 tRPC 获取预签名上传 URL,直接将文件上传到 S3/OSS,避免文件流经过应用服务器。

// server/routers/upload.ts
import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
import { getSignedUrl } from '@aws-sdk/s3-request-presigner';

const s3 = new S3Client({ region: 'us-east-1' });

export const uploadRouter = router({
  // 获取预签名上传 URL
  getUploadUrl: protectedProcedure
    .input(z.object({
      filename: z.string(),
      contentType: z.string().startsWith('image/'),
      size: z.number().max(5 * 1024 * 1024), // 最大 5MB
    }))
    .mutation(async ({ input, ctx }) => {
      const key = `uploads/${ctx.user.id}/${Date.now()}-${input.filename}`;
      const command = new PutObjectCommand({
        Bucket: process.env.S3_BUCKET!,
        Key: key,
        ContentType: input.contentType,
        ContentLength: input.size,
      });
      const uploadUrl = await getSignedUrl(s3, command, { expiresIn: 300 });
      return { uploadUrl, key, publicUrl: `${process.env.S3_CDN_URL}/${key}` };
    }),
});
TS

10.6 部署方案对比

平台优势注意事项
Vercel零配置,Edge Network,与 Next.js 深度集成Serverless,不支持 WebSocket(需 Ably/Pusher)
Railway支持持久进程,WebSocket 友好,简单部署费用按使用量计算
Fly.io全球多区域,支持长连接,价格合理需要 Dockerfile
AWS Lambda极致弹性伸缩,按调用付费冷启动延迟,WebSocket 需 API Gateway
自建 VPS完全控制,支持所有功能需要自行处理运维、SSL、负载均衡

10.7 从 REST 渐进式迁移到 tRPC

不需要一次性迁移所有 REST API。tRPC 可以与现有 REST API 共存,逐步迁移:

  1. 安装 tRPC 并配置 Route Handler(/api/trpc/[trpc]),不影响现有 /api/* 路由
  2. 将新功能直接用 tRPC 开发,旧接口暂时保留为 REST
  3. 按模块逐步将高频使用的 REST 接口迁移到 tRPC(优先迁移前端代码最多的接口)
  4. 迁移过程中使用 httpLinkfetch 选项将 tRPC 请求代理到旧 REST 接口(过渡方案)
  5. 最终完全切换,删除旧 REST 接口
// 过渡方案:tRPC Procedure 内部调用旧 REST 接口
getLegacyData: publicProcedure
  .input(z.object({ id: z.string() }))
  .query(async ({ input }) => {
    // 调用旧 REST 接口,返回类型安全的数据
    const res = await fetch(`/api/legacy/data/${input.id}`);
    const data = await res.json();
    // 用 Zod 验证并转换旧接口数据格式
    return LegacyDataSchema.parse(data);
  }),
TS
ℹ️

生产清单上线前检查:① 错误格式化已配置,生产环境不暴露堆栈信息;② 认证中间件覆盖所有敏感接口;③ 限流中间件保护高风险接口(登录、注册、发送验证码);④ httpBatchLink 开启(减少网络请求);⑤ OpenTelemetry 或至少基础日志中间件已配置;⑥ 健康检查接口(health.ping)已添加。

10.8 tRPC v11 新特性速览

特性说明
shorthand router更简洁的 router 定义语法
type-safe errors完全类型化的错误对象,含 cause 链
迭代器支持AsyncIterable 替代 Observable,原生 TS 支持
Server-Sent Events内置 SSE Link,无需 WebSocket 即可实现实时功能
HTTP 流式响应长时间运行的查询支持流式返回部分结果
更好的 tanstack query 集成与 TanStack Query v5 深度对齐
💡

课程总结tRPC 通过 TypeScript 类型系统在前后端之间建立了"零成本类型契约":后端定义 Procedure,类型自动流向前端,无需任何代码生成。核心架构是 initTRPC → Router/Procedure → Context/Middleware → AppRouter 类型导出。客户端通过 createTRPCReact<AppRouter> 获得完整类型推断。生产实践的关键是:按领域拆分 Router、Procedure Builder 封装通用逻辑、统一错误格式化、OpenTelemetry 可观测性。对于全栈 TypeScript 项目,tRPC 是目前开发体验最好的 API 方案。