跳至主要内容

一篇文章标签为“relay-compiler”

查看所有标签

·阅读时长 12 分钟

我们非常兴奋地宣布今天开源新的基于 Rust 的 Relay 编译器预览版(作为 v13.0.0-rc.1)!这个新的编译器速度更快,支持新的运行时特性,并为未来的发展奠定了坚实的基础。

在发布这个版本之前,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 编译器和垃圾收集器,如果你小心谨慎(我们确实很小心),它可能会非常快。但是编译时间正在增长。

我们尝试了许多策略来跟上

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

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

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

这在某些情况下效果很好。 例如,Jest 采用这种模式,并在运行测试或转换文件时利用所有内核。 这是一个很好的选择,因为 Jest 不需要在进程之间共享太多数据或内存。

另一方面,我们的架构太大,无法在内存中拥有多个实例,因此根本没有好的方法来有效地并行化 Relay 编译器,在 JavaScript 中每个架构只能使用一个线程。

选择 Rust

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

  • C++ 满足了大多数标准,但感觉难以学习。 而且,编译器在安全性方面的帮助不如我们所愿。
  • Java 也可能是一个不错的选择。 它可以很快,并且是多核的,但提供了较少的底层控制。
  • OCaml 是编译器领域的成熟选择,但多线程具有挑战性。
  • Rust 速度快、内存安全,并支持并发。 它使跨线程安全共享大型数据结构变得容易。 随着 Rust 的普遍关注,我们团队之前的一些经验,以及 Facebook 其他团队的使用情况,这显然是我们的首选。

内部推广

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

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

开源发布

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

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

我们正在继续开发编译器中的功能,例如,使开发人员能够访问图上的派生值,添加对更符合人体工程学的本地数据更新语法支持,以及完善我们的 VSCode 扩展,我们希望将所有这些都发布到开源。 我们为此次发布感到自豪,但未来还有很多事情要做!

感谢

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