首页
学习
活动
专区
工具
TVP
发布
精选内容/技术社群/优惠产品,尽在小程序
立即前往

五年时间,我们怎样构建一个 GraphQL API 组合?

工程师们都非常喜欢听好故事。我们花 5 年时间构建的由 GraphQL 组合的 API 现在上线了(峰值为每秒 110 个请求,延迟 100ms),这个过程应该是一个不错的故事。

我们的需求

多年来,Pipedrive(在 2020 年初已经 10 年了)一直有针我们的 webapp 的一个公开的 REST API,以及隐藏的未记录的端点——其中一个是 /users/self,这个接口最初是用来加载用户信息的,但是随着时间的推移,它变成了一个页面加载 API,由 30 种不同实体类型组成。它最初是在我们的 PHP 单体应用中创建的,本质上的同步的。我们尝试将它分离到并行线程中,但是结果并不太好。

针对现有流量的 /users/self 的延迟分布

从维护的角度来看,随着每一个新的改动,它变得更加混乱,因为没人想要负责这个巨大的端点。

直接数据库访问的概念验证项目

让我们回顾我们的开发人员刚接触 graphql 的时候。

大约 3-4 年前,在 marketplace 团队,我开始从我们的全栈工程师 Pavel 那里听到“elixir”和“graphql”之类的新术语。他参与了一个概念验证(proof-of-concept,PoC)项目,该项目直接访问 MySQL 并暴露 /graphql 端点来查询核心的 Pipedrive 实体。

这个项目在开发环境运行得很好,但是并没有推广,因为我们的后端不仅仅是 CRUD,而且没人想要重写整个应用。

概念验证

项目改进 2019 年时,我看到了另外一个同事创建的内部概念验证项目,它使用了 GraphQL schema 以及 graphql-compose,并向我们的 REST API 发送请求。可以想象,这是一个重大的改进,因为我们不再需要重写所有的业务逻辑,它只是一个包装器。

这个概念验证项目的缺点是:

  • 性能。它没有一个数据加载器,因此它有潜在的 N+1 API 调用问题。它没有限制查询复杂度,也没有任何中间缓存。平均而言,它的延迟比单体应用要高。
  • Schema 管理。随着 schema 变化,我们需要在单独的仓库中定义一个 schema,与服务数据的实际服务分开。这使部署变得复杂,因为我们需要中间过程的向后兼容的部署,以避免在服务更改时发生崩溃。

准备

在 2019 年 10 月,我开始为将先前的概念验证项目转移到生产环境的任务做准备,但是使用了同年出现的一个新的 Apollo 联盟。这将落地到一个核心团队,来长期维持服务。

收集开发者预期

在内部,一些开发者持怀疑态度,建议通过修改 REST API URL 和它们的负载到单个 POST 请求,并依赖一个内部网关,来在后端分离请求,从而构建一个内部 API 组合。

一些人认为 graphql 还太原始,还不能在生产中采用,最好保持现状。一些人建议探索替代方案,例如 Protobuf 或者 Thrift,以及使用诸如 GRPC、OData 之类的传输协议。

相反,有些团队全力以赴,已经在生产中为个别服务(insights、teams)使用了 graphql,但是不能复用其它 schema(例如 User 实体)。有些(leads)使用了 typescript+relay,还需要弄清楚 如何联合。

调研新技术是令人兴奋的:

为前端开发人员提供严格的自定义的 API?全局的实体声明和所有权,强制减少了重复并提高了透明度?一个网关能够自动合并来自不同服务的数据,而不会过度获取?太棒了。

我知道,我们需要 schema 管理作为一个服务,以避免依赖硬编码并能够看到正在发生的事情。类似 Confluent’s schema-registry 或者 Atlassian’s Braid 之类的东西,但不是 Kafka 绑定的,也不是用 Java 编写的,我们并不想维护这样的服务。

计划

我提出了一个工程任务,重点是 3 个目标:

  • 将初始页面的加载时间减少 15%。通过将一些 REST API 调用合并到单个 /graphql 请求来实现。
  • 将 API 流量减少 30%。通过将事务加载转移到 graphql 并请求更少的属性来实现。
  • 在 API 中使用严格的 schema(以便前端编写更少防御性代码)。

我很幸运有 3 位经验丰富的开发人员加入这个任务,其中包括一位概念验证项目的作者。

来自 webapp 的多个 REST API 调用在不同的时间完成

针对这些服务的最初计划如下:

