第二章 什么是API设计模式

本章涵盖:

  • 什么是API设计模式
  • 为什么API设计模式很重要
  • 解构API设计模式
  • 使用设计模式和不使用设计模式来设计API的差异

现在,我们已经了解了API是什么以及什么是“好”的API,我们可以继续探讨在构建API时如何应用不同的模式。我们将从探讨API设计模式是什么,为什么它们很重要,以及我们将如何在后续章节中解析这些模式。最后,我们将查看一个示例API,看看如何使用预构建的API设计模式可以节省大量时间和避免潜在的麻烦。

2.1 什么是API设计模式

在我们开始探讨API设计模式之前,我们需要打下一些基础。首先从一个简单的问题开始:什么是设计模式?如果我们注意到软件设计是指为了解决问题而编写的一些代码的结构或布局,那么软件设计模式是指特定的设计可以反复应用于许多类似的软件问题,只需要进行小的调整以适应不同的情境。这意味着该模式不是我们用来解决单个问题的预构建库,而更像是解决相似结构问题的蓝图。

如果这听起来太抽象,让我们来具体化一下,想象一下我们想在后院建一个小屋。有几种不同的选择,从几百年前的做法到如今由Lowe's和Home Depot等公司提供的现代化专业方法。有很多选择,但有四种常见的选择,如下所示:

  1. 购买预制的小屋并放在后院。
  2. 购买小屋套件(设计图纸和材料)并自己组装。
  3. 购买一套小屋的设计图,根据需要修改设计,然后自己建造。
  4. 从头开始设计和建造整个小屋。

如果我们将这些与它们的软件等效物联系起来,它们将从使用现成的现成软件包一直到编写完全定制的系统来解决问题。在表2.1中,我们可以看到随着列表项目从上到下,这些选项会变得越来越困难,但从一个选项到下一个会增加越来越多的灵活性。换句话说,难度最低的灵活性最小,而难度最大的灵活性最大。

表2.1 建造小屋和构建软件系统方式的类比

选项困难度灵活度对应的软件构件选项
购买预制的小屋简单直接使用现成的软件包
购买套件组装比较简单很小自定义现成的软件包
按照现成设计图建造一般一般从设计文档开始构建
从头设计并建造困难最大从头设计软件系统并构建

软件工程师大多数情况下倾向于选择“从头开始构建”的选项。有时这是必要的,特别是在我们解决的问题是新问题的情况下。其他情况下,这个选择在成本效益分析中胜出,因为我们的问题与易用选项有足够的不同。还有一些情况下,我们可能已经知道一个库恰好解决了我们的问题(或者足够接近),因此我们选择依赖已经解决类似问题的工具。事实证明,选择介于中间的选项(定制现有软件或根据设计文档进行构建)不太常见,但却应该经常使用并会获得很好的效果。这就是设计模式的应用之处。

从上层看,设计模式是应用于软件的“按设计图纸构建”的选项。就像小屋的设计图包括尺寸、门窗的位置以及屋顶的材料一样,设计模式包括我们编写的代码的某些规格和细节。在软件中,这通常意味着指定代码的高级布局以及依赖布局来解决特定设计问题的细微差别。然而,很少有设计模式是为了完全独立使用而生的。大多数情况下,设计模式侧重于特定的组件而不是整个系统。换句话说,设计图侧重于单个方面(比如屋顶形状)或组件(比如窗户设计),而不是整个小屋。乍看之下,这可能看起来不太好,但只有在目标确实是建造一个小屋的情况下才是如此。如果您尝试构建与小屋类似但不完全相同的东西,那么拥有每个单独组件的设计图意味着您可以将它们组合在一起,精确地构建出您想要的东西,选择屋顶形状A和窗户设计B。这也适用于我们的设计模式讨论,因为每个设计模式通常侧重于系统的单个组件或问题类型,通过组装许多预设计的组件,帮助您构建出您想要的东西。

