設計一套良好 REST API

設計一套良好 REST API

硅谷的apigee公司給出一份對REST API的設計指導原則,可以說這家公司在api開發,管理的成績有目共睹。其提供的指導原則,可以說結合了其自身實際開發經驗,諸多大型平臺的實際運營經驗和標準http規範。非常值得一讀。

首先,你需要對REST API有一個基本的概念認知,然後再深入閱讀:

1. 基於業務領域的數據建模,而非基於功能建模。

例如,取得所有的dog

GET /api/dogs

取得一個特定的dog

GET /api/dogs/{id}

取得特定名字的dogs

GET /api/dogs/?name=xxx

創建一個dog

POST /api/dogs

更改一個dog

PUT /api/dogs/{id}

刪除一個dog

DELETE /api/dogs/{id}

標準的HTTP的方法已經提供了一套約定俗成的操作語義。

而一個基於功能建模的api,通常會是下面的樣子:

/getAllDogs/getDogsByNames/getAllBabyDogs/createDogs/createThreeDogs/saveDogs

以上這些我經常在新手的代碼裡看到。這樣做的代碼,沒錯,可以運行,但是不‘標準’ why?基於功能建模的api,首先會造成學習曲線的增長,不容易上手,也往往意味著需要記憶大量的url(swagger會解決一部分這個問題,但不是全部)。

使用HTTP的標準方法作為操作數據的基本語義,勝在其標準的普適性。這一點上,大的平臺Github,Heroku等,做的是最好的。

2. 設計數據的表現形式

毫無疑問,使用JSON,今天JSON已經是事實上的web數據標準,簡單易懂。但是JSON也有其缺點,比如如何表達Date和Timestamp,一個簡單的做法是用string來表達,根據上下文來判斷如何解釋。儘量保持JSON數據的簡潔性。

並且使用link去表達資源之間的聯繫:

例如:

{"id": "12345678","kind": "Dog""name": "Lassie","furColor": "brown","ownerID": "98765432"}

更好的方法:

{"id": "12345678","kind": "Dog""name": "Lassie","furColor": "brown","ownerID": "98765432","ownerLink": "https://dogtracker.com/persons/98765432"}

更簡潔的表示:

{"id": "12345678","kind": "Dog""name": "Lassie","furColor": "brown","owner": "https://dogtracker.com/persons/98765432"}

這樣做的好處是客戶端不需要自己重新構建URL去獲取owner,更方便使用。Google Drive API 和 Github 均使用這種方式。但這樣做也不是沒有壞處,最容易想到的是,如果url改變了怎麼辦?production,staging,testing用的是不一樣的domain喲。一個方法是:使用相對路徑,而不是絕對路徑。但這也不能解決全部問題。

3. 設計URL的表現形式

兩大原則:規範的(regular),可預測的(predictable)。

  1. 只使用名詞

  2. 要有切入點

  3. 要合適的選擇id形式

  4. 要表達資源間的聯繫

  5. 要支持查詢

  6. 要支持返回部分資源

  7. 處理更復雜的計算邏輯

只使用名詞:在URL中使用名詞,避免動詞,一旦使用動詞,意味著你是在對功能建模,而非數據。

要有切入點:原則上來講,一個api應該有一個root path '/', 其返回一個url map,包括了所有的resouces所對應的url。這樣客戶端更容易去發現和使用api。

要合適的選擇id形式:例如api/dogs/{id} 中的{id}如何表達,是類似於‘/dogs/1’,還是‘/dogs/haha’? 一般來說取決於後端的數據庫,大多數情況下,使用RDBMS,像mysql之類的主鍵自增功能,我比較傾向於使用自增主鍵的整數直接作為entity的id,避免很多問題,如果使用MongoDB的話,不妨試一試用字符串作為entity的id,可讀性會提高,但是如何維護一個全局唯一的字符主鍵,你得三思。

要表達資源間的聯繫:比如 GET /persons/1/dogs 返回所有屬於person 1的狗。

這種模式可以表述為:

/{relationship-name}[/{resource-id}]/…/{relationship-name}[/{resource-id}]

要支持查詢:

GET /persons;1/dogs GET /persons;name=blabla/dogs

這種模式可以表述為:

/{relationship-name}[;{selector}]/…/{relationship-name}[;{selector}]

更復雜的查詢條件:

GET /dogs?color=red&state=running&location=park

注意這裡的三個查詢子條件之間的關係是‘與(and)’,如果要表達是‘或(or)’的邏輯,那就得設計更復雜的query解釋機制。但其實實際使用中,表達‘或’的查詢條件很少使用。

要支持返回部分資源:

/dogs?fields=name,color,location

返回的resource中,只包含name,color,location三種信息。

處理更復雜的計算邏輯: 有很多例子,比如貨幣轉換,大多數開發人員給出的方案是:

/convert/100/EUR/CNY 或者 /convert?quantity=100&unit=EUR&in=CNY

切記:URL是用來表述資源resource,而不是表述計算的過程。在URL中使用名詞,避免動詞。

改進的方案1:

GET /monetary-amount/100/EUR HTTP/1.1Host: Currency-Converter.comAccept-Currency:CNY //在http的header中添加Accept-Currency來指明貨幣的種類。

