從達標到卓越 —— API 設計之道
新技術層出不窮,長江後浪推前浪。在浪潮褪去後,能留下來的,是一些經典的設計思想。
在前端界,以前有遠近聞名的 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
});
這樣做的好處是:
- 用戶雖然仍需記住參數名,但不用再關心參數順序。
- 不必擔心參數列表過長。將參數合並為字典這種結構後,想增加多少參數都可以,也不用關心需要將哪些可省略的參數後置的問題。
當然,凡事有利有弊,由於缺乏順序,就無法突出哪些是最核心的參數信息;另外,在設定參數的默認值上,會比參數列表的形式更繁瑣。因此,需要兼顧地使用最優的辦法來設計函數參數,為了同一個目的:易用。
合理運用函數重載
談到 API 的設計,尤其是函數的設計,總離不開一個機製:重載(overload)。
對於強類型語言來說,重載是個很 cool 的功能,能夠大幅減少函數名的數量,避免命名空間的汙染。然而對於弱類型語言而言,由於不需要在編譯時做 type-binding,函數在調用階段想怎麼傳實參都行……所以重載在這裏變得非常微妙。以下著重談一下,什麼時候該選擇重載,什麼時候又不該。
Element getElementById(String: id)
HTMLCollection getElementsByClassName(String: names)
HTMLCollection getElementsByTagName(String: name)
以上三個函數是再經典不過的 DOM API,而在當初學習它們的時候(從 Java 思維轉到 JS 思維)我就在想這兩個問題:
- 為什麼要設計成
getSomethingBySomething
這麼複雜結構的名字,而不是使用getSomething
做重載? - 這三個函數隻有
getElementById
是單數形式,為何不設計為返回 HTMLCollection(即使隻返回一個成員也可以包一個 Collection 嘛),以做成複數形式的函數名從而保持一致性?
兩個問題中,如果第二個問題能解決,那麼這三個函數的結構將完全一致,從而可以考慮解決第一個問題。
先來看問題二。稍微深入下 DOM 知識後就知道,id 對於整個 DOM 來說必須是唯一的,因此在理論上 getElementsById
(注意有複數)將永遠返回僅有 0 或 1 個成員的 Collection,這樣一來用戶的調用方式將始終是 var element = getElementsById(id)[0]
,而這是非常荒謬的。所以 DOM API 設計得沒問題。
既然問題二無解,那麼自然這三個函數沒法做成一個重載。退一步說,即使問題二能解決,還存在另外一個麻煩:它們的入口參數都是一樣的,都是 String!對於強類型語言來說,參數類型和順序、返回值統統一樣的情況下,壓根無法重載。因為編譯器無法通過任何一個有效的特征,來執行不同的邏輯!
所以,如果入口參數無法進行有效區分,不要選擇重載。
當然,有一種奇怪的做法可以繞過去:
// fail
function getElementsBy(byWhat, name) {
switch(byWhat) {
case 'className':
// ...
case 'tagName':
// ...
}
}
getElementsBy('tagName', name);
getElementsBy('className', name);
一種在風格上類似重載的,但實際是在運行時走分支邏輯的做法……可以看到,API 的信息總量並沒降低。不過話不能說死,這種風格在某些特定場景也有用武之地,隻是多數情況下並不推薦。
與上述風格類似的,是這樣一種做法:
// get elements by tag-name by default
HTMLCollection getElements(String: name)
// if you add a flag, it goes by class-name
HTMLCollection getElements(String: name, Boolean: byClassName)
「將 flag 標記位作為了重載手段」—— 在早期微軟的一些 API 中經常能見到這樣的寫法,可以說一旦離開了文檔就無法編碼,根本不明白某個 Boolean 標記位是用來幹嘛的,這大大降低了用戶的開發體驗,以及代碼可讀性。
這樣看起來,可重載的場景真是太少了!也不盡然,在我看來有一種場景很適合用重載:批量處理。
Module handleModules(Module: module)
Collection<Module> handleModules(Collection<Module>: modules)
當用戶經常麵臨處理一個或多個不確定數量的對象時,他可能需要思考和判斷,什麼時候用單數 handleModule
、什麼時候用複數 handleModules
。將這種類型的操作重載為一個(大抵見於 setter 型操作),同時支持單個和批量的處理,可以降低用戶的認知負擔。
所以,在合適的時機重載,否則寧願選擇「函數名結構相同的多個函數」。原則是一樣的,保證邏輯正確的前提下,盡可能降低用戶負擔。
對了,關於 getElements
那三個 API,它們最終的進化版本回到了同一個函數:querySelector(selectors)
。
使返回值可預期
函數的易用性體現在兩方麵:入口和出口。上麵已經講述了足夠多關於入口的設計事項,這一節講出口:函數返回值。
對於 getter 型的函數來說,調用的直接目的就是為了獲得返回值。因此我們要讓返回值的類型和函數名的期望保持一致。
// expect 'a.b.c.d'
function getSPMInString() {
// fail
return {
a, b, c, d
};
}
從這一點上來講,要慎用 ES2015 中的新特性「解構賦值」。
而對於 setter 型的函數,調用的期望是它能執行一係列的指令,然後去達到一些副作用,比如存文件、改寫變量值等等。因此絕大多數情況我們都選擇了返回 undefined / void —— 這並不總是最好的選擇。
回想一下,我們在調用操作係統的命令時,係統總會返回「exit code」,這讓我們能夠獲知係統命令的執行結果如何,而不必通過其他手段去驗證「這個操作到底生效了沒」。因此,創建這樣一種返回值風格,或可一定程度增加健壯性。
另外一個選項,是讓 setter 型 API 始終返回 this
。這是 jQuery 為我們帶來的經典啟示 —— 通過返回 this
,來產生一種「鏈式調用(chaining)」的風格,簡化代碼並且增加可讀性:
$('div')
.attr('foo', 'bar')
.data('hello', 'world')
.on('click', function() {});
最後還有一個異類,就是異步執行的函數。由於異步的特性,對於這種需要一定延時才能得到的返回值,隻能使用 callback 來繼續操作。使用 Promise 來包裝它們尤為必要。對異步操作都返回一個 Promise,使整體的 API 風格更可預期。
固化術語表
在前麵的詞法部分中曾經提到「準確用詞」,但即使我們已經盡量去用恰當的詞,在有些情況下仍然不免碰到一些難以抉擇的尷尬場景。
比如,我們經常會看到 pic 和 image、path 和 url 混用的情況,這兩組詞的意思非常接近(當然嚴格來說 path 和 url 的意義是明確不同的,在此暫且忽略),稍不留神就會產生 4 種組合……
- picUrl
- picPath
- imageUrl
- imagePath
- 更糟糕的情況是 imgUrl、picUri、picURL……
所以,在一開始就要 產出術語表,包括對縮寫詞的大小寫如何處理、是否有自定義的縮寫詞等等。一個術語表可以形如:
標準術語 | 含義 | 禁用的非標準詞 |
---|---|---|
pic | 圖片 | image, picture |
path | 路徑 | URL, url, uri |
on | 綁定事件 | bind, addEventListener |
off | 解綁事件 | unbind, removeEventListener |
emit | 觸發事件 | fire, trigger |
module | 模塊 | mod |
不僅在公開的 API 中要遵守術語表規範,在局部變量甚至字符串中都最好按照術語表來。
page.emit('pageRenderRow', {
index: this.props.index,
modList: moduleList
});
比如這個我最近碰到的案例,同時寫作了 modList
和 moduleList
,這就有點怪怪的。
另外,對於一些創造出來的、業務特色的詞匯,如果不能用英語簡明地翻譯,就直接用拚音:
- 淘寶
Taobao
- 微淘
Weitao
- 極有家
Jiyoujia
- ……
在這裏,千萬不要把「微淘」翻譯為 MicroTaobao
……當然,專有詞已經有英文名的除外,如 Tmall
。
遵循一致的 API 風格
這一節算得上是一個複習章節。詞法、語法、語義中的很多節都指向同一個要點:一致性。
一致性可以最大程度降低信息熵。
好吧,這句話不是什麼名人名言,就是我現編的。總而言之,一致性能大大降低用戶的學習成本,並對 API 產生準確的預期。
- 在詞法上,提煉術語表,全局保持一致的用詞,避免出現不同的但是含義相近的詞。
- 在語法上,遵循統一的語法結構(主謂賓順序、主被動語態),避免天馬行空的造句。
- 在語義上,合理運用函數的重載,提供可預期的甚至一致類型的函數入口和出口。
甚至還可以一致得更細節些,隻是舉些例子:
- 打 log 要麼都用中文,要麼都用英文。
- 異步接口要麼都用回調,要麼都改成 Promise。
- 事件機製隻能選擇其一:
object.onDoSomething = func
或object.on('doSomething', func)
。 - 所有的 setter 操作必須返回
this
。 - ……
一份代碼寫得再怎麼爛,把某個單詞都拚成一樣的錯誤,也好過這個單詞隻出現一次錯誤。
是的,一致性,再怎麼強調都不為過。
卓越:係統性和大局觀
不管是大到發布至業界,或小到在公司內跨部門使用,一組 API 一旦公開,整體上就是一個產品,而調用方就是用戶。所謂牽一發而動全身,一個小細節可能影響整個產品的麵貌,一個小改動也可能引發整個產品崩壞。因此,我們一定要站在全局的層麵,甚至考慮整個技術環境,係統性地把握整個體係內 API 的設計,體現大局觀。
版本控製
80% 的項目開發在版本控製方麵做得都很糟糕:隨心所欲的版本命名、空洞詭異的提交信息、毫無規劃的功能更新……人們顯然需要一段時間來培養規範化開發的風度,但是至少得先保證一件事情:
在大版本號不變的情況下,API 保證向前兼容。
這裏說的「大版本號」即「語義化版本命名」<major>.<minor>.<patch>
中的第一位 <major>
位。
這一位的改動表明 API 整體有大的改動,很可能不兼容,因此用戶對大版本的依賴改動會慎之又慎;反之,如果 API 有不兼容的改動,意味著必須修改大版本號,否則用戶很容易出現在例行更新依賴後整個係統跑不起來的情況,更糟糕的情況則是引發線上故障。
如果這種情況得不到改善,用戶們就會選擇 永遠不升級依賴,導致更多的潛在問題。久而久之,最終他們便會棄用這些產品(庫、中間件、whatever)。
所以,希望 API 的提供者們以後不會再將大版本鎖定為 0
。更多關於「語義化版本」的內容,請參考我的另一篇文章《論版本號的正確打開方式》。
確保向下兼容
如果不希望對客戶造成更新升級方麵的困擾,我們首先要做好的就是確保 API 向下兼容。
API 發生改動,要麼是需要提供新的功能,要麼是為之前的糟糕設計買單……具體來說,改動無外乎:增加、刪除、修改 三方麵。
首先是刪除。不要輕易刪除公開發布的 API,無論之前寫得多麼糟糕。如果一定要刪除,那麼確保正確使用了「Deprecated
」:
對於某個不想保留的可憐 API,先不要直接刪除,將其標記為 @deprecated
後置入下一個小版本升級(比如從 1.0.2
到 1.1.0
)。
/**
* @deprecated
*/
export function youWantToRemove(foo, bar) {}
/**
* This is the replacement.
*/
export function youWantToKeep(foo) {}
並且,在 changelog 中明確指出這些 API 即將移除(不推薦使用,但是目前仍然能用)。關於 changelog 的寫法建議可參考 更新日誌的寫法規範。
之後,在下一個 大版本 中(比如 1.1.0
到 2.0.0
)刪除標記為 @deprecated
的部分,同時在 changelog 中指明它們已刪除。
其次是 API 的修改。如果我們僅僅是修複 bug、重構實現、或者添加一些小特性,那自然沒什麼可說的;但是如果想徹底修改一個 API……比如重做入口參數、改寫業務邏輯等等,建議的做法是:
- 確保原來的 API 符合「單一職責」原則,如果不是則修改之。
- 增加一個全新的 API 去實現新的需求!由於我們的 API 都遵循「單一職責」,因此一旦需要徹底修改 API,意味著新需求和原來的職責已經完全無法匹配,不如幹脆新增一個 API。
- 視具體情況選擇保留或移除舊 API,進入前麵所述「刪除 API」的流程。
最後是新增 API。事實上,即使是隻加代碼不刪代碼,整體也不一定是向下兼容的。有一個經典的正麵案例是:
// modern browsers
document.hidden == false;
// out-of-date browsers
document.hidden == undefined;
瀏覽器新增的一個 API,用以標記「當前文檔是否可見」。直觀的設計應該是新增 document.visible
這樣的屬性名……問題是,在邏輯上,文檔默認是可見的,即 document.visible
默認為 true
,而不支持此新屬性的舊瀏覽器返回 document.visible == undefined
,是個 falsy 值。因此,如果用戶在代碼中簡單地以:
if (document.visible) {
// do some stuff
}
做特征檢測的話,在舊瀏覽器中就會進入錯誤的條件分支……而反之,以 document.hidden
API 來判斷,則是向下兼容的。
設計擴展機製
毫無疑問,在保證向下兼容的同時,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 應該變成一種基本的思維方式。比如這個活生生的業務例子:
// json
[
{
"type": "item",
"otherAttrs": "foo"
},
{
"type": "shop",
"otherAttrs": "bar"
}
]
// render logic
switch(feed.type) {
case 'item':
console.log('render in item-style.');
break;
case 'shop':
console.log('render in shop-style.');
break;
case 'other':
default:
console.log('render in other styles, maybe banner or sth.');
break;
}
根據不同的類型渲染一組 feeds 信息:商品模塊、店鋪模塊,或是其他。某天新增了需求說要支持渲染天貓的店鋪模塊(多顯示個天貓標等等),於是 JSON 接口直接新增一個 type = 'tmallShop'
—— 這種接口改法很簡單直觀,但是並不好。在不改前端代碼的情況下,tmallShop
類型默認進入 default
分支,導致奇奇怪怪的渲染結果。
考慮到 tmallShop
和 shop
之間是一個繼承的關係,tmallShop
完全可以當一個普通的 shop
來用,執行後者的所有邏輯。用 Java 的表達方式來說就是:
// a tmallShop is a shop
Shop tmallShop = new TmallShop();
tmallShop.doSomeShopStuff();
將這個邏輯關係反映到 JSON 接口中,合理的做法是新增一個 subType
字段,用來標記 tmallShop
,而它的 type
仍然保持為 shop
。這樣一來,即使原來的前端代碼完全不修改,仍然可以正常運行,除了無法渲染出一些天貓店鋪的特征。
這裏還有一個非常類似的正麵案例,是 ABS 搭建係統(淘寶 FED 出品的站點搭建係統)設計的模塊 JSON Schema:
// json
[
{
"type": "string",
"format": "enum"
}, {
"type": "string",
"format": "URL"
}
]
同樣采用了 type
為主類型,而擴展字段在這裏變成了 format
,用來容納一些擴展特性。在實際開發中,的確也很方便新增各種新的數據結構邏輯。
控製 API 的抽象級別
API 能擴展的前提是什麼?是接口足夠抽象。這樣才能夠加上各種具體的定語、裝飾更多功能。用日常語言舉個例子:
// abstract
I want to go to a place.
// when
{Today, Tomorrow, Jan. 1st} I want to go to a place.
// where
I want to go to {mall, cafe, bed}.
// concrete, no extends any more
Today I want to go to a cafe for my business.
所以,在設計 API 時要高抽象,不要陷入具體的實現,不要陷入具體的需求,要高屋建瓴。
看個實際的案例:一個類 React Native 的頁麵框架想暴露出一個事件「滾動到第二屏」,以便頁麵開發者能監聽這個事件,從而更好地控製頁麵資源的加載策略(比如首屏默認加載渲染、到第二屏之後再去加載剩下的資源)。
但是因為一些實現上的原因,頁麵框架還不能通過頁麵位移(offset)來精確地通知「滾動到了第二屏」,而隻能判斷「第二屏的第一個模塊出現了」。於是這個事件沒有被設計為 secondScreenReached
,而變成了 secondScreenFirstModuleAppear
……雖然 secondScreenFirstModuleAppear
不能精確定義 secondScreenReached
,但是直接暴露這個具體的 API 實在太糟糕了,問題在於:
- 用戶在依賴一個非常非常具體的 API,給用戶造成了額外的信息負擔。「第二屏的第一個模塊出現了!」這很怪異,用戶根本不關心模塊的事情,用戶關心的隻是他是否到達了第二屏。
- 一旦頁麵框架能夠真正通過頁麵位移來實現「滾動到第二屏」,如果我們暴露的是高抽象的
secondScreenReached
,那麼隻需要更改一下這個接口的具體實現即可;反之,我們暴露的是很具體的secondScreenFirstModuleAppear
,就隻能挨個通知用戶:「你現在可以不用依賴這個事件了,改成我們新出的secondScreenReached
吧!」
是的,抽象級別一般來說越高越好,將 API 設計成業務無關的,更通用,而且方便擴展。但是物極必反,對於像我這樣的抽象控來說,最好能學會控製接口的抽象級別,將其保持在一個恰到好處的層次上,不要做無休止的抽象。
還是剛才的例子 secondScreenReached
,我們還可以將其抽象成 targetScreenReached
,可以支持到達首屏、到達第二屏、第三屏……的事件,這樣是不是更靈活、更優雅呢?並沒有 ——
- 抽象時一定要考慮到具體的業務需求場景,有些實現路徑如果永遠不可能走到,就沒必要抽出來。比如這個例子中,沒有人會去關心第三屏、第四屏的事件。
- 太高的抽象容易造成太多的層次,帶來額外的耦合、通信等不同層次之間的溝通成本,這將會成為新的麻煩。對用戶而言,也是額外的信息負擔。
對於特定的業務來說,接口越抽象越通用,而越具體則越能解決特定問題。所以,思考清楚,API 麵向的場景範圍,避免懶惰設計,避免過度設計。
收斂 API 集
對於一整個體係的 API 來說,用戶麵對的是這個整體集合,而不是其中某幾個單一的 API。我們要保證集合內的 API 都在一致的抽象維度上,並且適當地合並 API,減小整個集合的信息量,酌情做減法。
產品開始做減法,便是對用戶的溫柔。
收斂近似意義的參數和局部變量。下麵這樣的一組 API 好像沒什麼不對,但是對強迫症來說一定產生了不祥的直覺:
export function selectTab(index) {}
export function highlightTab(tabIndex) {}
export function gotoPage(index) {}
又是 index
又是 tabIndex
的,或許還會有 pageIndex
?誠然,函數形參和局部變量的命名對最終用戶來說沒有直接影響,但是這些不一致的寫法仍然能反映到 API 文檔中,並且,對開發者自身也會產生混淆。所以,選一個固定的命名風格,然後從一而終!如果忘了的話,回頭看一下前文「固化術語表」這一節吧!
收斂近似職責的函數。對用戶暴露出太多的接口不是好事,但是一旦要合並不同的函數,是否就會破壞「單一職責」原則呢?
不,因為「單一職責」本身也要看具體的抽象層次。以下這個例子和前文「合理運用函數重載」中的例子有相似之處,但具體又有所不同。
// a complex rendering process
function renderPage() {
// too many APIs here
renderHeader();
renderBody();
renderSidebar();
renderFooter();
}
// now merged
function renderPage() {
renderSections([
'header', 'body', 'sidebar', 'footer'
]);
}
// call renderSection
function renderSections(sections) {}
// and the real labor
function renderSection(section) {}
類似於這樣,避免暴露過多近似的 API,合理利用抽象將其合並,減小對用戶的壓力。
對於一個有清晰繼承樹的場景來說,收斂 API 顯得更加自然且意義重大 —— 利用多態性(Polymorphism)構建 Consistent APIs。(以下例子來源於 Clean Code JS。)
// bad: type-checking here
function travelToTexas(vehicle) {
if (vehicle instanceof Bicycle) {
vehicle.pedal(this.currentLocation, new Location('texas'));
} else if (vehicle instanceof Car) {
vehicle.drive(this.currentLocation, new Location('texas'));
}
}
// cool
function travelToTexas(vehicle) {
vehicle.move(this.currentLocation, new Location('texas'));
}
有一個將 API 收斂到極致的家夥恐怕大家都不會陌生:jQuery 的 $()
。這個風格不正是 jQuery 當年的殺手級特性之一嗎?
如果
$()
能讓我搞定這件事,就不要再給我foo()
和bar()
。
收斂近似功能的包。再往上一級,我們甚至可以合並相近的 package。
淘寶 FED 的 Rax 體係(類 RN 框架)中,有基礎的組件標簽,如 <Image> (in @ali/rax-components)
、<Link> (in @ali/rax-components)
,也有一些增強功能的 package,如 <Picture> (in @ali/rax-picture)
、<Link> (in @ali/rax-spmlink)
。
在這裏,後者包之於前者相當於裝飾了更多功能,是前者的增強版。而在實際應用中,也是推薦使用諸如 <Picture>
而禁止使用 <Image>
。那麼在這種大環境下,<Image>
等基礎 API 的暴露就反而變得很擾民。可以考慮將增強包的功能完全合並入基礎組件,即將 <Picture>
並入 <Image>
,用戶隻需麵對單一的、標準的組件 API。
發散 API 集
這聽上去很荒謬,為什麼一個 API 集合又要收斂又要發散?僅僅是為了大綱上的對稱性嗎?
當然不是。存在這個小節是因為我有一個不得不提的案例,不適合放在其他段落,隻能放在這裏……不,言歸正傳,我們有時的確需要發散 API 集,提供幾個看似接近的 API,以引導用戶。因為 —— 雖然這聽起來很荒謬 —— 某些情況下,API 其實不夠用,但是用戶 沒有意識到 API 不夠用,而是選擇了混用、濫用。看下麵這個例子:
// the func is used here
requestAnimationFrame(() => {
// what? trigger an event?
emitter.emit('moduleDidRenderRow');
});
// ...and there
requestAnimationFrame(() => {
// another one here, I guess rendering?
this.setState({
// ...
});
});
在重構一組代碼時,我看到代碼裏充斥著 requestAnimationFrame()
,這是一個比較新的全局 API,它會以接近 60 FPS 的速率延時執行一個傳入的函數,類似於一個針對特定場景優化過的 setTimeout()
,但它的初衷是用來繪製動畫幀的,而不應該用在奇奇怪怪的場景中。
在深入地了解了代碼邏輯之後,我認識到這裏如此調用是為了「延時一丟丟執行一些操作」,避免阻塞主渲染線程。然而這種情況下,還不如直接調用 setTimeout()
來做延時操作。雖然沒有太明確的語義,但是至少好過把自己偽裝成一次動畫的繪製。更可怕的是,據我所知 requestAnimationFrame()
的濫用不僅出現在這次重構的代碼中,我至少在三個不同的庫見過它的身影 —— 無一例外地,這些庫和動畫並沒有什麼關係。
(一個可能的推斷是,調用 requestAnimationFrame(callback)
時不用指定 timeout
毫秒數,而 setTimeout(callback, timeout)
是需要的。似乎對很多用戶來說,前者的調用方式更 cool?)
所以,在市麵上有一些 API 好像是「偏方」一般的存在:雖然不知道為什麼要這麼用,但是……用它就對了!
事實上,對於上麵這個場景,最恰當的解法是使用一個更加新的 API,叫做 requestIdleCallback(callback)
。這個 API 從名字上看起來就很有語義:在線程空閑的時候再執行操作。這完全契合上述場景的需求,而且還自帶底層的優化。
當然,由於 API 比較新,還不是所有的平台都能支持。即便如此,我們也可以先麵向接口編程,自己做一個 polyfill:
// simple polyfill
export function requestIdleCallback(callback) => {
callback && setTimeout(callback, 1e3 / 60);
};
另一個經典的濫用例子是 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.
引用
花絮:由於文章很長,在編寫過程中我也不由得發生了「同一個意思卻使用多種表達方式」的情況。某些時候這是必要的 —— 可以豐富文字的多樣性;而有些時候,則顯得全文缺乏一致性。在發表本文之前,我搜索了這些詞語:「調用者」、「調用方」、「引用者」、「使用者」,然後將它們統一修改為我們熟悉的名字:「用戶」。
最後更新:2017-11-27 20:04:00