返回探索
指南VibeFix 编辑部更新于 2026年10月19日

REST 写腻了?vibe 项目的 GraphQL 与 tRPC 选型实战

AI 写 REST 又快又糙,改个字段名,前后端一起崩。这篇实战指南给一人 TS 团队一个明确立场:默认 tRPC,白拿端到端类型安全;只有出现多端消费、公开 API、聚合网关三个信号才上 GraphQL。含可直接跑的 tRPC + Next.js App Router 落地、GraphQL schema 优先与 codegen、AI 必踩 N+1 的 DataLoader 标准解法、公开 API 的深度限制与复杂度计分护栏、middleware vs directive 鉴权写法、选型决策树,以及 REST → tRPC 渐进式迁移 5 步 SOP。

暗色编辑器中源代码的虚化特写,语法高亮色彩斑斓

先说一个你大概率已经踩过的坑:让 AI 给你写一套 REST 接口,它半小时给你吐出 20 个路由,Postman 一测全通,你很满意。两周后你把 Post 表的 title 改成 headline,后端改完、前端漏改一个页面,线上直接白屏——而 TypeScript 全程一声不吭,因为前后端之间隔着一层 HTTP JSON,类型在序列化那一刻就断了。最气人的是排查过程:你先怀疑是缓存,再怀疑是 AI 把组件写坏了,最后才发现是三年前(两周前)改的那个字段名。这种"改一处、崩一处、查半天"的循环,就是 REST + AI 组合的日常税。

这就是 vibe coding 时代 API 层的真实矛盾:AI 写 REST 又快又糙,类型一改,前后端一起崩。你缺的不是更多的接口,而是一层能在编译期就把"前后端对不上"拦下来的 API 设计。本篇给你一个明确立场:一人 TS 全栈团队,默认选 tRPC;同时讲清楚 GraphQL 什么时候真值得上,以及 AI 生成这两种代码时必踩的两个专属大坑(N+1 和 over-fetching),每个坑都给能直接抄的代码级解法。

本文 8 节的阅读路线:先看第 1 节的对比表和默认推荐,如果你已经在用 tRPC,直接跳到第 4、5 节看 AI 专属坑;如果在纠结要不要上 GraphQL,第 7 节的决策树是为你画的;已经有一坨 REST 想迁移的,第 8 节是 5 步 SOP。每一节的代码都是能直接跑的形状,复制粘贴后按你的表结构改字段名即可。

三协议一张表:REST / tRPC / GraphQL 的取舍

先把三者在四个维度的差异摆平,避免"我听说 GraphQL 很火"式的选型:

维度RESTtRPCGraphQL
类型安全靠手写类型或 OpenAPI codegen,最容易漂移:后端改了字段,前端类型文件没人记得重新生成端到端 TS 类型推导:router 类型直接 typeof 导出给客户端,改一个字段,编译器替你抓全链路schema 强类型 + codegen:类型安全在 schema 层,前后端都对 schema 负责
心智负担最低,人人会,调试工具最多低(仅限 TS 全栈):没有 schema 文件要维护,没有 codegen 步骤高:schema 设计、resolver、DataLoader、查询护栏,每一项都是新概念
调试友好度curl / Postman 直接打,CDN 与 HTTP 缓存开箱即用HTTP 层是批量 POST,可读性差一截;靠 tRPC Panel 或客户端 devtoolsGraphiQL 自文档、所见即所得;但错误藏在 data.errors 里,监控要单独做
生态成熟度最高:网关、缓存、限流全是现成方案TS 生态内成熟(Next.js / React / Express 适配器齐全),多语言客户端弱多端生态最成熟:iOS / Android / Web 客户端、网关、持久化查询都很全
AI 生成友好度AI 写得最顺,但类型漂移的坑也最深AI 写 router 很顺,且改字段有编译器兜底AI 写的 resolver 几乎必踩 N+1(第 4 节细讲)

给一人团队的默认推荐只有一句话:TS 全栈 + 前后端同一拨人(就是你自己)→ 默认 tRPC。REST 留给两种场景:第三方 webhook 回调、需要强 HTTP 缓存的公开读接口(这类接口的版本与废弃流程,见《API 别翻车:vibe 项目的版本管理与废弃 SOP》,本篇不重复)。GraphQL 则是"出现特定信号才上"的重武器,信号清单在第 7 节。

为什么默认 tRPC 而不是"更流行"的 GraphQL?算一笔账:GraphQL 的 schema 设计 + codegen 管线 + DataLoader + 查询护栏,一个人要搭 2~3 天,且以后每个新功能都要先写 schema 再写 resolver;而 tRPC 的类型安全是白送的——你写 router 函数的同时类型就有了。对单人团队,省下来的不是代码量,是"多一套要维护的心智模型"。

