在過去的這幾年當中,當人們想要構建一個 HTTP API,在諸如 XML-RPC、SOAP以及 JSON-RPC 這些選項之中,幾乎都會選擇 REST 作爲首選的架構風格。REST 的出現最終被認爲優於其它的“基於 RPC”的方式,這其實是一種無解,它們只是不同而已。
本文討論構建 HTTP API 的場景中的兩種方法, 因爲這兩種方法最常被用到。REST 和 RPC 都可以被其他的傳輸協議使用,比如 AMQP,不過那完全是另外一個話題了。
REST 表示的是“描述性狀態傳遞(representational state transfer),” Roy Fielding 在他的論文做了如是描述。可悲的是,那篇論文並沒有多少人讀過,許多人對 REST 是什麼都有自己的簡介,這就導致許多的混亂和分歧。REST 整個就是關於 客戶端和服務端之間的關係的,其中服務端要提供格式簡單的描述性數據,常用的是 JSON 和 XML。對這些資源或者資源集合的描述,有些也許是可以通過一種叫做超媒體的方法所發現的動作和關係來修改的。 超媒體是 REST 的基礎,它本質上就是一個向其它資源提供鏈接的概念。
除了超媒體之外,還有其它的一些約束,如下:
-
REST 必須是無狀態的:請求之間不能對會話進行持久化。
-
響應消息應該對可緩存性進行聲明: 如果客戶端遵守這個規則就能幫助你進行 API 擴展。
-
REST 專注於統一性: 如果你使用的是HTTP,就應該在哪兒都儘可能使用 HTTP 的功能,而不是再弄一些約定出來。
這些約束 (還有另外一些) 讓 REST 架構能幫助 API 持續使用幾十年,而不是幾年。
在 REST 流行 (在諸如 Twitter 和 Facebook 這樣的公司將它們的 API 稱作 REST 以後)之前, 大多數 API 都是使用 XML-RPC 或者 SOAP 構建的。XML-RPC 是有毛病的,因爲要確定 XML 的數據類型,開銷是比較大的。在 XML 中,許多東西都只是字符串而已,因此你需要在頂部有一層元數據來描述諸如那一個域對應哪一種數據,之類的事情。這成爲SOAP(簡單對象訪問協議 Simple Object Access Protocol)基本的組成部分。XML-RPC 和 SOAP, 以及自定義的本土解決方案主宰了 API 領域很長一段時間,而它們都是基於 RPC 的 HTTP API。
“RPC”指的是“遠程過程調用(remote procedure call)” ,本質上在JavaScript、PHP、Python等等中調用都是一樣的:取方法名,傳參數。因爲不是每個人都喜歡XML,RPC API可以使用 JSON-RPC協議,也可以考慮自定義基於JSON的API,像Slack 是用它的Web API實現的。
以這個RPC調用爲例:
POST /sayHello HTTP/1.1
HOST: api.example.com
Content-Type: application/json
{"name": "Racey McRacerson"}
JavaScript也是如此,定義一個函數,然後在其他地方調用:
/* 簽名 */
function sayHello(name) {
// ...
}
/* 用法 */
sayHello("Racey McRacerson");
想法一樣,定義公共方法建立API接口,然後這些方法傳入參數被調用。RPC就是一堆功能,但在一個HTTP API的上下文裏,需要把方法放在URL中,同時將參數放在查詢串或請求塊(body)中。SOAP訪問一個大同小異的數據時,就像報告一樣,異常冗長。如果你在Google中搜索“SOAP案例”,你會找到下面類似,名爲getAdUnitsByStatement的案例:
<?xml version="1.0" encoding="UTF-8"?><soapenv:Envelope
xmlns:soapenv="http://schemas.xmlsoap.org/soap/envelope/"
xmlns:xsd="http://www.w3.org/2001/XMLSchema"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
<soapenv:Header>
<ns1:RequestHeader
soapenv:actor="http://schemas.xmlsoap.org/soap/actor/next"
soapenv:mustUnderstand="0"
xmlns:ns1="https://www.google.com/apis/ads/publisher/v201605">
<ns1:networkCode>123456</ns1:networkCode>
<ns1:applicationName>DfpApi-Java-2.1.0-dfp_test</ns1:applicationName>
</ns1:RequestHeader>
</soapenv:Header>
<soapenv:Body>
<getAdUnitsByStatement xmlns="https://www.google.com/apis/ads/publisher/v201605">
<filterStatement>
<query>WHERE parentId IS NULL LIMIT 500</query>
</filterStatement>
</getAdUnitsByStatement>
</soapenv:Body></soapenv:Envelope>
這樣的數據載荷相當龐大,如包裝爲參數僅僅就一行:
`<query>WHERE parentId IS NULL LIMIT 500</query>`
在JavaScript中, 看起來會是這樣:
/* 簽名 */
function getAdUnitsByStatement(filterStatement) {
// ...
};
/* 用法 */
getAdUnitsByStatement('WHERE parentId IS NULL LIMIT 500');
JSON API會更加簡潔, 或許看起來像這樣:
POST /getAdUnitsByStatement HTTP/1.1
HOST: api.example.com
Content-Type: application/json
{"filter": "WHERE parentId IS NULL LIMIT 500"}
儘管此負載更簡單,但我們還是需要不同的方法來處理getAdUnitsByStatement 、 getAdUnitsBySomethingElse。當你看這樣的案例時,REST很快就能適應,因爲它允許泛型端點與查詢字符( 例如,GET /ads?statement={foo}orGET /ads?something={bar})聯合查詢。你可以聯合字符去獲取GET /ads?statement={foo}&limit=500,然後就可以考慮拋棄以前看似SQL語法的那種參數了。
目前,REST看似更好,但只是因爲這些使用RPC處理的服務是REST更擅長處理的。這篇文章將不再嘗試闡述什麼”更好“,但取而代之的是幫你做出非正式的決定,以決定一個方案何時更合適。
它們是什麼?
基於 RPC 的 API 適用於動作(過程、命令等)。
基於 REST 的 API 適用於領域模型(資源或實體),基於數據的 CRUD (create, read, update, delete) 操作。
REST 不 僅 用於 CRUD,但主要是基於 CRUD 的操作。REST 會使用 HTTP 方法,如 GET,POST,PUT,DELETE,OPTIONS 以及很有希望的 PATCH 來從語義上說明動作的意圖。
然而 RPC 不會這麼幹。它多數時候只使用 GET 和 POST。GET 用於獲取信息,POST 用來幹其它事情。RPC API 通常會使用像 POST /deleteFoo 這樣的方法,帶上內容 { "id": 1 }。如果用 REST,就會像這樣 DELETE /foos/1。
這並不是一個重要的區別,它只是一個簡單的細節上的實現。我認爲如何處理動作纔是應該關注的區別。在 RPC 中,只需要 POST /doWhateverThingNow,這很簡潔。但是用 REST 使用類似 CRUD 的操作會讓你覺得 REST 不適合幹除 CRUD 之外的事情。
當然,不能以偏概全。兩種方法都可以觸發動作。REST 的觸發可以想像成是結果導向的,比如,如果你想向用戶 “發送消息”,用 RPC 會這樣做:
POST /SendUserMessage HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"userId": 501, "message": "Hello!"}
而在 REST 方法中,做同樣的事情會這樣:
POST /users/501/messages HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"message": "Hello!"}
雖然它們看起來很相似,但這裏確實有一個概念上的區別:
-
RPC
發送一個消息,然後可能會往數據庫裏保存一些東西以作爲歷史記錄,有可能另一個 RPC 會在這裏請求同一個數據——誰知道呢?
-
REST
在用戶消息集中創建一個消息資源,如果用同一個 URL 的 GET 請求,可以看到之前的用戶消息,消息將在後臺發送。
這裏 “事後發生的行爲” 在 REST 可以幹很多事情。想像一個擁有“旅途”的拼車App。那些旅途需要“開始”、“結束”和“取消”動作,否則用戶就無法知道它們何時開始,何時結束。
使用 REST API,你已經擁有 GET /trips 和 POST /trips,然後很多人會嘗試使用有點像下面這些動作的節點:
-
POST /trips/123/start
-
POST /trips/123/finish
-
POST /trips/123/cancel
這些節點基本上就是在 REST API 中混入了 RPC 風格,這是流行的解決方案,但是從技術上來說並不是 REST。這個現象展示了將行爲放在 REST 中的難處——看起來並不明顯,但卻是可能的。有一種方法是使用狀態機,比如使用 state 字段:
PATCH /trips/123 HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"status": "in_progress"}
像其它字段一樣,你可以給 status PATCH 一個新值,在後臺啓動一些重要的行爲:
module States
class Trip
include Statesman::Machine
state :locating, initial: true
state :in_progress
state :complete
transition from: :locating, to: [:in_progress]
transition from: :in_progress, to: [:complete]
after_transition(from: :locating, to: :in_progress) do |trip|
start_trip(trip)
end
after_transition(from: :in_progress, to: :complete) do |trip|
end_trip(trip)
end
end
end
Statesman 是 Ruby 中一個簡單的狀態機,它由 GoCardless 團隊開發。有各種不同語言的許多狀態機實現,但對於用作演示來說,它是最簡單的一個。
基本上在你的控制器中,libcode 或 DDD 邏輯的某處,你可以在 PATCH 請求中找到“status”,然後,可以嘗試轉變這個狀態:
`resource.transition_to!(:in_progress)`
這段代碼執行的時候,它會進行轉變,並且運行定義在 after_transitionblock 中的任何邏輯。如果不成功,就拋出錯誤。
成功的動作可能是任何東西:發送郵件、推薦提醒、調用另一個服務以開始監控駕駛員的 GPS 所代表的汽車位置——什麼都行,只要你喜歡。
RPC 方法 POST /startTrip 或者像 REST 的 POST /trips/123/start 節點都不再需要,因爲只需要簡單的處理就可以遵循 REST API 的約定。
當行爲不事後發生時
我們已經看到了兩種適應REST API行爲,而不破壞其RESTful的方法,但是依賴於API被構建的應用程序的不同,這些方法可能會讓人覺得越來越缺少邏輯,像在轉圈圈。有人開始懷疑,爲什麼我要試圖將所有的行爲都使用REST API來實現?RPC API或許能對現有的REST API進行更好地補充。我們可以相對放寬使用RPC的Web API的條件,因爲它只用於不適合使用REST API的情況。設想一下,我們爲用戶提供“tick”,“ban”或者“leave”選項,讓用戶只使用REST進行保留操作,或者從一個頻道或者整個Slack中進行移除操作,如下:
DELETE /users/jerkface HTTP/1.1
Host: api.example.com
剛開始,DELETE似乎最適用的HTTP方法,但是這個需求卻相對模糊。它可能意味着完全關閉用戶賬戶,也可能只是禁用這個用戶。可以肯定的是,它絕對是這些選項中的一個。另一種方法是嘗試使用PATCH:
PATCH /users/jerkface HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"status": "kicked"}
有一點很奇怪,因爲用戶的狀態不會因爲任何原因被kick,這就需要獲取進一步的傳遞信息進入指定頻道。
PATCH /users/jerkface HTTP/1.1
Host: api.example.com
Content-Type: application/json
{"status": "kicked", "kick_channel": "catgifs"}
這樣處理仍然不和常理,因爲它產生了一個新的專用字段,並且這個字段實際上並不是爲用戶而存在的。我們嘗試使用如下關係:
DELETE /channels/catgifs/users/jerkface HTTP/1.1
Host: api.example.com
這樣好一些了,因爲我們不會混淆全局/users/jerkfase的資源,但依然缺少“kick”,“ban”或者“leave”選項。再次將它們放入正文或者查詢語句中,爲RPC方式添加一個專用字段。
唯一想到的其他方法是創建一個kicks集合,一個bans集合和一個leaves集合,另外爲POST/kicks,POST/bans和POST/leaves端點創建一些端點用於匹配。這些集合將允許特定資源的元數據,例如,列出用戶被踢出的頻道,但是感覺很像強制將一個不適合的應用程序作爲範例。
Slack的網頁API看起來像下面這樣:
POST /api/channels.kick HTTP/1.1
Host: slack.com
Content-Type: application/json
{
"token": "xxxx-xxxxxxxxx-xxxx",
"channel": "C1234567890",
"user": "U1234567890"
}
簡單漂亮!我們僅僅爲手頭的任務發送了參數,就像任何具有函數的編程語言中一樣。
一個簡單的經驗法則:
-
如果一個API主要是動作,也許它應該是RPC。
-
如果一個API主要是CRUD和操作相關數據,也許它應該是REST。
如果兩者都不是明顯的贏家?你選擇哪種方法?
同時使用REST和RPC
你需要選擇一個方法並只有一個API的想法是錯誤的。應用程序可以很輕鬆的擁有多個不被視爲“主”API的API或者額外的服務。使用暴漏HTTP端點的API或者服務,你可以選擇遵循REST或者RPC規則,只需要有一個REST API和少量的RPC服務。例如,在會議上,有人問這個問題:
我們有一個REST API來管理web託管公司。我們可以創建新的服務實例並將它們分配給用戶,這很好,但是我們如何通過API以RESTful方式來重啓服務並在批量服務器上運行命令?
除了創建一個具有POST/restartServer方法和POST/execServer方法的簡單的RPC風格的服務,並可以通過REST服務器構建和維護的服務器上執行的服務之外,沒有什麼可行的方法時可行的。
總結
瞭解REST和RPC之間的差異在你設計新的API時可能會非常有用,當你使用現有API的功能時,它也可以真正幫助你。最好不要在單個API中混合使用樣式,因爲這可能會迷惑您的API的用戶,並使所有需要統一編碼約定(例如REST)卻看到不同風格的編碼規約集合(例如RPC)的工具崩潰。 在有使用REST意義時使用才REST;如果RPC更合適,請使用RPC。 或兩者結合使用,這樣就兩全其美了!