gRPC 对比 REST 是个错误的问题
在 Twitter、LinkedIn、Reddit、Hacker News 以及任何开发者聚集的地方,同样的争论反反复复上演:gRPC vs. REST。到底在哪里该用 HTTP/JSON API(大多数人所谓的 “REST”),又在哪里该用 gRPC?几乎总是得到同样的建议:公开接口或面向浏览器的 API 用 REST,微服务和 etcd、Kubernetes 等基础设施用 gRPC。
但……为什么?多数人的解释其实都挺准确。使用 application/json 作为内容类型的 HTTP API 无需任何额外工具即可在浏览器中运行,而现有的代理、CDN 和调试器也早已熟悉这种 API 形态。另一方面,gRPC 提供基于 schema 的工作流、生成的客户端,以及紧凑的二进制消息和流式 RPC。两者各有千秋,但我们为什么不能兼得两者的优势呢?
这正是 “gRPC vs REST” 这个提问方式本身有问题所在。Connect 允许浏览器通过 HTTP 和 JSON 调用与 gRPC 客户端相同的服务。
标准 gRPC 需要代理来支持浏览器
gRPC 依赖 HTTP/2 的某些特性,而浏览器 API 并未向 JavaScript 暴露这些特性,其中包括 trailers。因此,标准 gRPC 无法直接在浏览器 JavaScript 中调用。
gRPC-Web 曾试图通过把 trailers 编码到 HTTP 响应体内部的独立帧中来弥合这一差距。其他实现(包括 Connect)仍支持该协议,但官方的 grpc/grpc-web 项目最终失败了。其自身的路线图 承认已无法提供新的现代方案,不打算增加新功能,并建议改用 gRPC-Gateway。早些时候的一篇文章《gRPC-Web 未能征服 Web》详细剖析了其中的原因。
gRPC-Gateway 和 Envoy 允许浏览器通过 HTTP/JSON API 调用你的 gRPC 服务。这两者都支持默认路由,自定义路径和 HTTP 方法则作为 google.api.http 注解写在 proto 文件中。
要在这种架构下测试一次浏览器调用,除了应用本身,你还得把网关也跑起来。自定义映射还会增加 API 设计的工作量。GET /users/{id} 这样的路由可能正是你想要的,但为了从 JavaScript 调一个已有的 GetUser RPC 就专门加一个路由,并不划算。
Connect 不需要代理
Connect 服务端可以在同一个端口上同时支持 gRPC、gRPC-Web 和 Connect 协议。浏览器可以用 JSON 或二进制 Protobuf 发送 Connect 请求,访问的 URL 与 gRPC 客户端完全相同。服务端会先解码请求,再交给你的处理函数。三种协议只需要一套服务端实现:
curl 应该开箱即用
对任何 API 来说,一个最基本的要求就是“把 curl 命令发给同事”测试。这本该是件轻而易举的事。REST API 确实轻松通过,但标准 gRPC 和 gRPC-Web 在这项测试上表现得相当糟糕。
如果你愿意手动构造数据帧,用 curl 也能实现。标准 gRPC 和 gRPC-Web 对数据消息使用相同的帧格式,下面是 gRPC-Web 版本,它不需要 HTTP/2:
printf '\x00\x00\x00\x00\x1c{"sentence":"I feel happy."}' | curl -sS --data-binary @- \
-H 'Content-Type: application/grpc-web+json' \
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say | xxd开头的五个字节是消息前缀:一个标志字节,加上一个四字节的大端长度,即 00 00 00 1c,表示后面跟着 28 字节的 JSON。响应也是用同样的帧格式返回的,状态信息藏在响应体里。即便用 xxd 命令查看,输出也只能算勉强能看懂:
00000000: 0000 0000 277b 2273 656e 7465 6e63 6522 ....'{"sentence"
00000010: 3a22 446f 2079 6f75 206f 6674 656e 2066 :"Do you often f
00000020: 6565 6c20 68617070 793f 227d 8000 0000 eel happy?"}....
00000030: 2067 7270 632d 6d65 7373 6167 653a 200d grpc-message: .
00000040: 0a67 7270 632d 7374 6174 7573 3a20 300d .grpc-status: 0.
00000050: 0a .如果把 I feel happy. 换成一句更长的话,你就得重新计算字节数并更新前缀。而这还是 gRPC-Web 最理想的情况:因为该演示使用的是 Connect 服务器,它对所使用的每种协议都提供了 JSON 编解码器,所以这里的 JSON 编码才能正常工作。协议规范允许这样做,但官方的 gRPC-Web 客户端只发送 Protobuf 数据,而 gRPC-Go 默认也只注册了 Protobuf 编解码器。
像 grpcurl 和 buf curl 这样的工具会帮你处理编码。它们需要访问 schema,你可以自己提供,也可以通过反射机制从服务器获取。
Connect 使用纯 HTTP
对于一元 Connect 调用,你可以将 JSON 作为 POST 请求体发送,并将 Content-Type 设置为 application/json。这里是对上面那个方法发起的请求示例:
curl -X POST \
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say \
-H "Connect-Protocol-Version: 1" \
-H "Content-Type: application/json" \
-d '{"sentence":"Hello"}'你可以直接对该在线演示运行这条命令,并在终端中查看 JSON 响应。
在 curl 命令中加上 -i 参数即可查看 HTTP 状态码。如果一元 Connect 处理器返回 NotFound,你将收到 404 状态码。而 gRPC 对同样的错误会返回 HTTP 200,并将失败信息记录在 grpc-status 尾部字段中。
如果一元方法没有副作用,Connect 允许你使用 GET 请求调用它。为该方法添加此选项并重新生成服务器代码:
rpc Say(SayRequest) returns (SayResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
}然后用 GET 发起调用,将请求参数放在查询字符串中:
curl "https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say?message=%7B%22sentence%22%3A%22Hello%22%7D&encoding=json&connect=v1"因为整个请求都包含在 URL 中,标准的 HTTP 缓存机制即可生效。设置 Cache-Control 响应头后,CDN 和浏览器就能像缓存普通 GET 请求一样处理它。
保留 gRPC 特性
gRPC 阵营的优势在于基于 Schema 的工作流:生成客户端代码、紧凑的二进制消息以及流式传输。Connect 在浏览器及所有其他客户端中均完整保留这些特性。以下是 Eliza 的 Say 方法及其请求与响应消息定义:
syntax = "proto3";
package connectrpc.eliza.v1;
service ElizaService {
rpc Say(SayRequest) returns (SayResponse) {
option idempotency_level = NO_SIDE_EFFECTS;
}
}
message SayRequest {
string sentence = 1;
}
message SayResponse {
string sentence = 1;
}在 buf.gen.yaml 中配置 Protobuf-ES 后,运行 buf generate 即可生成浏览器客户端使用的定义文件:
import { createClient } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { ElizaService } from "./gen/connectrpc/eliza/v1/eliza_pb";
const client = createClient(
ElizaService,
createConnectTransport({ baseUrl: "https://demo.connectrpc.com" }),
);
const { sentence } = await client.say({ sentence: "Hello" });由于 proto 定义将 sentence 声明为字符串,因此 client.say({ sentence: 123 }) 无法通过 TypeScript 类型检查。上述有效调用发送的是 {"sentence":"Hello"},与 curl 命令一致。客户端负责构建 HTTP 请求并解码响应。若在 Transport 层设置 useBinaryFormat: true,即可发送二进制 Protobuf 数据,而无需修改其他代码。
流式功能同样来自生成的客户端。Eliza 的 Introduce 方法是服务器流式 RPC,在代码中表现为异步迭代器:
for await (const response of client.introduce({ name: "Kevin" })) {
console.log(response.sentence);
}浏览器无法通过 fetch 流式传输请求体,所以客户端流和双向流只能留在后端。这个限制来自浏览器,而不是 Connect。浏览器之外的客户端可以使用全部四种 RPC 类型。
Schema 还能在任何客户端受到影响之前捕获破坏性变更。buf breaking 会把 proto 与上一个发布版本对比,一旦发现字段被删除或类型被修改就会报错。由于浏览器客户端和 gRPC 客户端都由同一个文件生成,一次检查就能覆盖所有客户端。
你不必二选一
不能仅仅因为有人需要从浏览器调用服务,就在前面加一层网关。使用 Connect,浏览器客户端由 proto 生成,可以直接调用服务,同时服务端保持对 gRPC 的兼容。
下一篇