阿里妹导读:API 是模块或者子系统之间交互的接口定义。好的系统架构离不开好的 API 设计,而一个设计不够完善的 API 则注定会致使系统的后续发展和维护很是困难。
接下来,阿里巴巴研究员谷朴将给出建议,什么样的 API 设计是好的设计?好的设计该如何作?程序员
做者简介:张瓅玶 (谷朴),阿里巴巴研究员,负责阿里云容器平台集群管理团队。本科和博士毕业于清华大学。数据库
API 设计面临的挑战千差万别,很难有到处适用的准则,因此在讨论原则和最佳实践时,不管这些原则和最佳实践是什么,必定有适应的场景和不适应的场景。所以咱们在下文中不只提出一些建议,也尽可能去分析这些建议在什么场景下适用,这样咱们也能够有针对性地采起例外的策略。后端
为何去讨论这些问题? API 是软件系统的核心,而软件系统的复杂度 Complexity 是大规模软件系统可否成功最重要的因素。但复杂度 Complexity 并不是某一个单独的问题能彻底败坏的,而是在系统设计尤为是API设计层面不少不少小的设计考量一点点叠加起来的(John Ousterhout老爷子说的Complexity is incremental【8】)。设计模式
成功的系统不是有一些特别闪光的地方,而是设计时点点滴滴的努力积累起来的。数组
本文偏重于通常性的API设计,并更适用于远程调用(RPC或者HTTP/RESTful的API),可是这里没有特别讨论RESTful API特有的一些问题。缓存
另外,本文在讨论时,假定了客户端直接和远程服务端的API交互。在阿里,因为多种缘由,经过客户端的 SDK 来间接访问远程服务的状况更多一些。这里并不讨论 SDK 带来的特殊问题,可是将 SDK 提供的方法看做远程 API 的代理,这里的讨论仍然适用。安全
在这一部分,咱们试图总结一些好的 API 应该拥有的特性,或者说是设计的原则。这里咱们试图总结更加基础性的原则。所谓基础性的原则,是那些若是咱们很好地遵照了就可让 API 在以后演进的过程当中避免多数设计问题的原则。网络
提供清晰的思惟模型 provides a good mental model数据结构
为何这一点重要?由于 API 的设计自己最关键的难题并非让客户端与服务端软件之间如何交互,而是设计者、维护者、API使用者这几个程序员群体之间在 API 生命周期内的互动。一个 API 如何被使用,以及API自己如何被维护,是依赖于维护者和使用者可以对该 API 有清晰的、一致的认识。这很是依赖于设计者提供了一个清晰易于理解的模型。这种情况其实是不容易达到的。架构
就像下图所示,设计者心中有一个模型,而使用者看到和理解的模型多是另外一个模式,这个模式若是比较复杂的话,使用者使用的方式又可能与本身理解的不彻底一致。 对于维护者来讲,问题是相似的。
而好的 API 让维护者和使用者可以很容易理解到设计时要传达的模型。带来理解、调试、测试、代码扩展和系统维护性的提高 。
图片来源:https://medium.com/@copyconstruct/effective-mental-models-for-code-and-systems-7c55918f1b3e
简单 is simple
“Make things as simple as possible, but no simpler.” 在实际的系统中,尤为是考虑到系统随着需求的增长不断地演化,咱们绝大多数状况下见到的问题都是过于复杂的设计,在 API 中引入了过多的实现细节(见下一条),同时也有很多的例子是Oversimplification 引发的,一些不应被合并的改变合并了,致使设计很不合理。
过于简单化的例子:过去曾经见过一个系统,将一个用户的资源帐户模型的 account balance 和 transactions 都简化为用 transactions 一个模型来表达,逻辑在于 account balance 能够由历史的 transactions 累计获得。可是这样的过于简化的模型设计带来了不少的问题,尤为在引入分期付款、预定交易等概念以后,暴露了不少复杂的逻辑给一些只须要获取简单信息的客户端(如计算这个用户是否还有足够的余额交易变得和不少业务逻辑耦合),属于典型的模型过分简化带来的设计复杂度上升的案例。
允许多个实现 allows multiple implementations
这个原则看上去更具体,也是我很是喜欢的一个原则。Sanjay Ghemawat 经常提到该原则。通常来讲,在讨论 API 设计时经常被提到的原则是解耦性原则或者说松耦合原则。然而相比于松耦合原则,这个原则更加有可核实性:若是一个 API 自身能够有多个彻底不一样的实现,通常来讲这个API已经有了足够好的抽象,那么通常也不会出现和外部系统耦合过紧的问题。所以这个原则更本质一些。
举个例子,好比咱们已经有一个简单的 API
QueryOrderResponse queryOrder(string orderQuery)
可是有场景需求但愿老是读取到最新更新数据,不接受缓存,因而工程师考虑。
QueryOrderResponse queryOrder(string orderQuery, boolean useCache)
增长一个字段 useCache 来判断如何处理这样的请求。
这样的改法看上去合理,但实际上泄漏了后端实现的细节(后端采用了缓存),后续若是采用一个新的不带缓存的后端存储实现,再支持这个 useCache 的字段就很尴尬了。
在工程中,这样的问题能够用不一样的服务实例来解决,经过不一样访问的 endpoint 配置来区分。
本部分则试图讨论一些更加详细、具体的建议,可让 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】。
Naming and identification of the resource 命名与标识
当 API 定义了一个资源对象,下面通常须要的是提供命名/标识( Naming and identification )。在 naming/ID 方面,通常有两个选择(不是指系统内部的 ID,而是会暴露给用户的):
什么时候选择哪一个方法,须要具体分析。采用 Free-form string 的方式定义的命名,为系统的具体实现留下了最大的自由度。带来的问题是命名的内在结构(如路径)自己并不是API强制定义的一部分,转为变成实现细节。若是命名自己存在结构,客户端须要有提取结构信息的逻辑,这是一个须要作的平衡。
例如文件 API 采用了 free-form string 做为文件名的标识方式,而文件的 URL 则是文件系统具体实现规定。这样,就允许 Windows 操做系统采用 "D:DocumentsFile.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 实现并不老是很容易。
IncrementBy 这样的语义重试的时候难以免出错,而 SetNewTotal(3)(总量设置为x)语义则比较容易具有幂等性。
固然在这个例子里面,也须要看到,IncrementBy 也有优势,即多个客户请求同时增长的时候,比较容易并行处理,而 SetTotal 可能致使并行的更新相互覆盖(或者相互阻塞)。
这里,能够认为 更新增量和_设置新的总量_这两种语义是不一样的优缺点,须要根据场景来解决。若是必须优先考虑并发更新的情景,可使用_更新增量_的语义,并辅助以 Deduplication token 解决幂等性。
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 机制?
更多的Design patterns,能够参考[5] Google Cloud API guide,[6] Microsoft API design best practices等。很多这里提到的问题也在这些参考的文档里面有涉及,另外他们还讨论到了像versioning,pagination,filter等常见的设计规范方面考虑。这里再也不重复。
本文来自云栖社区合做伙伴“ 阿里技术”,如需转载请联系原做者。