标签 GraphQL 下的文章

我通常会抽象地总结我为他人所做的工作(出于显而易见的原因),但是我被允许公开谈论一个网站:Vocal 。我去年为它做了一些 SRE 工作。实际上早在 2 月份,我就在 GraphQL 悉尼会议上做过一次演讲,不过这篇博客推迟了一点才发表。

Vocal 是一个基于 GraphQL 的网站,它获得了人们的关注,然后就遇到了可扩展性问题,而我是来解决这个问题的。这篇文章会讲述我的工作。显然,如果你正在扩展一个 GraphQL 站点,你会发现这篇文章很有用,但其中大部分内容讲的都是当一个站点获得了足够的流量而出现的必须解决的技术问题。如果你对站点可扩展性有兴趣,你可能想先阅读 最近我发表的一系列关于可扩展性的文章

Vocal

Vocal 是一个博客平台,内容包括日记、电影评论、文章评论、食谱、专业或业余摄影、美容和生活小贴士以及诗歌,当然,还有可爱的猫猫和狗狗照片。

Vocal 的不同之处在于,它允许人们制作观众感兴趣的作品而获得报酬。作者的页面每次被浏览都可以获得一小笔钱,还能获得其他用户的捐赠。有很多专业人士在这个平台上展示他们的工作,但对于大多数普通用户来说,他们只是把 Vocal 当作一个兴趣爱好,碰巧还能赚些零花钱作为奖励。

Vocal 是新泽西初创公司 Jerrick Media 的产品,更新:Jerrick Media 已经更名为 Creatd,在纳斯达克上市。2015 年,他们与 Thinkmill 合作一起开发,Thinkmill 是一家悉尼中型软件开发咨询公司,擅长 JavaScript、React 和 GraphQL 开发。

剧透

不幸的是,有人告诉我,由于法律原因,我不能提供具体的流量数字,但公开的信息可以说明。Alexa 对所有网站按照流量进行排名。这是我演讲中展示的 Alexa 排名图,从 2019 年 11 月到今年 2 月,Vocal 流量增长到全球排名第 5567 位。

去年 11 月到今年 2 月 Vocal 的全球排名从 9574 名增长到 5567 名

曲线增长变慢是正常的,因为它需要越来越多的流量来赢得每个位置。Vocal 现在排名 4900 名左右,显然还有很长的路要走,但对于一家初创公司来说,这一点也不寒酸。大多数初创公司都很乐意与 Vocal 互换排名。

在网站升级后不久,Creatd 开展了一项营销活动,使流量翻了一番。在技术方面,我们要做的就是观察仪表盘上的上升的数字。自发布以来的 9 个月里,只有两个平台问题需要员工干预:3 月份每五年一次的 AWS RDS 证书轮换,以及一款应用推出时遇到的 Terraform 错误。作为一名 SRE,我很高兴看到 Vocal 不需要太多的平台工作来保持运行。更新:该系统也抗过了 2020 年的美国大选,没有任何意外。

以下是本文技术内容的概述:

  • 技术和历史背景
  • 从 MongoDB 迁移到 Postgres
  • 部署基础设施的改造
  • 使应用程序兼容扩展措施
  • 让 HTTP 缓存发挥作用
  • 其他一些性能调整

一些背景信息

Thinkmill 使用 Next.js(一个基于 React 的 Web 框架)构建了一个网站,和 Keystone 在 MongoDB 前面提供的 GraphQL API 进行交互。Keystone 是一个基于 GraphQL 的无头 CMS 库:在 JavaScripy 中定义一个模式,将它与一些数据存储挂钩,并获得一个自动生成的 GraphQL API 用于数据访问。这是一个自由开源软件项目,由 Thinkmill 提供商业支持。

Vocal V2

Vocal 的第一版就受到了关注,它找到了一个喜欢它的用户群,并不断壮大,最终 Creatd 请求 Thinkmill 帮助开发 V2,并于去年 9 月成功推出。Creatd 员工避免了 第二个系统效应,他们一般都是根据用户的反馈进行改变,所以他们 主要是 UI 和功能更改,我就不赘述了。相反,我将讨论下我的工作内容:使新站点更加健壮和可扩展。

声明:我很感谢能与 Creatd 以及 Thinkmill 在 Vocal 上的合作,并且他们允许我发表这个故事,但 我仍然是一名独立顾问,我写这篇文章没有报酬,甚至没有被要求写它,这仍然是我自己的个人博客。

迁移数据库

Thinkmill 在使用 MongoDB 时遇到了几个可扩展性问题,因此决定升级到 Keystone 5 以利用其新的 Postgres 支持。

如果你从事技术工作的时间足够长,那你可能还记得 00 年代末的 “NOSQL” 营销,这可能听起来很有趣。NoSQL 的一个重要特点是,像 Postgres 这样的关系数据库(SQL)不像 MongoDB 这样“网站级规模”的 NoSQL 数据库那样具有可扩展性。从技术上将,这种说法是正确的,但 NoSQL 数据库的可扩展性来自它可以有效处理各种查询的折衷。简单的非关系数据库(如文档数据库和键值数据库)有其一席之地,但当它们用作应用的通用后端时,应用程序通常会在超出关系数据库的理论扩展限制之前,就超出了数据库的查询限制。Vocal 的原本的大多数数据库查询在 MongoDB 上运行良好,但随着时间推移,越来越多的查询需要特殊技巧才能工作。

在技术要求方面,Vocal 与维基百科非常相似。维基百科是世界上最大的网站之一,它运行在 MySQL(或者说它的分支 MariaDB)上。当然,这需要一些重要的工程来实现,但在可预见的未来,我认为关系数据库不会对 Vocal 的扩展构成严重威胁。

我做过一个比较,托管的 AWS RDS Postgres 实例的成本不到旧 MongoDB 实例的五分之一,但 Postgres 实例的 CPU 使用率仍然低于 10%,尽管它提供的流量比旧站点多。这主要是因为一些重要的查询在文档数据库架构下一直效率很低。

迁移的过程可以新写一篇博客文章来讲述,但基本上是 Thinkmill 开发人员构建了一个 ETL 管道,使用 MoSQL 来完成这项繁重的工作。由于 Keystone 对于 Postgres 支持仍然比较基础,但它是一个 FOSS 项目,所以我能够解决在 SQL 生成性能方面遇到的问题。对于这类事情,我总是推荐 Markys Winand 的 SQL 博文:使用 Luke 索引现代 SQL。他的文章很友好,甚至对那些暂时不太关注 SQL 人来说也是容易理解的,但他拥有你大多数需要的理论知识。如果你仍然有问题,一本好的、专注于即可性能的书可以帮助你。

平台

架构

V1 是几个 Node.js 应用,运行在 Cloudflare(作为 CDN)背后的单个虚拟专用服务器(VPS)上。我喜欢把避免过度工程化作为一个高度优先事项,所以我对这一架构竖起了大拇指。然而,在 V2 开始开发时,很明显,Vocal 已经超越了这个简单的架构。在处理巨大峰值流量时,它没有给 Thinkmill 开发人员提供很多选择,而且它很难在不停机情况下安全部署更新。

这是 V2 的新架构:

Vocal V2 的技术架构,请求从 CDN 进入,然后经过 AWS 的负载均衡。负载均衡将流量分配到两个应用程序 “Platform” 和 “Website”。“Platform” 是一款 Keystone 应用程序,将数据存储在 Redis 和 Postgres 中。

基本上就是两个 Node.js 应用程序复制放在负载均衡器后面,非常简单。有些人认为可扩展架构要比这复杂得多,但是我曾经在一些比 Vocal 规模大几个数量级的网站工作过,它们仍然只是在负载均衡器后面复制服务,带有 DB 后端。你仔细想想,如果平台架构需要随着站点的增长而变得越来越复杂,那么它就不是真正“可扩展的”。网站可扩展性主要是解决那些破坏可扩展的实现细节。

如果流量增长得足够多,Vocal 的架构可能需要一些补充,但它变得更加复杂的主要原因是新功能。例如,如果(出于某种原因)Vocal 将来需要处理实时地理空间数据,那将是一个与博客文章截然不同的技术,所以我预期它会进行架构上的更改。大型网站架构的复杂性主要来自于复杂的功能。

