🤔 GraphQL 应该因用户不同而有所不同吗?
GraphQL 是一个从某个数据源获取数据的接口,GraphQL 规范定义了该接口的要求。只要满足这些要求,GraphQL 并不关心具体的实现方式。GraphQL 服务器可以用 JavaScript 的 Promise 实现,也可以使用基于 Golang 的并发架构,或者映射到 Excel 文件,等等——这些都可以是 GraphQL 规范的有效实现。

服务器引擎的实现方式对 GraphQL 请求的成功执行并不重要,因为客户端与服务器之间的交互始终相同——使用定义好的语法提交 GraphQL query,并以 JSON 格式获取相应的响应。
当我说实现方式不重要时,我是从 API 用户的角度来说的。用户只是想从服务器获取数据,对返回数据是如何生成的并不感兴趣。
但对于在 API 上工作的服务端开发者来说,情况就不同了,实现的细节对他们来说确实非常重要。如果我用 PHP 编写 GraphQL API,我会尽力让 API 尽可能高效地解析,并利用 PHP 提供的能力设计尽可能优雅的架构。

由此产生了一种潜在的利益冲突:一方面需要保护 API 的安全,另一方面在 API 上工作的开发者期望底层语言所支持的功能(例如执行递归代码的能力)不会被剥夺。
这种冲突在 issue #929: Allow recursive references in fragments 中变得十分明显,该 issue 主张 GraphQL 不应禁止 fragment 中的递归。
在 GraphQL 工作组过去的一次聚会上,提出该 issue 的开发者 Roman 表达了他对规范所施加限制的异议:
我是一名服务端开发者,我觉得规范过多地讨论服务端执行,而应该专注于客户端希望得到什么——而不是如何实现
禁止 fragment 中递归的规则,其正当性建立在保护公开 API 安全的前提之上。毕竟,GraphQL 是 Facebook 为了向其面向公众的应用程序提供数据而创建的,用户不应能够利用 API 设计上的缺陷来使服务宕机。
GraphQL 的创始人 Lee Byron 表达了三个主要担忧:
无限递归;限制不仅仅是规范层面的问题——应该何时以及如何停止
数据验证;多次返回相同值时,在数据中如何表示。理想情况下,你希望检测到循环后立即停止,但某些服务器无法检测到这一点,可能会循环多次后才发现出了问题并停止
不支持此功能的代价是什么;这些问题是否值得?不值得;你始终可以在 query 中指定层级深度——这实际上就是我们在 GraphQL 中处理此问题时所做的脱糖版本
从各自的立场来看,Roman 和 Lee 都有道理。Lee Byron 担心公开 GraphQL API 的安全性。禁止递归 fragment 是有道理的,这样可以确保恶意行为者无法通过在 query 中执行永无止境的循环来使系统宕机,甚至消除团队「自我 DDoS」的可能性——如果他们无意中发布了一个会使系统停止的 query,这种情况就可能发生。
然而,Roman 担心的是对他自己创建 GraphQL API 能力的限制。因为 Roman 可能是其 API 的唯一消费者(即不对用户开放的私有 API),或者他的服务器可能具备检测并停止循环的能力,因此他认为 GraphQL 的这一限制是有害且不合理的。
讨论的核心不在于是否应该允许递归 fragment,而在于一个更根本的问题:GraphQL 的目标受众是谁?如果不是单一群体,单一的 API 规范能否满足所有不同利益相关者的需求?如果无法避免冲突,至少能以某种方式加以缓解吗?
让我们来探讨这些问题。
GraphQL 的目标受众是谁?
GraphQL 被不同类型的利益相关者使用,其中包括:
1. API 用户:出于各种目的从某个 GraphQL 端点获取数据的人。例如,我们都可以成为 GitHub 公开 GraphQL API 的 API 用户,以获取与我们 GitHub 仓库相关的数据。
2. 客户端开发者: 创建由某个 GraphQL 端点驱动的客户端应用程序的人。例如,使用 Gatsby 构建网站的开发者依赖 GraphQL 来获取网站内容。
3. 后端开发者: 为 GraphQL API 创建解析器的人。
此外,我们还需注意 GraphQL API 可以是公开的或私有的:
公开 API: 由于任何人都可以访问 GraphQL 端点,我们必须关注安全措施,以避免恶意行为者的攻击。
私有 API: 由于只有预期的行为者才能访问 API,因此不存在固有的安全风险,通过良好的编码实践也可以轻松避免自我 DDoS。
单一 API 规范能否满足所有利益相关者的需求?
Roman 提出的 issue 可以这样理解:"如果我的 GraphQL API 是私有的,而且我清楚地知道自己在做什么(100% 确信代码会按预期工作,不会产生执行停止的情况),那么为什么我不能在 fragment 中使用递归?"

