用ASP.NET Core 2.1 建立規範的 REST API -- HATEOAS

solenovex發表於2018-06-09

本文所需的一些預備知識可以看這裡: http://www.cnblogs.com/cgzl/p/9010978.html 和 http://www.cnblogs.com/cgzl/p/9019314.html

建立Richardson成熟度2級的POST、GET、PUT、PATCH、DELETE的RESTful API請看這裡:https://www.cnblogs.com/cgzl/p/9047626.html 和 https://www.cnblogs.com/cgzl/p/9080960.html 和 https://www.cnblogs.com/cgzl/p/9117448.html

本文將把WEB API專案開始提升到Richardson成熟度3級的高度,儘管暫時還沒有實現REST所有的約束,但是已經比較RESTful了。

本文需要的程式碼(右鍵另存,字尾改為zip):https://images2018.cnblogs.com/blog/986268/201806/986268-20180608085054518-398664058.jpg

HATEOAS(Hypermedia as the engine of application state)是 REST 架構風格中最複雜的約束,也是構建成熟 REST 服務的核心。它的重要性在於打破了客戶端和伺服器之間嚴格的契約,使得客戶端可以更加智慧和自適應,而 REST 服務本身的演化和更新也變得更加容易。

HATEOAS的優點有:

具有可進化性並且能自我描述

超媒體(Hypermedia, 例如超連結)驅動如何消費和使用API, 它告訴客戶端如何使用API, 如何與API互動, 例如: 如何刪除資源, 更新資源, 建立資源, 如何訪問下一頁資源等等. 

例如下面就是一個不使用HATEOAS的響應例子:

{
    "id" : 1,
    "body" : "My first blog post",
    "postdate" : "2015-05-30T21:41:12.650Z"
}

如果不使用HATEOAS的話, 可能會有這些問題:

  • 客戶端更多的需要了解API內在邏輯
  • 如果API發生了一點變化(新增了額外的規則, 改變規則)都會破壞API的消費者.
  • API無法獨立於消費它的應用進行進化.

如果使用HATEOAS:

{
    "id" : 1,
    "body" : "My first blog post",
    "postdate" : "2015-05-30T21:41:12.650Z",
    "links" : [
        {
            "rel" : "self",
            "href" : http://blog.example.com/posts/{id},
            "method" : "GET"
        },
     {
       "rel": "update-blog",
       "href": http://blog.example.com/posts/{id},
       "method" "PUT"
}
.... ] }

這個response裡面包含了若干link, 第一個link包含著獲取當前響應的連結, 第二個link則告訴客戶端如何去更新該post.

 

Roy Fielding的一句名言: "如果在部署的時候客戶端把它們的控制元件都嵌入到了設計中, 那麼它們就無法獲得可進化性, 控制元件必須可以實時的被發現. 這就是超媒體能做到的.

針對上面的例子, 我可以在不改變響應主體結果的情況下新增另外一個刪除的功能(link), 客戶端通過響應裡的links就會發現這個刪除功能, 但是對其他部分都沒有影響.

HTTP協議還是很支援HATEOAS的:

如果你仔細想一下, 這就是我們平時瀏覽網頁的方式. 瀏覽網站的時候, 我們並不關心網頁裡面的超連結地址是否變化了, 只要知道超連結是幹什麼就可以.

我們可以點選超連結進行跳轉, 也可以提交表單, 這就是超媒體驅動應用程式(瀏覽器)狀態的例子.

如果伺服器決定改變超連結的地址, 客戶端程式(瀏覽器)並不會因為這個改變而發生故障, 這就瀏覽器使用超媒體響應來告訴我們下一步該怎麼做.

那麼怎麼展示這些link呢? 

JSON和XML並沒有如何展示link的概念. 但是HTML卻知道, anchor元素: 

<a href="uri" rel="type"  type="media type">

href包含了URI

rel則描述了link如何和資源的關係

type是可選的, 它表示了媒體的型別

為了支援HATEOAS, 這些形式就很有用了:

{
    ...
    "links" : [
        {
            "rel" : "self",
            "href" : http://blog.example.com/posts/{id},
            "method" : "GET"
        }
        ....
    ] 
}

method: 定義了需要使用的方法

rel: 表明了動作的型別

href: 包含了執行這個動作所包含的URI.

 

為了讓ASP.NET Core Web API 支援HATEOAS, 得需要自己手動編寫程式碼實現. 有兩種辦法:

靜態型別方案: 需要基類(包含link)和包裝類, 也就是返回的資源裡面都含有link, 通過繼承於同一個基類來實現.

動態型別方案: 需要使用例如匿名類或ExpandoObject等, 對於單個資源可以使用ExpandoObject, 而對於集合類資源則使用匿名類.

 

使用靜態基類包裝類

 首先建立一個LinkResource,表示連結:

