无缝交换Netflix Android应用程序的API后端

2020-10-23 07:18:57

作为Android开发人员,我们通常可以将我们的后端视为在云中运行的魔盒,忠实地返回给我们JSON。在Netflix,我们采用前端后端(BFF)模式:每个客户端(Android/iOS/TV/Web)有一个后端,而不是一个通用的“后端API”。在Android团队中,虽然我们的大部分时间都花在应用程序上,但我们也负责维护我们的应用程序与之通信的后端及其编排代码。

最近,我们完成了一个为期一年的项目,重新设计了我们的后端,并将其与以前使用的集中式模型分离。我们在没有放慢发布节奏的情况下进行了这次迁移,并且特别小心地避免了对用户体验的任何负面影响。我们从单一服务中本质上无服务器的模型发展到部署和维护托管我们的应用程序后端端点的新微服务。这使得Android工程师可以更好地控制和观察我们获取数据的方式。在这篇文章中,我们将讨论我们的迁移方法,我们采用的策略,以及我们为支持这一迁移而构建的工具。

Netflix Android应用程序使用Falcor数据模型和查询协议。这允许应用程序查询每个HTTP请求中的“路径”列表,并获得我们用来缓存数据和更新UI的特殊格式的JSON(JsonGraph)。如前所述,每个客户端团队都拥有各自的端点:这实际上意味着我们正在为查询中的每个路径编写解析器。

例如,要呈现此处显示的屏幕,应用程序发送如下所示的查询:

路径从根对象开始,后跟我们要检索其数据的键序列。在上面的代码片段中,我们正在访问ID为80154610的视频对象的细节键。

在上面的示例中,应用程序需要的数据由不同的后台微服务提供服务。例如,Artwork服务与视频元数据服务是分开的,但我们需要详细信息键中两者的数据。

我们使用API团队提供的库在端点代码上进行此编排,该库公开RxJava API来处理对各种后端微服务的下游调用。我们的端点路由处理程序使用此API有效地获取数据(通常跨越多个不同的调用),并将其传递到UI期望的数据模型中。我们编写的这些处理程序被部署到API团队运行的服务中,如下图所示。

如您所见,我们的代码只是这个整体服务的一部分(图中的#2)。除了托管我们的路由处理程序之外,该服务还处理以容错方式进行下游调用所需的业务逻辑。虽然这为客户团队提供了一个非常方便的“无服务器”模型,但随着时间的推移,我们在这项服务上遇到了多个运营和Devex挑战。您可以在我们以前的帖子中阅读更多关于这方面的内容:第1部分,第2部分。

很明显,我们需要将端点代码(归每个客户端团队所有)与容错下游调用的复杂逻辑隔离。从本质上讲,我们希望将客户端特定的代码从这个整体分解到它自己的服务中。我们尝试了这个新服务应该是什么样子的几次迭代,最终确定了一个旨在为客户端团队提供更多API体验控制的现代架构。它是一个Node.js服务,使用可组合的JavaScript API进行下游微服务调用,取代了旧的Java API。

作为Android开发人员,我们已经开始依赖像Kotlin这样的强类型语言的安全性,也许还有Java的一面。因为这个新的微服务使用Node.js,所以我们不得不用JavaScript编写端点,这是我们团队中的许多人都不熟悉的语言。关于为什么选择Node.js生态系统作为这项新服务的上下文值得一篇文章。对我们来说,这意味着在编写路由时,我们现在需要打开大约15个MDN选项卡:)。

让我们简要讨论一下这个微服务的体系结构。它看起来像Node.js世界中非常典型的后端服务:Restify、HTTP中间件堆栈和基于Falcor的API的组合。我们将略过这个堆栈的细节:总体思路是我们仍在为[Videos,<;id>;,Detail]这样的路径编写解析器,但我们现在用JavaScript编写它们。

然而,与单一的服务最大的不同在于,它现在是一个独立的服务,在我们的云基础设施中作为单独的“应用程序”(服务)部署。更重要的是,我们不再只是从服务中运行的端点脚本的上下文中获取和返回请求:我们现在有机会完整地处理HTTP请求。从“终止”公网网关的请求开始,然后向下调用API应用程序(使用前面提到的JS API),并构建响应的各个部分。最后,我们从服务返回所需的JSON响应。

在我们看这一变化对我们意味着什么之前,我们想先谈谈我们是如何做到这一点的。我们的应用程序有大约170条查询路径(想想路由处理程序),所以我们必须找出一种迭代的方法来进行迁移。让我们来看看我们在应用程序中构建了什么来支持此迁移。回到上面的屏幕截图,如果你在该页面再往下滚动一点,你会看到标题为“More Like This”的部分:

正如您可以想象的那样,这不属于此标题的视频详细信息数据。相反,它是一条不同道路的一部分:[视频,<;id>;,Similars]。这里的总体思路是,每个UI屏幕(活动/片段)都需要来自多个查询路径的数据来呈现UI。

为了让我们自己为端点技术堆栈的重大变化做好准备,我们决定跟踪响应查询所用的时间。在与我们的后端团队进行了一些协商之后,我们确定了按UI屏幕对这些指标进行分组的最有效方式。我们的应用程序使用存储库模式的一个版本,其中每个屏幕都可以使用查询路径列表获取数据。这些路径与其他一些配置一起构建一个任务。这些任务已经带有唯一标识每个屏幕的uiLabel:该标签成为我们的起始点,我们将其作为头传递给我们的端点。然后,我们使用它来记录响应每个查询所用的时间,按uiLabel分组。这意味着我们可以通过屏幕跟踪用户体验的任何可能的倒退,这与用户在应用程序中导航的方式相对应。我们将在后面的章节中更多地讨论我们如何使用这些指标。

快进一年:我们开始时的170个数字缓慢但肯定地减少到0,我们所有的“路由”(查询路径)都迁移到了新的微服务。那么,事情进行得怎么样了,…。?

今天,这种迁移已经完成了很大一部分:我们的大部分应用程序都是从这项新的微服务中获取数据的,希望我们的用户不会注意到这一点。与任何这种规模的迁移一样,我们在这一过程中遇到了一些坎坷:但首先,让我们看看好的部分。

我们的“巨石”已经存在很多年了,在创建时并没有考虑到功能和单元测试,所以这些都是由每个UI团队独立地固定在一起的。对于移民来说,Testing是一等公民。虽然没有技术原因阻止我们更早地添加完整的自动化覆盖范围,但在迁移每个查询路径时添加它要容易得多。

对于我们迁移的每条路由,我们希望确保不会引入任何倒退:无论是以丢失(或更糟糕的是,错误的)数据的形式,还是通过增加每个端点的延迟。如果我们将问题缩减到绝对基础,我们实际上有两个返回JSON的服务。我们希望确保对于作为输入的一组给定路径,返回的JSON始终完全相同。在其他平台和后端团队的大量指导下,我们采取了三管齐下的方法来确保每条迁移路线的正确性。

功能测试是所有测试中最直接的:每条路径旁边的一组测试都对新旧端点进行测试。然后,我们使用了优秀的Jest测试框架和一组自定义匹配器,这些匹配器清理了一些东西,比如时间戳和uuid。它在开发过程中给了我们很高的信心,并帮助我们覆盖了我们必须迁移的所有代码路径。测试套件自动化了一些事情,比如设置测试用户,以及匹配真实设备发送的查询参数/头:但仅此而已。功能测试的范围仅限于已经设置的测试场景,但我们永远无法复制全球数百万用户使用的各种设备、语言和区域设置组合。

这是一个自包含的流,按照设计,它捕获了整个请求,而不仅仅是我们请求的一条路径。此测试最接近生产:它重放设备发送的真实请求,从而执行我们的服务部分,即从旧端点获取响应,并将它们与来自新端点的数据缝合在一起。这个重放管道的彻底性和灵活性在它自己的帖子中得到了最好的描述。对我们来说,重放测试工具让我们确信我们的新代码几乎没有bug。

金丝雀金丝雀是“审查”我们新的路由处理程序实现的最后一步。在此步骤中,管道选择我们的候选更改,部署服务,使其可公开发现,并将一小部分生产流量重定向到此新服务。您可以在Spinnaker Canaries文档中找到更多关于这是如何工作的详细信息。

这就是我们前面提到的uiLabel指标变得重要的地方:在金丝雀期间,Kayenta被配置为捕获并比较所有请求的这些指标(除了已经被跟踪的系统级指标之外,比如服务器CPU和内存)。在金丝雀周期结束时,我们得到了一份报告,该报告汇总并比较了特定UI屏幕发出的每个请求的百分位数。通过查看我们的高流量UI屏幕(如主页),我们可以在为所有用户启用端点之前确定端点造成的任何倒退。这里有一份这样的报告,可以让你对它的样子有个大概的了解:

每个确定的回归(像这个)都要经过大量的分析:追查其中的几个会导致以前未确定的性能收益!能够为新路由设置金丝雀,使我们可以验证延迟和错误率是否在可接受的范围内。这种类型的工具需要时间和精力来创建,但最终,它提供的反馈是非常物有所值的。

许多Android工程师会熟悉Systrace或Android Studio中优秀的分析器之一。想象一下,为您的端点代码获得类似的跟踪,遍历许多不同的微服务:这就是分布式跟踪提供的有效功能。我们的微服务和路由器已经集成到Netflix请求跟踪基础设施中。我们使用Zipkin来使用跟踪,这允许我们按路径搜索跟踪。下面是典型的跟踪:

