掌握API建模:基本概念和实践
万字讲透REST API资源建模
0 前言
★
“REST 中信息的核心抽象是资源。任何可以被命名的信息都可以成为资源:文档或图像、临时服务(例如‘今天洛杉矶的天气’)、其他资源的集合、非虚拟对象(例如一个人)等等。换句话说,任何可能成为作者超文本引用目标的概念都必须符合资源的定义。资源是一个概念性的映射到一组实体,而不是在任何特定时间点对应于该映射的实体。”—— Roy Fielding 的论文。”
符(URI)、资源表示、API 操作(使用不同的 HTTP 方法)等都是围绕资源这一概念构建的。在设计 REST API 时,选择合适的资源并以适当的粒度进行建模非常重要,以确保 API 消费者能够从 API 中获得所需的功能,使 API 行为正确且易于维护。
资源可以是单例的也可以是集合。如银行领域中,“customers”是一个集合资源,“customer”则是一个单例资源。可用 URN(Uniform Resource Name) “/customers” 来标识“customers”集合资源。可用 URN “/customers/{customerId}” 来标识单个“customer”资源。
资源还可能“包含”子集合资源。如可用 URN “/customers/{customerId}/accounts” 标识某个特定客户的“accounts”子集合资源。同样,子集合资源“accounts”中的单个资源“account”可以标识为:“customers/{customerId}/accounts/{accountId}”。
选择资源的起点是分析你的业务领域,并提取与业务需求相关的名词。更重要的是,应该关注 API 消费者的需求,以及如何从 API 消费者交互的角度使 API 变得相关和有用。一旦确定了这些名词(资源),就可以将 API 交互建模为针对这些名词的 HTTP 动词。当它们不能很好地对应时,我们可以进行适当的调整。例如,我们可以轻松地使用“领域中的名词”方法,并在博客领域中识别出低级资源,如 Post、Tag、Comment 等。同样,我们可以在银行领域中识别出 Customer、Address、Account、Teller 等名词作为资源。
以“Account”这个名词为例,“open”(开设账户)、“close”(关闭账户)、“deposit”(存款到账户)、“withdraw”(从账户取款)等都是动词。这些动词可很好映射到 HTTP 动词。如API消费者可通过使用 HTTP POST 方法创建“Account”资源的实例来“开设”一个账户。类似地,API 消费者可以使用 HTTP DELETE 方法“关闭”一个账户。API 消费者可以使用 HTTP 的“PUT”/“PATCH”/“POST”方法来“取款”或“存款”。
然而,这种简化的方式在抽象层面上可能有效,但一旦进入更复杂的实际领域就会出现问题。最终,你会遇到一些概念,这些概念并没有被通常或显而易见的名词所涵盖。接下来的部分将探讨资源建模的艺术以及以正确的粒度建模资源的重要性。
1 细粒度 CRUD 资源与粗粒度资源
如围绕细粒度的资源设计 API,对消费者应用程序,我们将得到一个更加冗长的 API。另一方面,如果我们基于非常粗粒度的资源设计 API(旨在完成所有功能),那么 API 将无法支持所有消费者的需求,且 API 可能变得难以使用和维护。为了更好地理解这一点,我们来看一个博客 API 的例子:
博客文章 API(用于创建新的博客文章条目)可以通过两种方式设计:
- 设计多个 API——每个 API 用于处理博客文章的不同部分(例如标题和文本内容、图片/附件、内容/图片的标签等)。这使 API 更细粒度,导致消费者和提供者之间的交互更加频繁。这种方法将要求消费者向服务器发出多个 API 请求,服务器将接收到显著增加的 HTTP 请求数量——这可能会影响其为多个消费者服务的能力
- 设计一个粗粒度的 API 用于发布博客(到“Posts”集合资源),该 API 可以包括文章标题、内容、图片和标签。这样只需向 API 提供者发出一个请求,从而减少服务器负载
相反,消费者应用程序应当能够通过向“Likes”子集合资源(“/posts/{post_id}/likes”)发出 API 请求来“点赞”博客文章,或通过向“Comments”子集合资源(“/posts/{post_id}/comments”)发出 API 请求来为博客添加评论,而不需要通过博客“Post”资源(“/posts/{post_id}”)来操作。使用单一的粗粒度“Post”资源来进行“点赞”或“评论”操作,会增加 API 提供者和消费者的工作难度。对于单一的粗粒度“Post”资源,为添加评论或点赞,API 提供者必须提供一种方式让消费者表明请求的意图——可能通过在有效载荷中指定一个单独的 XML 元素或 JSON 属性来指示有效载荷类型。在服务器端,API 提供者需要查找这些提示并决定请求是用于添加评论、点赞,还是用于实际更新博客内容。同样,消费者端的代码也需要处理使用单一粗粒度资源时有效载荷的这些变化,这会增加不必要的复杂性。
1.1 防止业务逻辑向 API 消费者迁移
如果 API 消费者被期望直接操作低级资源(使用细粒度的 API),会带来两个主要结果:首先,消费者与提供者之间的交互会变得非常频繁。其次,业务逻辑将开始泄漏到 API 消费者中。在我们的博客文章 API 示例中,细粒度的 API 可能会导致博客文章数据处于不一致的状态,并引发维护问题。例如,博客应用程序可能有这样的业务逻辑:内容必须附有标签,或者图片标签只能在文章有图片时添加。为了正确地处理这些情况,消费者需要按正确的顺序发出所有必要的 API 请求——一个请求用于基本文章内容,另一个请求用于图片,另一个请求用于标签等。如果消费者发出创建博客文章的请求,但没有附上标签的请求,那么博客文章就会因为缺少标签而数据不一致(假设标签在应用程序上下文中是强制性的)。这实际上意味着消费者需要在客户端理解并应用业务逻辑(如确保标签被附加,确保 API 请求的顺序正确等)。
即使 API 消费者清楚理解这个责任,当出现故障时又该怎么办?如第一个 API 请求成功通过——创建了博客文章,但第二个附加标签的请求失败了。这会导致数据处于不一致的状态。在这种情况下,应该非常明确地规定消费者该怎么做?消费者能重试吗?如果不能,谁来清理数据?等等。这种责任往往难以理解,也很难强制执行。考虑到业务逻辑可能会发生变化,这种方法会增加维护成本——特别是在不同类型的 API 消费者(如原生移动应用、Web 等)中,以及当 API 消费者可能未知/数量较多(对于公共 API 来说)时。
基本上,低级 CRUD 方法将业务逻辑放在了客户端代码中,造成了客户端(API 消费者)与服务(API)之间不必要的紧密耦合,且通过在客户端解构用户意图的方式丧失了用户的本意。每当业务逻辑发生变化时,所有 API 消费者都需要修改代码并重新部署系统。在某些情况下,如原生移动应用,这是无法做到的,因为用户不会乐意频繁更新他们的移动应用。同时,提供支持频繁交互的低级服务意味着 API 提供者在升级服务时必须维护所有这些低级服务,以保持对 API 消费者的向后兼容性。
对于粗粒度的 API,业务逻辑保留在 API 提供者一侧,从而减少了之前讨论的数据不一致问题。在许多情况下,消费者甚至不需要了解服务器端应用的业务逻辑。
注意:当我们谈论防止业务逻辑迁移时,我们指的是控制流程的业务逻辑(例如,按正确顺序发出所有必要的 API 请求),而不是功能性业务逻辑(例如,税费计算)。
2 面向业务流程的粗粒度聚合资源
如何使用针对命名资源的 HTTP 动词来调和讲述业务能力语言的粗粒度接口?如果我没有足够的名词怎么办?如果我的服务涉及多个资源并且有许多针对这些资源的操作怎么办?如何确保在处理许多名词和少量动词的情况下实现粗粒度的交互?以及如何避免服务交互的低级 CRUD 化,并使用更符合业务术语的语言?
让我们重新看看 Roy Fielding 的论文中关于资源的论述:“……任何可能成为作者超文本引用目标的概念都必须符合资源的定义……。”业务能力/流程可以很好地符合资源的定义。换句话说,对于跨多个资源的复杂业务流程,我们可以将业务流程本身视为一种资源。例如,银行领域中新客户开户的过程可以被建模为一种资源。CRUD 只是适用于几乎所有资源的最小业务流程。这使我们能够将业务流程建模为真正的资源,这些资源可以自行跟踪。
区分 REST API 中的资源和领域驱动设计中的领域实体非常重要。领域驱动设计适用于实现方面(包括 API 实现),而 REST API 中的资源驱动 API 设计和契约。API 资源选择不应依赖于底层领域实现的细
节。
2.1 脱离 CRUD
摆脱低级 CRUD 的方法是创建业务操作或业务流程资源,或我们可以称之为“意图”资源,它们表示“想要某物”的业务/领域级状态,或“朝着最终结果的流程状态”。但要做到这一点,首先需要确保找出你所有状态的真正所有者。在四动词(AtomPub 风格)的 CRUD 世界中,就好像你允许随机的外部方通过 PUT 和 DELETE 修改你的资源状态,好像服务只是一个低级数据库。PUT 将过多的内部领域知识放在客户端中。客户端不应操作内部表示;它应该是用户意图的来源。为了更好地理解这一点,让我们考虑一个客户在银行领域中更改其地址的例子。
两种方法可做到这点:
- 第一种方法是使用围绕“Customer”资源构建的 CRUD API 来直接更新客户的地址。即,为了更新现有客户的地址,可以向“Customer”资源(或存在的“Address”资源)发出 HTTP PUT 请求。如果我们采用这种 CRUD API 设计,那么业务相关的事件数据,如地址何时更改、由谁更改(由客户更改还是由银行员工更改)、更改的内容、历史记录等,将被忽略。换句话说,我们将错失那些可能在以后有用的业务相关事件数据。此外,通过这种方式,客户端代码需要了解“Customer”领域(包括客户的属性等)的知识。如果“Customer”的领域定义发生变化,即使客户端不使用受影响的属性,客户端代码可能也需要立即更新。这使得客户端代码更加脆弱。
- 另一种解决 CRUD 问题的方法是围绕基于业务流程和领域事件的资源设计 API。例如,为了更新现有银行客户的地址,可以向“ChangeOfAddress”资源发出 POST 请求。这个“ChangeOfAddress”资源可以捕获完整的地址更改事件数据(如由谁更改、具体更改了什么等)。这种方法尤其适用于那些业务相关事件数据可能在以后有用的情况(如长时间运行的异步流程,地址更改作为后台批处理过程的一部分)或从长期角度(如分析或显示历史更改以供审计等)。即使没有立即或可预见的业务需求来保留事件数据,POST 到“意图”资源“ChangeOfAddress”仍然可以被考虑(通过成本效益分析)来避免客户端需要了解内部领域知识。这使得客户端代码较少受“Customer”领域定义变化的影响。当事件数据对业务没有必要时,是否持久化“ChangeOfAddress”事件数据是可选的。我们可以选择直接应用地址更改而不存储“ChangeOfAddress”事件数据。
摆脱 CRUD 意味着确保托管资源的服务是唯一能够直接更改其状态的代理。这可能意味着根据谁真正拥有特定状态来将资源拆分为更多的资源。然后,每个人只需 POST 他们的“意图”或发布他们自己拥有的资源状态,可能是为了轮询。
2.2 名词与动词
围绕名词与动词的争论 是无休止的。让我们考虑一个例子——在银行开户。这一业务流程可以称为 EnrollCustomer 或 CustomerEnrollment。在这种情况下,CustomerEnrollment 这个术语听起来更好,因为它更像一个名词。它也读起来更自然:“CustomerEnrollment 编号 2543,用于客户 xxxx”。它还有一个额外的好处,即维护业务相关的、可独立查询的和可演化的状态。此类资源的业务等价物是我们可能在企业中填写的典型表格,这些表格会触发业务流程。考虑典型的业务职能中的纸质表格类比,有助于我们以技术中立的方式专注于业务需求,正如 Dan North 在其文章“SOA 的经典介绍” 中所讨论的那样。
典型的客户注册可能涉及向外部机构发送 KYC(了解您的客户)请求、注册客户、创建账户、打印借记卡、发送邮件等。这些步骤可能会重叠,流程运行时间较长且可能会出现多个失败点。这可能是我们将流程建模为资源的更具体示例。这样的流程将导致创建/更新多个低级资源,如 Customer、Account、KYCRequest 等。针对这样的流程,GET 请求是有意义的,因为我们将获得流程的当前状态。
如果我们没有将客户注册流程建模为资源,API 消费者将不得不“知道”客户注册涉及的业务逻辑——一个请求用于创建客户资源,一个请求用于 KYC 请求,一个请求用于打印卡片请求等。基本上,所有 API 消费者都必须在他们的代码中理解和应用业务逻辑。如果 API 消费者漏掉了某个步骤,例如“打印卡片请求”,那么你将得到一个不完整的注册流程,而客户由于没有收到卡片而不满。这显然容易出错。
或许这可以作为一个经验法则:这个流程是否需要自己的状态?业务是否会提出这样的问题——流程的状态是什么?如果失败了,为什么?谁发起的?从哪里发起的?发生了多少次?流程失败的最常见原因是什么?以及在哪一步失败的?该流程平均、最小、最大需要多长时间?对于大多数非简单的流程,业务会希望得到这些问题的答案。这样的流程应该作为独立的资源进行建模。
而这正是名词方法开始显得局限的地方。业务流程当然是行为,业务语言通常关注动词。但它们对业务来说也是“事物”。既然我们可以将大多数动词转换为名词,区分就开始变得模糊。实际上,这只是你如何看待它——任何名词都可以被动词化,反之亦然。问题是你想用它做什么。你可能会说“注册 Sue”而不是“为 Sue 注册”,但当谈论一个长期运行的流程时,使用“ Sue 的注册进展如何?”显得更合理。这就是为什么对于任何足够长时间的流程来说,使用一个名词来表示它看起来更好的原因。
2.3 抽象概念的具象化
具象化的字典定义是“使(某些抽象的东西)更加具体或真实”。换句话说,具象化使某些抽象的东西(例如概念)变得更为具体/真实。
随着专注于业务能力的粗粒度方法,我们正在将更多的具象化抽象概念建模为资源。具象化资源的一个很好的例子是我们之前讨论过的 CustomerEnrollment。我们没有使用 Customer 资源,而是使用了相当于客户注册请求的资源。让我们考虑同一银行领域的另外两个示例:
- 银行账户的现金存款:客户向其账户存款。这涉及诸如应用业务规则(例如检查存款金额是否在允许范围内)、更新客户账户余额、添加交易条目、向客户手机或电子邮件发送通知等操作。虽然我们在这里可以技术上使用 Account 资源,但更好的选择是具象化业务能力/抽象概念——交易(或存款),并创建一个新的资源“Transaction”。
- 两个银行账户之间的转账:客户将钱从一个银行账户转入另一个银行账户。这涉及更新两个低级资源(“from”账户和“to”账户),还涉及业务验证、创建交易条目、发送通知等。如果“to”账户在另一家银行,转账可能通过中央银行或外部机构进行。在这里,抽象概念是“资金转移”交易。为了转账,我们可以将 POST 请求发送到 /transactions 或 /accounts/343/transactions,并创建一个新的“Transaction”(或“MoneyTransfer”)资源。需要注意的是,创建新的“Transaction”资源并不自动意味着要为“Transaction”创建数据库表。API 设计应独立于 API 实现和数据持久化的底层设计问题。
在这两种情况下,我们没有使用 Account 资源,而是使用了相当于存款或转账命令的资源——Transaction 资源(类似于前面提到的 CustomerEnrollment)。这种方法尤其适用于这种可能是一个长期运行的流程(例如,转账可能涉及多个阶段才能完成)。这当然并不排除你拥有 Account 资源的可能——它可能会在“Transaction”被处理后更新。此外,也可能有正当的用例发出请求给“Account”资源。例如,要获取账户摘要/余额信息,API 请求应发送到“Account”资源。
思维转换的关键之一是理解你可以利用无限的 URI 空间。同时,应该避免资源过度扩展,这可能会增加 API 设计的混乱。只要有明确的需求,并且资源在整体 API 设计中与用户/消费者“意图”契合,就可以扩展 URI 空间。具象化资源可以用作服务的事务边界。
这里还有一个方面——组织服务器行为的方式
与 API 的工作方式是分开的。我们已经讨论了为存款创建“Transaction”资源,做这件事有很多好的理由。但将存款处理作为 Account 资源的一部分也是完全合理的。处理 Account 的服务需要负责协调更改并创建一个 Transaction 资源、Notification 资源等(这些可能在同一服务中,或在不同的服务中)。没有理由要求客户端自己处理所有这些。API 提供者需要选择一个服务来处理协调责任。在我们的例子中,这个责任交给了处理 Transaction 资源的服务,如果这是要采取的路线。这与内存对象设计是一样的。当客户端需要在一堆对象之间协调更改时,常见的方法是选择一个对象来处理协调工作。
3 没有 PUT 的 REST 和 CQRS
HTTP 动词 PUT 可用于 API 消费者的幂等资源更新(或在某些情况下用于资源创建)。然而,对于复杂状态转换使用 PUT 可能会导致同步的 CRUD。它还通常会丢失在触发此更新时可用的大量信息——是什么真实的业务领域事件触发了此更新?通过“没有 PUT 的 REST”技术,消费者被迫发布新的“名词化”请求资源。如前所述,更改客户的邮寄地址是向新的“ChangeOfAddress”资源发出 POST 请求,而不是使用 PUT 更新带有不同邮寄地址字段值的“Customer”资源。最后一点意味着我们可以减少消费者对原子一致性的期望——如果我们 POST 一个“ChangeOfAddress”,然后 GET 相关的 Customer,很明显更新可能还未处理完,旧状态可能仍然存在(异步 API)。GET 一个带有“201”响应的新建“ChangeOfAddress”资源将返回与该事件相关的详细信息,以及链接到已更新或将更新的资源。
这个想法是我们不再 PUT 实体的“新”状态,而是让我们的变更成为一等公民的名词(而不是动词),并 POST 它们。这与事件溯源非常契合——事件是第一等公民名词的典型例子,并帮助我们摆脱将其视为“变更者”的思维模式——它们是与领域相关的事件,而不仅仅是某个对象状态的变化。
没有 PUT 的 REST 还有一个副作用,那就是分离命令和查询接口(CQRS),并迫使消费者允许最终一致性。我们将命令实体 POST 到一个端点(CQRS 的“C”),并从另一个端点(“Q”)GET 一个模型实体。进一步解释这个问题,可以设想一个管理 Customers 的 API。当我们要查看 Customer 的当前状态时,我们 GET Customer。当我们要更改 Customer 时,我们实际上是在 POST 一个“CustomerChange”资源。当我们通过 GET 查看客户时,这可能只是从与客户相关的变更事件系列中构建的当前状态的投影。或者也可能是我们有“CustomerChange”资源,实际上是它们在数据库中改变 Customer 的状态,在这种情况下,GET 是直接的数据库检索。所以没有 PUT 的 REST 并不意味着我们会自动获得 CQRS,但如果我们想要,它会非常容易实现。
总而言之,PUT 将过多的内部领域知识放在了客户端中,如前所述。客户端不应操作内部表示;它应该是用户意图的来源。另一方面,PUT 对许多简单的情况来说更容易实现,并且在库中有良好的支持。因此,决定需要在使用 PUT 的简单性与事件数据对业务的相关性之间取得平衡。
4 来自 GitHub 公共 API 的一个示例
GitHub API 是一个在公共领域内设计合理的 API 示例,其资源粒度恰到好处。例如,创建一个 fork 是一个异步操作。GitHub 支持 reified 的“Forks”子集合资源,可用于列出现有的 fork 或创建新的 fork。使用合并子集合资源执行代码“合并”是将“合并”概念和底层合并操作具象化的另一个示例。另一方面,GitHub 还支持许多低级资源,如Tag。大多数现实世界的 API 既需要粗粒度的聚合资源,也需要低级的“领域中的名词”资源。
5 结束
与其他所有事情一样,没有一种方法适用于所有情况。如前所述,在某些情况下,围绕低级资源构建的 API 可能完全可以。例如,获取银行账户余额信息,围绕“Account”资源构建的 API 已足够。另一方面,如果需要进行资金转账或获取银行对账单,API 需要围绕粗粒度的“Transactions”资源构建。同样,在许多情况下,对于低级领域资源使用 HTTP PUT 可能是合适且简单的。而在状态转换复杂且长时间运行,或者事件数据对业务有意义并值得捕获的情况下,使用 HTTP POST 针对用户/消费者“意图”资源会更合适。资源建模需要仔细考虑业务需求、技术考虑(干净的设计、可维护性等)和各种方法的成本效益分析,以使 API 设计带来最佳的消费者交互体验。
本文章转载微信公众号@JavaEdge