每当我们使用基于 GraphQL 的框架构建静态网站(例如 Gatsby、Next.js 或 RedwoodJS)时,就会出现这种情况,因为 GraphQL API 通常是私有的,我们不会无意间 DDoS 自己的应用并遭受不良后果(最多只会在开发或预发环境构建静态网站时发生崩溃)。
使用上述设置的开发者完全有理由疑惑:为什么 GraphQL 规范会禁止他们使用对他们的设置毫无不良影响的有益功能?
综上所述,通过禁止递归 fragment,GraphQL 规范强制推行了一项安全措施,该措施仅适用于 GraphQL 所有潜在用途中的一部分,而非全部,目的是确保万无一失。
GraphQL 规范能否更好地满足所有利益相关者?
如果不同利益相关者有不同的需求,GraphQL 规范如何才能满足所有人?(前提是避免分叉规范,为特定目标生成定制化版本。)
让我们探讨两个思路,第一个需要经历规范贡献流程,而第二个则不需要。
GraphQL 规范层面的 feature-toggle
一种可能的方案是让规范「建议」而非「强制」规则。在这种情况下,禁止 fragment 中递归的规则可以作为强烈建议,但该功能仍然被接受。
然而,这一方案会将递归 fragment 的默认状态从「必须」改为「可选」,从而产生两个负面后果:
- API 默认情况下不安全(这正是 Lee Byron 想要避免的场景)
- 由于原本被禁止的 query 将变为允许,会产生破坏性变更
因此,更好的方式是反转这一选项:fragment 中的递归默认仍然被禁止,但提供切换 feature-flag 的可能性来禁用此行为。由于该功能必须被显式禁用,只有清楚自己在做什么的管理员才会去操作。
由于该功能在特定设置下最有价值,GraphQL 服务器和框架可以决定是否、如何以及何时提供此配置。例如,Gatsby 可以在创建静态网站时通过 UI 突出显示该选项,在其他情况下则隐藏它。
总体思路是让 GraphQL 规范支持「已启用但可选的功能」,这些功能可以通过配置启用或禁用,其默认状态与规范中已有的状态相同。
禁止递归 fragment 将成为其中之一,还可能有其他类似功能,例如 Map 类型。该类型未被 Lee Byron 纳入规范,原因是:
Map 类型与键/值对列表之间存在重大权衡。一个问题是对集合进行分页。值列表可以有清晰的分页规则,而 Map 通常具有无序的键值对,分页要困难得多。
另一个问题是使用方式。大多数情况下,Map 在 API 中用于对值的某个字段进行索引,在我看来这是一种 API 反模式,因为索引是存储问题和客户端缓存问题,而不是传输问题。这种反模式令我担忧。虽然 Map 在 API 中有一些好的用途,但我担心常见的用法会用于这些反模式,所以建议谨慎推进。
Lee Byron 表达了对该功能被当作反模式使用的担忧。但他也承认确实存在好的用途。因此,鉴于该 issue 获得了社区的大量支持(超过 150 个 👍),可以给开发者提供选项,让他们明确启用在其 schema 中添加 Map 类型,并自行处理相应的后果。
GraphQL 服务器层面的 feature-toggle
如果上述提案因对 GraphQL 规范风险过高而得不到支持,另一个替代方案是在 GraphQL 服务器层面实现。这样,GraphQL 服务器可以提供一个自定义功能来禁用 fragment 中的递归。
推广这一思路,GraphQL 服务器可以提供禁用规范中某些功能、启用规范中缺失功能的选项。为了不产生意外,服务器必须确保默认状态符合规范要求,而 API 管理员也必须充分了解切换功能所带来的后果。(这正是 Gato GraphQL 对其「innovative features」所采用的策略。)
总结
随着 GraphQL 日益普及,支持新能力的新框架将其纳入技术栈,新的利益相关者(以及新类型的利益相关者)也随之加入。因此,这个最初由 Facebook 创建以定义其应用程序如何从服务器获取数据的规范,需要应对越来越多的使用场景。
冲突的出现是不可避免的——某些利益相关者需要的功能,对其他利益相关者来说是适得其反甚至有害的,递归 fragment 的情况正是如此。我们能做些什么来改善这种局面,避免不满意的利益相关者对 GraphQL 感到失望?
我主张规范提供「禁用」某个功能的机会,让清楚自己在做什么的管理员能够取消某些限制以满足自身需求。不过,我本人并不认同这一解决方案,但还是将其提出来,因为这场讨论有其必要性。鉴于这一想法存在争议,更好的替代方案是由 GraphQL 服务器通过自定义功能提供此行为,且这些功能必须被显式启用。