再补一个 AI 时代的特有视角:AI 写 REST 时类型漂移几乎是必然的。它的工作流是"先写 handler,再补一个 types.ts",而这两份代码之间没有任何机器可验证的链接——后端把 limit 从 number 改成 string,前端的 types.ts 不会自动变,AI 也不会主动去改一个"看起来没坏"的文件。tRPC 把"类型即实现"焊死了:类型是从 router 推导出来的,实现变了类型必变,AI 再怎么局部改代码也绕不开编译器。这是给 AI 干活划的一条硬边界:让机器检查机器,而不是让人检查机器。

最后说说 REST 的反杀场景,免得有人觉得我在开"REST 葬礼":你的 API 要暴露给 Python 脚本、Go 服务、硬件设备调用时,tRPC 的 TS 锁死就是缺点;你的读接口 QPS 很高且适合 CDN 缓存时,REST 的 GET 可缓存性是 GraphQL 和 tRPC(默认 POST)都比不了的。选型没有鄙视链,只有"谁付成本"的问题——单人团队付不起的成本,就别选。

tRPC 落地:router 定义与 Next.js App Router 集成

tRPC 的核心就三样东西:初始化、router、procedure。先看一个真实可跑的最小落地:

// server/trpc.ts —— 初始化一次,全项目复用
import { initTRPC } from '@trpc/server';
import { z } from 'zod';

const t = initTRPC.context<{ db: Db; session: Session | null }>().create();

export const router = t.router;
export const publicProcedure = t.procedure;
// server/routers/post.ts —— 业务 router:函数即接口
import { router, publicProcedure } from '../trpc';
import { z } from 'zod';

export const postRouter = router({
  list: publicProcedure
    .input(z.object({ limit: z.number().min(1).max(50).default(20) }))
    .query(async ({ input, ctx }) => {
      return ctx.db.post.findMany({
        take: input.limit,
        orderBy: { createdAt: 'desc' },
      });
    }),

  create: publicProcedure
    .input(z.object({
      title: z.string().min(1).max(120),
      body: z.string().min(1),
    }))
    .mutation(async ({ input, ctx }) => {
      return ctx.db.post.create({ data: { ...input, authorId: ctx.session!.user.id } });
    }),
});
// server/routers/_app.ts —— 拼成总 router,并导出类型
import { router } from '../trpc';
import { postRouter } from './post';

export const appRouter = router({ post: postRouter });

// 关键一行:类型从实现里直接推导出来,没有任何 codegen
export type AppRouter = typeof appRouter;

Next.js App Router 的集成只需要一个 catch-all 路由:

// app/api/trpc/[trpc]/route.ts
import { fetchRequestHandler } from '@trpc/server/adapters/fetch';
import { appRouter } from '@/server/routers/_app';
import { db } from '@/server/db';

const handler = (req: Request) =>
  fetchRequestHandler({
    endpoint: '/api/trpc',
    req,
    router: appRouter,
    createContext: () => ({ db, session: null }), // 这里接你的鉴权逻辑
  });

export { handler as GET, handler as POST };

客户端侧(React Query 风格,用 @trpc/react-query):

// 组件里直接调用,input 和返回值全是类型化的
import { trpc } from '@/lib/trpc';

function PostList() {
  const { data, isLoading } = trpc.post.list.useQuery({ limit: 10 });
  // data 自动推导为 Post[],limit 传字符串会直接编译报错
  const create = trpc.post.create.useMutation();
  // ...
}

端到端类型安全的实测效果才是 tRPC 真正值钱的地方。做个实验:把 router 里 create 的 input 从 title 改名为 headline,保存的瞬间,编辑器里所有调用 trpc.post.create.useMutation() 并传 { title } 的地方全部飘红——编译器替你完成了全链路回归。对比 REST:后端改字段名,前端要靠全局搜索 "title" 字符串去碰运气,漏一处就是线上事故。AI 时代这个差异会被放大:AI 重构代码时改名最积极,有 tRPC 兜底你才敢让它放手改。

一个实操建议:router 按业务域拆文件(post.ts、user.ts、billing.ts),但 AppRouter 永远只有一个。拆太碎会导致客户端 import 路径混乱,单人项目按"页面聚合"拆就够了。

三个 tRPC 实战细节,AI 不会主动告诉你:

  • httpBatchLink 会合并请求。客户端 100ms 内的多次 useQuery 会被打包成一个 HTTP POST 发出去。好处是减少请求数,代价是单个请求体变大、调试时网络面板里看到的是一坨批量载荷。开发期可以用 httpLink(不合并)换可读性,上线再切回 batch。
  • Server Components 里别走 HTTP。Next.js App Router 的 RSC 里调 tRPC 不需要发 HTTP 请求,用 createCaller 直接调 router 函数,省一次网络往返:const caller = appRouter.createCaller(ctx); const posts = await caller.post.list({ limit: 10 });。类型照样全,速度更快。
  • AI 写 tRPC 的两个常见坑:一是 zod schema 与 Prisma 模型不一致——比如 Prisma 里 title 是可选,zod 写成 z.string() 必填,用户不填标题就 400,AI 还会信誓旦旦说"校验更严格是好事";二是 procedure 命名太泛(getData、updateThing),三个月后你自己都看不懂。命名规范现在就定:资源.动作(post.list、post.create),AI 生成时在 prompt 里写死这条规则。