不知道未来的架构应该是什么样子很正常,所以我总是建议你尽可能从简单开始。修复一个简单架构要比复杂架构更容易,成本也更低。此外,不必要的复杂架构更有可能出现错误,而这些错误将更难调试。

顺便说一下,Vocal 分成了两个应用程序,但这并不重要。一个常见的扩展错误是,以可扩展的名义过早地将应用分割成更小的服务,但将应用分割在错误的位置,从而导致更多的可扩展性问题。作为一个单体应用,Vocal 可以扩展的很好,但它的分割做的也很好。

基础设施

Thinkmill 有一些人有使用 AWS 经验,但它主要是一个开发车间,需要一些比之前的 Vocal 部署更容易上手的东西。我最终在 AWS Fargate 上部署了新的 Vocal,这是弹性容器服务(ECS)的一个相对较新的后端。在过去,许多人希望 ECS 作为一个“托管服务运行 Docker 容器”的简单产品,但人们仍然必须构建和管理自己的服务器集群,这让人感到失望。使用 ECS Fargate,AWS 就可以管理集群了。它支持运行带有复制、健康检查、滚动更新、自动缩放和简单警报等基本功能的 Docker 容器。

一个好的替代方案是平台即服务(PaaS),比如 App Engine 或 Heroku。Thinkmill 已经在简单的项目中使用它们,但通常在其他项目中需要更大的灵活性。在 PaaS 上运行的网站规模要大得多,但 Vocal 的规模决定了使用自定义云部署是有经济意义的。

另一个明显的替代方案是 Kubernetes。Kubernetes 比 ECS Fargate 拥有更多的功能,但它的成本要高得多 —— 无论是资源开销还是维护(例如定期节点升级)所需的人员。一般来说,我不向任何没有专门 DevOps 员工的地方推荐 Kubernetes。Fargate 具有 Vocal 需要的功能,使得 Thinkmill 和 Creatd 能专心于网站改进,而不是忙碌于搭建基础设施。

另一种选择是“无服务器”功能产品,例如 AWS Lambda 或 Google 云。它们非常适合处理流量很低或很不规则的服务,但是 ECS Fargate 的自动伸缩功能足以满足 Vocal 的后端。这些产品的另一个优点是,它们允许开发人员在云环境中部署东西,但无需了解很多关于云环境的知识。权衡的结果是,无服务器产品与开发过程、测试以及调试过程紧密耦合。Thinkmill 内部已经有足够的 AWS 专业知识来管理 Fargate 的部署,任何知道如何开发 Node.js 简单的 Hello World 应用程序的开发人员都可以在 Vocal 上工作,而无需了解无服务器功能或 Fargate 的知识。

ECS Fargate 的一个明显缺点是供应商锁定。但是,避免供应商锁定是一种权衡,就像避免停机一样。如果你担心迁移,那么在平台独立性花费比迁移上更多的钱是没有意义的。在 Vocal 中,依赖于 Fargate 的代码总量小于 500 行 Terraform。最重要的是 Vocal 应用程序代码本身与平台无关,它可以在普通开发人员的机器上运行,然后打包成一个 Docker 容器,几乎可以运行在任何可以运行 Docker 容器的地方,包括 ECS Fargate。

Fargate 的另一个缺点是设置复杂。与 AWS 中的大多数东西一样,它处于一个 VPC、子网、IAM 策略世界中。幸运的是,这类东西是相对静态的(不像服务器集群一样需要维护)。

制作一个可扩展的应用程序

如果你想轻松地运行一个大规模的应用程序,需要做一大堆正确的事情。如果你遵循 应用程序设计的十二个守则 the Twelve-Factor App design ,事情会变得容易,所以我不会在这里赘述。

如果员工无法规模化操作,那么构建一个“可扩展”系统就毫无意义 —— 就像在独轮车上安装喷气式发动机一样。使 Vocal 可扩展的一个重要部分是建立 CI/CD 和 基础设施即代码 之类的东西。同样,有些部署的思路也不值得考虑,因为它们使生产与开发环境相差太大(参阅 应用程序设计守则第十守则)。生产和开发之间的任何差异都会降低应用程序的开发速度,并且(在实践中)最终可能会导致错误。

缓存

缓存是一个很大的话题 —— 我曾经做过 一个关于 HTTP 缓存的演讲,相对比较基础。我将在这里谈论缓存对于 GraphQL 的重要性。

首先,一个重要的警告:每当遇到性能问题时,你可能会想:“我可以将这个值放入缓存中吗,以便再次使用时速度更快?”微基准测试 总是 告诉你:是的。 然而,由于缓存一致性等问题,随处设置缓存往往会使整个系统 变慢。以下是我对于缓存的检查表:

  1. 是否需要通过缓存解决性能问题
  2. 再仔细想想(非缓存的性能往往更加健壮)
  3. 是否可以通过改进现有的缓存来解决问题
  4. 如果所有都失败了,那么可以考虑添加新的缓存

在 Web 系统中,你经常使用的一个缓存是 HTTP 缓存系统,因此,在添加额外缓存之前,试着使用 HTTP 缓存是一个好主意。我将在这篇文章中重点讨论这一点。

另一个常见的陷阱是使用哈希映射或应用程序内部其他东西进行缓存。它在本地开发中效果很好,但在扩展时表现糟糕。最好的办法是使用支持显式缓存库,支持 Redis 或 Memcached 这样的可插拔后端。

基础知识

HTTP 规范中有两种类型缓存:私有和公共。私有缓存不会和多个用户共享数据 —— 实际上就是用户的浏览器缓存。其余的就是公共缓存。它们包括受你控制的(例如 CDN、Varnish 或 Nginx 等服务器)和不受你控制的(代理)。代理缓存在当今的 HTTPS 世界中很少见,但一些公司网络会有。

缓存查找键通常基于 URL,因此如果你遵循“相同的内容,相同的 URL;不同的内容,不同的 URL” 规则,即,给每个页面一个规范的 URL,避免从同一个 URL 返回不同的内容这样“聪明”的技巧,缓存就会强壮一点。显然,这对 GraphQL API 端点有影响(我将在后面讨论)。

你的服务器可以采用自定义配置,但配置 HTTP 缓存的主要方法是在 Web 响应上设置 HTTP 头。最重要的标头是 cache-control。下面这一行说明所有缓存都可以缓存页面长达 3600 秒(一小时):

cache-control: max-age=3600, public

对于针对用户的页面(例如用户设置页面),重要的是使用 private 而不是 public 来告诉公共缓存不要存储响应,防止其提供给其他用户。

另一个常见的标头是 vary,它告诉缓存,响应是基于 URL 之外的一些内容而变化。(实际上,它将 HTTP 头和 URL 一起添加到缓存键中。)这是一个非常生硬的工具,这就是为什么尽可能使用良好 URL 结构的原因,但一个重要的用例是告诉浏览器响应取决于登录的 cookie,以便在登录或注销时更新页面。

vary: cookie

如果页面根据登录状态而变化,你需要 cache-control:private(和 vary:cookie),即使是在公开的、已登出的页面版本上,以确保响应不会混淆。

其他有用的标头包括 etaglast-modified,但我不会在这里介绍它们。你可能仍然会看到一些诸如 expirespragma:cache 这种老旧的 HTTP 标头。它们早在 1997 年就被 HTTP/1.1 淘汰了,所以我只在我想禁用缓存或者我感到偏执时才使用它们。

客户端标头

鲜为人知的是,HTTP 规范允许在客户端请求中使用 cache-control 标头以减少缓存时间并获得最新响应。不幸的是,似乎大多数浏览器都不支持大于 0 的 max-age ,但如果你有时在更新后需要一个最新响应,no-cache 会很有用。

HTTP 缓存和 GraphQL

如上,正常的缓存键是 URL。但是 GraphQL API 通常只使用一个端点(比如说 /api/)。如果你希望 GraphQL 查询可以缓存,你需要查询参数出现在 URL 路径中,例如 /api/?query={user{id}}&variables={"x":99}(忽略了 URL 转义)。诀窍是将 GraphQL 客户端配置为使用 HTTP GET 请求进行查询(例如,apollo-link-http 设置为 useGETForQueries )。