改進的方案2:

POST /currency-converter HTTP/1.1Host: Currency-Converter.comContent-Length: 69{"amount": 100,"inputCurrency": "EUR","outputCurrency": "CNY"} 

兩個方案都可行,但是方案2有兩個注意的地方:POST返回的結果可能無法再server端緩存;你是在構建一個計算的過程,而非資源的表述,如何理解?就像數據庫操作中的‘store procedure’,你可以使用,並且功能強大,但是接口變得複雜,邏輯變得耦合。

4. 反思-設計數據的表現形式。

  1. 添加self link

  2. 集合數據

  3. 數據分頁

  4. 數據格式

添加self link:self link提供了一個上下文環境,客戶端可以更容易理解當前的resource的位置

{"user": { "html_url": "octocat (The Octocat)", "type": "User", "url": "https://api.github.com/users/octocat"}}

集合數據:

方案1:集合collection也是一種resource,也具有self和kind屬性,這樣所有的單獨entity和collection都具有更加統一的規範

{"self": "https://dogtracker.com/dogs","kind": "Collection","contents": [{"self": "https://dogtracker.com/dogs/12344","kind": "Dog","name": "Fido","furColor": "white"},{"self": "https://dogtracker.com/dogs/12345","kind": "Dog","name": "Rover","furColor": "brown"}]}

方案2:看起來更加簡潔,但是客戶端可能需要去添加額外的邏輯去處理collection

[{"self": "https://dogtracker.com/dogs/12344","kind": "Dog","name": "Fido","furColor": "white"},{"self": "https://dogtracker.com/dogs/12345","kind": "Dog","name": "Rover","furColor": "brown"}]

還有一種做法是,針對一個collection,使用自定義的media type header(比如‘Collection+JSON’)這個方法可行,但是會讓客戶端的處理邏輯複雜。

數據分頁:當數據返回的集合變大時,顯然不可能一次性把所有數據都返回給客戶端,最好能分批的返回,比如:

GET https://dogtracker.com/dogs?limit=25,offset=0返回{"self": "https://dogtracker.com/dogs?limit=25,offset=0","kind": "Page","pageOf": "https://dogtracker.com/dogs","next": "https://dogtracker.com/dogs?limit=25,offset=25","contents": [...】}

在返回的數據中,加入‘pageOf’來指明查詢的起點,‘next’指明下一頁的url,當返回第二頁的時候,還需加入‘previous’來指明上一頁。

數據格式:現在大多數的api幾乎只支持JOSN格式的數據來作為input和output,如果要支持更多的數據格式,那麼應該要支持Http Accept Header。

能否用HTML作為輸出的格式?可以,但是這樣就喪失的rest API的靈活性。現代的web應用,大多使用REST API + SPA的設計,SPA端使用Angular等框架,自己渲染HTML,REST API只提供數據服務,前端後端通過JSON數據來交流,從而實現了前後端的徹底解耦。

如果選擇JSON作為唯一的數據格式,那麼最好支持Http的patch方法,現在有兩種patch的模式:JSON Patch和JSON Merge Patch,選擇一個來用於資源的更新操作。現在也有很多API只提供PUT來更新資源,這意味著每次請求都必須發送整個resource enrity,勢必會消耗更多的payload,但是實現起來更容易。

5. 錯誤處理

總的原則:使用標準http的status code來表示錯誤的類型。具體的錯誤內容,也要被返回。

人生苦短,使用OAuth2。最起碼也要使用基於token的鑑權模式。

7. SDK

可以推出SDK來作為你的REST API的一個補充,就像AWS那樣,針對每一個服務,都有相應的編程語言的SDK。這樣更方便第三方的開發人員使用你的api。多見於SaaS平臺。但是小型的平臺,得考慮維護的成本。

8. Versioning 多版本

REST API的版本控制問題是一個非常有爭議的話題,網上的提議有很多,在這裡我們不是簡單的給定具體的方法,而是提供幾種可行的想法,具體的實施還需自己拿捏:

  1. 不(顯式)支持多版本

  2. 使用Http Accept Header

第一種,什麼都不做,不支持多版本的api。這個想法的背後依據是,根據調研發現,大多數的中小型規模的平臺服務,客戶規模都在一個可控的範圍,api的升級不會很頻繁,你只需通知你的客戶,在某個時間點api會更新,然後再server端做一些兼容性的數據遷移,比如增加或刪除某個數據庫中的表的某個列的名字。大多數情況下,支持多版本api費力不討好,測試和部署的成本很大,收益卻很小。你要做就是保持唯一個可用api服務的兼容性,而不是提供多個版本的api讓用戶使用。

第二種,如果你一定要支持versioning,那麼就在http的accept header中添加version信息,不要在url中使用version信息,千萬不要用/api/v1/xxx。

實際的工作中,對於剛入職的小盆友,在介紹REST API的時候,我會推薦他們讀這個。

具體的可以參考:Zalando RESTful API and Event Scheme Guidelines其中的規範涵蓋了很多細節內容,細節的規範越多,代碼風格才越統一,團隊溝通效率才越高。


分享到:


相關文章: