第 53 章 / 共 57 章 · 约 6 分钟
API 设计 1:REST、RPC 与 GraphQL
设计 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 查询。
| 维度 | REST | gRPC | GraphQL |
|---|---|---|---|
| 基本单位 | 资源 + HTTP 方法 | 函数调用 | 带类型的查询 |
| 契约 | OpenAPI(可选) | .proto,强制 | schema,强制 |
| HTTP 缓存 | 天然支持 GET 缓存 | 不支持 | 需要额外手段(持久化查询) |
| 流式 | 需要 SSE 或 WebSocket | 原生支持 | 订阅(Subscription) |
| 浏览器直连 | 可以 | 需要代理 | 可以 |
| 最适合 | 对外公开 API | 服务之间的内部调用 | 客户端种类多、页面数据需求多变 |
案例:聊天应用
假设要为一个聊天应用设计 API:用户有会话列表,会话里有消息,要显示在线状态和未读数,新消息要实时到达。
查看图表源码
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。