近一年半,我參與了兩到三個專案的工作,這些專案涉及到大量供“外部”使用的REST API,稍後我們會看到為什麼要將“外部”這個詞放在引號之中。在專案工作期間,我不得不對這些API進行反覆地設計,再設計和重構,這篇文章是我對Rest API最佳實踐的一些個人看法,希望讀者能夠從中獲益。
更好、更早地設計
對於很多語言來說,實現REST Service是一項極其微不足道的任務。換言之,無論你選擇什麼底層框架,只要輔以少量配置和程式碼,你可以在一小時之內就擁有一個REST Service。雖然對於缺乏經驗的人來說,這確實很方便,但它也很容易讓你迅速寫出一個質量低下的API。因此,在你編寫程式碼之前,先留出一分鐘的時間思考一下,試著去設計你的API,花足夠的時間去理解業務範疇,判斷客戶端需要從你的系統中獲取什麼。舉個例子,如果你的系統是針對一群硬幣收藏家所建立的資料庫,此時你需要決定的是:你是否允許客戶端新增新的硬幣,或者僅僅允許取出原有的硬幣;客戶需要什麼樣的查詢方式;如果遇上涉及大量資料檢索的請求,你如何處理它?儘早地回答這些問題能夠幫助你開發出更貼近使用者需求的API。
名稱與方法
現在已經很有多關於資源(Resource)命名和組織的討論了,在這裡我基於自己的經驗再老調重彈一下,以下是三種易於遵循的規範。
1. 只使用名詞:舉個例子,如果你想提供一項在資料庫中搜尋硬幣的服務,要避免將端點(Endpoint)命名為/searchCoins或/findCoins或/getAllCoins 等等,一個簡單的/coins就已經足夠了,當客戶端傳送一個GET請求的時候,可以獲得所有有效硬幣的集合。類似的,如果你想提供一項在資料庫中新增硬幣的服務,要避免使用諸如/addCoin或/saveCoin或/insertCointToDatabase這樣的名稱,你可以使用與上面相同的資源名稱,要改變的僅僅是用POST請求代替GET請求。同樣地,對於更新硬幣,可以使用PUT請求。
2. 如果需要獲取單個硬幣,又應該怎麼做呢?我所建議的最佳方式是在端點中加入一個引數,比如說客戶端需要拿到一個ID是20的硬幣,那麼傳送一個請求到/coins/20就足夠了。我們再來看一個更復雜的例子,如果要讓客戶端能夠為每個硬幣新增一張圖片,一個快速而醜陋的方式是/addCoinImage或/addNewImageToCoin等等,一個稍好一點的方式是/coins/addImage,但是正如我之前所說的,不應該有任何動詞存在。還記得我們之前提到的獲取某種硬幣的方法嗎?我們可以將其稍微增強一下,傳送POST請求給/coins/20/images如何?目前看起來很不錯。不過天下沒有完美的事物,假設一下,如果我們要讓一些超級使用者能夠從系統中刪除硬幣,根據我們之前的討論,一個簡單的DELETE請求傳送給/coins/{id}就足夠了,但是請你想一下,如果{id}僅僅是COINS表中的一個順序編號,那會產生多大的問題?某人可以輕易地一個接一個的傳送DELETE請求,最後系統中所有的資料全沒了。我想說的重點是,使用識別符號作為請求引數是不錯,但是前提是這些識別符號必須很難猜測或根本無法猜測。所以,如果你想要用一串序號去確定一個實體,那就忘了這種實現吧。我的建議是,不要使用資源引數,直接傳送一個DELETE請求給/coins,結合一個request body(比如json),其中含有足夠的引數能夠定位所要被刪除的實體即可。
3. 儘可能使用特定領域的名稱。如果你的業務域中有一群硬幣收藏家(Coin Collectors),那麼當你設計API的時候,應當使用collectors這個詞,而不是users或accounts。要避免使用一些意義過於寬泛的名稱,這些名稱不能表示什麼,到了客戶端又容易產生誤解。對於請求引數的命名,道理也是一樣的。另外,強烈建議給請求引數取一個儘可能短,同時又有意義的名稱,舉個例子,如果你想要查詢在某一指定年份發行的硬幣,一個很讚的引數名稱是issueYear,比較典型的反例是:year(意義不明確),yearOfFirstIssue(包含無用資訊)。
錯誤處理和響應
對於這個話題,我的經驗是讓客戶端在每次傳送請求後,無論結果是成功還是失敗,都能獲得相同格式的json響應,這將會給客戶端處理帶來極大的幫助。舉個例子,你想要新增一個新的硬幣,向/coins傳送POST請求,一個成功的響應包含以下json文件:
1 2 3 4 5 6 7 8 |
{ "meta":{ "code":200 }, "data":{ "coinId":"a7sad-123kk-223" } } |
一個錯誤的響應可能是這樣的:
1 2 3 4 5 6 7 8 9 |
{ "meta":{ "code":60001, "error":"Can not add coin", "info":"Missing one ore more required fields" }, "data":{ } } |
請注意,對所有可能的結果(成功或失敗),json響應的文件都具備相同的結構,其中有兩種基本元素:meta和data,meta包含結果資訊,在出錯的情況下,其中還會包含一個特殊的錯誤碼(error code),在錯誤碼之後,”error”表示出錯的內容,”info”表示出錯的具體描述;data是可選的,包含從伺服器返回的所有資料,就拿上面的例子來說,當新增硬幣成功後,伺服器會返回一個唯一的自動生成的識別符號,如果有錯誤,這項就為空。這種做法的優勢是,對於同一個API的各種服務型別和結果,客戶端都可以採用相同的方式進行處理。此外,當有意外情況發生時,我們也可以傳遞一些額外的資訊,正如上面例子中所展示的,”error”傳達資訊,”info”記錄日誌。我們還有一種選擇,可以基於錯誤碼去處理響應,只要明確每個數字的含義即可,請注意這些數字並非http狀態碼,你依然要為每個請求返回正確的http狀態碼(如400、401等)。
在我們討論下一節之前,我想強調另一件值得重視的事,假設我們不允許刪除硬幣,但是客戶端嘗試向/coins/{id}傳送一個DELETE請求,通常情況下Web容器會返回一個405的狀態碼,但我發現,如果我們對這些響應進行過濾並返回相同的json文件,會很有幫助。比如我們可以返回:
1 2 3 4 5 6 7 8 9 |
{ "meta":{ "code":405, "error":"Method not allowed for the /coins/{id} resource", "info":"Method DELETE is not allowed for that resource. Available methods : GET, POST, OPTIONS" }, "data":{ } } |
這比原來好多了,不是嗎?現在,響應內容不但包含原有的資訊(405狀態碼),還通知客戶端該資源可用的方法。
文件
最後但也是最重要的一點,花一點時間,提供一份專業的、對開發人員友好的文件,並保證及時更新,一份過期文件的危害性比沒有文件更甚。你可以使用一些開源免費的工具對你的API進行文件化。再好一點的做法是,對每一項資源的使用方式都能提供範例,對成功或錯誤的響應都能提供預期結果。不要忘了,在最後要記錄下每一個錯誤碼並提供完整的資訊,這樣客戶端才能在錯誤發生時做出反應,有一些客戶端不會理會你的響應內容,它們會根據你的錯誤碼自行提供資訊。
我還有若干個更為實用的建議待寫,特別是關於API的版本控制和安全性方面的建議,但我想它們更適合在另一篇博文中進行探討。