終於找到了一個靠譜的REST介紹

REST簡介

  一說到REST,我想大家的第一反應就是“啊,就是那種前後臺通信方式。”但是在要求詳細講述它所提出的各個約束,以及如何開始搭建REST服務時,卻很少有人能夠清晰地說出它到底是什麼,需要遵守什麼樣的準則。

  在您將看到的這一篇文章中,我們將對REST,尤其是基於HTTP的REST服務進行詳細地介紹。通過這些文章,您不僅可以瞭解到什麼是REST,更能清晰地瞭解到您在編寫REST服務時所需要遵守的各個守則,設計RESTful API時需要考慮的各種因素以及實現過程中可能遇到的問題等內容。

 

REST示例

  我想,很多讀者可能並不太清楚REST到底是一個什麼概念。那麼,首先讓我們來看一個簡單的基於HTTP的REST服務示例。

  假設用戶正在訪問一個電子商務網站www.egoods.com。該網站對其所銷售的各個物品進行了詳細分類。當用戶登錄該網站進行購物時,他首先需要在該網站上選擇其所需要尋找物品的分類,進而列出屬於該分類的各個物品。

wKiom1W4yhrgJ4HyAAFECS9EoLY439.jpg

當然,雖然從業務邏輯的角度來說這個流程非常簡單,但實際上瀏覽器向後臺發送了多個請求:頁面邏輯在頁面加載時將首先得到所有的商品分類,並將這些分類顯示在了頁面中。在用戶選擇了一個分類的時候,頁面邏輯將發送一個請求得到該分類的詳細信息,併發送另外一個請求來得到該分類的商品列表:

wKioL1W4zIbQm4pAAADR7euq2TM614.jpg

在通過瀏覽器的調試功能查看這些請求的時候,我們可以看到其首先向www.egoods.com/api/categories發送一個GET請求,以取得所有的商品分類:

GET /api/categories
Host: 
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json

而服務端將返回所有的類別:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx
 
[
   {
      "label" : "食品",
      "url" : "/api/categories/1"
   }, {
      "label" : "服裝",
      "url" : "/api/categories/2"
   }
   ...
   {
      "label" : "電子設備",
      "url" : "/api/categories/25"
   }
]

該響應返回了一個用JSON表示的數組。該數組中的每個元素包含了兩部分信息:用戶能夠讀懂的表示分類名稱的label以及相應分類所對應的URL。其中Label所記錄的分類名稱將在頁面中顯示給用戶。而在用戶根據label所標示的分類名選擇了一個分類的時候,頁面邏輯會取得該分類所對應的URL並向該URL 發送請求,以得到該分類的詳細信息。例如在用戶點擊了“食品”這個分類的時候,瀏覽器將會向服務器發送如下的請求:

GET /api/categories/1
Host: 
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json

這一次,頁面邏輯根據用戶對分類的選擇“食品”來得到了其所對應的URL,並向該URL發送了一個GET請求。而該請求所得到的響應則爲:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx
 
{
   "url" : "/api/categories/1",
   "label" : "Food",
   "items_url" : "/api/items?category=1",
   "brands" : [
         {
            "label" : "友臣",
            "brand_key" : "32073",
            "url" : "/api/brands/32073"
         }, {
            "label" : "樂事",
            "brand_key" : "56632",
            "url" : "/api/brands/56632"
         }
         ...
   ],
   "hot_searches" : …
}

該響應略爲複雜。首先,響應中的URL標示了“食品”分類所對應的URL。而label屬性則和前面一樣,用來在頁面上顯示分類的名稱。一個較爲特殊的屬性則是items_url。其用來標示獲取屬於食品分類的各個產品的URL。而屬性brands則用來列出在“食品”分類中的著名品牌,例如友臣,樂事等。這些品牌被組織爲一個對象數組,而數組中的每個對象都擁有label,url等屬性。在這些屬性的幫助下,頁面可以列出這些著名品牌的名稱,並允許用戶通過點擊跳轉到這些品牌所對應的頁面上。除了這些屬性之外,Food分類還包含了其它一系列屬性,如表示當前其它用戶正在搜索的hot_searches屬性等,這裏就不再贅述。

  該響應有一個問題,那就是符合用戶篩選條件的各個產品並沒有包含在該響應中。這是因爲頁面所列出的各個產品是根據用戶所設置的篩選條件,即其選擇的品牌以及搜索關鍵字而變化的。因此,頁面邏輯會根據屬性items_url以及用戶所設定的搜索條件組合成爲目標URL,再次發送請求到後臺,以請求需要在頁面中展現的各個物品。

  例如用戶在只想瀏覽屬於樂事品牌的食品時,其可以鉤選樂事這個品牌,那麼此時的URL將由食物分類的items_url以及表示按照品牌進行篩選的URL參數共同組成:

GET /api/items?category=1&brand_key=56632
Host: 
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json

現在讓我們來總結一下上面所展示的基於HTTP的REST系統的整個運行流程。在開始的時候,我們拿到了所有分類的列表。列表中的各個條目不僅僅包含了用戶可以看到的分類名稱等信息,更擁有一個額外的URL屬性。在用戶選擇該列表中的一項時,頁面邏輯將會向對應的URL發送一個請求,以獲得該項目的詳細信息。在這個詳細信息中,一些內容又包含了一些其它的URL,從而使得頁面邏輯又能通過該URL屬性發送請求。

  您也許會說,哎,這不和我們現有系統的運行流程一樣的嘛。是的。在上面所舉出的例子中,我們也更偏重地描述了REST系統所需要具有的HATEOAS(Hypermedia As The Engine Of Application State)特性。正是由於這個特性已經在大家所創建的系統裏面廣泛地使用了,因此我更希望從熟悉的地方入手,而不是開始就非常教條地說REST一定要這樣,一定要那樣,徒增了學習的難度。

  反過來說,上面所展示的REST服務並不具有典型性。在充分了解了REST後,您會發現,REST在系統設計上的視角將不再把流程放在了最優先的位置。

  而在後面的章節中,我們則會逐漸展開,詳細地介紹如何創建一個純正的基於HTTP的REST服務。

REST的定義

  OK,現在讓我們來看看REST的定義。Wikipedia是這樣描述它的:

 

Representational State Transfer (REST) is a software architecture style consisting of guidelines and best practices for creating scalable web services. REST is a coordinated set of constraints applied to the design of components in a distributed hypermedia system that can lead to a more performant and maintainable architecture.

 

  從上面的定義中,我們可以發現REST其實是一種組織Web服務的架構,而並不是我們想象的那樣是實現Web服務的一種新的技術,更沒有要求一定要使用HTTP。其目標是爲了創建具有良好擴展性的分佈式系統。

  反過來,作爲一種架構,其提出了一系列架構級約束。這些約束有:

  1. 使用客戶/服務器模型。客戶和服務器之間通過一個統一的接口來互相通訊。

  2. 層次化的系統。在一個REST系統中,客戶端並不會固定地與一個服務器打交道。

  3. 無狀態。在一個REST系統中,服務端並不會保存有關客戶的任何狀態。也就是說,客戶端自身負責用戶狀態的維持,並在每次發送請求時都需要提供足夠的信息。

  4. 可緩存。REST系統需要能夠恰當地緩存請求,以儘量減少服務端和客戶端之間的信息傳輸,以提高性能。

  5. 統一的接口。一個REST系統需要使用一個統一的接口來完成子系統之間以及服務與用戶之間的交互。這使得REST系統中的各個子系統可以獨自完成演化。

  如果一個系統滿足了上面所列出的五條約束,那麼該系統就被稱爲是RESTful的。

