前篇描述了公司程式的現狀。這篇則是探討該怎麼重新設計 framework,還不會進入實作環節。
API 介面的設計與相容性#
清晰的輸入輸出介面#
首要的目標是建立清晰的輸入輸出介面。簡單來說
- 明確 HTTP method:GET/POST/PUT/DELETE 等須明確指定,沒有什麼 GET/POST 打上來都可以運作這種事。
- 明確 input:資料須標示是從 path/query string/body 進來的,沒有參數從 query string 或 body 都可以通這種荒謬邏輯。
- 明確 output,最後送出的資料必須要明確為一個特定的 class,以後的人才知道這個 enpdoint 到底會送出什麼資料。
想像介面大致如下
class Input:
a: int
class Resp:
b: int
@api.get('/endpoint') # 僅支援 get method
def e_get(query: Input) -> Resp: # 參數僅來自 query string
return Resp(1) # 回傳必須是一個明確的 instance 而非 dict
@api.post('/endpoint') # 僅支援 post method
def e_post(body: Input) -> Resp: # 參數僅來自 body
return Resp(2)相信有用過任何 http framework 的人應該都對這種結構不陌生。
明確的標示輸入輸出只是程式的基本要求。
相容既有輸入#
雖然 http method 和 input 很重要,但 production 已經有地方是仰賴舊行為才能運作了。而這些舊 endpoint 也仍然需要維護。
有一個誘人的做法是舊 endpoint 不管,如果舊 endpoint 要提供新功能,就二選一:要不按照舊寫法繼續疊,要不強迫前端一起轉換為新寫法。
而類似的事情其實已經發生過了,參考前篇中的三個風格的 endpoint,第三種風格多半就是當時的人覺得「舊的寫法太爛,我們要創造正確的新做法」。
當時的做法是否正確我不敢說,不過就結果而言,恭喜為後人創造了一隻功能與舊 endpoint 相同,但需要維護的新 endpoint。
順帶一提,舊 endpoint 還是有人在用,所以不能拿掉。
就我看來,比較好的做法是明確宣告一種相容舊介面的 method,並在每次碰到需要修改 legacy endpoint 的時候,就將其移植到新寫法去。介面設計如下
@api.legacy('/endpoint') # 這個 endpoint 支援 get/post
def e_legacy(legacy: Input) -> Resp: # 參數可來自 query string 或 body
...對於後人來說,他看到程式就知道「這個 endpoint get/post 會造成同樣結果,且不分 query string 和 body」,但同時他也可以知道到底這個 endpoint 的具體 input field 為何。
將錯誤視為一等公民#
狀況模擬#
對於 API 用戶來說,知道這個 API 會發生什麼事是很重要的,而這就包含正確情況和錯誤情況。模擬一個情境:
前端:「可以幫我看一下這個查詢使用者的 API,如果使用者不存在,會回傳什麼值嗎?」
當後端打開程式,看到如下的程式碼
@api.get('/user')
def get_user(query: Query) -> User:
return user_service.get(query.user_id)後端眉頭一皺,立刻 jump to definition,然後看到
class UserService:
def get(self, user_id: int) -> User:
return user_repository.get(user_id)後端再次 jump to definition,最後終於
class UserRepository:
def get(self, user_id: int) -> User:
# 一頓 db query 查詢後
if user is None:
raise UserNotFound()
return user然後在查看 UserNotFound 的定義後,終於可以回答前端會回傳 user-not-found 的字串。
問題探討#
這當中出了什麼問題?一個反射式的回答是「你 doc 寫的不夠完整」,如果一開始 endpoint 上就有 raise UserNotFound 的 docstring 作為說明,那就不需要 trace 到這麼底層了。
這話雖然沒錯,但如果新增了一個 UserBanned 的 Exception,而你向上幫所有 caller 新增 docstring,此時漏改了一個,會收到任何提示嗎?顯然是不會的,因為 docstring 就只是 doc,沒有任何檢查作用。
相對的,如果開發者更新的是回傳值 User 的定義,假設改成回傳 UserV2 好了,如果有任何一個 caller 漏改了回傳資訊,那 linter 就會立刻提醒你沒改到。這兩者的安全防護有非常顯著的區別。
究其原因,是因為錯誤路徑並沒有被當作一等公民對待,但這對 API 來說是非常嚴重的瑕疵。
商業軟體的正確和錯誤路徑都非常重要,尤其是錯誤時「使用者不存在」「文章不存在」「身分不夠」「字數沒滿」「影片長度過長」等業務邏輯的錯誤,都非常仰賴提示訊息來告訴使用者如何修正行為。
把錯誤只當作寫錯也沒關係 docstring,本身就是一種輕慢的表現。
解決方案#
關於錯誤的探討以前有寫過一篇文章解釋,這篇不再贅述,只簡單說明當初沒涵蓋到的部分。
首先,要把錯誤分成兩類
- 可預期錯誤:例如轉帳因為餘額太少失敗、想註冊結果 email 已經被佔用。程式沒有寫錯,事情一定會發生,前端也必須要處理。http 的表現為 4xx。
- 不可預期錯誤:例如 OOM、存取 array 的時候超過 index 等,這類的錯誤其實是「你程式寫錯了」的警訊,沒必要告知前端,也沒必要處理。http 的表現為 5xx。
還有一類的錯誤是基礎設施錯誤,例如說 db 短暫連不上。至於這些東西要當成可預期還是不可預期,通常是看該業務對於錯誤的容忍性。
就我所了解多數的應用程式通常會當作是不可預期,因此也就不做處理。
將錯誤分類完畢後,事情就比較單純了
- 可預期的錯誤必須留在 return annotation。
- 不可預期的錯誤,反正也不會處理,直接 raise exception 終止流程即可。
因此最後介面會長的像這樣
@api.get('/user')
def get_user(query: Query) -> Result[User, UserNotFound | UserBanned]:
return user_service.get(query.user_id)這個 endpoint 到底會輸出什麼樣的錯誤一目了然,新增錯誤也不會漏改。
BTW: 我用的 lib 是 Result,但非常可惜這個 libary 已經不再維護。
難得有寫的好的 python lib,好人不長命阿…
處理不同風格的輸出#
現狀回顧#
前一節描述了程式會產生哪些成功與失敗結果,但還沒解決這些結果要如何被翻譯成不同 API 風格。
簡單回顧一次現狀:
| 風格 | 成功回應 | 失敗回應 |
|---|---|---|
| Web | 200,可能是純文字或 json | 4xx,純文字 |
| APP | 200,json | 4xx,json |
| api | 200,json | 200,json |
其中 api 風格會在 json 最外層加入 success 欄位,並透過 true 或 false 表示成功與失敗。這和 Web、APP 依賴 HTTP status 判斷成功或失敗,是兩種不同的錯誤模型。
我打算繼續沿用 Web 和 APP 的風格,只將 Web 改為預設回傳 json。
至於 api 風格,目前是被部分網頁前端所使用。如果前端能改,就希望它能切回使用 Web 風格的 endpoint。
如果一時改不了,就先用相容的方式維持運作。
案例探討#
以新增回應為例,假設在新增回應成功後,我希望能回傳 response_id,如果失敗則顯示錯誤原因(下方以超過字數為例)。 這三種 endpoint 的行為應該如下
| 風格 | 成功 | 失敗 |
|---|---|---|
| Web | 200{"response_id": 9527} | 400{"code": "CONTENT_TOO_LONG"} |
| APP | 200{"response_id": 9527} | 400{"error_text": "CONTENT_TOO_LONG"} |
| api | 200{"success": true, "result":{"response_id": 9527}} | 200{"success": false, "code": "CONTENT_TOO_LONG"} |
對內部的業務邏輯而言,這三種 endpoint 其實可以共用同一組結果。例如:
Ok(ResponseId(response_id=9527))
Err(ContentTooLongError())差異只出現在結果被轉成 HTTP response 的最後一步:
- Web、APP 的成功輸出,就是成功資料的序列化結果。
- Web 的失敗輸出,是把 error code 放進
code。 - APP 的失敗輸出,是把 error code 放進
error_text。 - api 的成功輸出,是把成功資料放進
result,並將success設為true。 - api 的失敗輸出,是把 error code 放進
code,並將success設為false。
這些細微的不同是輸出風格的差異,而非業務邏輯真的有什麼實質上的不同。因此應該能做到指定輸出風格,就產出對應結果。
介面設計如下:
class BaseError(Exception):
code: str
class ContentTooLongError(BaseError):
code = "CONTENT_TOO_LONG"
class ResponseId:
response_id: int
@api.post('/addResponse', style=WebStyle())
def e1() -> Result[ResponseId, ContentTooLongError]: ...
@api.post('/APP/addResponse', style=AppStyle())
def e2() -> Result[ResponseId, ContentTooLongError]: ...
@api.post('/api/addResponse', style=ApiStyle())
def e3() -> Result[ResponseId, ContentTooLongError]: ... endpoint 會根據指定不同的 style,將其轉換為該風格的輸出。
順帶一提:這就是 framework 該做的事,消除重複的轉換。而不是仰賴使用者天天撰寫 json.dumps() 把 return value 硬改成符合的風格。
範例中的 status code 可先假設為 Web 和 APP 的錯誤回傳 400,api 風格則維持 200。 這是為了簡化說明,實際實作時,仍需要透過特別方式宣告 status code。
相容舊 endpoint#
指定 style 只能處理共通規則,無法保證 legacy endpoint 以前的輸出狀況。假設舊 endpoint 是下列情形:
| 風格 | 成功 | 失敗 |
|---|---|---|
| Web | 200{"id": 9527} | 400content too long |
| APP | 200{"response_id": 9527} | 400{"error_text": "content-too-long"} |
- Web 風格的 response 只有
id而非response_id - Web 風格的 error 是純文字的
content too long - App 風格的 error 是
content-too-long
仍然需要一套 mapping 機制,讓新寫法可以相容舊程式。
介面設計如下:
class WebResponseId:
id: int
@api.post(
'/addResponse',
style=WebStyle(
error_map={
ContentTooLongError: Text("content too long")
}
)
)
def e1() -> Result[WebResponseId, ContentTooLongError]:
# 新增 response 後拿到 response_id
return Ok(WebResponseId(id=response_id))
@api.post(
'/APP/addResponse',
style=AppStyle(
error_map={
ContentTooLongError: Json("content-too-long")
}
)
)
def e2() -> Result[ResponseId, ContentTooLongError]: ... 這裡故意把成功資料的轉換寫在 endpoint 裡,而不是交給 error_map。原因為成功狀況偶爾會需要組合不同資料進行輸出,而非只是單純的 1-to-1 mapping。這樣做雖然多寫了一點 code,但也保留了組合不同資料的彈性。
相對地,錯誤格式通常比較制式化,因此交給 error_map 集中處理更好。
自動產生 openapi#
當 API 的輸入輸出都已經被明確宣告後,下一步就是把這些資料提供給 API 用戶(前端)。而 API 的型別、nullability 等資訊,有時候很難僅靠範例說明。
萬一規格不清,前端就很難判斷到底要如何處理 API,總不能一個一個狀況試打,所以最後還是會跑來詢問後端。為了降低溝通成本,清晰的文件是必須的。
而經過了剛才的介面調整,除了讓 endpoint 更加容易閱讀,也已經有了產生 openapi 所需的主要資訊。包含:
- 輸入資料:可明確知道 HTTP method,以及資料來自 query string 還是 body。
- 輸出資料:function 的 return annotation 可提供預期的業務結果,style 可以告知業務結果如何封裝。兩者結合就能得到正確的 response 輸出。
至於 tag、summary 等文件 metadata,則可以再透過 decorator 補充:
@api.post('/addResponse', style=WebStyle(), tag="response")
def add_response(...) -> Result[ResponseId, ContentTooLongError]:
...這邊舉例方便我只寫了 tag 一項,實際上可以按自己的需求再整合不同資訊進去,這樣文件就會更有條理。
對於 legacy endpoint,雖然 openapi 無法完全表述複合 input,但我們可以明確標示此 endpoint 的期望做法,例如:
@api.legacy('/addResponse', style=WebStyle(), primary="POST", secondary="GET", tag="response")
def add_response(legacy_body: AddResponse) -> Result[ResponseId, ContentTooLongError]:
...這樣就可以記錄
- 此 legacy endpoint 的建議 method 是 POST,但打 GET 也可以動。
- 此 legacy endpoint 建議資訊由 body 帶入,但 query string 上來也能接受。
同時 openapi 只輸出一種主要格式,讓 frontend 知道這是建議做法。當以後前端有空調整的時候,就可以將其轉換為建議 input。而後端在確認無人使用舊格式後,就可以縮緊相容性。
結語#
以上大致上描述了我對新介面的做法。除了明確化 error 以外,大部分的篇幅都在處理相容性。
相容性的重要再怎麼提也不為過,如果無法相容舊的 endpoint,需要讓外部(前端)配合你重寫,那只是增加團隊的負擔。
叫別人配合你重寫程式是有成本的,大爆炸重寫只會造成專案大爆炸。
不過這些做法也並非最開始就定案。甚至最開始的做法也沒考慮相容性,是隨著時間不斷修修補補,才逐漸完善所有功能。
也曾經想看一下有沒有其他 python wsgi framework 可供替換,但大多數 python lib 的品質都低到無法想像,部分達標的 (如 litestar) 是 asgi 的 framework,所以無法做到漸進式 migration,因此最後還是決定自己來。
下一篇會開始深入一些實作細節,不過對於只是想了解設計想法的人來說,下一篇看與不看可能也不是太重要了。
