2020年7月11日 星期六

[筆記] API 設計概念 — 上篇

  當初在設計 MLGame 專案時,為了能夠讓遊戲開發者和玩家能夠使用 MLGame 架構,於是嘗試查了關於 API 設計的概念。本篇整理一場 Google 的技術演講,演講標題為「How to Design a Good API and Why it Matters」,演講雖然是在講 API 的設計概念,但我認為這些概念在平常撰寫程式上也很受用。演講以 Java 為舉例語言,文章中會引用演講中的例子,我也會盡量舉在專案中(以 Python 撰寫)遇到的情況。
上篇整理演講前半段的部分,主要是 API 設計的通用概念。

為何 API 設計重要?


  • 好的 API 可以是公司最重要的資產之一:客戶會使用 API 來製作產品,同時也會學習使用 API。而放棄一個 API 去學習另一個全新的 API 的成本太高,所以一個好的 API 會吸引客戶來使用
  • 壞的 API 就會是公司的負債:客戶會佔滿客服線路。公司要修改 API 也會困難重重,造成技術負債
  • 發布的 API 就發布了:修改 API 可能會造成客戶的程式崩潰。

為何 API 設計對你重要?


  • 平常寫程式也是在做 API 設計:好程式是模組化的,而模組之間的界線就是 API。而且好的模組應該要能夠一再被重複使用
  • 以 API 設計的方式去寫程式可以增進程式品質

好 API 的特徵


  • 容易學習、使用:API 要好記憶、要合理、不會誤用,即使沒有文件也可以知道怎麼使用
  • 使用 API 的程式要好讀寫容易維護
  • 只做好它該做的事
  • 容易擴展:會與 API 模組化的程度有關
  • 適合對象使用者:像是用詞等

API 的設計過程


收集需求

  • 收集到的需求應該是「使用情境」,也就是 API 要解決的「問題」,而不是解法。它會變成評估 API 是否解決問題的標準
  • 將收集到的需求擴大為較通用的需求,會比較容易設計 API。太特定的功能,會讓 API 沒有那麼好用
  以 MLGame 來說,它是一個「讓玩家可以用程式玩遊戲的平台,並且可以自由加入遊戲」。而不是「幫助遊戲分離控制的 I/O,讓玩家程式能夠與遊戲溝通」。

從一頁內的規格開始

  • 快速完成重於完整性:如果一開始的規格短,就容易修改、彈性也高。可以避免牽一髮而動全身
  • 給越多人看越好:重視他們的意見。如果得到認可,才去充實規格
  MLGame 的設計是將遊戲程式與玩家程式執行在不同的 process 中,兩者間透過專屬的溝通 API 來傳輸場景資訊或是遊戲指令。當初嘗試畫的規格(不知道架構圖算不算規格):

及早且常使用 API

  • 在你實作 API 之前開始,避免在實作後發現 API 不好用而丟棄。甚至是在規劃完整之前開始,避免寫規格時發現不好用而丟棄
  • 在實作 API 的期間也要經常使用 API,避免在完成後在用的時候發現沒有解決問題。
  • 可以透過寫例子(短短的程式碼)或單元測試來檢視。在實作 API 的過程中,這些程式也會變成範例程式
  在實作 MLGame 的過程中,我先寫了兩個遊戲(單人跟多人遊戲)和對應的玩家程式來測試架構,後續的更新也一直使用這些遊戲來測試。在 MLGame 發布之後,這些遊戲就成為預設遊戲,而對應的玩家程式就成為玩家撰寫玩遊戲程式的樣板,讓玩家剛下載 MLGame 時就有遊戲可以玩。
  不過要在實作 API 之前就先嘗試使用 API 對我來說有點困難,雖然有先假想使用流程(如下方的假想程式碼),但我是在快速實現 API 的功能後,也就是先不管程式好不好看,只要呼叫 API 可以動就好了,用到程式上才明確感受到好不好用。
class Game:
    def game_loop(self):
        api.wait_ml_ready()
        while True:
            scene_info = self.get_scene_info()
            api.send_to_ml(scene_info)
            wait_fps()
            command = api.recv_from_ml()
            self.update_the_game()

記得實際的期望

  • 大部分的 API 設計過度約束:與其迎合每一個使用者的需求,不如找出通用的需求,公平的得罪每一個使用者
  • 預期 API 會出錯:多年的使用會使這些錯誤被消除,所以期望能夠一直改進 API
  真的在實際給人用之後,才會發現有些部份有問題、有些部份需要改進。

API 設計通則


