快轉到主要內容

重新設計公司的 API framework (1) - 歷史篇

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

在公司工作也兩年出頭了,其中最有趣的部份應該是重做公司的 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 Endpointformdatajsonstatus 200,body 可能是 text/plainjsonstatus 4xx,body 為 text/plain
APP Endpoint (/APP/*)formdatajsonstatus 200,body 為 jsonstatus 4xx,body 為 json
api Endpoint (/api/*)jsonstatus 200,body 為 json,且必有欄位 success=truestatus 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。前人

  1. 先將原始錯誤訊息取出
  2. 將原始錯誤字串填入 {"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 

先看成功狀況:這版作者考慮到了序列化問題,因此當回傳 Nonedict, list 等可 json 序列化的物件,不需要再像白痴一樣重複寫 json.dumpsapi_expose 會自行處理序列化。

再看失敗狀況:這版試圖移除對 HTTP 底層的依賴,當 api_expose 接收到 APIException 時,直接在 decorator 攔截,按照規定的格式填好後回傳並送出。

因此,使用者可能收到的回傳值如下:

情境HTTP statusResponse 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

理論上這版應該能運作得不錯,因為

  1. 無須處理序列化問題
  2. (如果有寫對) 核心功能可以保持對 HTTP 底層的無知。

第二點尤為重要:我們仍有 script / worker 等非 HTTP 的進入點,讓這些進入點跟 HTTP 底層互相耦合,是典型的程式壞味道。

但這版最後沒有推行下去。我猜測原因是:如果將核心程式碼更改為丟出 APIException,會導致另外兩版的 API 立刻壞掉。
但如果不將核心程式碼變更為 APIException,則必須在此版的 endpoint 上不斷的手工加上 exception handling,再轉換為對應的 APIException 寫起來反而更加麻煩。

API 的設計問題
#

不管是哪種風格的 endpoint,都有一些共通的「有趣」特點,整理如下

濫用 kwargs 和 dict
#

人生看過最可怕的行為

  1. 在 endpoint input 中使用 **kwargs
  2. 在 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 的層層傳遞,才能回答兩個簡單的問題

  1. 「我可以給什麼值」
  2. 「我會收到什麼回傳值」

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")

幾個問題

  1. endpoint 應該檢查輸入資料是否合法。
  2. 核心邏輯應該只處理業務邏輯。
  3. 核心邏輯不該直接依賴 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 做法。