跳至主要内容

Introducing the new Relay compiler

·12 分钟阅读

我们非常高兴地宣布今天(作为 v13.0.0-rc.1)发布新的基于 Rust 的 Relay 编译器的预览版!这个新的编译器更快,支持新的运行时特性,并且为未来的额外增长提供了坚实的基础。

在此发布之前,Meta 的代码库一直在增长,而且没有停止的迹象。在我们这个规模下,编译代码库中所有查询所需的时间正在增加,直接损害了开发人员的生产力。虽然我们尝试了许多方法来优化基于 JavaScript 的编译器(下面讨论),但我们逐渐提高性能的能力无法跟上代码库中查询数量的增长。

因此,我们决定用 Rust 重写编译器。我们选择 Rust 因为它速度快,内存安全,并且可以轻松地跨线程安全地共享大型数据结构。开发工作于 2020 年初开始,编译器在当年年底内部发布。推出过程很顺利,没有中断应用程序开发。初步的内部基准测试表明,编译器的平均性能提高了近 5 倍,在 P95 上提高了近 7 倍。从那时起,我们进一步提高了编译器的性能。

这篇文章将探讨为什么 Relay 有一个编译器,我们希望用新的编译器解锁什么,它的新特性,以及为什么我们选择使用 Rust 语言。如果您急于开始使用新的编译器,请查看 编译器包自述文件 或者 发行说明

为什么 Relay 有一个编译器?

Relay 有一个编译器是为了提供稳定性保证并实现出色的运行时性能。

要理解原因,请考虑使用该框架的工作流程。使用 Relay,开发人员使用一种称为 GraphQL 的声明性语言来指定每个组件需要哪些数据,但不会指定如何获取数据。然后,编译器将这些组件的数据依赖关系拼接成查询,这些查询获取给定页面的所有数据,并预先计算出为 Relay 应用程序提供如此高性能和稳定性的工件。

在这个工作流程中,编译器

  • 允许隔离地推理组件,使许多类型的错误成为不可能,并且
  • 将尽可能多的工作转移到构建时间,从而显著提高使用 Relay 的应用程序的运行时性能。

让我们依次考察这些内容。

支持本地推理

使用 Relay,组件只通过使用 GraphQL 片段来指定自己的数据需求。然后,编译器将这些组件的数据依赖关系拼接成查询,这些查询获取给定页面的所有数据。开发人员可以专注于编写组件,而无需担心其数据依赖关系如何融入更大的查询。

但是,Relay 更进一步地实现了这种本地推理。编译器还生成一些文件,这些文件由 Relay 运行时用来读出给定组件片段选择的仅有的数据(我们称之为 数据屏蔽)。因此,组件永远不会访问(实际上,不仅仅是在类型级别!)它没有显式请求的任何数据。

因此,修改一个组件的数据依赖关系不会影响另一个组件看到的数据,这意味着 **开发人员可以隔离地推理组件。** 这使 Relay 应用程序具有无与伦比的稳定性,并使许多类型的错误成为不可能,这是 Relay 可以扩展到许多开发人员触摸同一个代码库的关键原因。

改进的运行时性能

Relay 还利用编译器将尽可能多的工作转移到构建时间,从而提高 Relay 应用程序的性能。

由于 Relay 编译器具有对所有组件数据依赖关系的全局了解,它能够编写与手动编写一样好(通常甚至更好)的查询。它能够通过在运行时效率低下且不切实际的方式优化查询来做到这一点。例如,它修剪了永远无法从生成的查询中访问的分支,并扁平化了查询的相同部分。

由于这些查询是在构建时生成的,因此 Relay 应用程序永远不会从 GraphQL 片段生成抽象语法树 (AST),操作这些 AST,或在运行时生成查询文本。相反,Relay 编译器用预先计算的、优化的指令(作为简单的 JavaScript 数据结构)替换了应用程序的 GraphQL 片段,这些指令描述了如何将网络数据写入存储库,以及如何将其读回。

这种安排的另一个好处是,Relay 应用程序包既不包含模式,也不包含 GraphQL 片段的字符串表示形式(当使用持久化查询时)。这有助于减小应用程序的大小,节省用户的带宽并提高应用程序的性能。

事实上,新的编译器更进一步,以另一种方式节省了用户的带宽——Relay 可以告知应用程序的服务器每个查询文本在构建时间,并生成一个唯一的查询 ID,这意味着应用程序永远不需要在用户缓慢的网络上发送可能很长的查询字符串。当使用此类持久化查询时,唯一需要通过网络发送以进行网络请求的是查询 ID 和查询变量!

新的编译器能实现什么?

与动态语言相比,编译语言有时被认为引入了摩擦,并减慢了开发人员的速度。但是,Relay 利用编译器来减少摩擦,使常见的开发人员任务更容易。例如,Relay 公开了用于常见交互的高级原语,这些交互很容易被微妙地弄错,例如分页和使用新变量重新获取查询。

这些交互的共同点是它们都需要从旧查询中生成新的查询,因此涉及样板代码和重复——这是自动化的理想目标。Relay 利用编译器的全局知识,使开发人员能够通过添加一个指令和更改一个函数调用来启用分页和重新获取。就这样。

**但是,使开发人员能够轻松地添加分页仅仅是冰山一角。** 我们对编译器的愿景是,它提供更多高级工具来发布特性和避免样板代码,为开发人员提供实时帮助和洞察力,并且由可以被其他工具用来处理 GraphQL 的部分组成。

这个项目的首要目标是,重写的编译器的架构应该为我们在未来几年实现这一愿景做好准备。

虽然我们还没有达到目标,但我们在每个标准上都取得了重大进展。

