討論下 RESTful 風格 API 的路由設計

Jiannei發表於2020-06-05

RESTful 風格的 API 路由實際

RESTful API = Http Method(動詞,描述資源操作型別) + URI(名詞+屬性,描述資源的層級和位置)

寫在前面

上篇分享了最近在梳理出如何去「入門」Lumen 開發 API 專案,算是列出了一個清單,簡短介紹了「如何統一 API 中的響應結構」,內容相對較少。結合最近在思考「如何使用 Lumen 來合理地架構 API專案,從而提升自身的開發體驗」的過程中,發現蠻多不錯的文章,想逐一分享討論一下。

因為內容本身是一些規範約束性的理論,或許不會短時間內就能對日常開發工作有明顯的促進作用,生搬硬套一些規則,為了使用而使用,可能反而會給自己的開發過程造成約束,影響效率。

所以,不妨各抒己見,來討論一番。

特別說明

文章主體內容摘選自:RESTful服務最佳實踐,侵刪。

REST 是什麼?

表現層狀態轉換(英語:Representational State Transfer,縮寫:REST)是Roy Thomas Fielding博士於2000年在他的博士論文[1]中提出來的一種全球資訊網軟體架構風格,目的是便於不同軟體/程式在網路(例如網際網路)中互相傳遞資訊。表現層狀態轉換是根基於超文字傳輸協議(HTTP)之上而確定的一組約束和屬性,是一種設計提供全球資訊網路服務的軟體構建風格。 ——來源於自由的 WIKI 百科『表現層狀態轉換

Tips:

由此可見,REST 只是一種「軟體架構風格」,不是多麼玄乎的東西,設計出來的目的是為了方便應用程式之間互相傳遞資訊。通常說的 RESTful API 就是表明應用系統中 API 的架構設計符合 REST 規範,遵守這種規範某種程度上可以說明應用系統的架構設計優秀。

近些年實際上出現了另外一種 API 設計風格 GraphQL 已經趨於成熟,各種程式語言的支援逐漸出現,也可以感受下『為什麼 GraphQL 是 API 的未來』(規範的成熟不等同於實際專案中就可以直接落地使用,技術選型前要有自己的判斷,預估一下未來能夠投入的時間和人力成本,不要受網文推廣的影響)

使用HTTP動詞表示一些含義

任何API的使用者能夠傳送GET、POST、PUT和DELETE請求,它們很大程度明確了所給請求的目的。

同時,GET請求不能改變任何潛在的資源資料。測量和跟蹤仍可能發生,但只會更新資料而不會更新由URI標識的資源資料。

合理的資源名

合理的資源名稱或者路徑(如/posts/23而不是/api?type=posts&id=23)可以更明確一個請求的目的。

使用URL查詢串來過濾資料是很好的方式,但不應該用於定位資源名稱。

適當的資源名稱為服務端請求提供上下文,增加服務端API的可理解性。

通過URI名稱分層地檢視資源,可以給使用者提供一個友好的、容易理解的資源層次,以在他們的應用程式上應用。

資源名稱應該是名詞,避免為動詞。使用HTTP方法來指定請求的動作部分,能讓事情更加的清晰。

Tips:相關名詞解釋和理解

URL:統一資源定位符(英語:Uniform Resource Locator,縮寫:URL;或稱統一資源定位器、定位地址、URL地址[1],俗稱網頁地址或簡稱網址)是因特網上標準的資源的地址(Address),如同在網路上的門牌。——來自維基百科

URI:統一資源識別符號(英語:Uniform Resource Identifier,縮寫:URI)——來自維基百科

應用到 RESTful API 的路由設計中:

API URL = Http Method(動詞,描述對資源操作的型別 CRUD) + URI(Uniform Resource Identifier)(可以類比檔案路徑,體現資源層級以及描述資源位置)

也就是在 API 的 URL 應該是用來描述去哪個位置找到資源,然後通過 Http Method 描述對資源進行怎樣的操作,這樣路由設計就清晰了

