跳至主要内容

契約

先於程式撰寫 · 依呼叫方的需要設計 · 有版本

契約是擁有者所提供內容的規格:有哪些操作、輸入輸出是什麼、附帶什麼保證。它是 git 裡的一個檔案。它不是程式碼,也不是從程式碼產生出來的。

三個性質,只有第三個有討論空間​

它先於程式撰寫​

先有 spec,程式由它生成 —— server stub 與 client 套件都是。反方向(從實作產生 spec)產出的是一份「描述程式碼碰巧在做什麼」的文件,包含它的所有意外。然後呼叫方就開始依賴那些意外。

它依呼叫方的需要設計,而非依資料表的樣子​

這一條決定項目 30 值不值得做。

把資料表一對一包成 CRUD 的 API —— GET /orders、GET /order_items,一張表一個 endpoint、欄位就是欄位 —— 耦合一點沒少,還多了一個網路跳點。它比直連更糟,因為改一次 schema 現在要連 API 帶呼叫方一起動,而不是只動呼叫方。

能解耦的契約,是從呼叫方的問題寫出來的。呼叫方要的不是 order_items,它要的是「這位客戶買了什麼,各多少錢」。回答那個問題,後面的資料表就自由了。

照資料表設計照呼叫方設計
endpointGET /order_items?order_id=GET /orders/{id},明細直接內含
呼叫方必須知道明細放在第二張表裡完全不必知道怎麼存的
把那張表拆成兩張所有呼叫方壞掉完全無感

它有版本​

增量變更就地進行。破壞性變更開一個新版本,舊版本一直服務到它的呼叫方都走光為止 —— 而那是可知的,因為擁有者看得到誰在呼叫。

什麼算破壞性變更​

這是 CI 要執行的那條界線。落在左欄就需要新版本;落在右欄就直接上。

破壞性 —— 需要新版本非破壞性 —— 就地發布
從回應中移除一個欄位在回應中新增一個選填欄位
欄位改名新增一個 endpoint
收窄型別(string → enum、範圍變小)放寬型別
把選填的請求欄位改成必填新增一個選填的請求欄位
對呼叫方會 switch 的 enum 新增一個值對已載明為開放式的 enum 新增值
改變分頁語意 —— 每頁筆數、cursor 意義、排序新增一個篩選參數
改變既有情境的錯誤碼為新情境新增錯誤碼
收緊呼叫方依賴的 rate limit 或 timeout放寬一條驗證規則

其中兩項特別容易判錯:

  • enum。 新增一個值是破壞性的,除非契約從第一版就載明這份清單是開放式的,而且要求呼叫方處理未知值。這件事要逐欄位在第一版就決定。
  • 排序。 沒有明訂排序,不等於可以隨便改。只要呼叫方觀察得到,它就是契約的一部分,不論有沒有寫下來 —— 所以就把它寫下來。

呼叫方身分​

契約叫得出它的呼叫方。「擁有者服務知道呼叫方、知道操作、知道是哪一列」要成立,前提是每個請求都帶著一個擁有者驗得了的身分 —— 所以身分是契約的一部分,不是契約底下的管線。

呼叫方是一個 Zitadel service user​

每個呼叫方服務都是一個 Zitadel service user。它用自己的憑證換一張短效 JWT,audience 是擁有者服務;擁有者在本機以快取的 JWKS 驗證這張 token —— 簽章、iss、aud、exp —— 再檢查一個由擁有者自己定義的角色。不必每個請求都回頭問一次。授權從「這組憑證可以讀這張表」變成「這個服務可以執行這個操作」。

整條流程 —— token 請求、負責驗證的 middleware —— 在稽核日誌的身分驗證頁已經走過一遍。這裡只有兩點不同:audience 是擁有者服務自己的,角色由擁有者命名。audit.writer 是那一頁的角色,不是慣例。

這個身分是服務,不是人​

service user 的 token 說的是哪個應用在呼叫,沒有說這次呼叫是替哪一位終端使用者發出的。擁有者若必須依使用者授權或稽核,契約就得明確帶上使用者脈絡;怎麼帶,由各契約在里程碑 30.2 的契約設計階段決定,這裡不規定。

API 上有了身分,直連的路並不會自己關上​

一個同時握有有效 JWT 和資料庫密碼的服務有兩條路,也背著第二條路的全部耦合。授權仍然在里程碑 30.4 收回,新的憑證仍然只由密鑰引擎核發 —— 遷移頁的完成判準不變。

CI 檢查什麼​

沒有東西強制執行的契約只是文件,而文件擋不住呼叫方壞掉。管線把分支上的 spec 與主線上的 spec 比對,凡是左欄的變更而沒有伴隨版本升級,就讓它失敗。

spec 發布到 Backstage catalog(路線圖項目 22),呼叫方不必問人就能找到契約。