這個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, 這些形式就很有用了:

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

rel: 表明了動作的類型

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

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

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

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

使用靜態基類包裝類

 首先建立一個LinkResource,表示鏈接:

它只有一個屬性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里面添加這兩句話以支持這個媒體類型:

然后再測試:

現在就對了。

根文檔

RESTfulAPI需要為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的消費者造成破壞。

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

但是在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

本文章轉載微信公眾號@dotNET跨平臺

上一篇:

用ASP.NET Core 2.1 建立規范的 REST API -- 緩存和并發

下一篇:

在C#中使用RESTful API的幾種好方法
#你可能也喜歡這些API文章!

我們有何不同?

API服務商零注冊

多API并行試用

數據驅動選型,提升決策效率

查看全部API→
??

熱門場景實測,選對API

#AI文本生成大模型API

對比大模型API的內容創意新穎性、情感共鳴力、商業轉化潛力

25個渠道
一鍵對比試用API 限時免費

#AI深度推理大模型API

對比大模型API的邏輯推理準確性、分析深度、可視化建議合理性

10個渠道
一鍵對比試用API 限時免費