例如,如果您想要向系统添加调试日志记录,您可能只希望有一种方法来记录消息。有很多方法可以做到这一点(例如,使用一个单一的全局变量),但偶然间有一个设计模式旨在解决这个软件问题。这个模式在经典著作《设计模式》(Gamma等人,1994)中有所描述,被称为单例模式(singleton),它确保只创建一个类的实例。这个“设计图”要求一个类具有私有构造函数和一个名为getInstance()的静态方法,该方法总是返回该类的单个实例(只有在它还不存在的情况下才创建该单个实例)。这个模式本身不是全部(毕竟,拥有一个什么都不做的单例类有什么用呢?);但是,它是在需要解决这个小型问题的情况下要遵循的一个经过明确定义和经过充分测试的模式,即始终有一个类的单个实例。

现在我们知道了通常的软件设计模式是什么,我们必须问一个问题:什么是API设计模式?根据第1章中对API的描述,API设计模式只是将软件设计模式应用于API而不是所有软件。这意味着API设计模式与普通设计模式一样,只是用于设计和构建API的方法的蓝图。由于重点是接口而不是实现,在大多数情况下,API设计模式将专注于接口,而不一定会构建实现。虽然大多数API设计模式通常不会详细说明这些接口的底层实现,但有时它们会规定API行为的某些方面。例如,一个API设计模式可能会指定某个RPC可以是最终一致的(eventually consistent),这意味着从该RPC返回的数据可能会略有过时(例如,它可能会从缓存中读取而不是从存储系统中读取)。

在后面的章节中,我们将更详细地解释我们计划如何记录API模式,但首先让我们快速看看为什么我们应该关心API设计模式。

2.2 为什么API设计模式很重要

API设计模式之所以有用,类似于在建造小屋时使用设计图,是因为它们充当我们可以在项目中使用的预先设计的基本模块。然而,我们并没有深入探讨为什么我们需要这些预先设计的图纸。难道我们不足够聪明,可以构建出良好的API吗?难道我们不是最了解我们的业务和技术问题吗?虽然这通常是正确的,但事实是,当设计API时,我们用来构建非常精心设计的软件的一些技术在很大程度上无法奏效。特别是,敏捷开发过程特别推崇的迭代方法在设计API时很难应用。为了理解原因,我们必须探讨软件系统的两个方面。首先,我们必须研究各种接口的灵活性(或刚性),然后必须了解接口的受众对我们的变更和整体设计迭代产生的影响。让我们从灵活性(flexibility)开始。

正如我们在第1章中所看到的,API是一种特殊的接口,主要用于计算系统相互交互。尽管以编程方式访问系统非常有价值,但它也更加脆弱,因为接口的变更很容易导致使用接口的人发生故障。例如,在API中更改字段的名称将导致用户使用旧名称编写的代码发生故障。从API服务器的角度来看,旧代码正在使用一个不再存在的名称请求某个内容。这与其他类型的接口(例如图形用户界面(GUI))非常不同,后者主要由人类而不是计算机使用,因此更能够抵御变化。这意味着尽管变更可能令人不悦,但通常不会导致灾难性故障,使我们完全无法使用接口。例如,更改网页上按钮的颜色或位置可能会很丑陋和不方便,但我们仍然可以弄清楚如何使用接口来完成我们需要做的事情。

通常,我们将接口的这个方面称为其灵活性,即用户可以轻松适应变化的接口是灵活的,而即使是小的变更(例如重命名字段)也会导致完全失败的接口是刚性或死板(rigid)的。这个区别很重要,因为接口是否能够进行大量变化在很大程度上取决于接口的灵活性。最重要的是,我们可以看到刚性接口使我们难以像在其他软件项目中一样向完美的设计不断迭代。这意味着我们通常会被困在设计决策中,无论是好的还是坏的。这可能让你认为API的刚性意味着我们永远无法使用迭代式开发流程。但实际上情况并不总是这样,这要归功于接口的另一个重要方面:可见性(visibility)。

通常,我们可以将大多数接口分为两种不同的类别:用户可以看到和互动的接口(在软件中通常称为前端),以及他们无法看到的接口(通常称为后端)。例如,当我们打开浏览器时,可以轻松看到Facebook的图形用户界面;但是,我们无法看到Facebook如何存储我们的社交图和其他数据。为了对这个可见性方面使用更正式的术语,我们可以说前端(所有用户都可以看到和互动的部分)通常被认为是公共的(public),而后端(只对较小的内部人员组可见)被认为是私有的(private)。这个区别很重要,因为它在一定程度上决定了我们对不同种类的接口(特别是刚性的API)进行变更的能力。