Mutation 不能缓存,所以它们仍然需要使用 HTTP POST 请求。对于 POST 请求,缓存只会看到 /api/ 作为 URL 路径,但缓存会直接拒绝缓存 POST 请求。请记住,GET 用于非 Mutation 查询(即幂等),POST 用于 Mutation(即非幂等)。在一种情况下,你可能希望避免使用 GET 查询:如果查询变量包含敏感信息。URL 经常出现在日志文件、浏览器历史记录和聊天中,因此 URL 中包含敏感信息通常是一个坏主意。无论如何,像身份验证这种事情应该作为不可缓存的 Mutation 来完成,这是一个特殊的情况,值得记住。

不幸的是,有一个问题:GraphQL 查询往往比 REST API URL 大得多。如果你简单地切换到基于 GET 的查询,你会得到一些非常长的 URL,很容易超过 2000 字节的限制,目前一些流行的浏览器和服务器还不会接受它们。一种解决方案是发送某种查询 ID,而不是发送整个查询,即类似于 /api/?queryId=42&variables={"x":99}。Apollo GraphQL 服务器对此支持两种方式:

一种方法是 从代码中提取所有 GraphQL 查询,并构建一个服务器端和客户端共享的查询表。缺点之一是这会使构建过程更加复杂,另一个缺点是它将客户端项目与服务器项目耦合,这与 GraphQL 的一个主要卖点背道而驰。还有一个缺点是 X 版本和 Y 版本的代码可能识别一组不同的查询,这会成为一个问题,因为 1:复制的应用程序将在更新推出或回滚期间提供多个版本,2:客户端可能会使用缓存的 JavaScript,即使你升级或降级服务器。

另一种方式是 Apollo GraphQL 所宣称的 自动持久查询(APQ)。对于 APQ 而言,查询 ID 是查询的哈希值。客户端向服务器发出请求,通过哈希查询。如果服务器无法识别该查询,则客户端会在 POST 请求中发送完整的查询,服务器会保存此次查询的散列值,以便下次识别。

HTTP 缓存和 Keystone 5

如上所述,Vocal 使用 Keystone 5 生成 GraphQL API,而 Keystone 5 和 Apollo GraphQL 服务器配合一起工作。那么我们是如何设置缓存标头的呢?

Apollo 支持 GraphQL 模式的 缓存提示 cache hint 。巧妙地是,Apollo 会收集查询涉及的所有内容的所有缓存提示,然后它会自动计算适当的缓存标头值。例如,以这个查询为例:

query userAvatarUrl {
    authenticatedUser {
        name
        avatar_url
    }
}

如果 name 的最长期限为 1 天,而 avatar_url 的最长期限为 1 小时,则整体缓存最长期限将是最小值,即 1 小时。authenticatedUser 取决于登录 cookie,因此它需要一个 private 提示,它会覆盖其他字段的 public,因此生成的 HTTP 头将是 cache-control:max-age=3600,private

对 Keystone 列表和字段添加了缓存提示支持。以下是一个简单例子,在文档的待办列表演示中给一个字段添加缓存提示:

const keystone = new Keystone({
  name: 'Keystone To-Do List',
  adapter: new MongooseAdapter(),
});

keystone.createList('Todo', {
  schemaDoc: 'A list of things which need to be done',
  fields: {
    name: {
      type: Text,
      schemaDoc: 'This is the thing you need to do',
      isRequired: true,
      cacheHint: {
        scope: 'PUBLIC',
        maxAge: 3600,
      },
    },
  },
});

另一个问题:CORS

令人沮丧的是, 跨域资源共享 Cross-Origin Resource Sharing (CORS)规则会与基于 API 网站中的缓存产生冲突。

在深入问题细节之前,让我们跳到最简单的解决方案:将主站点和 API 放在一个域名上。如果你的站点和 API 位于同一个域名上,就不必担心 CORS 规则(但你可能需要考虑 限制 cookie)。如果你的 API 专门用于网站,这是最简单的解决方案,你可以愉快地跳过这一节。

在 Vocal V1 中,网站(Next.js)和平台(Keystone GraphQL)应用程序处于不同域(vocal.mediaapi.vocal.media)。为了保护用户免受恶意网站的侵害,现代浏览器不会随便让一个网站与另一个网站进行交互。因此,在允许 vocal.mediaapi.vocal.media 发出请求之前,浏览器会对 api.vocal.media 进行“预检”。这是一个使用 OPTIONS 方法的 HTTP 请求,主要是询问跨域资源共享是否允许。预检通过后,浏览器会发出最初的正常请求。

令人沮丧的是,预检是针对每个 URL 的。浏览器为每个 URL 发出一个新的 OPTIONS 请求,服务器每次都会响应。服务器没法说 vocal.media 是所有 api.vocal.media 请求的可信来源 。当所有内容都是对一个 API 端点的 POST 请求时,这个问题并不严重,但是在为每个查询提供 GET 式 URL 后,每个查询都因预检而延迟。更令人沮丧的是,HTTP 规范说 OPTIONS 请求不能被缓存,所以你会发现你所有的 GraphQL 数据都被完美地缓存在用户身旁的 CDN 中,但浏览器仍然必须向源服务器发出所有的预检请求。

如果你不能只使用一个共享的域,有几种解决方案。

如果你的 API 足够简单,你或许可以利用 CORS 规则的例外

某些缓存服务器可以配置为忽略 HTTP 规范,任何情况都会缓存 OPTIONS 请求。例如,基于 Varnish 的缓存和 AWS CloudFrone。这不如完全避免预检那么有效,但比默认的要好。

另一个很魔改的选项是 JSONP。当心:如果做错了,那么可能会创建安全漏洞。

让 Vocal 更好地利用缓存

让 HTTP 缓存在底层工作之后,我需要让应用程序更好地利用它。

HTTP 缓存的一个限制是它在响应级别上要么是全有要么是全无的。大多数响应都可以缓存,但如果一个字节不能缓存,那整个页面就无法缓存。作为一个博客平台,大多数 Vocal 数据都是可缓存的,但在旧网站上,由于右上角的菜单栏,几乎没有页面可以缓存。对于匿名用户,菜单栏将显示邀请用户登录或创建账号的链接。对于已登录用户,它会变成用户头像和用户个人资料菜单,因为页面会根据用户登录状态而变化,所以无法在 CDN 中缓存任何页面。

Vocal 的一个典型页面。该页面的大部分内容都是高度可缓存的内容,但在旧网站中,由于右上角有一个小菜单,实际上没有一个内容是可缓存的。

这些页面是由 React 组件的服务器端渲染(SSR)的。解决方法是将所有依赖于登录 cookie 的 React 组件,强制它们 只在客户端进行延迟呈现。现在,服务器会返回完全通用的页面,其中包含用于个性化组件(如登录菜单栏)的占位符。当页面在浏览器中加载时,这些占位符将通过调用 GraphQL API 在客户端填充。通用页面可以安全地缓存到 CDN 中。

这一技巧不仅提高了缓存命中率,还帮助改善了人们感知的页面加载时间。空白屏幕和旋转动画让我们不耐烦,但一旦第一个内容出现,它会分散我们几百毫秒的注意力。如果人们在社交媒体上点击一个 Vocal 帖子的链接,主要内容就会立即从 CDN 中出现,很少有人会注意到,有些组件直到几百毫秒后才会完全出现。

顺便说一下,另一个让用户更快地看到第一个内容的技巧是 流式渲染,而不是等待整个页面渲染完成后再发送。不幸的是,Node.js 还不支持这个功能

拆分响应来提高可缓存性也适用于 GraphQL。通过一个请求查询多个数据片段的能力通常是 GraphQL 的一个优势,但如果响应的不同部分具有差别很大的缓存,那么最好将它们分开。举个简单的例子,Vocal 的分页组件需要知道当前页面的页数和内容。最初,组件在一个查询中同时获取两个页面,但由于页面的总数是所有页面的一个常量,所有我将其设置为一个单独的查询,以便缓存它。

