翻译自:GraphQL Resolvers: Best Practices

本文是在 PayPal 构建 GraphQL APIs 所做的最佳实践和观察系列的第一部分。在日后的文章中,我们将分享我们关于:方案设计,错误处理,生产可视性,优化客户端集成和团队工具的想法。

你或许已经看过我们之前的文章:GraphQL: PayPal Checkout 成功的故事,它讲述了 PayPal 从 REST 到 GraphQL 的旅程。本文将详细介绍一些构建解析器的最佳实践,解析器是快速的,可测试的,有弹性的。

解析器是什么?

让我们从相同的基准线开始。解析器是什么?

解析器:一个在 schema 中解析类型或字段值的函数或方法。

每种类型的每个字段被一个叫做解析器的函数支持。

解析器是一个在 schema 中解析类型或字段值的函数。解析器可以返回对象或标量如 Strings, Numbers, Booleans 等。如果返回一个对象,执行继续到下一个子字段。如果返回标量(通常在叶子结点),执行完成。如果返回 null,执行停止并不再继续。

解析器也可以是异步的。他们可以从其他 REST API,数据库,缓存,常量等,解析值。

稍后,我们将遍历一系列示例,图解如何构建快速的、可测试的、有弹性的解析器。

执行查询

为了更好理解解析器,你需要了解查询如何执行。

每个 GraphQL 查询经过三个阶段。解析、校验和执行查询。

  1. 解析:解析查询为一个抽象语法树(或者 AST)。ASTs 难以置信的强大,并且位于 ESLint, babel 等工具之后。如果你想看 GraphQL AST 看起来像什么,看看astexplorer.net,并且将 JavaScript 改成 GraphQL。你将看看到查询在左侧,AST 在右侧。
  2. 校验:根据 schema 验证 AST。检查正确的查询语法以及字段是否存在。
  3. 执行:运行时遍历 AST,从树的根节点开始,调用解析器,收集结果,并且输出 JSON。

例如,我们将参考这个查询:

Query for later reference

当解析该查询时,它被转化为一个 AST 或者树。

Query represented as a tree

根 Query 类型是树的入口点,并且包含两个根字段,useralbum。并行执行useralbum解析器(在所有运行时中国呢都是常见的)。宽度优先执行树,意味着必须解析user,在它的子节点 nameemail 执行之前。如果解析器时异步的,user分支延迟到它被执行。一旦解析所有子节点:name, email, title,执行就完成了。

并行执行根 Query 字段,像useralbum,但是没有特定的顺序。通常,按照他们在查询中出现的顺序执行字段,但是做这样的假定是不保险的。因为并行执行字段,他们假定为原子的、幂等的、边际效应无关的。

仔细观察解析器

在接下来的部分,我们将使用 JavaScript,但是可以用很多语言来写 GraphQL 服务。

Resolvers with four arguments — root, args, context, info

在某种形式中,每种语言的每个解析器都接收 4 个参数:

  • root:来自上一个或父类型的结果

  • args:为字段提供的参数

  • context:提供给所有解析器的可变对象

  • info:与查询有关的特定于字段的信息(很少使用)

这 4 个参数是理解解析器间数据流的核心。

默认解析器

我们继续之前,值得注意的是 GraphQL 服务有内建的默认解析器,所以你不必为每个字段指明解析函数。默认解析器会在根节点寻找和字段同名的属性。一个可能的实现:

Default resolver implementation

在解析器中查询数据

我们应该在哪里查询数据?我们的选择有哪些权衡?

在接下来的示例中,我们将参考这个 schema:

An event field has a required id argument, returns an Event

在解析器间传递数据

context 是一个提供给所有解析器的可变对象。在每个请求间创建和销毁它。它是存储公用 Auth(身份鉴权)数据、APIs 和数据库通用模型/加载器等的好去处。在 PayPal,我们是一个基于 Express 架构的 Node.js 商店,所以我们在那里存储 Express 的 req。

当你第一次学习 context 时,初始的想法或许是使用 context 作为通用缓存。这不被推荐,但是实现可能是这样的。

Passing data between resolvers using context. This is not recommended!

当 调用 title,我们在 context 中存储 event 结果。当调用 photoUrl,我们从 context 获取 event,并且使用它。这个代码是不可靠的。不能保证 titlephotoUrl 之前执行。

我们可以改进所有解析器,核查 event 是否存在于 context。如果这样,使用它。否则,我们获取它并且稍微存储它,但是这仍然有很大出错空间。

反而,我们应该避免在解析器内部修改 context。我们应该避免知识和焦虑混杂在一起,以便于我们的解析器容易理解、调试和测试。

从父到子传递数据

root 参数用于从父解析器到子解析器传递数据。

