作者:谷朴
来源:阿里系统软件技术
因此,这里我们试图思考并给出建议,一方面,什么样的 API 设计是好的设计?另一方面,在设计中如何能做到?
API 设计面临的挑战千差万别,很难有处处适用的准则,所以在讨论原则和最佳实践时,无论这些原则和最佳实践是什么,一定有适应的场景和不适应的场景。因此我们在下面争取不仅提出一些建议,也尽量去分析这些建议在什么场景下适用,这样我们也可以有针对性的采取例外的策略。
范围
本文偏重于一般性的 API 设计,并更适用于远程调用(RPC 或者 HTTP/RESTful 的 API),但是这里没有特别讨论 RESTful API 特有的一些问题。
另外,本文在讨论时,假定了客户端直接和远程服务端的 API 交互。在阿里,由于多种原因,通过客户端的 SDK 来间接访问远程服务的情况更多一些。这里并不讨论 SDK 带来的特殊问题,但是将 SDK 提供的方法看作远程 API 的代理,这里的讨论仍然适用。
在这一部分,我们试图总结一些好的 API 应该拥有的特性,或者说是设计的原则。这里我们试图总结更加基础性的原则。所谓基础性的原则,是那些如果我们很好的遵守了就可以让 API 在之后演进的过程中避免多数设计问题的原则。
提供清晰的思维模型 (A good API provides a good mental model):API 是用于程序之间的交互,但是一个 API 如何被使用,以及 API 本身如何被维护,是依赖于维护者和使用者能够对该 API 有清晰的、一致的认识。这种状况实际上是不容易达到的;
简单 (A good API is simple):“Make things as simple as possible, but no simpler.” 在实际的系统中,尤其是考虑到系统随着需求的增加不断地演化,我们绝大多数情况下见到的问题都是过于复杂的设计,而非过于简单,因此强调简单性一般是恰当的;
容许多个实现 (A good API allows multiple implementations):这个原则看上去更具体,但是这是我非常喜欢的一个原则。这是 Sanjay Ghemawat 常常提到的一个原则。一般来说,在讨论 API 设计时常常被提到的原则是解耦性原则或者说松耦合原则。然而相比于松耦合原则,这个原则更加有可操作性:如果一个 API 自身可以有多个完全不同的实现,一般来说这个 API 已经有了足够好的抽象,和自身的某一个具体实现无关,那么一般也不会出现和外部系统耦合过紧的问题。因此这个原则更本质一些。
最佳实践
本部分则试图讨论一些更加详细、具体的建议,可以让 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 是一项及其困难的事情,因此仅有一个好的参考还不够,下面再试图展开去讨论一些更细节的问题。
写详细的文档,并保持更新。 关于这一点,其实无需赘述,现实是,很多 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]。
这一条与前一条密切关联,但是强调的是不同层之间模型不同。
在服务化的架构下,数据对象在处理的过程中往往经历多层,例如上面的 View-Logic model-Storage 是典型的分层结构。在这里我们的建议是不同的 Layer 采用不同的数据结构。John Ousterhout [8] 书里面则更直接强调:Different layer, different abstraction。
例如网络系统的 7 层模型,每一层有自己的协议和抽象,是个典型的例子。而前面的文件 API,则是一个 Logic layer 的模型,而不同的文件存储实现(文件系统实现),则采用各自独立的模型(如快设备、内存文件系统、磁盘文件系统等各自有自己的存储实现 API)。
当 API 设计倾向于不同的层采用一样的模型的时候(例如一个系统使用后段存储服务与自身提供的模型之间,见下图),可能意味着这个 Service 本身的职责没有定义清楚,是否功能其实应该下沉?
不同的层采用同样的数据结构带来的问题还在于 API 的演进和维护过程。一个系统演进过程中可能需要替换掉后端的存储,可能因为性能优化的关系需要分离缓存等需求,这时会发现将两个层的数据绑定一起(甚至有时候直接把前端的 json 存储在后端),会带来不必要的耦合而阻碍演进。
当 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? 这是一个经常遇到的问题。有几个问题与之相关需要考虑:
是否已经有结构化或者字符串的标识可以唯一、稳定标识对象?如果已经有了,那么就不一定需要 numerical ID;
64 位整数范围够用吗?
数字 ID 可能不是那么用户友好,对于用户来讲数字的 ID 会有帮助吗?
如果这些问题都有答案而且不是什么阻碍,那么使用数字 ID 是可以的,否则要慎用数字 ID。
在确定下来了资源/对象以后,我们还需要定义哪些操作需要支持。这时,考虑的重点是“概念上合理(Conceptually reasonable)”。换句话说,operation + resource
连在一起听起来自然而然合理(如果 Resource 本身命名也比较准确的话。当然这个“如果命名准确”是个 big if,非常不容易做到)。操作并不总是 CRUD(create, read, update, delete)。
例如,一个 API 的操作对象是额度(Quota),那么下面的操作听上去就比较自然:
Update quota
(更新额度),transfer 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 实现并不总是很容易。
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 的方式,都能支持幂等删除的实现。
API 的变更需要兼容,兼容,兼容!重要的事情说三遍。这里的兼容指的是向后兼容,而兼容的定义是不会 Break 客户端的使用,也即老的客户端能否正常访问服务端的新版本(如果是同一个大版本下)不会有错误的行为。这一点对于远程的 API(HTTP/RPC)尤其重要。关于兼容性,已经有很好的总结,例如[4] 提供的一些建议。
常见的不兼容变化包括(但不限于)
删除一个方法、字段或者 enum 的数值;
方法、字段改名;
方法名称字段不改,但是语义和行为的变化,也是不兼容的。这类比较容易被忽视。更具体描述可以参加[4]。
另一个关于兼容性的重要问题是,如何做不兼容的 API 变更?通常来说,不兼容变更需要通过一个 Deprecation process,在大版本发布时来分步骤实现。关于 Deprecation process,这里不展开描述,一般来说,需要保持过去版本的兼容性的前提下,支持新老字段/方法/语义,并给客户端足够的升级时间。这样的过程比较耗时,也正是因为如此,我们才需要如此重视 API 的设计。
有时,一个面向内部的 API 升级,往往开发的同学倾向于选择高效率,采用一种叫“同步发布”的模式来做不兼容变更,即通知已知的所有的客户端,自己的服务 API 要做一个不兼容变更,大家一起发布,同时更新,切换到新的接口。这样的方法是非常不可取的,原因有几个:
我们经常并不知道所有使用 API 的客户;
发布过程需要时间,无法真正实现“同步更新”;
不考虑向后兼容性的模式,一旦新的 API 有问题需要回滚,则会非常麻烦,这样的计划八成也不会有回滚方案,而且客户端未必都能跟着回滚。
因此,对于在生产集群已经得到应用的 API,强烈不建议采用“同步升级”的模式来处理不兼容 API 变更。
批量更新如何设计是另一个常见的 API 设计决策。这里我们常见有两种模式:
客户端批量更新,或者
服务端实现批量更新。
如下图所示:
API 的设计者可能会希望实现一个服务端的批量更新能力,但是我们建议要尽量避免这样做。除非对于客户来说提供原子化+事务性的批量很有意义(all-or-nothing),否则实现服务端的批量更新有诸多的弊端,而客户端批量更新则有优势:
服务端批量更新带来了 API 语义和实现上的复杂度。例如当部分更新成功时的语义、状态表达等
即使我们希望支持批量事物,也要考虑到是否不同的后端实现都能支持事务性
批量更新往往给服务端性能带来很大挑战,也容易被客户端滥用接口
在客户端实现批量,可以更好的将负载由不同的服务端来承担(见图)
客户端批量可以更灵活的由客户端决定失败重试策略
所谓 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 机制?
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]https://en.wikipedia.org/wiki/Computer_file
[2]https://yq.aliyun.com/articles/6383
[3]https://en.wikipedia.org/wiki/Idempotence
[4]https://cloud.google.com/apis/design/compatibility
[5]https://cloud.google.com/apis/design/design_patterns
[6]https://docs.microsoft.com/en-us/azure/architecture/best-practices/api-design
[7]https://en.wikipedia.org/wiki/List_of_HTTP_status_codes
[8]A philosophy of software design, John Ousterhout
1.微信群:
添加小编微信:color_ld,备注“进群+姓名+公司职位”即可,加入【云计算学习交流群】,和志同道合的朋友们共同打卡学习!
2.征稿:
投稿邮箱:[email protected];微信号:color_ld。请备注投稿+姓名+公司职位。
推荐阅读
程序员抢票的正确姿势 ↓↓交朋友还能抢票?
为交流学习,请备注抢票+姓名+公司职位