想要讓人們使用你的軟體,你必須成為一名 API 開發者。這篇文章中,你將瞭解如何製作檔案完備、具備一致性、可擴充套件的 API。幫助使用者充分利用你的 Java 應用程式。
1. 簡介
身為軟體開發工程師,我們每天都編寫程式碼。然而不可思議的是,這些程式碼會一直“存在於真空中”,與所有其他開發的軟體隔離開來。在軟體工程領域,“站在巨人的肩膀上”這個比喻從來沒有像今天這樣合適。GitHub、Stack Overflow、Maven Central 以及所有其他程式碼倉庫、支援庫和軟體庫都唾手可得。
軟體是由應用程式程式設計介面(API)構建的——我們每天都在使用 Maven 或 Gradle 等工具引入的 JDK API 和數量眾多的依賴 API。如果你走到一個待滿了軟體工程師房間裡,問他們是不是 API 開發者,他們的回答通常是:“不,我們不是”。這是不正確的!任何曾經親手設計過 public class 或定義過 public method 的人都應該認為自己是 API 開發者。這裡故意使用了“手工設計(craft)”這個詞。軟體工程往往會被工程的形式所掩蓋,但某種程度上 API 設計更像是一門藝術,而非一門精確的科學,需要依賴打磨多年的創造力和直覺。
2. API的特點
關於 API 有許多標準,下麵重點介紹其中6個,它們構成了整篇文章深入討論的重點。
2.1 容易理解
從 Maven 下載了函式庫,接下來該從哪個 class 下手?如果不能憑直覺找到入口,可能就算不上一個成功函式庫。
API 開發者應該充分考慮 API 的入口。完整的檔案對於幫助使用 API 使用者瞭解全域性非常有用,但理想情況下我們希望確保開發者使用 API 時遇到的阻礙降到最低。因此,應該在檔案的開始部分為開發人員提供最少的步驟。好的 API 會從入口公開其最重要的功能,幫助開發者掌握 API 的主要功能。然後,開發人員可以根據需要透過外部檔案瞭解更高階的功能。
2.2 檔案完備
既然是提供給他人使用,那麼完備的檔案很重要。接下來會介紹如何編寫高質量、詳盡的 JavaDoc 檔案。
2.3 一致性
一個好的 API 不應該讓使用者在使用過程中感到意外,比如前後概念不一致。在討論一致性時, 我們的意思是確保在 API 中重覆相同的概念, 而不是引入不同的臨時概念。比如下麵的例子:
- 可以用 getXYZ() 或者 xyz(),但不要兩種同時出現。
- 如果有兩個函式(其中一個函式多載了另一個函式。比如一個函式用 Object… 作為引數,另一個用 Collection extend Object>),那麼盡可能在所有的地方都進行多載。
重點是建立一套團隊中公用的詞彙表和“備忘清單”,併在整個 SDK 中透過這種方式保證一致性。
2.4 適用性
在開發 API 的過程中,必須確保 API 為標的使用者提供合適的抽象級別。可以從兩方面考慮:
- 只做一件事,並且把它做好。
- 理解使用 API 的人,以及他們的標的。
JDK 中的 Collection API 就是最佳範例。使用者不用關心儲存空間的閾值、擴容策略、hash 衝突策略、裝載繫數、快取策略等。只要呼叫、儲存就可以了。開發人員不必理解內部工作機制就可以使用集合框架實現自己想要的功能。
2.5 約束
開發新 API 的過程可能非常快。但我們應該在心裡提醒自己,每個新的 API 都有可能承諾終身支援。
我們對 API 決策的實際成本在很大程度上取決於我們的專案和社群——一些專案樂於不斷地進行突破性的改進,而其他專案(如JDK本身)則希望盡可能少地出現突破性改進。而大多數專案則處於中間地帶,採用一種語意版本控制方法,在主版本刪除 API 之前小心地棄用它們。
有些專案甚至提供了各種標記區分 experimental、beta 和 preview 功能,以便在最終鎖定 API 之前尋求反饋。一種通常的做法是,對新引入的實驗性 API 加上 @Deprecated,當它們已經就緒的時候再把註解拿掉。
2.6 可擴充套件性
每次 API 的決定,都讓自己的餘地變得更小。所以盡可能從 SDK 的長遠發展來考慮。
3. API即約定
API 更像是一種約定,為其他開發者承諾了某種功能。我們需要不斷改進 API 實現,每次改進都深思熟慮。每次冒險增加新功,都可能給下游 API 的使用者帶來 bug 風險。
在 API 1.0.0版本釋出之前,我們應該大膽試驗。在某些專案中,1.0.0就是 API 鎖定的時候(至少在2.0.0之前)。然而在其他專案中,這種責任可以有更大的靈活性,能夠持續不斷地試驗和改進 API。事實上,透過富有遠見的棄用(depression)流程,在很長一段時間內,不限制引入更好方法的前提下,以向後相容的方式擴充套件 API 並不太難。
4. 必要性
最容易維護的 API 就是不使用 API,因此證明 API 中每個方法和類存在的必要性是一件十分重要的事情。在設計 API 的過程中,我們要時刻問這樣的問題:“它真的是必須的嗎?”只有不斷地提問和證明,才能確保留下的函式都是必須的,而且是值得長久保留的。
5. 吃自己的狗糧
作為 API 開發者,如何保證自己設計的 API 能夠滿足現實需求?我們需要用 API 使用的視角而非自己的角度來看待這個問題。要做到這點,最好的方法就是“吃自己的狗糧”,在整個開發過程中不但自己要使用自己開發的 API,更重要的是還要確保有“真實世界”中可信賴的使用者使用你的 API。
引入真實使用者的價值,在於能夠避免自己失去限制,僅憑自己對 API 的理解加入高階功能。“真實世界”的使用者可以平衡這點,讓我們能夠確保只修複那些真正的問題。
在自己使用 API 開發的過程中,應當利用這段時間在程式碼中尋找那些不清晰(或者意圖不明確的)程式碼,例如重覆或冗餘的程式碼,或者強迫 API 使用者在過高、過低抽象層次工作的程式碼。
6. API檔案
在使用 SDK 時,有兩種開發檔案對使用者很重要:一種是 JavaDoc,另一種是深入講解的文章(關於如何上手的教程,比如微軟在 Azure 上釋出的 Java 教程)。雖然它們對開發者都很重要,但解決的標的不同。這篇文章主要關註 JavaDoc,因為它與 API 開發者的關係更大。
JavaDoc 是 API 的說明書。開發 API 的工程師需要確保 JavaDoc 的完整性,包括類功能說明、函式功能說明、期望的輸入格式、輸出結果、異常等等。雖然起到了說明書的作用,但是很重要的一點,它既不是詳細的開髮指南也不討論實現細節。
理想情況下,高質量的 JavaDoc 會更進一步,提供程式碼片段。使用者可以將其複製到自己的軟體中,開始自己的開發。這些程式碼片段不需要很長——最好不超過5到10行。隨著時間的推移,使用者開始問有關 API 的問題,可以將這些程式碼段加到相關類或方法的 JavaDoc 中。
JavaDoc 的價值不僅為其他開發人員提供價值,還能為我們提供幫助。JavaDoc 對 API 進行了過濾,只展示標記了 public 的方法。如果我們定期生成 JavaDoc,就能審查 API 中 JavaDoc 缺失、遺漏實現類、缺少外部依賴及其他沒有想到的問題。
Java 專案大多基於 Maven 或 Gradle 構建,生成 JavaDoc 非常方便,可以分別執行 mvn javadoc:javadoc 或者 gradle javadoc。養成定期生成 JavaDoc 的好習慣(可以設定在錯誤或報警時生成失敗),能夠確保及早發現 API 中的問題,提醒自己在哪些地方還需要更詳細的 JavaDoc。
6.1 JavaDoc中的行為約定
JavaDoc 一個未被充分利用的方面是透過它來定義行為約定。關於行為約定的一個例子是 Arrays.sort() 方法,該方法保證是“穩定的”(即不對相等的元素重新排序)。想要透過 API 本身資訊沒有辦法很容易地做到這一點(除非使 API 變得難以使用,比如 Arrays.stableSort()),但 JavaDoc 提供了最理想的實現場所。
然而,如果我們新增行為約定作為 API 的一部分,那麼它就會成為 API 的一部分,就像 API 本身一樣。我們不能在 API 層次改變行為約定,這麼做可能會給 API 下游的使用者帶來問題。
6.2 JavaDoc標簽
JavaDoc 附帶了許多標簽,例如 @link、@param 和 @return。它們為 JavaDoc 工具提供了更多的背景關係,併在生成 HTML 時提供更豐富的體驗。在編寫 JavaDoc 時,將這些內容牢記在心是非常有用的,可以確保它們在需要的時候用到。要瞭解何時使用這些標記,請參閱“J2SE 參考檔案”中的“標記註釋”部分。
7. 一致性
現在很少有軟體由一個人開發。即便是,人類也會反覆無常,今天認為偉大的東西第二天可能會被認為是大錯特錯。幸運的是,在設計 API 的時候,我們清楚地記錄了以 public API 的形式所做的決策,並且很容易發現什麼東西背離了這種約定。
API 具備一致性,短期的好處是可以減小讓使用者感到沮喪的風險。長期來看,可以讓使用者在使用 API 功能的時候憑直覺就知道該如何使用。
關於一致性需要考慮:
7.1 傳回值
理想情況下,所有傳回集合的 API 都應該保持一致,只使用幾個集合類而不是所有可能的類。傳回集合的一個很好的子集可以是 List、Set 和 Iterator(這種情況下,絕對不要用 Collection、Iterable 和 Stream。但是請註意,這些都是有效的傳回型別——僅在本例中,它們不在考慮的子集範圍中)。對應的,如果(針對某種型別)API 在大多數情況下都不傳回 null,那麼最好不要為該傳回型別傳回 null。
7.2 方法命名樣式
開發人員依賴 IDE 自動完成輸入,因此要考慮 API 命名的重要性,確保相關的內容在使用者自動完成彈出串列中的位置相近。API 應該有一組完善的術語定義,併在以下情況下一直重用它們:型別名稱、方法名稱、引數名稱、常量等等。方法名如 Type.of(…)、Type.valueOf()、Type.toXYZ()、Type.from(…) 等使用時應該保持一致,不要混合使用。標的是在整個 API 中使用團隊定義的詞彙表。
7.3 引數順序
多載 API 以接受不同數量或型別的引數時,應該始終確保引數順序的一致性和邏輯性。在某些情況下,我們會按照某種邏輯分組的形式將引數傳遞給方法。這時,引入封裝這些引數的中間型別可能是有意義的。這樣能夠減少 API 後續版本中需要多載方法以接受更多引數的風險。這也有助於我們達成 API 可擴充套件這個標的。
8. 最小化API
開發更強大的 API 是 API 開發者的本能——提供更多而不是更少的便利。但是這會導致兩個問題:
- 導致 API 過載:開發人員需要閱讀和理解比完成工作所需更多的 API。
- 公開的 API 越多,未來的維護負擔就越大。
所有 API 開發者都應該從瞭解他們負責的 API 所需的關鍵用例開始,設計 API 並支援這些用例。應該抵制增加更多便利的衝動(自認為透過增加新 API 使開發人員少寫幾行程式碼)。
話雖如此,需要澄清一點,便捷的 API 在任何好的 API 中都扮演著至關重要的角色,尤其是讓 API 變得易於理解方面非常有用。挑戰在於,決定什麼應該被作為有價值的東西被接納,什麼東西不能“證明自己的價值”應該拒絕。JDK API 中一個很好的例子是 List.add(Object),可以避免開發人員必須總是呼叫 List.add(int, Object)。
在與 Oracle JDK 團隊的工程師 Stuart Marks 討論這個主題時,他發表了以下見解:
另一方面,我也見過一些 API 因為“便捷性”而陷入困境。這裡有一個假設的例子。 假設有一個提供了 bar() 和 foo() 操作的 API。它們可以單獨使用,但經常會放在一起使用。 這時,可能有一個 bf() 操作,它能同時做這兩件事。目前為止沒有問題。
現在,假設你增加了一個 mumble() 操作,需要分別呼叫 bf() 和 mumble() 因此需要更方便的 API,比如 bfm()。好吧,如果你不需要foo() 怎麼辦? 再提供一個 bm() 怎麼樣?另外,還可以再增加一個 fm()。 現在你有了7個方法,其中一半以上是三個基本操作的組合。 也許這是好事,也許不是;當然,這麼乾可能會使 API 膨脹。 在某種程度上,有足夠多的便捷 API,它們往往會比基本操作更方便。
現在,主要是風格問題。可以只執行基本操作,交給使用者來組合,這是 JDK 的風格。 或者你可以提供所有的組合,這樣一旦使用者瞭解他們的系統,所需的任何組合都已經有了。 後者的例子可以參考 Eclipse Collections。
9. 防止洩露
防止“洩露”很重要。要確保實現類、屬於外部依賴項的類不在 public API 中以傳回型別或引數型別的形式暴露出來。應該採取適當的措施來確保這些類被隱藏。
隱藏實現類主要有兩種方法:
- 將實現類放入 impl 包中。然後,可以將這個包下的所有類排除在 JavaDoc 輸出之外,並標記為開發人員不得使用的 API。JDK 9或更高版本下開發的 API,可以定義一個模組將 impl 包從匯出模組中排除(這樣,開發人員不可能誤用)。
- 將實現類包標記為“包級私有”(即類上沒有修飾符)。這意味著這些類不是 public API的一部分,開發人員也不能使用它們。
當在 API 中洩漏了外部依賴項時,無意中增加了 API 的範圍。API 會包含洩露類相關的依賴項,從而讓我們的 API 變得不可控。如果發現暴露了 API 中的外部依賴,我們應該考慮這是不是一個理想的結果,或者是否應該採取行動來抵消影響。現有的措施包括刪除洩漏外部依賴項的違規 API,以及圍繞洩漏類編寫包裝類,進而將實際使用的類隱藏。
10. 理解protected
Java 中的 protected 關鍵字經常被誤解,甚至被濫用。簡而言之,protected 成員用於與子類通訊,而 public 成員用於與呼叫者通訊。
在某些情況下,protected 在 API 開發人員工具箱中可能是非常有用的工具,但是除非從一開始就將它設計成一個類,否則它經常被錯誤地使用。導致看起來類是可擴充套件的,但實際上並不是。實際上,有時候 protected 關鍵字看做感染 class 的一種病毒,使用者越來越多地要求 API 中 private 方法變成 protected(或 public)時,為 class 加上 protected 滿足他們的需求。
此外,API 開發者需要理解,protected 方法和 public API一樣,也是其中的一部分。這一點常常被 API 開發的新手誤解,最終造成傷害。
11. 有意的繼承
作為一名 API 開發者,我們必須保持一種平衡,既能為開發人員提供功能和靈活性以完成他們的工作,又讓自己的 API 具有長期可擴充套件性。確保我們能夠保持一定程度的控制,一種方法是使用 final 關鍵字。透過將我們的類或方法設定為 final,我們向開發人員發出的訊號是,此時他們不能擴充套件或改寫這些特定的類和方法。
final 對 API 開發者的價值是基於這樣的事實,我們的 API 不是完美的。相比聯絡 API 開發者修複問題,更多的開發人員會繞過我們的問題來改進自己的程式碼,這樣他們就可以繼續解決下一個問題。透過這種方法,他們只會給自己提出新問題,最終會給我們這些 API 開發者提新需求。理想情況下,當 API 使用者遇到一個 final 類或方法時,會聯絡我們討論他們的需求,這將引出一個更好的 API。明智的做法,不要在釋出版本後再標記 final,畢竟 final 關鍵字總是可以在以後的版本中刪除。
12. 向後相容
到目前為止,這篇文章一直圍繞著如何改進 API 這個問題。最基本的建議是新增 API 通常沒問題,但不能刪除或更改現有的 API。原因主要是新增 API(通常)向後相容,而刪除或更改 API 無法向後相容。換句話說,當我們刪除或更改現有 API 時,如果使用者的程式碼依賴這些 API,那麼當他們升級到下一個版本時就會報錯。
有時我們必須進行無法向後相容的更改,例如在 API 設計中犯錯,或者忽視了需求中的某些方面而沒有採取相應的措施。挑戰在於盡可能清楚地向我們的使用者傳達這一資訊。使用@Deprecated 註解(以及相關的 @deprecated JavaDoc 標記)是一個很好開始,但這僅適用於我們對何時允許在版本中進行重大變更有一個清晰明確的策略。一種常見的做法是採用語意版本控制策略,即只在主版本中更改不相容的 API(也就是說,在版本控制方案 MAJOR.MINOR.PATCH 中,MAJOR 值遞增)。在這個方案中,計劃更改或刪除的任何內容都被標記為 deprecated,但直到下一個主版本才進行刪除或修改。如果要使用這個策略,必須向外傳達資訊,以便 API 使用者確信更新計劃。
另一種是由開發人員不知道所做更改帶來的影響造成的 API 意外中斷。這種情況比理想種的情況更常見,而且往往很難註意到。有一些工具可以監視 API 變化,並通知已經引入的向後不相容情況。Revapi 就是這樣一個工具,我在微軟參與的幾個專案中它都起到了很好的效果。
相容性問題涉及的不僅僅是命名和方法簽名。同樣重要的是,在這篇文章討論的內容之外,行為(即實現)的變化也可能破壞更改。事實上,據說每個更改都是不相容的,因為即使是修複錯誤也可能會破壞依賴這個錯誤實現的使用者。
為什麼要關心向後的相容性呢?就是因為破壞相容性對我們的使用者來說真的很痛苦。有一些專案由於過於快速和隨意地應對向後相容性而遭受了相當嚴重的後果。
13. 不要傳回null
Tony Hoare 稱 null 取用的發明(他創造的東西)是他的“十億美元錯誤”。在 Java 中,我們已經非常習慣於透過傳回 null 來處理一些錯誤條件。所以,對所有內容進行 null檢查成為了第二天性。但在許多情況下,有比直接傳回 null 更好的方法。一些常見的用法可參閱下表:
透過保證向 API 呼叫者傳回非 null 值,使用者在他們的程式碼中不必到處寫檢查 null 的程式碼。然而重要的是,如果採用這種方式,必須確保在整個 API 中保持一致。如果 API 不能始終如一地應用樣式,就很容易損害使用者對它的信任(如果不這樣做,會導致用戶遇到意外的空指標異常)。
譯註:Tony Hoare 爵士,計算機領域專家,圖靈獎得主,發明瞭快速排序演演算法。
14. 理解何時使用Optional
Java 8 引入 Optional 是為了減少可能出現的空指標異常,因為當一個方法傳回 Optional 時,能保證傳回值非 null。然後由 API 的呼叫者決定傳回的 Optional 包含元素還是為空。換句話說,Optional 可以看作最多包含一個元素的容器。
Optional 傳回型別最適用於以下情況:
- 可能無法傳回結果
- 在這種情況下,API 呼叫者必須進行一些不同的處理
假設有 public Optional getFastest(List cars) 方法,Optional 在這種情況下提供了許多便捷的方法,下麵展示了其中一些方法:
// getFastest 傳回 Optional,但是如果 cars 為空串列,
// 傳回 Optional.empty()。這種情況,我們可以選擇對映為 invalid 值
Car fastestCar = getFastest(cars).orElse(Car.INVALID);
// 如果 orElse 情況處理很複雜,我們可以使用 Supplier
// 針對 Optional 為空生的情況成替代的值
Car fastestCar = getFastest(cars).orElseGet(() -> searchTheWeb());
// 我們也可以選擇丟擲異常
Car fastestCar = getFastest(cars).orElseThrow(MissingCarsException::new);
// 如果不是空串列,我們可以透過 lambda 運算式對其進行操作
getFastest(cars).ifPresent(this::raceCar)
上面展示了正確呼叫 API 傳回 Optional 的示例。如果大多數 API 使用者像下麵這樣處理 Optional 傳回值,則有可能認為這是一個檢查空取用的面向物件版本,並且可能不如傳回 null 更好(或者更直觀)。
// 雖然可以直接在 Optional 傳回值上呼叫 get(),
// 如果傳回值為空,可能會遇到 NoSuchElementException 異常
// 可以像下麵這樣使用 isPresent() 對呼叫封裝,
// 但如果 API 通常像這樣使用,則表明 Optional 可能不是正確的傳回型別
Optional result = getFastest(cars);
if (result.isPresent()) {
result.get().startCarRace();
}
API 傳回 Optional 有兩條終極規則:
- 方法永遠不要傳回 Optional>,只需要傳回 Collection 型別的一個空集合(像前文提到的那樣)就可以更簡潔地完成。
- 傳回型別為 Optional 的方法,永遠不要傳回 null。
15. 總結
本文介紹了所有工程師在編寫提供公共 API 程式碼時都應該牢記的一系列註意事項。在較高的層次上,讀者應該放下這篇文章,瞭解並思考API設計的重要性。如果還沒有出現,讀者應該開始意識到 API 設計有某種藝術形式,透過來自導師和使用者的實踐和反饋提高我們在這方面的技能。與許多藝術形式一樣,API 設計的成功不是看能夠投入多少,而是看能輸出多少。因此,API 設計面臨的挑戰是極簡主義、一致性、原始意圖(intentionalism),最重要的是對 API 的思考,為 API 的終端使用者考慮。我們必須不斷培養開發者的同理心,以確保我們能夠正確地看待終端使用者的需求。
無論為自己使用編寫 API,還是為組織中的其他人編寫 API,或者更廣泛地將其作為開源專案或商業開發庫的一部分,思考本文中列舉的內容將有助於指導讀者產出更高 質量和更專業的結果。這不應該被簡單地看作是“更多的工作”,更應該看成是對自己的一種挑戰,即專註於為我們的使用者提供一個愉快的、功能豐富的、高效的 API。
16. 致謝
許多人對本文的草稿進行了審查,他們的反饋對改進本文起了極大的作用。感謝 Abhinay Agarwal、Brian Benz、Bruno Borges、Lukas Eder、Stuart Marks、Theresa Nguyen、Kevin Rushforth、Eugene Ryzhikov、Johan Vos 和 Ruth Yakubu。