例如,如果你正在构建 Event 类型,在那里 Event 的所有字段都依赖相同数据,你或许想要在 event 字段中获取它一次而不是在 每个 Event 字段。

似乎是一个好的注意,对吗?这是开始建立解析器的快速方式,但是你或许会遇到一些问题。让我们理解为什么。

如下示例,我们将使用有两个字段的 Event 类型。

Event type with two fields: title and photoUrl

Event 的很多字段可以从 Event API 获取,所以我们可以在顶级 event 解析器中获取它,并为我们的 title 和 photoUrl 解析器提供结果。

Top-level event resolver fetches data, provides results to title and photoUrl field resolvers

甚至,我们不需要指明下面的两个解析器。

我们可以使用默认解析器,因为被 getEvent()返回的对象有 titlephotoUrl 属性。

id and title are resolved using default resolvers

这有什么问题吗?

有两种场景你或许会重复查询……

场景#1:多层数据查询

假设有一些需求进来,你需要展示一个 event 的参与者。我们开始为 Event 增加 attendees 字段。

Event type with an additional attendees field

当你查询 attendees 详情时,你有两个选择:在 event 解析器中查询数据或者 attendees 解析器。

我们将测试第一个选项:将其添加到 event 解析器中。

event resolver calls two APIs, fetching event details and attendees details

如果客户端仅查询 title 和 photoUrl, 而没有 attendees。现在你开始低效并且向 Attendees API 发起无效的请求。

这不是你的错误,这是我们的工作方式。我们识别模式并复制他们。如果参与者看到数据查询在 event 解析器中完成,他们将可能增加任何其他数据查询,而不需要考虑太多。

我们还有一个选项测试在 attendees 解析器中获取 attendees。

attendees resolver fetches attendees details from the Attendees API

如果我们的客户端仅查询 attendees,而不是 title 和 photoUrl。我们仍然效率低下,因向 Events API 发起了不必的请求。

场景#2:N+1 问题

由于数据在字段级查询,所以我们有过渡获取的风险。在 GraphQL 世界中,过渡获取和 N+1 问题是常见的话题。Shopify 有一个很赞的文章很好的解释了 N+1 问题。

这对我们有什么影响?

为了更好的阐明它,我们将增加一个新的 events 字段,返回所有 events。

An events field returns all events.

Query for all events w/ their title and attendees

如果客户端查询所有 events 和他们的 attendees,我们有过渡获取的风险,因为 attendees 可以参与不止一个 event。我们或许重复查询相同的 attendee。

该问题在大型组织中被放大,在那里请求可能扩散并且给系统造成不必要的压力。

为了解决它,我们需要批量并且删除重复请求!

在 JavaScript,一些受欢迎的选项是 数据加载器Apollo 数据源.

如果你正使用其他语言,你可能会发现一些其他东西。所以,在你自己解决这个之前,先找一圈。

最重要的是,这些位于数据访问层之上的库,将缓存和删除重复请求,使用消除抖动和缓存。如果你好奇异步缓存,看看 Daniel Brain 的大作

在字段级获取数据

早些时候,我们看到“头重脚轻“的父到子解析器很容易导致过渡获取。

有没有更好的选择?

让我们再次梳理下父到子选项。如果我们反过来,让子字段负责获取他们自己的数据呢?

Fields are responsible for their own data fetching.

为什么这是一个更好的选择?

这段代码很容易理解。你确切的知道邮件在那里获取。这很容易调试。

这段代码是可测试的。当你只想测试 title 解析器时,你不必测试 event 解析器。

在某种程度上,getEvent 重复或许看起来像坏代码的味道。但是, 拥有简单、易于推理和更易于测试的代码是值得重复的。

但是,这仍然有一个潜在的问题。如果客户端查询 title 和 photoUrl,我们用 getEvent 在 Event API 上发起额外的请求。正如我们之前在 N+1 问题中看到的,我们应该使用像 数据加载器Apollo 数据源一样的库在框架层删除重复请求。

如果我们在字段级获取数据并且删除重复请求,我们的代码容易调试和测试,并且我们可以不用考虑优化查询数据。

最佳实践

  • 从父到子的数据获取和解析应该谨慎使用
  • 使用像 数据加载器这样的库删除重复下游请求
  • 注意对数据源造成的任何压力
  • 不要修改”context。确保代码一致,Bug 更少
  • 写可读的、可维护的和可测试的解析器。不要太过聪明
  • 确保解析器尽可能的薄。为可重用的异步函数提供数据获取逻辑

继续学习!

思考?我们将喜欢听到你的团队构建解析器的最佳实践和学习。这不是经常讨论的话题,但对构建持久的 GraphQL APIs 至关重要。

在即将到来的文章中,我们将分享我们的思考关于:方案设计,错误处理,生产可视性,优化客户端集成和团队工具。