Thinking in GraphQL

GraphQL 为客户端提出了一个新的方式,通过专注于开发者们和客户端应用程序的需求去抓取数据。它提供一个方式给开发者们针对 view 指定精准的数据需求,并让客户端在单一的网络请求去抓取那些数据。与传统的方法像是 REST 做比较,GraphQL 帮助应用程序更有效率地抓取数据 (与面向资源的REST方法相比) 并避免服务器逻辑的重复 (可能会发生在客户端)。此外,GraphQL 帮助开发者们解耦产品程序代码和服务器的逻辑之间的黑色逻辑。例如,产品可以抓取更多或更少的信息而不需改变每一个相关的服务端。这是获取数据的好方法。

在本文中,我们将探讨构建GraphQL客户端框架的意义,以及与传统REST系统的客户端进行比较。在这个过程中,我们将看看Relay背后的设计决策,并且看到它不仅仅是一个GraphQL客户端,而且是用于 声明性数据提取 的框架。让我们从头开始,并获取一些数据!

获取数据 #

想象一下,我们有一个简单的应用程序来获取故事列表,以及每个故事的一些细节。 下面是面象资源的REST:

// Fetch the list of story IDs but not their details:
rest.get('/stories').then(stories =>
  // This resolves to a list of items with linked resources:
  // `[ { href: "http://.../story/1" }, ... ]`
  Promise.all(stories.map(story =>
    rest.get(story.href) // Follow the links
  ))
).then(stories => {
  // This resolves to a list of story items:
  // `[ { id: "...", text: "..." } ]`
  console.log(stories);
});

注意,此方法需要n+1个请求到服务器:1 个获取列表,其他 n 个抓取每个项目。使用GraphQL,我们可以在单个网络请求中获取相同的数据到服务器(无需创建我们需要维护的自定义端点):

graphql.get(`query { stories { id, text } }`).then(
  stories => {
    // A list of story items:
    // `[ { id: "...", text: "..." } ]`
    console.log(stories);
  }
);

到目前为止,我们只是使用 GraphQL 作为一个比典型 REST 方法更有效率的版本。注意GraphQL版本中的两个重要优点:

  • • 所有数据都是在一次往请求中获得的。
  • • 客户端和服务器解耦:客户端指定所需的数据,而不是 依赖 服务器端点返回正确的数据。

对于一个简单的应用程序这已经是一个很好的改进。

客户端缓存 #

重复从服务器重新获取信息可能会变得很慢。例如,从故事列表导航到列表项,并返回到故事列表,意味着我们必须重新获取整个列表。我们将使用标准解决方案来解决这个问题: 缓存.

在面向资源的REST系统中,我们可以基于URI 维护响应缓存:

var _cache = new Map();
rest.get = uri => {
  if (!_cache.has(uri)) {
    _cache.set(uri, fetch(uri));
  }
  return _cache.get(uri);
};

响应缓存也可以应用于GraphQL。基本的方法会用与 REST 版本类似地方式。查询本身的文本可以用作缓存密钥:

var _cache = new Map();
graphql.get = queryText => {
  if (!_cache.has(queryText)) {
    _cache.set(queryText, fetchGraphQL(queryText));
  }
  return _cache.get(queryText);
};

现在,对于先前缓存的数据的请求可以立即响应而不进行网络请求。这是可以感觉到应用程序性能提高的实用方法。不过,这种缓存方法可能会导致数据一致性问题。

缓存一致性 #

使用GraphQL,多个查询的结果重叠是很常见的。但是,我们上一节中的响应缓存并没有考虑到这种重叠 - 它基于不同的查询进行缓存。例如,如果我们发出查询来提取故事:

query { stories { id, text, likeCount } }

然后再提取一个 likeCount 已经增加的故事:

query { story(id: "123") { id, text, likeCount } }

我们现在将看到不同的 likeCount 取决于故事如何访问。使用第一个查询的视图将看到过期的计数,而使用第二个查询的视图将会看到更新的计数。

缓存图 #

缓存GraphQL的解决方案是将分层响应规范化为平面的记录集合。Relay 实现此缓存作为从ID到记录的映射。每个记录是从字段名称到字段值的映射。记录也可以链接到其他记录(允许它描述循环图),并且这些链接被存储为引用回到顶层映射的特殊值类型。使用这种方法,无论如何获取,每个服务器记录都被存储一次。

这是一个提取故事文本及其作者姓名的查询示例:

query {
  story(id: "1") {
    text,
    author {
      name
    }
  }
}

这是一个可能的响应:

query: {
  story: {
     text: "Relay is open-source!",
     author: {
       name: "Jan"
     }
  }
}

虽然响应是分层的,但我们将通过平铺所有记录来缓存它。下面是一个示例,说明Relay如何缓存此查询响应:

Map {
  // `story(id: "1")`
  1: Map {
    text: 'Relay is open-source!',
    author: Link(2),
  },
  // `story.author`
  2: Map {
    name: 'Jan',
  },
};