至於URI如何定義,你可以類比平時是如何在磁碟中進行分類管理檔案的,或許就思路清晰了。

相關定義

我們一起簡單過一下與 REST 有關的定義。

冪等性

下面是來自維基百科的解釋:

在電腦科學中,術語冪等用於更全面地描述一個操作,一次或多次執行該操作產生的結果是一致的。根據應用的上下文,這可能有不同的含義。例如,在方法或者子例程呼叫具有副作用的情況下,意味著在第一呼叫之後被修改的狀態也保持不變。

從 REST 服務端的角度來看,由於操作(或服務端呼叫)是冪等的,客戶端可以用重複的呼叫而產生相同的結果。注意,當冪等操作在伺服器上產生相同的結果(副作用),響應本身可能是不同的(例如在多個請求之間,資源的狀態可能會改變)。

PUT 和 DELETE方 法被定義為是冪等的。GET、HEAD、OPTIO 和 TRACE 方法自從被定義為安全的方法後,也被定義為冪等的。

安全

來自維基百科:

一些方法(例如GET、HEAD、OPTIONS和TRACE)被定義為安全的方法,這意味著它們僅被用於資訊檢索,而不能更改伺服器的狀態。換句話說,它們不會有副作用,除了相對來說無害的影響如日誌、快取、橫幅廣告或計數服務等。任意的GET請求,不考慮應用狀態的上下文,都被認為是安全的。

總之,安全意味著呼叫的方法不會引起副作用。因此,客戶端可以反覆使用安全的請求而不用擔心對服務端產生任何副作用。這意味著服務端必須遵守GET、HEAD、OPTIONS和TRACE操作的安全定義。否則,除了對客戶端產生混淆外,它還會導致Web快取,搜尋引擎以及其它自動代理的問題——這將在伺服器上產生意想不到的後果。

根據定義,安全操作是冪等的,因為它們在伺服器上產生相同的結果。

安全的方法被實現為只讀操作。然而,安全並不意味著伺服器必須每次都返回相同的響應。

Http 動詞/方法

Http動詞主要遵循“統一介面”規則,並提供給我們對應的基於名詞的資源的動作。

最主要或者最常用的http動詞(或者稱之為方法,這樣稱呼可能更恰當些)有POST、GET、PUT和DELETE。這些分別對應於建立、讀取、更新和刪除(CRUD)操作。

也有許多其它的動詞,但是使用頻率比較低。在這些使用較少的方法中,OPTIONS和HEAD往往使用得更多。

GET

HTTP的GET方法用於檢索(或讀取)資源的資料。

在正確的請求路徑下,GET方法會返回一個xml或者json格式的資料,以及一個200的HTTP響應程式碼(表示正確返回結果)。在錯誤情況下,它通常返回404(不存在)或400(錯誤的請求)。

例如:

GET http://www.example.com/customers/12345
GET http://www.example.com/customers/12345/orders
GET http://www.example.com/buckets/sample

按照HTTP的設計規範,GET(以及附帶的HEAD)請求僅用於讀取資料而不改變資料。因此,這種使用方式被認為是安全的。

也就是說,它們的呼叫沒有資料修改或汙染的風險——呼叫1次和呼叫10次或者沒有被呼叫的效果一樣。

此外,GET(以及HEAD)是冪等的,這意味著使用多個相同的請求與使用單個的請求最終都擁有相同的結果。

不要通過GET暴露不安全的操作——它應該永遠都不能修改伺服器上的任何資源。

PUT

PUT通常被用於更新資源。

通過PUT請求一個已知的資源URI時,需要在請求的body中包含對原始資源的更新資料。

不過,在資源ID是由客戶端而非服務端提供的情況下,PUT同樣可以被用來建立資源。換句話說,如果PUT請求的URI中包含的資源ID值在伺服器上不存在,則用於建立資源。同時請求的body中必須包含要建立的資源的資料。有人覺得這會產生歧義,所以除非真的需要,使用這種方法來建立資源應該被慎用。