再建立一個抽象父類 LinkResourceBase:

它只有一個屬性Links。

然後我讓CityResource繼承於LinkResourceBase:

最後在Controller裡面,我們需要寫程式碼來為資源建立上面概念提到的Links。這裡也需要用到UrlHelper,需要在Controller裡面注入。

由於我要為Resource建立很多基於路由的連結地址,所以需要為相關Action的路由填上名字:

然後在Controller裡面建立一個方法,它可以為CityResource新增需要的Links,並返回處理後的CityResource。

首先為資源新增的是本身的連結,這裡使用UrlHelper和路由名以及cityId作為引數可以得到href,難道不需要傳遞countryId嗎?因為Controller的路由地址已經包含了countryId引數,UrlHelper會自動處理這個問題的;而rel的值可以自行填寫,這裡我用self來表示本身,API消費者需要知道這部分,通過rel的值,API消費者就會知道API提供了哪些功能;最後method的值是GET。

其它幾個連結也是類似的。根據需要你可以新增額外的連結,但是針對本文這個簡單的例子,這些連結就夠了。

接下來要做的就是保證每當CityResource被Action返回的時候,都會執行該方法來建立相關的連結

首先考慮返回單個City的情況,GET:

POST也是一樣的:

還有一個GetCitiesForCountry這個方法,它返回的資源的集合,所以我需要遍歷集合,在每一個資源上呼叫該方法:

這裡只需要使用Select方法即可,它本身就是遍歷。

測試,首先是GET單個City:

看起來是OK的,然後在用裡面的連結測試相關操作也是好用的,我就不貼圖了。

下面測試一下POST:

結果也是OK的,連結都是好用的。

最後看一下集合的GET:

看起來還不錯,集合裡的每個資源都有正確的連結。但是結果裡並不存在針對整個集合的連結。我們也可以直接把結果改變成這個樣子

{
     value: [city1, city2...]
     links: [link1, link2...]    
}

因為這是不合理的JSON結果,它並不是被請求的資源的型別。

 

暫時先不管這點,為了支援集合的HATEOAS,我們需要一個包裝類:

這個類可以看作是針對某種型別的特殊集合,它繼承於LinkResourceBase,具有連結的屬性;此外還要保證T的型別也是LinkResourceBase,這樣就可以保證返回的集合裡面的元素也都有Links屬性;這個類只有一個Value屬性,型別是IEnumerable<T>。

 

回到Controller再建立一個方法叫CreateLinksForCities:

 

 

注意引數和返回型別都是LinkCollectionResourceWrapper。

最後在GET Action方法裡呼叫該方法即可:

 

測試:

結果是可以的,現在對於CityResource來說差不多可以說是支援HATEOAS了。

 

使用動態型別

這裡要用到dynamic和匿名型別。

現在CountryController裡面的GET方法返回的是IEnumerable<ExpandoObject>,是塑形後的CountryResource:

我無法把這種物件繼承於某種父類以便新增Links屬性。所以這種情況下,就需要使用匿名類的方式。

這裡也是分單個資源和集合資源兩種情況。

單個資源

首先為路由新增好名稱:

由於ExpandoObject無法繼承我定義的父類,所以只好建立一個方法返回Links:

由於資料塑形的存在,引數還要加上fields。前面幾個連結很好理解就是Country資源的相關連結,而後兩個資源是Country資源的子資源City的,分別是為Country建立City和獲取Country下的Cities。

這個方法表明的我們已經是在驅動應用程式的狀態了。這也就是HATEOAS的亮點。

然後就把這些links新增到響應的body即可。首先是GET方法:

返回Links,為ExpandoObject新增一個links屬性,並返回即可。

測試:

OK。然後我們新增幾個資料塑形的引數:

仍然OK, self的Link裡面的href也帶著這些引數。

 

然後是POST Action的方法:

和GET差不多,只不過POST不需要資料塑形。注意返回的CreatedAtRoute裡面的第二個引數裡面的id,我是從linkedCountryResource裡面取出來的,而不是countryModel的id,這樣做也許更好,因為這個id應該是linkedCountryResource裡面的。

測試:

結果也是OK的。

集合資源

之前我們對GetCountries做了翻頁的處理,並且把翻頁的後設資料放在了響應的Header裡面,並且裡面包含了前一頁和後一頁的連結:

其實這兩個連結放在Links集合裡是更好的,所以下面這個方法會新增前一頁和後一頁的連結:

 這裡使用了之前建立的CreateCountryUri方法,分別返回了self和前一頁以及後一頁。

最後在GetCountries方法裡呼叫:

首先把後設資料裡面的兩個連結去掉了。

然後為集合建立了links,再然後對集合進行資料塑形,並把集合裡面的每個物件都加上了links。最後返回一個包含value和links的匿名類。

