這是一個建立於 的文章,其中的資訊可能已經有所發展或是發生改變。
聲明
本文只是我個人在閱讀資料與工程實踐中的總結,可能並不是最好的實踐。但希望可以給對RESTful API 設計與工程實踐有疑惑的讀者一些協助。
前言
RESTful 原則由 Roy Fielding 在他的論文的第五章中提出。
RESTful API 之於後端開發人員,就像 UI 之於 UI 設計師。RESTful API 與所有 UI 一樣,標準、友好、一致的使用者體驗是極其重要的。為了達到上面的目標,API 需要滿足以下要求:
- 儘可能的遵守有關 WEB 規範和常見約定
- 調用介面簡單明了,可讀性強,沒有歧義
- API 風格保持一致,調用規則,傳入參數和返回資料有統一的標準
- 能夠為用戶端提供簡單靈活的資料訪問方式
- 高效、安全、易擴充
安全
使用 HTTPS 保證串連安全
使用 HTTPS 來保證整個 API 呼叫過程的安全,這可以有效防止竊聽和篡改。
另外,由於通訊有了安全保障,可以使用更方便的 Token 機制來簡化驗證流程,而不必再為每個請求籤名。
對於非 HTTPS 的 API 呼叫,不要將其重新導向到 HTTPS,而要直接返回調用錯誤以禁止不安全的調用。
使用 JWT 實現 Authorization 機制
基於 JWT 的認證機制是無狀態認證機制中比較好的方案。具體的實現與使用方法,請閱讀 JWT。
在使用 JWT 時,請務必使用 HTTPS。
API 的設計
版本控制
API 的迭代是必然的。為了保證在迭代的過程中,不會由於 API 頻繁迭代而損害開發人員的利益,為 API 進資料列版本設定是很有必要的。
關於版本資訊的儲存位置有兩種觀點:
理論上講,確實是應該放在 HTTP 頭中。但個人覺得更好的實踐還是放在 URL 中,這樣可以更加直觀地看到當前正在使用的 API 的版本。
不過,也有權衡兩者的使用方法 —— Strip API versioning:
- URL 中放置主要版本號,以標明 API 的總體結構
- HTTP 頭中放置基於時間的次版本號碼,以標明 API 的微小變化,如參數欄位的廢棄,Endpoint 的變化等
123 |
GET https://api.stripe.com/v1/charges HTTP/1.1Stripe-Version: 2017-01-27 |
實現方式:
- API 呼叫者在 Request Headers 中添加版本資訊以標示自己所請求的 API 版本
- API 提供者在 Response Headers 中添加與 Request Headers 對應的版本資訊以標示所響應的 API 版本
路由
RESTful API 中路由設計的關鍵在於「將 API 分解成邏輯上的資源,並通過擁有具體含義的 HTTP 方法(GET、POST、PUT、PATCH、DELETE)對資源進行修改」。
比如:
GET /posts - 擷取 post 列表
GET /posts/8 - 擷取指定的 post
POST /posts - 建立一個新的 post
PUT /posts/8 - 更新 ID 為 8 的 post
PATCH /posts/8 - 部分更新 ID 為 8 的 post
DELETE /posts/8 - 刪除 ID 為 8 的 post
其中,有幾點有需要注意。
使用名詞命名資源,而不是動詞
閱讀更多。
Endpoint 使用複數,而不是單數
即使使用複數形式表示單個資源是錯的,但是為了保證 URL 格式的一致,也請始終使用複數形式。另外,不要處理英語中的特殊單詞的複數變換,比如 goose/geese。
資源之間的關聯關係
如果兩種資源之間存在關聯關係,比如 posts 與 comments 之間的關聯關係,可以通過一下形式將 comment 映射到 post 的路由上:
GET /posts/8/comments - 擷取 posts #8 的 comment 列表
GET /posts/8/comments/5 - 擷取 post #8 的 comment #5 的內容
POST /posts/8/comments - 為 post #8 建立新的 comment
PUT /posts/8/comments/5 - 更新 post #8 的 comment #5 的內容
PATCH /posts/8/comments/5 - 部分更新 post #8 的 comment #5 的內容
DELETE /posts/8/comments/5 - 刪除 post #8 的 comment #5
另外,如果 comment 有自身對應的路由,如 /comments,最好就不要使用上述的 /posts/:post_id/comments/:comment_id 路,而是使用其自身對應的路由。這麼做出於兩點原因:
- 避免重複的 API 設計
- 使用更短更方便的路由
條件請求
請閱讀 HTTP conditional requests。
查詢參數
查詢參數通過 URL 的 Query Parameters 實現。
資源排序
Query Parameters 以 排序關鍵字=[-]欄位1,...,[-]欄位N 的形式拼接:
- 排序關鍵字:可自行選擇,不與其他欄位衝突即可,比如
sort。
- 欄位:以
, 分隔的欄位列表,如果欄位首碼為-表示降序排列。
1 |
/entrypoint?sort=-age,sex |
資源過濾
Query Parameters 以 欄位=值 的形式拼接。
1 |
/entrypoint?age=35&sex=male |
資源欄位過濾
API 消費者並不都一直需要資源的所有內容。提供按需返回欄位的能力對減小流量消耗、加快 API 呼叫有很大好處。
Querystring 以 欄位過濾關鍵字=欄位1,...,欄位N 的形式拼接:
- 欄位過濾關鍵字:可自行選擇,不與其他欄位衝突即可,比如
fields。
- 欄位:以
, 分隔的欄位列表。
1 |
/entrypoint?since=1499410815441&count=10&age=35&fields=age,sex |
全文檢索搜尋
有時,基本的資源過濾功能是不夠的。這時,你可能就會用到 Elasticsearch 或者其他基於 Lucene 的全文檢索搜尋工具了。
當使用全文檢索搜尋時,全文檢索搜尋的參數應該通過資源 API 的查詢參數提供。查詢完成後所返回的資料,應該和普通列表查詢一致。
比如:
1 |
GET /messages?q=return&state=read&sort=-priority,created_at |
為常用查詢設定別名
有些查詢會經常用到,為它們設定個別名,可以讓開發人員用得更舒服。比如,查詢最近已讀的訊息:
1 |
GET /messages/recently_read |
請求內容
如果不需要相容老舊系統,優先使用 JSON。
保證接收到的要求標頭的 Content-Type 為 application/json ,不然就返回 415 Unsupported Media Type 。
響應狀態代碼
HTTP 定義了很多有意義的狀態代碼,但也不是所有的都能用到。下面列出了一些常用的狀態代碼:
- 200 OK - 對成功的 GET、PUT、PATCH 或 DELETE 操作進行響應。也可以被用在不建立新資源的 POST 操作上
- 201 Created - 對建立新資源的 POST 操作進行響應。應該帶著指向新資源地址的 Location 頭
- 204 No Content - 對不會返迴響應體的成功請求進行響應(比如 DELETE 請求)
- 304 Not Modified - HTTP緩衝header生效的時候用
- 400 Bad Request - 請求異常,比如請求中的body無法解析
- 401 Unauthorized - 沒有進行認證或者認證非法。當API通過瀏覽器訪問的時候,可以用來彈出一個認證對話方塊
- 403 Forbidden - 當認證成功,但是認證過的使用者沒有訪問資源的許可權
- 404 Not Found - 請求一個不存在的資源
- 405 Method Not Allowed - 所請求的 HTTP 方法不允許當前認證使用者訪問
- 410 Gone - 表示當前請求的資源不再可用。當調用老版本 API 的時候很有用
- 415 Unsupported Media Type - 如果請求中的內容類型是錯誤的
- 422 Unprocessable Entity - 用來表示校正錯誤
- 429 Too Many Requests - 由於請求頻次達到上限而被拒絕訪問
響應內容
- 如果不需要相容老舊系統,優先使用 JSON。
- 建立和修改操作後,返回資源的全部資訊。
錯誤處理
錯誤一般分為兩類:
關於狀態代碼的使用
用戶端請求錯誤使用 400 系列狀態代碼,服務端響應錯誤使用 500 系列狀態代碼。
關於響應體
發生錯誤時,API 返回的響應體應該為開發人員提供一些有用的資訊:
- 唯一的錯誤碼
- 有用的錯誤資訊
- 可能的話,提供錯誤細節的描述
用 JSON 格式來表示的話,看起來像這樣:
12345 |
{ "code" : 1234, "message" : "Something bad happened :(", "description" : "More details about the error here"} |
對於 PUT、PATCH、POST 的請求,在發生校正錯誤時,使用額外的 errors 欄位來提供錯誤細節,比如:
12345678910111213141516 |
{ "code" : 1024, "message" : "Validation Failed", "errors" : [ { "code" : 5432, "field" : "first_name", "message" : "First name cannot have fancy characters" }, { "code" : 5622, "field" : "password", "message" : "Password cannot be blank" } ]} |
更多的元資訊
如果需要更多的元資訊,可以將其放在 HTTP 頭中。比較常見的中繼資料有:
- 分頁資訊
- 請求頻率限制資訊(已經提及)
- 認證資訊(已經提及)
- 版本資訊(已經提及)
- ……
為了避免命名衝突,在設定 HTTP 頭時,應該為 HTTP 頭內的自訂欄位添加首碼,比如 OpenStack 就這麼做了:
123 |
OpenStack-Identity-Account-IDOpenStack-Networking-Host-NameOpenStack-Object-Storage-Policy |
在以前,協議的設計者與實現者通過使用 X- 首碼來區分自訂與非自訂的 HTTP 頭,但實踐證明,這個問題在解決問題的同時,也引入了諸多問題。所以 RFC 6848 已經開始廢棄這種做法。
另外,雖然在 HTTP 規範中並沒有規定 HTTP 頭的大小,但是在某些平台中,它的大小是被限制了的。比如,Node.js 的 Header 大小不能高於 HTTP_MAX_HEADER_SIZE(預設 80KB),這麼做的目的是為了防禦基於 HTTP 頭的 DDOS 攻擊。
開啟 gzip 壓縮
記得開啟 gzip 壓縮。
調用頻率限制
伺服器的資源是有限的,為了防止有意或無意的高頻率請求,對請求頻率做限制是很必要的。
RFC 6585 引入了一個 HTTP 狀態代碼 429 Too Many Requests 來解決這個問題。
當然,如果能在達到調用上限之前通知到 API 消費者肯定更好。當前這個領域缺少一些標準,但是很多流行的做法是使用 HTTP 回應標頭。
實現方式:API 提供者維護 API 呼叫者的頻率限制資訊,並通過多個 Response Headers 來通知 API 呼叫者對 API 的調用情況:
X-Rate-Limit-Limit:目前時間段內允許的最多請求次數
X-Rate-Limit-Remaining:目前時間段內剩餘的請求次數
X-Rate-Limit-Reset:還有多少秒,請求次數限制會被重設
為什麼 X-Rate-Limit-Reset使用的是剩下的秒數而不是時間戳記(timestamp)?
時間戳記包含了很多有用但是非必需的資訊,比如日期和時區。API消費者真正想知道的是他們什麼時候可以繼續發起請求,使用秒對於消費者來說處理的成本最小。並且使用秒也規避了時鐘位移問題。
緩衝
HTTP 內建了緩衝策略。你只需要在 API 響應中增加幾個 Header,在處理請求的時候對一些請求 Header 做點校正。
這裡有兩個方案:ETag 和 Last-Modified。
ETag
當處理一個請求的時候,在響應中包含一個名為 ETag 的 HTTP 頭,它的值可以為資源內容的hash 或者 checksum。ETag 的值應該在資源內容發生變化的時候跟著變化。如果 HTTP 要求頭中包含 If-None-Match,並且其值與被請求資源的 ETag 值相同,那麼 API 則返回 304 Not Modified 狀態代碼,而不再輸出資源內容。
Last-Modified
和 ETag 的工作原理差不多,區別在於這個頭使用的是時間戳記。回應標頭 Last-Modified 中包含一個 RFC 1123 格式的時間戳記,用來對 If-Modified-Since 的值進行校正(HTTP 協議接受三種不同的日期格式,所以對這三種格式伺服器應該都能處理)。
API 文檔
- 保持文檔與 API 同步更新
- 讓文檔可以被公開訪問,並且易於尋找
- 文檔應該提供完整的調用樣本(GitHub 和 Stripe 深諳此道)
API 的測試
黑箱測試
黑箱測試是一種不關心應用內部結果和工作原理,而只關心結果是否正確的測試方法。
黑箱測試時,不應該 Mock 任何資料。
另外,寫測試時,儘可能不對系統狀態做假設,但在某些情境下,需要準確地知道系統當前所處的狀態以增加更多的斷言來提供測試覆蓋率。如果有這種需求的話,可以使用如下兩種方法對資料庫進行預填充:
- 選擇生產環境資料的子集來運行黑箱測試
- 運行黑箱測試之前把手動構造的資料填充到資料庫
單元測試
除了黑箱測試,單元測試也要老老實實地寫。
API 的衍化
API 的衍化是不可避免的。通過文檔記錄變更,並通過某些途徑通知開發人員,如 CHANGELOG 、部落格、Deprecation Schedules、郵件清單等,之後,逐步廢棄老舊的 API。
設計優秀的 API
站在巨人的肩膀上,多多瞭解並借鑒大站的 API 設計方法,是很不錯的學習與實踐方法。
- GitHub API
- Twilio API
- Stripe API
- DigitalOcean API
RESTFul API 的問題
- 缺乏可拓展性。一個剛開始簡單的使用者介面可能只返回少部分資訊,例如使用者名稱、頭像等。隨著API的不斷髮展,可能需要返回更多的資訊,例如年齡、暱稱、簽名等。很多時候用戶端只是需要其中的部分資訊,但是介面依舊傳輸了所有的資訊,這個情況增加了網路傳輸量,特別對於行動裝置 App來說特別不友好,同時需要用戶端自行提取需要的資料。而建立兩個功能大致相同只是返回欄位有所區別的API則增加了後端實現的複雜度,或者是需要增加商務邏輯判斷,或者是增加了維護的難度。當然,這種問題可以通過使用查詢參數來解決,但是這無疑增加了代碼冗餘量。
- 複雜的資料需求需要做多次 API 呼叫。比如,用戶端要顯示文章的內容,可能要調用文章介面、評論介面、使用者資訊介面。為構成對一個資源的完整視圖,需要做多次單獨調用,這樣的資料擷取方式非常不靈活。
API 的未來
可能是 GraphQL 和 Falcor 吧。
參考
- Best Practices for Designing a Pragmatic RESTful API
- 10 Best Practices for Writing Node.js REST APIs
- Best practices for API versioning?
- RESTful Service API 設計最佳工程實踐和常見問題解決方案