近年来,API 网关成为微服务架构中不可或缺的一部分。因此,一个架构良好的 API 网关显得尤为重要。本文详细介绍了 Uber API 网关的架构设计,以飨读者。
近年来,API 网关成了微服务架构中不可或缺的一部分。API 网关为 Uber 所有的应用程序提供一个统一入口,并提供了一个从后端微服务访问数据、逻辑或功能的接口。同时,它还提供了一个集中的地方来实现许多高级职责,包括路由、协议转换、速率限制、负载削减、丰富头信息并传播、数据中心亲缘性限定、安全审计、用户访问阻塞、移动客户端生成等。
在上一篇文章中,我们介绍了 Uber 几代 API 网关的演变及每个阶段的设计选择。
https://eng.uber.com/gatewayuberapi/?fileGuid=SmCGZSKhUZsFEdLd
在本文中,我们将更深入地研究自助式 API 网关系统的技术组件。
在最抽象的层面,网关是通过 API 提供数据服务的另一种服务。网关有多种形式,覆盖范围很广,从作为 API 网关的低级负载均衡器,到功能非常丰富的应用程序级负载均衡器(操作 API 中的请求和响应负载)。在 Uber,我们开发了一个功能丰富的 API 网关,能够跨多个协议对输入和输出数据的有效载荷进行复杂的操作。
API 管理
一个功能丰富的 App 是通过与众多提供不同功能的后端服务交互来实现的。所有这些交互都要经过一个通用的应用网关层。API 管理指的是这些网关 API 的创建、编辑、删除和版本控制。
工程师在 UI 中配置 API 的参数,并将功能性的 API 发布到互联网上供所有 Uber App 消费。配置管理着 API 的行为:路径、请求数据类型、响应类型、允许的最大调用数、允许的 App、通信协议、要调用的特定微服务、允许的头、可观察性、字段映射验证等等。
一旦配置发布,网关基础设施就会将这些配置转换为有效的功能性 API,服务于我们的应用流量。网关基础设施还为使用这些 API 的 App 生成客户端 SDK。
与网关系统的所有交互都通过 UI 发生,UI 会引导用户一步一步地完成创建端点的过程。UI 简化了流程,并对 API 的各个方面做了各种验证。此外,这也是配置请求超时、监控和告警的地方。
管理系统提供了一些辅助功能,比如新的配置更改发布前的审查门,以及存储会话用于共享或恢复 API 管理。以下截图是可用于添加中间件的 UI 步骤的概览:
请求生命周期中的组件
为了说明网关的各种组件,了解单个请求如何通过网关运行时是很重要的。传入请求包含一个路径,该路径映射到为其提供服务的处理程序。在请求的生命周期中,它流经以下组件:协议管理器、中间件、数据验证、处理程序和后端客户端。请求生命周期中的所有组件被实现为一个栈。
下面详细介绍了每个组件,它们在请求对象进入时对其进行操作,而相同的组件在响应对象传出时以相反的顺序运行。
协议管理器 是栈的第一层。它包含网关支持的每种协议的反序列化器和序列化器。这一层提供了实现 API 的能力,它可以接收相关协议的任何类型的有效负载,包括 JSON、Thrift 或 Protobuf。它还可以方便地接收传入的 JSON 请求,并使用原编码的响应进行应答。
中间件层 是在调用端点处理程序之前实现可组合逻辑的抽象。中间件实现了横切关注点,如身份验证、授权、速率限制等。每个端点可以选择配置一个或多个中间件。除了可选中间件之外,该平台还包括一组必备的会针对每个请求执行的中间件。一个中间件不需要同时实现 requestMiddleware 和 responseMiddleware 方法。如果中间件执行失败,调用将使栈的其余部分短路,来自中间件的响应将返回给调用者。在某些情况下,中间件可能没有操作,这取决于请求上下文。
端点处理程序层 负责请求验证、有效负载转换以及将端点请求对象转换为客户端请求对象。当操作响应对象时,endpointHandler 将后端服务响应转换为端点响应,对响应对象执行某些转换,基于模式进行响应验证并序列化。
客户端 向后端服务发送请求。客户端是协议感知的,根据配置过程中选择的协议生成。用户可以配置客户端的内部功能,如请求和响应转换、模式验证、断路和重试、超时和截止日期管理以及错误处理。
配置组件
协议管理器、中间件、处理程序和客户端有许多行为可以通过配置控制。管理 API 的用户不需要修改任何代码,而只要修改配置,就可以决定网关上端点的预期行为。为了便于配置,这些是通过 UI 进行管理的,其后台有一个 Git 存储库。
每个组件的配置都是从 Thrift 和 / 或 YAML 文件中获取。YAML 文件提供了组件信息,并充当它们之间的粘合剂。Thrift 文件定义有效负载和协议语义。
网关 thrift 文件大量使用了 thrift IDL 中的注解特性,以便为各种特性和协议提供唯一事实来源。在下面的小节中,我们将深入研究每个组件的配置。
https://thriftrw.readthedocs.io/en/latest/api.html#thriftrw.idl.Annotation?fileGuid=SmCGZSKhUZsFEdLd
协议管理器
协议管理器需要理解请求协议上下文中数据的格式和类型。响应也应该知道类似的参数。
下面三行 YAML 配置提供了协议类型、Thrift 文件路径和协议管理器用于处理传入请求的方法:
上面的配置表明,新 API 的类型是“HTTP”协议,关于模式和协议的所有其他细节都在 apiSample.thrift 文件中提供。
Thrift 文件 apiSample.thrift 功能丰富,描述了 JSON 请求和响应有效负载的数据类型、HTTP 路径和 HTTP 谓词。HTTP 协议是在 Thrift 模式中使用 Thrift 注解特性定义的。
并非所有 API 调用都会成功。下面的示例模式提供了从处理程序到适当的 HTTP 协议的错误响应。这是通过如下所示的注解来完成的:
中间件
中间件是栈中最灵活、功能最丰富的组件。它允许网关平台向 API 网关用户公开更高阶的特性。我们将在解锁特性一节中详细介绍中间件支持的特性。在这里,我们将重点关注 YAML 文件中的中间件配置。
在上面的配置中,身份验证中间件被添加到 API。身份验证中间件将从 header.x-user-uuid 的值接收配置的路径参数。上面配置的第二个中间件是 transformRequest 中间件,它负责将 region 从传入请求复制到后端服务调用中的 regionID。在开发新的中间件时,它为 API 开发人员需要提供的所有可配置参数定义了一个模式。
处理程序
支持处理程序的主要配置是以验证和传入请求到后端客户端请求参数的映射为中心。
上面的配置提供了处理程序需要的输入,用于识别请求应该映射到哪个后端客户端。如果传入请求字段与后端服务完全匹配,那么上面的配置就足够了。如果字段的名称不同,则必须使用 transformRequest 中间件来映射它们。
客户端
后端客户端的配置分为 YAML 文件和 thrift 文件。在下面的示例中有一个使用 TChannel 协议的新的后端服务,该服务的请求和响应是在 backendSample.thrift 文件中定义的,它有两个可以调用的方法。
https://github.com/uber/tchannel?fileGuid=SmCGZSKhUZsFEdLd
再次提醒下,方法 Backend::method 也可以是真正的 HTTP API,借助注解,使用与 Thrift 规范类似的路径 /Backend/method 表示。
可运行的工件
上一节中描述的所有组件的 YAML 和 Thrift 配置对于完整描述一个 API 配置是必需的。自助服务网关负责确保这些组件配置一起提供一个网关运行时。
网关有两种类型:一种接收配置,并基于配置动态地提供 API(很像 Kong、Tyk 和反向代理 Envoy、Nginx);另一种基于输入配置使用代码生成步骤生成一个构建工件。在 Uber,我们选择了后者,即使用代码生成方法来创建一个可运行的构建工件。
生成模式对象:所有模式文件都通过处理器运行,输出 thriftrw 和 protoc 的原生 Go 语言代码。这是序列化 / 反序列化和客户端接口代码生成所需要的。
https://github.com/thriftrw?fileGuid=SmCGZSKhUZsFEdLd
生成自定义序列化:移动应用程序的 API 契约需要自定义与 i64、枚举类型和多个协议相关的序列化。
依赖关系的 DAG:端点、后端客户端和中间件的代码是静态生成的。代码生成存在固有的依赖性。客户端是独立的,可以立即生成。中间件的功能可能依赖于零个或多个客户端。端点可能依赖于零个或多个中间件,以及零个或一个客户端。这个 DAG(有向无环图)是在构建时解析的。
由于客户端是独立于端点生成的,所以端点可以是 HTTP,而后端服务可以是 gRPC。绑定在边缘网关构建这一步完成。
API 生成:在最后一步中,对 DAG 进行迭代以生成所有端点。单个生成步骤如下:加载模板,将端点请求生成到客户端请求映射,反之亦然,注入依赖关系,并使用请求 - 响应转换来还原(hydrate)idl 对象。
代码生成的整个工作被抽象为一个 Uber OSS 库 Zanzibar。
https://github.com/uber/zanzibar?fileGuid=SmCGZSKhUZsFEdLd
解锁特性
集中式系统的一个优点是构建的特性可以使所有在线用户受益。借助像 Edge Gateway 这样功能丰富的网关,我们有多种途径可以用来构建特性,供所有访问 Uber 内部服务的 API 使用。
以下是一些已经开发出来的特性,以及一些仍在开发中的特性。
审计管道
Edge Gateway 会生成包含丰富元数据的访问日志,并将其持久化以供审计。保留所有产品所有 API 访问模式的审计记录至关重要。当有人试图使用自动化系统恶意访问我们的 API 时,它让我们可以进行安全审计,并帮助我们构建一个涵盖各种产品的概要文件(跨版本、地理位置和应用程序)。
该管道让我们可以跨特定的 SDK 版本、应用程序、地理位置或互联网提供商快速捕获 Bug、问题和异常。我们的所有应用程序都启用了审计管道。
身份验证
每个外部 API 请求都需要 Authenticated(AuthN)和 / 或 Authorized(AuthZ)。该平台为 AuthX 提供了几个可重用的实现,用户可以从他们的端点中选择它们作为中间件。这使得用户可以不必关注这些 AuthN/AuthZ 如何实现,并确保一个端点使用至少一个预备好的实现。平台所有者可以对这些实现进行无缝更新,并自动应用于所有端点。
断路器
每个用于调用后端服务的客户端都包含一个断路器。当后端服务延迟或错误率增加(可配置)时,断路器将启动,以防出现任何级联中断。这也为恢复已经恶化的服务提供了空间。
速率限制
终端所有者可以选择对 API 进行速率限制。在提供的实现中,有一部分例子是基于 userID、用户代理、IP、请求中某些属性的组合进行速率限制。也可以根据路径 / 查询参数、头或正文中的特定字段强制进行限制。这让我们可以提供比简单的用户级 API 访问更细粒度的应用程序可感知的限流策略。每个端点都可以动态地独立分配配额,而不需要重新部署。
文档
所有 YAML 和 Thrift 中的配置完整地描述了一个 API。这提供了一个选项,让我们可以以一致的方式为所有网关 API 自动生成文档。
移动客户端生成
Uber 的所有移动应用程序都基于 Thrift IDL 生成服务和模型,从而实现与服务器的交互。CI 作业从网关获取所有端点 IDL,并为各种模型运行自定义代码生成。移动代码生成还依赖于各种自定义 Thrift 注解,如异常状态代码、URL 路径和 HTTP 方法。一个进行生成代码审查的 CI 作业可以防止对端点模式做任何向后不兼容的更改。
响应字段裁剪
因为 API 的创建很容易,而且多个端点可以由相同的底层客户端服务提供支撑。我们在创建 API 时,可以细粒度地选择用户体验所需的特定字段,而不是使用完整的后端响应进行响应。
数据中心亲缘性
目前,拥有冗余数据中心和区域是大型 Web 公司实际采用的架构。属于不同业务单元或域的 API 托管在网关上,每个业务单元可以定义跨多个数据中心的工作负载分片。Edge Gateway 提供了一个缓存,业务单元可以向其中写入数据,以配置与适当的数据中心相关联的用户、地区或版本。网关将遵照数据中心关联信息重新路由来自特定用户、设备或应用程序的传入 API。
短期用户禁用
账户禁用是对付恶意行为者的方法。对于暂时滥用系统的用户,网关提供了一个中心位置,用于在短时间内阻止特定用户对 API 的访问。这种方法类似于数据中心亲缘性,网关可以提供一个外部缓存来存储被阻塞的用户(有一个 TTL)。欺诈和安全系统可以提供用户、应用程序版本或其他标识符作为阻塞依据。Edge Gateway 将确保这些短期禁令得以执行,以保护我们的用户。
挑战和教训
在网关的开发过程中,我们不得不做出多方面的设计选择。有些选择让我们获得了非常令人兴奋的结果,而有些却没有提供预期的投资回报。下面我们将简要介绍几项挑战。
语言
在开发网关时,我们选择的语言是 Go 和 Java。我们的上一代网关使用的是 Node.js。虽然这种语言非常适合构建 IO 密集型网关层,但我们决定与 Uber 语言平台团队支持的语言保持一致。Go 提供了显著的性能改进。在构建期间,泛型的缺乏导致生成了大量的代码,达到了 Go 链接器的极限。在二进制编译期间,我们必须关闭符号表和调试信息。在 Go(但在 Thrift 中不是)中,像 ID、HTTP 和保留关键字这样的语言命名约定会导致失败,以致将内部实现细节暴露给了最终用户。
序列化格式
我们的网关的协议管理器能够实现多种协议。这个特性带来了复杂的兼容性问题,比如 JSON 模式与 Thrift 模式中,表示 Union、Set、List 和 Map 的数据类型不匹配。我们必须自定义一些约定来实现映射。
配置存储
如上所述,用户配置存储在 Git 中。然而本质上,有些配置是动态的,比如 API 速率限制。以前,更改需要代码生成和部署。这非常耗时,因此,我们现在将用户配置的动态部分存储在配置存储中。
网关 UI
在网关 UI 中开发单个 API 很容易,但开发批量编辑流就比较困难了。当 Thrift 文件引用其他 Thrift 文件并且嵌套可以任意深时,尤其如此。一旦用户提供了配置并由构建系统接管,而构建系统又独立于 UI 而发展,将构建失败呈现到 UI 就变得非常困难。为了显示错误,在它们之间保持一致的契约至关重要。
了解有效载荷
在开发大多数网关特性时,不需要对传入或传出的有效载荷进行反序列化。我们的协议互操作性用例迫使我们对有效载荷进行反序列化。这增加了构建系统的复杂性,也影响了运行时的性能。如果后端协议和移动协议相同,那么限制网关只访问协议谓词和消息头,而不反序列化消息体可能会有好处。然而,这会限制一些复杂的网关功能。
一个功能丰富的网关,就像我们描述的这个,是一项复杂的工作。如果你有兴趣遵循相同的路径,Zanzibar 可以提供一个可扩展的模块,你可以由此入手。在 Uber,我们正基于 Envoy 开发一种 API 网关运行时,用于从应用程序到后端服务的 gRPC 请求,我们的自助服务 UI 在用户体验上没有很大的变化。
代码生成的整个工作被抽象为一个 Uber OSS 库 Zanzibar。