用ASP.NET Core 2.1 建立规范的 REST API -- HATEOAS
本文所需的一些预备知识可以看这里: 和
建立Richardson成熟度2级的POST、GET、PUT、PATCH、DELETE的RESTful API请看这里: 和 和
本文将把WEB API项目开始提升到Richardson成熟度3级的高度,尽管暂时还没有实现REST所有的约束,但是已经比较RESTful了。
本文需要的代码(右键另存,后缀改为zip):
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上标注版本了:
实际上我并不是很喜欢这种版本管理,感觉会很乱。。有兴趣的话,请看一下官方文档吧:
随后我把这个库删掉了。
除了手动实现的这种HATEOAS,还有很多其它的选项,例如OData。但是OData就不仅仅是HATEOAS了,它正在尝试对RESTful API进行标准化,例如它还对创建Uri、翻页以及调用方法等等都制定了很多规则,还有很多的东西,但是我还是不怎么使用OData。
这次就写到这里,源码在:
下周继续。
上一篇: “武媚娘”机器人现身深圳
下一篇: 学生越来越不靠谱了,呵呵~
推荐阅读
-
用ASP.NET Core 2.1 建立规范的 REST API -- HATEOAS
-
用ASP.NET Core 2.1 建立规范的 REST API -- 翻页/排序/过滤等
-
用ASP.NET Core 2.0 建立规范的 REST API -- 预备知识
-
用ASP.NET Core 2.1 建立规范的 REST API -- 缓存和并发
-
用ASP.NET Core 2.1 建立规范的 REST API 系列文章索引
-
用ASP.NET Core 2.0 建立规范的 REST API -- DELETE, UPDATE, PATCH 和 Log
-
用ASP.NET Core 2.0 建立规范的 REST API -- GET 和 POST
-
用ASP.NET Core 2.0 建立规范的 REST API -- DELETE, UPDATE, PATCH 和 Log
-
用ASP.NET Core 2.1 建立规范的 REST API -- HATEOAS
-
用ASP.NET Core 2.1 建立规范的 REST API -- 翻页/排序/过滤等