Uber 在客户服务平台中,借助 GraphQL 构建了数据融合(data hydration)层。本文分析了原有技术方案的痛点,采用 GraphQL 方案后的技术架构以及收益。
本文最初发表于Uber的开发者博客,由 InfoQ 中文站翻译分享。
每当客户通过支持工单和优步联系时,我们都希望能够快速和无缝地处理他们的问题。
为了让客户支持工单的处理过程尽可能地简化,我们的 Customer Obsession 工程团队设计和开发了一个帮助解决客户支持工单的新 Web 应用,该应用会用到从优步技术栈融合而来的聚合数据。我们设计了一个后端库来处理数据融合(data hydration),也就是获取并适配不同的数据片段以形成一个完整的联系对象(contact object)。在只有几十个服务的时候,后端库能够很好地运行,随着更多的业务线加入到平台中来,各种技术痛点随之而来,包括开发者速度的降低、扇出(fan-out)的复杂性以及错误处理。
在考虑了各种融合替代方案之后,我们选择了 GraphQL,这是一个用于 API 的开源查询语言。GraphQL 可以解释我们的数据源结构,只提供解决问题所需的信息,从而降低客户服务平台(Customer Care Platform)的吞吐量。
集成 GraphQL 能够明显降低工程师添加新特性时要编写的代码数量,简化这些特性的开发,从而使优步的用户从中收益。它还使我们能够在不需要大量培训的情况下从优步的软件工程师那里吸收他们的贡献,从而提高开发人员的生产力。
吸收数据
Customer Obsession 系统的代理一般会查看一个仪表盘,上面全是解决客户支持工单所需要的信息。我们将其称为解决方案上下文(solution context),因为它能够给代理成功解决问题所需的数据。为了将与支持代理的每一次交互都实现个性化,平台会根据大量的可用数据来构建解决方案上下文,这些数据可能包括行程的目的地或路线、支付类型,以及客户是否为合作的司机、乘客、食客或餐馆等等。
解决方案上下文可以帮助我们确定问题是否可以以自动化的方式解决,或者是否需要通过一系列应用程序内的屏幕切换获取其他的额外信息。成功的解决方案可能还需要我们的支持代理通过电话进行干预。如下的图 1 提供了一个自动化解决方案的示例,用户可以通过导航优步移动应用程序来更改他们的支付方式:
图 1 通过优步 App 中的自服务流,乘客可以成功解决支付相关的问题:他们所需要做的就是选择“Support”,点击“Switch payment method”,然后从菜单列表中选择他们的支付方式。随后,客户服务平台会确认问题已经得到了解决
鉴于优步采用的是面向服务的微服务架构,我们必须要融合(转换上游服务的响应来填充我们的数据对象)数据,这需要使用 100 多个上游服务的数据来解决客户的问题。我们的全球业务扩展到了餐饮(Uber Eats)、货运(Uber Freight)和电动车(Jump e-bikes)等领域,所以很重要一点就是对客户要有一个统一的视图,以便于查看他与不同业务单元和产品的交互,这样才能够确保用户在我们的平台中获得最优的体验。例如,一个客户既可能是合作的司机,也可能是食客,还可以是商务旅行者,在美国使用 UberX,但在印度使用 Uber Premium。我们的客户服务平台需要在讨论客户支持问题的时候,在上下文中调用最相关的数据。
随着每天支持的行程超过 1400 万次,我们最初的数据融合工具开始显示出它的局限性。数据源和贡献者数量的增加进一步扩展了我们的融合服务。我们还必须要考虑后端服务在创建新特性和响应高速增长时,所做的重写都需要重新进行集成。
旧数据融合层的痛点
我们的客户服务平台依赖于 JavaScript 编写的后端库,名为 Contact-Context-Service(CCS)。CCS 会从上游服务抓取信息,并基于不同的需求对数据进行融合。每个上游服务都有一个对应的上下文抓取器(context fetcher)或中间件。上下文抓取器对于简单的数据抓取来讲能够运行得非常好,比如获取优惠券和促销信息,但是对于更复杂的数据融合,我们要使用中间件处理对多个抓取器的扇出。例如,为了收集发票的信息,我们必须从支付服务、地理服务和其他服务发送和接收数据。
为了提升数据抓取的效率和性能,我们为 CCS 配置了 Redis 和内存缓存。如果 CCS 发现内存缓存和 Redis 均不可用的话,上下文抓取器会通过 HTTP 或 TChannel 调用一个远程过程调用(remote procedure call,RPC)。响应数据会由中间件和适配器进行处理,并在回调函数中返回给服务模块,如下面的图 2 所示:
图 2 CCS 使用抓取器来检索数据,并使用中间件适配来自多个抓取器的数据,随后再将其传输给缓存和微服务
CCS 扇出机制使用了 async.parallel 和 callbacks。相对于 Promise 和 Async/Await,这种方式的优势在于它需要更少的内存和调用栈,如下面的图 3 所示:
图 3 Callbacks 需要更少的调用栈,因此相对于 Async/Await 和 Promise,这会带来更低的延迟,如上图所示。在所有的竞争者中,Callbacks 的延迟最低,最高可以超过 50000 个并行调用。在某些场景下,延迟的差异是非常明显的,比如在 50000 并行调用的场景中,callback 要比 Async/Await Babel 快两倍还多
但是,我们发现扇出机制的深度嵌套回调使得代码的跟踪变得非常困难。随着优步业务的增长,CCS 架构中不断增加的分层开始降低开发人员的速度。另外,随着数据模型的不断增长,添加新的数据字段需要对整个数据结构有全面的了解,这样才能确定新的调用应该是串行还是并行。总而言之,旧的设计导致了资源的低效利用。
随着客户服务平台与更多的业务单元和产品集成,解决重要的痛点变得越发重要,比如:
扇出的复杂性
为了改善融合的延迟,我们需要识别哪些端点需要并行化,哪些端点需要串行化。每当我们添加新的数据或重构端点的时候,我们都必须要重复这个过程,这需要过多的工程资源。
开发人员的速度
我们的客户服务平台由来自 Customer Obsession 团队和优步其他团队的 40 多位贡献者。如图 2 所示,在平台之前的版本中,工程师必须要实现多个分层。每当发布一个新服务的时候,我们都需要新的上游服务代理客户端、中间件、适配器,以及每个分层的测试。对于开发人员来说,这样的上线过程太耗时了。
随着数据模型的增长,我们经历了 CCS 层过深嵌套字段所导致的故障。在 CCS 中识别给定请求所需的特定字段变得越来越困难。这使得故障检测变得更加困难,并且当我们不能快速识别和修复根本原因时,会对客户体验产生负面影响。
数据融合要吸收一些基本信息,该信息来源于合作司机、乘客和交付伙伴数据模型中共享的字段。但是,在使用 CCS 的时候,我们必须从这三个模型中分别检索重复的字段,这会带来不必要的工作。
错误处理
优步微服务体系结构的本质特点就是,当上游服务在下游服务没有进行测试的情况下改变其响应时,会带来意外之外的结果。使用 CCS,我们必须手动分析每个新的上游端点,以确保它与下游兼容。我们还必须不断更新上游服务的所有非向后兼容的更改,以确保恰当地进行错误处理。随着客户服务平台的扩大,保持一致性变得尤其困难。
为何选择 GraphQL?
为了解决这些痛点,我们开始评估更健壮的数据融合策略。我们考虑在优步内部的服务通信层上构建一个图层以获取数据。但是,在研究完各种可选方案(包括 GraphQL)之后,我们意识到构建自己的图层是没有必要的。
GraphQL 是一个开源的工具,目前已经用到了很多行业中,它为 API 中可用的数据提供一个完整且可理解的描述。该功能使得 API 更易于随时间演化,它优化了错误处理,并且支持 GraphQL Playground,这是一个用户友好的交互式开发人员工具,借助它我们能够对模式进行可视化并且能够在本地运行测试查询。
GraphQL 的主要优势在于它的声明式数据抓取。在我们的使用场景中,客户服务平台基于 Web 的前端应用会查询 GraphQL 服务器以获取特定的数据。服务器了解完整的模式,因此能够基于应用的解析器(resolver)去解析查询,并准确交付所请求的数据。
GraphQL 只会抓取满足查询所需的数据,不会抓取任何更多的数据,这在最大程度上减少了上游服务或源的压力。GraphQL API 确保服务器会返回请求所需的准确的、结构化响应,不带有任何不必要的额外信息。
使用 GraphQL 能够明显减少在添加新服务和端点时工程师需要编写的代码量。工程师不再需要编写代理客户端、抓取器、中间件和适配器,他们只需要在解析器中编写一遍抓取逻辑就可以了,将旧架构中所有不必要的客户端、抓取器和中间件全部移除掉了。
将 GraphQL 与客户服务平台集成
为了将 GraphQL 与我们的客户服务平台前端集成,工程师首先需要在 GraphQL 服务器上定义一组模式。模式中所定义的每个类型的字段还需要一个函数或解析器,以便于调用上游服务的请求并将响应映射为对应的模式。
模式和解析器准备就绪之后,GraphQL 服务器会使用它们来解析接收到的所有请求。现在,当调用者查询某个字段的时候,GraphQL 调用解析器来抓取对应的数据以便于进行处理。在处理完查询中所有的字段和嵌套字段(例如,图 4 所示的 fareAmount 和 currencyCode)后,GraphQL 服务器为客户端返回一个结构化的响应,类似于 JSON 文件。
图 4 GraphQL 服务器根据预定义的模式解释查询,然后调用对应的解析器以抓取请求的数据并返回响应
客户服务平台的架构
将 GraphQL 集成到客户服务平台中大大改变了以前基于 CCS 的系统的架构。新的架构如 5 所示,它能够让开发人员避免为每个新的上游服务不断编写新适配器和各种层。GraphQL 不会加载完整的用户数据文件,GraphQL 基于查询的声明式数据抓取调用能够快速得到所需的数据,给整个系统所带来的压力是很有限的。
图 5 客户服务平台新架构,在基于 Web 的前端中发起查询会被路由至 GraphQL 服务器,GraphQL 服务器依赖其解析器抓取对应的字段
客户服务平台的架构包含如下的组件:
Fusion.js/Apollo:该 Web 应用接收来自客户服务平台对各种数据集查询请求并将它们传递给我们的 GraphQL 服务器;
GraphQL 服务器:架构的主动脉,该服务器接收并处理来自客户服务平台的查询;
解析器:GraphQL 服务器解释完传入的请求并定位对应的模式之后,它将会调用模式对应的解析器;
查询上下文缓存:GraphQL 服务器检查缓存以确认当前请求的数据是否已经缓存到了该查询中。如果已经缓存的话,它会将结果返回给客户端。否则的话,它会批量加载并路由至下一步;
内存和 Redis 缓存:与查询上下文缓存类似,我们检查内存和 Redis 缓存,以确认是否可以无需调用上游服务抓取数据就能返回结果;
RPC 客户端:如果 GraphQL 服务器无法从缓存中获取数据的话,RPC 客户端将会被调用,以便于从优步的微服务中抓取数据并将结果返回给客户端;
嵌套解析器:有些查询请求可能需要跨多个服务查询关系数据。工程师需要基于查询的结构继续调用解析器。系统会递归重复步骤 5 到步骤 7,直到查询调用了所有必要的嵌套数据为止。
使用 Fusion.js 实现 GraphQL
我们使用优步的开源、统一 Web 框架 Fusion.js 来构建 GraphQL 服务器和客户服务平台的前端。基于我们的需求,优步 Web 平台团队开发了 fusion-plugin-apollo、fusion-apollo-universal-client 和 fusion-plugin-apollo-server 来托管 Web 服务器和 GraphQL 端点。
除了服务器之外,GraphQL 实现中第二个最重要的组成部分就是解析器,它们定义了平台如何抓取并渲染来自上游服务的数据。Atreyu 是我们内部的一个通信层,能够帮助 Web 应用与上游服务进行交互,它提供了一个通用的接口,能够同时向多个服务发送请求。对于客户服务平台,我们使用了内部的通信层,借助它发送到面向服务架构 API 的请求。随后,如果需要的话,解析器会进行简单的转换,并将查询结果返回给前端客户端。
随着优步的不断发展,我们会为已有的产品添加新的业务线和特性。在这种情况下,我们需要添加新的上游服务,它们都需要新的通信层客户端初始代码。为了达成让工程更高效的目标,我们的 Customer Obsession 团队与 Web 平台团队协作构建了一个代码生成工具,该工具致力于达成如下三个目标:
根据 Apache Thrift 或 gRPC 服务定义,生成 Flow 类型化的通信层 RPC 客户端代码;
将新的上游服务注册为新的依赖;
为每个新的服务创建模式和解析器文件的骨架。
运行完代码生成工具之后,工程师要填充与客户服务平台相关的模式和解析器,根据请求的端点结构和想要使用的特定字段指明对应的模式。在我们的新架构中,工程师可以使用自动生成的客户端代码很容易地实现解析器来调用特定的端点,不必与通信层进行交互。
例如,当我们新增一个行程服务时,代码生成工具将会读取上游服务的 Thrift 文件并生成 RPC 客户端、flow 类型、插件注册、模式骨架以及解析器骨架,如下面的图 6 所示:
图 6 代码生成工具会自动处理新增的服务。它将会读取 thrift 文件,然后生成五个文件,用于设置新服务与通信层的交互
为了让客户服务平台更加全面,来自 Customer Obsession 团队的后端工程师已经开始着手在 GraphQL 之上构建一个网关。一旦该网关完成,这个特性将会帮助 Customer Obsession 团队在融合客户工单信息时,能够避免对外部服务不必要的重复调用。
监控系统的健康状态
在生成的 RPC 客户端中,我们实现了优步特有的日志和跟踪功能,以便于跟踪数据并确保准确性。如果在查询的处理中,出现部分错误的话,GraphQL API 依然会返回部分成功的响应,我们的 RPC 客户端会记录从通信层请求直到服务后端的错误细节。响应中所包含的字段,如头信息、调用者、请求参数和错误信息,会帮助我们识别上游的错误。为了理解系统的运维情况,我们使用了 Elastic Stack 仪表盘,它能够以接近实时的速度收集、处理和展现大量的日志数据。
我们的工程师在客户服务平台的端点和服务级别实现了监控。我们会记录客户服务平台每秒钟的请求数、每个上游端点的错误数、成功/失败率以及第 95 个百分位(p95)的性能。我们会根据生成的 p95 数值调整超时配置。我们还使用 Elastic Stack 仪表盘来确定错误产生的原因。随着将 GraphQL 集成到客户服务平台中,我们利用这个机会清理掉了遗留代码,并将预期与上游服务保持一致。
图 7 监控 Atreyu 请求的错误,显示了解析器何时失败,从而使我们能够识别 GraphQL 与上游服务交互的问题
图 8 端点级别的性能监控,包括错误和成功的数量(左侧的聚类图)、成功和错误的比例(中间的折线图)以及 p95 的请求时间(右侧的波状图)
我们的监控系统还支持端点级别的告警。通过模板化告警,我们利用优步基于配置的告警生成命令行接口来监视告警并生成仪表盘。除此之外,该系统还利用构建自动化,不仅会校验,而且还会强制所有的告警都正确地进行了配置,从而提升对故障的响应能力。
错误处理
除了更好的跟踪信息之外,相对于之前版本的解决方案,新的客户服务平台能够更容易地识别并纠正数据不准确的问题。通过改善服务器错误和请求错误,我们提升了错误处理的能力:
服务器错误
我们会在解析器级别处理上游错误。在接收到解析器中的上游错误后,开发人员将决定是简单地将错误抛给客户端、用错误代码和更多信息包装错误,还是返回一个 nullable 类型而不向客户端返回错误。
请求相关的错误
当我们的客户服务平台发送格式错误的请求时,GraphQL 服务器在调用解析器和模式之前会执行预检查并解析语法错误。
作为强类型语言,GraphQL 可以在输入时处理校验错误。在客户服务平台的实现中,GraphQL 可以使用校验工具(如 validator.js)强制进行查询校验。
使用不同的技术来解决不同类型的错误使系统更加流畅,错误更易于分类和解决。
当解析器扇出至多个上游服务时,有些字段可能无法成功抓取,这样就会出现部分错误。尽管结果不完美,但是遇到这样的错误时,我们依然可以返回有用的信息给原始客户端。GraphQL 有一个 nullable 的概念,这意味着如果服务器端返回一个 null 值给声明为非 nullable 的字段,那么 GraphQL 将向最接近的 nullable 父字段返回一个 null 值。例如,Card 是一个 nullable 类型,它有一个非 nullable 的字段,名为 cardNumber。如果在我们的查询中,cardNumber 字段得到了一个 null 值,那么 cardNumber 不能为 null 的事实将会导致它的父字段,也就是 Card,为 null。这个 nullable 的概念能够避免客户服务平台发布信息时缺少必备的字段,同时还能在部分失败的情况下依然可以使用精确的数据。
在使用非 nullable 字段时,开发人员必须确定客户端希望得到部分结果,还是希望完全不返回任何结果。nullable 字段也许能够使用不完整的数据,而非 nullable 可能不允许这样做。我们在如下的场景下会使用非 nullable 字段:
如果字段没有传入值的话,参数将没有任何意义。例如,如果没有 tripUUID 参数的话,getFareAmount(tripUUID: ID!)永远不应该被调用,所以将 tripUUID 标记为非 nullable 是非常有帮助的。
我们知道特定对象字段始终会被填充。将它们定义为非 nullable 能够简化前端代码,这样前端代码就不用检查 null 值了。例如,在我们的 API 中,每个乘客都有姓和名。我们可以将姓和名字段定义为非 nullable,这样的话就没有必要校验查询返回的姓和名的值了。
我们区分了非 nullable 和 nullable 的概念,这样在利用 GraphQL 的 nullable 理念所提供的部分数据时,能够避免信息方面的问题。这一特性和 GraphQL 的其他特性不仅适用于客户服务平台,也适用于优步的其他业务领域。
将 GraphQL 扩展至所有的服务
我们持续地将利用 GraphQL 构建的新数据融合层用到客户服务平台前端中。我们还将此功能用到了平台的后端服务中。
因为我们的后台是使用 Go 语言和 thrift/protobuf 编写的,所以我们不能直接使用 GraphQL。因此,我们使用 Go 为 GraphQL 编写了一个协议包装代理。
借助这个代理,我们可以编写一次 GraphQL 适配器,并在任何地方使用它。数据融合层对前端和后端贡献者都是开放的。我们只需对适配器执行一次维护或修改,就可以避免在多个服务之间重复相同的工作。我们还可以在客户服务平台的所有服务中使用 GraphQL 服务器的一些特性,比如缓存、日志记录、跟踪、告警和监控。
结果
将 GraphQL 集成到客户服务平台之后,我们就替换掉了旧的数据融合模型。从开发人员的反馈来看,我们从如下方面都得到了改善:
编写更少的代码;
不用担心向后兼容性的问题;
带来模式上的灵活性;
依赖系统来优雅地处理错误;
更好地理解和处理系统的性能问题。
其他的考虑因素
在通过 GraphQL 提升开发人员效率之后,我们正在研究在客户服务平台上改善缓存。我们旧的数据结构通过为每个服务定义缓存配置并利用内存和 Redis 缓存,能够实现 60%的缓存命中率。
在客户服务平台中实现查询上下文缓存可以实现更高效的缓存使用。我们计划通过 DataLoader 添加查询上下文缓存,DataLoader 是开源的数据缓存和批处理工具,可作为 GraphQL 技术栈的一部分使用。这个缓存将显著提高客户服务平台的效率。与 GraphQL 相结合,DataLoader 可以批量处理查询,从而减少对上游服务的调用次数。同样,它的缓存功能减少了对相同数据的调用次数。
客户服务平台的前端团队也在探索与优步其他团队共享模式和解析器文件的可能性。我们计划将客户服务平台的 GraphQL 解决方案作为服务暴露为 web monorepo,这样优步的其他前端服务就可以使用它的模式和解析器了。每个前端服务随后将托管自己的 GraphQL 服务器。每个服务器都有自己的缓存和身份验证配置。
在客户服务平台中,GraphQL 在提高客户支持查询效率方面显示了良好的前景。我们所做的任何改进都将进一步实现快速获取所需信息以有效解决用户问题的目标。
原文链接:《Using GraphQL to Improve Data Hydration in our Customer Care Platform and Beyond》
评论