顺便说一句 tRPC 不是银弹,它有两个硬伤你要提前知道:一是锁死 TypeScript——哪天你要给 Python 脚本或 Go 服务暴露接口,tRPC 客户端没有现成 SDK(社区有 trpc-openapi 插件能把 router 导出成 REST + OpenAPI 文档,算是个逃生舱,但类型安全的优势就没了);二是默认走 POST,HTTP 缓存、CDN 边缘缓存这些 REST 的免费午餐吃不到。所以第 1 节才说:公开读接口、高 QPS 缓存场景,继续用 REST,别为了统一技术栈而统一。

GraphQL 落地:schema 优先与渐进式引入

如果你命中了第 7 节的切换信号,GraphQL 的正确姿势是 schema 优先:先写 schema,再写 resolver,最后 codegen 出 TS 类型。顺序反了(先写 resolver 再补 schema)是 AI 最常见的写法,结果就是 schema 与实现两张皮。

# schema.graphql —— 先有契约,再有实现
type Post {
  id: ID!
  title: String!
  body: String!
  author: User!
  createdAt: String!
}

type User {
  id: ID!
  name: String!
}

type Query {
  posts(limit: Int = 20): [Post!]!
  post(id: ID!): Post
}

type Mutation {
  createPost(title: String!, body: String!): Post!
}
// resolvers.ts —— resolver 只做"把 schema 字段填上"
export const resolvers = {
  Query: {
    posts: (_: unknown, { limit }: { limit: number }, { db }: Ctx) =>
      db.post.findMany({ take: limit, orderBy: { createdAt: 'desc' } }),
  },
  Mutation: {
    createPost: (_: unknown, { title, body }: { title: string; body: string }, { db, user }: Ctx) =>
      db.post.create({ data: { title, body, authorId: user.id } }),
  },
};

前端类型靠 codegen 生成,而不是手写:

// codegen.ts —— @graphql-codegen/cli 配置
import type { CodegenConfig } from '@graphql-codegen/cli';

const config: CodegenConfig = {
  schema: './schema.graphql',
  documents: 'app/**/*.{ts,tsx}',   // 扫描前端的 graphql`` 模板字符串
  generates: {
    'lib/gql/': { preset: 'client' }, // 生成类型安全的 document helper
  },
};
export default config;
// 前端:查询语句本身被类型检查,字段写错直接报错
import { graphql } from '@/lib/gql';

const PostsQuery = graphql(`
  query Posts($limit: Int) {
    posts(limit: $limit) {
      id
      title
      author { name }
    }
  }
`);
// useQuery(PostsQuery, { variables: { limit: 10 } }) —— variables 和返回都 typed

与现有 REST 共存的渐进式引入是单人团队唯一现实的路径:不要重写。把 GraphQL 服务挂在 /api/graphql(或独立端口),resolver 内部直接复用你现有的 service 函数——GraphQL 只是多了一层"查询编排",数据逻辑不动。先迁 1~2 个"多端字段需求差异大"的查询(比如移动端要的精简版 feed),跑稳两周再扩大。REST 路由保持不动,等 GraphQL 覆盖到位后再按废弃 SOP 下掉(流程见《API 别翻车:vibe 项目的版本管理与废弃 SOP》)。

codegen 的工程化配置别漏了两项:一是 package.json 里加 "gql:watch": "graphql-codegen --watch",schema 一改前端类型自动重生成,否则你早晚会遇到"schema 改了但类型还是旧的"灵异事件;二是 fragment 共置(colocation)——每个组件在自己的文件里声明它要的字段 fragment,页面级查询用 ...Component_fragment 拼起来:

// components/PostCard.tsx —— 组件声明自己的数据需求
export const PostCardFragment = graphql(`
  fragment PostCard_post on Post {
    id
    title
    author { name }
  }
`);

// app/feed/page.tsx —— 页面查询只负责拼装
const FeedQuery = graphql(`
  query Feed($limit: Int) {
    posts(limit: $limit) {
      ...PostCard_post
    }
  }
`);

这样改 PostCard 要多一个字段时,只改它自己的 fragment,codegen 会告诉你拼装后的查询类型变了——组件与数据的绑定关系第一次有了机器可验证的形态。这是 GraphQL 官网首页也在强调的 data colocation 思想,AI 写组件时把这段模式喂给它,能少很多"字段对不上"的返工。

