cswork / 系统设计

第 53 章 / 共 57 章 · 约 6 分钟

API 设计 1:REST、RPC 与 GraphQL

本页目录
  1. 三种风格的核心
  2. 案例:聊天应用
  3. 面试里怎么回答
  4. 章节测验

设计 API 时,第一个问题往往是风格:REST、RPC(以 gRPC 为代表)还是 GraphQL。这三种没有谁绝对更好。选择取决于谁来调用、要不要缓存、需不需要流式传输、客户端的数据需求有多分散。这一章先讲三者的核心差别,再用一个聊天应用说明它们怎么搭配使用。

三种风格的核心

REST 把系统看成一组资源,用 URL 表示资源,用 HTTP 方法表示动作:GET 读取,POST 创建,PUT 和 PATCH 修改,DELETE 删除;结果用状态码表达。它的优势在于和 HTTP 生态完全对齐:GET 可以被浏览器和 CDN 缓存,状态码、重定向、条件请求都现成可用,任何语言写个 HTTP 客户端就能调用。

RPC 把系统看成一组函数:GetStatus(user_id)。gRPC 用 Protocol Buffers 定义接口,自动生成各语言的客户端和服务端代码,基于 HTTP/2 传输二进制数据,原生支持双向流。它的优势是契约严格、性能好;缺点是浏览器不能直接调用(需要 gRPC-Web 之类的代理),也不能直接利用 HTTP 缓存。

GraphQL 只暴露一个端点,由客户端在查询里写明要哪些字段、要嵌套到多深,服务端按 schema 解析返回。它解决的是“一个页面要拼好几个接口”(请求不足)和“接口返回一堆用不上的字段”(数据过多)。代价也很明确:查询一般用 POST 发送,HTTP 缓存基本用不上;一个查询可能触发大量数据库访问,要限制查询深度和复杂度;字段解析器容易引发 N+1 查询。

维度RESTgRPCGraphQL
基本单位资源 + HTTP 方法函数调用带类型的查询
契约OpenAPI(可选).proto,强制schema,强制
HTTP 缓存天然支持 GET 缓存不支持需要额外手段(持久化查询)
流式需要 SSE 或 WebSocket原生支持订阅(Subscription)
浏览器直连可以需要代理可以
最适合对外公开 API服务之间的内部调用客户端种类多、页面数据需求多变

案例:聊天应用

假设要为一个聊天应用设计 API:用户有会话列表,会话里有消息,要显示在线状态和未读数,新消息要实时到达。

聊天应用的 API 分层:对外 REST 与 WebSocket,对内 gRPC
滚动到此处后加载图表…
图表可横向滚动,展开后可缩放查看。
查看图表源码
flowchart LR
  accTitle: 聊天应用的 API 分层:对外 REST 与 WebSocket,对内 gRPC
  app["手机 / 网页客户端"] -->|"REST 或 GraphQL"| gw["API 网关"]
  app <-->|"WebSocket"| ws["实时网关"]
  gw -->|"gRPC"| msg["消息服务"]
  gw -->|"gRPC"| user["用户服务"]
  ws -->|"gRPC 流"| pres["在线状态服务"]
  msg -->|"gRPC"| push["推送服务"]

对外:REST 加 WebSocket

会话和消息是天然的资源,用 REST 表达很清晰:

GET   /v1/conversations?limit=20&cursor=eyJ0IjoxNzM...
GET   /v1/conversations/c_81/messages?before=m_9902&limit=50
POST  /v1/conversations/c_81/messages
      {"client_msg_id": "4f1c...", "text": "on my way"}
PATCH /v1/conversations/c_81
      {"last_read_message_id": "m_9907"}
POST  /v1/messages/m_9907:recall

几个设计点:

  • 消息列表用游标翻页:before=m_9902 表示“比这条更早的 50 条”。聊天记录在不断追加,按页码翻页会重复或漏掉消息,下一章会细讲。
  • 发消息带 client_msg_id:客户端生成的唯一 ID。网络超时后重发,服务端发现 ID 重复就返回第一次的结果,不会出现两条一样的消息。
  • “标记已读”不是新建资源,而是修改会话的一个字段,用 PATCH。
  • “撤回消息”是一个动作,硬套成 CRUD 很别扭,可以用 POST /v1/messages/{id}:recall 这种“自定义方法”的写法,语义清楚。

新消息的实时推送不适合让客户端轮询 REST 接口,而是建立一条 WebSocket 长连接,由服务端推送。连接管理和消息投递的细节见聊天系统和实时更新两章。

对内:gRPC

网关后面的消息服务、用户服务、在线状态服务之间用 gRPC。调用方和被调用方都在自己团队手里,不存在浏览器兼容问题,严格的契约和代码生成反而减少了联调成本。在线状态天然是一个变化流,gRPC 的服务端流正好合适:

service Presence {
  rpc GetStatus(GetStatusRequest) returns (GetStatusResponse);
  // The server keeps the stream open and sends a PresenceEvent per change.
  rpc Watch(WatchRequest) returns (stream PresenceEvent);
}

message PresenceEvent {
  string user_id = 1;
  bool online = 2;
  int64 changed_at_ms = 3;
}

.proto 里的字段编号一旦发布就不能改含义,新增字段用新编号,老客户端会忽略不认识的字段。这让接口可以向前演进。

要不要上 GraphQL

如果首页要同时显示会话列表、每个会话的最后一条消息、前三个成员的头像和未读数,用 REST 要么调多个接口,要么专门为首页做一个聚合接口。GraphQL 可以一次查回来:

query Home {
  conversations(first: 20) {
    nodes {
      id
      unreadCount
      lastMessage { text sentAt }
      members(first: 3) { name avatarUrl }
    }
    pageInfo { endCursor hasNextPage }
  }
}

值得上 GraphQL 的信号是:客户端种类多(iOS、Android、Web、小程序),每个页面要的字段都不一样,而且变化很快。只有一个 Web 客户端时,几个专门的 REST 接口通常更省事。上了 GraphQL,就要配套做三件事:用批量加载(DataLoader 一类)解决 N+1,限制查询深度和复杂度,用持久化查询换回缓存能力。

面试里怎么回答

不要说“GraphQL 比 REST 先进”。给出一个组合方案并说明理由:对外 REST,便于缓存,第三方也容易接入;实时消息走 WebSocket;内部服务之间用 gRPC,追求契约严格和性能;客户端数据需求复杂时,在网关层加 GraphQL。

章节测验

3 道选择题
  1. GraphQL 里常说的 N+1 问题指什么?
  2. 为什么 GraphQL 默认很难用上 CDN 和浏览器的 HTTP 缓存?
  3. 聊天应用内部的消息服务和在线状态服务之间,为什么常用 gRPC 而不是 REST?

章节讨论

正在加载讨论…