下面我們再次通過電子商務網站egoods這個示例來幫助我們理解這些約束。首先,egoods是一個電子商務網站。用戶需要通過瀏覽器,手機或者網站所發佈的瀏覽應用來訪問該網站的內容。因此其使用的自然是客戶/服務器模型。而在瀏覽過程中,用戶需要訪問不同類型的數據,如商品描述、購物車等信息。這些信息可能由egoods網站服務中不同的服務器來提供的,因此在用戶瀏覽過程中可能需要與不止一個服務器進行交互。如果在服務端保存了有關客戶的任何狀態,那麼在用戶與不同服務器進行交互的時候,客戶的狀態就需要在這些服務之間進行同步,大大地增加了系統的複雜度。因此,REST要求客戶端自行維護狀態,並在每次發送請求的時候提供自身所儲存的處理該請求所必需的信息。而恰當地使用緩存這一條也非常容易理解。在客戶端請求一個自上次請求後沒有發生過變化的信息時,如產品分類列表,服務端僅僅需要返回一個304響應即可。

  這裏您可以看到,前四條約束中除了無狀態這條約束較爲特別之外,其它三條約束在基於HTTP的Web服務中都很常見,也較容易達成。而無狀態約束在其它類型的Web服務中並不十分常見,因此如何避免違反該約束是在實現REST服務時最常討論的話題。其不僅僅會影響到很多功能的設計,更是REST系統擴展性的關鍵。因此在後面的章節中,我們會對無狀態約束單獨進行講解。

  在簡單地介紹了前四個約束之後,我們就需要着重講解統一接口這個約束了。可以說,前面的四個約束實際上都較爲容易達成。唯一需要注意的無非是是否某些技術實現違反了這些約束。而第五條約束,統一接口,可以說是REST服務設計的核心所在,也是決定REST服務設計的成敗之處。在實現一個基於HTTP的REST服務時,軟件開發人員不僅僅需要考慮REST所設置的一系列約束,更需要考慮HTTP各組成的語意,HTTP相關技術如何與REST服務約束結合,如何保持前後向兼容性以及如何進行版本管理等問題,才能給出一個自然的,具有較高易用性和較強生命力的REST系統。

  而在介紹統一接口約束之前,我們則需要了解一下和REST密切相關的兩個名詞:資源和狀態。可以說,資源是REST系統的核心概念。所有的設計都會以資源爲中心,包括如何對資源進行添加,更新,查找以及修改等。而資源本身則擁有一系列狀態。在每次對資源進行添加 ,刪除或修改的時候,資源就將從一個狀態轉移到另外一個狀態。

  比如說,在egoods中,商品的分類就是一種資源。該資源有很多實例,包括表示食品的分類,其所對應的URL是“/api/categories/1”。同樣地,食品的品牌也是一種資源。這些資源的實例都對應着一個當前的狀態。在修改了一個資源實例之後,比如修改了食品分類中的熱搜關鍵字,那麼其將對應着一個新的狀態。這種狀態之間的變化被稱爲是狀態的轉移。

  在大概瞭解了REST系統中的資源和狀態的定義後,我們來看看統一接口這個約束。該約束又包含了四個子約束:

  1. 每個資源都擁有一個資源標識。每個資源的資源標識可以用來唯一地標明該資源。

  2. 消息的自描述性。在REST系統中所傳遞的消息需要能夠提供自身如何被處理的足夠信息。例如該消息所使用的MIME類型,是否可以被緩存等。

  3. 資源的自描述性。一個REST系統所返回的資源需要能夠描述自身,並提供足夠的用於操作該資源的信息,如如何對資源進行添加,刪除以及修改等操作。也就是說,一個典型的REST服務不需要額外的文檔對如何操作資源進行說明。

  4. HATEOAS。即客戶只可以通過服務端所返回各結果中所包含的信息來得到下一步操作所需要的信息,如到底是向哪個URL發送請求等。也就是說,一個典型的REST服務不需要額外的文檔標示通過哪些URL訪問特定類型的資源,而是通過服務端返回的響應來標示到底能在該資源上執行什麼樣的操作。一個REST服務的客戶端也不需要知道任何有關哪裏有什麼樣的資源這種信息。

  現在,讓我們仍然以egoods作爲示例來解釋一下上面四個子約束。

  在前面的章節中,我們已經看到了從egoods所返回的表示食品這個分類的響應:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx

{
   "url" : "/api/categories/1",
   "label" : "Food",
   "items_url" : "/api/items?category=1",
   "brands" : [
         {
            "label" : "友臣",
            "brand_key" : "32073",
            "url" : "/api/brands/32073"
         }, {
            "label" : "樂事",
            "brand_key" : "56632",
            "url" : "/api/brands/56632"
         }
         ...
   ],
   "hot_searches" : …
}

首先我們看到的是,該響應通過Content-Type響應頭來標示響應中所包含的信息是按照JSON格式來組織的。在看到了該響應頭中所標示的格式之後,消息的接收方就可以按照JSON的格式理解或分析該響應中的負載。這也便是消息的自描述性。

  當然,消息的自描述性不僅僅包含如何解析其所攜帶的負載。在一個基於HTTP的REST系統中,我們可以通過使用大部分HTTP標準所提供的功能來提高消息的自描述性。由於這些功能已經擁有了完備的文檔,被廣大的軟件開發人員所熟知,並得到了衆多瀏覽器廠商以及Web類庫的支持,因此根據這些標準實現REST服務具有較高的消息自描述性。舉例來說,如果在請求中標明瞭If-Modified-Since頭,那麼服務端將可能返回一個304 Not Modified響應。在看到該響應的時候,瀏覽器或其它瀏覽工具可以從緩存中取得上一次得到的結果。因此,在一個基於HTTP的REST系統中,如何準確地使用HTTP協議是一項非常重要的內容。

  在獲知瞭如何對響應所攜帶的負載進行解析之後,我們就來看看資源的自描述性。在上面的示例中,服務端響應使用了JSON表示了食品分類。該表示首先通過label屬性描述了自己是一個什麼分類。接下來,其通過brands屬性表示了該分類中的著名品牌,並通過hot_searches標示了在該分類中的熱搜關鍵字。可以看到,該負載中的所有屬性都清晰地描述了自身所表達的含義。

  那在該資源表示中的url屬性是什麼意思?實際上這是爲子約束“每個資源都擁有一個資源標識”所添加的一個屬性。該子約束要求每個資源的資源標識可以用來唯一地標明該資源。對於網絡應用來說,資源標識就是URI。而在一個基於HTTP的系統中,最自然的資源標示便是URL。在表示單個資源的時候,這個URL常常會包含着資源在該類資源中的ID。

  在本文的其它章節中,我們就將以這種方式來區分URL和ID:URL用來指向資源所在的地址,而ID則表示該資源在該類型資源中的ID。請讀者一定要記得這兩個術語所對應的不同意義,以防止理解錯誤。

  現在還有一部分食品分類表示中的屬性沒有被講解,那就是在該表示中的各個URL。這是爲子約束HATEOAS服務的。在用戶看到items_url屬性時,其就可以通過向該URL發送GET消息得到屬於食品分類中的所有商品的列表。而在商品品牌的表示中也擁有一個url屬性。也就是說,向該URL發送一個GET請求也能夠得到相應品牌的詳細信息。

  您可能會問:既然在介紹HATEOAS時說REST服務並不需要文檔來告訴用戶哪裏擁有什麼樣的資源,那用戶應該如何知道向/api/categories發送GET請求就能得到所有的分類呢?標準的做法則是向/api直接發送一個GET請求:

GET /api
Host: 
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json

而在返回的響應中將標示出REST API的版本以及所有可以訪問的資源等信息:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx

{
   "version": "1.0",
   "resources": [
      {
         "label" : "Categories",
         "description" : "Product categories",
         "uri": "/api/categories"
      }, {
         "label" : "Items",
         "description" : "All items on sell",
         "uri": "/api/items"
      }
   ]
}

可以看到,在該響應中列出了可以被訪問的兩種資源:表示商品分類的Categories以及表示商品的Items。在需要訪問特定類型的資源時,軟件開發人員可以通過直接向這兩種資源所對應的URI發送GET請求即可。

  OK,相信現在讀者已經瞭解了REST服務所提供的各種約束。那麼在後面的章節中,我們將會逐步講解如何設計一個基於HTTP的REST服務。

 