服务端框架选哪个?单人项目直接按这个优先级:GraphQL Yoga > Apollo Server。Yoga 基于 graphql-js、无历史包袱、插件写起来简单,上面第 5 节的 depth limit 和复杂度插件都是几行代码的事;Apollo Server 生态最大、资料最多,但 v4 之后配置变啰嗦,一个人维护时"资料多"不如"代码少"实在。还有一条中间路线 Pothos(code-first:用 TS 代码写 schema,类型自动推导),适合"想要 GraphQL 的灵活性、又不想手写 schema 文件"的人,但 code-first 的 schema 可读性不如 schema-first,公开 API 场景下不推荐——第三方开发者要看的是 schema 文件,不是你的 TS 代码。

一句话总结选型成本:tRPC 的类型安全是写代码时白送的;GraphQL 的类型安全是靠"schema 优先 + codegen 管线"这个纪律换来的。纪律需要人执行,一个人时,纪律越少越好——这就是默认 tRPC 的底层逻辑。

AI 专属坑之一:N+1,AI 生成的 resolver 为什么必踩

这是 AI 写 GraphQL 时最稳定复现的坑。看一段 AI 极大概率会给你生成的 resolver:

// AI 的"直觉写法":每个字段各自查库,看起来完全正确
export const resolvers = {
  Query: {
    posts: () => db.post.findMany({ take: 20 }),
  },
  Post: {
    author: (post: Post) =>
      db.user.findUnique({ where: { id: post.authorId } }), // ← 坑在这里
  },
};

前端查 20 篇文章带作者名:posts { title author { name } }。执行过程是:1 次查 post 列表 + 20 次逐个查 user = 21 次数据库往返。这就是 N+1。为什么 AI 必踩?因为它的训练数据里"字段 resolver 各自查库"是最常见的示例写法,而批量加载需要跨 resolver 共享状态(context 里的 loader),这超出了"看函数写函数"的局部模式。更阴险的是:开发环境数据量小,N+1 完全无感,上线后列表页一翻页数据库直接被打爆。

标准解法是 DataLoader(graphql/dataloader,MIT 协议,Facebook 2010 年内部 Loader 思想的开源实现):把同一次事件循环 tick 内的多次 load(id) 合并成一次批量查询。

import DataLoader from 'dataloader';

// 每个请求创建一个 loader(关键:per-request,不能跨请求共享,
// 否则 A 用户可能读到 B 用户的缓存——官方 README 明确警告过这一点)
export function createLoaders(db: Db) {
  return {
    userById: new DataLoader(async (ids: readonly number[]) => {
      const users = await db.user.findMany({ where: { id: { in: [...ids] } } });
      const byId = new Map(users.map((u) => [u.id, u]));
      // 必须遵守的契约:返回数组长度与 keys 等长,且顺序一一对应
      return ids.map((id) => byId.get(id) ?? new Error(`User ${id} not found`));
    }),
  };
}

// resolver 改成走 loader:一行改动,21 次查询变 2 次
export const resolvers = {
  Query: {
    posts: (_: unknown, __: unknown, { db }: Ctx) => db.post.findMany({ take: 20 }),
  },
  Post: {
    author: (post: Post, _: unknown, { loaders }: Ctx) =>
      loaders.userById.load(post.authorId),
  },
};

原理一句话:DataLoader 把同一 tick 内的 20 次 load() 收集起来,只调一次 batch 函数(一次 WHERE id IN (...)),再把结果按 key 拆回去分发。附带还送了单请求内的去重缓存。

AI 生成代码自查清单(让 AI 写完 resolver 后逐条过一遍,或者直接把这段贴给 AI 当 review prompt):

  • 所有嵌套字段 resolver(如 Post.author、User.posts)是否走了 DataLoader?凡是直接 db.xxx.findUnique 的,一律标红。
  • loader 是否在每请求 context 里新建?全局单例的 loader = 跨用户缓存污染。
  • batch 函数返回数组是否与 keys 等长且顺序对应?顺序错位是最难查的 bug, symptom 是"偶发串数据"。
  • mutation 之后是否 loader.clear(key) 了?DataLoader 的缓存只在单请求内有效,同一请求里先查后改再查会读到脏数据。
  • 本地压测:列表接口返回 50 条时,用数据库日志数一下实际 SQL 条数,超过 3 条就回去改。

DataLoader 还有两个进阶用法,AI 几乎 never 会主动用,但生产环境迟早要补:

  • prime 预热:如果你在顶层 resolver 里已经一次性查出了关联数据(比如 findMany({ include: { author: true } })),可以用 loader.prime(post.authorId, post.author) 把结果塞进缓存,后续嵌套 resolver 的 load() 直接命中,零额外查询。注意 prime 只在"你确定数据新鲜"时用,写操作之后记得 clear。
  • N+1 的 REST 变体:这个坑不只 GraphQL 有。AI 写的 tRPC / REST 代码里同样常见:for (const post of posts) { post.author = await db.user.findUnique(...) }——循环里 await,串行 N+1,比 GraphQL 版更慢(连并发都没蹭上)。review AI 代码时看到"循环 + await + 查库"三连,直接标红,改成 Promise.all 或一次 IN 查询。