缓存的好处

缓存的明显好处是它减轻了 Vocal 后端服务器的负载。这很好。但是依赖缓存来获得容量是危险的,你仍然需要一个备份计划,以便当有一天你不可避免地放弃缓存。

提高页面响应速度是使用缓存是一个更好的理由。

其他一些好处可能不那么明显。峰值流量往往是高度本地化的。如果一个有很多社交媒体粉丝的人分享了一个页面的链接,那么 Vocal 的流量就会大幅上升,但主要是指向分享的那个页面及其元素。这就是为什么缓存擅长吸收最糟糕的流量峰值,它使后端流量模式相对更平滑,更容易被自动伸缩处理。

另一个好处是 优雅的退化 graceful degradation 。即使后端由于某些原因出现了严重的问题,站点最受欢迎的部分仍然可以通过 CDN 缓存来提供服务。

其他的性能调整

正如我常说的,可扩展的秘诀不是让事情变得更复杂。它只是让事情变得不比需要的更复杂,然后彻底解决所有阻碍扩展的东西。扩展 Vocal 的规模涉及到许多小事,在这篇文章中无法一一说明。

一个经验:对于分布式系统中难以调试的问题,最困难的部分通常是获取正确的数据,从而了解发生的原因。我能想到很多时候,我被困住了,只能靠猜测来“即兴发挥”,而不是想办法找到正确的数据。有时这行得通,但对复杂的问题却不行。

一个相关技巧是,你可以通过获取系统中每个组件的实时数据(甚至只是 tail -F 的日志),在不同的窗口中显示,然后在另一个窗口中单击网站来了解很多信息。比如:“为什么切换这个复选框会在后台产生这么多的 DB 查询?”

这里有个修复的例子。有些页面需要几秒钟以上的时间来呈现,但这个情况只会在部署环境中使用 SSR 时会出现。监控仪表盘没有显示任何 CPU 使用量峰值,应用程序也没有使用磁盘,所以这表明应用程序可能正在等待网络请求,可能是对后端的请求。在开发环境中,我可以使用 sysstat 工具来记录 CPU、RAM、磁盘使用情况,以及 Postgres 语句日志和正常的应用日志来观察应用程序是如何工作的。Node.js 支持探测跟踪 HTTP 请求,比如使用 bpftrace,但处于某些无聊的原因,它们不能在开发环境中工作,所以我在源代码中找到了探针,并创建了一个带有请求日志的 Node.js 版本。我使用 tcpdump 记录网络数据,这让我找到了问题所在:对于网站发出的每一个 API 请求,都要创建一个新的网络连接到 “Platform”。(如果这都不起作用,我想我会在应用程序中添加请求跟踪功能。)

网络连接在本地机器上速度很快,但在现实网络上却不可忽略。建立加密连接(比在生产环境中)需要更长时间。如果你向一个服务器(比如一个 API)发出大量请求,保持连接打开并重用它是很重要的。浏览器会自动这么做,但 Node.js 默认不会,因为它不知道你是否发出了很多请求,所以这个问题只出现在 SSR 上。与许多漫长的调试过程一样,修复却非常简单:只需将 SSR 配置为 保持连接存活,这样会使页面的呈现时间大幅下降。

如果你想了解更多这方面的知识,我强烈建议你阅读《高性能浏览器网络》这本书(可免费在线阅读),并跟进 Brendan Gregg 发表的指南

你的站点呢?

实际上,我们还可以做很多事情来提升 Vocal 的速度,但我们没有全做。这是因为在初创公司和在大公司身为一个固定员工做 SRE 工作还是有很大区别的。我们的目标、预算和发布日期都很紧张,但最终我们的网站得到了很大改善,给了用户他们想要的东西。

同样的,你的站点有它自己的目标,并且可能与 Vocal 有很大的不同。然而,我希望这篇文章和它的链接至少能给你一些有用的思路,为用户创造更好的东西。


via: https://theartofmachinery.com/2020/06/29/scaling_a_graphql_site.html

作者:Simon Arneaud 选题:lujun9972 译者:MjSeven 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

在问题导致关键的微服务瘫痪之前,使用 GraphQL 的监控功能帮助你及早发现问题。

微服务GraphQL 就像面包和黄油一样,是一个很好的组合。它们本身都很棒,结合起来就更棒了。了解你的微服务的健康状况是很重要的,因为它们运行着重要的服务。如果等到某个关键的服务崩溃了才诊断问题,那是很愚蠢的。让 GraphQL 帮助你及早发现问题并不需要花费太多精力。

 title=

常规的健康检查可以让你观察和测试你的服务,在问题影响到你的业务、客户或项目之前,尽早得到通知。说起来很简单,但健康检查到底要做什么呢?

以下是我在设计服务检查时考虑的因素:

服务器健康检查的要求:

  1. 我需要了解我的微服务的可用性状态。
  2. 我希望能够管理服务器的负载。
  3. 我希望对我的微服务进行端到端(e2e)测试。
  4. 我应该能够预测中断。

 title=

做服务器健康检查的方法

进行健康检查可能比较棘手,因为理论上,你可以检查的东西几乎是无穷无尽的。我喜欢从小处着手,运行最基本的测试:ping 测试。这只是测试运行应用的服务器是否可用。然后,我加强测试以评估特定问题,思考服务器中最重要的元素。我想到那些如果突然消失的话将是灾难性的事情。

  1. **Ping 检查:**Ping 是最简单的监控类型。它只是检查你的应用是否在线。
  2. **脚本化浏览器:**脚本化浏览器比较高级。像 Selenium 这样的浏览器自动化工具可以让你实现自定义的监控规则集。
  3. **API 测试:**API 测试用于监控 API 端点。这是 ping 检查模型的高级版本,你可以根据 API 响应来定义监控计划。

使用 GraphQL 进行健康检查

在一个典型的基于 REST 的微服务中,你需要从头开始构建健康检查功能。这是一个时间密集型的过程,但使用 GraphQL 就不用担心了。

根据它的网站称:

“GraphQL 是一种用于 API 的查询语言,也是一种用现有数据完成这些查询的运行时环境。GraphQL 为你的 API 中的数据提供了一个完整的、可理解的描述,让客户有能力精确地仅查询他们所需要的东西,让 API 更容易随着时间的推移而进化,并实现强大的开发者工具。”

当你启动一个 GraphQL 微服务时,你还可以获得监控微服务的运行状况的供给。这是一个隐藏的宝贝。

正如我上面提到的,你可以用 GraphQL 端点执行 API 测试以及 ping 检查。

Apollo GraphQL 服务器提供了一个默认的端点,它可以返回有关你的微服务和服务器健康的信息。它不是很复杂:如果服务器正在运行,它就会返回状态码 200。

默认端点是 <server-host>/.well-known/apollo/server-health

 title=

高级健康检查

在某些情况下,基本的健康检查可能不足以确保系统的完整性。例如,紧密耦合的系统需要更多的业务逻辑来确保系统的健康。

Apollo GraphQL 在定义服务器的同时,通过声明一个 onHealthCheck 函数来有效地管理这种情况。

* Defining the Apollo Server */
const apollo = new ApolloServer({
  playground: process.env.NODE_ENV !== 'production',
  typeDefs: gqlSchema,
  resolvers: resolver,
  onHealthCheck: () => {
    return new Promise((resolve, reject) => {
      // Replace the `true` in this conditional with more specific checks!
      if (true) {
        resolve();
      } else {
        reject();
      }
    });
  }
});

当你定义一个 onHealthCheck 方法时,它返回一个 promise,如果服务器准备好了,它就会返回 resolve,如果有错误,它就会返回 reject

GraphQL 让监控 API 变得更容易。此外,在你的服务器基础架构中使用它可以使代码变得可扩展。如果你想尝试采用 GraphQL 作为你的新基础设施定义,请参见我的 GitHub 仓库中的示例代码和配置


via: https://opensource.com/article/20/8/microservices-graphql

作者:Rigin Oommen 选题:lujun9972 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

以下是 GraphQL 在标准 REST API 技术上获得发展的原因。

