gRPC-Web 未能征服网页
gRPC-Web 是一个奇怪的协议。它的存在,仅仅因为浏览器无法直接使用 gRPC。原因在于,gRPC 调用最终的状态信息是通过 HTTP trailer 传递的,而浏览器并不将这些字段暴露给 JavaScript。
尽管如此,团队仍希望在浏览器中使用 gRPC 那种优先定义 schema、类型安全的设计模式,于是 gRPC-Web 将 trailer 移到了响应体中,并保留了 gRPC 其余的帧结构与语义。这让 gRPC 得以进入浏览器,代价是诞生了一个其他 Web 技术无法理解的协议。
Connect 本应是 gRPC-Web 该有的样子:一个在保留 Protobuf 契约与自动生成客户端的同时,采用 Web 标准已有三十年历史的协议方案。
我稍后会回到 Connect 的话题。但在此之前,我需要先厘清自己所说的 gRPC-Web 到底指什么。
gRPC-Web 究竟出了什么问题?
grpc/grpc-web 是 Google 推出的 JavaScript 客户端,用于从浏览器调用 gRPC 服务。传统上,要让这套方案跑通,你需要在它前面加一个翻译代理层,而官方 README 推荐的正是 Envoy。
该协议规范在 gRPC 仓库中粗略定义,与 gRPC 存在几处差异。由于浏览器 API 不向 JavaScript 暴露 trailers,因此将 trailers 移入响应体,作为每个流式调用和 unary 调用的最后一个帧。此外,它还做了一些其他实用改动:content type 从 application/grpc 改为 application/grpc-web(实际使用时会加上编码后缀,如下文看到的 application/grpc-web+proto),这样服务器可以识别被要求使用的协议。借助新的 application/grpc-web-text content type,数据流被 base64 编码以绕过 XHR——XHR 只能以文本方式增量读取响应。自从 fetch() 支持流式二进制响应体后,浏览器就不再需要这种处理了,但官方 gRPC-Web 客户端仍基于 XHR 构建,对服务器端流式传输仍需文本模式。除此之外,它依然保持着 gRPC 的典型特征:五字节消息前缀、grpc-status 代码「trailers」,以及仅支持 POST 请求。这正是其设计目标:保留 gRPC 的语义和帧格式,使得服务器、代理和客户端库能够适配而非重写。
效果不错。 Protobuf 契约如今可通过 gRPC-Web 到达浏览器,同一段 Schema 既能生成 Go 服务端桩代码,也能生成前端可用的类型化客户端。我仍对这个规范有所保留,但 grpc/grpc-web 项目的现状更为重要。
该项目实质上已进入维护模式。其路线图称「我们计划不再添加新功能」,理由是Google Closure已归档,而Protobuf JavaScript也仅维持最低限度的维护。它没有推荐其他实现了 gRPC-Web 的客户端,反而推荐了 gRPC-Gateway——后者根本不实现 gRPC-Web,只是在 JSON REST API 与 gRPC 之间进行转码。我认为这是一个奇怪的选择。
如今这套协议已经走出了最初的参考客户端,有了自己的生态。Envoy 依然可以作为代理,把 gRPC-Web 转发给普通的 gRPC 服务器,也有不少服务器端和客户端实现能直接支持它。gRPC-Web 至今仍被广泛支持,而这正是我要抱怨的地方。
被掩盖的失败
HTTP 状态码的存在是有道理的:200 表示成功,404 表示资源不存在,500 表示服务器出错。这套约定已经深入人心,连大多数普通用户都知道 404 是什么。
gRPC 明明构建在 HTTP 之上,却完全抛弃了这套约定。只要传输层正常工作,gRPC 响应的 HTTP 状态码就是 200 OK,你得从末尾的 grpc-status trailer 里才能挖出 RPC 本身的结果。用 trailer 也说得过去:流式 RPC 可能在响应已经开始传输之后才失败,那时状态行早就发出去了,所以状态只能放在最后。但 gRPC 把这条规则一视同仁地套用到了一元调用上。
gRPC-Web 更进一步,把状态藏到了更难看到的地方。trailer 被挪进了响应体里,这样一来,负载均衡器或 Web 应用防火墙本来还能解析 HTTP trailer,现在则必须理解 gRPC-Web 的帧格式才能发现错误。
看个例子。数据库挂了,RPC 以 internal 错误失败。跟着这个请求穿过服务器和浏览器之间的各层:
客户端之上的每一层通用 HTTP 组件都会把这次交互当成成功。失败确实存在,但只有服务器和客户端知道。想实现可靠的监控、负载削减策略,或者复用现有的 HTTP 告警规则?祝你好运。所有现有基础设施都得深入理解 gRPC-Web 才能获得这种可观测性,而绝大多数根本做不到。
把这些响应放到监控面板上,数据库已经挂了,它却显示 100% 成功率。这应该让你感到警觉。
不透明的载荷
流式传输需要额外的帧格式,因为多条消息要在同一个 HTTP body 里发送,总得有个东西标记每条消息在哪里结束。gRPC 在每条消息前面加了五个字节的前缀:一个压缩标志加一个四字节的长度。gRPC 解析器就靠这个四字节长度来切分流中的消息。
尽管这种帧结构原本只为流式传输而设,但一元调用(unary calls)也不得不继承同样的前缀。HTTP 早已具备处理「单次请求对应单次响应」的机制:Content-Length 标明请求或响应的大小,Content-Type 描述负载的数据形态,Content-Encoding 与 Accept-Encoding 则负责处理压缩。
以下通过实时的 Eliza 演示,说明这个额外帧在实际中的表现。使用 curl 这类通用工具时,你必须自行构造 gRPC-Web 帧:
printf '\x00\x00\x00\x00\x0f\x0a\x0dI feel happy.' | curl -sS --data-binary @- \
-H 'Content-Type: application/grpc-web+proto' \
https://demo.connectrpc.com/connectrpc.eliza.v1.ElizaService/Say | xxd前五个字节是帧前缀:一个零标志位,接着是 0f,表示后续有 15 个字节。响应也以相同方式被封装:
00000000: 0000 0000 2a0a 2847 6f6f 642c 2074 656c ....*.(Good, tel
00000010: 6c20 6d6f72 6520 6162 6f75 7420 7468 65 l me more about
00000020: 7365 2066 6565 6c69 6e67 732e 8000 0000 these feelings..
00000030: 2067 7270 632d 6d65 7373 6167 653a 20 .. grpc-message
00000040: 0d0a 6772 7063 2d73 7461 7475 733a 20 : ..grpc-status:
00000050: 300d 0a 0..Eliza 的回复就在其中,连同 grpc-status: 0,它们都放置在主体(body)里,而不是像传统 gRPC 那样放在 trailer 中。Eliza 的回复内容会随机变化,所以你抓到的字节可能不同,但数据整体结构保持不变。
由此可见,你完全可以使用标准 HTTP 工具发送 gRPC-Web 请求,但需要在发送时自己封装帧结构,在接收时再将其拆解还原。
契约的终点
我之前提到,gRPC-Web 的路线图建议改用 gRPC-Gateway。我们来拆解一下。它确实可以从 gRPC 服务暴露一个对 Web 友好的 API,但同时也会丢掉契约驱动 API 的核心优势,迫使你为 Web 客户端单独维护一份契约,或者手写 Web 客户端代码。
使用 gRPC-Web 时,一个 Schema 同时生成服务端和浏览器端客户端,中间通过一个代理协议转换:
gRPC-Gateway 则会从该 Schema 生成一个反向代理。浏览器不再直接通信 Protobuf 定义的 RPC API,而是与代理前的 JSON/HTTP API 交互。如果你还需要生成的前端客户端,常规做法是从 Protobuf 生成 OpenAPI,再从 OpenAPI 生成客户端:
这让你回到了原本就有的状态:一个由 Schema 生成的、类型安全的客户端。区别在于 Protobuf 契约现在止步于网关,而在变更到达前端之前,还要经过另一层生成物来传递。
网关本身也是从 Schema 生成的。protoc-gen-grpc-gateway 会为每个 HTTP 绑定生成处理程序和翻译代码,因此新增 RPC 或修改其 HTTP 映射意味着必须重新生成这些代码。如果网关作为独立代理运行,那就还得部署另一个生成的产物,伴随 API 的变更一起上线。
如果你确实需要一个独立的公开 JSON/HTTP API,这样的边界划分完全合理。但如果目标是把 Protobuf 契约带入浏览器,那为了再生成一个类型安全的客户端而特意去生成 OpenAPI 只是多余的间接层,还会成为两种 Schema 语言产生分歧的新隐患。
在 GA 发布将近八年之后,gRPC-Web 官方推荐的浏览器替代方案竟然是一个让浏览器彻底停止使用 gRPC-Web 的翻译层。我们完全可以在不增加额外翻译层的同时保留 Protobuf 定义的契约,并与标准 Web 工具链协同工作。
Connect 的做法
我们在 Buf 构建了 Connect。它保留了 Protobuf 服务契约和生成客户端的能力,同时放弃了那些仅在作为后端传输时才合理的 gRPC 特性。
能用标准 HTTP 状态码表达失败,就用标准 HTTP 状态码。Connect 用标准 HTTP 状态码表示粗粒度的结果,用响应体承载细节。同样是数据库宕机,向同样五层报告的结果如下:
这条路径上没有任何环节需要学习自定义协议才能拿到正确结果。CDN 看到的是 500,记录的也是 500;错误监控面板能正常统计;客户端依然能收到 internal 错误码,以及服务器附带的类型化详情。在线上传输时,一个 Connect 错误就是状态码加 JSON 响应体。比如下面这个服务器无法反序列化的请求:
HTTP/2 400
content-type: application/json
{"code":"invalid_argument","message":"unmarshal message: invalid value for string type"}流式调用是例外,原因前面说过:响应一旦开始,状态行就已发送出去了。与 gRPC 和 gRPC-Web 类似,Connect 对流式调用也返回 200,成败信息通过 EndStreamResponse 报告。Connect 只在调用形式迫使它偏离的地方,才偏离普通 HTTP 语义。
一元调用的响应体就是消息本身。Content-Type 为 application/json 或 application/proto,响应体就是……