API 應該只做一件事,而且作到好

  • API 的功能應該要能簡單描述:API 會與使用者「對話」。如果很難為 API 命名,那就是個壞現象
  • 好的命名,驅動好的程式開發
  • 如果模組太大,就合適地分割;如果不同模組內作了相似的事情,就融合或提取這些功能

API 越小越好,但不要釋出後縮小

  • API 應該滿足需求就好
  • API 釋出就釋出了:可以擴展 API,但不要移除 API,因為已經有人在用了,移除 API 會讓使用者的程式壞掉
  雖然建議釋出後不要移除,不過現在會搭配棄用警告(Deprecation Warning),告知 API 即將被移除,但也不會立刻造成程式壞掉。
  • API 的概念比起 API 的體積(提供的類別、函式等)重要,會影響學習的難度:讓 API 可以作更多事,但使用者學的更少。
  例如用來溝通的模組提供 IPC 跟 TCP 兩種方式,可能建構子的參數不一樣,但是用來傳接訊息的函式都叫 sendrecv。如此一來使用者轉換溝通方式,只需要確認設定參數,而不太需要更動程式其他部份。

API 的實作不該影響 API

  • 不要讓實作細節洩漏到 API 上:會混淆使用者,也會限制改變實作的空間
  例如 API 回傳的 exception 應該要有抽象層(為 API 制定一組它的 exception),而不是直接回傳內部產生的 exception。像是溝通模組的 exception 應該有自己的 CommunicationError,如果回傳實作的 exception,像是 BrokenPipeError(IPC 溝通方式),此時使用者切換到另一種溝通方式,卻得到 TimeoutError(TCP 溝通方式),使用者會混亂一陣子。此外,如果要再新增一種溝通方式,可能讓使用者需要多處理一種 exception,相當不方便。
  • 注意什麼為實作細節,不要過度命名函式
  例如一個儲存名字到資料庫的函式命名,用 save_to_dbsave_hash_to_db 還要好,一是後者跟使用者說是怎麼儲存的,二是如果要換一種儲存方式,前者有較自由的變動空間。

盡量縮小每樣東西的存取性

  • 盡可能的隱藏資訊(information hiding):public 類別應該沒有 public member,除了常數,而是透過 setter 與 getter 來存取 class member
  • 減少模組織間的連結(coupling):不要讓模組直接存取其他模組的 member。這樣模組就可以個別開發、測試、優化

命名很重要,API 是一個小語言

  • 使用他的人也會使用你定的名稱溝通,包含你
  • API 名稱必須能自我解釋:能夠理解用法,也能增加程式的可讀性,讓程式碼讀起來像文章
  • 相同的概念使用相同的字詞:remove vs delete,除非他們在 API 中有差別,不然擇一使用
  • 盡可能對稱:動詞有 add 與 remove,名詞有 entry 與 key,如果用動詞 + 名詞的格式,如:addKey、removeEntry,那其餘的 API 命名也應該要一致
  在 MLGame 中,遊戲端就稱為 game,玩家端就稱為 ml,像是執行兩者的控制類別就稱為 GameExecutorMLExecutor,而通訊類別就是 GameCommManagerMLCommManager

文件也很重要

  • 有好的設計還不夠,沒有好的文件就不會被一再使用
  • 虔誠地寫文件:從類別、界面、函式、建構子、引數到例外,為 API 的每個部份寫文件
  例如:是類別就講它的 instance 代表什麼,是函式就說會有什麼樣的前提、效果或是副作用,是引數就提示型別、單位(byte 還是 bit、秒還是毫秒)、傳進去的物件的擁有者會不會轉移
  • 謹慎地為物件有幾種狀態(state space)寫文件
  例如:溝通物件會有連線中跟關閉連線兩種狀態,關閉狀態下,recvsend 就不能被呼叫。

考慮決定 API 設計的效能後果

  • 壞的決定限制 API 的效能: 不需使用 mutable 型別卻還是用了、提供建構子而不是 factory、使用實作的類別而不是介面
  • 不要為了效能而扭曲 API:好的 API 伴隨好的效能

API 必須與平台(或使用語言)和平共存

  • 使用平台習慣的用法:遵守命名習慣、避免過時的型別、模仿語言與其核心 API 的模式、利用對 API  友善的功能、避免陷阱
  • 不要直接複製 API 到其他平台:而是退一步去想 API 本身提供的功能,而在目標平台上提供對應的 API。例如在 C++ 合理的名字,在 Java 上不一定合理

沒有留言:

張貼留言