如果我们对公共接口进行变更,整个世界都会看到它,并可能会受到它的影响。由于受众如此之大,粗心地进行变更可能会导致用户沮丧或愤怒。尽管这当然适用于刚性接口,比如API,但它同样适用于灵活的接口。例如,在Facebook的早期,大多数主要功能或设计变更都会在几周内引起大学生的愤怒。但是,如果接口不是公共的,对只有某个内部小组的成员可见的后端接口进行变更是否也很重要?在这种情况下,受变更影响的用户数量要小得多,甚至可能仅限于同一个团队或同一办公室的人员,因此似乎我们重新获得了更多自由来进行变更。这是个好消息,因为这意味着我们应该能够快速迭代朝着理想设计前进,同时应用敏捷原则。

那么,为什么API是特殊的呢?事实证明,当我们设计许多API(根据定义是刚性的)并与世界分享时,实际上存在两个方面(刚性和变更困难)的最坏情况。这意味着进行变更要比这两个属性的任何其他组合更加困难。

表2.2 接口变更的困难度

灵活性受众示例接口变更困难度
灵活私有内部监控平台非常容易
灵活公共Facebook.com中等
死板私有内部存储API困难
死板公共公共Facebook API非常困难

简而言之,这种“两者兼具”的情况(既刚性又难以变更)使得可重复使用且经得起考验的设计模式对于构建API比其他类型的软件更为重要。在大多数软件项目中,代码通常是私有的,但API中的设计决策则是明显可见的,展示给服务的所有用户。由于这严重限制了我们对设计进行渐进性改进的能力,而依赖已经经受时间考验的现有模式会非常有价值——力争在第一次就做对事情而不是像在大多数软件中最终做对事情。

既然我们已经探讨了这些设计模式之所以重要的原因,让我们通过解构并探索它的各个组成部分来深入了解API设计模式。

2.3 解构API设计模式

像软件设计中的其他部分一样,API设计模式由几个不同的组件组成,每个组件负责处理模式的不同方面。显然,主要组件关注模式本身的工作原理,但还有其他一些组件针对设计模式的非技术性方面。这些组件包括如何确定某类问题存在某种与之对应的模式、了解模式是否适合你所处理的问题以及了解为什么模式采用的是某种方式而不是其他(可能更简单的)替代方法等等。

由于这个解构过程可能会变得有点复杂,让我们假设我们正在构建一个存储数据的服务,而该服务的客户希望拥有一个API,他们可以从服务中提取数据。我们将依赖这个示例场景来引导接下来要探讨的每个模式组件。首先我们从名称开始说起。

译者注: 此处提及的设计模式“组件”主要指的是本书在介绍每一种设计模式时遵循的固定流程,即按照“名称和摘要,动机,概述,接口实现,权衡“的顺序深入探讨每个模式。

2.3.1 名称和摘要

目录中的每个设计模式都有一个名称,用于在目录中唯一标识模式。名称应足够描述模式的功能,但不要冗长到拗口。例如,当描述解决我们的示例场景——数据导出的模式时,我们可以将其称为“导入、导出、备份、恢复、快照和回滚模式”,但更好的名称可能是“输入/输出模式”或简称为“IO模式”。

虽然名称本身通常足以理解和识别模式,但有时它可能不够详细,无法充分解释模式所解决的问题。为确保对模式本身有一个简短而简单的介绍,名称后面还会有一个模式的简要摘要,其中会简要描述它旨在解决的问题。例如,我们可以说输入/输出模式“提供了一种有序的方式,将数据从各种不同的存储源和目标位置移动。” 简而言之,本节的总体目标是使快速识别某个特定模式是否值得进一步研究,以确定是否适合解决特定问题。

2.3.2 动机

由于API设计模式的目标是为一类问题提供解决方案,因此最好在开头定义好模式旨在涵盖的问题领域。本节旨在解释基本问题,以便易于理解为什么我们需要这个模式。这意味着我们首先需要一个详细的问题陈述——通常以用户导向进行描述。在数据导出示例中,我们可能有一个场景,其中用户“想要将一些数据从服务中导出到另一个外部存储系统。”

