楼层: 首页/ 软件技术/ Node.js 全栈实战/ gRPC 与 GraphQL:Node 里的另外两种接口形态
14

gRPC 与 GraphQL:Node 里的另外两种接口形态

gRPC & GraphQL in Node

你一定已经会写 REST 了。但工作两三年后,几乎必然会遇到这两个词:GraphQL(前端说"我要什么字段就给我什么,别给我一堆用不上的")和 gRPC(后端说"服务之间调来调去,JSON over HTTP 太费了,用二进制")。它们不是 REST 的替代品,而是解决不同问题的三种工具。这一章的目标:让你在技术选型会上能判断"这个场景该用哪个",并且能跑起来一个能用的最小实现。

先摆清楚:三种接口形态各管什么

RESTGraphQLgRPC
数据格式 JSON(通常),结构由服务端定 JSON,结构由客户端查询定 Protobuf 二进制,结构与字段号由 .proto 定
传输 HTTP/1.1 或 HTTP/2,方法 + 路径 通常是 HTTP POST,单一端点 /graphql 强制 HTTP/2,支持双向流
核心优势 简单、通用、缓存与调试生态最成熟(浏览器直接打开就能调) 一次请求取多资源,不多不少;schema 即文档,前端不用等后端加接口 快、强类型、支持流式;代码自动生成,接口契约编译期就能对
典型痛点 多页面聚合时要发多个请求(over-fetching / under-fetching) N+1 查询、复杂查询可被滥用、HTTP 缓存不友好、错误码语义弱 浏览器不能直接调(要 grpc-web + 代理)、不可读难调试、生态偏重
用在哪 对外 API、开放平台、绝大多数 CRUD 业务 前端数据需求多变的产品(中台、BFF)、多端复用同一套接口 服务之间的内部调用、跨语言微服务、实时流、大数据量传输

论最常见的正确组合:对外 REST + 中间层 GraphQL + 内部 gRPC

对外(给第三方、给开放平台):用 REST。它最好理解、最好调试、最好做缓存和限流,第三方接入成本最低。

前端到 BFF:用 GraphQL。前端页面千变万化,让后端为每个页面写一个定制接口,沟通成本会爆炸;GraphQL 把"取哪些字段"的决定权交给前端,BFF 只负责聚合。

服务之间:用 gRPC。内部调用不需要给人类读,要的是低延迟、强类型、schema 演进安全(Protobuf 的字段号机制让加字段不会破坏老客户端)。

三者不是非此即彼:一个系统里可以三种并存,关键看"这条链路的对面是谁"。

GraphQL:让前端决定要哪些字段

GraphQL 的核心思想是:服务端只声明"有哪些数据、彼此什么关系"(Schema),具体取哪些字段由客户端写 Query 决定。它把"接口数量"从 N 个压成 1 个,代价是把复杂度转移到了服务端的解析器(resolver)上。

最小可用:Apollo Server 5 + 自定义数据加载

// server.js —— Apollo Server 现在推荐用 @apollo/server + standalone 启动 import { ApolloServer } from "@apollo/server"; import { startStandaloneServer } from "@apollo/server/standalone"; const typeDefs = `#graphql type User { id: ID! name: String! posts: [Post!]! # 关系:一个用户的多篇文章 } type Post { id: ID! title: String! author: User! } type Query { user(id: ID!): User posts(first: Int = 10): [Post!]! } type Mutation { createPost(title: String!, authorId: ID!): Post! } `; const resolvers = { Query: { user: (_, { id }, ctx) => ctx.db.findUser(id), posts: (_, { first }, ctx) => ctx.db.listPosts(first), }, // 字段级 resolver:只有客户端真的请求了 posts,这段才会执行 User: { posts: (user, _args, ctx) => ctx.db.listPostsByAuthor(user.id), }, Mutation: { createPost: (_, { title, authorId }, ctx) => ctx.db.insertPost(title, authorId), }, }; const server = new ApolloServer({ typeDefs, resolvers }); // context 每次请求创建一次:放用户身份、数据加载器(DataLoader 必须放这里) const { url } = await startStandaloneServer(server, { context: async ({ req }) => ({ user: await getUserFromToken(req.headers.authorization), createLoaders: () => makeLoaders(), // 每次请求新建,避免跨用户串数据 }), listen: { port: 4000 }, }); console.log(`GraphQL 就绪:${url}`);

坑GraphQL 第一大坑:N+1 查询

假设前端查"20 篇文章及其作者"。朴素实现是:先查 1 次文章列表(1 次查询),然后为每一篇文章去查作者(20 次查询)——总共 21 次。数据一多就是灾难,而且 REST 里你至少能一眼看出来,GraphQL 里它藏在字段级 resolver 里,非常隐蔽。