資源識別

  在一般情況下,對資源的識別通常都是REST服務設計的第一步。在準確地識別出了各資源之後,怎麼用HTTP規範中的各組成來表示這些資源便是順理成章的事情。在本節中,我們將對如何識別REST系統中的資源進行講解。

  在通常的軟件開發過程中,我們常常需要分析達成某個目標所需要使用的業務邏輯,併爲業務邏輯的執行提供一系列運行接口。在一些Web服務中,這些接口常常表達了某個動作,如將商品放入購物車,提交訂單等。這一系列動作組合在一起就可以組成完成目標所需要執行的業務邏輯。在需要調用這些接口的時候,軟件開發人員需要向這些接口所在的URL發送一個請求,從而驅使服務執行該動作。

  而在REST服務中,我們所提供的各個接口則需要是一系列資源,而業務邏輯需要通過對資源的操作來完成。也就是說,REST服務中的API將不再以執行了什麼動作爲中心,而是以資源爲中心。一些對資源的通用操作有添加,取得,修改,刪除,以及對符合特定條件的資源進行列表操作。

  仍然讓我們以上面所舉的“將商品放入購物車”這個操作爲例。在一個REST系統中,購物車將被抽象爲一個資源,而“將商品放入購物車”這個操作將被解釋爲對購物車這個資源的更新:更新購物車,以使特定商品包含在購物車內。

  可能對於剛剛學習REST的各位讀者而言,這種以資源爲中心的描述方法有些彆扭。這種描述方法的確有別於很多Web服務那樣以動作爲中心。而與之對應的則是系統設計步驟的改變:我們將不再首先是別完成業務邏輯所需的各動作,而是支持業務邏輯所需要的各資源。那麼我們應該如何抽象出這些資源呢?首先,我們對某個操作不要再關注它所執行的動作,而是關心它所操作的賓語。通常情況下,該賓語就會是REST系統中的資源。

  在這裏,我們就以“提交訂單”作爲示例來展示如何抽象資源。

  首先,在“提交訂單”這個動作中,訂單是賓語。因此對於該業務邏輯,其將作爲一個資源存在。除此之外,在訂單中還需要包含一系列信息,例如訂單中所包含的商品,訂單所屬人等。一旦這些都可以被該REST系統中的其它資源使用,那麼它們也將成爲獨立的資源。

  但是有時候,一個動作可能並不存在着它所操作的賓語。在這種情況下,我們就需要考慮該動作產生或消除了哪個實體,或者哪個實體的狀態發生了變化。這個發生了變化的實體實際上就是一種資源。例如對於登陸這一行爲,其實際上在服務端創建了一個會話實例。該會話實例中則包含了登陸IP,登陸時間,以及登陸時所用的憑證等。再比如對於用戶更改密碼這種行爲,其所操作的資源就是用戶資料。

  在抽象資源的過程中,我們需要按照自頂向下的方式,即首先辨識出系統中的最主要資源,然後再辨識這些主要資源的子資源,並依次進行迭代。

  對主資源的抽取主要通過分析業務邏輯來完成。在得到功能需求以後,我們首先要分析這些業務邏輯所操作的賓語。這些賓語可能有兩種情況:主資源或者其它資源的子資源。主資源實際上就是能夠獨立存在的一系列資源。而子資源則需要依附於主資源之上才能表達實際的意義。同時各個子資源也可能擁有自身的子資源。

  判斷一個資源是否是子資源的一個方法就是看它是否能獨立地表示其具體含義。例如對於一個egoods上所銷售的商品,其名稱,價格,簡介等屬性可以清晰地描述該商品到底是什麼,到底如何銷售。因此這些商品實際上是一個主資源。但是每種商品所支持的郵遞服務需要是一個子資源:一個商品可以支持多種郵遞服務。這些郵遞服務根據派送距離等需要不同的價格,也提供了不同的郵遞速度。由於這些郵遞服務與商家和郵遞服務公司所達成的服務價格有關,並且會由於商品重量的變化而變化,因此這些郵遞服務並不能爲其它商家所提供的郵遞服務作爲參考,因此其應該作爲該商品的一個子資源。

  或者也可以說,如果一個資源是主資源,那麼其可以被不同的資源實例包含引用而不會產生歧義。而如果一個資源是子資源,那麼被不同的資源實例引用可能會產生歧義。

  但是需要注意的是,一種資源可能有多種不同的表現形式。例如對於在使用列表展示各個商品的時候,egoods只需要展示商品的名稱,一個對該商品的簡單描述,商品的價格以及一張商品的照片。而在用戶打開了該商品頁之後,頁面則需要顯示更詳盡的信息,如商品的重量,商品所在地等等。

  除此之外,資源列表也有可能擁有多種不同的表現形式。舉例來說,如果egoods上屬於某個分類的商品太多,需要分頁顯示,那麼這種分頁是否也應該是一種資源?答案是,這些分頁並不是一種資源,而其只是資源列表的一種表現方式。在每頁所包含商品數量,排序規則等條件發生變化的時候,該資源列表中所包含的各個商品也會發生變化。

  那麼如何判斷我們爲REST服務所定義的資源是否合理呢?一般情況下,我都使用下面的一些判斷方法:

  首先,我們需要考慮對該資源的CRUD是否有意義,從而驗證資源的定義是否合理。就以剛剛說到的列表的分頁顯示爲例,我們可以想象一下如何對分頁進行添加和刪除?一旦刪除了該分頁,那麼屬於該分頁中的各個商品也應該被刪除麼?而且刪除了分頁X的數據後,原本X + 1分頁的數據將展示在X分頁中。很顯然,將商品的分頁定義爲資源並不合理。

  其次,我們需要檢查資源是否需要除CRUD之外的動詞來操作。該方法用來檢查資源中是否還有子資源沒有被抽象。如果該資源還需要額外的動詞,那麼我們就需要考慮這些操作到底引起了什麼樣的狀態變化,進而抽象出該資源的子資源。

  除此之外,我們還需要檢查這些資源是否是被整體使用,創建和刪除。該方法用來探測是否一個子資源應該是一個主資源。如果在刪除一個資源的時候,其子資源還可以被其它資源重用,那麼該子資源實際上具有較高的重用性,應該是一個主資源。

 

資源的URL設計

  在前面已經提到過,統一接口約束中的第一條子約束就是每個資源都擁有一個資源標識。在正確地辨識出了一個資源之後,我們就需要爲這些資源分配其所對應的URI。一個資源所對應的URI可能有多種表示方式,如到底是用單數還是複數表示資源等。因此在一個基於HTTP的REST系統中,如何組織針對各個資源的URL實際上是最重要的一部分。畢竟一個明確的,有意義並且穩定的API接口實際上是對服務對用戶的一種承諾。

  在HTTP中,一個URL主要由以下幾個部分組成:

  1. 協議。即HTTP以及HTTPS。

  2. 主機名和端口。如www.egoods.com:8421

  3. 資源的相對路徑。如/api/categories。

  4. 請求參數。即由問號開始的由鍵值對組成的字符串:?page=1&page_size=20

  在爲一個資源設計其所對應的URL時,我們需要着重考慮第三部分和第四部分組成。

通過URL來表示資源

  在辨識出了REST系統中的各個資源以後,我們就需要開始爲這些資源設計各自所對應的URL了。

  首先要介紹的是,所有的資源都應該存在於一個相對路徑之下。請讀者回憶之前我們介紹的通過向/api發送一個GET請求得到所有可以被訪問的資源這個示例:

GET /api
Host: www.egoods.com
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx

{
   "version": "1.0",
   "resources": [
      {
         "label" : "Categories",
         "description" : "Product categories",
         "uri": "/api/categories"
      }, {
         "label" : "Items",
         "description" : "All items on sell",
         "uri": "/api/items"
      }
   ]
}

因此對於從向該相對路徑發送請求才能得到的各個主資源來說,將它們置於相對路徑/api之下是非常合理的。

  除了這個原因之外,API的版本更迭也是一個考慮。假如軟件開發人員需要開發一個新版本的REST API,那麼他可能就需要重新抽象並定義系統中的各個資源。但是如果兩個版本的API中都擁有一個categories資源,並且系統爲了保持後向兼容性同時保留了兩個版本的API,那麼將只有一個資源可以使用/categories這個相對路徑。也正因爲如此,將這些資源置於相對路徑/api之下,並在第二個版本的API出現之後將新的資源抽象置於/api-v2下是一種較爲流行的做法。

  在明確了所有的資源都應該置於/api這樣一個相對路徑下之後,我們就來講解如何爲資源定義對應的URL。一個最簡單的情況是:指定主資源所對應的URL。由於主資源是一類獨立的資源,因此它應該直接置於/api下。例如egoods網站中的產品分類就是一個主資源,我們會爲其分配如下URL:

/api/categories

而對於其它主資源,如egoods網站中的產品,我們也會爲其賦予一個具有類似結構的URL:

/api/items

這樣,每類主資源都將擁有一個特定於該類資源的URL。這些URL就對應着相應資源實例的集合。

  如果需要表示某個主資源類型中的特定實例,那麼我們就需要在該類主資源所對應的URL之後添加該實例的ID。如egoods網站中的食品分類的ID爲1,那麼其所對應的URL就將是:

/api/categories/1

