在 談 API 的撰寫 - 總覽 里我們談到了做一個 API 系統的基本思路和一些組件的選型,今天談談架構。
部署
首先要考慮的架構是部署的架構。部署的方案往往會深刻影響着系統的結構。我們需要問自己一個問題:從宏觀上看,這個系統我們希望如何進行部署?
很多 API 系統是這樣部署的(方案一):
(load balancer 和 nginx proxy (web server) 可能是同一個 cluster。這里邏輯上把他們划分開來。)
這是很典型的做法,所有的 API 在一套系統里部署,簡單,高效,比較容易上手。然而,隨着時間的推移,功能的復雜,這樣的系統會越來越不堪重負。比如說我們做一個內容發布平台的 API 系統(類似於知乎日報),起初我們可能只需要內容相關的 API,漸漸地你要加入統計(tracking)相關的 API,然后我們又需要用戶行為相關的 API,它們各自訪問不同的數據源,行為方式也大不相同(內容相關的 API 可以做 cache,而統計和用戶行為相關的 API 不能 cache)等等,當這些邏輯結構各異的 API 被揉進一個系統里時,這個系統會越來越難以維護。
所以,這樣的部署方案會演進成下面的部署方案(方案二):
我們把 API 按照功能做了拆分,load balancer / nginx proxy 之后是一個個 API application。它們有自己的 load balancer / nginx proxy,可以按照每個 API application 本身的規模進行 scale up / down。比如說內容相關的 API,訪問量(折合成運算量)是用戶相關的 API 的 5 倍,那么,部署的時候我們可以把資源按照 5:1 的比例部署;再比如在高峰期整個系統負載過大,可以把統計 API 關掉,在 proxy 側直接返回 503,把節省的資源配置到其他地方。
這里談到的部署方案刻意忽略了一些細節,比如說日志如何收集和管理,服務本身的監控和信息的收集(APM)等沒有提及。它們是部署方案中的關鍵環節,但畢竟本文不是專門講部署的,故而忽略。
顯而易見地,方案一和方案二的軟件架構也會有所不同。方案二中,兩個 API application 間的訪問會通過 RPC(也可以使用 HTTP,但效率略低)完成,而方案一種,可能直接就是一個 function call 或者直接訪問對方的數據庫。方案二是一種分治的思想,把大的問題變成一條公共路徑上若干相似的小問題的解決。
Pipeline
接下來的文章中,我們以方案二為藍本,描述一個 API application 的架構。之前我們提到了這些目標:
-
A well defined pipeline to process requests
-
REST API done right (methods, status code and headers)
-
Validation made easy
-
Security beared in mind
-
Policy based request throttling
-
Easy to add new APIs
-
Easy to document and test
-
Introspection
除了后面三個,其他都和 API 處理的 pipeline 有關。我們知道,一個 API 的執行,從 request 到 response,整個 pipeline 能夠划分成幾個階段:request -> pre-processing -> processing -> post-processing -> response。其中,"processing" 指的是 API 路由真正執行的代碼。好的架構應該盡可能把 API 執行路徑上的各種處理都抽象出來,放到公共路徑(或者叫中間件,middleware)之中,為 API 的撰寫者掃清各種障礙,同時能夠促使 API 更加標准化。
下圖是我構思的一個 pipeline,它並不是最好的,但最能反映我的思想:
我們詳細說說這個 pipeline 下的每個組件:
-
throttling:API 應該有最基本的訪問速度的控制,比如,對同一個用戶,發布 tweet 的速度不可能超過一個閾值,比如每秒鍾 1 條(實際的平均速度應該遠低於這個)。超過這個速度,就是濫用(abuse),需要制止並返回 429 Too many requests。throttling 可以使用 leaky bucket 實現(restify 直接提供)。
-
parser / validation:接下來我們要解析 HTTP request 包含的 headers,body 和 URL 里的 querystring,並對解析出來的結果進行 validation。這個過程可以屏蔽很多服務的濫用,並提前終止服務的執行。比如你的 API 要求調用者必須提供 X-Client-Id,沒有提供的,或者提供的格式不符合要求的,統統拒絕。這個步驟非常重要,如同我們的皮膚,將骯臟的世界和我們的器官隔離開來。
-
ACL:除了基本的 throttling 和 validation 外,控制資源能否被訪問的另一個途徑是 ACL。管理員應該能夠配置一些規則,這些規則能夠進一步將不合法 / 不合規的訪問過濾掉。比如說:路徑為 "/topic/19805970" 的知乎話題,北京時間晚上10點到次日早上7點的時間端,允許在中國大陸顯示。這樣的規則可以是一個復雜的表達式,其觸發條件(url)可以被放置在一個 bloom filter 里,滿足 filter 的 url 再進一步在 hash map 里找到其對應的規則表達式,求解並返回是否允許顯示。
-
normalization:顧名思義,這個組件的作用是把請求的內容預處理,使其統一。normalization 可以被進一步分為多個串行執行的 strategy,比如:
-
paginator:把 request 里和 page / sort 相關的信息組合起來,生成一個 paginator。
-
client adapter:把 API client 身份相關的信息(device id,platform,user id,src ip,...)組合成一個 adapter。
-
input adapter:輸入數據的適配。這是為處女座准備的。很多時候,輸入數據的格式和語言處理數據的格式不一樣,這對處女座程序員是不可接受的。比如說 API 的輸入一般是 snake case(show_me_the_money),而在某些語言里面(如: javascript),約定俗成的命名規則是 showMeTheMoney,所以把輸入的名稱轉換有利於對代碼有潔癖的程序員。
-
authentication:用戶身份驗證。這個不多說,主要是處理 "Authorization" 頭。對於不需要驗證的 API,可以跳過這一步。做 API,身份驗證一定不要使用 cookie/session based authentication,而應該使用 token。現有的 token base authentication 有 oauth, jwt 等。如果使用 jwt,要注意 jwt 是 stateless 的 token,一般不需要服務器再使用數據庫對 token 里的內容校驗,所以使用 jwt 一定要用 https 保護 token,並且要設置合適的超時時間讓 token 自動過期。
-
authorization:用戶有了身份之后,我們進一步需要知道用戶有什么樣的權限訪問什么樣的資源。比如:uid 是 9527 的用戶對 "POST /topic/"(創建一個新的話題),"PUT /topic/:id"(修改已有的話題)有訪問權限,當他發起 "DELETE /topic/1234" 時,在 authorization 這一層直接被拒絕。authorization 是另一種 ACL(role based ACL),處理方式也類似。
-
conditional request:在訪問的入口處,如果訪問是 PUT/PATCH 這樣修改已有資源的操作,好的 API 實現會要求客戶端通過 conditional request(if-match / if-modified)做 concurrent control,目的是保證客戶端要更新數據時,它使用的是服務器的該數據的最新版本,而非某個歷史版本,否則返回 412 precondition failed。
-
preprocessing hook:稍后講。
-
processing:API 本身的處理。這個一般是 API 作者提供的處理函數。
-
postprocessing:稍后講。
-
conditional request:在訪問的出口處,如果訪問的是 GET 這樣的操作,好的 API 實現會支持客戶端的 if-none-match/if-not-modified 請求。當條件匹配,返回 200 OK 和結果,否則,返回 304 Not Modified。304 Not Modified 對客戶端來說如同瑰寶,除了節省網絡帶寬之外,客戶端不必刷新數據。如果你的 app 里面某個類別下有五十篇文章,下拉刷新的結果是 304 Not Modified,客戶端不必重繪這 50 篇文章。當然,有不少 API 的實現是通過返回的數據中的一個自定義的狀態碼來決定,這好比「脫褲子放屁」—— 顯得累贅了。
-
response normalization:和 request 階段的 normalization 類似,在輸出階段,我們需要將結果轉換成合適的格式返回給用戶。response normalization 也有很多 strategy,比如:
-
output adapter:如果說 input adapter 是為有潔癖的程序員准備的,可有可無,那么 output adapter 則並非如此。它能保持輸出格式的一致和統一。比如你的數據庫里的字段是 camel case,你的程序也都是用 camel case,然而 API 的輸出需要統一為 snake case,那么,在 output adapter 這個階段統一處理會好過每個 API 自己處理。
-
aliasing:很多時候,你獲得的數據的名稱和定義好的 API 的接口的名稱並不匹配,如果在每個 API 里面單獨處理非常啰嗦。這種處理可以被抽取出來放在 normalization 的階段完成。API 的撰寫者只需要定義名稱 A 需要被 alias 成 B 就好,剩下的由框架幫你完成。
-
partial response:partial response 是 google API 的一個非常有用的特性(見:https://developers.google.com/+/web/api/rest/#partial-response ),他能讓你不改變 API 實現的情況下,由客戶端來決定服務器返回什么樣的結果(當前結果的一個子集),這非常有利於節省網絡帶寬。
-
serialization:如果 API 支持 content negotiation,那么服務器在有可能的情況下,優先返回客戶端建議的輸出類型。同一個 API,android 可以讓它返回 application/msgpack;web 可以讓它返回 application/json,而 xbox 可以獲得 application/xml 的返回,各取所需。
-
postserialization:這也是個 hook,在數據最終被發送給客戶端前,API 調用者可以最后一次 inject 自己想要的邏輯。一般而言,一些 API 系統內部的統計數據可以在此收集(所有的出錯處理路徑和正常路徑都在這里交匯)。
多說兩句 response normalization,如果在這一層做得好,很多 API 里面啰啰嗦嗦處理的事情都能被處理的很干凈。你只需要一套嚴格測試過的代碼,就可以讓所有的 API 在輸出時大為受益。比如:
在經過 response normalization:
-
output adapter 把 camel case 變成 snake case,所以 errorName -> error_name
-
aliasing(如果定義了 error_name -> err_name)把 error_name 轉換為 err_name
-
如果客戶端訪問時只想要 err_name / err_msg,那么 partial response 只返回這兩個域
返回結果如下:
這樣的一個 pipeline 從具體的 API 的行為中抽象化出了一個 API 處理的基本流程,並且很容易在幾個 hook 處進行擴展。
以上的描述基本上和語言,框架無關。回到 node 和 restify 本身,我們會發現,有些事情並不好處理。比如說,在 restify 里,一個路由的 action 往往就會直接調用 res.send()
發送數據,那么,post-processing 的各種行為如何能夠注入?如果是從頭開始構建一個框架,那么,pipeline 里的每個組件返回一個 Promise 或者 Observable,將其串聯起來就可以了,但在 restify 里,你無法這么干。對於這樣一個具體的問題,我采用的方法是使用 python 中 wraps
類似的方式:
然后通過監聽 'beforeSend','afterSend' 兩個事件來起到注入邏輯的效果。這樣雖說是個 hack,但是是眼下可能最好的解。
在 node.js 這樣的異步系統里還要注意,event emit 的監聽函數如果是異步的,處理起來的順序可能並非如你所願,為此,我開發了一個 eventasync
庫,對 node.js 的 event emitter 做 monkey patch,使其支持 async listerner。
接口
理順了 pipeline,整個架構基本就清晰了,接下來要考慮提供一個什么樣的接口讓 API 的寫作能夠高效。restify 提供的接口:
雖然很簡單,但是很難滿足我們對於 pipeline 的需求,比如說,validation。如何做 validation 只能是某個 API 的作者來做決策,框架來收集這些決策信息並在 pre-processing 階段執行。所以,我們必須在路由初始化之前收集這一信息;此外,還有很多信息,如一條路由是否需要 authentication,如何做 alias,這些信息都需要 API 的撰寫者提供給框架,而框架來收集。所以,作為一個框架,我們需要一個更好的 interface 提供給 API 的撰寫者。這是我的 proposal:
這個接口包含幾重信息:
-
路由接受 POST method
-
路由的 path 是
/logout
-
路由有一個很詳細的 markdown 撰寫的文檔(還記得我們的需求是:easy to document 么?)
-
其接受一個參數為 (req, res, next) 的 action function(也可以是多個)
-
其對 body 提供一個 joi validator(除 body 外,也可以對 header,param 和 query 做 validation)
-
使用這個 API 需要 authentication,調用完畢后要記錄 audit trail
通過這樣一個接口,我們把 API 系統區隔為「編譯時」和「運行時」。這個接口寫出來的 API,更像是一個等待編譯的源文件。在 API 系統啟動的時候,會經歷一個「編譯」的過程,把所有的 route
匯總起來,生成 restify 認識的路由形式,同時,收集里面的各種信息(比如 validator,authentication),供框架的各個 middleware 使用。
不要小看這樣一個接口上的改變和「編譯時」/「運行時」的區分,它除了可以讓 API 的各個信息無縫地和 pipeline 對接,還能夠實現我們期望的 introspection:
(通過 route
生成的 swagger 文檔,供 API 使用者使用)
(通過 route
生成的 cli 文檔,供 API 開發者 introspection)
相信通過這個接口,你能夠更好地理解 David Wheeler 的那句:
All problems in computer science can be solved by another level of indirection.
轉載: