REST
REST(REpresentational State Transfer)是 Roy Fielding 博士于 2000 年在他的博士論文中提出來的一種軟體架構風格(一組架構限制條件和原則)。在該論文的 中文譯本 中翻譯是"表述性狀态移交"。

原則
- 網絡上的所有事物都被抽象為資源
- 每個資源都有一個唯一的資源辨別符
- 同一個資源具有多種表現形式(xml,json 等)
- 對資源的各種操作不會改變資源辨別符
- 所有的操作都是無狀态的
資源(Resources)
資源是一種資訊實體或者說是一個具體資訊,能夠被想象出名字。比如多個圖書館,那麼便是可使用的圖書館資源,而圖書館内,多個樓層,那麼便擁有了多個樓層的資源,各樓層提供了不同服務,那麼服務也是資源。在網際網路中,可以用一個 URI(統一資源定位符)指向它,每種資源對應一個特定的 URI(如同一本書,按照書頁碼去定位哪一頁,目的是定位資源)。通路這個特定 URI 便擷取到了這個對應的資源。
表述(REpresentations)
資源的表述是一段對于資源在某個特定時刻的狀态的描述,通過表述捕獲資源,并在元件間(客戶/伺服器)移交該表述。表述有多種格式,如 HTML/XML/JSON/純文字/圖檔/視訊/音頻等。具體的表述格式,可以在 HTTP 請求頭資訊中用 Accept 和 Content-Type 字段指定,請求/響應方向的表述通常使用不同的格式。
狀态移交(State Transfer)
對于元件間而言(客戶/伺服器),資源的請求是一個互動過程。通過表述捕獲資源目前或是預期的狀态,相當于獲得了資源的狀态。通過移交代表資源的表述,來将資源在元件的兩者之間進行傳遞,進而改變應用狀态。如當用戶端擷取了資源後,自身狀态處于穩定,當再次擷取資源後自身狀态再次處于穩定。用戶端操作并對服務端發起請求,在資源上執行各種動作而打破資源自身狀态,達到用戶端操作所期望狀态。
RESTful Api
與 REST 相比多了一個 ful,就英語層面來說是一個形容詞,RESTful 翻譯成中文為“REST 式的”,滿足了 REST 架構風格的應用程式設計的 Api 則便是 RESTful Api,即 REST 式的 Api。
以往 Api 設計
在 MVC 項目中,經常都是設計成動賓結構給 ajax 調用
/getCustomers
/getCustomersByName
/getCustomersByPhone
/getNewCustomers
/verifyCredit
/saveCustomer
/updateCustomer
/deleteCustomer
可有時卻因為沒有統一的規範,多人協作時,對于動詞的描述上也沒有統一,時長出現了類似如下的各類叫法,不能說這種情況有什麼弊端,畢竟這種方式也是正常工作着。
/getCustomers
/getAllCustomer
/getCustomerList
/getPagedCustomer
/queryCustomers
/queryAllCustomers
/queryCustomerList
...
相比之下,RESTful Api 提供了更為标準化,規範化的 URL 寫法。
設計規範
考慮 Api 設計時,URI 中不能有動詞,URI 的目的是定位資源,而具體的對資源的操作,是借助 HTTP 的動詞完成,與早期 Api 設計相比,本身的思路是不同的,原來更多的是考慮函數式程式設計或者叫做面向行為的服務模組化,比如 RPC,遠端調用一個函數,那麼 Api 設計便是會考慮為動詞名詞格式,而對于 REST 風格來講,是面向資源的服務模組化。而對于資源而言,可以是對象、資料或是查詢服務。
HTTP 動詞
對于一個系統而言,對外提供的功能總體上劃分為兩類:
- 擷取系統資源,主要包括讀取資源和資源描述資訊。
- 對系統資源進行變更,主要包括寫入資源,對已有資源狀态的變更,删除已有資源。
了解RESTful Api設計
對于這其中使用到的一些動詞,使用 HTTP 的動詞描述來承擔對資源執行的行為,動詞通常使用以下幾種。對于 HTTP1.1 規範中的其他幾個動詞(如 OPTIONS 等)則不再介紹。
- GET: 擷取目标資源。
- HEAD: 擷取(傳輸)目标資源的中繼資料資訊。該方法與 GET 相同,但是不傳遞内容體。
- POST: 建立新資源,對于複雜查詢而言,送出查詢表單給查詢服務也是常用 POST 的(當然其他幾個能做的它也能做)。
- PUT: 替換已有資源(整體)。
- PATCH: 修改已有資源(局部)。
- DELETE: 删除資源。
URI
URI 作為統一資源辨別符,其本質是辨別資源,就像進入圖書館,任一本書都應具有在哪個樓層,哪個區域,哪個書架等等辨別資訊,來唯一确定這本書,于資源而言,更是如此,對于 URI 的設計,規範是使用名詞來定位資源,比如常見的
GET /Api/Users/{id}
這樣便按照 id 值,來唯一定位這一 User
對于資源的單複數格式,盡管規範是盡可能使用複數,但并沒有說哪條紀律或是限制限制說一定要使用複數,這無需強制限制,按照自身統一即可。畢竟有些不可數名詞,沒有複數格式,那麼還是沿用本身,而對于整體風格為複數下,卻又顯得格格不入。
面向資源
資源的組織決定着 URI 的展示方式,對于底層資料庫而言,也許 Order 模型有若幹張表來支撐存儲,對外總體是提供着 Order 服務。這樣一來,如果按照底層資料庫表來考慮 Api 設計,則會陷入無盡的關系進行中,比如 Order 下有 OrderItem,OrderItem 下有 OrderItemAttachment,如果按照這個思路去實作 Api 設計時,那麼 URI 的設計上則會存在多級情況。
POST /Api/Orders
POST /Api/Orders/{id}/OrderItems
POST /Api/Orders/{id}/OrderItems/{itemId}/OrderItemAttachments
POST /Api/Orders/{id}/OrderItems/{itemId}/OrderItemAttachments/{}/...
...
于資料庫而言,表與表間構成了一張龐大的網,有時還不好找到定位資源的入口
如果按照單表進行 URI 設計,那麼則成了面向表服務模組化,這又造成了底層的服務細節統統對外暴露,是以需要避免建立僅反映資料庫内部結構的 API。
在領域驅動設計中,聚合這一概念,将具有強相關的實體和值對象納入到一起,形成獨立空間、業務邏輯内聚于聚合之中,同生共死。面向聚合進行 Api 設計,多級路由的嵌套結構緩和許多,如需求上考慮 Order 建立時一定需要有 OrderItem 的存在,那麼則對于這兩者而言是捆綁的關系,而對于 OrderItemAttachment 而言,不是必要的。
那麼則可以獨立設計聚合(此處忽略底層資料庫中表是如何設計的,僅考慮聚合),URI 的設計也圍繞着聚合這一資源來進行,這樣一來,URI 的設計便成了如下結構
POST /Api/Orders
{
"locationId": 1,
"productIds": [
1,
2,
3
]
}
POST /Api/Orders/{id}/OrderItems
{
"productIds": [
4,
5,
6
]
}
POST /Api/OrderItemAttachments
{
"orderItemId": 1,
"fileUrl": "xxx"
}
嵌套層級結構不會太深,因為太深的層級結構往往也意味着這個聚合的設計或許存在一點問題。
限制設計
對于 Post、Put、Patch 和 Delete 這些操作來講,面向聚合設計 URI 基本可以有路可循。
比如以下一些常見的 URI
POST /Api/Orders
POST /Api/Orders/{id}/OrderItems
POST /Api/OrderItemAttachment
PUT /Api/Orders/{id}
PUT /Api/Orders/{id}/OrderItems/{itemId}
PUT /Api/OrderItemAttachments/{id}
PATCH /Api/Orders/{id}/Address
PATCH /Api/Orders/{id}/OrderItems/{id}/Amount
PATCH /Api/OrderItemAttachments/{id}/FileUrl
DELETE /Api/Orders/{id}
DELETE /Api/Orders/Batches
DELETE /Api/Orders/{id}/OrderItems/{id}
DELETE /Api/OrderItemAttachments/{id}
POST /Api/Invites/emailTemplate
PATCH /Api/Invites/{id}/Sendmail //Sendmail 作為郵件服務資源
PATCH /Api/Notifications/{id}/MessageStatus
PATCH /Api/Notifications/MessageStatus/batches
PATCH /Api/Orders/{id}/OrderItem/{itemId}/PayStatus
POST /Api/Orders/exports //傳回導出資源
POST /Api/exportServices //送出給導出服務資源
POST /Api/exportServices/Sendmail
POST /Api/InviteParseServices //送出給解析服務資源
...
當然也有一些夾雜着動詞,習以為常的 Api 設計,如果習慣了,不想改變,仍然可以使用着動詞(後續提到該部分違反限制),但若想改變,就得換個思路去考慮設計了
POST /Api/Account/Login
POST /Api/Account/Logout
POST /Api/Account/Register
比如,Login/Logout 操作的目标資源是什麼?如果把登入的使用者當作在系統中存儲的資源來看便可以認為已上線的使用者資訊,取個資源名字,線上使用者(onlineUser),然後對其執行行為。
而對于 Register 來講,則更是容易轉換了,注冊本身是對 Account 的操作行為,其本質是建立一個沒有過的使用者。那麼直接去掉注冊即可了,如認可改變可以按照如下設計,如仍習慣現有,則不改即可,并沒有什麼限制、紀律限制說一定要遵循。
POST /Api/Accounts
POST /Api/OnlineUsers
//如下需要結合 Authorization,不直接在 URI 中傳遞參數
DELETE /Api/OnlineUsers
主要是對于查詢類的操作,設計起來複雜一些,無論是實際開發中還是按照二八原則,大部分操作都是查詢操作,并且查詢起來天馬行空。
先是以下簡單的查詢
GET /Api/Orders
GET /Api/Orders/{id}
GET /Api/Orders/{id}/OrderItems
GET /Api/Orders/{id}/OrderItems/{id}
// 篩選
GET /Api/Orders?Name=xxx&LocationId=xxx
// 分頁
GET /Api/Orders?Page=1&Limit=10
// 也可以拆分成如下兩個此處資源為 Page
GET /Api/Orders/Page?Page=1&Limit=10
GET /Api/Orders/PageCount?Page=1&Limit=10
// 排序
GET /Api/Orders?Sort=Name%20DESC
GET /Api/Orders?Sort=Name%20DESC,CreationTime%20ASC
然後再為一些常見場景下的(對于查詢類的,聚合的邊界應消失了,更多的應該是将各種資源串聯起來)
// UI 上需要知道某個資源是否存在
GET /Api/Orders?name=xxx
HEAD /Api/Orders?name=xxx
能夠查詢到狀态碼傳回 204
找不到狀态碼傳回 404
// 檔案下載下傳
GET /Api/OrderFiles/{id}/Url
// 報表分析(将報表分析的結果作為虛拟資源)
GET /Api/AnalyseResults
// 傳回指定條件下的總數
GET /Api/Locations/{id}/OrderCount?Status[]&Status[]=2&CreationTime=2022-05-01
// UI 上下拉框所需要的基礎資料
GET /Api/Locations/Names?page=1&limit=30&search=xxx
{
"id": "xxx",
"name": "xxx"
}
// 擷取最近的循環周期
GET /Api/Plans/{id}/LatestCycleDate
// 擷取最近的記錄(根據時間,狀态過濾後的第一條)
GET /Api/Orders/Latest
...
實際使用中,算了算也隻有百分之八十左右的接口是按照 RESTful Api 的規範使用着的,總是有些接口,不能或是難以用簡單的描述就能解決。比如如下幾個接口,我便直接違反着限制(不能有動詞,隻能使用名詞)。
PATCH /Api/Invites/{id}/Approval
PATCH /Api/Invites/{id}/Decline
PATCH /Api/Invites/{id}/Reject
...
Github中也還是有動詞描述
https://docs.github.com/en/rest/codespaces/codespaces#start-a-codespace-for-the-authenticated-user
https://docs.github.com/en/rest/codespaces/codespaces#stop-a-codespace-for-the-authenticated-user
https://docs.github.com/en/rest/checks/runs#rerequest-a-check-run
https://docs.github.com/en/rest/checks/suites#rerequest-a-check-suite
如果按照這幾個限制條件來看的話,僅當滿足三個限制條件的才能認為是 RESTful Api,而滿足一個或是兩個限制條件的為 Http Api,那麼我們或許是一直在追随 RESTful Api 的路上了。
面對這部分難以描述或是無法組織的接口,個人認為直接違反一些限制即可,總歸是隻有少部分接口僅滿足一個到兩個限制。
狀态碼
HTTP 中使用狀态碼來表示着請求的成功與否,我們可以直接使用它,而無需在傳回值中再包裹一層 code/message,盡管在 mvc 中,我也很喜歡這麼做。
{
"code":200,
"message":"",
"data":{
}
}
對 HTTP 的狀态碼接觸越多後,越發覺得思路偏了,不應該将請求響應的狀态碼與業務中行為的成功與否進行隔離開,因為 HTTP 本身是應用層協定(超文本移交協定),是為業務服務的。如何在網絡層面上把一個請求發送出去,再接收到響應,這是 TCP 協定來保障的。假設網絡層如果請求失敗了,那麼應用層都無法進行,是以結合狀态碼與傳回内容(當出現異常時仍然傳回狀态碼與錯誤描述資訊)。
如下 HTTP 的狀态碼覆寫了絕大部分場景。當用戶端需要追蹤問題時,檢視對應請求的狀态碼,結合其對應的解釋說明,便可以去定位相關的問題,當然,前提是真的傳回了符合場景下的狀态碼。
- Informational responses (
–100
)199
- Successful responses (
–200
)299
- Redirection messages (
–300
)399
- Client error responses (
–400
)499
- Server error responses (
–500
)599
在 Api 中,100 階段的狀态碼不會涉及,具體的各響應碼參見如下圖
版本号
對外提供的資源服務位址需要存在版本控制,以便于用戶端應用能夠通路到對應的資源,版本号的規劃有如下幾種方式,具體使用哪種得依靠具體的情況而分析:
- 不考慮版本,内部使用、短暫的生命周期下不考慮資源的變更或是直接對資源本身進行了換新如此變更到新的 url 上。
- 為每個資源的 URI 添加一個版本号。
GET /Api/v2/Orders/{id}
- 作為查詢字元串參數來指定資源的版本
GET /Api/Orders/{id}?version=2
- 在 http 的 header 中增加自定标頭設定版本号。
GET /Api/Orders/{id}
Custom-Header: version=2
成熟度模型
2008 年,Leonard Richardson 為 Web API 提出了以下 成熟度模型 :
- Level 0: 定義一個 URI,所有操作是對此 URI 發出的 POST 請求。
- Level 1: 為各個資源單獨建立 URI。
- Level 2: 使用 HTTP 方法來定義對資源執行的操作。
- Level 3: 使用超媒體(HATEOAS: Hypermedia As The Engine Of Application State,參見 HATEOAS - Wikipedia )。
誠然,對于這個成熟度模型,我一般都隻會去達到前三個級别,雖然 Roy Fielding明确表示 ,Level 3 才是真正的 RESTful Api,對于 Level 3 級别,其實并沒有了解到其具體奧妙。因為我們面對的是 UI,用 UI 去連結操作,那麼對于 Level 3 傳回的超媒體而言,又如何表現呢?
參考文檔
- https://docs.github.com/en/rest
- https://docs.microsoft.com/en-us/azure/architecture/best-practices/api-design
- https://www.infoq.cn/minibook/web-based-apps-archit-design
- https://florimond.dev/en/posts/2018/08/restful-api-design-13-best-practices-to-make-your-users-happy/
- https://gitbook.cn/books/5dcaef6522061f2f65418f25/index.html
- https://gitbook.cn/gitchat/activity/5ec21576ef4eff0c0bf709ba
2022-05-24,望技術有成後能回來看見自己的腳步