这只是一个简单的例子:实际上,缓存必须处理一对多关联和分页 (忽略其他事项)。

使用缓存 #

所以我们要如何使用这种缓存?我们来看看两种操作:当接收到响应时写入缓存,并从缓存读取,以确定查询是否可以在本地完成 (等同于上面的 _cache.has(key) ,不过是用于 graph 的)。

缓存写入 #

填充缓存涉及走层次化的GraphQL响应并创建或更新规范化缓存记录。 起初,似乎单独的响应足以处理,但实际上这只适用于非常简单的查询。 考虑 user(id: "456") { photo(size: 32) { uri } } — 我们应该如何存储 photo? 使用 photo 作为缓存中的字段名称将不起作用, 因为不同的查询可能会获取相同的字段,但具有不同的参数值(例如 photo(size: 64) {...})。分页时出现类似的问题。如果我们拿到第11到第20个故事 stories(first: 10, offset: 10) ,这些新结果应该 附加 到现有的列表中。

因此,GraphQL的规范化响应缓存需要并行处理有效负载和查询。例如, photo 可以使用生成的字段名称来缓存来自上述的字段 photo_size(32) ,以便唯一地标识字段及其参数值。

缓存读取 #

从缓存中读取,我们可以查询并解析每个字段。但是等一下: 这听起来 就像 是 GraphQL 服务器在查询时做的事。是这样没错!从缓存中读取是执行器的一种特殊情况,其中a)不需要用户定义的字段函数,因为所有结果都来自固定的数据结构,b)结果总是同步的 - 我们要么将数据缓存 要不然就是没有数据。

Relay 实现了 查询遍历 的几种变体: 将 Relay 与一些其他数据(如缓存或响应有效负载)一起行进的操作。例如,当查询被提取时,Relay 执行“差异”遍历以确定哪些字段缺失(就像React diff虚拟DOM树)。这可以减少在许多常见情况下获取的数据量,甚至允许 Relay 在缓存查询时完全避免网络请求。

缓存更新 #

注意,这种标准化缓存结构允许重叠的结果被缓存而不会重复。每个记录被存储一次,而不管如何获取它们。我们回到上一个数据不一致的例子,看看这种缓存在这种情况下产生的帮助。

第一个查询是一个故事列表:

query { stories { id, text, likeCount } }

使用规范化的响应缓存,将为列表中的每个故事创建一条记录。该 stories 字段将存储每个这些记录的链接。

第二个查询重新提供了其中一个故事的信息:

query { story(id: "123") { id, text, likeCount } }

当这个响应被归范化时, Relay 可以检测到这个结果与现有数据的重叠 id。 Relay 不会创建新记录,而是更新现有 123记录。 新的 likeCount 在这 两个 查询中, 以及任何其他可能会引用到这个故事的查询都可以存取。

数据/视图一致性#

标准化缓存可以确保 缓存 是一致的。 但是我们的视图呢? 理想情况下, 我们的 React view 将始终反映缓存中的当前信息。

考虑渲染故事的文字和评论以及相应的作者姓名和照片。这是GraphQL查询:

query {
  node(id: "1") {
    text,
    author { name, photo },
    comments {
      text,
      author { name, photo }
    }
  }
}

最初抓取这个故事后,我们的缓存可能如下。请注意,故事和评论都链接到相同的记录 author:

// Note: This is pseudo-code for `Map` initialization to make the structure
// more obvious.
Map {
  // `story(id: "1")`
  1: Map {
    text: 'got GraphQL?',
    author: Link(2),
    comments: [Link(3)],
  },
  // `story.author`
  2: Map {
    name: 'Yuzhi',
    photo: 'http://.../photo1.jpg',
  },
  // `story.comments[0]`
  3: Map {
    text: 'Here\'s how to get one!',
    author: Link(2),
  },
}

这个故事的作者也评论过 - 很常见。现在想象一些其他视图会获取有关作者的新信息,她的个人资料照片已经更改为一个新的URI。这里是 唯一 改变我们的缓存数据的一部分:

Map {
  ...
  2: Map {
    ...
    photo: 'http://.../photo2.jpg',
  },
}

photo 字段的值已经改变了; 因些记录 2 也改变了。就是这样, 缓存 中的其他任何内容都不会受到影响。 但显然,我们的 视图 需要渲染更新:作者在UI中的两个实例(作为故事作者和评论作者)需要显示新照片。

一个标准的回应是“只使用不可变数据结构” — 但是让我们看看如果我们这样做会发生什么:

ImmutableMap {
  1: ImmutableMap {/* same as before */}
  2: ImmutableMap {
    ... // other fields unchanged
    photo: 'http://.../photo2.jpg',
  },
  3: ImmutableMap {/* same as before */}
}

如果我们替换 2 用一个不可更新的记录, 我们还将获得一个新的不可变缓存对象的实例。但是,记录 13 不变。 因为数据是标准化的,所以我们不能只看 story记录才能知道 story内容的变化。