之后,我们必须深入了解用户希望实现的目标细节。例如,我们可能会发现用户需要将其数据导出到各种存储系统,而不仅仅是Amazon的S3。他们还可能需要在传输之前对数据进行进一步约束,例如是否压缩或加密。这些要求将直接影响设计模式本身,因此重要的是我们要详细说明我们使用这个特定模式解决的问题细节。

接下来,一旦我们更全面地了解了用户目标,我们需要探索在实际实现的正常过程中可能出现的特殊情况。例如,我们应该了解当数据太大时系统应该如何反应(以及多大才算太大,因为这些词通常对不同的人有不同的含义)。我们还必须探讨系统遇到异常时应如何反应。例如,当导出作业失败时,我们应该描述是否应重试。这些不寻常的场景可能比我们通常期望的要常见得多,即使我们可能不必立即决定如何解决每个场景,但模式必须照顾到这些真空地带,以便最终可以由具体接口实现进行填补。

2.3.3 概述

现在我们越来越接近有趣的部分:解释设计模式建议的解决方案。在这一点上,我们不再专注于定义问题,而是提供解决方案的上层描述。这意味着我们可以开始探讨解决问题所需的策略和方法。例如,在我们的数据导出场景中,这一部分将概述各种组件及其职责,例如一个组件用于描述要导出的数据的详细信息,另一个组件用于描述作为导出数据目标位置的存储系统,还有一个组件用于描述在将数据发送到目标位置之前应用的加密和压缩设置。

在许多情况下,问题的定义和一系列需求将决定解决方案的一般大纲。在这些情况下,概述的目标是明确表述这个大纲,而不是让它从问题描述中被推断出来,无论解决方案多么明显。例如,如果我们正在定义一个用于搜索资源列表的模式,拥有一个查询参数似乎是相当明显的;但是,其他方面(例如该参数的格式或搜索的一致性保证)可能不那么明显,值得进一步讨论。毕竟,即使明显的解决方案也可能有微妙的影响,值得讨论,正如人们常说的,魔鬼通常隐藏在细节中。

另一方面,虽然问题已经定义得很好,但可能没有明显的单一解决方案,而是有多种不同的选项,每种选项都可能具有自己的利弊。例如,在API中建立多对多关系有许多不同的方法,每种方法都有其不同的优点和缺点;但是,重要的是API选择一种选项并一致地应用它。在这种情况下,概述将讨论每种不同的选项以及推荐模式所采用的策略。这一部分可能包含对提到的其他可能选项的优点和缺点的简要讨论,但更多的讨论将留到模式描述的最后的“权衡”部分。

2.3.4 接口实现

每个设计模式的最重要部分已经到来:我们如何实现它。此时,我们应该充分了解我们要解决的问题领域,并对上层策略和解决方法有所了解。这一部分最重要的内容将是以代码形式定义的接口,它解释了使用该模式来解决问题的API会是什么样子。API定义将关注资源的结构以及与这些资源进行各种具体交互的方式。这将包括各种内容,如资源或请求中定义的字段,可以进入这些字段的数据格式(例如Base64编码的字符串),以及资源之间的关系(例如层级关系)。

在许多情况下,API接口和字段定义本身可能无法解释API的实际工作原理。换句话说,虽然结构和字段列表可能看起来很清晰,但这些结构的行为和不同字段之间的交互可能复杂得多。在这些情况下,我们需要更详细地讨论这些并非显而易见的方面。例如,在导出数据时,我们可能会指定一种方法,在将数据传输到存储服务时使用字符串字段来指定压缩算法。在这种情况下,该模式可能会讨论该字段的各种可能值(可能使用与Accept-Encoding HTTP头使用的相同格式),当提供无效选项时应该执行什么操作(可能会返回错误),以及当请求留空该字段时的含义(可能会默认为gzip压缩)。

最后,这一部分将包括一个示例API定义,其中包含了注释来解释正确实现此模式的API应该是什么样的。这将以代码形式定义,其依赖于具体问题的示例场景,包含了解释各个字段行为的注释。这一部分几乎肯定会是最长且包含最多细节的部分。

