在公司工作也兩年出頭了,其中最有趣的部份應該是重做公司的 API framework。
重新設計的過程中遇到很多有趣的問題,因此決定寫幾篇文章紀錄一下。
歷史概況#
在正式開始介紹所謂的「改進方案」以前,我會先描述一下公司的 API framework 歷史背景,以及現狀問題。
至於太久遠的歷史,我的描述也許會有錯誤,當作聽故事看看就好。
首先想像一下專案剛開始的時候:開發者只有一個網頁要維護,而這已經是 18~19 年前的事了。
此時許多畫面還是由 server side render (我們公司用的是 mako template) 產生,另外為了讓前端能夠不重整就更新畫面,後端也有提供 HTTP API(接下來所稱的 API 皆指 HTTP API)。
這就是一切的起點:只需要照顧 python codebaes 與 Web API,且只有一種 endpoint 規格。
後來 APP 開始流行,公司也開始製作手機 APP,此時良好的 API 就更加重要。
因此為了特供 APP 的行為,公司開了第二條以 /APP/* 開頭的 API 分支,這個分支的輸入輸出皆使用 json,跟 web 略有不同,這部份後面會再用比較表說明。
再後來(約2020),由於嫌棄第一版的 web API 規格已經太過老舊,難以支援特定的錯誤型態,因此希望大幅更新 API 界面。
原本的目標是換掉所有舊 Web Endpoint,但工程浩大,因此先以新前端做為練手對象,此新格式 API 以 /api/* 作為開頭。
此 endpoint 的最後成果可參考下圖
另外一提:這些 endpoint 並非一成不變,後續仍持續演進與維護。
最後列一下大約 2024 年我接手的時候,這些 endpoint 的狀況:
| Endpoint | 輸入格式 | 成功回應 | 失敗回應 |
|---|---|---|---|
| Web Endpoint | formdata 或 json | status 200,body 可能是 text/plain 或 json | status 4xx,body 為 text/plain |
APP Endpoint (/APP/*) | formdata 或 json | status 200,body 為 json | status 4xx,body 為 json |
api Endpoint (/api/*) | json | status 200,body 為 json,且必有欄位 success=true | status 200,body 為 json,且必有欄位 success=false |
這些 endpoint 不是互斥關係,這點應該很好理解:如果 Web endpoint 有一隻「回覆文章」的 API,那 APP endpoint 也會有同樣的一隻。另外,api endpoint 的數量雖然最少,但視情況也會有功能相同的 API。
因此修改一個核心功能時,同時改到三隻 API 是很常見的情況。
程式風格#
在講解程式風格之前,先大略說明一下程式架構:公司內部有一套自行開發的 API framework,底層建立在 Werkzeug 之上。
API framework 負責提供 endpoint 定義等開發介面,而 Werkzeug 則作為 HTTP 底層,處理較基礎的 HTTP request / response。
以下皆為示意碼,非當事 code (?)。
核心邏輯#
假設有一個 add response 的核心功能
# BadRequest 為 HTTP 底層提供的 exception
class ArticleNotFound(BadRequest):
description = "article-not-found"
class UserNotFound(BadRequest):
description = "user-not-found"
def add_response(article_id: int, user_id: int, content: str):
"""
如果文章不存在: raise ArticleNotFound
如果使用者不存在: raise UserNotFound
回應成功則回傳 None
"""剛剛歷史演進有提到:程式最初只需要顧及 python codebase 以及 web api 即可。
因此當初在撰寫核心邏輯時,所有錯誤都直接繼承 HTTP 底層提供的 HTTP exception,以最簡化程式的撰寫。
具體能簡化到什麼程度,可參考下方的 Web Endpoint。
Web Endpoint#
@expose('/addResponse') # expose 為我們公司 framework 的功能,可以快速註冊 endpoint
def addResponse(article_id: int, content: str):
# web 先取得現在的 user_id
add_response(article_id, user_id, content)
return 'ok'可以看到 Web API 寫起來相當簡潔,只需要將 /addResponse 註冊為 endpoint,再取得一些必要資料(如 user_id),就可以直接向下傳遞。如果成功,最後會回傳 ok 的純文字給前端。
如果出現 exception,則不管是 ArticleNotFound, 或是 UserNotFound,只要是繼承自 HTTP 底層提供的 HTTP exception。當錯誤一路向上,最後就會被頂層的 error handler 捕獲,並將其 description 作為 HTTP response body 回傳。以這個案例來說,response body 就會是article-not-found 或是 user-not-found 的純文字。
APP Endpoint#
換成 APP endpoint,事情就開始讓人頭痛了。由於 APP 規定輸出和輸入都是 json,因此頂層 error handler 預設產生的 response 格式便不符合 APP Endpoint 的需求。
既然頂層 error handler 產生的 response 不符合 APP Endpoint 的格式,就只好用其他方法處理。
@expose('/APP/addResponse', wrap=json_error_wrap)
def addResponse(article_id: int, content: str):
add_response(article_id, user_id, content)
# json.dumps 的功能是把一個 python object 變成 str (符合 json format)
# 因此這句的效果其實是回傳一個 string
return json.dumps({"result": "ok"})先看成功時的荒謬行為:回傳值為 json.dumps({"result": "ok"}) 相當於回傳一個 string。
面對這種重複的序列化動作,前人沒有去改進 framework 本身的能力,反而在幾十幾百個 endpoint 上反覆重寫 json.dumps。這本身就是一個令人費解的行為。
再看失敗時的荒謬行為:注意 decorator 上的 wrap=json_error_wrap。他的實作類似下方程式:
def json_error_wrap(func):
try:
return func()
except Exception as e:
raise BadRequest('{"error_text": "%s"}' % e.description)因為頂層 error handler 會將 HTTP exception 的 description 直接作為 response body,而目前核心邏輯的 description 都是純文字,為了產生 json。前人
- 先將原始錯誤訊息取出
- 將原始錯誤字串填入
{"error_text": "%s"}
這樣一來就可以在不更動頂層 error handler 的情況下,讓最後 raise 的 HTTP exception 帶有符合 APP Endpoint 格式的 description。
api Endpoint#
最後是最晚出現的 api endpoint,程式大致如下
class APIException(Exception):
code: str
class APIArticleNotFound(APIException):
code = "article-not-found"
class APIUserNotFound(APIException):
code = "user-not-found"
@api_expose('/api/addResponse') # 注意: 換了新的 decorator
def addResponse(article_id: int, content: str):
try:
add_response(article_id, user_id, content)
return None
except ArticleNotFound:
raise APIArticleNotFound
except UserNotFound:
raise APIUserNotFound 先看成功狀況:這版作者考慮到了序列化問題,因此當回傳 None 或 dict, list 等可 json 序列化的物件,不需要再像白痴一樣重複寫 json.dumps。api_expose 會自行處理序列化。
再看失敗狀況:這版試圖移除對 HTTP 底層的依賴,當 api_expose 接收到 APIException 時,直接在 decorator 攔截,按照規定的格式填好後回傳並送出。
因此,使用者可能收到的回傳值如下:
| 情境 | HTTP status | Response body |
|---|---|---|
| 成功 | 200 | {"success": true} |
| 文章不存在 | 200 | {"success": false, "code": "article-not-found"} |
| 使用者不存在 | 200 | {"success": false, "code": "user-not-found"} |
實際程式碼邏輯大致像這樣
def api_expose(func):
def decorator():
try:
result = func()
return json.dumps({"success": True, "result": result})
except Exception as e:
return json.dumps({"success": False, "code": e.code})
return decorator理論上這版應該能運作得不錯,因為
- 無須處理序列化問題
- (如果有寫對) 核心功能可以保持對 HTTP 底層的無知。
第二點尤為重要:我們仍有 script / worker 等非 HTTP 的進入點,讓這些進入點跟 HTTP 底層互相耦合,是典型的程式壞味道。
但這版最後沒有推行下去。我猜測原因是:如果將核心程式碼更改為丟出 APIException,會導致另外兩版的 API 立刻壞掉。
但如果不將核心程式碼變更為 APIException,則必須在此版的 endpoint 上不斷的手工加上 exception handling,再轉換為對應的 APIException 寫起來反而更加麻煩。
API 的設計問題#
不管是哪種風格的 endpoint,都有一些共通的「有趣」特點,整理如下
濫用 kwargs 和 dict#
人生看過最可怕的行為
- 在 endpoint input 中使用 **kwargs
- 在 endpoint output 中使用 dict
舉例如下
@expose('/addResponse')
def addResponse(**kwargs):
# 以下兩句未必寫在第一行
article_id = kwargs["article_id"]
content = kwargs["content"]
# 有時候甚至會將 kwargs 往核心層直接傳
result = add_response_core(**kwargs)
return result
def add_response_core(**kwargs):
# 假設經過資料庫 insert 之後
return {"response_id": response_id}公司的 endpoint 原本就缺乏文件,一切皆靠口傳心授。
當前端不了解 API 接受哪些輸入時,即使沒文件,後端翻一下程式碼理論上就可以回答。
但這招動態組合拳讓後端必須讀完 function 包 function 的層層傳遞,才能回答兩個簡單的問題
- 「我可以給什麼值」
- 「我會收到什麼回傳值」
Endpoint 層不處理 input validation#
這也是很糟糕的設計,雖然並非所有 endpoint 皆如此,但很常看到 endpoint 層不處理 input validation,舉例來說
@expose('/addResponse')
def addResponse(article_id, content):
add_response_core(article_id, user_id, content)
def add_response_core(article_id, user_id, content):
try:
article_id = int(article_id)
user_id = int(user_id)
content = str(content)
except ValueError:
raise BadRequest("input problem")幾個問題
- endpoint 應該檢查輸入資料是否合法。
- 核心邏輯應該只處理業務邏輯。
- 核心邏輯不該直接依賴 HTTP exception (codebase 中仍有 script 或 worker 的程式進入點)
而像這樣的程式可以說比比皆是。
GET/POST 不分#
除非程式中特別指定,否則只要 url 正確,不管是使用 get/post 打上來,也不管資料放在 body 或 query string,效果都相同。
也就是說,使用
POST /addResponse
{
"article_id": 0,
"content": "hello"
}跟使用
GET /addResponse?article_id=0&content=hello會得到一樣的結果。
雖然並非所有 endpoint 都如此,但沒有指定 method 的 API 佔多數。
到處複製貼上 endpoint code#
Web、APP 很常會需要共用一小段邏輯,但前人不是抽取共用的核心邏輯,反而直接把 Web endpoint 的程式複製貼上到 APP endpoint 再做一些小小微調。
這不只增加程式碼量,也讓同一個功能在不同 endpoint 之間逐漸產生行為差異。修正問題時,也必須確認每一份複製出來的程式是否都有同步修改。
小結#
以上已經是我盡可能濃縮後的公司程式碼冰山一角,即使這樣也花了不少篇幅描寫。
下一篇文章會介紹我如何在考慮相容性的情況下,設計新的 api 做法。
