导读: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 在之后演进的过程中避免多数设计问题的原则。
为什么这一点重要?因为 API 的设计本身最关键的难题并不是让客户端与服务端软件之间如何交互,而是设计者、维护者、API 使用者这几个程序员群体之间在 API 生命周期内的互动。一个 API 如何被使用,以及 API 本身如何被维护,是依赖于维护者和使用者能够对该 API 有清晰的、一致的认识。这非常依赖于设计者提供了一个清晰易于理解的模型。这种状况实际上是不容易达到的。
就像下图所示,设计者心中有一个模型,而使用者看到和理解的模型可能是另一个模式,这个模式如果比较复杂的话,使用者使用的方式又可能与自己理解的不完全一致。 对于维护者来说,问题是类似的。
而好的 API 让维护者和使用者能够很容易理解到设计时要传达的模型。带来理解、调试、测试、代码扩展和系统维护性的提升 。
“Make things as simple as possible, but no simpler.” 在实际的系统中,尤其是考虑到系统随着需求的增加不断地演化,我们绝大多数情况下见到的问题都是过于复杂的设计,在 API 中引入了过多的实现细节(见下一条),同时也有不少的例子是 Oversimplification 引起的,一些不该被合并的改变合并了,导致设计很不合理。
过于简单化的例子:过去曾经见过一个系统,将一个用户的资源账户模型的 account balance 和 transactions 都简化为用 transactions 一个模型来表达,逻辑在于 account balance 可以由历史的 transactions 累计得到。但是这样的过于简化的模型设计带来了很多的问题,尤其在引入分期付款、预约交易等概念之后,暴露了很多复杂的逻辑给一些只需要获取简单信息的客户端(如计算这个用户是否还有足够的余额交易变得和很多业务逻辑耦合),属于典型的模型过度简化带来的设计复杂度上升的案例。
这个原则看上去更具体,也是我非常喜欢的一个原则。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 是一项及其困难的事情,因此仅有一个好的参考还不够,下面再试图展开去讨论一些更细节的问题。
写详细的文档,并保持更新。 关于这一点,其实无需赘述,现实是,很多 API 的设计和维护者不重视文档的工作。
在一个面向服务化 /Micro-service 化架构的今天,一个应用依赖大量的服务,而每个服务 API 又在不断的演进过程中,准确的记录每个字段和每个方法,并且保持更新,对于减少客户端的开发踩坑、减少出问题的几率,提升整体的研发效率至关重要。
如果适合的话,选用“资源”加操作的方式来定义。今天很多的 API 都可以采用这样一个抽象的模式来定义,这种模式有很多好处,也适合于 HTTP 的 RESTful API 的设计。但是在设计 API 时,一个重要的前提是对 Resource 本身进行合理的定义。什么样的定义是合理的? Resource 资源本身是对一套 API 操作核心对象的一个抽象 Abstraction。
抽象的过程是**去除细节的过程。**在我们做设计时,如果现实世界的流程或者操作对象是具体化的,抽象的 Object 的选择可能不那么困难,但是对于哪些细节应该包括,是需要很多思考的。例如对于文件的 API,可以看出,文件 File 这个 Resource(资源)的抽象,是“可以由一个字符串唯一标识的数据记录”。这个定义去除了文件是如何标识的(这个问题留给了各个文件系统的具体实现),也去除了关于如何存储的组织结构(again,留给了存储系统)细节。
虽然我们希望 API 简单,但是更重要的是选择对的实体来建模。在底层系统设计中,我们倾向于更简单的抽象设计。有的系统里面,域模型本身的设计往往不会这么简单,需要更细致的考虑如何定义 Resource。一般来说,域模型中的概念抽象,如果能和现实中的人们的体验接近,会有利于人们理解该模型。选择对的实体来建模往往是关键。结合域模型的设计,可以参考相关的文章,例如阿白老师的文章【2】。
与前面的一个问题密切相关的,是在定义对象时需要选择合适的 Level of abstraction (抽象的层级)。不同概念之间往往相互关联。仍然以 File API 为例。在设计这样的 API 时,选择抽象的层级的可能的选项有多个,例如:
这些不同的层级的抽象方式,可能描述的是同一个东西,但是在概念上是不同层面的选择。当设计一个 API 用于与数据访问的客户端交互时,“文件 File “是更合适的抽象,而设计一个 API 用于文件系统内部或者设备驱动时,数据块或者数据块设备可能是合适的抽象,当设计一个文档编辑工具时,可能会用到“文本图像混合对象”这样的文件抽象层级。
又例如,数据库相关的 API 定义,底层的抽象可能针对的是数据的存储结构,中间是数据库逻辑层需要定义数据交互的各种对象和协议,而在展示(View layer)的时候需要的抽象又有不同【3】。
当 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 reasonable)”。换句话说,operation + resource 连在一起听起来自然而然合理(如果 Resource 本身命名也比较准确的话。当然这个“如果命名准确”是个 big if,非常不容易做到)。操作并不总是 CRUD(create, read, update, delete)。
例如,一个 API 的操作对象是额度(Quota ),那么下面的操作听上去就比较自然:
但是如果试图 Create Quota,听上去就不那么自然,因额度这样一个概念似乎表达了一个数量,概念上不需要创建。额外需要思考一下,这个对象是否真的需要创建?我们真正需要做的是什么?
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 解决幂等性。
API 的变更需要兼容,兼容,兼容!重要的事情说三遍。这里的兼容指的是向后兼容,而兼容的定义是不会 Break 客户端的使用,也即**老的客户端能否正常访问服务端的新版本(如果是同一个大版本下)不会有错误的行为。**这一点对于远程的 API(HTTP/RPC)尤其重要。关于兼容性,已经有很好的总结,例如【4】提供的一些建议。
常见的不兼容变化包括(但不限于):
另一个关于兼容性的重要问题是,**如何做不兼容的 API 变更?**通常来说,不兼容变更需要通过一个 Deprecation process,在大版本发布时来分步骤实现。关于 Deprecation process,这里不展开描述,一般来说,需要保持过去版本的兼容性的前提下,支持新老字段 / 方法 / 语义,并给客户端足够的升级时间。这样的过程比较耗时,也正是因为如此,我们才需要如此重视 API 的设计。
有时,一个面向内部的 API 升级,往往开发的同学倾向于选择高效率,采用一种叫”同步发布“的模式来做不兼容变更,即通知已知的所有的客户端,自己的服务 API 要做一个不兼容变更,大家一起发布,同时更新,切换到新的接口。这样的方法是非常不可取的,原因有几个:
因此,对于在生产集群已经得到应用的 API,强烈不建议采用“同步升级”的模式来处理不兼容 API 变更。
批量更新如何设计是另一个常见的 API 设计决策。这里我们常见有两种模式:
API 的设计者可能会希望实现一个服务端的批量更新能力,但是我们建议要尽量避免这样做。除非对于客户来说提供原子化 + 事务性的批量很有意义(all-or-nothing),否则实现服务端的批量更新有诸多的弊端,而客户端批量更新则有优势:
所谓 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。
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 等常见的设计规范方面考虑。这里不再重复。