2.3.5 权衡

到目前为止,我们了解了设计模式为我们提供了什么,但我们尚未讨论它带走了什么,实际上这也非常重要。坦白地说,如果严格按设计实现设计模式,可能有些事情是不可能的。在这些情况下,了解为了获得的好处而必须做出的牺牲非常重要。在这里可能会有各种可能性,从功能上的限制(例如,直接将数据作为Web浏览器中的下载提供给用户是不可能的)到增加的复杂性(例如,描述将数据发送到哪里需要更多的输入),甚至到更多的技术方面如数据一致性(例如,您可以看到数据可能有点陈旧,但不能确定)。因此这里的讨论可以既包括简单的解释,也包括在依赖特定设计模式时产生的微小缺陷。

此外,尽管某些设计模式通常适用于特定问题,但肯定会有一些情景,它虽然总体上符合要求,但却不完美。在这些情况下,了解依赖于这种设计模式将会产生什么后果非常重要:不是错误的模式,但也不是完美的模式。本节将讨论这种轻微不匹配的后果。

既然我们对API设计模式的结构和解释有了更好的了解,让我们换个角度看看在构建一个被认为是简单的API时,这些设计模式可以带来的差异。

2.4 案例研究: Twapi,一个类似Twitter的API

假设你不熟悉Twitter,可以将其看作是一个可以与他人分享短消息的地方——仅此而已。想到一个完整的业务建立在每个人创建微小消息的基础上有点吓人,但显然这足以使其成为一家价值数十亿美元的科技公司。这里没有提到的是,即使有一个非常简单的概念,在表面下实际上隐藏着相当多的复杂性。为了更好地理解这一点,让我们开始探讨Twitter可能的API是什么样子,我们将其称为Twapi。

2.4.1 概述

使用Twapi,我们的主要责任是允许人们发布新消息并查看其他人发布的消息。表面上看起来似乎很简单,但正如你可能猜到的,我们需要注意一些隐藏的陷阱。让我们首先假设我们有一个简单的API调用来创建Twapi消息。之后,我们将看一下此API可能需要的两个附加操作:列出消息和将所有消息导出到不同的存储系统。

在我们开始之前,有两件重要的事情需要考虑。首先,这将只是一个示例API。这意味着重点将放在我们如何定义接口上,而不是实现实际工作的方式。在编程术语中,这有点像说我们只会讨论函数定义,将函数体留待以后填充。其次,这将是我们首次尝试查看API定义。如果你还没有查看“关于本书”部分,现在是一个比较好的时机先阅读一遍,以便TypeScript样式的格式不会让你感到奇怪。

现在这些事情都澄清了,让我们看看如何列出Twapi消息。

2.4.2 消息列表

假设我们可以创建消息,那么很自然我们会希望列出创建的那些消息。此外,我们还想看到我们朋友创建的消息。更进一步,我们可能希望看到一个长列表,按照朋友消息的热门程度排序(有点像新闻推送)。让我们首先定义一个简单的API方法来实现这一点,不使用任何设计模式。

不使用设计模式

从头开始,我们需要发送一个请求,要求列出一堆消息。为此,我们需要知道我们想要的消息是谁的,我们将其称为“父级”(parent)。作为回应,我们希望我们的API返回一个简单的消息列表。该交互在图 2.1 中进行了概述。

图2.1 请求 Twapi 消息的简单流程

现在我们了解了列出这些消息涉及的流程,让我们将其正式化为一个真实的API定义。

代码2.1 列出Twapi消息的API示例

// 首先,我们将API服务定义为一个抽象类。这只是一组以TypeScript函数形式定义的API方法。
abstract class Twapi {
    // 我们可以使用TypeScript的静态变量来存储关于API的元数据,比如名称或版本。
    static version = "v1";
    static title = "Twapi API";
    // 在这里,我们依赖特殊的包装函数来定义HTTP方法(GET)和URL模式(/users/<user-id>/messages)
    // 并将其映射到这个函数。
    @get("/{parent=users/*}/messages")
    // 这里`ListMessages` 函数接受一个 `ListMessagesRequest` 并返回一个 `ListMessagesResponse`。
    ListMessages(req: ListMessagesRequest): ListMessagesResponse;
}
interface ListMessagesRequest {
    // `ListMessagesRequest` 接受一个参数:`parent`。这是我们试图列出消息的所有者。
    parent: string;
}
interface ListMessagesResponse {
    // `ListMessagesResponse` 返回请求中提供的用户所拥有消息的简单列表。
    results: Message[];
}

