分布式架构设计之RestAPI HAL
在上一篇文章《分布式架构设计之Rest API》中,我对什么是Rest进行了详细的介绍,同时以书本的CRUD为例,实现了Rest API的基本操作。但是细心的读者可能会发现,所有接口返回的数据格式并不统一,没有一定的规范性,更重要的是接口通信的内容并未很明确地体现“资源”的概念,所以在这篇文章就来介绍下现在流行的HAL风格的数据格式,需要提及的是:HAL(Hypertext Application Language)是一种API数据格式风格,同时也能规范接口通信内容的格式,降低客户端与服务器端接口API的耦合度。
l 什么是HAL
l 实例的验证
一、什么是HAL
上面的拼图并不一个创新,是直接从HAL的作者Mike kelly的官方网站(http://stateless.co/hal_specification.html)上复制的一份,它包含三个标准部分:
1、状态(State)
指的是资源本身的固有属性,比如:书本的资源表述如下:
{
…
"id": 1,
"name": "《Web进阶实战教材》",
"tag": "编程语言",
"price": 68.59
…
}
2、链接(Links)
链接定义了与当前资源相关的一组资源的集合,比如:
"_links":{
"self": {
"href": "…"
}
}
正如上所示,链接包含了三部分组成:链接名称、目标地址及访问的地址参数。
3、子资源(Embedded Resource)
指的是描述在当前资源的内部,其嵌套资源的定义。比如:
"_embedded":{
"books": [
{
"id": 1,
"name": "《Web进阶实战教材》",
"tag": "编程语言",
"price": 68.59,
"updateTime": 1502556100000,
"createTime":1502556100000,
"_links": {
"ex:items": {
"href":"…"
},
"self": {
"href": "…"
},
"curies": [
{
"href":"…",
"name":"ex",
"templated": true
}
]
}
},
{
"id": 2,
"name": "《Ruby进阶实战教材》",
"tag": "编程语言",
"price": 68.59,
"updateTime":1502691808000,
"createTime": 1502691808000,
"_links": {
"ex:items": {
"href":"http://localhost:8080/cwteam/2"
},
"self": {
"href":"http://localhost:8080/cwteam/books/2"
},
"curies": [
{
"href":"http://localhost:8080/cwteam/books/{rel}",
"name":"ex",
"templated": true
}
]
}
}
]
}
另外,HAL规范是围绕资源和链接两个概念展开的。资源的表达包含链接、嵌套的资源和状态。资源的状态一般指的是资源本身所包含的数据,链接则包含其指向的目标地址(URI),所表达的关系和其它可选的相关属性。正如上面所示json格式,资源的链接包含在_links属性对应的键值对中,而其中的键(key)是链接的关系,而值(value)则是另一个包含href等其它链接属性的对象或对象数组。当前所包含的资源,则由_embedded属性表示,其值是包含了其它资源的哈希对象或对象数组。
使用 URL 作为链接的关系带来的问题是 URL 作为属性名称来说显得过长,而且不同关系的 URL 的大部分内容是重复的。为了解决这个问题,可以使用 Curie,而Curie 可以作为链接关系 URL 的模板。链接的关系声明时使用 Curie 的名称作为前缀,不用提供完整的 URL。应用中声明的 Curie 出现在_links 属性中。代码中定义了 URI 模板为“http://localhost:8080/exlist/rels/{rel}”的名为 ex 的 Curie。在使用了 Curie 之后,名为 items 的链接关系变成了包含前缀的“ex:items”的形式。这就表示该链接的关系实际上是“http://localhost:8080/exlist/rels/items”。
二、实例的验证
我们以上一篇文章的例子为基础,对其实现HAL的规范化。可在此之前,有必要介绍下Spring的子项目HATEOAS对HAL的支持,也是Rest风格中最复杂的约束,现阶段,Spring HATEOAS仅支持一种超媒体表达格式,同时我们也只需要在应用的配置类上使用
@EnableHypermediaSupport(type=EnableHypermediaSupport.HypermediaType.HAL)这个注解,就可以启用对超媒体类型的支持,如下所示:
@Configuration
@EnableWebMvc
@EnableHypermediaSupport(type=EnableHypermediaSupport.HypermediaType.HAL)
public class ServletConfigextends WebMvcConfigurerAdapter {
…
}
在启用了该支持后,服务端输出的表达格式就会遵循HAL规范。当然,启用了超媒体支持后,会默认启用@EnableEntityLinks功能(下面介绍),同时应用还需要相关的定制表达,使HAL的表达更加友好,那么就要如下操作:
首先,内嵌在_embedded中的内容,是由RelProvider接口实现提供,对应我们的应用而言,只需要在内嵌资源对应的模型中添加Relation注解即可,如下所示:
@Relation(value="book", collectionRelation="books")
public class Book extends BaseModel {
…
}
需要注意的是,当内嵌资源使用Book作为模型时,单个资源则使用book作为属性,而多个资源则使用books作为属性。
另外,如果需要添加Curie,那么需要提供CurieProvider接口实现。这里我们使用已有的DefaultCurieProvider类并提供Curie的前缀和URI模版,具体如下:
@Bean
public CurieProvider curieProvider() {
return newDefaultCurieProvider("ex",
new UriTemplate("http://localhost:8080/cwteam/books/{rel}"));
}
好了,有了上面的准备工作之后,我们就可以进入HAL主题实现了,具体技术实现如下所示:
1、Maven依赖
<dependency>
dependency>
建议使用最新版本。
2、基础模型
public class BaseModel implements Identifiable
private Long id;
public Long getId() {
return id;
}
public booleanequals(Object o) {
if (this == o) return true;
if (o == null || getClass() != o.getClass()) return false;
BaseModel that = (BaseModel) o;
if (id != null ? !id.equals(that.id) : that.id != null) return false;
return true;
}
public int hashCode() {
return id != null ? id.hashCode() : 0;
}
}
该类为所有资源模型类的父级,实现了资源标志接口Identifiable
3、书本模型
@Relation(value="book", collectionRelation="books")
public class Book extends BaseModel {
private long id;
private String name;
private String tag;
private double price;
private Timestamp updateTime;
private TimestampcreateTime;
…
}
如果内嵌资源为单个,则返回键属性为book,如果内嵌资源为多个,那么返回键属性为books。
4、链接信息
为了把模型对象转为满足HATEOAS约束的资源,那么就需要添加链接信息。在Spring HATEOAS中,org.springframework.hateoas.Link类是用来表示和生成链接的,该类也遵循着Atom规范中对应链接的定义,包括rel和href两个属性。属性rel表示链接关系,href则表示链接指向的资源标志符,一般为URI。
在创建资源时,需要继承Spring HATEOAS提供的org.springframwork.hateoas.Resource类,该类提供了简单的方式创建资源链接。如下即为书本模型类对应的资源类BookResource的实现方式:
public class BookResource extends Resource {
public BookResource(BaseModel list) {
super(list);
Long listId = list.getId();
add(linkTo(getClass()).slash(listId).withRel("items"));
}
}
该类主要的工作是构建资源对象,并为每个资源创建一个附加非self的资源链接。
5、组装资源
一般我们需要将模型类对象转换为对应的资源对象,比如:把Book类对象转换为BookResource类对象。一般的做法就是new BookResource(books)方式来转换。我们也可以(推荐)使用SpringHATEOAS提供的资源组装器把转换逻辑封装起来。该组装起可以自动创建rel属性和href链接,具体如下:
public classBookResourceAssembler extends ResourceAssemblerSupport
public BookResourceAssembler(Class> sourceClass) {
super(sourceClass,BookResource.class);
}
public BookResource toResource(BaseModel entity) {
BookResourceresource = createResourceWithId(entity.getId(),entity);
return resource;
}
protected BookResource instantiateResource(BaseModel entity) {
return new BookResource(entity);
}
}
创建此类时,需要指定使用资源的控制器(Class> sourceClass),以用来确定生成链接的地址信息。
ResourceAssemblerSupport 类的默认实现是通过反射来创建资源对象的。toResource 方法用来完成实际的转换。此处使用了 ResourceAssemblerSupport类的 createResourceWithId 方法来创建一个包含 self 链接的资源对象。
BookResourceAssembler 类的 instantiateResource 方法用来根据一个模型类 Book 的对象创建出 BookResource对象。
需要注意的是:
单个资源转换:
newBookResourceAssembler(getClass()).toResource(entity);
多个资源转换:
newResources<>(newBookResourceAssembler(getClass()).toResources(entities), link);
6、模型类创建链接
上面介绍的是通过 Spring MVC 控制器来创建链接,另外一种做法是从模型类中创建。这是因为控制器通常用来暴露某个模型类。如RestApiAction类直接暴露模型类Book,并提供了访问 Book 资源集合和单个 Book 资源的接口。对于这样的情况,并不需要通过控制器来创建相关的链接,而可以使用 EntityLinks。首先需要在控制器类中通过“@ExposesResourceFor”注解声明其所暴露的模型类,如下所示:
@RestController
@RequestMapping("/books")
@ExposesResourceFor(BaseModel.class)
public class RestApiAction extends BaseAction {
…
}
另外在 Spring 应用的配置类中需要通过“@EnableEntityLinks”注解来启用 EntityLinks 功能,如果上面有启用了超媒体支持,那么该注解自动启用。而此EntityLinks功能依赖spring-plugin-core组建包,maven依赖如下:
<dependency>
<groupId>org.springframework.plugingroupId>
<artifactId>spring-plugin-coreartifactId>
<version>1.1.0.RELEASEversion>
dependency>
那么如何使用EntityLinks?如下所示:
@Autowired
EntityLinks entityLinks;
entityLinks.linkForSingleResource(BaseModel.class, entity);
需要注意的是,为了 linkForSingleResource 方法可以正常工作,控制器类中需要包含访问单个资源的方法,而且其“@RequestMapping”是类似“/{id}”这样的形式。
有了上面的准备之后,我们来看看书本Book的CRUD有何改进:
1、BaseAction类添加了构建单个或多个资源的方法:
// 构建单个资源对象
public BookResource genResultListByCode(BaseModel entity) {
return newBookResourceAssembler(getClass()).toResource(entity);
}
// 构建多个资源对象
public Resources
Linklink = linkTo(getClass()).withSelfRel();
return newResources<>(newBookResourceAssembler(getClass()).toResources(entities), link);
}
同时改进了请求头的返回处理:
@Autowired
EntityLinks entityLinks;
// 返回请求头的信息
public HttpHeaders genHeaders(BaseModel entity) {
HttpHeadersheaders = new HttpHeaders();
headers.setLocation(entityLinks.linkForSingleResource(BaseModel.class, entity).toUri());
return headers;
}
2、检索所有书籍
后端:
// 检索所有书本
@RequestMapping(method=RequestMethod.GET,produces="application/hal+json")
public Resources
List
if(null == result || result.size() == 0) {
throw newDataNotFoundException();
}
return genResultList(result);
}
前端:
// 检索所有书籍
function readBooks(){
$.ajax({
url:'/cwteam/books',
data:null,
type:"get",
dataType:'json',
contentType:'application/hal+json',
success:function(result){
var result = JSON.stringify(result);
$("#result").html(result);
}
});
}
需要注意的是ajax请求的contentType必须与服务接口相同,并且必须同时为application/hal+json时,才能支持HAL格式结果返回。同理,除HTTP请求头返回的信息外,json/xml格式的数据都需要配置内容类型为application/hal+json,否则非HAL风格格式。
请求结果:
3、检索指定书籍
后端:
// 根据书号检索一本书
@RequestMapping(value="/{id}",method=RequestMethod.GET,produces="application/hal+json")
public BookResource books(@PathVariable long id) throws Exception {
Bookresult = bookService.readBook(id);
if(null == result) {
throw new DataNotFoundException(id,10001);
}
return genResultListByCode(result);
}
前端:
// 根据书号检索一本书
function readBook(){
$.ajax({
url:'/cwteam/books/1',
data:null,
type:"get",
dataType:'json',
contentType:'application/hal+json',
success:function(result){
var result = JSON.stringify(result);
$("#result").html(result);
}
});
}
请求结果:
4、上架一本书
后端:
// 上架一本书
@RequestMapping(method=RequestMethod.POST,produces="application/json")
public ResponseEntity> createBook(@RequestBody Book book) throws Exception {
Bookresult = bookService.createBook(book);
if(null == result) {
throw new DataNotFoundException();
}
return newResponseEntity
}
前端:
// 上架一本书
function createBook(){
var jsonStr = "{\"id\":3,\"name\":\"《Ruby进阶实战教材》\",\"tag\":\"编程语言\",\"price\":68.59}";
$.ajax({
url:'/cwteam/books',
data:jsonStr,
type:"post",
dataType:'json',
contentType:'application/json',
success:function(result){
var result = JSON.stringify(result);
$("#result").html(result);
}
});
}
请求结果:
5、更新一本书
后端:
// 更新一本书
@RequestMapping(value="/{id}",method=RequestMethod.PUT,produces="application/json")
public BookResource updateBook(@PathVariable long id,@RequestBody Book book) throws Exception {
Bookresult = bookService.updateBook(book);
if(null == result) {
throw newDataNotFoundException();
}
return genResultListByCode(result);
}
前端:
// 更新一本书
function updateBook(){
var jsonStr = "{\"id\":1,\"name\":\"《Web进阶实战教材》\",\"tag\":\"编程语言\",\"price\":68.59}";
$.ajax({
url:'/cwteam/books/1',
data:jsonStr,
type:"put",
dataType:'json',
contentType:'application/json',
success:function(result){
var result = JSON.stringify(result);
$("#result").html(result);
}
});
}
请求结果:
6、下架一本书
后端:
// 下架一本书
@RequestMapping(value="/{id}",method=RequestMethod.DELETE,produces="application/json")
public ResponseEntity> deleteBook(@PathVariable long id) throws Exception {
int rows = bookService.deleteBook(id);
if(rows <= 0) {
throw newDataNotFoundException();
}
return newResponseEntity
}
前端:
// 下架一本书
function deleteBook(){
var jsonStr = "{\"id\":3,\"name\":\"《WEB进阶实战教材》\",\"tag\":\"编程语言\",\"price\":68.59}";
$.ajax({
url:'/cwteam/books/3',
data:jsonStr,
type:"delete",
dataType:'json',
contentType:'application/json',
success:function(result){
var result = JSON.stringify(result);
$("#result").html(result);
}
});
}
请求结果:
好了,由于作者水平有限,如有不正确或是误导的地方,请不吝指出讨论(技术交流群:497552060(新))