[转] 阿里研究员谷朴:API 设计最佳实践的思考

API是软件系统的核心,而软件系统的复杂度Complexity是大规模软件系统可否成功最重要的因素。但复杂度Complexity并不是某一个单独的问题能彻底败坏的,而是在系统设计尤为是API设计层面不少不少小的设计考量一点点叠加起来的(也即John Ousterhout老爷子说的Complexity is incremental【8】)。成功的系统不是有一些特别闪光的地方,而是设计时点点滴滴的努力积累起来的。css

所以,这里咱们试图思考并给出建议,一方面,什么样的API设计是__好__的设计?另外一方面,在设计中如何能作到?前端

API设计面临的挑战千差万别,很难有到处适用的准则,因此在讨论原则和最佳实践时,不管这些原则和最佳实践是什么,必定有适应的场景和不适应的场景。所以咱们在下面争取不只提出一些建议,也尽可能去分析这些建议在什么场景下适用,这样咱们也能够有针对性的采起例外的策略。java

范围

本文偏重于__通常性的API设计__,__并更适用于远程调用(RPC或者HTTP/RESTful的API)__,可是这里没有特别讨论RESTful API特有的一些问题。数据库

另外,本文在讨论时,假定了客户端直接和远程服务端的API交互。在阿里,因为多种缘由,经过客户端的SDK来间接访问远程服务的状况更多一些。这里并不讨论SDK带来的特殊问题,可是将SDK提供的方法看做远程API的代理,这里的讨论仍然适用。json

API设计准则:什么是好的API

在这一部分,咱们试图总结一些好的API应该拥有的特性,或者说是设计的原则。这里咱们试图总结更加基础性的原则。所谓基础性的原则,是那些若是咱们很好的遵照了就可让API在以后演进的过程当中避免多数设计问题的原则。后端

A good API设计模式

  • __提供清晰的思惟模型 provides a good mental model__:API是用于程序之间的交互,可是一个API如何被使用,以及API自己如何被维护,是依赖于维护者和使用者可以对该API有清晰的、一致的认识。这种情况其实是不容易达到的。
  • __简单 is simple__:“Make things as simple as possible, but no simpler.” 在实际的系统中,尤为是考虑到系统随着需求的增长不断的演化,咱们绝大多数状况下见到的问题都是__过于复杂__的设计,而非过于简单,所以强调简单性通常是恰当的。
  • __允许多个实现 allows multiple implementations__:这个原则看上去更具体,可是这是我很是喜欢的一个原则。这是Sanjay Ghemawat经常提到的一个原则。通常来讲,在讨论API设计时经常被提到的原则是解耦性原则或者说松耦合原则。然而相比于松耦合原则,这个原则更加有可操做性:若是一个API自身能够有多个__彻底不一样的实现__,通常来讲这个API已经有了足够好的抽象,和自身的某一个具体实现无关,那么通常也不会出现和外部系统耦合过紧的问题。所以这个原则更本质一些。

最佳实践

本部分则试图讨论一些更加详细、具体的建议,可让API的设计更容易知足前面描述的基础原则。api

想一想优秀的API例子:POSIX File API缓存

若是说API的设计实践只能列一条的话,那么可能最有帮助的和最可操做的就是这一条。本文也能够叫作“经过File API体会API设计的最佳实践”。安全

因此整个最佳实践能够总结为一句话:“想一想File API是怎么设计的。”

