我所認為的RESTful API最佳實踐

光、夜雨微涼發表於2019-05-10

我所認為的RESTful API最佳實踐

不要糾結於無意義的規範

在開始本文之前,我想先說這麼一句:RESTful 真的很好,但它只是一種軟體架構風格,過度糾結如何遵守規範只是徒增煩惱,也違背了使用它的初衷。

就像 Elasticsearch 的 API 會在 GET 請求中直接傳 JSON,但這是它的業務需要,因為普通的 Query Param 根本無法構造如此複雜的查詢 DSL。Github 的 V3 API 中也有很多不符合標準的地方,這也並不會妨礙它成為業界 RESTful API 的參考標準。

我接下來要介紹的一些東西也會跟標準不符,但這是我在實際開發中遇到過、困擾過、思考過所得出的結論,所以才是我所認為的RESTful API 最佳實踐。

為什麼要用 RESTful

RESTful 給我的最大感覺就是規範、易懂和優雅,一個結構清晰、易於理解的 API 完全可以省去許多無意義的溝通和文件。並且 RESTful 現在越來越流行,也有越來越多優秀的周邊工具(例如文件工具 Swagger)。

協議

如果能全站 HTTPS 當然是最好的,不能的話也請儘量將登入、註冊等涉及密碼的介面使用 HTTPS。

版本

API 的版本號和客戶端 APP 的版本號是毫無關係的,不要讓 APP 將它們用於提交應用市場的版本號傳遞到伺服器,而是提供類似於v1v2之類的 API 版本號。版本號只允許列舉,不允許判斷區間。

版本號拼接在 URL 中或是放在 Header 中都可以。例如:

api.xxx.com/v1/users

或:

api.xxx.com/users

version=v1

請求

一般來說 API 的外在形式無非就是增刪改查(當然具體的業務邏輯肯定要複雜得多),而查詢又分為詳情和列表兩種,在 RESTful 中這就相當於通用的模板。例如針對文章(Article)設計 API,那麼最基礎的 URL 就是這幾種:

  • GET /articles: 文章列表
  • GET /articles/id:文章詳情
  • POST /articles/: 建立文章
  • PUT /articles/id:修改文章
  • DELETE /articles/id:刪除文章

RESTful 中使用 GET、POST、PUT 和 DELETE 來表示資源的查詢、建立、更改、刪除,並且除了 POST 其他三種請求都具備冪等性(多次請求的效果相同)。需要注意的是 POST 和 PUT 最大的區別就是冪等性,所以 PUT 也可以用於建立操作,只要在建立前就可以確定資源的 id。

將 id 放在 URL 中而不是 Query Param 的其中一個好處是可以表示資源之間的層級關係,例如文章下面會有評論(Comment)和點贊(Like),這兩項資源必然會屬於一篇文章,所以它們的 URL 應該是這樣的:

評論:

  • GET /articles/aid/comments: 某篇文章的評論列表
  • GET /comments/cid: 獲取
  • POST /articles/aid/comments: 在某篇文章中建立評論
  • PUT /comments/cid: 修改評論
  • DELETE /comments/cid: 刪除評論

    這裡有一點比較特殊,永遠去使用可以指向資源的的最短 URL 路徑,也就是說既然/comments/cid已經可以指向一條評論了,就不需要再用/articles/aid/comments/cid特意的指出所屬文章了。

    點贊:

  • GET /articles/id/like:檢視文章是否被點贊

  • PUT /articles/id/like:點贊文章
  • DELETE /articles/id/like:取消點贊

RESTful 中不建議出現動詞,所以可以將這種關係作為資源來對映。並且由於大部分的關係查詢都與當前的登入使用者有關,所以也可以直接在關係所屬的資源中返回關係狀態。例如點贊狀態就可以直接在獲取文章詳情時返回。注意這裡我選擇了 PUT 而不是 POST,因為我覺得點贊這種行為應該是冪等的,多次操作的結果應該相同。

Token 和 Sign

API 需要設計成無狀態,所以客戶端在每次請求時都需要提供有效的 Token 和 Sign,在我看來它們的用途分別是:

  • Token 用於證明請求所屬的使用者,一般都是服務端在登入後隨機生成一段字串(UUID)和登入使用者進行繫結,再將其返回給客戶端。Token 的狀態保持一般有兩種方式實現:一種是在使用者每次操作都會延長或重置 TOKEN 的生存時間(類似於快取的機制),另一種是 Token 的生存時間固定不變,但是同時返回一個重新整理用的 Token,當 Token 過期時可以將其重新整理而不是重新登入。
  • Sign 用於證明該次請求合理,所以一般客戶端會把請求引數拼接後並加密作為 Sign 傳給服務端,這樣即使被抓包了,對方只修改引數而無法生成對應的 Sign 也會被服務端識破。當然也可以將時間戳、請求地址和 Token 也混入 Sign,這樣 Sign 也擁有了所屬人、時效性和目的地。

統計性引數

我不太清楚這類引數具體該被稱為什麼,總之就是使用者的各種隱私【誤。類似於經緯度、手機系統、型號、IMEI、網路狀態、客戶端版本、渠道等,這些引數會經常收集然後用作運營、統計等平臺,但是在大部分情況下他們是與業務無關的。這類引數變化不頻繁的可以在登入時提交,變化比較頻繁的可以用輪訓或是在其他請求中附加提交。

業務引數

在 RESTful 的標準中,PUT 和 PATCH 都可以用於修改操作,它們的區別是 PUT 需要提交整個物件,而 PATCH 只需要提交修改的資訊。但是在我看來實際應用中不需要這麼麻煩,所以我一律使用 PUT,並且只提交修改的資訊。