我们需要改动的服务

这里的 schema-registry 是一个通用服务,可以存储任何类型的 schema 作为输入(swagger、typescript、graphql、avro、proto)。它还可以足够智能,将一个实体转变成你想要输出的任何格式。这个网关将轮询 schema 并调用负责它的服务。前端组件需要下载 schema 并用它来进行查询。

然而实际上,我们只实现了 graphql,因为联盟只需要这个,而且我们很快就用完了时间。

结果

在 webapp 中替换混乱的 /users/self 端点的主要目标在任务的前 2 周就完成了。但是,为了使它性能更好和更可靠,我们花费了任务中大量时间来打磨它。

到任务结束时(2020 年 2 月),根据我们使用的 Datadog 的合成测试,我们确实实现了将初始页面加载时间减少 13%,并将页面重新加载的时间减少了 25%(由于引入了缓存)。

我们没有达到减少流量的目标,因为我们没有重构 webapp 中的 pipeline 视图——我们在那里仍然使用 REST。

为了提供采用率,我们增加了内部工具来简化联合过程,并录制了上手视频,以便团队理解它是如何工作的。在任务结束后,IOS 和 Android 客户端也迁移到了 graphql,而且团队也给出了积极的反馈。

经验教训

回顾整个 60 天我保存的任务日志,我整理出了其中最大的一些问题,以便你不会犯同样的错误。

管理你的 schema

我们能自己构建这个吗?也许能,但它不会被打磨得这么好。

在最初的几天里,我尝试了 Apollo studio 及其 CLI 工具来验证 schema。这个服务非常棒只需很少的网关配置就可以开箱即用。

我们内部用来查看 schema 对比和校验终端推送的 schema 的工具,和 Apollo 的工具相似

尽管它起作用,我仍然觉得将核心后端流量捆绑到一个外部 SaaS 服务对于业务的连续性来说风险太大了,无论它有多么好的功能或定价计划。这也是为什么我写了一个我们自己的只有基础功能的服务——现在这是一个开源的 graphql-schema-registry。

使用内部 schema-registry 的第二个原因是遵循 Pipedrive 的分布式数据中心模型。我们不依赖中心化的基础设施,每个数据中心都是自给自足的。这为我们提供了更高的可靠性以及潜在的优势,以防我们需要在中国、俄罗斯、伊朗或火星等地开设新的数据中心。

对你的 schema 进行版本控制

联合的 schema 和 graphql 网关是非常脆弱的。如果某个服务中有命令冲突或者无效的引用,并将它提供给网关,那么网关不会喜欢的。

默认地,一个网关的行为是轮询服务的 schema,因此一个服务很容易让整个流量崩溃。Apollo studio 通过校验推送时的 schema 并在可能引起冲突时拒绝注册来解决这个问题。

这个校验理念是正确的方法,但是这个实现也意味着 Apollo studio 是一个 有状态的 服务,保存着 当前的 有效 schema。这使得 schema 注册协议 更复杂并且依赖时间,在滚动部署的情况下可能会 有点儿难以调试。

相反,我们将服务版本(基于 docker 镜像哈希值)绑定到它的 schema。服务也在运行时注册 schema,但我们只注册一次,而不需要一直推送。网关从服务发现(consul)获取联合服务,并请求到 /schema/compose 的 schema-registry,提供它们的版本。

如果 schema-registry 看到提供的版本集不稳定,他会回滚到上一次注册的版本(这些版本用于提交时的 schema 校验,因此应该是稳定的)。

使用内部库的运行时 schema 注册示例

这些服务可以同时为 REST 和 Graphql API 提供服务,因此我们在 schema 注册失败时可以诉诸警报,从而让服务对于 REST 仍可以保持工作。

基于现有 REST 来定义 schema 不是那么简单

由于我不知道怎么将我们的 REST API 转变成 graphql,我尝试了 openapi-to-graphql,但是我们的 API 参考文档 没有足够详细的 swagger 文档来覆盖当时所有的输入 / 输出。

让每个团队定义 schema 会花费大量时间,因此我们自己基于 REST API 的响应为主要实体定义了 schema。

这样做后来让我们备受困扰,因为其中一些 REST API 依赖发起请求的客户端 或者 根据一些业务逻辑 / 状态有不同的响应格式。

例如,自定义字段 根据客户影响 API 响应。如果你将一个自定义字段添加到一个 deal,它将作为与 deal.pipeline_id 相同级别的 hash 响应。动态 schema 不能用于 graphql 联合,因此我们不得不通过将自定义字段转移到一个单独的属性来解决这个问题。