首先回顾一下File API的主要接口(以C为例,不少是Posix API,选用比较简单的I/O接口为例【1】:

int open(const char *path, int oflag, .../*,mode_t mode */); int close (int filedes); int remove( const char *fname ); ssize_t write(int fildes, const void *buf, size_t nbyte); ssize_t read(int fildes, void *buf, size_t nbyte);

File API为何是经典的好API设计?

  • File API已经有几十年历史(从1988年算起将近40年),尽管期间硬件软件系统的发展经历了好几代,这套API核心保持了稳定。这是极其了不得的。
  • API提供了很是清晰的概念模型,每一个人都可以很快理解这套API背后的基础概念:什么是文件,以及相关联的操做(open, close, read, write),清晰明了;
  • 支持不少的不一样文件系统实现,这些系统实现甚至于属于类型很是不一样的设备,例如磁盘、块设备、管道(pipe)、共享内存、网络、终端terminal等等。这些设备有的是随机访问的,有的只支持顺序访问;有的是持久化的有的则不是。然而全部不一样的设备不一样的文件系统实现均可以采用了一样的接口,使得上层系统没必要关注底层实现的不一样,这是这套API强大的生命力的表现。

例如一样是打开文件的接口,底层实现彻底不一样,可是经过彻底同样的接口,不一样的路径以及Mount机制,实现了同时支持。其余还有Procfs, pipe等。

int open(const char *path, int oflag, .../*,mode_t mode */);

例如这里的cephfs和本地文件系统,底层对应彻底不一样的实现,可是上层client能够不用区分对待,采用一样的接口来操做,只经过路径不一样来区分。

基于上面的这些缘由,咱们知道File API为何可以如此成功。事实上,它是如此的成功以致于今天的*-nix操做系统,everything is filed based.

尽管咱们有了一个很是好的例子File API,可是__要设计一个可以长期保持稳定的API是一项及其困难的事情__,所以仅有一个好的参考还不够,下面再试图展开去讨论一些更细节的问题。

Document well 写详细的文档

写详细的文档,并保持更新。 关于这一点,其实无需赘述,现实是,不少API的设计和维护者不重视文档的工做。

在一个面向服务化/Micro-service化架构的今天,一个应用依赖大量的服务,而每一个服务API又在不断的演进过程当中,__准确的记录每一个字段和每一个方法,而且保持更新__,对于减小客户端的开发踩坑、减小出问题的概率,提高总体的研发效率相当重要。

Carefully define the "resource" of your API 仔细的定义“资源”

若是适合的话,选用“资源”加操做的方式来定义。今天不少的API均可以采用这样一个抽象的模式来定义,这种模式有不少好处,也适合于HTTP的RESTful API的设计。可是在设计API时,一个重要的前提是对Resource自己进行合理的定义。什么样的定义是合理的?Resource资源自己是对一套API操做核心对象的一个抽象Abstraction。

抽象的过程是__去除细节的过程__。在咱们作设计时,若是现实世界的流程或者操做对象是具体化的,抽象的Object的选择可能不那么困难,可是对于哪些细节应该包括,是须要不少思考的。例如对于文件的API,能够看出,文件File这个Resource(资源)的抽象,是“能够由一个字符串惟一标识的数据记录”。这个定义去除了文件是如何标识的(这个问题留给了各个文件系统的具体实现),也去除了关于如何存储的组织结构(again,留给了存储系统)细节。

虽然咱们但愿API简单,可是更重要的是__选择对的实体来建模__。在底层系统设计中,咱们倾向于更简单的抽象设计。有的系统里面,域模型自己的设计每每不会这么简单,须要更细致的考虑如何定义Resource。通常来讲,域模型中的概念抽象,若是能和现实中的人们的体验接近,会有利于人们理解该模型。__选择对的实体来建模__每每是关键。结合域模型的设计,能够参考相关的文章,例如阿白老师的文章【2】。

Choose the right level of abstraction 选择合适的抽象层

与前面的一个问题密切相关的,是在定义对象时须要选择合适的Level of abstraction(抽象的层级)。不一样概念之间每每相互关联。仍然以File API为例。在设计这样的API时,选择抽象的层级的可能的选项有多个,例如:

  • 文本、图像混合对象
  • “数据块” 抽象
  • ”文件“抽象

这些不一样的层级的抽象方式,可能描述的是同一个东西,可是在概念上是不一样层面的选择。当设计一个API用于与数据访问的客户端交互时,“文件File“是更合适的抽象,而设计一个API用于文件系统内部或者设备驱动时,数据块或者数据块设备多是合适的抽象,当设计一个文档编辑工具时,可能会用到“文本图像混合对象”这样的文件抽象层级。

又例如,数据库相关的API定义,底层的抽象可能针对的是数据的存储结构,中间是数据库逻辑层须要定义数据交互的各类对象和协议,而在展现(View layer)的时候须要的抽象又有不一样【3】。

Prefer using different model for different layers 不一样层建议采用不一样的数据模型

这一条与前一条密切关联,可是强调的是不一样层之间模型不一样。

在服务化的架构下,数据对象在处理的过程当中每每经历多层,例如上面的View-Logic model-Storage是典型的分层结构。在这里咱们的建议是不一样的Layer采用不一样的数据结构。John Ousterhout 【8】书里面则更直接强调:Different layer, different abstraction。

例如网络系统的7层模型,每一层有本身的协议和抽象,是个典型的例子。而前面的文件API,则是一个Logic layer的模型,而不一样的文件存储实现(文件系统实现),则采用各自独立的模型(如快设备、内存文件系统、磁盘文件系统等各自有本身的存储实现API)。

当API设计倾向于不一样的层采用同样的模型的时候(例如一个系统使用后段存储服务与自身提供的模型之间,见下图),可能意味着这个Service自己的职责没有定义清楚,是否功能其实应该下沉?

不一样的层采用一样的数据结构带来的问题还在于API的演进和维护过程。一个系统演进过程当中可能须要替换掉后端的存储,可能由于性能优化的关系须要分离缓存等需求,这时会发现将两个层的数据绑定一块儿(甚至有时候直接把前端的json存储在后端),会带来没必要要的耦合而阻碍演进。

Naming and identification of the resource 命名与标识

当API定义了一个资源对象,下面通常须要的是提供命名/标识(Naming and identification)。在naming/ID方面,通常有两个选择(不是指系统内部的ID,而是会暴露给用户的):

  • 用free-form string做为ID(string nameAsId)
  • 用结构化数据表达naming/ID

什么时候选择哪一个方法,须要具体分析。采用Free-form string的方式定义的命名,为系统的具体实现留下了最大的自由度。带来的问题是命名的内在结构(如路径)自己并不是API强制定义的一部分,转为变成实现细节。若是命名自己存在结构,客户端须要有提取结构信息的逻辑。这是一个须要作的平衡。

例如文件API采用了free-form string做为文件名的标识方式,而文件的URL则是文件系统具体实现规定。这样,就允许Windows操做系统采用"D:\Documents\File.jpg"而Linux采用"/etc/init.d/file.conf"这样的结构了。而若是文件命名的数据结构定义为

{
   disk: string, path: string }

这样结构化的方式,透出了"disk""path"两个部分的结构化数据,那么这样的结构可能适应于Windows的文件组织方式,而不适应于其余文件系统,也就是说泄漏了实现细节。

若是资源Resource对象的抽象模型天然包含结构化的标识信息,则采用结构化方式会简化客户端与之交互的逻辑,强化概念模型。这时牺牲掉标识的灵活度,换取其余方面的优点。例如,银行的转帐帐号设计,能够表达为

{
   account: number routing: number }

这样一个结构化标识,由帐号和银行间标识两部分组成,这样的设计含有必定的业务逻辑在内,可是这部分业务逻辑是__被描述的系统内在逻辑而非实现细节__,而且这样的设计可能有助于具体实现的简化以及避免一些非结构化的字符串标识带来的安全性问题等。所以在这里结构化的标识可能更适合。

另外一个相关的问题是,__什么时候应该提供一个数字unique ID?__ 这是一个常常遇到的问题。有几个问题与之相关须要考虑:

  • 是否已经有结构化或者字符串的标识能够惟1、稳定标识对象?若是已经有了,那么就不必定须要numerical ID;
  • 64位整数范围够用吗?
  • 数字ID可能不是那么用户友好,对于用户来说数字的ID会有帮助吗?

若是这些问题都有答案并且不是什么阻碍,那么使用数字ID是能够的,__不然要慎用数字ID__。

Conceptually what are the meaningful operations on this resource? 对于该对象来讲,什么操做概念上是合理的?

在肯定下来了资源/对象之后,咱们还须要定义哪些操做须要支持。这时,考虑的重点是“__概念上合理(Conceptually reasonable)__”。换句话说,operation + resource 连在一块儿听起来天然而然合理(若是Resource自己命名也比较准确的话。固然这个“若是命名准确”是个big if,很是不容易作到)。操做并不老是CRUD(create, read, update, delete)。

例如,一个API的操做对象是额度(Quota),那么下面的操做听上去就比较天然:

  • Update quota(更新额度),transfer quota(原子化的转移额度)

可是若是试图Create Quota,听上去就不那么天然,因额度这样一个概念彷佛表达了一个数量,概念上不须要建立。额外须要思考一下,这个对象是否真的须要建立?咱们真正须要作的是什么?

For update operations, prefer idempotency whenever feasible 更新操做,尽可能保持幂等性

Idempotency幂等性,指的是一种操做具有的性质,具备这种性质的操做能够被屡次实施而且不会影响到初次实施的结果“the property of certain operations in mathematics and computer science whereby they can be applied multiple times without changing the result beyond the initial application.”【3】

很明显Idempotency在系统设计中会带来不少便利性,例如客户端能够更安全的重试,从而让复杂的流程实现更为简单。可是Idempotency实现并不老是很容易。

  • Create类型的idempotency
    建立的Idempotency,屡次调用容易出现重复建立,为实现幂等性,常见的作法是使用一个__client-side generated de-deduplication token(客户端生成的惟一ID)__,在反复重试时使用同一个Unique ID,便于服务端识别重复。
  • Update类型的Idempotency
    更新值(update)类型的API,应该避免采用"Delta"语义,以便于实现幂等性。对于更新类的操做,咱们再简化为两类实现方式

    • Incremental(数量增减),如IncrementBy(3)这样的语义
    • SetNewTotal(设置新的总量)
`IncrementBy` 这样的语义重试的时候难以免出错,而`SetNewTotal(3)`(总量设置为x)语义则比较容易具有幂等性。 固然在这个例子里面,也须要看到,`IncrementBy`也有有点,即多个客户请求同时增长的时候,比较容易并行处理,而`SetTotal`可能致使并行的更新相互覆盖(或者相互阻塞)。 这里,能够认为*更新增量*和*设置新的总量*这两种语义是不一样的优缺点,须要根据场景来解决。若是必须优先考虑并发更新的情景,可使用*更新增量*的语义,并辅助以Deduplication token解决幂等性。 
  • __Delete类型idempotency__:Delete的幂等性问题,每每在于一个对象被删除后,再次试图删除可能会因为数据没法被发现致使出错。这个行为通常来讲也没什么问题,虽然严格意义上不幂等,可是也无反作用。若是须要实现Idempotency,系统也采用了Archive->Purge生命周期的方式分步删除,或者持久化Purge log的方式,都能支持幂等删除的实现。

Compatibility 兼容

API的变动须要兼容,兼容,兼容!重要的事情说三遍。这里的兼容指的是向后兼容,而兼容的定义是不会Break客户端的使用,也即__老的客户端可否正常访问服务端的新版本(若是是同一个大版本下)不会有错误的行为__。这一点对于远程的API(HTTP/RPC)尤为重要。关于兼容性,已经有很好的总结,例如【4】提供的一些建议。

常见的__不兼容__变化包括(但不限于)

  • 删除一个方法、字段或者enum的数值
  • 方法、字段更名
  • 方法名称字段不改,可是语义和行为的变化,也是不兼容的。这类比较容易被忽视。

    更具体描述能够参加【4】。

另外一个关于兼容性的重要问题是,__如何作不兼容的API变动__?一般来讲,不兼容变动须要经过一个__Deprecation process,在大版本发布时来分步骤实现__。关于Deprecation process,这里不展开描述,通常来讲,须要保持过去版本的兼容性的前提下,支持新老字段/方法/语义,并给客户端足够的升级时间。这样的过程比较耗时,也正是由于如此,咱们才须要如此重视API的设计。

有时,一个面向内部的API升级,每每开发的同窗倾向于选择高效率,采用一种叫”同步发布“的模式来作不兼容变动,即通知已知的全部的客户端,本身的服务API要作一个不兼容变动,你们一块儿发布,同时更新,切换到新的接口。这样的方法是很是不可取的,缘由有几个:

  • 咱们常常并不知道全部使用API的客户
  • 发布过程须要时间,没法真正实现“同步更新”
  • 不考虑向后兼容性的模式,一旦新的API有问题须要回滚,则会很是麻烦,这样的计划八成也不会有回滚方案,并且客户端未必都能跟着回滚。

所以,对于在生产集群已经获得应用的API,强烈不建议采用“同步升级”的模式来处理不兼容API变动。

Batch mutations 批量更新

批量更新如何设计是另外一个常见的API设计决策。这里咱们常见有两种模式:

  • 客户端批量更新,或者
  • 服务端实现批量更新。

    以下图所示。

API的设计者可能会但愿实现一个服务端的批量更新能力,可是咱们建议要尽可能避免这样作。__除非对于客户来讲提供原子化+事务性的批量颇有意义(all-or-nothing)__,不然实现服务端的批量更新有诸多的弊端,而客户端批量更新则有优点:

  • 服务端批量更新带来了API语义和实现上的复杂度。例如当部分更新成功时的语义、状态表达等
  • 即便咱们但愿支持批量事物,也要考虑到是否不一样的后端实现都能支持事务性
  • 批量更新每每给服务端性能带来很大挑战,也容易被客户端滥用接口
  • 在客户端实现批量,能够更好的将负载由不一样的服务端来承担(见图)
  • 客户端批量能够更灵活的由客户端决定失败重试策略

Be aware of the risks in full replace 警戒全体替换更新模式的风险

所谓Full replacement更新,是指在Mutation API中,用一个全新的Object/Resource去替换老的Object/Resource的模式。API写出来大概是这样的

UpdateFoo(Foo newFoo);

这是很是常见的Mutation设计模式。可是这样的模式有一些潜在的风险做为API设计者必须了解。

使用Full replacement的时候,更新对象Foo在服务端可能已经有了新的成员,而客户端还没有更新并不知道该新成员。服务端增长一个新的成员通常来讲是兼容的变动,可是,若是该成员以前被另外一个知道这个成员的client设置了值,而这时一个不知道这个成员的client来作full-replace,该成员可能就会被覆盖。

更安全的更新方式是采用Update mask,也即在API设计中引入明确的参数指明哪些成员应该被更新。

UpdateFoo { Foo newFoo; boolen update_field1; // update mask boolen update_field2; // update mask }

或者update mask能够用repeated "a.b.c.d“这样方式来表达。

不过因为这样的API方式维护和代码实现都复杂一些,采用这样模式的API并很少。因此,本节的标题是“be aware of the risk“,而不是要求必定要用update mask。

Don't create your own error codes or error mechanism 不要试图建立本身的错误码和返回错误机制

API的设计者有时很想建立本身的Error code,或者是表达返回错误的不一样机制,由于每一个API都有不少的细节的信息,设计者想表达出来并返回给用户,想着“用户可能会用到”。可是事实上,这么作常常只会使API变得更复杂更难用。

Error-handling是用户使用API很是重要的部分。为了让用户更容易的使用API,最佳的实践应该是用标准、统一的Error Code,而不是每一个API本身去创立一套。例如HTTP有规范的error code 【7】,Google Could API设计时都采用统一的Error code等【5】。

为何不建议本身建立Error code机制?

  • Error-handling是客户端的事,而对于客户端来讲,是很难关注到那么多错误的细节的,通常来讲最多分两三种状况处理。每每客户端最关心的是"这个error是否应该重试(retryable)"仍是应该继续向上层返回错误,而不是试图区分不一样的error细节。这时多样的错误代码机制只会让处理变得复杂
  • 有人以为提供更多的自定义的error code有助于传递信息,可是这些信息除非有系统分别处理才有意义。若是只是传递信息的话,error message里面的字段能够达到一样的效果。

More

更多的Design patterns,能够参考[5] Google Cloud API guide,[6] Microsoft API design best practices等。很多这里提到的问题也在这些参考的文档里面有涉及,另外他们还讨论到了像versioning,pagination,filter等常见的设计规范方面考虑。这里再也不重复。

参考文献

【1】File wiki https://en.wikipedia.org/wiki/Computer_file
【2】阿白,域模型设计系列文章,https://yq.aliyun.com/articles/6383
【3】Idempotency, wiki https://en.wikipedia.org/wiki/Idempotence
【4】Compatibility https://cloud.google.com/apis/design/compatibility
【5】API Design patterns for Google Cloud, https://cloud.google.com/apis/design/design_patterns
【6】API design best practices, Microsoft https://docs.microsoft.com/en-us/azure/architecture/best-practices/api-design
【7】Http status code https://en.wikipedia.org/wiki/List_of_HTTP_status_codes【8】A philosophy of software design, John Ousterhout

相关文章
相关标签/搜索