或者我們也可以在body中提供由客戶端定義的資源ID然後使用POST來建立新的資源——假設請求的URI中不包含要建立的資源ID(參見下面POST的部分)。

例如:

PUT http://www.example.com/customers/12345 
PUT http://www.example.com/customers/12345/orders/98765
PUT http://www.example.com/buckets/secret_stuff

當使用PUT操作更新成功時,會返回200(或者返回204,表示返回的body中不包含任何內容)。如果使用PUT請求建立資源,成功返回的HTTP狀態碼是201。

響應的body是可選的——如果提供的話將會消耗更多的頻寬。在建立資源時沒有必要通過頭部的位置返回連結,因為客戶端已經設定了資源ID。

PUT不是一個安全的操作,因為它會修改(或建立)伺服器上的狀態,但它是冪等的。換句話說,如果你使用PUT建立或者更新資源,然後重複呼叫,資源仍然存在並且狀態不會發生變化。

但是,如果在資源增量計數器中呼叫PUT,那麼這個呼叫方法就不再是冪等的。這種情況有時候會發生,且可能足以證明它是非冪等性的。不過,建議保持PUT請求的冪等性。並強烈建議非冪等性的請求使用POST

Tips:為什麼 PUT 是冪等?

比如,你第一次請求更新訂單狀態為配送中,第二次請求如果不加校驗,讓請求處理成功,訂單也是被更新成了配送中的狀態。兩次請求得到的結果相同,都是將訂單更新成了配送中的狀態。(要理解結果相同和響應不一定相同這一點,多次請求對資源造成的結果相同就被定義成冪等)

POST

POST請求經常被用於建立新的資源,特別是被用來建立從屬資源。從屬資源即歸屬於其它資源(如父資源)的資源。換句話說,當建立一個新資源時,POST請求傳送給父資源,服務端負責將新資源與父資源進行關聯,並分配一個ID(新資源的URI),等等。

例如:

POST <http://www.example.com/customers
POST <http://www.example.com/customers/12345/orders

當建立成功時,返回HTTP狀態碼201,並附帶一個位置頭(Location:xxx)資訊,其中帶有指向最先建立的資源的連結。

POST請求既不是安全的又不是冪等的,因此它被定義為非冪等性資源請求。

使用兩個相同的POST請求很可能會導致建立兩個包含相同資訊的資源。

Tips:非冪等操作在實際專案中需要考慮的點

在實際專案開發中遇到這種請求需要考慮併發情況,解決思路參考:前端增加校驗,比如建立按鈕禁用,不允許短時間內連續操作,必須等待後端返回成功後才能繼續下一次建立操作;後端增加「業務鎖」處理前端傳送過來的請求前加鎖,等業務處理完以後釋放鎖。

PUT和POST的建立比較

總之,我們建議使用POST來建立資源。當由客戶端來決定新資源具有哪些URI(通過資源名稱或ID)時,使用PUT:即如果客戶端知道URI(或資源ID)是什麼,則對該URI使用PUT請求。否則,當由伺服器或服務端來決定建立的資源的URI時則使用POST請求。換句話說,當客戶端在建立之前不知道(或無法知道)結果的URI時,使用POST請求來建立新的資源。

Tips:

可以簡單點約定,獲取/查詢資源使用 GET;更新整個資源(相當於替換)使用PUT;更新資源部分的內容使用 PATCH;刪除資源使用 DELETE;建立資源使用 POST,以及非冪等性的請求使用 POST(比如更新資源內部的計數器等)。

DELETE

DELETE很容易理解。它被用來根據URI標識刪除資源。

例如:

DELETE <http://www.example.com/customers/12345
DELETE <http://www.example.com/customers/12345/orders
DELETE <http://www.example.com/buckets/sample

當刪除成功時,返回HTTP狀態碼200(表示正確),同時會附帶一個響應體body,body中可能包含了刪除項的資料(這會佔用一些網路頻寬),或者封裝的響應(參見下面的返回值)。也可以返回HTTP狀態碼204(表示無內容)表示沒有響應體。總之,可以返回狀態碼204表示沒有響應體,或者返回狀態碼200同時附帶JSON風格的響應體。

