GraphQL 模式变更:没有版本号的停用
阅读约 1 分钟
REST API 可以在 /v1/ 旁边发布一个 /v2/,让每个调用方按自己的节奏迁移。GraphQL 却是一个端点对应一份模式,每一个客户端,无论是用去年那个构建版本的移动应用,还是今天早上刚部署的内部仪表盘,查询的都是同一张图。没有哪个 URL 可以被分叉出去。停用一个字段,意味着就地把它标记为已停用,而这份模式所有人早就依赖着;这让这套纪律和 REST 完全不同,尽管底层要解决的问题,也就是告诉调用方某样东西即将消失,和API 停用一般性地讲的其实是同一个问题。
既然没有版本可以升,GraphQL 到底怎么把一个字段标记成已停用
靠直接加在字段上的 @deprecated 指令:
type Product {
price: Float @deprecated(reason: "Use priceV2 for multi-currency support.")
priceV2: Money
}
这个字段依然可以被查询。它不会消失,不会返回 404,也不会改变行为;它只是携带了一条机器可读的说明,大多数 GraphQL 工具,GraphiQL、Apollo Studio、模式检查工具,都会把这条说明展示给任何浏览这份模式、或者对着它写查询的人看。这就是整个机制。没有单独的停用端点,没有响应头,规范也没有要求附带任何文档,这既是它的吸引力,也是它的陷阱:这个指令加起来很容易,被忽略起来也同样容易,因为没有任何东西强迫客户端去看它。
到底有没有人真的会去看停用原因
只有直接使用这份模式的人才会看到,也就是通过内省或者对模式有感知的编辑器去使用的人,而这批受众比一份 API 体验日志的常规读者要小得多。一个六个月前针对某个查询构建出来的移动应用,早就把那条查询烤进了它的二进制文件里;无论那个字段停不停用,它都会继续请求 price,也会继续得到答案,直到有人用新字段重新构建这个应用并发布一次更新为止。这个指令告诉正在写新代码的开发者不要再用那个旧字段。但对那个已经发布、正在运行的客户端,它什么都做不了。
| 机制 | 触达到谁 |
|---|---|
@deprecated 指令 | 浏览模式或编写新查询的开发者 |
| 模式检查工具的 CI 失败 | 拥有客户端代码库的团队,前提是他们真的跑了这套检查 |
| 一条体验日志记录 | 任何读到它的人,包括没有检查工具的客户端团队 |
| 什么都没有(字段照常工作) | 一个已经构建好、还在用旧字段的客户端 |
一个已停用的字段是不是还应该拿到一条体验日志记录
应该,而且它做的事情比那个指令单独能做的要多,因为体验日志能触达到指令触达不到的人:一个消费这张图、却从不浏览它模式的合作方团队,一个针对几个月前缓存下来的那份模式副本构建出来的客户端,任何只有读到一段散文才会注意到这件事的人。API 体验日志讲的是一条记录一般性地欠调用方什么;而一条 GraphQL 记录欠的是 REST 几乎从来不需要明说的一件事,因为 REST 的调用方可以直接从版本号里推断出来:那个旧字段今天是不是还能用、是不是带着警告还能用,还是已经真的不再返回数据了。光靠那个指令,对一个从没打开过这份模式的读者来说,这些问题一个都回答不了。
到底什么时候把一个字段从模式里删掉才算真的安全
只有当查询日志显示已经没人再请求它的时候才算安全,这是一个关于使用情况的问题,而不是关于日历的问题。一个字段可以挂着 @deprecated 整整一年,却依然是某个从未重新构建过的客户端赖以运作的支柱;像 REST 的 Sunset 经常做的那样,按一个固定的时间表把它删掉,会在没有任何调用方能采取行动的警告下弄坏那个客户端,因为除了它从没读过的那个指令之外,GraphQL 没给它任何可以据以行动的东西。在承诺一个删除日期之前,先把字段级别的使用情况记录下来,把任何非零的查询计数当成一次暂停,而不是一个倒计时。
添加一个字段是不是和在 REST API 里承担同样的风险
对新增的字段来说风险更小,因为一个 GraphQL 客户端只会拿到它明确要求的那些字段。在 price 旁边加一个 priceV2,不会像在 REST 的 JSON 响应里加一个字段那样,可能弄坏一个严格的反序列化器去破坏现有查询,因为没有任何东西强迫客户端去请求那个新字段。给一个已有的枚举加一个新值则是同一口气里就该点出来的例外:强类型语言会鼓励客户端针对每一个枚举值都穷举式地做分支判断,而这样的客户端一旦遇到新值就会立刻崩掉,跟有没有查询请求过它毫无关系。这种安全性只对客户端主动选择接收的字段和联合类型成员成立;对一个由客户端代码手工穷举出来的封闭集合,它并不成立。
一条 GraphQL 体验日志记录需要什么,是 REST 记录不需要的
需要的是查询的形态,而不只是字段名,因为「price 字段已停用」恰恰缺了调用方真正需要的那一块:到底哪些类型、哪些查询碰到了它。一条有用的记录会点名类型、字段、替代字段,如果能生成的话,还会点名生产环境里那些仍在请求旧形态的真实查询。这最后一块,把停用通知和真实使用情况绑在一起,正是 REST 调用方能从一个 URL 的服务器日志里免费拿到、而 GraphQL 调用方拿不到的东西,因为不管请求的是什么,每一条查询打的都是同一个端点。
除了字段之外,还有什么能带上 @deprecated 指令
枚举值,用的是同一个指令,只不过加在值本身的定义上,而不是加在字段上:
enum ShippingMethod {
STANDARD
EXPRESS
OVERNIGHT @deprecated(reason: "Use EXPRESS with priority: true instead.")
}
规范把 @deprecated 明确定义在恰好两个位置上,一个字段定义或者一个枚举值,在稳定发布版本里没
有第三个位置;参数和输入字段级别的停用只存在于更晚的草案文本里,并不是大多数服务端今天实际实
现的东西。用这种方式标记过的枚举值依然是服务端可以继续返回或接受的一个合法值,这和一个已停用
字段给出的那个不破坏性的承诺是一样的,也正因如此,在真正删除这个值之前先这样发布出去才是安
全的。
FAQ
GraphQL 支持类似整个端点的 Sunset 响应头那样的东西吗?
不支持,因为通常只有一个端点。停用的时间安排活在字段这个层级,存在于 @deprecated 指令的原因文本里,以及团队随之发布的任何体验日志或迁移指南里,而不在一个客户端能以编程方式读取的响应头里。
一个已停用的字段能不能先删掉,之后再用不同的类型加回来?
只能作为一个新的字段名加回来。用变了的类型重新引入同一个字段名,正是停用周期这套机制存在的意义所在,就是要避免这种破坏性变更;像 priceV2 那样,给替代字段起个自己的名字,让旧的那个彻底消亡之后,那个名字才空出来可以被重新使用。
@deprecated 的原因文本应该链接到那条体验日志记录吗?
应该,只要模式工具支持这么做。原因字段接受一个普通字符串,而字符串里的一个 URL,就是从一个盯着内省输出发呆的开发者,通向一条体验日志记录能给出的那个更完整解释之间,最短的一条路径。
GraphQL 的模式变更有没有可能以某种 REST 做不到的方式保持向后兼容? 增量式的字段变更可以,原因就在上面:客户端只会拿到它们请求的东西。新增的枚举值是例外,因为一个穷举一个封闭集合的客户端,会在遇到一个它没预料到的值时崩掉。删除和类型变更和它们在 REST 里的对应变更一样,破坏性完全相同。
本文的技术内容未经独立审核。如有错误,请告诉我们,我们会更正。