解法是 DataLoader:它把同一轮事件循环内所有"查 author(id)"的调用合并成一次批量查询(WHERE id IN (...)),再把结果分发回每个调用者。21 次查询变成 2 次。同时它自带请求级缓存,同一个 id 在一次请求里只查一次。

用 DataLoader 干掉 N+1(这段代码值得背下来)

import DataLoader from "dataloader"; export function makeLoaders() { return { userById: new DataLoader(async (ids) => { // ids 是这一轮被问到的、去重后的所有 id,一次查回来 const rows = await db.query("SELECT id, name FROM users WHERE id IN (?)", [ids]); const byId = new Map(rows.map(r => [String(r.id), r])); // ⚠️ 必须按传入 ids 的顺序返回、且长度一致,缺失的位置给 null return ids.map(id => byId.get(String(id)) ?? null); }), }; } // resolver 里改成从 loader 拿,注意:不要在模块顶层创建 loader const resolvers = { Post: { author: (post, _a, ctx) => ctx.createLoaders().userById.load(post.authorId), }, };
DataLoader 的三个致命误用

误用一:把 loader 定义成模块级单例。DataLoader 的缓存不区分用户,全局单例会导致 A 用户请求的数据被 B 用户命中——这是实打实的越权数据泄漏。正确做法是在每次请求的 context 里创建(context: () => ({ loaders: makeLoaders() }))。

误用二:批量函数返回顺序乱了。DataLoader 靠"返回数组的下标"对应"传入 id 的下标"。用 IN 查回来的行顺序和传入顺序无关,必须先建 Map 再按 ids 顺序映射回去,中间一个都不能少(缺失要显式给 null)。

误用三:以为 loader 能解决所有 N+1。如果 resolver 里做了条件分支、或者每一层都自己发查询,照样会 N+1。判断方法很朴素:打开 SQL 日志,看一次 GraphQL 请求到底发了多少条 SQL。

GraphQL 的安全:它能被一条查询打垮

REST 里每个接口的成本是"已知"的,天然好限流。GraphQL 只有一个端点,查询的复杂度完全由客户端决定——一条深度嵌套的查询可以让你的数据库跑到天亮。这是 GraphQL 必须做的四件事。

// ① 限制查询深度:别让 10 层嵌套的查询进来 import depthLimit from "graphql-depth-limit"; const server = new ApolloServer({ typeDefs, resolvers, validationRules: [depthLimit(7)], }); // ② 限制复杂度 + 必须分页:给字段加权,超过预算直接拒绝 // 更专业的做法用 graphql-query-complexity,或直接用 @graphql/depth-limit // 并对所有列表字段强制 first/last 参数、设最大值(如 first <= 100) // ③ 生产环境关闭内省(introspection):别把整张 schema 送给攻击者 new ApolloServer({ typeDefs, resolvers, introspection: process.env.NODE_ENV !== "production", }); // ④ 持久化查询(APQ):只允许"注册过"的查询,彻底封死任意查询 // 前端构建时把用到的 query 生成 hash 上传,线上只传 hash + variables // 既安全又能省带宽(长 query 字符串不用每次传)
  • 字段级授权必须写在 resolver 里,别只在入口做。GraphQL 很容易"从这个关系绕过去拿到不该看的数据"(比如查 order 时带出 user.email)。
  • 错误信息别泄漏内部细节:默认的 GraphQL 错误会把堆栈和 SQL 片段带出去,生产环境要关掉 debug / 自定义 formatError。
  • HTTP 缓存用不上怎么办:GraphQL 是 POST 请求,CDN 默认不缓存。解决办法是持久化查询后改用 GET + hash,或者用客户端缓存(Apollo/URQL 的 normalized cache)。

gRPC:服务之间用二进制说话

gRPC 是 Google 开源的 RPC 框架,一句话概括:用 .proto 文件定义接口和消息,自动生成客户端/服务端代码,走 HTTP/2 传 Protobuf 二进制。它比 JSON over HTTP 快,是因为三件事:二进制编码更紧凑、有 schema 不用猜字段名、HTTP/2 多路复用没有队头阻塞。

第一步:写 proto(这是唯一的"接口契约")

// proto/blog.proto syntax = "proto3"; package blog.v1; service BlogService { rpc GetPost (GetPostRequest) returns (Post); // 一元:请求-响应 rpc ListPosts (ListPostsRequest) returns (stream Post); // 服务端流 rpc UploadImages (stream ImageChunk) returns (UploadResult); // 客户端流 rpc Chat (stream Message) returns (stream Message); // 双向流 } message GetPostRequest { string id = 1; } message ListPostsRequest { int32 page_size = 1; } message Post { string id = 1; string title = 2; string author_id = 3; int64 created_at = 4; }

记Protobuf 用"字段号"而不是"字段名"通信

线路上传的是 1: "abc" 这样的"字段号 + 值",字段名只存在于代码里。所以字段名改了不影响兼容,但字段号绝不能复用——把删掉的 3 号字段重新分配给一个新字段,老客户端会把旧数据当新字段解析,产生静默的数据错乱。

正确做法:删字段时用 reserved 3; 把号段封存,永不复用。这是 Protobuf 演进最重要的一条纪律。

第二步:Node 侧跑一个 gRPC 服务(@grpc/grpc-js)

# 装依赖:@grpc/grpc-js 是纯 JS 实现(不需要编译原生模块),proto-loader 负责读 .proto pnpm add @grpc/grpc-js @grpc/proto-loader
import grpc from "@grpc/grpc-js"; import protoLoader from "@grpc/proto-loader"; const def = protoLoader.loadSync("proto/blog.proto", { keepCase: true, // 保持 author_id 这种下划线命名,别自动转驼峰 longs: String, // int64 转成字符串,避免 JS 精度丢失 enums: String, defaults: true, }); const proto = grpc.loadPackageDefinition(def).blog.v1; // 实现方法:注意签名是 (call, callback),不是 Express 的 (req, res) const handlers = { getPost(call, callback) { const post = findPost(call.request.id); if (!post) { // gRPC 有标准状态码,比 HTTP 语义更细;别直接 throw 普通 Error return callback({ code: grpc.status.NOT_FOUND, message: "文章不存在" }); } callback(null, post); }, // 服务端流:用 call.write 多次推送,最后 call.end() listPosts(call) { for (const p of allPosts(call.request.page_size)) call.write(p); call.end(); }, // 客户端流:监听 data / end 事件累积 uploadImages(call, callback) { let total = 0; call.on("data", chunk => { total += chunk.data.length; }); call.on("end", () => callback(null, { count: total })); }, }; const server = new grpc.Server(); server.addService(proto.BlogService.service, handlers); server.bindAsync( "0.0.0.0:50051", grpc.ServerCredentials.createInsecure(), // 生产环境请用 TLS 凭据! (err, port) => { if (err) console.error(err); else console.log("gRPC 监听", port); }, );

第三步:调用方(同一份 proto,生成类型安全的客户端)

const client = new proto.BlogService( "localhost:50051", grpc.credentials.createInsecure(), ); // 一元调用:务必传 deadline(超时),否则下游挂了你会一直挂着 client.getPost({ id: "42" }, { deadline: Date.now() + 2000 }, (err, res) => { if (err) { if (err.code === grpc.status.DEADLINE_EXCEEDED) console.warn("下游超时"); if (err.code === grpc.status.UNAVAILABLE) console.warn("下游不可用"); return; } console.log(res.title); }); // 服务端流:消费方就是一个可读流 const stream = client.listPosts({ page_size: 100 }); stream.on("data", post => handle(post)); stream.on("end", () => console.log("流结束"));
gRPC 在 Node 里的五个坑

坑一:浏览器不能直接调 gRPC。浏览器的 fetch/XHR 无法控制 HTTP/2 帧,说不了 gRPC 协议。前端要调必须用 grpc-web + 一个代理(Envoy / grpc-web 代理)。所以"把 gRPC 直接暴露给前端"这个方案不成立,通常是前端 → REST/GraphQL → gRPC 内部服务。

坑二:不设 deadline。gRPC 默认没有超时,下游卡住你的调用就一直挂着,最后引发雪崩。每个调用都要给 deadline,而且 deadline 要小于上游给它的超时预算(层层收敛)。

坑三:把 gRPC 状态码当 HTTP 码用。gRPC 有自己的码表:NOT_FOUND(5)、INVALID_ARGUMENT(3)、UNAVAILABLE(14)、DEADLINE_EXCEEDED(4)。判断"能不能重试"要看码:UNAVAILABLE 可以重试,INVALID_ARGUMENT 重试一万次也没用。

坑四:默认用不安全连接上线。createInsecure() 是明文,只在本地开发用。生产必须用 TLS 凭据,或者跑在服务网格(Istio / Linkerd)里由 sidecar 做 mTLS。

坑五:改了 .proto 忘了重新生成代码。Protobuf 的"向后兼容"是消息层面的,代码层面你必须重新跑 codegen(protoc / ts-proto / buf generate)。把它写进构建脚本,别手工生成。

到底选哪个:一张判断清单

  • 对面是第三方开发者或浏览器?→ REST(或 GraphQL,如果对方愿意学)。别给外部用 gRPC。
  • 前端页面对同一份数据的字段需求经常变?→ GraphQL。让前端自己写 query,省掉来回改接口。
  • 服务之间 QPS 高、延迟敏感、要跨语言?→ gRPC。二进制 + HTTP/2 + 代码生成,是这类场景的标准答案。
  • 要传大文件或实时双向通信?→ gRPC 的流式能力(服务端流/双向流)比 REST 的分块/SSE 更规整。
  • 团队只有两三个人、要快速上线?→ 先 REST。GraphQL 和 gRPC 都有明确的学习与运维成本,别在没痛点时引入复杂度。
  • 已经有大 REST 体系,只想优化某条链路?→ 局部引入。比如"前端 → BFF 用 GraphQL,BFF → 内部服务用 gRPC",存量接口不动。
记
本章小结

① REST / GraphQL / gRPC 各管一段:对外 REST、前端到 BFF 用 GraphQL、服务之间用 gRPC,三者可以共存。

② GraphQL 把"取哪些字段"的决定权交给客户端,代价是服务端得管好 N+1(DataLoader)、深度与复杂度限制、字段级授权、生产关内省。

③ DataLoader 必须请求级创建(放 context),批量函数返回顺序必须与传入 id 一一对应。

④ gRPC 用 .proto 定义契约、Protobuf 二进制传输、HTTP/2 承载;支持一元 + 三种流。字段号只加不复用,删字段要 reserved。

⑤ gRPC 两条铁律:浏览器调不了(要 grpc-web + 代理),每次调用必设 deadline。

本章自测(GraphQL / gRPC)

1.(排错题)线上 GraphQL 接口发现:查一页 20 篇文章及其作者,监控显示数据库 QPS 突然飙到原来的 10 倍以上。最可能是什么问题?怎么修?

查看答案

答案:N+1 查询。每篇文章的作者字段各触发了一次查询,20 篇文章就是 21 次。修法:① 用 DataLoader 把同一轮的 author 查询合并成一次 WHERE id IN (...),并且 loader 要在每次请求的 context 里创建;② 确认批量函数返回的顺序与传入 id 一一对应;③ 用 SQL 日志或 APM 验证一次 GraphQL 请求的 SQL 条数是否已经降下来。

2.(辨析题)同事说"GraphQL 只有一个端点,所以特别好做限流和缓存",这句话对吗?

查看答案

答案:不对,两件事都恰好相反。① 限流:单一端点意味着一句"按 URL 限流"毫无意义,因为一条深度为 10 的嵌套查询和一条查单字段的查询成本差几个数量级,必须按查询复杂度/深度限流;② 缓存:GraphQL 基本都用 POST,CDN 和 HTTP 缓存默认不缓存 POST,需要靠持久化查询 + GET 或客户端 normalized cache 来补。

3.(概念题)Protobuf 里为什么"删掉的字段号不能重新发给新字段"?如果违反会发生什么?

查看答案

答案:因为线上传输的是字段号而不是字段名。老版本客户端发过来的数据里,3 号字段是"旧的 A 字段",而新版本服务端已经把它定义成"新的 B 字段",就会把 A 的值按 B 的语义解析——类型可能对得上、不报错,但数据是错的,属于最难排查的静默 bug。正确做法是删除时写 reserved 3; 永久封存该号段。

4.(场景题)你们的内部服务调用超时严重,排查发现上游服务"卡住一段时间后自己好了",日志里只有零星报错。gRPC 的调用代码里最可能缺了什么?

查看答案

答案:缺 deadline(死线)。gRPC 调用默认没有超时,下游慢的时候调用方会一直挂着、连接和线程资源被占满,等下游恢复才集体返回——表现就是"卡一阵子然后自己好"。修法:每次调用传 { deadline: Date.now() + N },并且让 deadline 层层收敛(上游给的 2s 预算,调下游就用 1.5s),同时按 gRPC 状态码区分能否重试(UNAVAILABLE 可重试、INVALID_ARGUMENT 不可)。

5.(设计题)一个团队要把现有 REST 单体改造成"前端统一走 GraphQL、内部服务之间走 gRPC"的架构。请说出这个改造的分步顺序,并解释为什么不能一步到位。

查看答案

答案(参考分步):① 先建 BFF 层,GraphQL schema 直接包装现有 REST 接口(不动存量);② 前端逐个页面迁到 BFF,同时接入 DataLoader 与查询限制,把性能和安全基线立住;③ 再把后端按领域逐个抽出独立服务,服务间新建接口用 gRPC,BFF 内部改为调用 gRPC;④ 全部稳定后再考虑下线旧的 REST 内部接口。不能一步到位的原因:GraphQL 的 N+1/复杂度/授权问题、gRPC 的 TLS/超时/可观测性都需要逐项补齐,同时改三层会让故障根本定位不了;而且迁移期间必须保证新旧接口并存、可回滚。