根據HTTP規範,DELETE操作是冪等的。如果你對一個資源進行DELETE操作,資源就被移除了。在資源上反覆呼叫DELETE最終導致的結果都相同:即資源被移除了。

但如果將DELETE的操作用於計數器(資源內部),則DETELE將不再是冪等的。如前面所述,只要資料沒有被更新,統計和測量的用法依然可被認為是冪等的。建議非冪等性的資源請求使用POST操作。

然而,這裡有一個關於DELETE冪等性的警告。在一個資源上第二次呼叫DELETE往往會返回404(未找到),因為該資源已經被移除了,所以找不到了。這使得DELETE操作不再是冪等的。如果資源是從資料庫中刪除而不是被簡單地標記為刪除,這種情況需要適當妥協。

Tips:如何理解 DELETE 操作被定義為冪等?

上面討論的也就是「物理刪除」和「軟刪除」的不同場景要不要都使用 DELETE,因為資源的「物理刪除」不是冪等操作,第二次請求操作時資源在第一次就沒了,對資源造成的結果不同。

物理刪除,都沒有資源了還怎麼操作資源,第一次是有操作結果,第二次沒有操作結果(都沒資源可以操作,哪來的結果?),兩次操作結果不同,所以不是冪等

軟刪除,第一次刪除是更新資源的刪除狀態為刪除,第二次刪除即使不加校驗,最終也是將資源更新為刪除狀態。

資源命名(URI)

除了適當地使用HTTP動詞,在建立一個可以理解的、易於使用的Web服務API時,資源命名可以說是最具有爭議和最重要的概念。一個好的資源命名,它所對應的API看起來更直觀並且易於使用。相反,如果命名不好,同樣的API會讓人感覺很笨拙並且難以理解和使用。當你需要為你的新API建立資源URL時,這裡有一些小技巧值得借鑑。

從本質上講,一個RESTFul API最終都可以被簡單地看作是一堆URI的集合,HTTP呼叫這些URI以及一些用JSON和(或)XML表示的資源,它們中有許多包含了相互關聯的連結。RESTful的可定址能力主要依靠URI。每個資源都有自己的地址或URI——伺服器能提供的每一個有用的資訊都可以作為資源來公開。統一介面的原則部分地通過URI和HTTP動詞的組合來解決,並符合使用標準和約定。

在決定你係統中要使用的資源時,使用名詞來命名這些資源,而不是用動詞或動作來命名。換句話說,一個RESTful URI應該關聯到一個具體的資源而不是關聯到一個動作。另外,名詞還具有一些動詞沒有的屬性,這也是另一個顯著的因素。

一些資源的例子:

  • 系統的使用者
  • 學生登記的課程
  • 一個使用者帖子的時間軸
  • 關注其他使用者的使用者
  • 一篇關於騎馬的文章

服務套件中的每個資源至少有一個URI來標識。如果這個URI能表示一定的含義並且能夠充分描述它所代表的資源,那麼它就是一個最好的命名

URI應該具備可預測性分層結構,這將有助於提高它們的可理解性和可用性的:可預測指的是資源應該和名稱保持一致;而分層指的是資料具有關係上的結構。這並非REST規則或規範,但是它強化了對API的定義。

RESTful API是提供給消費端(客戶端)的,URI的名稱和結構應該將它所表達的含義傳達給消費者。通常我們很難知道資料的邊界是什麼,但是從你的資料上你應該很有可能去嘗試找到要返回給客戶端的資料是什麼。API是為客戶端而設計的,而不是為你的資料

假設我們現在要描述一個包括客戶、訂單,列表項,產品等功能的訂單系統。考慮一下我們該如何來描述在這個服務中所涉及到的資源的URIs:

準確的案例✅

為了在系統中插入(建立)一個新的使用者,我們可以使用:
POST <http://www.example.com/customers

讀取編號為33245的使用者資訊:

GET <http://www.example.com/customers/33245

