函式庫合約
一個應用程式由使用者評斷; 一個函式庫由程式設計師評斷, 而且是處於一個特定的時刻——當他們想要作者未曾想像到的東西時。 一個好的函式庫,並非在其預期用途中方便使用。 而是在非預期用途中,不會被拋棄。
以下每一條規則都源自一個不對稱性。 在函式庫內部,你可以永遠更改任何東西。 在外部,呼叫者所能觀察到的任何事情,在有人第一次依賴它的那一刻就凍結了——而且當有足夠多的呼叫者時, 有人會依賴全部。
什麼凍結,以及何時凍結
Hyrum 法則是運作條件,而非奇聞軼事: 只要有足夠的使用者, 每一個可觀察的行為都會成為關鍵——例外訊息、 列舉順序、日誌輸出、時序, 甚至是你文件化為未定義的行為。 「合約裡從未包含這個」並不能讓那些已經據此發佈的呼叫者不受影響。
因此,凍結線位於可觀察性,而非 public 關鍵字,
並產生三個後果:
- 表面積從小開始。 一個符號,一旦公開, 就是一個你可以拓寬但永遠無法悄然撤回的承諾。 「暫時公開」是毫無益處的負債;按需暴露。
- 格式比程式碼長壽。 一種檔案格式、一個網路協定、 一個持久化結構,在圍繞它的函式庫每次重寫後依然存在。 將內部結構體序列化為原樣,會將已凍結的層次與可編輯的層次焊接在一起。
- 應用程式內部模組在第二個消費者處跨越這條線, 而非在打包時。 當兩個呼叫者存在時, 其 API 具有與已發佈套件相同的凍結經濟學——只是爆炸半徑較小。
語義化版本控制是同一條線的公開拼寫, 並且從拒絕在沒有一個的情況下運作開始: 「使用語義化版本控制的軟體 MUST 宣告一個公開 API」(SemVer 2.0.0,第 1 條)。 一旦宣告,增量是決定而非感覺——對該 API 的非向後相容變更 MUST 提升 MAJOR (第 8 條), 新的向後相容功能 MUST 提升 MINOR (第 7 條), 向後相容的修復 MUST 提升 PATCH (第 6 條)。 Hyrum 法則使得宣告變得困難: 規範凍結了你所宣告的內容, 而呼叫者凍結了他們所觀察到的內容。
規範也為尚未跨越界線的模組提供了一個名稱。 「主要版本零 (0.y.z) 是為了初始開發。任何東西 MAY 隨時變更」(第 4 條)。 這是對於只有一個消費者的模組的誠實標籤, 而當它獲得第二個消費者的那一天,就是應該發布 1.0.0 的日子, 而不是它被打包的日子。
不奪取控制權
將機制與策略分離。 執行 HTTP 請求是機制; 用指數退避重試三次是策略, 而將策略內建於核心會將函式庫限制在與之共享策略的呼叫者。 提供日誌記錄、執行緒、重試、超時、 快取、序列化的便利預設值——並讓每一個都可替換。
應用程式的角色是單一的,而且不是你的。 退出程序、寫入 stdout、攔截 SIGINT、 安裝頂層例外處理器、調整執行緒池大小: 一個進程中恰好只能有一個元件執行這些操作, 而執行這些操作的函式庫會與有權執行的元件衝突。 讀取進程的輸入屬於同一個角色: 環境變數、 配置檔案和命令列參數在應用程式根目錄讀取, 應用程式根目錄將每個值作為參數或選項物件傳遞給函式庫。 然後呼叫者選擇值的來源, 並且測試可以傳入值,而不需要設定整個機器。
魔術——自動偵測、自動轉換、自動重連、 自動快取——只有在呼叫者能夠重構其行為原因時才可接受。 使用者無法除錯的便利性是奪取控制權,而非節省工作量。
不增加隱藏成本
事情發生的時機屬於呼叫者。 一個啟動執行緒的導入、一個開啟連線的建構子、 一個讀取環境變數的靜態初始化器——每個都將實際工作移動到一個呼叫者從未選擇且無法回溯的時刻。
一個依賴是你 API 的一部分。 其執行時間、更新節奏、二進位相容性、授權條款、 其自身的初始化副作用都會轉移給每個呼叫者; 你在代表他們做出選擇。
效能特性是合約的一部分。 將 O(1) 查找變成 O(n)——或者加倍分配和複製次數——是穿上相容簽名外衣的破壞性變更, 而呼叫者會在上線環境中發現它。
在輸入比輸出更快到達的地方——串流、事件、日誌、 佇列——一個無界內部緩衝區是延時發生的記憶體洩漏。 反壓是設計的一部分,而非運維細節。
對於抽象本身的基準是零成本, 包含 Stroustrup 的兩個條款。 呼叫者未使用的部分,他們不需要付費: 便利層不得因其記憶體分配、 其分派、或其初始化而增加原始層的成本。 而呼叫者使用的部分,他們無法合理用手寫程式碼做得更好: 一個被手寫程式碼擊敗的抽象會被繞過, 而被繞過的函式庫就是一個帶有版本號碼但被拋棄的函式庫。
不持有隱藏狀態
單例模式、靜態快取、全域事件循環、 程序級配置: 只要你的函式庫是進程中唯一的函式庫,每個都能運作, 但當第二個函式庫、一個外掛主機、 或一個並行測試套件共享它時就會發生衝突。 狀態存在於呼叫者建立、擁有、 且可以建立兩次的實例中。
不隱藏失敗
API 形狀宣告了可能發生什麼以及誰負責。 錯誤、取消、超時、部分成功、 不存在——以型別建模, 而不是透過「這種事通常不會發生」和例外來處理。 同樣的形狀承載了註解無法強制執行的所有權答案: 誰關閉串流、緩衝區是否可以重用、 回呼在哪個執行緒上送達。
保留原因。
將失敗簡化為 false 或 null 會剝奪上層決定所需的確切資訊;
自己記錄日誌然後繼續同樣會丟失該資訊,並奪走呼叫者的日誌記錄策略。
品質在異常路徑上分離。 在快樂路徑上,函式庫是可以互換的;超時、重連、 串流中斷、部分讀取、 損壞資料和重試才是差異所在, 因此首先要為它們的組合進行設計。
取消的改造很困難——一個取消權杖會穿透它所觸及的每個簽名。 I/O 和長時間運算從第一個版本開始就接受它。
不阻擋逃脫路徑
建立兩層架構。
一個高階 API,用一行程式碼服務常見情況,
建立在公開原始型別之上——ReadAsync() 基於 ReadAsync(Memory<byte>)——以便需求超出頂層的呼叫者可以向下遷移,而不是離開。
只有高層的函式庫在遇到第一個未預期的需求時就會被拋棄。
可觀測性是注入的,而非擁有的:日誌記錄器、指標、 追蹤插入到呼叫者的堆疊中。 只有你的函式庫能讀取的診斷格式是被挾持的遙測資料。
一種稅,兩種貨幣
此技能與乾淨架構是以兩種貨幣拒絕同一件事。 零成本抽象拒絕運行時效能的稅; 依賴規則拒絕可變性的稅。 在兩者中,抽象是根據它讓依賴方放棄什麼來評判。
| 此處的職責 | 其架構名稱 |
|---|---|
| 機制與策略分離 | 用例與框架和驅動程式保持分離 |
| 可替換的預設值 | 依賴反轉——穩定方選擇實作 |
| 便利層下的原始型別 | 外層細節可替換 |
| 無全域狀態 | 依賴明確,因此可以劃定邊界 |
| 依賴是你 API 的一部分 | 依賴規則 |
呼叫者的健康形狀將你的函式庫置於底層: 一個在其策略和領域之下的可替換細節。 反轉的形狀——應用程式存在於框架內部, 拖曳其生命週期、類型、依賴注入、錯誤模型、 非同步模型——是函式庫扮演應用程式的角色, 以架構規模編寫。
一般形式:好的架構最小化的不是依賴的數量,而是每個依賴所奪走的自由。
定論
函式庫中的抽象存在是為了擴大呼叫者的選擇。 當它用效能、控制或資訊交換時, 它就不再是抽象,而是開始做決定。
何時不適用
一個具有一個消費者、一個儲存庫、 一個團隊的模組尚未跨越凍結線——以 SemVer 的術語來說,它處於 0.y.z 階段, 無論它是否帶有版本號——為它建立每個可替換的策略接縫是一種帶有維護帳單的投機行為。 從第一天起就保留那些零成本的原則——無全域狀態、 保留原因的失敗、 在呼叫者詢問之前不做任何工作——並在第二個消費者到來時添加接縫, 這時它們就不再是投機性的了。
來源
- 語義化版本控制 2.0.0,semver.org: 第 1 條 (MUST 宣告公開 API)、 第 6-8 條 (PATCH、MINOR、MAJOR 增量)、第 4 條 (0.y.z)、 以及關於棄用的 FAQ 條目 (文件化,然後發布一個包含棄用的次版本)。 用詞已與頁面核對。
- Hyrum 法則是一個觀察結果,而非標準,並被視為一個觀察結果引用。