一個較爲特殊的情況則是,對於某種類型的主資源,整個系統將有且僅有一個該類型資源的實例。那麼該資源將不再需要通過ID來訪問。我能想到的一個例子就是對整個系統進行介紹的資源。該資源實例所對應的URL將是

/api/about

而一個資源實例中還可能擁有子資源。這些子資源與資源實例之間的關係主要有兩種情況:資源實例包含了一個子資源的集合,以及資源實例僅僅可以包含一個子資源。對於資源實例包含了一個子資源集合的情況,我們需要將該子資源集合的URL置於該資源的相對路徑下。例如對於egoods上所銷售的ID爲23456的商品所提供的郵遞服務,我們將使用如下的URL:

/api/items/23456/shipments

在該URI中,/api/items/23456對應的就是商品本身,而該商品所提供的郵遞服務則是該商品的子資源。與主資源特定實例所具有的URI類似,其中一個ID爲87256的郵遞服務所對應的URI則爲:

/api/items/23456/shipments/87256

如果資源實例僅僅可以包含一個子資源,那麼對該子資源的訪問也將不再需要ID。如當前商品的折扣信息:

/api/items/23456/discount

單數 vs. 複數

  接下來要考慮的一點是,資源在URL中需要由單數表示還是複數表示?這在stackoverflow等衆多論壇上已經成爲了一個經久不衰的話題。我們知道,在一個基於HTTP的REST系統中,一個資源所對應的URL實際上也就是對其進行操作的URL。因此適當地使用單數和複數對於該系統的用戶而言有一定的指示作用。在stackoverflow上的一個常見觀點是:如果一個URL所對應的資源是使用複數表示的,那麼該類型的資源可能有多個。對該URL發送Get請求可能返回該資源的一個列表。反之,如果一個URL所對應的資源是使用單數表示的,那麼該類型的資源將只有一個,因此對該URL發送Get請求將只返回該資源的一個實例。

  以egoods中的商品分類爲例。由於一個網站所售賣的商品可能有多種類別,因此其需要在URL中使用複數形式:/api/categories。而對於一個該網站的用戶而言,由於其只會有一個個人偏好設置,因此其URL則需要使用單數形式:/api/users/{user_id}/preference。

  你可能會問:如果需要得到具有特定ID的某個實例時,我們應該對該資源使用單數還是複數呢?答案是複數。這是因爲在通過特定ID訪問某個資源的實例實際上就是從該資源的集合中取出特定實例。因此表示該資源集合的URL實際上仍然需要使用複數形式,而其後所使用的ID則標明瞭其所訪問的是資源中的單一實例,因此向這個URL發送Get請求將返回該資源的單一實例。

  就以“食品”分類爲例。該分類所對應的URL爲/api/categories/1。該URL中的前半部分/api/categories表示egoods網站中所有分類的集合,而1則表示在該分類集合中的ID爲1的分類。

 

相對路徑 vs. 請求參數

  另一個經常導致疑惑的地方就是針對資源的某一種特徵,我們到底是將其定義爲URL中相對路徑的一部分還是作爲請求參數。

  請考慮下面一個例子。在egoods網站中,我們售賣的手機主要有蘋果,三星等品牌。那麼在爲這些手機設計URL的時候,我們是否需要按照品牌對這些手機進行細分,從而用戶只要通過向/api/mobiles/brands/apple發送請求就能列出所有的蘋果手機?還是說,直接將手機的品牌置於請求參數中,從而通過/api/mobiles?brand=apple來列出所有的蘋果手機?

  在判斷到底是使用請求參數還是相對路徑時,我們一般分爲下面幾步。

  首先,可選參數一般都應置於請求參數中。仍以egoods中的手機爲例。在選擇手機時,用戶可以選擇品牌以及顏色。如果將品牌和顏色都定義在相對URL中,那麼具有特定品牌和顏色的手機將可以通過兩個不同的URL訪問:/api/mobiles/brand/{brand}/color/{color}以及/api/mobiles/color/{color}/brand/{brand}。就用戶而言,其並無法瞭解這兩個URL所表示的是同一類資源還是不同類型的資源。當然,您可以說,我們只用/api/mobiles/brand/{brand}/color/{color}。但是該URL將無法處理用戶僅僅選擇了顏色,卻沒有選擇品牌的情況。

  其次,不是所有字符都可以在URL中被使用,如漢字,標點。爲了處理這種情況,包含這些字符的篩選條件需要置於請求參數中。

  最後,如果該特徵下包含子資源,那麼它自身也就是一個資源,因此需要以相對路徑的方式展現它。例如在egoods網站中,每件商品所屬於的分類僅僅是它的一個特徵。但是一個分類更包含了屬於它的各個品牌以及熱搜關鍵字等衆多信息。因此它其實是一個資源,需要在URI路徑中表示它。

  總的來說,既然使用HTTP來構建REST系統,那麼我們就需要遵守URL各組成中的含義:URL中的相對路徑將用來標示“What I want”,也既對應着資源;而請求參數則用來標示“How I want”,即查看資源的方式。

使用合適的動詞

  在知道了如何爲每種資源定義URI之後,我們來看看如何操作這些資源。

  首先,在一個資源的生命週期之內常常會發生一系列通用事件(CRUD)。一開始,一個資源並不存在。只有用戶或REST服務創建了該資源以後其才存在,也即是上面所列出的通用事件中的C,Create。在一個資源創建完畢以後,用戶可能會從服務端請求該資源的表示,也就是上面所列出的通用事件的R,Retrieve。在特定情況下,用戶可能決定要更新該資源,因此會使用上面的通用事件中的U,即Update來更新資源。而在資源不再需要的時候,用戶可能需要通過通用事件D,即Delete來刪除該資源。同時用戶有時也需要列出屬於特定類型資源的資源實例,即通過List操作來得到屬於特定類型的資源的列表。

  在前面的講解中我們已經提到過,在REST系統中的每個資源都有一個特定的URI與之對應。HTTP協議提供了多種在URI上操作的動詞,如GET,PUT,POST以及DELETE等。因此在一個基於HTTP的REST服務中,我們需要使用這些HTTP動詞來表示如何對這些資源進行CRUD操作。而在什麼情況下到底使用哪個動詞則是由這些動詞本身在HTTP協議中的意義所決定的。

  這其中GET和DELETE兩個動詞的含義較爲清晰:

 

The GET method means retrieve whatever information (in the form of an entity) is identified by the Request-URI.

The DELETE method requests that the origin server delete the resource identified by the Request-URI.

 

  也就是說,在需要讀取某個資源的時候,我們向該資源所對應的URI發送一個GET請求即可。類似的,在需要刪除一個資源的時候,我們只需要向該資源所對應的URI發送一個DELETE請求即可。而在希望得到某類型資源的列表的時候,我們可以直接向該類型資源所對應的URI發送一個GET請求。

  而動詞PUT和POST則是較爲容易混淆的兩個動詞。在HTTP規範中,POST的定義如下所示:

 

  The POST method is used to request that the origin server accept the entity enclosed in the request as a new subordinate of the resource identified by the Request-URI in the Request-Line

 

  也就是說,POST動詞會在目標URI之下創建一個新的子資源。例如在向服務端發送下面的請求時,REST系統將創建一個新的分類:

POST /api/categories
Host: www.egoods.com
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json

{
   "label" : "Electronics",
   ……
}

而PUT的定義則更爲晦澀一些:

 

The PUT method requests that the enclosed entity be stored under the supplied Request-URI. If the Request-URI refers to an already existing resource, the enclosed entity SHOULD be considered as a modified version of the one residing on the origin server. If the Request-URI does not point to an existing resource, and that URI is capable of being defined as a new resource by the requesting user agent, the origin server can create the resource with that URI."

 

  也就是說,PUT則是根據請求創建或修改特定位置的資源。此時向服務端發送的請求的目標URI需要包含所處理資源的ID:

POST /api/categories/8fa866a1-735a-4a56-b69c-d7e79896015e
Host: www.egoods.com
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json

{
   "label" : "Electronics",
   ……
}

