天天看點

如何保障 API 設計的穩定性

計算機行業有句名言 —— 計算機科學領域的任何問題,都可以通過增加一個間接的中間層來解決。

目前的計算機領域,無論廣度還是深度,已經沒有一個人能完全掌握了。但是,通過各種中間層的組合使用,我們不需要了解其内部細節,也可以像搭積木一樣,開發出各種有趣的服務和應用。

而各個中間層之是以能組合工作,正是因為大家都通過定義好的 API 互動和通信。每個子產品在對外提供經過抽象 API 的同時,也需要使用其他子產品的 API 作為自身運作的基礎。

今天我們來聊聊融雲在設計 API 過程保障穩定性的一些實踐。

| 無處不在的 API

API(Application Programming Interface) 又稱為應用程式設計接口。

而接口,本質可以了解為契約,一種約定。

計算機接口的概念起源于硬體。早期各家研發的各種元器件都不通用也沒有标準,互相使用非常困難,于是大家約定了功能和規格,就産生了接口,後來蔓延到軟體中。

接口蔓延到軟體之後,又分為ABI(Application Binary Interface)和API(Application Programming Interface) 。

前者主要約定了二進制的運作和通路的規則,後者則專注于邏輯子產品的互動。本文以下内容僅讨論開發者經常接觸的 API。

很多人對 API 的印象隻是包含一些函數的 Class 或 頭檔案。但 API 在我們生活中無處不在,隻是我們有時并沒有注意到。

比如,當我們在撥打電話時,手機和基站通信的整個系統是非常複雜的。

好在我們不需要了解内部的細節,僅需要把 11 位的電話号碼傳給“電話系統”的接口就可以,而隐藏的國家區号(如+86)可以了解為接口的預設參數。

這個高度抽象的 API 背後,隐藏了非常多的細節。借助上面的中間層理論,我們可以系統性地讨論設計一個 API 所需要考慮哪些内容。

  1. 子產品對上層暴露的 API 如何被使用?

API 從使用的耦合方式上,可以分為兩類:一種是通過協定調用,如調用 HTTP 接口;另一種是語言直接通過聲明調用。

如設計 HTTP Restful API 時,并不需要關心使用者的作業系統、使用的程式設計語言、記憶體線程管理等,是以會比後者簡單一些。

API 從使用者的規模和可控範圍上,可以分為 LSUD(Larget Set of Unkown Developers) 和 SSKD(Small Set of Kown Developers) 兩種。

前者一般都是公網開放的雲服務,任何開發者都可以使用,無法提前預知以何種姿勢被使用,版本也不可控制。融雲提供的通信雲就是這種 API。

後者使用者群有限,一般都在同一家公司或團隊内。比如前段時間比較火的元件化,即對内提供的子產品化 API,使用範圍和方式均可控,在更新時一般不用太糾結向後相容。

API 的第一閱聽人是人,然後才是機器,是以“可了解性”在設計時需要優先考慮。

而良好的 API 文檔、簡單扼要的 Demo、關鍵的 log,可以提升 API 使用者的體驗。

  1. API 所屬子產品對下層有什麼依賴?

API 所屬子產品都運作在一定的位址空間中。而其中的環境變量、加載庫、記憶體和線程模型、系統和語言特性都需要考慮。

  1. API 所屬子產品的内部實作對其他層有什麼影響?

    一般而言,設計良好的 API 在使用時,并不需要了解其内部實作。但如果能了解其内部架構并輔助關鍵 log,有助于提升使用 API 的效率。

并且子產品的内部實作,有時也會影響到 API 設計的風格。

如一個強依賴 IO 的接口,可能需要使用異步的方式。大量異步的方式,就衍生出了 RxJava 等架構。

| 向後相容

因為 API 如此重要,涉及的範圍又如此廣泛,廣大開發者對 API 的向後相容可以說要求非常高。

畢竟誰也不想在開發過程中,頻繁的更新接口和代碼,想想《 swift 從入門到精通到再次入門到再再次入門》的慘案就心有餘悸。

我們不僅問,為什麼很多公司或者項目都無法向後相容,僅僅是投入不夠或不夠重視,還是說 100% 的向後相容實際就是不可能的?

假設設計是理想和經過論證的,正如一個完美的圓圈。

設計是要落實到編碼中的,而編碼的過程中總是不可避免的引入一些 bug,而帶着 bug 的某個版本實作,其實正如一個 Amoeba 變形蟲,形态是不固定的。而随着版本不斷演進,不可避免會産生一定的差異。

第一個版本實作:

如何保障 API 設計的穩定性

第二個版本實作:

如何保障 API 設計的穩定性

是以說 100% 向後相容本身就是不可能的。