正如我以前所写,GraphQL 是一种下一代 API 技术,它正在改变客户端应用程序与后端系统的通信方式以及后端系统的设计方式。

由于一开始就从创建它的组织 Facebook 获得了支持,并得到了其他技术巨头(如 Github、Twitter 和 AirBnB)的支持,因此 GraphQL 作为应用程序系统的关键技术的地位似乎是稳固的 —— 无论现在还是将来。

GraphQL 的崛起

移动应用程序性能和组织敏捷性重要性的提高为 GraphQL 登上现代企业体系结构的顶端提供了助推器。

鉴于 REST 是一种非常流行的体系结构风格,早已提供了数据交互机制,与 REST 相比,GraphQL 这项新技术具有哪些优势呢?GraphQL 中的 “QL” 代表着查询语言,而这是一个很好的起点。

借助 GraphQL,组织内的不同客户端应用程序可以轻松地仅查询所需数据,这一点超越了其它 REST 方法,并带来了实际应用程序性能的提高。使用传统的 REST API 端点,客户端应用程序将详询服务器资源,并接受包含了与请求匹配的所有数据的响应。如果来自 REST API 端点的成功响应返回 35 个字段,那么客户端应用程序就会收到 35 个字段。

获取的问题

传统上,REST API 没有为客户端应用程序提供简便的方法来仅检索或只更新它们关心的数据。这通常被描述为“ 过度获取 over-fetching ”的问题。随着移动应用程序在人们的日常生活中的普遍使用,过度获取问题会给现实世界带来不良后果。移动应用程序发出的每个请求、每一个字节的接受和发送,对终端用户的性能影响越来越大。数据连接速度较慢的用户尤其会受到不太好的 API 设计方案的影响。使用移动应用程序而性能体验不佳的客户更有可能不购买产品或不使用服务。低效的 API 设计只会浪费企业的钱。

并非只有“过度获取”是问题,“欠缺获取”同样也是问题。默认情况下,端点只返回客户端实际需要的部分数据,这需要客户端进行额外的调用以满足其数据需求,这就产生了额外的 HTTP 请求。由于过度和欠缺的获取问题及其对客户端应用程序性能的影响,促进有效获取的 API 技术才有机会在市场上引起轰动 —— GraphQL 大胆地介入并填补了这一空白。

REST 的应对

REST API 设计师不甘心不战而退,他们试图通过以下几种方式来应对移动应用程序性能问题:

  • “包含”和“排除”查询参数,允许客户端应用程序通过可能较长的查询格式来指定所需的字段。
  • “复合”服务,将多个端点组合在一起,以使客户端应用程序在其发出的请求数量和接收到的数据方面更高效。 尽管这些模式是 REST API 社区为解决移动客户端所面临的挑战而做出的英勇尝试,但它们在以下几个关键方面仍存在不足:
  • 包含和排除查询键/值对很快就会变得混乱,特别是对于需要用嵌套“点表示法”语法(或类似方法)以对目标数据进行包含和排除的深层对象图而言,更是如此。此外,在此模型中调试查询字符串的问题通常需要手动分解 URL。
  • 包含和排除查询的服务器的实现往往是自定义的,因为基于服务器的应用程序没有标准的方式来处理包含和排除查询的使用,就像没有定义包含和排除查询的标准方式一样。
  • 复合服务的兴起形成了更加紧密耦合的后端和前端系统,这就需要加强协调以交付项目,并且将曾经的敏捷项目转回瀑布式开发。这种协调和耦合还有一个痛苦的副作用,那就是减宦了组织的敏捷性。此外,顾名思义,组合服务不是 RESTful。

GraphQL 的起源

对于 Facebook 来说,从其 2011-2012 年基于 HTML5 版本的旗舰移动应用程序中感受到的痛点和体验,才造就了 GraphQL。Facebook 工程师意识到提高性能至关重要,因此意识到他们需要一种新的 API 设计来确保最佳性能。可能考虑到上述 REST 的局限性,并且需要支持许多 API 客户端的不同需求,因此人们可以理解是什么导致其共同创建者 Lee Byron 和 Dan Schaeffer(那时尚是 Facebook 员工)创建了后来被称之为 GraphQL 的技术的早期种子。

通过 GraphQL 查询语言,客户端(通常是单个 GraphQL 端点)应用程序通常可以显著减少所需的网络调用数量,并确保仅检索所需的数据。在许多方面,这可以追溯到早期的 Web 编程模型,在该模型中,客户端应用程序代码会直接查询后端系统 —— 比如说,有些人可能还记得 10 到 15 年前在 JSP 上用 JSTL 编写 SQL 查询的情形吧!

现在最大的区别是使用 GraphQL,我们有了一个跨多种客户端和服务器语言和库实现的规范。借助 GraphQL 这样一种 API 技术,我们通过引入 GraphQL 应用程序中间层来解耦后端和前端应用程序系统,该层提供了一种机制,以与组织的业务领域相一致的方式来访问组织数据。

除了解决软件工程团队遇到的技术挑战之外,GraphQL 还促进了组织敏捷性的提高,特别是在企业中。启用 GraphQL 的组织敏捷性通常归因于以下因素:

  • GraphQL API 设计人员和开发人员无需在客户端需要一个或多个新字段时创建新的端点,而是能够将这些字段包含在现有的图实现中,从而以较少的开发工作量和跨应用程序系统的较少更改的方式展示出新功能。
  • 通过鼓励 API 设计团队将更多的精力放在定义对象图上,而不是在专注于客户端应用程序交付上,前端和后端软件团队为客户交付解决方案的速度日益解耦。 ### 采纳之前的注意事项

尽管 GraphQL 具有引人注目的优势,但 GraphQL 并非没有实施挑战。一些例子包括:

  • REST API 建立的缓存机制更加成熟。
  • 使用 REST 来构建 API 的模式更加完善。
  • 尽管工程师可能更喜欢 GraphQL 等新技术,但与 GraphQL 相比,市场上的人才库更多是从事于构建基于 REST 的解决方案。

结论

通过同时提高性能和组织敏捷性,GraphQL 在过去几年中被企业采纳的数量激增。但是,与 API 设计的 RESTful 生态系统相比,它确实还需要更成熟一些。

GraphQL 的一大优点是,它并不是作为替代 API 解决方案的批发替代品而设计的。相反,GraphQL 可以用来补充或增强现有的 API。因此,鼓励企业探索在 GraphQL 对其最有意义的地方逐步采用 GraphQL —— 在他们发现它对应用程序性能和组织敏捷性具有最大的积极影响的地方。


via: https://opensource.com/article/19/6/why-use-graphql

作者:Zach Lendon 选题:lujun9972 译者:wxy 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

GraphQL 是一种查询语言、一个执行引擎,也是一种规范,它让开发人员重新思考如何构建客户端和 API 应用。

GraphQL 是当今软件技术中最大的流行语之一。但它究竟是什么?是像 SQL 一样的查询语言吗?是像 JVM 这样的执行引擎?还是像 XML 这样的规范?

如果你回答上面这些都是,那么你是对的!GraphQL 是一种查询语言的语法、是一种编程语言无关的执行引擎,也是一种不断发展的规范。

让我们深入了解一下 GraphQL 如何成为所有这些东西的,并了解一下人们为什么对它感到兴奋。

查询语言

GraphQL 作为查询语言似乎是合理的 —— 毕竟 “QL” 似乎重要到出现在名称中。但是我们查询什么呢?看一个示例查询请求和相应的响应可能会有所帮助。

以下的用户查询:

{
    user(id: 4) {
        name
        email
        phoneNumber
    }
}

可能会返回下面的 JSON 结果:

{
    "user": {
        "name": "Zach Lendon"
        “email”: “[email protected]”
        “phoneNumber”: “867-5309”
    }
}

想象一下,客户端应用查询用户详细信息、获取结果,并使用它填充配置屏幕。作为查询语言,GraphQL 的核心优势之一是客户端应用可以只请求它需要的数据,并期望以一致的方式返回这些数据。

那么 GraphQL 响应返回的什么呢?这就是执行引擎发挥的作用,通常是以 GraphQL 服务器的形式出现。

