从达标到卓越 —— API 设计之道(上)
以下为下半部分:
卓越:系统性和大局观
不管是大到发布至业界,或小到在公司内跨部门使用,一组 API 一旦公开,整体上就是一个产品,而调用方就是用户。所谓牵一发而动全身,一个小细节可能影响整个产品的面貌,一个小改动也可能引发整个产品崩坏。因此,我们一定要站在全局的层面,甚至考虑整个技术环境,系统性地把握整个体系内 API 的设计,体现大局观。
版本控制
80% 的项目开发在版本控制方面做得都很糟糕:随心所欲的版本命名、空洞诡异的提交信息、毫无规划的功能更新……人们显然需要一段时间来培养规范化开发的风度,但是至少得先保证一件事情:
在大版本号不变的情况下,API 保证向前兼容。
这里说的「大版本号」即「语义化版本命名」..中的第一位位。
这一位的改动表明 API 整体有大的改动,很可能不兼容,因此用户对大版本的依赖改动会慎之又慎;反之,如果 API 有不兼容的改动,意味着必须修改大版本号,否则用户很容易出现在例行更新依赖后整个系统跑不起来的情况,更糟糕的情况则是引发线上故障。
如果这种情况得不到改善,用户们就会选择永远不升级依赖,导致更多的潜在问题。久而久之,最终他们便会弃用这些产品(库、中间件、whatever)。
所以,希望 API 的提供者们以后不会再将大版本锁定为0。更多关于「语义化版本」的内容,请参考我的另一篇文章《论版本号的正确打开方式》。
确保向下兼容
如果不希望对客户造成更新升级方面的困扰,我们首先要做好的就是确保 API 向下兼容。
API 发生改动,要么是需要提供新的功能,要么是为之前的糟糕设计买单……具体来说,改动无外乎:增加、删除、修改 三方面。
首先是删除。不要轻易删除公开发布的 API,无论之前写得多么糟糕。如果一定要删除,那么确保正确使用了「Deprecated」:
对于某个不想保留的可怜 API,先不要直接删除,将其标记为@deprecated后置入下一个小版本升级(比如从1.0.2到1.1.0)。
并且,在 changelog 中明确指出这些 API 即将移除(不推荐使用,但是目前仍然能用)。关于 changelog 的写法建议可参考更新日志的写法规范。
之后,在下一个大版本中(比如1.1.0到2.0.0)删除标记为@deprecated的部分,同时在 changelog 中指明它们已删除。
其次是 API 的修改。如果我们仅仅是修复 bug、重构实现、或者添加一些小特性,那自然没什么可说的;但是如果想彻底修改一个 API……比如重做入口参数、改写业务逻辑等等,建议的做法是:
确保原来的 API 符合「单一职责」原则,如果不是则修改之。
增加一个全新的 API 去实现新的需求!由于我们的 API 都遵循「单一职责」,因此一旦需要彻底修改 API,意味着新需求和原来的职责已经完全无法匹配,不如干脆新增一个 API。
视具体情况选择保留或移除旧 API,进入前面所述「删除 API」的流程。
最后是新增 API。事实上,即使是只加代码不删代码,整体也不一定是向下兼容的。有一个经典的正面案例是:
浏览器新增的一个 API,用以标记「当前文档是否可见」。直观的设计应该是新增document.visible这样的属性名……问题是,在逻辑上,文档默认是可见的,即document.visible默认为true,而不支持此新属性的旧浏览器返回document.visible == undefined,是个 falsy 值。因此,如果用户在代码中简单地以:
做特征检测的话,在旧浏览器中就会进入错误的条件分支……而反之,以document.hiddenAPI 来判断,则是向下兼容的。
设计扩展机制
毫无疑问,在保证向下兼容的同时,API 需要有一个对应的扩展机制以可持续发展 —— 一方面便于开发者自身增加功能,另一方面用户也能参与进来共建生态。
技术上来说,接口的扩展方式有很多,比如:继承(extend)、组合(mixin)、装饰(decorate)……选择没有对错,因为不同的扩展方式适用于不同的场景:在逻辑上确实存在派生关系,并且需要沿用基类行为同时自定义行为的,采用重量级的继承;仅仅是扩充一些行为功能,但是逻辑上压根不存在父子关系的,使用组合;而装饰手法更多应用于给定一个接口,将其包装成多种适用于不同场景新接口的情况……
另一方面,对于不同的编程语言来说,由于不同的语言特性……静态、动态等,各自更适合用某几种扩展方式。所以,到底采用什么扩展办法,还是得视情况而定。
在 JS 界,有一些经典的技术产品,它们的扩展甚至已经形成生态,如:
jQuery。耳熟能详的$.fn.customMethod = function() {};。这种简单的 mixin 做法已经为 jQuery 提供了成千上万的插件,而 jQuery 自己的大部分 API 本身也是基于这个写法构建起来的。
React。React 自身已经处理了所有有关组件实例化、生命周期、渲染和更新等繁琐的事项,只要开发者基于React.Component来继承出一个组件类。对于一个 component system 来说,这是一个经典的做法。
Gulp。相比于近两年的大热 Webpack,个人认为 Gulp 更能体现一个 building system 的逻辑 —— 定义各种各样的「任务」,然后用「管道」将它们串起来。一个 Gulp 插件也是那么的纯粹,接受文件流,返回文件流,如是而已。
Koa。对于主流的 HTTP Server 来说,中间件的设计大同小异:接受上一个 request,返回一个新的 response。而对天生 Promise 化的 Koa 来说,它的中间件风格更接近于 Gulp 了,区别仅在于一个是 file stream,一个是 HTTP stream。
不只是庞大的框架需要考虑扩展性,设计可扩展的 API 应该变成一种基本的思维方式。比如这个活生生的业务例子:
根据不同的类型渲染一组 feeds 信息:商品模块、店铺模块,或是其他。某天新增了需求说要支持渲染天猫的店铺模块(多显示个天猫标等等),于是 JSON 接口直接新增一个type = 'tmallShop'—— 这种接口改法很简单直观,但是并不好。在不改前端代码的情况下,tmallShop类型默认进入default分支,导致奇奇怪怪的渲染结果。
考虑到tmallShop和shop之间是一个继承的关系,tmallShop完全可以当一个普通的shop来用,执行后者的所有逻辑。用 Java 的表达方式来说就是:
将这个逻辑关系反映到 JSON 接口中,合理的做法是新增一个subType字段,用来标记tmallShop,而它的type仍然保持为shop。这样一来,即使原来的前端代码完全不修改,仍然可以正常运行,除了无法渲染出一些天猫店铺的特征。
这里还有一个非常类似的正面案例,是 ABS 搭建系统(淘宝 FED 出品的站点搭建系统)设计的模块 JSON Schema:
同样采用了type为主类型,而扩展字段在这里变成了format,用来容纳一些扩展特性。在实际开发中,的确也很方便新增各种新的数据结构逻辑。
控制 API 的抽象级别
API 能扩展的前提是什么?是接口足够抽象。这样才能够加上各种具体的定语、装饰更多功能。用日常语言举个例子:
所以,在设计 API 时要高抽象,不要陷入具体的实现,不要陷入具体的需求,要高屋建瓴。
看个实际的案例:一个类 React Native 的页面框架想暴露出一个事件「滚动到第二屏」,以便页面开发者能监听这个事件,从而更好地控制页面资源的加载策略(比如首屏默认加载渲染、到第二屏之后再去加载剩下的资源)。
但是因为一些实现上的原因,页面框架还不能通过页面位移(offset)来精确地通知「滚动到了第二屏」,而只能判断「第二屏的第一个模块出现了」。于是这个事件没有被设计为secondScreenReached,而变成了secondScreenFirstModuleAppear……虽然secondScreenFirstModuleAppear不能精确定义secondScreenReached,但是直接暴露这个具体的 API 实在太糟糕了,问题在于:
用户在依赖一个非常非常具体的 API,给用户造成了额外的信息负担。「第二屏的第一个模块出现了!」这很怪异,用户根本不关心模块的事情,用户关心的只是他是否到达了第二屏。
一旦页面框架能够真正通过页面位移来实现「滚动到第二屏」,如果我们暴露的是高抽象的secondScreenReached,那么只需要更改一下这个接口的具体实现即可;反之,我们暴露的是很具体的secondScreenFirstModuleAppear,就只能挨个通知用户:「你现在可以不用依赖这个事件了,改成我们新出的secondScreenReached吧!」
是的,抽象级别一般来说越高越好,将 API 设计成业务无关的,更通用,而且方便扩展。但是物极必反,对于像我这样的抽象控来说,最好能学会控制接口的抽象级别,将其保持在一个恰到好处的层次上,不要做无休止的抽象。
还是刚才的例子secondScreenReached,我们还可以将其抽象成targetScreenReached,可以支持到达首屏、到达第二屏、第三屏……的事件,这样是不是更灵活、更优雅呢?并没有 ——
抽象时一定要考虑到具体的业务需求场景,有些实现路径如果永远不可能走到,就没必要抽出来。比如这个例子中,没有人会去关心第三屏、第四屏的事件。
太高的抽象容易造成太多的层次,带来额外的耦合、通信等不同层次之间的沟通成本,这将会成为新的麻烦。对用户而言,也是额外的信息负担。
对于特定的业务来说,接口越抽象越通用,而越具体则越能解决特定问题。所以,思考清楚,API 面向的场景范围,避免懒惰设计,避免过度设计。
收敛 API 集
对于一整个体系的 API 来说,用户面对的是这个整体集合,而不是其中某几个单一的 API。我们要保证集合内的 API 都在一致的抽象维度上,并且适当地合并 API,减小整个集合的信息量,酌情做减法。
产品开始做减法,便是对用户的温柔。
收敛近似意义的参数和局部变量。下面这样的一组 API 好像没什么不对,但是对强迫症来说一定产生了不祥的直觉:
又是index又是tabIndex的,或许还会有pageIndex?诚然,函数形参和局部变量的命名对最终用户来说没有直接影响,但是这些不一致的写法仍然能反映到 API 文档中,并且,对开发者自身也会产生混淆。所以,选一个固定的命名风格,然后从一而终!如果忘了的话,回头看一下前文「固化术语表」这一节吧!
收敛近似职责的函数。对用户暴露出太多的接口不是好事,但是一旦要合并不同的函数,是否就会破坏「单一职责」原则呢?
不,因为「单一职责」本身也要看具体的抽象层次。以下这个例子和前文「合理运用函数重载」中的例子有相似之处,但具体又有所不同。
类似于这样,避免暴露过多近似的 API,合理利用抽象将其合并,减小对用户的压力。
对于一个有清晰继承树的场景来说,收敛 API 显得更加自然且意义重大 —— 利用多态性(Polymorphism)构建 Consistent APIs。(以下例子来源于Clean Code JS。)
有一个将 API 收敛到极致的家伙恐怕大家都不会陌生:jQuery 的$()。这个风格不正是 jQuery 当年的杀手级特性之一吗?
如果$()能让我搞定这件事,就不要再给我foo()和bar()。
收敛近似功能的包。再往上一级,我们甚至可以合并相近的 package。
淘宝 FED 的 Rax 体系(类 RN 框架)中,有基础的组件标签,如
(in @ali/rax-components)、(in @ali/rax-components),也有一些增强功能的 package,如(in @ali/rax-picture)、(in @ali/rax-spmlink)。
need-to-insert-img
在这里,后者包之于前者相当于装饰了更多功能,是前者的增强版。而在实际应用中,也是推荐使用诸如而禁止使用
。那么在这种大环境下,
need-to-insert-img
等基础 API 的暴露就反而变得很扰民。可以考虑将增强包的功能完全合并入基础组件,即将并入
need-to-insert-img
,用户只需面对单一的、标准的组件 API。
need-to-insert-img
发散 API 集
这听上去很荒谬,为什么一个 API 集合又要收敛又要发散?仅仅是为了大纲上的对称性吗?
当然不是。存在这个小节是因为我有一个不得不提的案例,不适合放在其他段落,只能放在这里……不,言归正传,我们有时的确需要发散 API 集,提供几个看似接近的 API,以引导用户。因为 —— 虽然这听起来很荒谬 —— 某些情况下,API 其实不够用,但是用户没有意识到 API 不够用,而是选择了混用、滥用。看下面这个例子:
在重构一组代码时,我看到代码里充斥着requestAnimationFrame(),这是一个比较新的全局 API,它会以接近 60 FPS 的速率延时执行一个传入的函数,类似于一个针对特定场景优化过的setTimeout(),但它的初衷是用来绘制动画帧的,而不应该用在奇奇怪怪的场景中。
在深入地了解了代码逻辑之后,我认识到这里如此调用是为了「延时一丢丢执行一些操作」,避免阻塞主渲染线程。然而这种情况下,还不如直接调用setTimeout()来做延时操作。虽然没有太明确的语义,但是至少好过把自己伪装成一次动画的绘制。更可怕的是,据我所知requestAnimationFrame()的滥用不仅出现在这次重构的代码中,我至少在三个不同的库见过它的身影 —— 无一例外地,这些库和动画并没有什么关系。
(一个可能的推断是,调用requestAnimationFrame(callback)时不用指定timeout毫秒数,而setTimeout(callback, timeout)是需要的。似乎对很多用户来说,前者的调用方式更 cool?)
所以,在市面上有一些 API 好像是「偏方」一般的存在:虽然不知道为什么要这么用,但是……用它就对了!
事实上,对于上面这个场景,最恰当的解法是使用一个更加新的 API,叫做requestIdleCallback(callback)。这个 API 从名字上看起来就很有语义:在线程空闲的时候再执行操作。这完全契合上述场景的需求,而且还自带底层的优化。
当然,由于 API 比较新,还不是所有的平台都能支持。即便如此,我们也可以先面向接口编程,自己做一个 polyfill:
另一个经典的滥用例子是 ES2015 中的「Generator / yield」。
原本使用场景非常有限的生成器 Generator 机制被大神匠心独运地加以改造,包装成用来异步代码同步化的解决方案。这种做法自然很有创意,但是从语义用法上来说实在不足称道,让代码变得非常难读,并且带来维护隐患。与其如此,还不如仅仅使用 Promise。
令人欣慰的是,随后新版的 ES 即提出了新的异步代码关键字「async / await」,真正在语法层面解决了异步代码同步化的问题,并且,新版的 Node.js 也已经支持这种语法。
因此,我们作为 API 的开发者,一定要提供足够场景适用的 API,来引导我们的用户,不要让他们做出一些出人意料的「妙用」之举。
制定 API 的支持策略
我们说,一组公开的 API 是产品。而产品,一定有特定的用户群,或是全球的开发者,或仅仅是跨部门的同事;产品同时有保质期,或者说,生命周期。
面向目标用户群体,我们要制定 API 的支持策略:
每一个大版本的支持周期是多久。
是否有长期稳定的 API 支持版本。(Long-term Support)
如何从旧版本升级。
老旧版本很可能还在运行,但维护者已经没时间精力再去管这些历史遗物,这时明确地指出某些版本不再维护,对开发者和用户都好。当然,同时别忘了给出升级文档,指导老用户如何迁移到新版本。还有一个更好的做法是,在我们开启一个新版本之际,就确定好上一个版本的寿命终点,提前知会到用户。
还有一个技术上的注意事项,那就是:大版本间最好有明确的隔离。对于一个复杂的技术产品来说,API 只是最终直接面向用户的接口,背后还有特定的环境、工具组、依赖包等各种支撑,互相之间并不能混用。
比如,曾经的经典前端库 KISSY。在业界技术方案日新月异的大潮下,KISSY 6 版本已经强依赖了 TNPM(阿里内网的 NPM)、DEF 套件组(淘宝 FED 的前端工具套件),虽然和之前的 1.4 版本相比 API 的变化并不大,但是仍然不能在老环境下直接使用 6 版本的代码库……这一定程度上降低了自由组合的灵活度,但事实上随着业务问题场景的复杂度提升,解决方案本身会需要更定制化,因此,将环境、工具等上下游关联物随代码一起打包,做成一整个技术方案,这正是业界的现状。
所以,隔离大版本,制定好 API 支持策略,让我们的产品更专业,让用户免去后顾之忧。
总结
以上,便是我从业以来感悟到的一些「道」,三个进阶层次、几十个细分要点,不知有没有给读者您带来一丁点启发。
但实际上,大道至简。我一直认为,程序开发和平时的说话写字其实没有太大区别,无非三者 ——
逻辑和抽象。
领域知识。
语感。
写代码,就像写作,而设计 API 好比列提纲。勤写、勤思,了解前人的模式、套路,学习一些流行库的设计方法,掌握英语、提高语感……相信大家都能设计出卓越的 API。
最后,附上 API 设计的经典原则:
Think about future, design with flexibility, but only implement for production.
作者:嘀咕哟
网友评论