契約
先於程式撰寫 · 依呼叫方的需要設計 · 有版本
契約是擁有者所提供內容的規格:有哪些操作、輸入輸出是什麼、附帶什麼保證。它是 git 裡的一個檔案。它不是程式碼,也不是從程式碼產生出來的。
三個性質,只有第三個有討論空間
它先於程式撰寫
先有 spec,程式由它生成 —— server stub 與 client 套件都是。反方向(從實作產生 spec)產出的是一份「描述程式碼碰巧在做什麼」的文件,包含它的所有意外。然後呼叫方就開始依賴那些意外。
它依呼叫方的需要設計,而非依資料表的樣子
這一條決定項目 30 值不值得做。
把資料表一對一包成 CRUD 的 API —— GET /orders、GET /order_items,一張表一個 endpoint、欄位就是欄位 —— 耦合一點沒少,還多了一個網路跳點。它比直連更糟,因為改一次 schema 現在要連 API 帶呼叫方一起動,而不是只動呼叫方。
能解耦的契約,是從呼叫方的問題寫出來的。呼叫方要的不是 order_items,它要的是「這位客戶買了什麼,各多少錢」。回答那個問題,後面的資料表就自由了。
| 照資料表設計 | 照呼叫方設計 | |
|---|---|---|
| endpoint | GET /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),呼叫方不必問人就能找到契約。