可以看到,兩者都有創建的含義,但是意義卻不同。在決定到底是使用PUT還是POST來創建資源的時候,軟件開發人員需要考慮一系列問題:

  首先就是資源的ID是如何生成的。如果希望客戶端在創建資源的時候顯式地指定該資源的ID,那麼就需要使用PUT。而在由服務端爲該資源自動賦予ID的時候,我們就需要在創建資源時使用POST。在決定使用PUT創建資源的時候,防止資源URI與其它資源所具有的URI重複的任務需要由客戶端來保證。在這種情況下,客戶端常常使用GUID/UUID作爲將資源的ID。但是到底使用GUID/UUID還是由服務端來生成ID不僅僅和REST有關,更會對數據庫性能等多個方面產生影響。因此在決定使用它們之前要仔細地考慮清楚。

  同時需要注意的是,因爲REST要求客戶只可以通過服務端返回結果中所包含的信息來得到下一步操作所需要的信息,因此客戶端僅僅可以決定資源的ID,而URI中的其它部分則需要從之前得到的響應中取得。

  但是軟件開發人員常常會進入另外一個誤區很多人認爲REST服務中的HATEOAS只能通過Hyperlink完成。實際上在Roy對REST的定義中使用的是Hypermedia,即響應中的所有多媒體信息。就像Roy在其個人網站上所說(http://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven):

 

A REST API must not define fixed resource names or hierarchies (an obvious coupling of client and server). Servers must have the freedom to control their own namespace. Instead, allow servers to instruct clients on how to construct appropriate URIs, such as is done in HTML forms and URI templates, by defining those instructions within media types and link relations.

 

  另外一個需要考慮的因素則是PUT的等冪性是否對REST系統的設計有所幫助。由於在同一個URI上調用兩次PUT所得到的結果相同。因此用戶在沒有接到PUT請求響應時可以放心地重複發送該響應。這在網絡丟包較爲嚴重時是一個非常好的功能。反過來,在同一個URI上調用兩次POST將可能創建兩個獨立的子資源。

  除此之外,還需要考慮是否將資源的創建和更新歸結爲一個API可以簡化用戶對REST服務的使用。用戶可以通過PUT動詞來同時完成創建和更新一個資源這兩種不同的任務。這樣的好處在於簡化了REST服務所提供的接口,但是反過來也讓一個API執行了兩種不同的任務,在一定程度上違反了API設計時每個API都需要有明確的意義這一原則。

  因此在決定到底使用POST還是PUT來完成資源的創建之前,請考慮上面所列出的三條問題,以確定到底哪個動詞更加適合。

  除此之外,另外一對類似的動詞則是PUT和PATCH。兩者之間的不同則在於PUT是對整個資源的更新,而PATCH則是對部分資源的更新。而該動詞的侷限性則在於對該動詞的支持程度。畢竟在某些類庫中並沒有提供原生的對PATCH動詞的支持。

 

使用標準的狀態碼

  在與REST服務進行交互的時候,用戶需要通過服務所返回的信息決定其所發送的請求是否被適當地處理。這部分功能是由REST服務實現時所使用的協議所決定的,與REST架構無關。而在基於HTTP的REST服務中,該功能就由HTTP響應的狀態碼(Status Code)來完成。因此在設計一個REST服務時,我們需要額外地注意是否返回了正確的狀態碼。

  但是這些預定義的HTTP狀態碼並不能滿足所有的情況。有時候一個REST服務所希望返回的錯誤信息能夠更加精確地描述問題,例如在用戶重設密碼時,我們需要在用戶所輸入原密碼與系統中所記錄的密碼不匹配時返回“您所輸入的密碼有誤”這樣的消息。在HTTP協議中,我們並沒有辦法找到一個能夠精確地表示該意義的狀態碼。

  因此在通常情況下,REST服務都會在響應中額外地提供一個說明性的負載來告知用戶到底產生了什麼問題。例如對於上面的重設密碼失敗的情況,服務端可能會返回如下響應:

HTTP/1.1 400 Bad Request
Content-Type: application/json
Content-Length: xxx

{
   "error_id" : "100045",
   "header" : "Reset password failed",
   "description" : "The original password is not correct"
}

上面的示例響應中主要包含以下的說明性信息:

  1. 服務端響應的狀態碼。頁面邏輯可以通過判斷該狀態碼是否是4XX或5XX來判斷是否請求出錯,從而在頁面中展示一個警告對話框。

  2. 服務所提供的內部錯誤ID。通常情況下,該內部錯誤ID也需要在警告對話框中展示出來。從而允許軟件用戶根據內部錯誤ID來獲取支持服務。

  3. 錯誤的標題及簡述。通過該錯誤的標題及簡述,軟件用戶能夠了解系統內部到底發生了什麼,並在是用戶輸入錯誤的時候允許用戶自行修改錯誤並重新發送正確的請求。

  在該錯誤中,最關鍵的當屬服務端的響應代碼。一個響應代碼不僅僅標示了請求是否成功,更有用戶該如何操作的含義。例如對於401 Unauthorized響應代碼而言,其表示該響應沒有提供一個合法的身份憑證,因此需要用戶首先執行登陸操作以得到一個合法的身份憑證,然後該資源可能就可以被訪問了。而403 Forbidden響應代碼則表示當前請求已經提供了一個合法的身份憑證,但是該身份憑證並沒有訪問該資源的權限,因此使用該身份憑證登陸重新登陸系統等操作並不能解決問題。

  因此在返回錯誤信息之前,軟件開發人員首先需要考慮清楚在響應中到底應該使用什麼樣的響應代碼。而正確地選擇響應代碼則建立在軟件開發人員對這些響應代碼擁有一個正確的理解的前提下。

  當然,要將所有的響應代碼完全理解也需要大量的工作,而且REST服務的用戶也可能並沒有那麼多的領域知識來了解所有的響應代碼的含義。因此在很多基於HTTP的REST系統中,系統在標示錯誤時只使用一系列常用的響應代碼,如400,401,403,404,405,500,503等。在用戶請求被處理時,系統將返回200 OK,表示請求已經被處理。而在處理時發生錯誤時則儘量使用這些響應代碼來表示。如果一個錯誤較爲複雜,那麼直接返回400或500,並在響應的負載中提供具體的錯誤信息。

  不得不說的是,這種做法有時顯得簡單粗暴,尤其是對於一個開放平臺而言則更是致命的。當一個第三方廠商爲一個開放平臺開發一個應用軟件,卻每次只能得到一個400錯誤,那麼其內部應用邏輯將無法判斷到底是哪裏出了問題。爲了能讓用戶知道這裏產生了錯誤,該第三方軟件只能將開放平臺所給出的信息直接顯示給用戶。但是這些信息實際上是建立在開放平臺這個語境下的,因此對於第三方廠商的用戶而言,這些信息晦澀難懂,甚至可能一點幫助也沒有。

  也就是說,到底如何組織這些響應代碼需要用戶根據所編寫的項目決定,尤其是該產品的使用者來決定。在定義一個平臺時,儘量使用更多的HTTP響應代碼,因爲用戶極有可能通過該平臺編寫自己的第三方軟件。而在爲一個普通的產品定義REST API時,將響應代碼定得非常專業可能反而導致易用性的下降。

  另外一點需要說明的是,個人不建議使用Wikipedia查找各個狀態碼的含義,而應該使用RFC所描述的各狀態碼的定義。 IANA提供了一張各個狀態碼所對應的RFC協議的列表,從而可以很容易地找到各個狀態碼所對應的RFC協議以及其所在的章節。該列表的地址爲:http://www.iana.org/assignments/http-status-codes/http-status-codes.xhtml

  之所以不建議使用Wikipedia的原因主要有兩點:

  1. 描述不夠詳細。在RFC定義中,每個狀態碼都對應着一段或多段文字,並且解釋非常清晰。而在Wikipedia中,每個狀態碼常常只有一句話。

  2. 不夠準確。在Wikipedia的Reference節中,我們可以看到一系列特定平臺所定義的狀態碼,如Spring Framework所定義的420 Method Failure等。這非常具有誤導性。

選擇適當的表示結構

  接下來我們要講解的就是如何爲資源定義一個恰當的表示。

  首先需要強調的是,REST並沒有規定其服務中需要使用什麼格式來表示資源。表示資源時所可以選取的表示形式實際上是由實現REST所使用的協議決定的。而在一個基於HTTP的REST服務中,我們可以使用JSON,也可以使用XML,甚至是自定義的MIME類型來表示資源。這些表現形式常常是等效的。相信讀者已經看到,本系列文章會使用JSON來表示這些資源。

  一個REST服務常常會同時支持多種客戶端。這些客戶端可能會使用不同的協議來與服務進行溝通。而且就算是使用相同的協議,不同的客戶端所可以接受的負載表示形式也會有所不同。因此客戶端需要與REST服務協商在通訊過程中所使用的負載。

  客戶端和服務端對所使用負載類型的協商通常都按照協議所規定的標準協商過程來完成。例如對於一個基於HTTP的REST服務,我們就需要使用Accept頭來標示客戶端所可以接受的負載類型:

GET /api/categories
Host: 
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/json

而在服務端支持的情況下,返回的響應就將使用該MIME類型組織其負載:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx

在這裏我們再重複一次:REST是一種組織Web服務的架構,其只在架構方面提出了一系列約束。可以說,所有對REST的講解都已經在前兩個章節,即“REST的定義”以及“資源識別”中完成了。而有關客戶端和服務端如何進行溝通,爲資源定義什麼樣的URI,使用什麼格式的數據進行溝通等討論都是在闡述如何將REST架構所提出的各種約束和基於HTTP協議的Web服務結合在一起。畢竟在通常情況下,實現一個單純的技術不難,但是如何將多種技術規範自然地混合在一起,構成一個自然的,成熟穩定的解決方案纔是項目開發中的難點。HTTP協議並不是爲REST架構所定義的,因此如何用HTTP協議來恰當地描述一個REST服務纔是本文所着重介紹的。

 

負載的自描述性

  在前面對REST提出的幾個約束的講解中我們已經提到過,REST系統中所傳遞的各個消息的負載需要提供足夠的用於操作該資源的信息,如如何對資源進行添加,刪除以及修改等操作,並可以根據負載中所包含的對其它各資源的引用來訪問各個資源。這也對負載的自描述性提出了更高的要求。

  首先讓我們回頭看看egoods電子商務網站對食品分類的描述:

{
   "uri" : "/api/categories/1",
   "label" : "Food",
   "items_url" : "/api/items?category=1",
   "brands" : [
         {
            "label" : "友臣",
            "brand_key" : "32073",
            "url" : "/api/brands/32073"
         }, {
            "label" : "樂事",
            "brand_key" : "56632",
            "url" : "/api/brands/56632"
         }
         ...
   ],
   "hot_searches" : …
}

我想讀者在看到該響應之後可能就已經明白了很多域的含義。但還是讓我們依次對這些域進行講解。

  第一個要講解的是url域。該域用來標示該資源所對應的URL。可能您會問:既然我們就是從這個URL返回的該資源,那麼爲什麼我們還需要在該資源中保存一個它所對應的URL呢?首先這是因爲在統一接口約束中要求每個資源都擁有一個資源標識。在這裏我們使用URL作爲標識。而另一些基於HTTP的REST系統中,用來作爲資源標識的常常是該資源的ID。個人更傾向於使用URL的原因則是:在某些情況下,如對某個資源定時刷新以進行監控的時候,URL可以直接被使用。

  接下來是label域。其用來記錄用於展示給用戶的分類名。

  items_url域則用來表示取得屬於該分類物品列表的URL。注意這裏我使用了後綴_url以明確標明其是一個URL,需要通過跳轉來取得實際的數據。

  下一個域brands則用來表示屬於該分類的著名商品品牌。這裏我們使用了一個數組,而數組中的每個元素都表示了一個品牌。每個品牌的表示都包含了一個展示給用戶的label,在搜索時所使用的鍵,以及該品牌所對應的url。您可能會懷疑爲什麼我們僅僅提供了這麼少的域。這是因爲他們僅僅是對這個品牌的引用,而並非是把該資源的詳細信息都包含進來了的緣故。在用戶希望查看該品牌的詳細信息的時候,他需要向該品牌引用中所標明的品牌的URL發送一個GET請求。

  而由於hot_searches域的組成及使用基本上與brands域類似,因此這裏不再贅述。

  在大致地瞭解了食品分類的JSON表示中各個域的含義後,我們就將開始講解如何自行定義資源的JSON表示。對於一個簡單的,不包含任何子資源以及對其它資源的引用的資源,我們只需要通過一個包含簡單屬性的JSON來表示它。例如對於一個品牌,我們可能僅僅提供了一系列描述性信息:品牌的名稱,以及對品牌的簡單描述。那麼它所對應的JSON表示可以表示爲:

{ "uri" : "/api/brands/32059",
 "label" : "Dole",
 "description" : "An American-based agricultural multinational corporation."
 }

而在另一個資源中,可能包含了對其它資源的引用。在這種情況下,我們就需要在表示對其它資源進行引用的域中通過URL來標明被引用資源的位置。例如一件Dole果汁中,可能就需要包含對品牌Dole的引用:

{
   "uri" : "/api/items/1438299",
   "label" : "Dole Grape Juice",
   "price" : "$3.99",
   "brand" : {
      "label" : "Dole"
      "uri" : "/api/brands/32059"
   }
   ……
}

在上面的Dole果汁的表示中,我們可以看到它的brand域就是對品牌的引用。該引用中包含了該品牌的品牌名稱以及一個指向該品牌的URL。

  在一個基於HTTP的REST系統中,我們常常在資源的引用中包含一定量的描述信息。這主要因爲兩點:

  1. 提高性能。在一個對資源的引用中添加了用於顯示的屬性後,客戶端頁面可以避免再次通過url發送請求得到資源的具體描述,以得到用於顯示的信息。

  2. 自描述性的要求。如果一個資源中包含了一個對其它資源進行引用的數組,那麼用戶就需要通過該標籤來決定到底訪問哪個被引用的資源。

  當然,如果需要在展示Dole果汁的頁面中需要Dole這個品牌的完整信息,我們也可以將它直接嵌到Dole果汁的表示中:

{
   "uri" : "/api/items/1438299",
   "label" : "Dole Grape Juice",
   "price" : "$3.99",
   "brand" : {
      "uri" : "/api/brands/32059",
      "label" : "Dole",
      "description" : "An American-based agricultural multinational corporation."
   }
   ……
}

當然,如果一個資源的表示太過複雜,而且有些屬性實際上是相互關聯的,那麼我們也可以通過一個屬性將它們歸結在一起:

{
   "uri" : "/api/items/1438299",
   "label" : "Dole Grape Juice",
   "price" : "$3.99",
   "brand" : {
      "uri" : "/api/brands/32059",
      "label" : "Dole",
      "description" : "An American-based agricultural multinational corporation."
   }
   "nutrient component" : {
      "sugar" : "14.5",
      "protein" : "0.3",
      "fat" : "0.1"
   }
   ……
}

在上面的Dole果汁的表示中,我們使用域nutrient component來表示所有的營養成分,而該域內部的各個子域則用來表示一系列相關的營養成分所佔比例。

  另外,在不同的情況下,我們還可能對同一個資源提供不同的表現形式。例如在一個資源極爲複雜,其JSON表示甚至可以達到幾百K的時候,我們可以爲該資源提供一個簡化版本,以在非必要的情況下減少傳輸的數據量。

  例如在egoods中,我們會將某些物美價廉的商品置於它的首頁上,以吸引用戶購買。在用戶將鼠標移動到某個商品上並停留一段時間時,我們會爲用戶展示一個Tooltip,並在該Tooltip中展示該商品的一部分信息。在這種情況下,向服務端請求該商品的所有信息以展示Tooltip便顯得有些效率低下了。

  有時候,一個資源可能並不支持特定用戶執行某個操作。例如一個管理員所創建的資源可能對普通用戶只讀。在這種情況下,我們需要禁止普通用戶對該資源的修改和刪除。爲了能明確地告知用戶他所具有的權限,我們需要一個能顯式地標示用戶可以在一個資源上所執行操作的組成。在REST響應中,這種組成被稱爲Hypermedia Controls。例如對於一個普通用戶,其從egoods中所返回的分類列表將如下所示:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx
 
[
   {
      "label" : "Food",
      "uri" : "/api/categories/1",
      "actions" : ["GET"]
   }, {
      "label" : "Clothes",
      "uri" : "/api/categories/2",
      "actions" : ["GET"]
   }
   ...
   {
      "label" : "Electronics",
      "uri" : "/api/categories/25",
      "actions" : ["GET"]
   }
]

可以看到,在上面的分類列表中,我們通過actions域顯式地標示了用戶可以在各個類別上所能執行的操作。而對於管理員,其還可以執行修改,刪除等操作:

HTTP/1.1 200 OK
Content-Type: application/json
Content-Length: xxx
 
[
   {
      "label" : "Food",
      "uri" : "/api/categories/1",
      "actions" : ["GET", "PUT", "DELETE"]
   }, {
      "label" : "Clothes",
      "uri" : "/api/categories/2",
      "actions" : ["GET", "PUT", "DELETE"]
   }
   ...
   {
      "label" : "Electronics",
      "uri" : "/api/categories/25",
      "actions" : ["GET", "PUT", "DELETE"]
   }
]

而在一系列較爲著名的REST系統中,如Sun Cloud API,其更是通過Hypermedia Controls定義了除CRUD之外的動詞。如對於一個虛擬機,其在運行狀態下可以執行停止命令,而在停止狀態下可以執行啓動命令:

{
   "vms" : [
      {
         "id" : "1",
         ......
         "status" : "stopped",
         "links" : [
            {
               "rel" : "start",
               "method" : "post",
               "uri" : "vms/1?op=start"
            }
         ]
      }, {
         "id" : "2",
         ......
         "status" : "started",
         "links" : [
            {
               "rel" : "stop",
               "method" : "post",
               "uri" : "vms/2?op=stop"
            }
         ]
      }
   ]
}

但是一個常見的觀點是:如果一個資源需要除CRUD之外的額外的動詞,那麼這種需求常常表示我們對於某個資源的定義並不是十分合理。因此在遇到這種情況時,軟件開發人員首先需要考慮爲資源添加額外的動詞是否合適。

 

無狀態約束

  在Roy Fielding的論文中,其爲REST添加了一個無狀態約束:

 

We next add a constraint to the client-server interaction: communication must be stateless in nature … such that each request from client to server must contain all of the information necessary to understand the request, and cannot take advantage of any stored context on the server. Session state is therefore kept entirely on the client.

 

  從上面的陳述中可以看到,在一個REST系統中,用戶的狀態會隨着請求在客戶端和服務端之間來回傳遞。這也便是REST這個縮寫中ST(State Transfer)的來歷。

  爲REST系統添加這個約束有什麼好處呢?主要還是基於集羣擴展性的考慮。如果REST服務中記錄了用戶相關的狀態,那麼在集羣中,這些用戶相關的狀態就需要及時地在集羣中的各個服務器之間同步。對用戶狀態的同步將會是一個非常棘手的問題:當一個用戶的相關狀態在一個服務器上發生了更改,那麼在什麼時候,什麼情況下對這些狀態進行同步?如果該狀態同步是同步進行的,那麼同時刷新多個服務器上的用戶狀態將導致對用戶請求的處理變得異常緩慢。如果該同步是異步的,那麼用戶在發送下一個請求時,其它服務器將可能由於用戶狀態不同步的原因無法正確地處理用戶的請求。除此之外,如果集羣進行了不停機的橫向擴展,那麼用戶狀態的同步需要如何完成?這些實際上都是非常難以處理的問題。

  但是現有的很多較爲流行的技術及規範實際上都沒有限制用戶的請求是無狀態的。相信您知道,一個技術或規範實際上都擁有一個生態圈。在該生態圈之內的各技術之間可以較好地契合在一起。尤其是,有些技術實際上就會以該生態圈中的核心技術或規範所建立的假設之上來實現自己的功能。如果希望禁止該假設,那麼讓某些技術工作起來就是非常困難的事情了。

  就以搭建基於HTTP的REST服務爲例。在HTTP中,一個重要的功能就是Cookie和Session的使用(RFC6265)。該功能會在服務器裏保留一個狀態。因此在一個基於HTTP的REST系統中,我們常常需要避免使用這些在服務器裏面保留狀態的技術。但是某些技術,如用戶的登陸,實際上常常需要在服務器中添加一個狀態。

  所以在stackoverflow中,我們常常會看到有人問:我現在使用了這樣一種解決方案。這樣實現是不是RESTful?此時一些人就會說,這不是RESTful。但是pure RESTful和almost RESTful之間的區別主要還是在於一個是理論,一個是工程。在工程中,輕微地違反了一個準則並不一定代表這個解決方案一無是處。而是要看遵守該準則和輕微地違反了該準則之後工作量的大小以及後期的維護成本:之所以提出一系列準則,那是因爲遵守該準則擁有一定的好處。如果對該準則的輕微違反可以減少大量的工作量,而且遵守準則的好處並沒有消失,或者是通過另一樣技術可以快速地重新獲得該好處,那麼對準則的輕微違反是值得的。

Authentication

  其實在上一節中,我們已經提出了無狀態約束給REST實現帶來的麻煩:用戶的狀態是需要全部保存在客戶端的。當用戶需要執行某個操作的時候,其需要將所有的執行該請求所需要的信息添加到請求中。該請求將可能被REST服務集羣中的任意服務器處理,而不需要擔心該服務器中是否存有用戶相關的狀態。

  但是在現有的各種基於HTTP的Web服務中,我們常常使用會話來管理用戶狀態,至少是用戶的登陸狀態。因此,REST系統的無狀態約束實際上並不是一個對傳統用戶登錄功能友好的約束:在傳統登陸過程中,其本身就是通過用戶所提供的用戶名和密碼等在服務端創建一個用戶的登陸狀態,而REST的無狀態約束爲了橫向擴展性卻不想要這種狀態。而這也就是爲基於HTTP的REST服務添加身份驗證功能的困難之處。

  爲了解決該問題,最爲經典也最符合REST規範的實現是在每次發送請求的時候都將用戶的用戶名和密碼都發送給服務器。而服務器將根據請求中的用戶名和密碼調用登陸服務,以從該服務中得到用戶所對應的Identity和其所具有的權限。接下來,在REST服務中根據用戶的權限來訪問資源。

wKioL1W40c-h74OQAADcX2SkHSo476.jpg

這裏有一個問題就是登陸的性能。隨着系統當前的加密算法越來越複雜,登陸已經不再是一個輕量級的操作。因此用戶所發送的每次請求都要求一次登陸對於整個系統而言就是一個巨大的瓶頸。

  在當前,解決該問題的方法主要是一個獨立的緩存系統,如整個集羣唯一的登陸服務器。但是緩存系統本身所存儲的仍然是用戶的登陸狀態。因此該解決方案將仍然輕微地違反了REST的無狀態約束。

  還有一個類似的方法是通過添加一個代理來完成的。該代理會完成用戶的登陸並獲得該用戶所擁有的權限。接下來,該代理會將與狀態有關的信息從請求中刪除,並添加用戶的權限信息。在經過了這種處理之後,這些請求就可以轉發到其後的各個服務器上了。轉發目的地所在的服務器則會假設所有傳入的請求都是合法的並直接對這些請求進行處理。

wKiom1W40AzD0T4GAADPK1r_ZdA697.jpg

可以看到,無論是一個獨立的登陸服務器還是爲整個集羣添加一個代理,系統中都將有一個地方保留了用戶的登陸狀態。這實際上和在集羣中對會話集中進行管理並沒有什麼不同。也就是說,我們所嘗試的通過禁止使用會話來達成完全的無狀態並不現實。因此在一個基於HTTP的REST服務中,爲登陸功能使用集中管理的會話是合理的。

  既然我們放鬆了對REST系統的無狀態約束,那麼一個REST系統所可以使用的登陸機制將主要分爲以下兩種:

  1.   基於HTTPS的Basic Access Authentication

其好處是其易於實現,而且主流的瀏覽器都提供了對該功能的支持。但是由於登陸窗口都是由瀏覽器所提供的,因此其與產品外觀有很大不同。除此之外,瀏覽器都沒有提供登出的功能,也沒有提供找回密碼等功能。

  2.   基於Cookie及Session的管理

在使用Cookie來管理用戶的註冊狀態的時候,其實際上就是將服務端所返回的Cookie在每次發送請求的時候添加到請求中。雖然說這個Cookie並非存儲了用戶應用的狀態,但是其實際存儲了用戶的登陸狀態。因此客戶端的角度來講,由服務端管理的Session並不符合REST所倡導的無狀態的要求。

  可以說,上面的兩種方法各有優劣。可能第二種方法從客戶端的角度看來並不是RESTful的,但是其優勢則在於很多類庫都直接提供了對該功能的支持,從而簡化了會話管理服務器的實現。

  在這裏順便提一句,如果項目足夠大,將一些SSO產品集成到服務中也是不錯的選擇。

 

版本管理

  在前面已經提到過,一個REST系統爲資源所抽象出的URI實際上是對用戶的一種承諾。但反過來說,軟件開發人員也很難預知一個資源的各方面特徵如何在未來發生變化,從而提供一個永遠不變的URI。

  在一個REST系統逐漸發展的過程中,新的屬性,新的資源將逐漸被添加到該系統中。在這些更改過程中,資源的URI,訪問資源的動詞,響應中的Status Code將不能發生變化。此時軟件開發人員所做的工作就是在現有系統上維護REST API的後向兼容性。

  當資源發生了過多的變化,原有的URI設計已經很難兼容現有資源應有的定義時,軟件開發人員就需要考慮是否應該提供一個新版本的REST API。那麼我們該如何對資源的版本進行管理呢?

  首先要考慮的就是,新API的版本信息是否應當包含在資源的URI中。這在各著名論壇中仍然是一個爭議較大的話題。一種觀點認爲在不同版本的API中,一個資源擁有不同的地址在一定程度上違反了HATEOAS:URI只是用來指定一個資源所在的位置,而不是該資源如何被抽象。如果一個資源由不同的URI標示其不同的表現形式,那麼用戶將無法通過一個響應中所標示的URI得到其它URI所指向的表示形式。而且在URI中添加了有關版本的信息也就標示着其可能會隨着時間的推移發生變化。

  一種使用獨立URI的方法是基於Accept頭。在一個請求中,我們常常標明瞭Accept頭,以標示客戶端希望得到的表現形式。在該頭中,用戶可以添加所請求的資源的版本信息:

GET /api/categories/1
Host: 
Authorization: Basic xxxxxxxxxxxxxxxxxxx
Accept: application/vnd.ambergarden.egoods-v3+json

而在接收到該請求之後,服務端將返回該資源的第三個版本:

HTTP/1.1 200 OK
Content-Type: application/vnd.ambergarden.egoods-v3+json
Content-Length: xxx
 
{
   "uri" : "/api/categories/1",
   "label" : "Food",
   ……
}

可以看到,該方法是非常嚴格地遵守REST系統所提出的約束的。但其也並不是沒有缺點:添加一個自定義MIME類型(Custom MIME Type)也是一個很麻煩的流程,而且在很多現有技術中都沒有很好地支持它,如HTML5中的Form。因此這種方案的缺點是對REST API用戶並不那麼友好。

  除此之外,另一種基於重定向的解決方案也被提出。該方案允許一個REST系統提供多個版本的API,並在URI中標明版本號:

/api/v2/categories
/api/v1/categories

這樣用戶可以選擇使用特定版本的REST API來實現客戶端功能。由於其使用固定版本的API,因此並不存在着一個資源有多種表示,進而違反了HATEOAS約束的問題。

  在REST系統的API隨時間逐漸發展出衆多版本的時候,系統對API的維護也將成爲一個較大的問題。此時就需要逐漸退役一些年代久遠的API 版本。對這些版本的退役主要分爲兩步:首先將其標爲過期的,但是還在一段時間內支持。在這種情況下,對這些已經過期的API的訪問將得到3XX響應,如301 Moved Permanently,以通知用戶該URI所標示的資源需要使用新版本的URI進行訪問。而再經過一段時間後,則將過期的REST API標記爲廢棄的。此時用戶在訪問這些URI時將返回4XX響應,如410 Gone。

  接下來,該REST系統還可以提供一個通用的REST API接口,並與最新版本的API保持一致:

/api/categories

這樣用戶還可以選擇一直使用最新版本的API,只是同時也需要一直對其進行維護,以保持與最新版本API的兼容性。在REST系統的API隨着時間的推移逐漸發生變化的時候,該客戶端也需要逐漸更新自身的功能。

  但是該方法有一個問題:由通用URI所辨識出的各個資源需要是穩定的,不能在一定時間之後被廢棄,否則會給用戶帶來非常大的維護性的麻煩。舉例來說,假設客戶端邏輯添加了一系列操作分類的功能。當REST系統決定不再採用分類作爲商品歸類的標準,那麼客戶端邏輯中與分類相關的各個功能都需要進行大幅度地修改。過於頻繁的這種改動很容易導致用戶對該系統所提供的API失去維護的信心。因此在抽象資源時一定要努力地將各個資源的邊界辨識清楚。雖然說這聽起來很嚇人,但是在經過仔細考慮後這種情況還是較爲容易避免的。

  但是反過來說,理論常常與實際有些脫鉤,更何況REST是在2000年左右提出的,無法做到能夠預見到十餘年後所使用的各項技術。因此在儘量符合REST所提出的各約束上提供一個最直觀的,具有最高易用性的API纔是王道。無限制地提供後向兼容性是一個非常困難,成本非常高的事情。因此在版本管理這一方面上來說,我們也需要儘量兼顧項目需求和完全遵從理論這兩者之間的平衡。

  而在同一個版本之中,我們則需要保證API的後向兼容性。也就是說,在添加新的資源以及爲資源添加新的屬性的時候,原有的對資源進行操作的API也應該是工作的。

  對於一個基於HTTP的REST服務而言,軟件開發人員需要遵守如下的守則以保持API的後向兼容性:

  1. 不能在請求中添加新的必須的參數。

  2. 不能更改操作資源的動詞。

  3. 不能更改響應的HTTP status。

  而前向兼容性則顯得沒有那麼重要了。REST服務的前向兼容性要求現有的服務兼容未來版本服務的客戶端。但是由於服務提供商所提供的服務常常是最新版本,因此對前向兼容性有要求的情況很少出現。另外一點是,爲一個服務提供前向兼容性其實並不那麼容易。因爲這要求軟件開發人員對產品的未來方向進行非常多的假設,而且這些假設不能有錯誤。反過來,這種對服務的前向兼容性的要求主要由客戶端自身通過保持後向兼容性來完成。

性能

  接下來我們就來簡單地說說基於HTTP的REST服務中的性能問題。在基於HTTP的REST服務中,性能提升主要分爲兩個方面:REST架構本身在提高性能方面做出的努力,以及基於HTTP協議的優化。

  首先要討論的就是對登陸性能的優化。在前面我們已經介紹過,在一個基於HTTP的REST服務中,每次都將用戶的用戶名和密碼發送到服務端並由服務端驗證這些信息是否合法是一個非常消耗資源的流程。因此我們常常需要在登陸服務中使用一個緩存,或者是使用第三方單點登陸(SSO)類庫。

  除此之外,軟件開發人員還可以通過爲同一個資源提供不同的表現形式來減少在網絡上傳輸的數據量,從而提高REST服務的性能。

  而在集羣內部服務之間,我們則可以不再使用JSON,XML等這種用戶可以讀懂的負載格式,而是使用二進制格式。這樣可以大大地減少內部網絡所需要傳輸的數據量。這在內部網絡交換數據頻繁並且所傳輸的數據量巨大時較爲有效。

  接下來就是REST系統的橫向擴展。在REST的無狀態約束的支持下,我們可以很容易地向REST系統中添加一個新的服務器。

  除了這些和REST架構本身相關的性能提升之外,我們還可以在如何更高效地使用HTTP協議上努力。一個最常見的方法就是使用條件請求(Conditional Request)。簡單地說,我們可以使用如下的HTTP頭來有條件地存取資源:

  1. ETag:一個對用戶不透明的用來標示資源實例的哈希值

  2. Data-Modified:資源被更改的時間

  3. If-Modified-Since:根據資源的更改時間有條件地Get資源。這將允許客戶端對未更改的資源使用本地緩存。

  4. If-None-Match:根據ETag的值有條件地Get資源。

  5. If-Unmodified-Since:根據資源的更改時間有條件地Put或Delete資源。

  6. If-Match:根據ETag的值有條件地Put或Delete資源。

  當然,這裏所提到的一系列性能優化方案實際上僅僅是比較常見的,與基於HTTP的REST服務關聯較大的方案。只是顧慮到過多地陳述和REST關聯不大的話題一方面顯得比較沒有效率,另一方面也是因爲通過寫另一個系列博客可以將問題陳述得更加清楚,因此在這裏我們將不再繼續討論性能相關的話題。

相關資源

AtomPub:http://atomenabled.org/。其是最爲廣泛討論的並借鑑的RESTful服務。其由衆多HTTP和REST專家所編寫,甚至包括Roy Fielding本人也參與於其中

Roy Fielding的REST論文:http://www.ics.uci.edu/~fielding/pubs/dissertation/top.htm

Roy Fielding的個人網站:http://roy.gbiv.com/untangled/

RFC列表:http://www.ietf.org/rfc/


原文鏈接:http://www.cnblogs.com/loveis715/p/4669091.html


發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章