请求跟踪对于Netflix基础设施的成功至关重要,但当我们在单机版中运行时,我们无法详细了解我们的应用程序是如何与各种微服务交互的。为了说明这对我们有何帮助,让我们放大图片的这一部分:

很明显,这里的调用正在被序列化:然而,在这一点上,我们已经与微服务断开了大约10个跃点。通过查看原始数据,很难得出这样的结论,也很难发现这样的问题:无论是在我们的服务上还是在上面的测试服务上,更难将它们归因于确切的UI平台或屏幕。有了Netflix微服务生态系统中丰富的端到端跟踪功能,并且可以通过Zipkin轻松访问,我们能够非常迅速地将此问题分流给负责的团队。

正如我们前面提到的,我们的新服务现在拥有请求生命周期的“所有权”。以前我们只将Java对象返回给API中间件,现在服务中的最后一步是将JSON刷新到请求缓冲区。所有权的增加使我们有机会在这一层轻松测试新的优化。例如,通过大约一天的工作,我们有了一个使用二进制msgpack响应格式而不是普通JSON的应用程序原型。除了灵活的服务架构外,这还可以归功于Node.js生态系统和丰富的NPM包选择。

在迁移之前,端点上的开发和调试非常痛苦,因为部署缓慢且缺乏本地调试(本文详细介绍了这一点)。Android团队做这个迁移项目的最大动机之一就是改善这种体验。新的微服务通过在本地Docker实例中运行该服务,为我们提供了快速部署和调试支持,从而显著提高了工作效率。

在打破一块巨石的艰难过程中,你可能会得到一两块锋利的碎片。下面的很多内容都不是Android特有的,但是我们想简单地提一下这些问题,因为它们最终确实影响了我们的应用程序。

旧的API服务运行在同一台“机器”上,这台机器也缓存了大量的视频元数据(根据设计)。这意味着静态数据(例如视频标题、描述)可以跨多个请求被积极缓存和重用。然而,使用新的微服务,即使获取这些缓存的数据也需要网络往返,这增加了一些延迟。

这听起来可能像是“巨无霸VS微服务”的经典例子,但实际情况要复杂得多。这块巨石本质上还在与许多下游微服务对话:它只是碰巧有一个定制的缓存,这对它有很大帮助。通过更好的可观察性和更高效的请求批处理,部分增加的延迟得到了缓解。但是,对于一小部分请求,在进行了大量优化尝试之后,我们不得不承担延迟损失:有时,没有什么灵丹妙药。

由于对端点的每个调用可能需要向API服务发出多个请求,其中一些调用可能会失败,从而给我们留下部分数据。处理这样的部分查询错误并不是一个新问题:它是Falcor或GraphQL等复合协议的本质。然而,正如前面提到的,当我们将路由处理程序移到一个新的微服务中时,我们现在引入了用于获取任何数据的网络边界。

这意味着我们现在遇到了以前由于自定义缓存而不可能实现的部分状态。我们在迁移开始时并没有完全意识到这个问题:我们只有在一些反序列化的数据对象具有空字段时才能看到这个问题。因为我们的很多代码都使用Kotlin,所以这些部分数据对象会立即导致崩溃,这有助于我们及早注意到问题:在它投入生产之前。

由于局部错误的增加,我们必须改进整体错误处理方法,并探索将网络错误的影响降至最低的方法。在某些情况下,我们还在端点或客户端代码上添加了自定义重试逻辑。

这是很久以前的事了(你看得出来!)。对于我们Android团队来说,这是一个令人满意的旅程:正如我们前面提到的,在我们的团队中,我们通常会开发这款应用程序,直到现在,我们还没有机会使用我们的端点进行这种级别的审查。我们不仅了解了更多有关微服务的有趣世界,而且对于我们从事此项目的人员来说,它为我们提供了一个完美的机会,可以为我们的应用程序-端点交互添加可观察性。与此同时,我们遇到了一些意想不到的问题,如部分错误,并在此过程中使我们的应用程序对它们更具弹性。

随着我们不断发展和完善我们的应用程序,我们希望能与您分享更多这样的真知灼见。

规划并成功迁移到这项新服务是多个后端和前端团队共同努力的结果。

在Android团队中,我们将Android上的Netflix应用程序发送给世界各地的数百万会员。我们的职责包括通过构建高性能且通常是自定义的UI体验,在各种设备上进行广泛的A/B测试。我们致力于在多样化、有时不可饶恕的设备和网络生态系统中进行大规模的数据驱动优化。如果您对这些挑战感兴趣,并想与我们合作,我们有一个开放的职位。