測試:

正確的返回了結果。

下面測試一下各種引數:

結果應該是OK的,但是大小寫貌似有一些問題,這個我直接在原始碼裡面改吧。

 

這裡介紹了兩種方法,其實在專案中根據情況還是使用一種比較好。

 

Media Type

針對響應的結果,其描述性的資料或者叫後設資料應該放在Header裡面。例如之前做翻頁的時候,總頁數,當前頁數等資料都放在了Header裡面;而下一頁和上一頁的連結則放在了響應的body裡面。那這兩個連結應該是資源的一部分嗎?或者說他們是否對資源進行了描述(是否是後設資料)?其它的連結也存在這個問題。如果是後設資料,那麼就應該放在Header,如果是資源的一部分,就可以放在響應的body裡。現在的情況是,上例和之前的寫法是對同一種資源的不同表述。但是到目前我們請求的Accept Header都是application/json,也就是想要資源的JSON表述,但是返回的並不是Country資源的表述,而是另外一種東西,它在Country資源的JSON表述的基礎上還擁有links屬性,所以說如果我們請求的是application/json,那麼links就不應該是資源的一部分。

實際上現在返回的東西是另一種media type而不是application/json,這樣我們就破壞了資源的自我描述性這條約束每個訊息都應該包含足夠的資訊以便讓其它東西知道如何處理該訊息)。所以我們返回的content-type的型別是錯誤的,而且還會導致API消費者無法從content-type的型別來正確的解析響應,也就是說我沒有告訴API消費者如何來處理這個結果。那麼解決方案就是建立新的media type。

Vendor-specific media type 供應商特定媒體型別

它的結構大致如下:

application/vnd.mycompany.hateoas+json

 

第一部分vnd是vendor的縮寫,這一條是mime type的原則,表示這個媒體型別是供應商特定的。

接下來是自定義的標識,也可能還包括額外的值,這裡我是用的是公司名,隨後是hateoas表示返回的響應裡面要包含連結。

最後是一個“+json”。

整個這個media type就表示我所需要的資源表述是JSON格式的,而且還要帶著相關連結。

所以當請求的media type是application/json的時候,只需要返回資源的JSON表述。

而請求application/vnd.mycompany.hateoas+json的時候,需要返回帶有連結的資源表述。

修改Action方法:

使用FromHeader讀取Header裡面的Accept的值,然後判斷如果media type是自定義的,那麼就是包含連結的結果;否則,就使用不包含連結的結果,並且把翻頁相關的連結放在自定義的Header裡面。

測試:

請求application/json,返回結果不帶links。

修改media type:

返回的是406,Not Acceptable。

這是因為ASP.NET Core的格式化器並不認識我們這個自定義的媒體型別。

在Startup裡面新增這兩句話以支援這個媒體型別:

然後再測試:

現在就對了。

 

根文件

RESTful的API需要為API的消費者提供一個根文件。通過這個文件,API消費者可以知道如何與其餘的API進行互動。可以把這個理解為索引頁面吧。

這個文件位於API的根部,建立一個RootController:

它的路由地址就是根路徑/api。

它只有一個GET方法,通過讀取Header裡的Accept的值,來返回相應的連結。

這裡如果媒體型別是我之前自定義的那個,就會返回三個連結:本身,獲取Countries,建立Country。這三個就足夠了,有了這三個連結,其它的操作和資源(City)的路由地址都會通過一層層的連結獲得到。

如果請求型別是其它的,就返回204。

由於我這個程式太簡單了,所以這裡只寫這些內容就足夠了。

 

現在,關於資源的表述以及媒體型別你可能會發現更多的問題。

看之前的例子裡面的Links連結,這些連結的格式並不是某個標準的格式,而是我自己建立的格式,消費者API並不知道如何處理這些Link,消費者API需要從API文件中瞭解如何解析Link,我需要在API文件裡描述rel的值。

我們也知道媒體型別media type也是API的對外介面合約的內容。這裡還有另外一個問題,超媒體允許程式控制元件、連結等在被需要的時候提供,針對某個動作的連結,API消費者並不知道應該在請求裡放什麼內容。

之前我們已經建立了自定義的媒體型別,回憶一下Country的GET和POST兩個Action,它們使用的是不同的ResourceModel:

儘管我的例子裡它們的屬性很像,但是它們是不同的Model,並且有可能屬性差別很大。

然後在兩個Action裡,我都是用的是application/json這個媒體型別,實際上這個專案裡目前大部分的API我都是用的是application/json。但是實際上這兩個Model是對Country這個資源的不同表述,使用application/json實際上是錯誤的。應該使用vendor-specific的媒體型別,例如:

application/vnd.mycompany.country.display+json和application/vnd.mycompany.country.create+json。根據情況也可以做的更細更靈活一些。這樣API消費者多少知道了針對不同動作應該傳送什麼樣的請求內容了。

 

版本

我們的API到現在已經更改了很多次,API肯定會變化,所以需要版本的介入。

API的功能,業務邏輯,甚至Resource Model都會發生變化,但是我們需要保證變化的同時不要對API的消費者造成破壞。

進行版本控制的辦法有幾個:

  • 在Uri裡面插入版本:/api/v1/countries
  • 通過query string 查詢字串:/api/countries?api-version=v1
  • 自定義Header:例如:”api-version“=v1

但是在RESTful的世界裡,這些做法不是都可以的。

實際上Roy Fielding建議不要對RESTful API進行版本管理

但是實際上很多人感覺還是需要對API進行版本管理的,因為需求肯定會一直變化的,API就會一直變化。但是也不要對任何東西都進行版本管理,我們應該儘量小心的使用版本,儘量使API向下相容

 

如果API的功能或業務邏輯變化了,HATEOAS會把這件事處理很好, API的消費者通過觀察HATEOAS的這些東西,就不會對它造成破壞。

但是如果Resource Model變化了,這確實是個問題,Roy Fielding說這種情況也不應該進行版本管理

這些其實就是之前的問題,我如何讓API的消費者知道資源的表述應該是什麼樣的;還有我如何保證隨著API的進化,API的消費者也會跟著進化?

根據Roy Fielding的闡述,這些問題的解決方案就是使用按需編碼約束(Code on Demand)來適配媒體型別和資源表述的進化,約束中提到API可以擴充套件客戶端的功能。

也許在ASP.NET MVC或者一些web網站可以自適應這種變化,如果這些網站的js,html等是從伺服器端生成的;但是大多數的時候,其實很難實現這種自適應變化。

 

我們也許可以在媒體型別裡新增版本號來適當處理資源表述的變化。例如:

application/vnd.mycompany.country.display.v1+json和application/vnd.mycompany.country.display.v2+json

下面舉個例子, 我在Entity Model裡面新增了一個新的屬性大洲 Continent,當然它是可空的:

而現在API的消費者可以在建立Country的時候給Continent賦值也可以不賦值,這時,就需要再建立一個帶有Continent屬性的ResourceModel為POST這個動作:

別忘了做AutoMapper的對映配置。

在Controller裡,針對POST動作它的引數型別可能是CountryAddResource和CountryAddWithContinentResource,所以還需要再建立一個POST的方法:

由於有了兩個路由地址一樣的POST方法,所以還需要根據Content-Type這個Headerd的值來決定請求進入哪個方法。這裡我們可以自定義一個應用於Action方法的自定義約束屬性標籤:

這個很簡單,傳進來需要匹配的header型別,和值(允許多個值);然後從request的headers裡面找到匹配即可返回true。

分別應用到兩個Action:

最後還需要把這兩個媒體型別註冊一下,注意這兩個是輸入:

 

下面測試,首先使用原來的application/json:

404,沒錯,因為Content-Type已經不符了。

接下來使用原來的POST方法的媒體型別:

就會進入原來的POST方法:

 

使用另一個媒體型別,就會進入另外一個方法,就不貼圖了是好用的。

 

上面的自定義約束標籤RequestHeaderMatchingMediaTypeAttribute的第二個引數meidatypes是個陣列,為什麼?

因為,就看上一個截圖,這個方法接收的格式是json,但是如果我想要也支援接收xml,就直接在陣列裡新增另一個xml的媒體型別就可以了。

 

這個約束標籤不僅僅可以過濾一個Header型別,也可以多個,比如說我同時還要根據Accept Header來指定不同的方法,那麼:

這裡提示重複,但是可以通過修改這個約束標籤類來解決:

這時,錯誤提示就沒有了:

 

微軟的API Versioning庫

微軟提供了一個API 版本管理的庫:Microsoft.AspNetCore.Mvc.Versioning

使用Nuget安裝後,在Startup裡面註冊:

隨後就需要在Controller上標註版本了:

實際上我並不是很喜歡這種版本管理,感覺會很亂。。有興趣的話,請看一下官方文件吧:

https://github.com/Microsoft/aspnet-api-versioning/wiki/New-Services-Quick-Start

隨後我把這個庫刪掉了。 

 

除了手動實現的這種HATEOAS,還有很多其它的選項,例如OData。但是OData就不僅僅是HATEOAS了,它正在嘗試對RESTful API進行標準化,例如它還對建立Uri、翻頁以及呼叫方法等等都制定了很多規則,還有很多的東西,但是我還是不怎麼使用OData。

 

這次就寫到這裡,原始碼在:https://github.com/solenovex/ASP.NET-Core-2.0-RESTful-API-Tutorial

下週繼續。

 

相關文章