用ASP.NET Core 2.1 建立規範的 REST API -- HATEOAS
本文所需的一些預備知識可以看這裏: 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
例如下面就是一個不使用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
下周繼續。
用ASP.NET Core 2.1 建立規範的 REST API -- HATEOAS