我们非常高兴今天发布基于 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
指令的支持,该指令将在读取时使父链接字段失效或抛出错误,前提是给定的子字段为 null。这听起来可能是一个微不足道的质量改进,但如果你的组件代码中有一半是空检查,@required
就会显得非常棒!
@required
的组件
@required

接下来,编译器为一个仅供内部使用的 VSCode 扩展提供支持,该扩展可以在你输入时自动完成字段名称,并在悬停时显示类型信息,以及许多其他功能。我们还没有将其公开,但我们希望能在将来实现!我们的经验是,这个 VSCode 扩展使使用 GraphQL 数据变得更加容易和直观。
最后,新的编译器被写成一系列独立的模块,这些模块可以被其他 GraphQL 工具重复使用。我们称之为 Relay 编译器平台。在内部,这些模块被其他代码生成工具和用于不同平台的其他 GraphQL 客户端重复使用。
编译器性能
到目前为止,我们已经讨论了为什么 Relay 需要编译器以及我们希望重写编译器能实现什么。但我们还没有讨论为什么我们在 2020 年决定重写编译器:性能。
在决定重写编译器之前,编译代码库中所有查询所需的时间随着代码库的增长而逐渐但无情地变慢。我们通过逐步提高性能来应对代码库中查询数量增长的能力已经跟不上,我们没有看到任何增量方式来解决这个问题。
到达 JavaScript 的尽头
以前的编译器是用 JavaScript 编写的。这对于多种原因来说是一个自然的语言选择:它是我们团队经验最丰富的语言,是 Relay 运行时所在的语言(允许我们在编译器和运行时之间共享代码),也是 GraphQL 参考实现和我们的移动 GraphQL 工具所在的语言。
编译器的性能在相当长的一段时间内一直保持在合理水平:Node/V8 附带一个经过高度优化的 JIT 编译器和垃圾收集器,如果你小心使用(我们确实如此)它可以非常快。但编译时间一直在增长。
我们尝试了许多策略来跟上
- 我们已经使编译器成为增量式。针对更改,它只重新编译受该更改影响的依赖项。
- 我们已经确定了哪些转换速度慢(即,flatten),并做出了我们能够做出的算法改进(例如,添加记忆)。
- 官方的
graphql
npm 包的 GraphQL 模式表示需要数 GB 的内存来表示我们的模式,因此我们用自定义的 fork 替换了它。 - 我们在最热代码路径中进行了分析器引导的微优化。例如,我们不再使用
...
运算符来克隆和修改对象,而是更倾向于在复制对象时明确列出对象的属性。这保留了对象的隐藏类,并使代码能够更好地进行 JIT 优化。 - 我们重构了编译器以分发到多个工作进程,每个工作进程处理一个模式。除了 Meta 之外,很少有项目使用多个模式,因此即使这样,大多数用户也可能只使用单线程编译器。
这些优化不足以跟上 Relay 在内部的快速采用。
最大的挑战是 NodeJS 不支持具有共享内存的多线程程序。最好的方法是启动多个工作进程,它们通过传递消息进行通信。
这在某些情况下效果很好。例如,Jest 使用了这种模式,并在运行文件转换测试时利用了所有核心。这很适合,因为 Jest 不需要在进程之间共享太多数据或内存。
另一方面,我们的模式太大,无法在内存中拥有多个实例,因此在 JavaScript 中,根本没有好方法可以有效地将 Relay 编译器并行化,每个模式只有一个线程。
选择 Rust
在决定重写编译器后,我们评估了许多语言,以找出哪种语言能够满足我们项目的需要。我们想要一种快速、内存安全并支持并发性的语言——最好是在构建时捕获并发错误,而不是在运行时。同时,我们希望这门语言在内部得到良好的支持。这将我们的选择范围缩小到几个。
- C++ 满足了大部分标准,但感觉很难学。而且,编译器在安全性方面的帮助并不像我们希望的那样多。
- Java 也可能是一个不错的选择。它可以很快,而且是多核的,但提供更少的底层控制。
- OCaml 是编译器领域的一个成熟选择,但多线程很具有挑战性。
- Rust 速度快,内存安全,并且支持并发。它使跨线程安全共享大型数据结构变得容易。随着人们对 Rust 的普遍兴趣,我们团队之前的一些经验以及 Facebook 其他团队的使用,这无疑是我们最明智的选择。
内部推广
事实证明,Rust 非常适合!这个主要由 JavaScript 开发人员组成的团队发现 Rust 很容易采用。而且,Rust 的高级类型系统在构建时捕获了许多错误,帮助我们保持了高速度。
我们在 2020 年初开始开发,并在年底在内部推出了编译器。初步的内部基准测试表明,编译器的平均性能提高了近 5 倍,P95 性能提高了近 7 倍。从那时起,我们进一步提高了编译器的性能。
开源发布
今天,我们很高兴发布新版本的编译器,作为 Relay v13 的一部分。新的编译器功能包括
@required
指令。@no_inline
指令,可用于阻止内联通用片段,从而生成更小的文件。- 针对冲突的 GraphQL 字段、参数和指令进行验证
- 支持 TypeScript 类型生成
- 支持远程查询持久化。
你可以在 README 和 发行说明 中找到有关编译器的更多信息!
我们正在继续开发编译器中的功能,例如为开发人员提供在图上访问派生值的能力、添加对更符合人体工程学的本地数据更新语法的支持以及完全完善我们的 VSCode 扩展,我们希望将所有这些功能开源。我们为此次发布感到自豪,但还有更多内容即将发布!
致谢
感谢 Joe Savona、Lauren Tan、Jason Bonta 和 Jordan Eldredge 为这篇博文提供了精彩的反馈。感谢 ch1ffa、robrichard、orta 和 sync 报告了与编译器错误有关的问题。感谢 MaartenStaa 添加了 TypeScript 支持。感谢 @andrewingram 指出启用 @required
指令有多么困难,该指令现在默认启用。还有许多其他人做出了贡献——这确实是一项社区合作!