另一个长期问题是 命名规则。我们想要使用驼峰式,但是大部分 REST 使用下划线分隔方式,所以我们现在使用了一种混合的方式。

voyager 生成的当前 Pipedrive 的联合图谱(左边)以及 2 个联合的微服务(539 个)和尚未联合的部分(右边)。

CQRS 和缓存

Pipedrive 的数据模型没有简单到可以依赖 TLL 缓存。

例如,如果一个支持工程师从我们的后台创建了一个关于维护的全局信息,他也希望这个信息能够立即显示给客户。这些全局信息可以显示给所有客户,也可以影响特定用户或公司。打破这样的缓存需要 3 层。

为了在异步模式处理 php 单体应用,我们创建了一个 nodejs 服务(称为 monograph),它将 php 响应的任何东西缓存到 memcached。这个缓存必须根据业务逻辑从 PHP 清除,这使得这有点儿像一个反模式的紧耦合的跨服务缓存。

你可以在这里看到 CQRS 模式。这些缓存使得加速 80% 的请求并且获得与 php 应用程序相同的平均延迟变得可能,同时还拥有严格的 schema 并且不会过多获取。

NewRelic 在美国地区的 php 应用程序的平均延迟(左边)vs graphql 网关的平均延迟(右边)

另外一个复杂的问题是客户的语言。改变这一点会影响如此多不同的实体——从活动类型到谷歌地图视图,更糟糕的是,用户语言不再由 php 应用程序管理,而是在 identity 服务中——我不想将更多的服务耦合到单个缓存中。

Identity 负责用户信息,因此它发送一个 monograph 监听的 change 事件并清空它的缓存。这意味着在更改语言和清理缓存之间有一些延迟(可能最大~1 秒),但这并不重要,因为一个客户不会从一个页面这么快地导航到另外一个页面而意识到旧语言仍在缓存中。

跟踪性能

性能是我们的主要目标,为了达到这个目标,我们必须掌握 APM 和微服务之间的分布式跟踪,看看哪个方面最慢。在此期间,我们使用了 Datadog,它显示了主要问题。

我们还使用 memcached 来缓存所有 30 个并行请求。(照片上展示的)问题显示,针对某些解析器的到 memcached 的紫色请求高达 220ms,而最初的 20 个请求在 10ms 内保存了数据。这是因为我对所有的请求使用了相同的 mcrouter 主机。切换主机最多减少了 20ms 的缓存写入延迟。

为了减少网络流量带来的延迟,我们使用 getMulti 和 5ms 的防抖来对不同的解析器发起单个批处理请求而从 memcached 获取数据。

任务期间的问题——在 Datadog 分布式追踪中的 memcached(左)、慢的单解析器(右)

注意右边的黄色条——这是 graphql网关 在所有数据解析后使数据严格类型化 的负担。它会随着你传输的数据的大小增长。

我们需要找出最慢的解析器,因为整个请求的延迟完全取决于它们。看到 30 个解析器中的 28 个在 40ms 内回复,而 2 个 API 因为没有任何缓存而花费 500ms,会非常令人恼火。

我们不得不将这些端点移出初始化查询来创建更好的延迟表现。因此,我们实际上从前端发起了 3-5 个单独的 graphql 查询,这取决于查询请求的时间(以及一些防抖逻辑)。

不要(在生产环境)跟踪性能

这里反直觉的诱惑点击的标题实际上意味着你应该避免在 graphql 网关的生产环境使用 APM 或者 apollo 服务器内置的 tracing:true。

使用 Chrome DevTools 分析 graphql 网关

如果一个函数非常小但是对它的调用非常多,那么 flame chart 是不明显的。

对我们的测试公司来说,同时打开或移除这些追踪工具会将延迟时间从 700ms 降低 2 倍到 300ms。当时的原因(据我所知)是时间函数(例如 performance.now())针对每个解析器的测量模块太占 CPU 了。

Ben 这里 对不同的后端服务器做了一个很好的基准测试,证实了这一点。

考虑在前端预加载

在前端执行 graphql 查询的时间点是非常棘手的。我想要将初始的 graphql 请求尽可能移到网络瀑布流的前方(在 vendors.js 之前)。这样做会给予我们一些时间,但是这使得 webapp 更加难以维护。