如您所见,这个API定义非常简单。它接受一个参数并返回匹配消息的列表。但让我们想象一下,假设我们将其部署为我们的API,并考虑随着时间推移可能出现的最大问题之一:数据量增加。

随着越来越多的人使用该服务,消息列表可能变得相当长。最初响应数十或数百条消息时,这可能并不是什么大问题。但是当您开始处理成千上万甚至百万条消息时呢?一个携带500,000条消息的单个HTTP响应,每条消息最多140个字符,意味着这可能高达70兆字节的数据!这对于常规的API用户来说似乎相当繁琐,更不用说一个单独的HTTP请求将导致Twapi数据库服务器发送70兆字节的数据。

那么我们该怎么办呢?明显, 答案是允许API将可能变得非常庞大的响应拆分成较小的部分,并允许用户一次请求全部消息中的一个片段。为此,我们可以依赖于分页模式(pagination pattern)(请参阅第26章)。

使用分页模式

正如我们将在第26章中了解到的那样,分页模式是以较小、更易管理的数据块的方式检索长列表项的一种方式,而不是一次性发送整个列表。该模式依赖于请求和响应上的额外字段;同时,这些字段应该看起来相当简单。该模式的一般流程如图 2.2 所示。

图2.2 检索Twapi消息的分页模式的示例流程

下面是实际的API定义示例。

代码2.2 列出带有分页的Twapi消息的示例API

abstract class Twapi {
    static version = "v1";
    static title = "Twapi API";
    @get("/{parent=users/*}/messages")
    // 注意,方法定义保持不变;这里不需要更改。
    ListMessages(req: ListMessagesRequest): ListMessagesResponse;
}
interface ListMessagesRequest {
    parent: string;
    // 为了澄清我们正在请求哪个数据块(或页面),我们在请求中包含了一个页面令牌参数。
    pageToken: string;
    // 我们还使用一种方式来指定Twapi服务端在一个数据块中应该向我们返回消息的最大数量。
    maxPageSize: number?;
}
interface ListMessagesResponse {
    results: Message[];
    // Twapi服务端的响应将包含一个令牌来获取下一块消息。
    nextPageToken: string;
}

如果不从开始就使用分页模式会怎样

通过对API服务进行这些小的改变,我们实际上已经创建了一个能够在Twapi消息数量激增时保持稳定的API方法。但这留下了一个问题:为什么我要从一开始就遵循这种模式呢?为什么不等到问题出现时再添加这些字段呢?换句话说,为什么我们要费心去修复还没有出现的问题呢?正如我们将在后面讨论的向后兼容性(backward compatibility)中了解的那样,原因很简单,就是为了避免使现有软件出现问题。

在这种情况下,从我们更简单的原始设计(将所有数据在单个响应中返回)转向依赖分页模式(将数据分割成较小的块)可能看起来是一个无害的改变,但实际上它会导致任何之前存在的软件运行异常。在这种情况下,先前编写的代码会期望单个响应包含所有请求的数据,而不是其中的一部分,因此会出现了两个重要问题。

首先,由于先前编写的软件期望所有数据都在单个请求中返回,它无法找到出现在后续页面上的数据。因此,在变更之前编写的代码实际上无法获取除了第一个数据块之外的所有数据,这带来了第二个问题。

由于现有的使用者不知道如何获取附加的数据块,他们以为已经获得了所有数据,尽管实际上只有一小部分。这种误解可能导致非常难以检测的错误。例如,尝试计算某些数据的平均值的用户可能最终得到一个看起来正确但实际上只是第一个数据块的平均值的数值。显然,这可能导致一个不正确的值,但不会产生明显的系统异常。因此,这种错误可能会长时间存在而难以察觉。

