网站开发能封装成app吗,建设酒店网站ppt,做平面设计的网站,太原工程建设信息网站API是软件系统的核心#xff0c;而软件系统的复杂度Complexity是大规模软件系统能否成功最重要的因素。但复杂度Complexity并非某一个单独的问题能完全败坏的#xff0c;而是在系统设计尤其是API设计层面很多很多小的设计考量一点点叠加起来的#xff08;也即John Ousterhou…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
__提供清晰的思维模型 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例子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作为IDstring 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: numberrouting: number
}
这样一个结构化标识由账号和银行间标识两部分组成这样的设计含有一定的业务逻辑在内但是这部分业务逻辑是__被描述的系统内在逻辑而非实现细节__并且这样的设计可能有助于具体实现的简化以及避免一些非结构化的字符串标识带来的安全性问题等。因此在这里结构化的标识可能更适合。
另一个相关的问题是__何时应该提供一个数字unique ID?__ 这是一个经常遇到的问题。有几个问题与之相关需要考虑
是否已经有结构化或者字符串的标识可以唯一、稳定标识对象如果已经有了那么就不一定需要numerical ID64位整数范围够用吗数字ID可能不是那么用户友好对于用户来讲数字的ID会有帮助吗
如果这些问题都有答案而且不是什么阻碍那么使用数字ID是可以的__否则要慎用数字ID__。
Conceptually what are the meaningful operations on this resource? 对于该对象来说什么操作概念上是合理的
在确定下来了资源/对象以后我们还需要定义哪些操作需要支持。这时考虑的重点是“__概念上合理(Conceptually reasonable)__”。换句话说operation resource 连在一起听起来自然而然合理如果Resource本身命名也比较准确的话。当然这个“如果命名准确”是个big if非常不容易做到。操作并不总是CRUDcreate, 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 这样的语义重试的时候难以避免出错而SetNewTotal3总量设置为x语义则比较容易具备幂等性。 当然在这个例子里面也需要看到IncrementBy也有有点即多个客户请求同时增加的时候比较容易并行处理而SetTotal可能导致并行的更新相互覆盖或者相互阻塞。 这里可以认为更新增量和设置新的总量这两种语义是不同的优缺点需要根据场景来解决。如果必须优先考虑并发更新的情景可以使用更新增量的语义并辅助以Deduplication token解决幂等性。 __Delete类型idempotency__Delete的幂等性问题往往在于一个对象被删除后再次试图删除可能会由于数据无法被发现导致出错。这个行为一般来说也没什么问题虽然严格意义上不幂等但是也无副作用。如果需要实现Idempotency系统也采用了Archive-Purge生命周期的方式分步删除或者持久化Purge log的方式都能支持幂等删除的实现。
Compatibility 兼容
API的变更需要兼容兼容兼容重要的事情说三遍。这里的兼容指的是向后兼容而兼容的定义是不会Break客户端的使用也即__老的客户端能否正常访问服务端的新版本如果是同一个大版本下不会有错误的行为__。这一点对于远程的APIHTTP/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 maskboolen update_field2; // update mask
}
或者update mask可以用repeated a.b.c.d“这样方式来表达。
不过由于这样的API方式维护和代码实现都复杂一些采用这样模式的API并不多。所以本节的标题是“be aware of the risk“而不是要求一定要用update mask。
Dont 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等。不少这里提到的问题也在这些参考的文档里面有涉及另外他们还讨论到了像versioningpaginationfilter等常见的设计规范方面考虑。这里不再重复。
参考文献
【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 原文链接 本文为云栖社区原创内容未经允许不得转载。