使用PUT和DELETE來請求相同的URI,可以更新和刪除資料。

下面是對產品相關的URI的一些建議:

POST <http://www.example.com/products

用於建立新的產品。

GET|PUT|DELETE <http://www.example.com/products/66432

分別用於讀取、更新、刪除編號為66432的產品。

那麼,如何為使用者建立一個新的訂單呢?

一種方案是:

POST <http://www.example.com/orders

這種方式可以用來建立訂單,但缺少相應的使用者資料。

因為我們想為使用者建立一個訂單(注意之間的關係),這個URI可能不夠直觀,下面這個URI則更清晰一些:

POST <http://www.example.com/customers/33245/orders

現在我們知道它是為編號33245的使用者建立一個訂單。(Tips:體現上面提到的 URI 應該具備分層結構的特性)

那下面這個請求返回的是什麼呢?(Tips:下面舉例體現了 URI 應該具體可預測的特性,從 URI 中就可以推斷出即將返回的資源資料)

GET <http://www.example.com/customers/33245/orders

可能是一個編號為33245的使用者所建立或擁有的訂單列表。注意:我們可以遮蔽對該URI進行DELETE或PUT請求,因為它的操作物件是一個集合。

繼續深入,那下面這個URI的請求又代表什麼呢?

POST <http://www.example.com/customers/33245/orders/8769/lineitems

可能是(為編號33245的使用者)增加一個編號為8769的訂單條目。沒錯!如果使用GET方式請求這個URI,則會返回這個訂單的所有條目。但是,如果這些條目與使用者資訊無關,我們將會提供 POST www.example.com/orders/8769/lineitems這個URI。

從返回的這些條目來看,指定的資源可能會有多個URIs,所以我們可能也需要要提供這樣一個URI GET <http://www.example.com/orders/8769,用來在不知道使用者ID的情況下根據訂單ID來查詢訂單。>

更進一步:

GET <http://www.example.com/customers/33245/orders/8769/lineitems/1

可能只返回同個訂單中的第一個條目。

現在你應該理解什麼是分層結構了。它們並不是嚴格的規則,只是為了確保在你的服務中這些強制的結構能夠更容易被使用者所理解。與所有軟體開發中的技能一樣,命名是成功的關鍵

錯誤的案例❌

前面我們已經討論過一些恰當的資源命名的例子,然而有時一些反面的例子也很有教育意義。下面是一些不太具有RESTful風格的資源URIs,看起來比較混亂。這些都是錯誤的例子!

首先,一些serivices往往使用單一的URI來指定服務介面,然後通過查詢引數來指定HTTP請求的動作。例如,要更新編號12345的使用者資訊,帶有JSON body的請求可能是這樣:

GET <http://api.example.com/services?op=update_customer&id=12345&format=json

儘管上面URL中的”services”的這個節點是一個名詞,但這個URL不是自解釋的,因為對於所有的請求而言,該URI的層級結構都是一樣的。此外,它使用GET作為HTTP動詞來執行一個更新操作,這簡直就是反人類(甚至是危險的)。

下面是另外一個更新使用者的操作的例子:

GET <http://api.example.com/update_customer/12345

以及它的一個變種:

GET <http://api.example.com/customers/12345/update

你會經常看到在其他開發者的服務套件中有很多這樣的用法。可以看出,這些開發者試圖去建立RESTful的資源名稱,而且已經有了一些進步。但是你仍然能夠識別出URL中的動詞短語。注意,在這個URL中我們不需要”update”這個詞,因為我們可以依靠HTTP動詞來完成操作。下面這個URL正好說明了這一點:

PUT <http://api.example.com/customers/12345/update

這個請求同時存在PUT和”update”,這會對消費者產生迷惑!這裡的”update”指的是一個資源嗎?因此,這裡我們費些口舌也是希望你能夠明白……

是否需要使用複數形式?

讓我們來討論一下複數和“單數”的爭議…還沒聽說過?但這種爭議確實存在,事實上它可以歸結為這個問題……