现在我们已经看过列举消息的示例,接下来让我们探讨为什么在导出数据时也可能要使用设计模式。

2.4.3 导出数据

在某个时刻,Twapi服务的用户可能希望能够导出所有他们的消息。与列举消息类似,我们首先必须考虑到我们需要导出的数据量可能会变得相当大(可能达到数百兆字节)。此外,与列举消息不同,我们应该考虑到在接收端可能有许多不同的存储系统,并且理想情况下,我们应该有一种方法来与新兴存储系统集成。此外,我们可能希望在导出数据之前应用许多不同的转换,例如加密、压缩或根据需要对一些数据进行匿名处理。最后,所有这些都不太可能在同步方式下运行,这意味着我们需要一种方法,可以表示有待处理工作(即实际的数据导出)正在后台运行以供用户监视其进度。

让我们从为这个API制定一个简单的实现开始,然后看看未来可能出现的各种问题。

不使用设计模式

正如提到的,我们有一些主要关注点:大量数据、数据的最终目标位置、数据的各种转换或配置(例如,压缩或加密),以及API的异步性质。由于我们只是尝试为导出Twapi消息制定一个基本的API,最简单的选项是触发生成一个将来可供下载的压缩文件。简而言之,当有人发出对此API的请求时,响应实际上并不包含数据本身。相反,它包含一个指向将来某个可以下载数据的地址。

代码2.3 导出消息的简单API

abstract class Twapi {
    static version = "v1";
    static title = "Twapi API";
    // 我们映射到的URI使用了POST HTTP动词,以及一种特殊的语法来表明这是执行的一项特殊操作,
    // 而不是标准的REST操作之一。
    @post("/{parent=users/*}/messages:export")
    // 就像我们之前的例子一样,我们依赖于一个`ExportMessages`函数,该函数接受一个请求并返回一个响应。
    ExportMessages(req: ExportMessagesRequest): ExportMessagesResponse;
}
interface ExportMessagesRequest {
    // 我们只允许一次导出一个用户的数据,因此导出方法仅适用于单个父级(用户)。
    parent: string;
}
interface ExportMessagesResponse {
    // Twapi服务器的响应指定了以后可以从文件服务器下载的压缩文件的位置,而不是从此API服务下载。
    exportDownloadUri: string;
}

这个API确实完成了主要任务(导出数据)和一些次要任务(异步检索),但缺少一些重要方面。首先,我们无法定义有关数据的额外配置的方法。例如,我们没有机会选择压缩格式或在加密数据时使用的密钥和算法。其次,我们无法选择数据的最终目标位置。相反,我们只是被告知以后可能查找它的位置。最后,如果我们更仔细地观察,就会清楚地看到接口的异步性质只是部分有用的:虽然服务确实以异步方式返回我们可以下载数据的位置,但我们无法监控导出操作的进度,也无法在不再对数据感兴趣的情况下中止操作。

让我们看看是否可以通过使用我们设计模式目录中稍后定义的一些设计模式来改进这个设计,主要集中在导入/导出模式上。

倒入/导出模式

正如我们将在第28章中学到的那样,导入/导出模式旨在解决类似于这样的问题:我们的API服务中有一些数据,用户希望有一种获取数据的方式(或将其导入)。然而,与我们之前讨论的分页模式不同,这个模式将依赖于其他模式,比如耗时操作模式(long-running operations)(在第13章中讨论)来完成任务。让我们首先定义API,然后更仔细地查看每个部分是如何协同工作的。就像以前一样,请记住我们不会详细讨论模式的每个方面,而是尝试提供相关部分的上层视图。

代码2.4 使用设计模式后的导出消息API