执行引擎

 title=

GraphQL 执行引擎负责处理 GraphQL 查询并返回 JSON 响应。所有 GraphQL 服务器由两个核心组件组成,分别定义了执行引擎的结构和行为:模式和解析器。

GraphQL 模式是一种自定义类型语言,它公开哪些查询既允许(有效),又由 GraphQL 服务器实现处理。上面用户示例查询的模式可能如下所示:

type User {
    name: String
    email: String
    phoneNumber: String
}

type Query {
    user: User
}

此模式定义了一个返回用户的用户查询。客户端可以通过用户查询请求用户上的任何字段,并且 GraphQL 服务器将仅返回请求的字段。通过使用强类型模式,GraphQL 服务器可以根据定义的模式验证传入的查询,以确保是有效的。

确定查询有效后,就会由 GraphQL 服务器的解析器处理。解析器函数支持每个 GraphQL 类型的每个字段。我们的这个用户查询的示例解析器可能如下所示:

Query: {
    user(obj, args, context, info) {
        return context.db.loadUserById(args.id).then(
            userData => new User(userData)
        )
    }
}

虽然上面的例子是用 JavaScript 编写的,但 GraphQL 服务器可以用任意语言编写。这是因为 GraphQL 也是也是一种规范!

规范

GraphQL 规范定义了 GraphQL 实现必须遵循的功能和特性。作为一个在开放网络基金会的最终规范协议(OWFa 1.0)下提供的开放规范,技术社区可以审查 GraphQL 实现必须符合规范的要求,并帮助制定 GraphQL 的未来。

虽然该规范对 GraphQL 的语法,什么是有效查询以及模式的工作方式进行了非常具体的说明,但它没有提供有关如何存储数据或 GraphQL 服务器应使用哪种编程语言实现的指导。这在软件领域是非常强大的,也是相对独特的。它允许以各种编程语言创建 GraphQL 服务器,并且由于它们符合规范,因此客户端会确切知道它们的工作方式。GraphQL 服务器已经有多种语言实现,人们不仅可以期望像 JavaScript、Java和 C# 这样的语言,还可以使用 Go、Elixir 和 Haskell 等。服务器实现所使用的语言不会成为采用过程的障碍。它不仅存在多种语言实现,而且它们都是开源的。如果没有你选择的语言的实现,那么可以自己实现。

总结

GraphQL 是开源 API 领域中一个令人兴奋的、相对较新的参与者。它将查询语言、执行引擎与开源规范结合在一起,它定义了 GraphQL 实现的外观和功能。

GraphQL 已经开始改变企业对构建客户端和 API 应用的看法。通过将 GraphQL 作为技术栈的一部分,前端开发人员可以自由地查询所需的数据,而后端开发人员可以将客户端应用需求与后端系统架构分离。通常,公司在使用 GraphQL 的过程中,首先会在其现有的后端服务之上构建一个 GraphQL API “层”。这使得客户端应用开始获得他们所追求的性能和运营效率,同时使后端团队有机会确定他们可能需要在 GraphQL 层后面的“幕后”进行哪些更改。通常,这些更改都是为了优化,这些优化有助于确保使用 GraphQL 的应用可以尽可能高效地运行。由于 GraphQL 提供了抽象性,因此系统团队可以进行更改的同时继续在其 GraphQL API 级别上遵守 GraphQL 的“合约”。

由于 GraphQL 相对较新,因此开发人员仍在寻找新颖而激动人心的方法来利用它构建更好的软件解决方案。GraphQL 将如何改变你构建应用的方式,它是否对得起众望所归?只有一种方法可以找到答案 —— 用 GraphQL 构建一些东西!


via: https://opensource.com/article/19/6/what-is-graphql

作者:Zach Lendon 选题:lujun9972 译者:geekpi 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出

摘要

GraphQL 在生产环境中似乎难以使用:虽然对于建模功能来说图接口非常灵活,但是并不适用于关系型存储,不管是在实现还是性能方面。

在这篇博客中,我们会设计并实现一个简单的博客引擎 API,它支持以下功能:

  • 三种类型的资源(用户、博文以及评论)支持多种功能(创建用户、创建博文、给博文添加评论、关注其它用户的博文和评论,等等。)
  • 使用 PostgreSQL 作为后端数据存储(选择它因为它是一个流行的关系型数据库)。
  • 使用 Golang(开发 API 的一个流行语言)实现 API。

我们会比较简单的 GraphQL 实现和纯 REST 替代方案,在一种普通场景(呈现博客文章页面)下对比它们的实现复杂性和效率。

介绍

GraphQL 是一种 IDL( 接口定义语言 Interface Definition Language ),设计者定义数据类型和并把数据建模为一个 graph 。每个顶点都是一种数据类型的一个实例,边代表了节点之间的关系。这种方式非常灵活,能适应任何业务领域。然而,问题是设计过程更加复杂,而且传统的数据存储不能很好地映射到图模型。阅读附录1了解更多关于这个问题的详细信息。

GraphQL 在 2014 年由 Facebook 的工程师团队首次提出。尽管它的优点和功能非常有趣而且引人注目,但它并没有得到大规模应用。开发者需要权衡 REST 的设计简单性、熟悉性、丰富的工具和 GraphQL 不会受限于 CRUD(LCTT 译注:Create、Read、Update、Delete) 以及网络性能(它优化了往返服务器的网络)的灵活性。

大部分关于 GraphQL 的教程和指南都跳过了从数据存储获取数据以便解决查询的问题。也就是,如何使用通用目的、流行存储方案(例如关系型数据库)为 GraphQL API 设计一个支持高效数据提取的数据库。

这篇博客介绍构建一个博客引擎 GraphQL API 的流程。它的功能相当复杂。为了和基于 REST 的方法进行比较,它的范围被限制为一个熟悉的业务领域。

这篇博客的文章结构如下:

  • 第一部分我们会设计一个 GraphQL 模式并介绍所使用语言的一些功能。
  • 第二部分是 PostgreSQL 数据库的设计。
  • 第三部分介绍了使用 Golang 实现第一部分设计的 GraphQL 模式。
  • 第四部分我们以从后端获取所需数据的角度来比较呈现博客文章页面的任务。

相关阅读

在 GraphQL 中建模一个博客引擎

下述列表1包括了博客引擎 API 的全部模式。它显示了组成图的顶点的数据类型。顶点之间的关系,也就是边,被建模为指定类型的属性。

type User {
  id: ID
  email: String!
  post(id: ID!): Post
  posts: [Post!]!
  follower(id: ID!): User
  followers: [User!]!
  followee(id: ID!): User
  followees: [User!]!
}

type Post {
  id: ID
  user: User!
  title: String!
  body: String!
  comment(id: ID!): Comment
  comments: [Comment!]!
}

type Comment {
  id: ID
  user: User!
  post: Post!
  title: String
  body: String!
}

type Query {
  user(id: ID!): User
}

type Mutation {
  createUser(email: String!): User
  removeUser(id: ID!): Boolean
  follow(follower: ID!, followee: ID!): Boolean
  unfollow(follower: ID!, followee: ID!): Boolean
  createPost(user: ID!, title: String!, body: String!): Post
  removePost(id: ID!): Boolean
  createComment(user: ID!, post: ID!, title: String!, body: String!): Comment
  removeComment(id: ID!): Boolean
}

列表1

模式使用 GraphQL DSL 编写,它用于定义自定义数据类型,例如 UserPostComment。该语言也提供了一系列原始数据类型,例如 StringBooleanID(它是String 的别名,但是有顶点唯一标识符的额外语义)。

QueryMutation 是语法解析器能识别并用于查询图的可选类型。从 GraphQL API 读取数据等同于遍历图。需要提供这样一个起始顶点;该角色通过 Query 类型来实现。在这种情况中,所有图的查询都要从一个由 id user(id:ID!) 指定的用户开始。对于写数据,定义了 Mutation 顶点。它提供了一系列操作,建模为能遍历(并返回)新创建顶点类型的参数化属性。列表2是这些查询的一些例子。