是以,大家平時在談論 API 穩定性時,其實預設是可以包含一定程度變更的。

但由于 API 涉及的範圍太廣泛,保障向後相容都需要極大代價。

比如 Linux 就希望快速疊代,完全不保證 API 的穩定性。針對這個問題,Linux 還特意寫了 stable-api-nonsense 文檔。有興趣的可以點選閱讀:

stable-api-nonsense.rst

| 漸進式改進

是以說,保障 API 的穩定性會面臨很多挑戰,比如:

  • 業務形态還不穩定,還在高速發展
  • 業務和 API 曆史包袱較重
  • 多個平台和語言的特性不一緻
  • 使用者群和使用方式不明确

我們回顧一下正常的開發流程,看看是否能通過一些名額和工具,改善 API 的穩定性,主要涉及:需求、設計、編碼、Review、測試、釋出、回報等步驟。

• 需求

普通的産品開發,在啟動的時候,使用者需求都比較明确,但對于 LSUD 的雲服務而言,無法提前預知使用者群都有哪些,以及使用者在他的産品中如何使用 API。

這容易造成,沒有明确的使用者需求,API 就不好進行設計和疊代,沒有設計就沒有使用者,需求更無從談起。這是一個雞生蛋、蛋生雞的問題。

建議可以在 API 釋出之前,内部先針對典型的使用場景,設計幾個完整的 Demo,驗證 API 的設計和使用是否合理。

需要注意的是,Demo 需要有完整應用場景,達到上架地步,如果能内部使用, Eating your own dog food 最好,過于簡單的 Demo 無法提前暴露 API 的使用問題。

Demo 的開發人員最好與 API 的設計者有所區分,避免思維固化,更多内容大家可以參照 Rust 語言開發在自舉過程中的一些實踐。

• 設計

在設計 API 的時候,有很多需要注意的點和普通開發不太一樣。

普通開發,快速實作功能始終被放在第一位。比如大家會用一些靈活開發的方式,優先實作功能再快速疊代等。

但 API 設計時,接口無法頻繁變更,是以首先需要考慮的是“少”,少即是多。

l 每個 API 做的事情要少

一個接口隻做一件事,把這個事情做好就足夠了。

需要避免為了讨好某個場景,在一個 API 上進行複雜的組合邏輯,提供一個類似文法糖的接口。否則,場景的業務自身在演進時,很難保證 API 的行為不變。

如果需要支援多種業務,可以考慮将 API 分層,比如融雲用戶端的 API 會分為下面幾層。

如何保障 API 設計的穩定性

舉個例子,融雲考慮通用性,基于訂閱分發的模型,抽象了 RTCLib,用戶端能處理媒體的任意流,非常的靈活,但是對于使用者而言開發代價可能高些,要思考和做的工作比較多。

考慮到大量的使用者,其實需要的是音視訊通話的業務,基于 RTCLib,融雲分裝了不帶 UI 的 CallLib 以及內建了 UI 的 CallKit。

如果一個使用者,需求和微信的音視訊通話類似,可以內建帶 UI 界面的 CallKit,開發效率會非常高;

如果使用者對通話音視訊通話 UI 的互動有大量需求,可以基于 CallLib 進行開發,對 UI 可以進行各種定制。

l 暴露的資訊要少

成熟的 API 設計者都會盡可能的隐藏内部實作細節。

比如字段不應該直接暴露而是通過 Getter/Setter 提供,不需要的類、方法、字段都應該隐藏,都已經成為各個語言的基礎要求,在此就不細述了。

但容易被忽略的一點需要提醒大家,應盡量隐藏技術棧的資訊。

比如:API

http://api.example.com/cgi-bin/

get_user.php?user=100,就明顯混入了很多無用的資訊,并且以後技術切換更新想維持 API 穩定非常麻煩。

l 行為擴散要少

在語言直接調用的 API 中,需要避免基礎接口通過繼承導緻行為擴散。

在普通的編碼過程中,抽象類和繼承都是面向對象的強大武器。但是對于 API,更建議通過組合使用。

比如一個管理生命周期的類,如果被繼承,子類有些行為就有可能被修改而導緻出錯。這時候建議使用 Interface + 工廠的方法提供執行個體。

由于 Java 8 之前 interface 沒有 default 實作,為了避免增加功能需要頻繁修改接口,可以使用 final class。

Objetive-C則可以使用 attribute ((objc_subclassing_restricted))和__attribute__((objc_requires_super))控制子類繼承行為。

l 畫風切換要少

API 命名要做到多個平台的業務命名統一,與每個平台的風格統一。

這點 HTTP 的接口要簡單一些,隻需要標明一種風格即可,Restful 或者 GraphQL 或者自己定義。