abstract class Twapi {
    static version = "v1";
    static title = "Twapi API";
    @post("/{parent=users/*}/messages:export")
    // 与先前的例子不同,我们的`ExportMessages`方法的返回类型是一个耗时操作,该操作在完成时返回一个`ExportMessagesResponse`,
    // 并使用`ExportMessagesMetadata`接口报告有关操作的元数据。
    ExportMessages(req: ExportMessagesRequest):
        Operation<ExportMessagesResponse, ExportMessagesMetadata>;
}
interface ExportMessagesRequest {
    parent: string;
    // 除了`parent`(用户)之外,`ExportMessagesRequest`还接受有关生成的输出数据的一些额外配置。
    outputConfig: MessageOutputConfig;
}
interface MessageOutputConfig {
    // 在这里,我们定义了“destination”(目标位置),它表示操作完成时数据应该到达的位置。
    destination: Destination;
    // 此外,我们可以使用单独的配置对象调整数据的压缩或加密方式。
    compressionConfig?: CompressionConfig;
    encryptionConfig?: EncryptionConfig;
}
interface ExportMessagesResponse {
    // 结果将回显用于将数据输出到结果目标时使用的配置。
    outputConfig: MessageOutputConfig;
}
interface ExportMessagesMetadata {
    // `ExportMessagesMetadata`将包含有关操作的信息,如进度(以百分比表示)。
    progressPercent: number;
}

这个模式有什么好处呢?首先,通过依赖封装的输出配置接口,我们能够在请求时接受各种参数,然后在响应中将相同的内容作为确认返回给用户。接下来,在这个配置中,我们能够定义几个不同的配置选项,我们将在代码2.5中更详细地讨论。最后,我们能够使用耗时操作的元数据信息来跟踪导出操作的进度,该信息存储操作的进度百分比(0%表示“未启动”,100%表示“完成”)。

话虽如此,您可能已经注意到我们在先前的API定义中使用的一些模块未被定义。现在让我们明确定义它们并提供一些示例配置。

代码2.5 用于配置目标位置和设置的接口

interface Destination {
    typeId: string;
}
// 就像最初的例子一样,我们可以定义一个文件目标位置,将输出放在文件服务器上,以便以后下载。
interface FileDestination extends Destination {
    fileName: string;
}
// 除了文件下载的例子,我们还可以要求将数据存储在亚马逊的S3上的某个位置。
interface AmazonS3Destination extends Destination {
    uriPrefix: string;
}
interface CompressionConfig {
    typeId: string;
}
// 在这里,我们定义了其他压缩选项以及每个选项的配置值。
interface GzipCompressionConfig {
    // An integer value between 1 and 9.
    compressionLevel: number;
}
// 我们可以在单个接口中定义所有加密配置选项,
// 也可以使用相同的子类结构(如压缩配置所示)来表示各种选择。
interface EncryptionConfig {
    ...
}

这里我们可以看到定义配置选项的各种方式,比如数据的目标位置或数据应该如何进行压缩。唯一剩下的就是要了解这个耗时操作到底是如何工作的。我们将在第28章中更详细地探讨这个模式,但现在,让我们简单地提出这些接口的API定义,以便至少对它们在做什么有一个总体理解。

代码2.6 通用错误接口和耗时操作接口的定义

// 一个必须的基本组件是错误的定义,它至少包括错误代码和消息。
// 还可以包括一个可选字段,其中包含有关错误的更多详细信息。
interface OperationError {
 code: string;
 message: string;
 details?: any;
}
// 耗时操作是一种类似于`Promise`的结构,基于结果和元数据类型进行参数化(就像C++/Java泛型)。
interface Operation<ResultT, MetadataT> {
 id: string;
 done: boolean;
 result?: ResultT | OperationError;
 metadata?: MetadataT;
}

如果不从开始就使用这些设计模式会怎样

前例(分页模式示例)中受模式驱动和非模式驱动的选项看起来相似,而本例(导出模式示例)却不同,这两个选项在最终的API上有显著差异。因此,对于这个问题的答案是明确的:如果您发现自己需要提供某些功能(不同的导出目标位置、单独的配置等),那么从非模式驱动的方法开始将导致对用户的破坏性变更。而通过从起始点采用模式驱动的方法,API将在需要新功能时得到优雅的演进。

本章总结

  • API设计模式有点像用于设计和构建API的可调试蓝图。
  • API设计模式之所以重要,是因为API通常非常“刚性”,因此不容易更改,设计模式有助于最小化对大型结构变更的需求。
  • 在本书中,API设计模式将包括几个部分,包括名称和摘要、建议的规则、动机、概述、接口实现以及使用提供的模式而非其他替代方案的权衡。