为了发起查询,你需要 graphql 客户端 和 gql 文本解析,这些通常通过 vendors.js 提供。现在你要么需要将它们单独打包,要么使用原始 fetch 发起查询。即使你发起了一个原始请求,你也需要优雅地管理响应,以便将响应传播到正确的模型中(但这些模型之后才被初始化)。因此最好不要这么做,将来也许应该使用服务端渲染或者 service workers。

将 /graphql 调用移动到网络请求链的比较靠前的位置(谷歌 Chrome 检查器)

评估查询复杂度

graphql 之所以与 REST 不同的地方是,你可以在处理一个客户端的请求之前评估它对于你的基础设施的复杂度。这完全基于他请求的内容以及你如何定义针对你的 schema 的执行成本。如果估计的成本过大,我们就拒绝请求,类似于我们的 速率限制。

我们最先尝试了 graphql-cost-analysis 库,但最终创建了我们自己的库,因为我们想要逻辑考虑分页乘数、嵌套和影响类型(网络、I/O、DB、CPU)。但最难的部分是向网关和 schema-registry 注入自定义的 cost 指令。我希望我们可以在不久的将来也把它开源。

Schema 有很多方面

在底层使用 js/typescript 的 schema 是非常令人困惑的。当你尝试将 federation 集成到你现有的 graphql 服务时,你就会发现这一点。

例如,普通 koa-graphql 和 apollo-server-koa 设置需要一个嵌套的 GraphQLSchema 参数,包含解析器,但是联合 apollo/server 想要 schema 单独传递:

buildFederatedSchema([{typeDefs, resolvers}])

在另一种情况下,你可能想要将 schema 定义为内联的 gql 标签 字符串或者将它存储到 schema.graphql 文件,但是当你需要做成本评估时,你需要将它作为 ASTNode(解析 / 构建 ASTSchema)。

逐步推广

在这个过程中,我们一开始向所有内部开发者进行了逐步的推广,以发现明显的错误。

到任何结束时,也就是 2 月份,我们只向 100 家幸运的公司发布了 graphql。我们然后缓慢地将它推广到 1000——1%、10%、30%、50%,最后在六月份完成 100% 的客户。

这个推广是基于公司 ID 和模块逻辑。我们还为测试公司和开发人员还不希望他们的公司使用 graphql 的情况提供了允许列表和拒绝列表。我们还有一个紧急开关,可以恢复,这在发生故障时非常便于简化调试。

考虑到我们做出了多大的改变,这是一个很好的方式来获得反馈并发现 bug,同时降低了客户的风险。

希望与梦想

为了获得 graphql 的所有好处,我们需要采用 mutations、subscriptions 和一个联合级别上的批处理。所有这些都需要团队合作和内部布道来扩大 联合服务的数量

一旦 graphql 稳定并足以满足我们的客户,它就可以成为我们公开 API 的第 2 版。一次公开发布需要指令来基于 OAuth scopes(对于 marketplace apps)和我们的产品套餐来限制实体访问。

对于 schema-registry,我们需要 追踪客户端 并获取 使用分析 来获得更好的弃用体验、过滤/ 高亮 schema 的成本和可见性、命名校验、管理非短期的 持久化查询、公开的 schema变更历史记录

由于我们有用 go 编写的服务,因此还不清楚内部通信会如何发生——通过 GRPC 实现速度和可靠性,或者单独的 graphql 端点,或者通过集中化的内部 graphql 网关?如果 GRPC 仅仅因为它的字节码特性而更好,那么我们是否可以使用 graphql 字节码而不是 msgpack?

至于外部世界,我希望 Apollo 的路线图(附带 project Constellation)会优化 Rust 中的 Query planner,这样我们就不会看到 10% 的网关性能损耗,以及在他们不知情的情况下实现灵活的服务联合。

享受软件开发中激动人心的时刻,充满了复杂性!

作者介绍:

Artjom Kurapov 是 Pipedrive 核心团队的一名软件工程师,喜欢聚会、猫和复杂的系统。

原文链接:

https://medium.com/pipedrive-engineering/journey-to-federated-graphql-2a6f2eecc6a4

  • 发表于:
  • 本文为 InfoQ 中文站特供稿件
  • 首发地址https://www.infoq.cn/article/oKtOMtyXrpkRi5kU9J5u
  • 如有侵权,请联系 cloudcommunity@tencent.com 删除。

扫码

添加站长 进交流群

领取专属 10元无门槛券

私享最新 技术干货

扫码加入开发者社群
领券