AI 专属坑之二:over-fetching,给公开 API 加护栏

GraphQL 把"查什么"交给了客户端,这是它灵活的原因,也是它危险的原因。AI 生成的 schema 里如果 User.posts 又能套 Post.author,再套 User.posts……一个恶意查询可以写成 20 层嵌套,一次请求拖垮数据库。REST 时代你至少能靠"端点固定"控制爆炸半径,GraphQL 把这个半径交给了调用方——公开 API 必须自己把半径收回来。

护栏有两层,两个都要上:

第一层:查询深度限制。直接掐死嵌套层数,一行配置:

import depthLimit from 'graphql-depth-limit';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(7)], // 超过 7 层直接拒绝,连执行都不执行
});

7 这个数字怎么来的?数一下你正常业务最深的查询(feed → post → author → avatar 大概 4~5 层),再留 2 层余量。定死之后写进文档,客户端超了就自己拆查询。

第二层:复杂度计分(cost analysis)。深度限制防不住"宽"查询:posts(limit: 10000) { ... 30 个字段 } 只有 2 层深,但能把数据库读穿。用 graphql-query-complexity 给每个字段定价:

import { getComplexity, simpleEstimator } from 'graphql-query-complexity';

const server = new ApolloServer({
  typeDefs,
  resolvers,
  validationRules: [depthLimit(7)],
  plugins: [
    {
      async requestDidStart() {
        return {
          async didResolveOperation({ request, document }) {
            const complexity = getComplexity({
              schema,
              query: document,
              variables: request.variables,
              estimators: [simpleEstimator({ defaultComplexity: 1 })],
            });
            if (complexity > 500) {
              throw new GraphQLError(`Query too complex: ${complexity} (max 500)`);
            }
          },
        };
      },
    },
  ],
});

更进一步的打法是给"贵字段"单独定价:比如 posts 按 limit 参数乘系数计分,search 这类全文检索字段直接定 50 分。预算(上面示例的 500)按你的数据库压测结果调:先压出单实例 QPS 拐点,再反推每个查询允许的复杂度上限。

贵字段定价的具体写法(用 fieldExtensions 声明,比在插件里写 if 清爽):

import { makeExecutableSchema } from '@graphql-tools/schema';

const typeDefs = /* GraphQL */ `
  type Query {
    posts(limit: Int = 20): [Post!]!
    search(keyword: String!): [Post!]!
  }
`;

const resolvers = {
  Query: {
    posts: {
      // 复杂度 = limit 参数值 × 2:查 10 条计 20 分,查 500 条直接超预算
      extensions: { complexity: ({ args }: any) => (args.limit ?? 20) * 2 },
      resolve: (_: unknown, { limit }: { limit: number }, { db }: Ctx) =>
        db.post.findMany({ take: limit }),
    },
    search: {
      extensions: { complexity: 50 }, // 全文检索固定 50 分,贵就贵得明明白白
      resolve: (_: unknown, { keyword }: { keyword: string }, { db }: Ctx) =>
        db.post.search(keyword),
    },
  },
};

如果是公开 API(第三方调用的那种),护栏再加一档:持久化查询(persisted queries)allowlist——生产环境只接受预先注册过的查询哈希,任意手写查询直接拒绝。这是 Stripe、GitHub 这类公开 GraphQL API 的标准做法:灵活性收归你审核过的查询集合,攻击面直接清零。单人项目可以用 @graphql-tools/persisted-query 这类现成方案,半天能接完。

记住顺序:先 depth limit(5 分钟),再复杂度计分(半天),最后 persisted queries(公开 API 才需要)。别一上来就搞 allowlist,会把你自己的迭代速度也锁死。

护栏之外,还有一层很多人漏掉的:速率限制(rate limiting)要按"复杂度"而不是按"请求数"限。传统 REST 网关按 IP 限"每分钟 100 次请求",搬到 GraphQL 上就失灵了——1 次复杂查询顶 100 次简单查询。正确做法是按复杂度扣配额:每个 token 每分钟 10000 分复杂度预算,简单查询一次扣几分,复杂查询一次扣几百分。这样攻击者没法用"合法次数内发超大查询"绕过限制。实现上可以在第 2 层的插件里顺手做:算出 complexity 后,从 Redis 里扣减该 token 的剩余额度,扣完直接 429。AI 生成限流代码时默认都是按次数限的,这条要你明确提。

认证与鉴权:tRPC middleware vs GraphQL directive

