在 Web API 领域出现了一种很有意思的新趋势,那就是各种工程师和公司都在致力于为每个用户的特定需求编写专有的 API。假设你的系统不仅要为 iOS 暴露 API、为 Android 暴露 API、为 Web 站点暴露 API、为 AngularJS 应用前端暴露 API,还要为各种机顶盒以及乱七八糟的移动平台或使用你 API 的第三方公司都暴露各自的 API,在 API 实现理想化的设计之前,现实就会给我们重重一击,因为各种 API 消费者会有不同且固守的关注点。这样的话,我们可能需要对 API 进行对应的优化。
体验式 API
在 InfoQ 上,Jérôme Louvel( Restlet 的联合创立者和首席 geek)曾经采访过 Daniel Jacobson( Netflix 公司 edge 工程团队的副主席),他们讨论了 Netflix 的体验式API(experience API)和临时API(ephemeral API)。Daniel 的团队负责处理全球范围内所有的注册请求以及在各种设备上查找和回放视频的流量。借助体验式API 的理念,Netflix 创建了特殊的API,从而为给定的请求终端产生优化后的响应。通过临时API,Netflix 的工程师会迭代式地转换和演化这些体验式API。
体验式API 的目标在于解决Netflix 所面临的问题,那就是要将它的平台扩展至六千万用户以及数十个具有不同特性的设备。通过这样的专有API,Netflix 能够让用户在各种设备上都获得最好的用户体验,并且根据设备的屏幕大小优化带宽,从而实现更少的延迟和数据量消耗。这样的话,能够让Netflix 的工程师进展迅速并且独立于核心的后端团队,实现了隔离,他们具有自己的版本模式,独立组织自己的部署工作。
在Netflix,有专门的团队负责这些体验式API,并不是由核心的API 团队来负责每个客户端所衍生出的API。其实,并没有太多的公司具有Netflix 这样的扩展性,Netflix 为所有的API 消费者均构建了专有的API,但是这并不意味着在你的环境中采取这种做法也是有效的。对于小型的组织,维护和演化太多的API 前端可能并不是高效的做法,甚至是一种反模式,因为这种做法的成本会很高。Netflix 必须要构建一个专门的API 平台来支持这种方式。
微服务架构
目前的趋势是向基于微服务的架构迁移, API 网关或 API 门面(facade)得到了复苏。架构会分散为多个小的服务,我们需要有前置的服务(front-facing service),它们会负责为用户暴露 API。我们可以有很多微服务,因此也可以有多个针对用户的门面,这一点丝毫不足为奇。
网关或门面能够让用户只进行一次调用,而不必要求用户多次调用底层的微服务。这会让 API 消费者的工作更加简单,并且有助于实现更加智能的网关或门面,它能够充分利用缓存(因为多次调用依然还有必要),应用安全功能(认证、授权)或实现特定的规则(速度限制、IP 过滤)。API 提供者能够控制消费者如何使用它们的 API。
网关(不管是来自供应商的还是像 Netflix 这样自建的)还会带来一定的附加价值,如边缘服务(edge service,公司中位于 DMZ 的 API 基础设施,能够以非常新颖和有趣的方式来使用,比如 Netflix 使用 Zuul 实现多个区的弹性)、流水线处理(pipelining)或过滤器链(帮助抽取横切性的关注点并实现企业范围内的模式)。
服务于前端的后端
Sam Newman 在一篇文章中,研究了这种消费者专用API 的方式,并将其称为“服务于前端的后端(Backends for Frontends,BfF)”模式。这种模式不会为所有的客户端创建通用的API,我们可以拥有多个BfF:一个用于Web 前端、一个用于移动客户端(甚至一个用于iOS,另一个用于Android)等等。
SoundCloud 采用了 BfF 模式,具有针对 iOS 平台、Android 平台、Web 站点以及嵌入式 Web 的 API 前端。与在 Netflix 的场景类似,如果有专门的团队负责这些前端的话,这种技术能够达到最佳的效果。如果你只有一个团队负责后端及其 API 的话,那么最好不要用大量不同消费者的 API 变种来加重他们的负担。
回到微服务方面,BfF 对于迁移也是很有意义的:当从单体架构迁移至微服务时,某个 BfF 可以调用单体应用的功能,而其他的 BfF 则可以调用新的微服务,请遵循 Strangler 模式,按照这种模式,我们可以渐进式从遗留代码转移到新的演进方案上。
单体应用是非常复杂的,很容易积累技术债,会同时混合太多的关注因素,而微服务能够让我们每次只聚焦一个特定的关注点。但是,微服务架构也有它的不足之处。我们需要对其进行运维,它们之间需要协作,它们可能会按照与大型单体应用不同的节奏进行演化。在这样一个分布式系统中,维护所有服务之间的一致性并不简单。众多微服务之间的通信可能会引入额外的等待时间,这是服务通信的延迟所造成的。微服务的数据副本和反规范化(denormalization)也会带来一定的影响,它们可能会使数据的管理和一致性复杂化。微服务并不是免费的午餐,你可以阅读Vijay Alagarasan 的文章了解微服务反模式的更多信息以及Tareq Abedrabbo 所撰写的“微服务的七宗罪”。
采用体验式API 或BfF 的决定性因素可以归结为它们是否有专门的团队来维护。如果你们是很小的组织,只有一个团队来负责后端以及面向前端或Web 的API,那么维护这么多的变种将会更加复杂(设想一下它们的维护成本),但是如果你们的组织足够大,那么多个团队能够更加容易地承担这些前端API 的任务,并按照他们自己的节奏来演化这些API。
API 作为团队沟通的模式
尽管公司是以团队的形式来组织的,但是我看到在越来越多的场景下,开发人员会被分为前端开发人员(Web 或移动)以及后端开发人员,其中后端开发者会负责实现 Web 或移动设备所需的 API。Web API 成为了项目交付的中心点:API 是一种契约,将不同的团队关联了起来,能够让他们高效协作。
如果我们开发的 API 要给别人使用的话,很重要的一点就是不要破坏这种契约。通常,有些框架和工具能够让我们根据代码库生成 API 定义——例如,通过注解驱动的方式,在端点、查询参数等内容上添加注解。但有时候,即便你自己的测试用例依然能够通过,很小的代码重构也可能会破坏契约。你的代码库没有问题,但重构可能会破坏 API 消费者的代码。为了更加高效的协作,可以考虑采用 API 契约优先的方式,确保你的实现依然能够遵循共享的协议:API 定义。目前,有不少可用和流行的 API 定义语言,如 Swagger ( Open API specification )、 RAML 或 API Blueprint 。你可以选择一个最适合你的。
采用 API 定义的方式有多项优势。首先,因为我们的实现需要遵循 API 定义,所以兼容性就更不容易遭到破坏了。其次,API 定义对工具化非常有利。通过 API 定义,我们可以生成客户端 SDK,这样的话 API 的消费者就可以将其集成到他们的项目中,实现对 API 的调用,甚至可以作为 skeleton,用来生成服务的初始实现。我们还可以创建 API 的 mock,这样在底层 API 构建的时候,开发可以调用这些 mock,从而避免协调 API 生产者和消费者之间不同的开发周期。每个团队都可以按照自己的节奏开展工作!但是,它的好处并不局限于代码和兼容性,还涉及到文档。API 定义语言还会帮助我们对 API 实现文档化,它们会生成很漂亮的文档,展现了各种端点、查询参数等等,并且(有时)还会提供可交互的控制台,通过它,我们可以很容易地发起对 API 的调用。
为不同的消费者提供不同的负载
采用 API 契约优先的方式当然会有所帮助,并且会提供很多的收益,但是如果不同的客户端有不同的 API 需求的话,该怎么办呢?具体来讲,如果我们无法奢侈到有专门的团队来负责不同的 API 门面的话,那么该如何让 API 满足所有 API 消费者的需求呢?
在 InfoQ 最近的一篇文章中,Jean-Jacques Dubray 阐述了他为什么 停止使用MVC 框架。在这篇文章的引言中,他阐述了移动或前端开发人员如何频繁地要求适合于他们UI 需求的API,而不管底层业务理念的数据模型是什么。Dubray 所描述的状态- 行为- 模型(SAM)模式能够很好地支持BfF 方式。SAM 是一个崭新的、反应型函数式的模式,它清晰地将业务逻辑与显示效果分开,进而简化了前端的架构,尤其是将后端API 与视图进行了解耦。因为state 和model 与action 和view 进行了分离,所以action 能够专门服务于给定的前端或根本不进行展现:这取决于你会将光标置于什么位置。我们还可以从中心后端或它们的门面中生成状态表述或视图。
Web 站点或单页应用可能需要展现产品的详细视图并且还要包含它的评论,但是移动设备则很可能只展现产品的详情和它的评分,并且允许移动用户在点击的时候再去加载评论。根据 UI 的不同,流程、可用的行为、详情等级以及查询到的实体可能都会有所差异。通常,在移动设备上,我们都希望减少 API 调用获取数据的次数,这是因为连接性和带宽的限制,我们希望返回的负载恰好只包含所需的内容,没有额外的信息。但是,这一点对于 Web 前端来说就没有那么重要,通过异步调用的方式,我们完全可以按照懒加载的方式加载更多的内容和资源。不管是在哪种场景下,API 显然都需要快速响应,并具有很好的服务等级协议。但是,当我们要为不同的消费者提供多个自定义的 API 时,这方面有什么可选方案吗?
特定端点、查询参数和字段过滤
一种基本的方式就是提供不同的端点(/api/mobile/movie与/api/web/movie),甚至是更为简单的查询参数(/api/movie?format=full或 **/api/movie?format=mobile**),不过我们可能还有更为优雅的方案。
类似于查询参数,我们的 API 还可以让用户决定想要哪些字段,从而自定义返回的负载,比如:/api/movie?fields=title,kind,rating或 **/api/movie?exclude=actors**。
通过使用字段过滤的方式,我们还可以确定是否要在响应中包含相关的资源:/api/movie?includes=actors.name。
自定义 MIME 媒体类型
作为 API 的实现者,我们还有其他的可选方案。我们可以根本不提供任何的自定义的功能!消费者要么接受我们提供的 API,要么将 API 包装到他们自己的门面中,在这个门面中,他们可以构建想要的自定义功能。因为我们都是非常友善的人,所以会给他们提供多个 profile:在媒体类型方面,我们可以发挥创造性,根据消费者所请求的媒体类型不同,返回更加精简或更加丰富的负载。例如,如果你看一下 GitHub API 的话,可能会注意到这样的类型:application/vnd.github.v3.full+json
通过使用“full” profile,API 会提供完整的负载和相关的实体,你还可以使用“mobile”或“minimal”变种的 profile。
API 消费者在发起调用时,就可以请求最适合其使用场景的媒体类型。
Prefer 头信息
Irakli Nadareishvili 曾经写过一篇文章,介绍了 API 中客户端优化的资源表述,提到了一个鲜为人知的头信息域:Prefer 头信息( RFC 7240 )。
与自定义媒体类型类似,客户端可以使用 Prefer 头信息请求特定的 profile:使用Prefer: return=mobile能够让 API 响应自定义的负载并且会带上Preference-Applied: return=mobile的头信息。注意的是,当使用 Prefer 头信息的时候,需要对应地使用 Vary 头信息。
作为 API 开发人员,如果我们要负责确定支持哪种类型的负载,那么你可能会喜欢自定义媒体类型、Prefer 头信息或专门的端点。如果你想要客户端能够更加灵活地确定要检索哪些字段或关联关系的话,那么你可能会更中意字段过滤或查询参数的方案。
GraphQL
与其 React 视图框架一起,Facebook 为开发人员引入了 GraphQL 。在这里,消费者能够完全控制会接受到什么样的负载结果,包括字段及关联关系。消费者在发起请求时,指定了返回的负载应该是什么样子的:
{ user(id: 3500401) { id, name, isViewerFriend, profilePicture(size: 50) { uri, width, height } } }
API 所响应的负载应该会像如下所示:
{ "user" : { "id": 3500401, "name": "Jing Chen", "isViewerFriend": true, "profilePicture": { "uri": "http://someurl.cdn/pic.jpg", "width": 50, "height": 50 } } }
GraphQL 会作为一个查询,同时还会作为回应该请求的描述。GraphQL 让 API 的消费者能够完全控制返回的内容,提供了最高级别的灵活性。
在规范方面,还有一种类似的方式,那就是 OData,它能够让我们通过 $select、$expand 和 $value 参数来自定义负载。但是 OData 有点落伍,处在被抛弃的边缘,前段时间 Netflix 和 eBay 已经宣布不再支持 OData ,而其他的参与者,如微软和 SalesForce 对它依然提供支持。
超媒体 API
最后一个要讨论的可选方案就是超媒体 API。当提到超媒体 API 的时候,你通常会想到那些额外的超链接会让响应变得凌乱,它们可能会让负载的大小成倍增加。对于移动设备来说,负载的大小和调用的次数确实值得关注。尽管如此,非常重要的一点在于,我们要通过 HATEOAS(超媒体作为应用状态引擎)来思考超媒体,它是一个经常被忽视的 REAT API 核心原则。它与 API 所提供的功能有关。消费者可以访问相关的资源,但是这些超媒体关系所提供的链接也可以作为不同的可选 profile,比如:
{ "_links": { "self": { "href": "/movie/123" }, "mobile": { "href": "/m/movie/123" }, } }
另外,有些超媒体方式能够完全接受嵌入式关联实体的理念。Hydra、HAL 和 SIREN 提供了嵌入子实体的功能,所以我们在获取一部电影的信息的时候,也能以嵌入式列表的形式列出它的所有演员。
在一篇关于如何选择超媒体格式的文章中,Kevin Sookocheff 给出了一个样例,展示了如何访问“玩家的朋友列表”,在这个资源访问中,嵌入了这些好友的实际表述而不仅仅是这些资源的链接,因此能够减少对每个好友资源的访问:
{ "_links": { "self": { "href": "https://api.example.com/player/1234567890/friends" }, "size": "2", "_embedded": { "player": [ { "_links": { "self": { "href": "https://api.example.com/player/1895638109" }, "friends": { "href": "https://api.example.com/player/1895638109/friends" } }, "playerId": "1895638109", "name": "Sheldon Dong" }, { "_links": { "self": { "href": "https://api.example.com/player/8371023509" }, "friends": { "href": "https://api.example.com/player/8371023509/friends" } }, "playerId": "8371023509", "name": "Martin Liu" } ] } }
小结
Web API 所面临的消费者种类在持续增加,他们有着不同的需求。微服务架构会鼓励我们针对这些需求部署细粒度的 API 门面(也就是所谓了体验式 API 或 BfF 模式),但是如果你要满足太多不同消费者的需求的话,这可能会成为一种反模式,如果你只有一个很小的团队来应对所有前端的话,那么这种问题会更加严重。
一定要进行必要的权衡!在准备采用某种方式之前,你需要学习可选方案的成本并考虑你是否能够支持这些方案。创建API 的不同变种是有成本的,对于实现API 的人和消费API 的人来说均是如此,这取决于所采用的策略。此外,在发布API 给消费者之后,你可能需要重新思考和重构这个API,因为在设计阶段,你可能没有充分考虑这些特定的设备或客户需求。
如果你有专门的团队来负责这些API 门面,那么这是一种可行的方案。如果你没有这么奢侈的团队的话,有一些其他的方式来为消费者提供自定义负载,而且能够避免引入复杂性,这包括一些简单的技巧,如字段过滤或Prefer 头信息,也包括全面的解决方案,如自定义媒体类型或GraphQL 这样的规范。
但是,我们也不一定非得这样大动干戈,可以采用一种中间方案:一个主要的、完整的API,再加上一个或两个针对移动设备的变种,这样的话,很可能就已经满足了所有消费者的需求。再考虑增加一些字段过滤功能,这样每个人都会对你的API 表示满意!
关于作者
Guillaume Laforge 是 Restlet 的产品领导者,他是 API 开发的倡导者,同时还担任 Apache Groovy 编程语言项目的 VP/ 主席。
查看英文原文: One API, Many Facades?
评论