顶点属性能被参数化,也就是能接受参数。在图遍历场景中,如果一个博文顶点有多个评论顶点,你可以通过指定 comment(id: ID) 只遍历其中的一个。所有这些都取决于设计,设计者可以选择不提供到每个独立顶点的直接路径。

! 字符是一个类型后缀,适用于原始类型和用户定义类型,它有两种语义:

  • 当被用于参数化属性的参数类型时,表示这个参数是必须的。
  • 当被用于一个属性的返回类型时,表示当顶点被获取时该属性不会为空。
  • 也可以把它们组合起来,例如 [Comment!]! 表示一个非空 Comment 顶点链表,其中 [][Comment] 是有效的,但 null, [null], [Comment, null] 就不是。

列表2 包括一系列用于博客 API 的 curl 命令,它们会使用 mutation 填充图然后查询图以便获取数据。要运行它们,按照 topliceanu/graphql-go-example 仓库中的指令编译并运行服务。

# 创建用户 1、2 和 3 的更改。更改和查询类似,在该情景中我们检索新创建用户的 id 和 email。
curl -XPOST http://vm:8080/graphql -d 'mutation {createUser(email:"[email protected]"){id, email}}'
curl -XPOST http://vm:8080/graphql -d 'mutation {createUser(email:"[email protected]"){id, email}}'
curl -XPOST http://vm:8080/graphql -d 'mutation {createUser(email:"[email protected]"){id, email}}'
# 为用户添加博文的更改。为了和模式匹配我们需要检索他们的 id,否则会出现错误。
curl -XPOST http://vm:8080/graphql -d 'mutation {createPost(user:1,title:"post1",body:"body1"){id}}'
curl -XPOST http://vm:8080/graphql -d 'mutation {createPost(user:1,title:"post2",body:"body2"){id}}'
curl -XPOST http://vm:8080/graphql -d 'mutation {createPost(user:2,title:"post3",body:"body3"){id}}'
# 博文所有评论的更改。`createComment` 需要用户 id,标题和正文。看列表 1 的模式。
curl -XPOST http://vm:8080/graphql -d 'mutation {createComment(user:2,post:1,title:"comment1",body:"comment1"){id}}'
curl -XPOST http://vm:8080/graphql -d 'mutation {createComment(user:1,post:3,title:"comment2",body:"comment2"){id}}'
curl -XPOST http://vm:8080/graphql -d 'mutation {createComment(user:3,post:3,title:"comment3",body:"comment3"){id}}'
# 让用户 3 关注用户 1 和用户 2 的更改。注意 `follow` 更改只返回一个布尔值而不需要指定。
curl -XPOST http://vm:8080/graphql -d 'mutation {follow(follower:3, followee:1)}'
curl -XPOST http://vm:8080/graphql -d 'mutation {follow(follower:3, followee:2)}'

# 用户获取用户 1 所有数据的查询。
curl -XPOST http://vm:8080/graphql -d '{user(id:1)}'
# 用户获取用户 2 和用户 1 的关注者的查询。
curl -XPOST http://vm:8080/graphql -d '{user(id:2){followers{id, email}}}'
curl -XPOST http://vm:8080/graphql -d '{user(id:1){followers{id, email}}}'
# 检测用户 2 是否被用户 1 关注的查询。如果是,检索用户 1 的 email,否则返回空。
curl -XPOST http://vm:8080/graphql -d '{user(id:2){follower(id:1){email}}}'
# 返回用户 3 关注的所有用户 id 和 email 的查询。
curl -XPOST http://vm:8080/graphql -d '{user(id:3){followees{id, email}}}'
# 如果用户 3 被用户 1 关注,就获取用户 3 email 的查询。
curl -XPOST http://vm:8080/graphql -d '{user(id:1){followee(id:3){email}}}'
# 获取用户 1 的第二篇博文的查询,检索它的标题和正文。如果博文 2 不是由用户 1 创建的,就会返回空。
curl -XPOST http://vm:8080/graphql -d '{user(id:1){post(id:2){title,body}}}'
# 获取用户 1 的所有博文的所有数据的查询。
curl -XPOST http://vm:8080/graphql -d '{user(id:1){posts{id,title,body}}}'
# 获取写博文 2 用户的查询,如果博文 2 是由 用户 1 撰写;一个现实语言灵活性的例证。
curl -XPOST http://vm:8080/graphql -d '{user(id:1){post(id:2){user{id,email}}}}'

列表2

通过仔细设计 mutation 和类型属性,可以实现强大而富有表达力的查询。

设计 PostgreSQL 数据库

关系型数据库的设计,一如以往,由避免数据冗余的需求驱动。选择该方式有两个原因:

  1. 表明实现 GraphQL API 不需要定制化的数据库技术或者学习和使用新的设计技巧。
  2. 表明 GraphQL API 能在现有的数据库之上创建,更具体地说,最初设计用于 REST 后端甚至传统的呈现 HTML 站点的服务器端数据库。

阅读 附录1 了解关于关系型和图数据库在构建 GraphQL API 方面的区别。列表3 显示了用于创建新数据库的 SQL 命令。数据库模式和 GraphQL 模式相对应。为了支持 follow/unfollow 更改,需要添加 followers 关系。

CREATE TABLE IF NOT EXISTS users (
  id SERIAL PRIMARY KEY,
  email VARCHAR(100) NOT NULL
);
CREATE TABLE IF NOT EXISTS posts (
  id SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  title VARCHAR(200) NOT NULL,
  body TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS comments (
  id SERIAL PRIMARY KEY,
  user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE,
  title VARCHAR(200) NOT NULL,
  body TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS followers (
  follower_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  followee_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  PRIMARY KEY(follower_id, followee_id)
);

列表3

Golang API 实现

本项目使用的用 Go 实现的 GraphQL 语法解析器是 github.com/graphql-go/graphql。它包括一个查询解析器,但不包括模式解析器。这要求开发者利用库提供的结构使用 Go 构建 GraphQL 模式。这和 nodejs 实现 不同,后者提供了一个模式解析器并为数据获取暴露了钩子。因此 列表1 中的模式只是作为指导使用,需要转化为 Golang 代码。然而,这个“限制”提供了与抽象级别对等的机会,并且了解模式如何和用于检索数据的图遍历模型相关。列表4 显示了 Comment 顶点类型的实现:

var CommentType = graphql.NewObject(graphql.ObjectConfig{
    Name: "Comment",
    Fields: graphql.Fields{
        "id": &graphql.Field{
            Type: graphql.NewNonNull(graphql.ID),
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                if comment, ok := p.Source.(*Comment); ok == true {
                    return comment.ID, nil
                }
                return nil, nil
            },
        },
        "title": &graphql.Field{
            Type: graphql.NewNonNull(graphql.String),
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                if comment, ok := p.Source.(*Comment); ok == true {
                    return comment.Title, nil
                }
                return nil, nil
            },
        },
        "body": &graphql.Field{
            Type: graphql.NewNonNull(graphql.ID),
            Resolve: func(p graphql.ResolveParams) (interface{}, error) {
                if comment, ok := p.Source.(*Comment); ok == true {
                    return comment.Body, nil
                }
                return nil, nil
            },
        },
    },
})
func init() {
    CommentType.AddFieldConfig("user", &graphql.Field{
        Type: UserType,
        Resolve: func(p graphql.ResolveParams) (interface{}, error) {
            if comment, ok := p.Source.(*Comment); ok == true {
                return GetUserByID(comment.UserID)
            }
            return nil, nil
        },
    })
    CommentType.AddFieldConfig("post", &graphql.Field{
        Type: PostType,
        Args: graphql.FieldConfigArgument{
            "id": &graphql.ArgumentConfig{
                Description: "Post ID",
                Type:        graphql.NewNonNull(graphql.ID),
            },
        },
        Resolve: func(p graphql.ResolveParams) (interface{}, error) {
            i := p.Args["id"].(string)
            id, err := strconv.Atoi(i)
            if err != nil {
                return nil, err
            }
            return GetPostByID(id)
        },
    })
}

列表4

正如 列表1 中的模式,Comment 类型是静态定义的一个有三个属性的结构体:idtitlebody。为了避免循环依赖,动态定义了 userpost 两个其它属性。