另一個問題是在 POST 建立物件時,究竟該用表單提交更好些還是用 JSON 提交更好些。其實兩者都可以,在我看來它們唯一的區別是 JSON 可以比較方便的表示更為複雜的結構(有巢狀物件)。另外無論使用哪種,請保持統一,不要兩者混用。

還有一個建議是最好將過濾、分頁和排序的相關資訊全權交給客戶端,包括過濾條件、頁數或是遊標、每頁的數量、排序方式、升降序等,這樣可以使 API 更加靈活。但是對於過濾條件、排序方式等,不需要支援所有方式,只需要支援目前用得上的和以後可能會用上的方式即可,並通過字串列舉解析,這樣可見性要更好些。例如:

搜尋,客戶端只提供關鍵詞,具體搜尋的欄位,和搜尋方式(字首、全文、精確)由服務端決定:

/users/?query=ScienJus

過濾,只需要對已有的情況進行支援:

/users/?gender=1

對於某些特定且複雜的業務邏輯,不要試圖讓客戶端用複雜的查詢參數列示,而是在 URL 使用別名:

/users/recommend

分頁:

/users/?offset=10&limit=10

/articles/?cursor=2015-01-01 15:20:30&limit=10

/users/?page=2&pre_page=20

排序,只需要對已有的情況進行支援:

/articles/sort=-create_date

PS:我很喜歡這種在欄位名前面加-表示降序排列的方式。

響應

儘量使用 HTTP 狀態碼,常用的有:

  • 200:請求成功
  • 201:建立、修改成功
  • 204:刪除成功
  • 400:引數錯誤
  • 401:未登入
  • 403:禁止訪問
  • 404:未找到
  • 500:系統錯誤

    但是有些時候僅僅使用 HTTP 狀態碼沒有辦法明確的表達錯誤資訊,所以我傾向於在裡面再包一層自定義的返回碼,例如:

    成功時:

{
    "code": 100,
    "msg": "成功",
    "data": {}
}

失敗時:

{
    "code": -1000,
    "msg": "使用者名稱或密碼錯誤"
}

data是真正需要返回的資料,並且只會在請求成功時才存在,msg只用在開發環境,並且只為了開發人員識別。客戶端邏輯只允許識別code,並且不允許直接將msg的內容展示給使用者。如果這個錯誤很複雜,無法使用一段話描述清楚,也可以在新增一個doc欄位,包含指向該錯誤的文件的連結。

返回資料

JSON 比 XML 視覺化更好,也更加節約流量,所以儘量不要使用 XML。

建立和修改操作成功後,需要返回該資源的全部資訊。

返回資料不要和客戶端介面強耦合,不要在設計 API 時就考慮少查詢一張關聯表或是少查詢 / 返回幾個欄位能帶來多大的效能提升。並且一定要以資源為單位,即使客戶端一個頁面需要展示多個資源,也不要在一個介面中全部返回,而是讓客戶端分別請求多個介面。

最好將返回資料進行加密和壓縮,尤其是壓縮在移動應用中還是比較重要的。

分頁

在 APP 後端分頁設計 中提到過,分頁佈局一般分為兩種,一種是在 Web 端比較常見的有底部分頁欄的電梯式分頁,另一種是在 APP 中比較常見的上拉載入更多的流式分頁。這兩種分頁的 API 到底該如何設計呢?

電梯式分頁需要提供page(頁數)和pre_page(每頁的數量)。例如:

/users/?page=2&pre_page=20

而服務端則需要額外返回total_count(總記錄數),以及可選的當前頁數、每頁的數量(這兩個與客戶端提交的相同)、總頁數、是否有下一頁、是否有上一頁(這三個都可以通過總記錄數計算出)。例如:

{
    "pagination": {
       "previous": 1,
       "next": 3,
       "current": 2,
       "per_page": 20,
       "total": 200,
       "pages": 10
    },
    "data": {}
}

流式佈局也完全可以使用這種方式,並且不需要查詢總記錄數(好處是減少一次資料庫操作,壞處時客戶端需要多請求一次才能判斷是否到最後一頁)。但是會出現資料重複和缺失的情況,所以更推薦使用遊標分頁。

遊標分頁需要提供cursor(下一頁的起點遊標) 和limit(數量) 引數。例如:

/articles/?cursor=2015-01-01 15:20:30&limit=10

如果文章列表預設是以建立時間為倒序排列的,那麼cursor就是當前列表最後一條的建立時間(第一頁為當前時間)。

服務端需要返回的資料也很簡單,只需要以此遊標為起點的總記錄數和下一個起點遊標就可以了。例如:

{
    "pagination": {
       "next": "2015-01-01 12:20:30",
       "limit": 10,
       "total": 100,
    },
    "data": {}
}

如果total小於limit,就說明已經沒有資料了。

流式佈局的分頁 API 還有一種情況很常見,就是下拉重新整理的增量更新。它的業務邏輯正好和遊標分頁相反,但是引數基本一樣:

/articles/?cursor=2015-01-01 15:20:30&limit=20

返回資料有兩種可能,一種是增量更新的資料小於指定的數量,就直接將全部資料返回(這個數量可以設定的相對大一些),客戶端會將這些增量更新的資料新增在已有列表的頂部。但是如果增量更新的資料要大於指定的數量,就會只返回最新的 n 條資料作為第一頁,這時候客戶端需要清空之前的列表。例如:

{
    "pagination": {
       "limit": 20,
       "total": 100,
    },
    "data": {}
}

如果total大於limit,說明增量的資料太多所以只返回了第一頁,需要清空舊的列表。

相關文章