在你的層級結構中URI節點是否需要被命名為單數或複數形式呢?舉個例子,你用來檢索使用者資源的URI的命名是否需要像下面這樣:

GET <http://www.example.com/customer/33245

或者:

GET <http://www.example.com/customers/33245

兩種方式都沒問題,但通常我們都會選擇使用複數命名,以使得你的API URI在所有的HTTP方法中保持一致。原因是基於這樣一種考慮:customers是服務套件中的一個集合,而ID33245的這個使用者則是這個集合中的其中一個。

按照這個規則,一個使用複數形式的多節點的URI會是這樣(注意粗體部分):

GET <http://www.example.com/**customers**/33245/**orders**/8769/**lineitems**/1

“customers”、“orders”以及“lineitems”這些URI節點都使用的是複數形式。

這意味著你的每個根資源只需要兩個基本的URL就可以了,一個用於建立集合內的資源,另一個用來根據識別符號獲取、更新和刪除資源。

例如,以customers為例,建立資源可以使用下面的URL進行操作:

POST <http://www.example.com/customers

而讀取、更新和刪除資源,使用下面的URL操作:

GET|PUT|DELETE <http://www.example.com/customers/{id}

正如前面提到的,給定的資源可能有多個URI,但作為一個最小的完整的增刪改查功能,利用兩個簡單的URI來處理就夠了。

或許你會問:是否在有些情況下複數沒有意義?嗯,事實上是這樣的。當沒有集合概念的時候(此時複數沒有意義)。換句話說,當資源只有一個的情況下,使用單數資源名稱也是可以的——即一個單一的資源。

例如,如果有一個單一的總體配置資源,你可以使用一個單數名稱來表示:

GET|PUT|DELETE <http://www.example.com/configuration

注意這裡缺少configuration的ID以及HTTP動詞POST的用法。假設每個使用者有一個配置的話,那麼這個URL會是這樣:

GET|PUT|DELETE <http://www.example.com/customers/12345/configuration

同樣注意這裡沒有指定configuration的ID,以及沒有給定POST動詞的用法。在這兩個例子中,可能也會有人認為使用POST是有效的。好吧…

回顧

  • Http Method 的使用場景

增:post、put(非冪等)

刪:delete(冪等,類似修改計數器資源時非冪等)

改:put、patch(冪等,類似修改計數器資源時非冪等)

查:gethead(冪等)

其他:connect、optionstrace(冪等)

  • 使用 PUT 建立/更新資源

建立資源:當由客戶端來決定新資源具有哪些URI(通過資源名稱或ID)時,使用PUT http://www.example.com/article,請求body 中 id 為 123,用來修改資源名稱的 id 為 123

更新資源:PUT http://www.example.com/article/123,用來更新文章 123 的內容

  • 非冪等的請求建議統一使用 POST
  • 使用 Http Method 來描述 API 請求對資源的操作型別(CRUD)
  • 使用 URI 來描述 API 請求處理資源的位置層級,UIR 可以是名詞+描述名詞的屬性,需要具備可預測性和分層結構,能夠自解釋。

Lumen-api-starter 中的路由定義

// routes/web.php
Route::get('/', function () {
    return app()->version();
});

Route::get('author', function () {
    $response = Http::withOptions(['timeout' => 3])->get('<https://api.github.com/users/Jiannei>');
    $response->throw();

    return $response->json();
});

Route::get('configurations', 'ExampleController@configurations');
Route::get('logs', 'ExampleController@logs');

Route::post('users', 'UsersController@store');
Route::get('users/{id}', 'UsersController@show');
Route::get('users', 'UsersController@index');

Route::post('authorization', 'AuthorizationController@store');
Route::delete('authorization', 'AuthorizationController@destroy');
Route::put('authorization', 'AuthorizationController@update');
Route::get('authorization', 'AuthorizationController@show');

擴充套件閱讀

參考

本作品採用《CC 協議》,轉載必須註明作者和本文連結

最近申請了見習版主,還望各路好漢多多關照~

相關文章