两种技术栈挂鉴权的位置不一样,但思路一致:在框架层统一拦截,不要散落在每个 resolver / procedure 里手写 if (!user) throw。AI 最爱干的就是后者——每个函数里复制粘贴一段鉴权,漏一处就是越权。

tRPC:middleware + procedure 组合。

// server/trpc.ts —— 在 t 后面追加
import { TRPCError } from '@trpc/server';

const isAuthed = t.middleware(({ ctx, next }) => {
  if (!ctx.session?.user) {
    throw new TRPCError({ code: 'UNAUTHORIZED', message: '请先登录' });
  }
  // 把 user 写进 ctx,后续 procedure 直接用,不用再判空
  return next({ ctx: { ...ctx, user: ctx.session.user } });
});

export const protectedProcedure = t.procedure.use(isAuthed);

// 业务侧:一行声明,鉴权完成
export const postRouter = router({
  create: protectedProcedure
    .input(z.object({ title: z.string().min(1).max(120) }))
    .mutation(async ({ input, ctx }) => {
      return ctx.db.post.create({ data: { ...input, authorId: ctx.user.id } });
    }),
});

GraphQL:directive 声明式鉴权。

# schema 里声明意图
directive @auth(requires: Role = USER) on FIELD_DEFINITION

enum Role { USER ADMIN }

type Mutation {
  createPost(title: String!, body: String!): Post! @auth
  deletePost(id: ID!): Boolean! @auth(requires: ADMIN)
}
// directive 实现:集中一处做角色检查
import { mapSchema, getDirective, MapperKind } from '@graphql-tools/utils';

function authDirective(directiveName: string) {
  return {
    authDirectiveTypeDefs: `directive @${directiveName}(requires: Role = USER) on FIELD_DEFINITION`,
    authDirectiveTransformer: (schema: GraphQLSchema) =>
      mapSchema(schema, {
        [MapperKind.OBJECT_FIELD]: (fieldConfig) => {
          const dir = getDirective(schema, fieldConfig, directiveName)?.[0];
          if (!dir) return fieldConfig;
          const { resolve = defaultFieldResolver } = fieldConfig;
          fieldConfig.resolve = async (source, args, ctx, info) => {
            if (!ctx.user) throw new GraphQLError('请先登录');
            if (dir.requires === 'ADMIN' && ctx.user.role !== 'ADMIN') {
              throw new GraphQLError('需要管理员权限');
            }
            return resolve(source, args, ctx, info);
          };
          return fieldConfig;
        },
      }),
  };
}

划界说明:权限模型本身(角色怎么分、权限怎么存)不在本篇展开,直接复用《从一个人到一个团队:vibe 项目的 RBAC 权限模型落地实战》的结论——那篇给的四角色最小模型(owner / admin / member / viewer)+ Prisma 表结构可以直接抄。本篇只解决"鉴权代码挂在 API 层的哪个钩子上"这个问题:tRPC 挂 middleware,GraphQL 挂 directive,两者都是"声明一次、处处生效",杜绝散落的手写 if。

一个容易忽略的细节:GraphQL 的字段级鉴权要注意 @auth 只保护了字段被"直接查询"的情况。如果 Post.author 嵌套展开时带了敏感字段(比如 User.email),要在 schema 层就把敏感字段拆到需要更高权限的 type 里,或者在 resolver 里二次检查。AI 生成 schema 时几乎从不考虑这一点,review 时重点看。

tRPC 侧的一个组合技巧:middleware 可以链式叠加,角色鉴权不用每个 procedure 重写:

// adminProcedure = 登录检查 + 角色检查,两行声明一个新 procedure
const isAdmin = t.middleware(({ ctx, next }) => {
  if (ctx.user.role !== 'ADMIN') {
    throw new TRPCError({ code: 'FORBIDDEN', message: '需要管理员权限' });
  }
  return next();
});

export const adminProcedure = protectedProcedure.use(isAdmin);

// 业务侧:adminRouter = router({ deletePost: adminProcedure.mutation(...) })

最后是测试建议,两种栈通用:鉴权逻辑必须有负向测试——未登录调 protectedProcedure 期望 UNAUTHORIZED、普通用户调 admin 接口期望 FORBIDDEN、GraphQL 侧带错误 token 查 @auth 字段期望报错。AI 写测试时只会写"正常登录能调通"的正向用例,负向用例要你明确要求它补。这三条测了,越权漏洞就堵住了 90%。

还有一个两边通用的地基:ctx 里的 session 从哪来。标准做法是在 createContext 里解析请求头里的 token(或 cookie),查一次用户表,把 { user, loaders } 塞进 context——注意 DataLoader 的实例也是在这里 per-request 创建的(第 4 节的原因)。AI 经常把"解析 token"写进每个 middleware,把"建 loader"写成全局单例,两个都是错的:token 解析只做一次放 context,loader 每个请求新建。context 是 API 层的"地基",地基歪了上面全歪。