Go 并不适用于这种动态建模,它只支持一些类型检查,代码中大部分变量都是 interface{} 类型,在使用之前都需要进行类型断言。CommentType 是一个 graphql.Object 类型的变量,它的属性是 graphql.Field 类型。因此,GraphQL DSL 和 Go 中使用的数据结构并没有直接的转换。

每个字段的 resolve 函数暴露了 Source 参数,它是表示遍历时前一个节点的数据类型顶点。Comment 的所有属性都有作为 source 的当前 CommentType 顶点。检索idtitlebody 是一个直接属性访问,而检索 userpost 要求图遍历,也需要数据库查询。由于它们非常简单,这篇文章并没有介绍这些 SQL 查询,但在参考文献部分列出的 github 仓库中有。

普通场景下和 REST 的对比

在这一部分,我们会展示一个普通的博客文章呈现场景,并比较 REST 和 GraphQL 的实现。关注重点会放在入站/出站请求数量,因为这些是造成页面呈现延迟的最主要原因。

场景:呈现一个博客文章页面。它应该包含关于作者(email)、博客文章(标题、正文)、所有评论(标题、正文)以及评论人是否关注博客文章作者的信息。图1图2 显示了客户端 SPA、API 服务器以及数据库之间的交互,一个是 REST API、另一个对应是 GraphQL API。

+------+                  +------+                  +--------+
|client|                  |server|                  |database|
+--+---+                  +--+---+                  +----+---+
   |      GET /blogs/:id     |                           |
1\. +------------------------->  SELECT * FROM blogs...   |
   |                         +--------------------------->
   |                         <---------------------------+
   <-------------------------+                           |
   |                         |                           |
   |     GET /users/:id      |                           |
2\. +------------------------->  SELECT * FROM users...   |
   |                         +--------------------------->
   |                         <---------------------------+
   <-------------------------+                           |
   |                         |                           |
   | GET /blogs/:id/comments |                           |
3\. +-------------------------> SELECT * FROM comments... |
   |                         +--------------------------->
   |                         <---------------------------+
   <-------------------------+                           |
   |                         |                           |
   | GET /users/:id/followers|                           |
4\. +-------------------------> SELECT * FROM followers.. |
   |                         +--------------------------->
   |                         <---------------------------+
   <-------------------------+                           |
   |                         |                           |
   +                         +                           +

图1

+------+                  +------+                  +--------+
|client|                  |server|                  |database|
+--+---+                  +--+---+                  +----+---+
   |      GET /graphql       |                           |
1\. +------------------------->  SELECT * FROM blogs...   |
   |                         +--------------------------->
   |                         <---------------------------+
   |                         |                           |
   |                         |                           |
   |                         |                           |
2\. |                         |  SELECT * FROM users...   |
   |                         +--------------------------->
   |                         <---------------------------+
   |                         |                           |
   |                         |                           |
   |                         |                           |
3\. |                         | SELECT * FROM comments... |
   |                         +--------------------------->
   |                         <---------------------------+
   |                         |                           |
   |                         |                           |
   |                         |                           |
4\. |                         | SELECT * FROM followers.. |
   |                         +--------------------------->
   |                         <---------------------------+
   <-------------------------+                           |
   |                         |                           |
   +                         +                           +

图2

列表5 是一条用于获取所有呈现博文所需数据的简单 GraphQL 查询。

{
  user(id: 1) {
    email
    followers
    post(id: 1) {
      title
      body
      comments {
        id
        title
        user {
          id
          email
        }
      }
    }
  }
}

列表5

对于这种情况,对数据库的查询次数是故意相同的,但是到 API 服务器的 HTTP 请求已经减少到只有一个。我们认为在这种类型的应用程序中通过互联网的 HTTP 请求是最昂贵的。

为了利用 GraphQL 的优势,后端并不需要进行特别设计,从 REST 到 GraphQL 的转换可以逐步完成。这使得可以测量性能提升和优化。从这一点,API 设计者可以开始优化(潜在的合并) SQL 查询从而提高性能。缓存的机会在数据库和 API 级别都大大增加。

SQL 之上的抽象(例如 ORM 层)通常会和 n+1 问题相抵触。在 REST 示例的步骤 4 中,客户端可能不得不在单独的请求中为每个评论的作者请求关注状态。这是因为在 REST 中没有标准的方式来表达两个以上资源之间的关系,而 GraphQL 旨在通过使用嵌套查询来防止这类问题。这里我们通过获取用户的所有关注者来作弊。我们向客户提出了如何确定评论并关注了作者的用户的逻辑。

另一个区别是获取比客户端所需更多的数据,以免破坏 REST 资源抽象。这对于用于解析和存储不需要数据的带宽消耗和电池寿命非常重要。

总结

GraphQL 是 REST 的一个可用替代方案,因为:

  • 尽管设计 API 更加困难,但该过程可以逐步完成。也是由于这个原因,从 REST 转换到 GraphQL 非常容易,两个流程可以没有任何问题地共存。
  • 在网络请求方面更加高效,即使是类似本博客中的简单实现。它还提供了更多查询优化和结果缓存的机会。
  • 在用于解析结果的带宽消耗和 CPU 周期方面它更加高效,因为它只返回呈现页面所需的数据。

REST 仍然非常有用,如果:

  • 你的 API 非常简单,只有少量的资源或者资源之间关系简单。
  • 在你的组织中已经在使用 REST API,而且你已经配置好了所有工具,或者你的客户希望获取 REST API。
  • 你有复杂的 ACL(LCTT 译注:Access Control List) 策略。在博客例子中,可能的功能是允许用户良好地控制谁能查看他们的电子邮箱、博客、特定博客的评论、他们关注了谁,等等。优化数据获取同时检查复杂的业务规则可能会更加困难。

附录1:图数据库和高效数据存储

尽管将其应用领域数据想象为一个图非常直观,正如这篇博文介绍的那样,但是支持这种接口的高效数据存储问题仍然没有解决。

近年来图数据库变得越来越流行。通过将 GraphQL 查询转换为特定的图数据库查询语言从而延迟解决请求的复杂性似乎是一种可行的方案。

问题是和关系型数据库相比,图并不是一种高效的数据结构。图中一个顶点可能有到任何其它顶点的连接,访问模式比较难以预测因此提供了较少的优化机会。

例如缓存的问题,为了快速访问需要将哪些顶点保存在内存中?通用缓存算法在图遍历场景中可能没那么高效。

数据库分片问题:把数据库切分为更小、没有交叉的数据库并保存到独立的硬件。在学术上,最小切割的图划分问题已经得到了很好的理解,但可能是次优的,而且由于病态的最坏情况可能导致高度不平衡切割。

在关系型数据库中,数据被建模为记录(行或者元组)和列,表和数据库名称都只是简单的命名空间。大部分数据库都是面向行的,意味着每个记录都是一个连续的内存块,一个表中的所有记录在磁盘上一个接一个地整齐地打包(通常按照某个关键列排序)。这非常高效,因为这是物理存储最优的工作方式。HDD 最昂贵的操作是将磁头移动到磁盘上的另一个扇区,因此最小化此类访问非常重要。

很有可能如果应用程序对一条特定记录感兴趣,它需要获取整条记录,而不仅仅是记录中的其中一列。也很有可能如果应用程序对一条记录感兴趣,它也会对该记录周围的记录感兴趣,例如全表扫描。这两点使得关系型数据库相当高效。然而,也是因为这个原因,关系型数据库的最差使用场景就是总是随机访问所有数据。图数据库正是如此。

随着支持更快随机访问的 SSD 驱动器的出现,更便宜的内存使得缓存大部分图数据库成为可能,更好的优化图缓存和分区的技术,图数据库开始成为可选的存储解决方案。大部分大公司也使用它:Facebook 有 Social Graph,Google 有 Knowledge Graph。


via: http://alexandrutopliceanu.ro/post/graphql-with-go-and-postgresql

作者:Alexandru Topliceanu 译者:ictlyh 校对:wxy

本文由 LCTT 原创编译,Linux中国 荣誉推出