架构设计我是程序员阿里云

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

2018-12-29  本文已影响37人  阿里云云栖号

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

因此,这里我们试图思考并给出建议,一方面,什么样的API设计是的设计?另一方面,在设计中如何能做到?

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

范围

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

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

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

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

A good 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设计?

例如同样是打开文件的接口,底层实现完全不同,但是通过完全一样的接口,不同的路径以及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的方式定义的命名,为系统的具体实现留下了最大的自由度。带来的问题是命名的内在结构(如路径)本身并非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? 这是一个经常遇到的问题。有几个问题与之相关需要考虑:

如果这些问题都有答案而且不是什么阻碍,那么使用数字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),那么下面的操作听上去就比较自然:

但是如果试图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实现并不总是很容易。

Compatibility 兼容

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

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

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

有时,一个面向内部的API升级,往往开发的同学倾向于选择高效率,采用一种叫”同步发布“的模式来做不兼容变更,即通知已知的所有的客户端,自己的服务API要做一个不兼容变更,大家一起发布,同时更新,切换到新的接口。这样的方法是非常不可取的,原因有几个:

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

Batch mutations 批量更新

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

API的设计者可能会希望实现一个服务端的批量更新能力,但是我们建议要尽量避免这样做。除非对于客户来说提供原子化+事务性的批量很有意义(all-or-nothing),否则实现服务端的批量更新有诸多的弊端,而客户端批量更新则有优势:

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机制?

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




本文作者:jessie筱姜

阅读原文

本文为云栖社区原创内容,未经允许不得转载。

上一篇下一篇

猜你喜欢

热点阅读