决策树:默认 tRPC,哪 3 个信号出现才切 GraphQL

前面所有讨论收敛成一张图。规则很简单:默认 tRPC,只有当下面三个信号出现任意一个,才值得付出 GraphQL 的额外成本(schema 维护 + DataLoader + 查询护栏,第 4、5 节那些全是成本)。

API 层选型决策树:默认 tRPC,出现多端消费、公开 API、聚合网关任一信号时切 GraphQL

三个信号逐个解释:

  • 信号 ①:多端消费。Web、iOS、Android 三个客户端要的字段差异大到"一个 REST/tRPC 接口要么 over-fetch 要么开 N 个版本"。GraphQL 的"客户端点菜"在这里是真省事:移动端拿精简字段,Web 拿完整字段,后端只维护一套 resolver。
  • 信号 ②:公开 API。你要做开放平台、给第三方提供调用。GraphQL 的自描述 schema(introspection)+ 强类型是天然的 SDK 替代品,第三方开发者体验好一个档次。但记住第 5 节的护栏一个不能少,公开 = 被攻击面。
  • 信号 ③:聚合网关。你有多个 REST 服务 / 数据源(比如主业务库 + 第三方 SaaS + 旧系统),前端不想调 5 个接口拼数据。GraphQL 当 BFF(Backend For Frontend)聚合层,一次查询扇出到多个下游,这是它最经典的架构位置。

三个信号都没出现?那就别折腾。tRPC + 好好设计的 router,撑到日活几万没有任何问题。选型最大的成本不是技术本身,是你为"可能用得上"提前支付的学习和维护成本——单人团队付不起这个。

再给三个信号各配一个"反例",防止你被信号骗了去上 GraphQL:

  • 你有 Web + 小程序两个端,但字段差异只是"小程序少要两个字段"——这不叫多端消费,这叫给小程序单独写个精简版 tRPC procedure,半小时的事。
  • 你的"公开 API"其实只有你自己的另一个 side project 在调——这不叫开放平台,这叫内部调用,tRPC + 共享 AppRouter 类型就能解决。
  • 你的"多个数据源"其实就一个 Postgres 加一个 Redis——这不叫聚合网关,这叫在 service 层拼一下数据。等你真有 3 个以上异构数据源再谈 BFF。

判断标准一句话:信号成立的前提是"用 tRPC/REST 会痛",而不是"用 GraphQL 会爽"。痛是成本,爽是幻觉。

补一个 REST 的位置:非 TS 客户端(比如要给 Python 脚本调用)、强依赖 HTTP 缓存的读多写少接口、已经有 OpenAPI 生态的场景,REST 依然是正确选择,不用觉得"不够潮"。

迁移 SOP:REST → tRPC 渐进式替换 5 步

最后给已经有一坨 REST、想换 tRPC 的人一个 5 步 SOP。核心原则:先新后旧、双跑验证、再删旧路由——任何"停机重写"在单人项目里都是自杀。

第 1 步:新功能只写 tRPC。从今天起,所有新接口走 app/api/trpc,不再新增 REST 路由。先让 tRPC 的水管通起来(第 2 节的集成代码半小时能跑通),旧代码一根不动。

第 2 步:逐个迁移高频路由,复用原 service 函数。挑调用量最高的 3~5 个 REST 路由(一般是列表、详情、创建),把它们 handler 里的业务逻辑原样搬进 tRPC procedure:

// 迁移前:app/api/posts/route.ts(REST)
export async function GET(req: Request) {
  const posts = await postService.list(parseLimit(req));
  return Response.json(posts);
}

