用ASP.NET Core 2.1 创建规范的 REST API -- HATEOAS

本文所需的一些预备知识能够看这里: http://www.cnblogs.com/cgzl/p/9010978.html 和 http://www.cnblogs.com/cgzl/p/9019314.htmlhtml

创建Richardson成熟度2级的POST、GET、PUT、PATCH、DELETE的RESTful API请看这里:http://www.javashuo.com/article/p-sryarofa-ct.html 和 http://www.javashuo.com/article/p-nmpufjil-ee.html 和 http://www.javashuo.com/article/p-oxgmtvqz-dz.htmlgit

本文将把WEB API项目开始提高到Richardson成熟度3级的高度,尽管暂时尚未实现REST全部的约束,可是已经比较RESTful了。github

本文须要的代码(右键另存,后缀改成zip):https://images2018.cnblogs.com/blog/986268/201806/986268-20180608085054518-398664058.jpgweb

HATEOAS(Hypermedia as the engine of application state)是 REST 架构风格中最复杂的约束,也是构建成熟 REST 服务的核心。它的重要性在于打破了客户端和服务器之间严格的契约,使得客户端能够更加智能和自适应,而 REST 服务自己的演化和更新也变得更加容易。json

HATEOAS的优势有:api

具备可进化性而且能自我描述数组

超媒体(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

下周继续。

相关文章
相关标签/搜索