新技术层出不穷,长江后浪推前浪。在浪潮褪去后,能留下来的,是一些经典的设计思想。
在前端界,以前有远近闻名的 jQuery,近来有声名鹊起的 Vue.js。这两者叫好又叫座的原因固然有很多,但是其中有一个共同特质不可忽视,那便是它们的 API 设计 非常优雅。
因此这次我想来谈个大课题 —— API 设计之道。
讨论内容的定义域
本文并不是《jQuery API 赏析》,当我们谈论 API 的设计时,不只局限于讨论「某个框架应该如何设计暴露出来的方法」。作为程序世界分治复杂逻辑的基本协作手段,广义的 API 设计涉及到我们日常开发中的方方面面。
最常见的 API 暴露途径是函数声明(Function Signature),以及属性字段(Attributes);而当我们涉及到前后端 IO 时,则需要关注通信接口的数据结构(JSON Schema);如果还有异步的通信,那么事件(Events)或消息(Message)如何设计也是个问题;甚至,依赖一个包(Package)的时候,包名本身就是接口,你是否也曾碰到过一个奇葩的包名而吐槽半天?
总之,「API 设计」不只关乎到框架或库的设计者,它和每个开发者息息相关。
提纲挈领
有一个核心问题是,我们如何评判一个 API 的设计算「好」?在我看来,一言以蔽之,易用。
那「易用」又是什么呢?我的理解是,只要能够足够接近人类的日常语言和思维,并且不需要引发额外的大脑思考,那就是易用。
Don't make me think.
具体地,我根据这些年来碰到的大量(反面和正面)案例,归纳出以下这些要点。按照要求从低到高的顺序如下:
- 达标:词法和语法
- 正确拼写
- 准确用词
- 注意单复数
- 不要搞错词性
- 处理缩写
- 用对时态和语态
- 进阶:语义和可用性
- 单一职责
- 避免副作用
- 合理设计函数参数
- 合理运用函数重载
- 使返回值可预期
- 固化术语表
- 遵循一致的 API 风格
- 卓越:系统性和大局观
- 版本控制
- 确保向下兼容
- 设计扩展机制
- 控制 API 的抽象级别
- 收敛 API 集
- 发散 API 集
- 制定 API 的支持策略
(本文主要以 JavaScript 作为语言示例。)
达标:词法和语法
高级语言和自然语言(英语)其实相差无几,因此正确地使用(英语的)词法和语法是程序员最基本的素养。而涉及到 API 这种供用户调用的代码时,则尤其重要。
但事实上,由于亚洲地区对英语的掌握能力普遍一般……所以现实状况并不乐观 —— 如果以正确使用词法和语法作为达标的门槛,很多 API 都没能达标。
正确拼写
正确地拼写一个单词是底线,这一点无需赘述。然而 API 中的各种错别字现象仍屡见不鲜,即使是在我们阿里这样的大公司内。
曾经有某个 JSON 接口(mtop)返回这样一组店铺数据,以在前端模板中渲染:
// json
[
{
"shopBottom": {
"isTmall": "false",
"shopLevel": "916",
"shopLeveImg": "//xxx.jpg"
}
}
]
乍一看平淡无奇,结果我调试了小半天都没能渲染出店铺的「店铺等级标识图片」,即 shopLevelImg
字段。问题到底出在了哪里?
眼细的朋友可能已经发现,接口给的字段名是 shopLeveImg
,少了一个 l
,而在其后字母 I
的光辉照耀下,肉眼很难分辨出这个细节问题。
拼错单词的问题真的是太普遍了,再比如:
- 某个叫做
toast
的库,package.json 中的 name 写成了taost
。导致在 npm 中没能找到这个包。 - 某个跑马灯组件,工厂方法中的一个属性错将
panel
写成了pannel
。导致以正确的属性名初始化时代码跑不起来。 - 某个 url(www.ruanyifeng.com/blog/2017/01/entainment.html)中错将
entertainment
写成了entainment
……这倒没什么大影响,只是 url 发布后就改不了了,留下了错别字不好看。 - ……
注意到,这些拼写错误经常出现在 字符串 的场景中。不同于变量名,IDE 无法检查字符串中的单词是否科学、是否和一些变量名一致,因此,我们在对待一些需要公开出去的 API 时,需要尤其注意这方面的问题;另一方面,更认真地注意 IDE 的 typo 提示(单词拼写错误提示),也会对我们产生很大帮助。
准确用词
我们知道,中英文单词的含义并非一一对应,有时一个中文意思可以用不同的英文单词来解释,这时我们需要选择使用恰当的准确的词来描述。
比如中文的「消息」可以翻译为 message、notification、news 等。虽然这几个不同的单词都可以有「消息」的意思,但它们在用法和语境场景上存在着细微差异:
- message:一般指双方通信的消息,是内容载体。而且经常有来有往、成对出现。比如
postMessage()
和receiveMessage()
。 - notification:经常用于那种比较短小的通知,现在甚至专指 iOS / Android 那样的通知消息。比如
new NotificationManager()
。 - news:内容较长的新闻消息,比 notification 更重量级。比如
getTopNews()
。 - feed:自从 RSS 订阅时代出现的一个单词,现在 RSS 已经日薄西山,但是 feed 这个词被用在了更多的地方。其含义只可意会不可言传。比如
fetchWeitaoFeeds()
。
所以,即使中文意思大体相近,也要准确地用词,从而让读者更易理解 API 的作用和 上下文场景。
有一个正面案例,是关于 React 的。(在未使用 ES2015 的)React 中,有两个方法叫做:
React.createClass({
getDefaultProps: function() {
// return a dictionary
},
getInitialState: function() {
// return a dictionary either
}
});
它们的作用都是用来定义初始化的组件信息,返回值的类型也都一样,但是在方法名上却分别用了 default
和 initial
来修饰,为什么不统一为一个呢?
原因和 React 的机制有关:
props
是指 Element 的属性,要么是不存在某个属性值后来为它赋值,要么是存在属性的默认值后来将其覆盖。所以这种行为,default
是合理的修饰词。state
是整个 Component 状态机中的某一个特定状态,既然描述为了状态机,那么状态和状态之间是互相切换的关系。所以对于初始状态,用initial
来修饰。
就这么个小小的细节,就可一瞥 React 本身的机制,足以体现 API 设计者的智慧。
另外,最近我还碰到了这样一组事件 API:
// event name 1
page.emit('pageShowModal');
// event name 2
page.emit('pageCloseModal');
这两个事件显然是一对正反义的动作,在上述案例中,表示「显示窗口」时使用了 show
,表示「关闭窗口」时使用了 close
,这都是非常直觉化的直译。而事实上,成对出现的词应该是:show & hide
、open & close
。
因此这里必须强调:成对出现的正反义词不可混用。在程序世界经常成对出现的词还有:
- in & out
- on & off
- previous & next
- forward & backward
- success & failure
- ...
总之,我们可以试着扩充英语的词汇量,使用合适的词,这对我们准确描述 API 有很大的帮助。
注意单复数
所有涉及到诸如数组(Array)、集合(Collection)、列表(List)这样的数据结构,在命名时都要使用复数形式:
var shopItems = [
// ...
];
export function getShopItems() {
// return an array
}
// fail
export function getShopItem() {
// unless you really return a non-array
}
现实往往出人意表地糟糕,前不久刚改一个项目,我就碰到了这样的写法:
class MarketFloor extends Component {
state = {
item: [
{}
]
};
}
这里的 item
实为一个数组,即使它内部只有一个成员。因此应该命名为 items
或 itemList
,无论如何,不应该是表示单数的 item
。
同时要注意,在复数的风格上保持一致,要么所有都是 -s
,要么所有都是 -list
。
反过来,我们在涉及到诸如字典(Dictionary)、表(Map)的时候,不要使用复数!
// fail
var EVENT_MAPS = {
MODAL_WILL_SHOW: 'modalWillShow',
MODAL_WILL_HIDE: 'modalWillHide',
// ...
};
虽然这个数据结构看上去由很多 key-value 对组成,是个类似于集合的存在,但是「map」本身已经包含了这层意思,不需要再用复数去修饰它。
不要搞错词性
另外一个容易犯的低级错误是搞错词性,即命名时拎不清名词、动词、形容词……
asyncFunc({
success: function() {},
fail: function() {}
});
success
算是一个在程序界出镜率很高的词了,但是有些同学会搞混,把它当做动词来用。在上述案例中,成对出现的单词其词性应该保持一致,这里应该写作 succeed
和 fail
;当然,在这个语境中,最好遵从惯例,使用名词组合 success
和 failure
。
这一对词全部的词性如下:
- n. 名词:success, failure
- v. 动词:succeed, fail
- adj. 形容词:successful, failed(无形容词,以过去分词充当)
- adv. 副词:successfully, fail to do sth.(无副词,以不定式充当)
注意到,如果有些词没有对应的词性,则考虑变通地采用其他形式来达到同样的意思。
所以,即使我们大部分人都知道:方法命名用动词、属性命名用名词、布尔值类型用形容词(或等价的表语),但由于对某些单词的词性不熟悉,也会导致最终的 API 命名有问题,这样的话就很尴尬了。
处理缩写
关于词法最后一个要注意的点是缩写。有时我们经常会纠结,首字母缩写词(acronym)如 DOM
、SQL
是用大写还是小写,还是仅首字母大写,在驼峰格式中又该怎么办……
对于这个问题,简单不易混淆的做法是,首字母缩写词的所有字母均大写。(如果某个语言环境有明确的业界惯例,则遵循惯例。)
// before
export function getDomNode() {}
// after
export function getDOMNode() {}
在经典前端库 KISSY 的早期版本中,DOM
在 API 中都命名为 dom
,驼峰下变为 Dom
;而在后面的版本内统一写定为全大写的 DOM
。
另外一种缩写的情况是对长单词简写(shortened word),如 btn (button)
、chk (checkbox)
、tpl (template)
。这要视具体的语言规范 / 开发框架规范而定。如果什么都没定,也没业界惯例,那么把单词写全了总是不会错的。
用对时态和语态
由于我们在调用 API 时一般类似于「调用一条指令」,所以在语法上,一个函数命名是祈使句式,时态使用一般现在时。
但在某些情况下,我们需要使用其他时态(进行时、过去时、将来时)。比如,当我们涉及到 生命周期、事件节点。
在一些组件系统中,必然涉及到生命周期,我们来看一下 React 的 API 是怎么设计的:
export function componentWillMount() {}
export function componentDidMount() {}
export function componentWillUpdate() {}
export function componentDidUpdate() {}
export function componentWillUnmount() {}
React 划分了几个关键的生命周期节点(mount, update, unmount, ...),以将来时和过去时描述这些节点片段,暴露 API。注意到一个小细节,React 采用了 componentDidMount
这种过去时风格,而没有使用 componentMounted
,从而跟 componentWillMount
形成对照组,方便记忆。
同样地,当我们设计事件 API 时,也要考虑使用合适的时态,特别是希望提供精细的事件切面时。或者,引入 before
、after
这样的介词来简化:
// will render
Component.on('beforeRender', function() {});
// now rendering
Component.on('rendering', function() {});
// has rendered
Component.on('afterRender', function() {});
另一方面是关于语态,即选用主动语态和被动语态的问题。其实最好的原则就是 尽量避免使用被动语态。因为被动语态看起来会比较绕,不够直观,因此我们要将被动语态的 API 转换为主动语态。
写成代码即形如:
// passive voice, make me confused
object.beDoneSomethingBy(subject);
// active voice, much more clear now
subject.doSomething(object);
进阶:语义和可用性
说了那么多词法和语法的注意点,不过才是达标级别而已。确保 API 的可用性和语义才使 API 真正「可用」。
无论是友好的参数设置,还是让人甜蜜蜜的语法糖,都体现了程序员的人文关怀。
单一职责
单一职责是软件工程中一条著名的原则,然而知易行难,一是我们对于具体业务逻辑中「职责」的划分可能存在难度,二是部分同学仍没有养成贯彻此原则的习惯。
小到函数级别的 API,大到整个包,保持单一核心的职责都是很重要的一件事。
// fail
component.fetchDataAndRender(url, template);
// good
var data = component.fetchData(url);
component.render(data, template);
如上,将混杂在一个大坨函数中的两件独立事情拆分出去,保证函数(function)级别的职责单一。
更进一步地,(假设)fetchData
本身更适合用另一个类(class)来封装,则对原来的组件类 Component
再进行拆分,将不属于它的取数据职责也分离出去:
class DataManager {
fetchData(url) {}
}
class Component {
constructor() {
this.dataManager = new DataManager();
}
render(data, template) {}
}
// more code, less responsibility
var data = component.dataManager.fetchData(url);
component.render(data, template);
在文件(file)层面同样如此,一个文件只编写一个类,保证文件的职责单一(当然这对很多语言来说是天然的规则)。
最后,视具体的业务关联度而决定,是否将一簇文件做成一个包(package),或是拆成多个。
避免副作用
严格「无 副作用 的编程」几乎只出现在纯函数式程序中,现实中的 OOP 编程场景难免触及副作用。因此在这里所说的「避免副作用」主要指的是:
- 函数本身的运行稳定可预期。
- 函数的运行不对外部环境造成意料外的污染。
对于无副作用的纯函数而言,输入同样的参数,执行后总能得到同样的结果,这种幂等性使得一个函数无论在什么上下文中运行、运行多少次,最后的结果总是可预期的 —— 这让用户非常放心,不用关心函数逻辑的细节、考虑是否应该在某个特定的时机调用、记录调用的次数等等。希望我们以后设计的 API 不会出现这个案例中的情况:
// return x.x.x.1 while call it once
this.context.getSPM();
// return x.x.x.2 while call it twice
this.context.getSPM();
在这里,getSPM()
用来获取每个链接唯一的 SPM 码(SPM 是阿里通用的埋点统计方案)。但是用法却显得诡异:每调用一次,就会返回一个不同的 SPM 串,于是当我们需要获得几个 SPM 时,就会这样写:
var spm1 = this.context.getSPM();
var spm2 = this.context.getSPM();
var spm3 = this.context.getSPM();
虽然在实现上可以理解 —— 此函数内部维护了一个计数器,每次返回一个自增的 SPM D 位,但是 这样的实现方式与这个命名看似是幂等的 getter 型函数完全不匹配,换句话说,这使得这个 API 不可预期。
如何修改之?一种做法是,不改变此函数内部的实现,而是将 API 改为 Generator 式的风格,通过形如 SPMGenerator.next()
接口来获取自增的 SPM 码。
另一种做法是,如果要保留原名称,可以将函数签名改为 getSPM(spmD)
,接受一个自定义的 SPM D 位,然后返回整个 SPM 码。这样在调用时也会更明确。
除了函数内部的运行需可预期外,它对外部一旦造成不可预期的污染,那么影响将更大,而且更隐蔽。
对外部造成污染一般是两种途径:一是在函数体内部直接修改外部作用域的变量,甚至全局变量;二是通过修改实参间接影响到外部环境,如果实参是引用类型的数据结构。
曾经也有发生因为对全局变量操作而导致整个容器垮掉的情况,这里就不再展开。
如何防止此类副作用发生?本质上说,需要控制读写权限。比如:
- 模块沙箱机制,严格限定模块对外部作用域的修改;
- 对关键成员作访问控制(access control),冻结写权限等等。
合理设计函数参数
对一个函数来说,「函数签名」(Function Signature)比函数体本身更重要。函数名、参数设置、返回值类型,这三要素构成了完整的函数签名。而其中,参数设置对用户来说是接触最频繁,也最为关心的部分。
那如何优雅地设计函数的入口参数呢?我的理解是这样几个要点:
优化参数顺序。相关性越高的参数越要前置。
这很好理解,相关性越高的参数越重要,越要在前面出现。其实这还有两个隐含的意思,即 可省略的参数后置,以及 为可省略的参数设定缺省值。对某些语言来说(如 C++),调用的时候如果想省略实参,那么一定要为它定义缺省值,而带缺省值的参数必须后置,这是在编译层面就规定死的。而对另一部分灵活的语言来说(如 JS),将可省参数后置同样是最佳实践。
// bad
function renderPage(pageIndex, pageData) {}
renderPage(0, {});
renderPage(1, {});
// good
function renderPage(pageData, pageIndex = 0) {}
renderPage({});
renderPage({}, 1);
第二个要点是控制参数个数。用户记不住过多的入口参数,因此,参数能省略则省略,或更进一步,合并同类型的参数。
由于可以方便地创建 Object 这种复合数据结构,合并参数的这种做法在 JS 中尤为普遍。常见的情况是将很多配置项都包成一个配置对象:
// traditional
$.ajax(url, params, success);
// or
$.ajax({
url,
params,
success,
failure
});