// 迁移后:server/routers/post.ts(tRPC),service 函数原样复用
list: publicProcedure
  .input(z.object({ limit: z.number().min(1).max(50).default(20) }))
  .query(({ input }) => postService.list(input.limit),
  // postService.list 就是原来 REST handler 调的那个函数,一行不改

注意 zod 的 .input() 顺手替代了原来手写的参数校验——原来散在各 handler 里的 if (!limit) return 400 可以删了,这是迁移的附带收益。

第 3 步:双跑 1~2 周,对比验证。新老接口同时在线,前端先切到 tRPC,对比三件事:返回数据 diff(抽样日志对比)、错误率(tRPC 的 onError 回调里打点)、P99 延迟(tRPC 的 httpBatchLink 会把多次调用合并成一个 HTTP 请求,延迟通常更低,但要实测确认)。

第 4 步:旧 REST 路由打废弃标记。确认无误后,给旧路由加 Deprecation: true 响应头(Sundown 日期写清楚),前端确认没有调用方后进入观察期。废弃公告的写法、双跑观察期的指标口径,按《API 别翻车:vibe 项目的版本管理与废弃 SOP》那套模板走,本篇不重复造轮子。

第 5 步:观察期结束,删除旧路由。删代码前最后检查一遍:全局搜索旧路径字符串、查网关/监控里是否还有流量、确认移动端发版已覆盖。删完跑一遍全量类型检查(tsc --noEmit)——tRPC 的好处在这里又体现一次:删掉的路由如果还有前端在调,编译直接报错,不用靠人肉搜索。

整个 SOP 跑下来,一个中等体量的 vibe 项目(30~50 个 REST 路由)大约 2~3 周可以迁完,且全程线上无感。记住顺序不能乱:先让新水管通水,再拆旧水管。凡是想"周末重写一版,周一直接切"的,最后都在周一凌晨回滚——别问我怎么知道的。

附:迁移回滚预案(第 3 步双跑期间如果翻车,按这个顺序处理):

  • 前端 tRPC 调用全部走统一的 trpc client 实例,回滚 = 把出问题的页面切回旧 REST 调用,一处改 import 即可——所以第 2 步迁移时别把旧 REST handler 删了,留着当逃生通道。
  • 双跑期间给 tRPC 响应打标(header 或日志字段 x-api-layer: trpc),监控里能一眼区分两种水管的错误率,定位时不用猜。
  • 数据写操作(mutation)先迁读接口、后迁写接口:读坏了最多白屏,写坏了是脏数据。AI 排迁移顺序时经常按文件字母序排,这个优先级要你亲手定。

收个尾之前,先给一个能直接用的东西:把本文的规范浓缩成一段 prompt 模板,以后让 AI 写 API 代码时贴在前面,能挡掉 80% 的返工。

API 规范(tRPC 项目,严格遵守):
1. 所有接口走 server/routers/ 下的 tRPC router,命名一律 资源.动作
   (post.list / post.create),禁止新增 REST 路由。
2. 每个 procedure 必须写 zod .input() 校验,zod 字段与 Prisma 模型逐一
   对齐:Prisma 可选则 zod .optional(),禁止"更严格是好事"式加码。
3. 需要登录的接口用 protectedProcedure,管理员接口用 adminProcedure,
   禁止在函数体内手写 if (!user) 鉴权。
4. 返回数据禁止在循环里 await 查库;需要关联数据时一次查出或用
   DataLoader,列表接口先数 SQL 条数再交差。
5. 改字段名时先改 router,编译器报错的地方就是要同步改的前端,
   全部改完、tsc --noEmit 通过才算做完。

收个尾。API 选型在 vibe coding 时代有个新权重:以前选型看的是"团队熟悉什么",现在要看的是"AI 写这种代码时,机器能不能替我兜底"。tRPC 兜底靠编译器,GraphQL 兜底靠 schema 纪律 + DataLoader + 查询护栏三件套,REST 基本靠人肉。一个人 + 一个 AI 的团队,人肉是最稀缺的资源——所以默认 tRPC,信号来了再上 GraphQL,REST 留给它真正擅长的地盘。这个排序不是技术优劣,是成本账。

阅读 0评论 0

评论 (0)

ME
0/1000
评论加载中...
浏览项目广场发布你的项目

相关文章

MacBook 键盘特写实拍,象征独立开发者的桌面应用分发
指南
做出来了,发不出去:vibe 项目的桌面应用分发实战

AI 一晚上就能写出桌面应用,但 macOS 公证、Windows 签名证书、自动更新全是流程型脏活。本文给一条从打包到用户收到的完整跑道:Electron vs Tauri 决策、Developer ID 签名+公证全流程、OV/EV 与 Azure 云签名、双端自动更新、四个分发渠道、CI 打包流水线,以及发版前 12 项检查清单。

部署上线ElectronTauri
手机上的私信聊天界面:一对一对话气泡与底部输入框
指南
vibe 项目私信系统实战:三种会话形态、投递链路与反骚扰四件套

私信不是发个消息那么简单。本文带你走完 vibe 项目私信系统的完整实战:三种会话形态取舍、三表数据模型与陌生人消息请求状态机、WebSocket 投递与幂等去重、已读水位线、反骚扰四件套、附件签名 URL、推送静默聚合、删除语义辨析,以及 12 条上线必测用例。

PostgreSQL后端工程项目创作
表单构建器拖拽搭建界面截图:左侧字段组件面板、中间画布、右侧属性面板
指南
vibe 项目的表单构建器实战:Schema、渲染引擎与拖拽搭建

问卷、报名表、申请表……当你的 vibe 项目长出第三个表单,手写维护就开始失控。这篇实战指南带你从零搭一个表单构建器:JSON Schema 做数据契约、UI Schema 管布局、类型到组件的注册表做渲染分发、规则引擎做条件逻辑,再到拖拽搭建器与 schema 版本化——附 AJV/zod 选型判断与上线 checklist。

开发工作流项目创作独立开发