实现视图一致性 #

有各种各样的解决方案,可以使视图与缓存保持同步更新。Relay 采取的方法是维护从每个UI视图到其引用的一组ID的映射。在这种情况下,故事视图将 Subscriptionsstory (1),作者 (2), 和评论 (3 以及其他内容)的更新。 当将数据写入缓存时, Relay tracks 跟踪哪些ID受到影响,并 通知 Subscriptions这些ID的视图。影响的视图重新渲染,不受影响的视图不重新渲染,以获得更好的性能( (Relay 提供安全但有效的默认值 shouldComponentUpdate)。没有这种策略,即使是最小的变化,每个视图都将重新渲染。

注意,此解决方案也适用于 写入: 缓存的任何更新将通知受影响的视图,写入只是更新缓存的另一件事。

Mutations #

到目前为止,我们已经看到了查询的过程并且让视图保持更新,不过我们还没有看到写入。在 GraphQL 中,写入被称为 strong>mutations。我们可以将它们视为具有副作用的查询。以下是一个mutation 的示例:,调用可能将给定故事标记为当前用户喜欢的:

// Give a human-readable name and define the types of the inputs,
// in this case the id of the story to mark as liked.
mutation StoryLike($storyID: String) {
   // Call the mutation field and trigger its side effects
   storyLike(storyID: $storyID) {
     // Define fields to re-fetch after the mutation completes
     likeCount
   }
}

注意,我们正在查询 可能 由于mutation而发生变化的数据。 一个明显的问题是:为什么服务器不能告诉我们改变了什么?答案是:这很复杂 GraphQL在 任何 数据存储层(或多个源的聚合)上抽象,并且与任何编程语言一起使用。此外,GraphQL的目标是以对构建视图的产品开发人员有用的形式提供数据。

们发现,GraphQL schema 与被储存在硬盘上的数据之间常常有些微或甚至大幅度的差异。简单来说:您的基础 数据存储 的数据更改与产品可见 可见 schema (GraphQL)的数据变化之间不总是存在 1:1 的对应。关于这个的完美范例是隐私:回传一个面对使用者的字段,如 age能需要在数据储存层中存取数次记录,来 判断 现在的使用者是否被允许可以看到那个 age (我们是朋友吗?我的年龄是公开的吗?我有封锁你吗?等等。)。

鉴于这些现实世界的约束,GraphQL中的方法是为客户端查询 mutation 后可能发生变化的内容。不过,我们究竟该如何查询?在 Relay 的开发期间,我们探讨过几个想法 — 让我们来简单地看看它们以了解为什么 Relay 使用这样的方式::

  • 选项1:重新获取应用程序曾经查询的所有内容。即使只有这个数据的一小部分实际上会发生变化,我们仍然需要等待服务器执行整个查询,等待下载结果,然后再等待处理。这是非常低效的。

  • 选项2:仅重新获取主动渲染的视图所需的查询。这跟选项 1 比较起来有些细微的改进。但是,现在没有被看见的被缓存数据将不会被更新。除非这份数据以某种方式被标记成失效的或是被从缓存移除,随后的查询将会读取到过时的信息。

  • 重新获取在 mutation 后可能发生变化的固定列表。我们会将此列表称为 fat query。 我们发现这也是无效的,因为典型的应用程序只会渲染 fat query的一个子集,但是这种方法需要获取所有这些字段。

  • 选项 4 (Relay): 重新获取缓存中可能更改 ( fat query) 和数据的位置。除了缓存数据之外,Relay 还会记住用于获取每个项目的查询。这些被称为跟踪查询。 通过与跟踪查询和 fat query 相交, Relay 可以准确地查询应用程序需要更新的信息集,而无需更多。

数据获取API #

到目前为止,我们研究了数据获取的较低级别方面,并了解了各种熟悉的概念如何转化为GraphQL。接下来,让我们回顾一下产品开发人员经常面对数据提取的一些更高层次的担忧:

  • 获取视图层次结构的所有数据。
  • 管理异步状态转换和协调并发请求。
  • 管理错误。
  • 重试失败的请求。
  • 在接收到查询/突变响应后更新本地缓存。
  • 排队 mutation 以避免竞争条件。
  • 在等待服务器响应 mutation 的同时乐观更新UI。

我们发现,采用强制性API的数据获取的典型方法迫使开发人员处理这种非必要复杂性。例如,考虑 optimistic UI updates. 这是在等待服务器响应时给予用户反馈的一种方式。 做什么的逻辑可以说是很清楚的:当用户点击“喜欢”时,将故事标记为喜欢,并将请求发送到服务器。但实施往往复杂得多。强制性方法要求我们实现所有这些步骤:达到UI并切换按钮,启动网络请求,如有必要,重试,如果失败(并取消关闭按钮)等则显示错误等。 数据也是如此-fetching:指定: what 我们需要的数据, 指定 如何 以及 何时 获取。接下来,我们将探讨我们采用 Relay解决这些问题。