例如,新的编译器附带对新 @required 指令的支持,该指令将在读出时使父级链接字段失效,或者如果给定子字段为空,则抛出错误。这听起来可能是一个微不足道的质量改进,但如果你的组件代码有一半是空检查,@required 就开始看起来相当好了!

没有 @required 的组件
使用 @required

接下来,编译器为内部专用的 VSCode 扩展提供支持,该扩展在您键入时自动完成字段名称,并在悬停时显示类型信息,以及许多其他功能。我们还没有公开发布它,但我们希望将来能够发布!我们的经验是,这个 VSCode 扩展使处理 GraphQL 数据变得更加容易和直观。

最后,新的编译器被写成一系列独立的模块,可以被其他 GraphQL 工具重复使用。我们称之为 Relay 编译器平台。在内部,这些模块正在被其他代码生成工具和其他用于不同平台的 GraphQL 客户端重复使用。

编译器性能

到目前为止,我们已经讨论了为什么 Relay 有一个编译器,以及我们希望重写能够实现什么。但我们还没有讨论为什么我们决定在 2020 年重写编译器:性能。

在决定重写编译器之前,随着代码库的增长,编译代码库中所有查询所需的时间逐渐但无情地减慢。我们逐渐提高性能的能力无法跟上代码库中查询数量的增长,而且我们没有看到任何增量式的解决办法。

到达 JavaScript 的尽头

之前的编译器是用 JavaScript 编写的。这是一种自然的语言选择,原因有以下几个:它是我们团队最熟悉的一种语言,也是 Relay 运行时用它编写的一种语言(让我们能够在编译器和运行时之间共享代码),也是 GraphQL 参考实现和我们移动 GraphQL 工具用它编写的一种语言。

编译器的性能在相当长一段时间内保持合理:Node/V8 带有一个高度优化的 JIT 编译器和垃圾收集器,如果您小心的话(我们很小心),它可以非常快。但是编译时间在增加。

我们尝试了许多方法来跟上

  • 我们使编译器成为增量的。为了应对更改,它只重新编译受该更改影响的依赖项。
  • 我们已经确定了哪些转换速度慢(即,flatten),并尽可能地进行了算法改进(例如添加记忆化)。
  • 官方的 graphql npm 包的 GraphQL 模式表示需要使用多个吉字节的内存来表示我们的模式,因此我们用自定义分支替换了它。
  • 我们在最热代码路径中进行了分析器引导的微优化。例如,我们停止使用 ... 运算符来克隆和修改对象,而是更倾向于在复制对象时显式列出对象的属性。这保留了对象的隐藏类,并使代码能够更好地进行 JIT 优化。
  • 我们重构了编译器以扩展到多个工作器,每个工作器处理一个模式。除了 Meta 之外,具有多个模式的项目并不常见,因此即使这样,大多数用户也只使用单线程编译器。

这些优化不足以跟上 Relay 在内部的快速采用速度。

最大的挑战是 NodeJS 不支持具有共享内存的多线程程序。最好的办法是启动多个通过传递消息进行通信的工作器。

这在某些场景下效果很好。例如,Jest 使用这种模式,并在运行转换文件的测试时利用所有核心。这非常适合,因为 Jest 不需要在进程之间共享太多数据或内存。

另一方面,我们的模式太大了,无法在内存中保存多个实例,因此在 JavaScript 中,除了每个模式一个线程之外,没有好的方法可以有效地并行化 Relay 编译器。

决定使用 Rust

在我们决定重写编译器后,我们评估了许多语言,以确定哪种语言能够满足我们项目的需要。我们想要一种快速、内存安全并支持并发性的语言——最好是在构建时而不是在运行时捕获并发错误。同时,我们希望使用一种在内部得到良好支持的语言。这将我们缩小到几个选择。

  • C++ 满足了大多数标准,但学习起来很困难。而且,编译器对安全性的帮助程度不如我们希望的那么高。
  • Java 也可能是一个不错的选择。它可以很快,而且是多核的,但提供的低级控制更少。
  • OCaml 是编译器领域中经过验证的选择,但多线程很具有挑战性。
  • Rust 速度快,内存安全,并支持并发性。它可以轻松地在线程之间安全地共享大型数据结构。由于围绕 Rust 的普遍兴奋,我们团队中的一些人之前就使用过 Rust,并且 Facebook 的其他团队也在使用 Rust,因此这是我们明确的首选。

内部推广

事实证明,Rust 非常适合!这个主要由 JavaScript 开发人员组成的团队发现 Rust 很容易上手。而且,Rust 高级类型系统在构建时捕获了许多错误,帮助我们保持了高速度。

我们在 2020 年初开始开发,并在同年年底在内部推出了编译器。最初的内部基准测试表明,编译器的平均性能提高了近 5 倍,在 P95 处提高了近 7 倍。从那时起,我们进一步提高了编译器的性能。

在 OSS 中发布

今天,我们很高兴发布新版本的编译器,作为 Relay v13 的一部分。新编译器功能包括

您可以在 README发布说明 中找到有关编译器的更多信息!

我们正在继续开发编译器中的功能,例如为开发人员提供访问图上派生值的能力,添加对更符合人体工程学的更新本地数据的语法支持,并完全完善我们的 VSCode 扩展,我们希望将所有这些功能发布到开源。我们为这次发布感到自豪,但还有更多内容即将推出!

感谢

感谢 Joe Savona、Lauren Tan、Jason Bonta 和 Jordan Eldredge 为这篇文章提供了宝贵的反馈。感谢 ch1ffa、robrichard、orta 和 sync 报告了与编译器错误相关的错误。感谢 MaartenStaa 添加了 TypeScript 支持。感谢 @andrewingram 指出了启用 @required 指令的难度,现在该指令默认情况下已启用。还有许多其他人做出了贡献——这确实是社区共同努力的成果!