快轉到主要內容

重新設計公司的 API framework (2) - 介面設計篇

·3847 字·8 分鐘
Denny Cheng / 月月冬瓜
作者
Denny Cheng / 月月冬瓜
獸控兼工程師兼鍵盤武術家

前篇描述了公司程式的現狀。這篇則是探討該怎麼重新設計 framework,還不會進入實作環節。

API 介面的設計與相容性
#

清晰的輸入輸出介面
#

首要的目標是建立清晰的輸入輸出介面。簡單來說

  1. 明確 HTTP method:GET/POST/PUT/DELETE 等須明確指定,沒有什麼 GET/POST 打上來都可以運作這種事。
  2. 明確 input:資料須標示是從 path/query string/body 進來的,沒有參數從 query string 或 body 都可以通這種荒謬邏輯。
  3. 明確 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,本身就是一種輕慢的表現。

解決方案
#

關於錯誤的探討以前有寫過一篇文章解釋,這篇不再贅述,只簡單說明當初沒涵蓋到的部分。

首先,要把錯誤分成兩類

  1. 可預期錯誤:例如轉帳因為餘額太少失敗、想註冊結果 email 已經被佔用。程式沒有寫錯,事情一定會發生,前端也必須要處理。http 的表現為 4xx。
  2. 不可預期錯誤:例如 OOM、存取 array 的時候超過 index 等,這類的錯誤其實是「你程式寫錯了」的警訊,沒必要告知前端,也沒必要處理。http 的表現為 5xx。

還有一類的錯誤是基礎設施錯誤,例如說 db 短暫連不上。至於這些東西要當成可預期還是不可預期,通常是看該業務對於錯誤的容忍性。
就我所了解多數的應用程式通常會當作是不可預期,因此也就不做處理。

將錯誤分類完畢後,事情就比較單純了

  1. 可預期的錯誤必須留在 return annotation。
  2. 不可預期的錯誤,反正也不會處理,直接 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 風格。

簡單回顧一次現狀:

風格成功回應失敗回應
Web200,可能是純文字或 json4xx,純文字
APP200,json4xx,json
api200,json200,json

其中 api 風格會在 json 最外層加入 success 欄位,並透過 truefalse 表示成功與失敗。這和 Web、APP 依賴 HTTP status 判斷成功或失敗,是兩種不同的錯誤模型。

我打算繼續沿用 Web 和 APP 的風格,只將 Web 改為預設回傳 json。
至於 api 風格,目前是被部分網頁前端所使用。如果前端能改,就希望它能切回使用 Web 風格的 endpoint。
如果一時改不了,就先用相容的方式維持運作。

案例探討
#

以新增回應為例,假設在新增回應成功後,我希望能回傳 response_id,如果失敗則顯示錯誤原因(下方以超過字數為例)。 這三種 endpoint 的行為應該如下

風格成功失敗
Web200
{"response_id": 9527}
400
{"code": "CONTENT_TOO_LONG"}
APP200
{"response_id": 9527}
400
{"error_text": "CONTENT_TOO_LONG"}
api200
{"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 是下列情形:

風格成功失敗
Web200
{"id": 9527}
400
content too long
APP200
{"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,因此最後還是決定自己來。

下一篇會開始深入一些實作細節,不過對於只是想了解設計想法的人來說,下一篇看與不看可能也不是太重要了。