語言調用的 API 命名,建議首先遵循平台的風格,然後再是參考語言标準,最後才考慮團隊的風格。

比如:iOS 平台的 API 開發,需要首先參照 iOS 的命名風格,did 和 will 之類的時态就非常有特色。

命名上細節較多,詞彙、時态、單複數、介詞、⼤小寫、同步異步風格等都需要考量,需要長時間的積累。

l 了解成本要少

一般 API 每個接口都會有相應的注釋說明,但是值得注意的是,大部分開發者并不看注釋。

大部分開發者對接口的了解,都僅源于 IDE 的補全和提醒。一個接口看着像就直接用,不行再換一個試試,這其實是一種經驗式程式設計的方式。也就意味着接口命名需要提高可了解性。有一個辦法可以驗證,将接口的所有注釋抹掉,使用者能否非常直接的看懂每個接口的含義。如果很困難,則需要改進。

API 設計還有一處和普通開發不太一緻。普通開發設計好架構即可,每個子產品的開發可能是同一個人,接口并不需要在設計時确定下來。但是 API 的設計階段,需要進行 Review 并直接确定接口的設計,以保證多端在開發時遵循完全一直的規則。

• 編碼

在 API 的編碼過程中,有以下幾點需要注意。

1.在 API 中,預定義好版本号。

這個主要是針對 HTTP API,如:

http://api.example.com/v1/users/12345?fields=name,age

如果目前僅有一個版本,也可以暫時不加,第二版時再區分。

2.注意 API 版本檢查。

當分層提供多種 API 時,每層 API 需要在啟動時,先校驗一下版本号,避免不比對的情況。

比如在以下 Java 代碼中,大家可能覺得判斷版本号相等的代碼非常奇怪,應該永遠是 true 才對。

但是抽象類和實作類出現在不同的分層子產品中,并且實作類先編譯,抽象類版本更新後再編譯,就會出現不一緻的情況。有很多語言或平台能提供類似的方式來确定版本。

3.提供規範性的 log 輸出。

普通開發的log,主要用于自己定位問題。但是 API 在編碼時,最好針對性的添加一些 log,有利于 API 的使用者了解并簡單排查問題。但出于性能考慮,需要定義好 log 的級别并可以調整。

4.注意廢棄與遷移。

當一個以前設計的 API 不再符合要求或者有重大問題時,我們可以對外标記成已廢棄,并在注釋中建議使用者遷移到另一個接口。如果是類似的被廢棄接口,内部編碼時最好能使用新的接口來實作,以降低向後相容的維護成本。HTTP 的 API,需要預定義好遷移的錯誤碼,比如在 HTTP 規範中,可以使用 410 Gone 說明已經不再支援某個接口。

• Review

API 的 Review 基于普通開發的 Code Review。如果基礎的 Code Review 都沒有做好,肯定無法保障 API 的品質和穩定性。

可以通過一些工具,為 API 的 Review 提供一些參考報告。比如可以使用 SonarLint 分析代碼複雜度,如果接口層的代碼複雜度較高,會是一個危險的信号。還可以借助 Java 反射、Clang 文法分析,擷取目前的 API 接口清單,生成接口變更報告,也有利于減少無用接口的暴露。另外,自動化工具生成的接口文檔也是 Review 重要的一環。

• 測試

在測試環節,我們可以通過 unit test 來關注 API 的穩定性。與靈活開發經常修改 test case 不同,API 的 test case 基本代表了接口的穩定性。是以在修改舊 case 時需要特别明确,是 case 自身的 bug 還是接口行為發生了變更。

• 釋出

我們可以通過區分 dev 和 stable 版本,為不同階段的開發者提供更好的體驗。

dev 版本包含最新的功能,但是 API 接口有變更風險。stable 版本 API 穩定,但功能不一定是最新的。如果開發者還在開發過程中,可以選用最新的 dev 版本,基于最新 API 開發。如果應用已經上線,可以選擇更新直接到最新的 stable 版本。

• 回報

由于前面提到的,雲服務的 API 比較難确定使用者群和使用者的使用方式。可以參考 APM(Application Performance Management) 的方式,記錄熱點 API 使用情況,為後續的優化提供資料。

| 總結

上面的改進,讓保障 API 的穩定性變得更容易。下面以融雲 IMLib iOS SDK 2.0 版本演進為例,曆盡 2015至 2019 四年時間,從 2.2.5 到 2.9.16 共 98 個版本。API 接口數量翻了一番,考慮到接口更内聚,功能大約增加了 3 倍。

但是需要使用者遷移的接口非常少,即使遷移時開發成本都非常低。

如何保障 API 設計的穩定性