6.2 启用超媒体

6.2 启用超媒体

到目前为止,我们所创建的API非常简单,但是只要消费它的客户端知道API的URL模式,它们就可以正常运行。例如,客户端可能会以硬编码的形式对“/design/recent”发送GET请求,以便于获取最近创建的taco。类似的,客户端会以硬编码的形式将taco列表中的ID拼接到“/design”上形成获取特定taco资源的URL。

在API客户端编码中,使用硬编码模式和字符串操作是很常见的。但是,我们设想一下,如果API的URL模式发生了变化又会怎么样呢?硬编码的客户端代码掌握的依然是旧的API信息,因此客户端代码将无法正常运行。对API URL进行硬编码和字符串操作会让客户端代码变得很脆弱。

超媒体作为应用状态引擎(Hypermedia as the Engine of Application State,HATEOAS)是一种创建自描述API的方式。API所返回的资源中会包含相关资源的链接,客户端只需要了解最少的API URL信息就能导航整个API。这种方式能够掌握API所提供的资源之间的关系,客户端能够基于API的URL中所发现的关系对它们进行遍历。

举例来说,假设某个客户端想要请求最近设计的taco的列表,按照原始的形式,在没有超链接的情况下,客户端以JSON格式接收到的taco列表会如下所示(为了简洁,这里只保留了第一个taco,剩余的省略了):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
[
{
"id": 4,
"name": "Veg-Out",
"createdAt": "2018-01-31T20:15:53.219+0000",
"ingredients": [
{"id": "FLTO", "name": "Flour Tortilla", "type": "WRAP"},
{"id": "COTO", "name": "Corn Tortilla", "type": "WRAP"},
{"id": "TMTO", "name": "Diced Tomatoes", "type": "VEGGIES"},
{"id": "LETC", "name": "Lettuce", "type": "VEGGIES"},
{"id": "SLSA", "name": "Salsa", "type": "SAUCE"}
]
},
...
]

如果客户端想要获取某个taco或者对其进行其他HTTP操作,就需要将它的id属性以硬编码的方式拼接到一个路径为“/design”的URL上。与之类似,如果客户端想要对某个配料执行HTTP请求,就需要将该配料id属性的值拼接到路径为“/ingredients”的URL上。在这两种情况下,都需要在路径上添加“http://”或“https://”前缀以及API的主机名。

如果API启用了超媒体功能,那么API将会描述自己的URL,从而减轻客户端对其进行硬编码的痛苦。如果嵌入超链接,那么最近创建的taco列表将会如程序清单6.3所示。

程序清单6.3 包含超链接的taco资源列表

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
{
"_embedded": {
"tacoResourceList": [
{
"name": "Veg-Out",
"createdAt": "2018-01-31T20:15:53.219+0000",
"ingredients": [
{
"name": "Flour Tortilla", "type": "WRAP",
"_links": {
"self": { "href": "http://localhost:8080/ingredients/FLTO" }
}
},
{
"name": "Corn Tortilla", "type": "WRAP",
"_links": {
"self": { "href": "http://localhost:8080/ingredients/COTO" }
}
},
{
"name": "Diced Tomatoes", "type": "VEGGIES",
"_links": {
"self": { "href": "http://localhost:8080/ingredients/TMTO" }
}
},
{
"name": "Lettuce", "type": "VEGGIES",
"_links": {
"self": { "href": "http://localhost:8080/ingredients/LETC" }
}
},
{
"name": "Salsa", "type": "SAUCE",
"_links": {
"self": { "href": "http://localhost:8080/ingredients/SLSA" }
}
}
],
"_links": {
"self": { "href": "http://localhost:8080/design/4" }
}
},
...
]
},
"_links": {
"recents": {
"href": "http://localhost:8080/design/recent"
}
}
}

这种特殊风格的HATEOAS被称为HAL(超文本应用语言,Hypertext ApplicationLanguage)。这是一种在JSON响应中嵌入超链接的简单通用格式。

虽然这个列表看上去不像前面那样简洁,但是它确实提供了一些有用的信息。这个新taco列表中的每个元素都包含了一个名为“_links”的属性,为客户端提供导航API的超链接。在本例中,taco和配料都有一个“self”链接,用来引用该资源;整个列表有一个“recents”链接,用来引用该API自身。

如果客户端应用需要对列表中的taco执行HTTP请求,那么在开发的时候不需要关心taco资源的URL是什么样子。相反,它只需要请求“self”链接就可以了,该属性将会映射至http://localhost:8080/design/4。如果客户端想要处理特定的配料,只需要查找该配料的“self”链接即可。

Spring HATEOAS项目为Spring提供了超链接的支持。它提供了一些类和资源装配器(assembler),在Spring MVC控制器返回资源之前能够为其添加链接。

为了在Taco Cloud API中启用超媒体功能,我们需要在构建文件中添加如下的Spring HATEOAS starter依赖:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

这个starter不仅会将Spring HATEOAS添加到项目的类路径中,还会提供自动配置功能以启用Spring HATEOAS。我们所需要做的就是重新实现控制器,让它们返回资源类型,而不是领域类型。

我们首先为最近taco列表添加超链接,也就是针对“/design/recent”的GET请求。

6.2.1 添加超链接

Spring HATEOAS提供了两个主要的类型来表示超链接资源:Resource和Resources。Resource代表一个资源,而Resources代表资源的集合。这两种类型都能携带到其他资源的链接。当从Spring MVC REST控制器返回时,它们所携带的链接将会包含到客户端所接收到的JSON(或XML)中。

为了给最近创建的taco添加超链接,我们需要重新实现程序清单6.2中的recentTacos()方法。原始的实现返回的是List,当时这种返回值是可以的,但是现在我们需要让它返回Resources对象。程序清单6.4展示了recentTacos()的新实现,包含了在最近taco列表中启用超链接的第一步。

程序清单6.4 为资源添加超链接

1
2
3
4
5
6
7
8
9
10
@GetMapping("/recent")
public Resources<Resource<Taco>> recentTacos() {
PageRequest page = PageRequest.of(
0, 12, Sort.by("createdAt").descending());
List<Taco> tacos = tacoRepo.findAll(page).getContent();
Resources<Resource<Taco>> recentResources = Resources.wrap(tacos);
recentResources.add(
new Link("http://localhost:8080/design/recent", "recents"));
return recentResources;
}

在这个新版本的recentTacos()中,我们不再直接返回taco的列表,而是使用Resources.wrap()将taco列表包装为Resources<Resource>,并使其作为该方法最终的返回值。但是在Resources对象返回之前,我们添加了名为recents的关联关系,它的URL为http://localhost:8080/design/recent。这样做的结果就是,API请求所返回的资源中将会包含如下的JSON片段:

1
2
3
4
5
"_links": {
"recents": {
"href": "http://localhost:8080/design/recent"
}
}

这是一个很好的起点,但是我们还有一些事情需要完成。现在,我们只是为整体的列表添加了链接,还没有为taco资源本身以及每个taco中的配料添加链接。我们很快就会实现该功能,但是在此之前,我们要先解决recents链接中的硬编码问题。

像这样对URL进行硬编码是一种很糟糕的办法。除非Taco Cloud的目标仅限于在本地开发机器上运行应用,否则,我们需要找一种方式避免在URL中使用硬编码的localhost:8080。幸运的是,Spring HATEOAS以链接构建者(link builder)的方式为我们提供了帮助。

在Spring HATEOAS中,最有用的链接构建者是ControllerLinkBuilder。这个链接构建者非常智能,它能自动探知主机名是什么,这样就能避免对其进行硬编码。同时,它还提供了流畅的API,允许我们相对于控制器的基础URL构建链接。

借助ControllerLinkBuilder,我们可以将recentTacos()中硬编码的Link创建改造成如下的形式:

1
2
3
4
5
Resources<Resource<Taco>> recentResources = Resources.wrap(tacos);
recentResources.add(
ControllerLinkBuilder.linkTo(DesignTacoController.class)
.slash("recent")
.withRel("recents"));

我们不仅不需要硬编码主机名,而且不再需要指定“/design”。在这里,我们向DesignTacoController请求获取一个链接,它的基础路径为“/design”。ControllerLinkBuilder使用控制器的基础路径作为我们创建的Link对象的基础。

接下来,调用了在Spring项目中我最喜欢的一个方法:slash()。我喜欢这个方法的原因是这个方法非常简洁地描述了它要做的事情。这个方法会为URL添加斜线(/)和给定的值,所形成的URL路径是“/design/recent”。

最后,我们为Link指定了一个关系名。在本例中,关系名为recents。

尽管我是slash()的忠实粉丝,但是ControllerLinkBuilder还有另外一个方法,能够消除链接URL上的所有硬编码。此时,我们不再需要调用slash(),而是调用linkTo(),并将控制器中的一个方法传递给它,这样ControllerLinkBuilder就能推断出控制器的基础路径和该方法的映射路径。如下的代码就以这种方式使用了linkTo()方法:

1
2
3
4
Resources<Resource<Taco>> recentResources = Resources.wrap(tacos);
recentResources.add(
linkTo(methodOn(DesignTacoController.class).recentTacos())
.withRel("recents"));

在这里,我静态导入了linkTo()和methodOn()方法(它们都来自ControllerLinkBuilder),从而让代码更易于阅读。methodOn()方法传入控制器类,从而允许我们调用recentTacos()方法,这个调用会被ControllerLinkBuilder拦截,用来确定控制器的基础路径和recentTacos()的映射路径。现在,整个URL都从控制器的映射中判断出来了,而且完全没有硬编码。非常棒!

6.2.2 创建资源装配器

现在,我们需要为列表中的taco资源添加链接。有种方案就是遍历Resources对象中所携带的每个Resource<Taco>元素,为它们依次添加Link。但是,这种方式有点过于枯燥,在需要返回taco列表的所有地方都需要在API中重复循环相关的代码。

我们需要有一种不同的策略。

对于列表中的每个taco,我们不再使用Resources.wrap()来创建Resource,而是定义一个将Taco对象转换为TacoResource对象的工具类。TacoResource对象与Taco类似,但是它本身能携带链接。程序清单6.5展示了TacoResource。

程序清单6.5 能够携带领域数据和超链接列表taco资源

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
package tacos.web.api;
import java.util.Date;
import java.util.List;
import org.springframework.hateoas.ResourceSupport;
import lombok.Getter;
import tacos.Ingredient;
import tacos.Taco;
public class TacoResource extends ResourceSupport {
@Getter
private final String name;
@Getter
private final Date createdAt;
@Getter
private final List<Ingredient> ingredients;
public TacoResource(Taco taco) {
this.name = taco.getName();
this.createdAt = taco.getCreatedAt();
this.ingredients = taco.getIngredients();
}
}

从很多方面来看,TacoResource都与Taco领域类型没有区别。它们都有name、createdAt和ingredients属性。但是,TacoResource扩展了ResourceSupport,从而继承了一个Link对象的列表和管理链接列表的方法。

除此之外,TacoResource并没有包含Taco的id属性。这是因为没有必要在API中暴露数据库相关的ID。从API客户端的角度来看,资源的self链接将会作为该资源的标识符。

注意:“领域”和“资源”应该各自独立还是应该是同一个类呢?有些Spring开发人员可能会让领域类型扩展ResourceSupport,从而将领域和资源对象合二为一。至于哪种方式才是正确的,这里并没有确切答案。我选择的做法是创建一个单独的资源类型,这样Taco就没有必要添加资源链接了,因为在有些场景下,这些链接是根本用不到的。另外,通过创建一个单独的资源类型,能够很容易地将id属性排除出去,这样它就不会暴露在API中了。

TacoResource有一个很简单的构造器,会接收一个Taco对象并且会将Taco中的相关属性复制到自己的属性中。这样的话,我们可以很容易地将一个Taco对象转换为TacoResource。但是,如果我们就此止步,就依然需要遍历Taco列表才能将其转换成Resources<TacoResource>

为了将Taco对象转换成TacoResource对象,我们需要创建一个资源装配器(resource assembler)。我们所需要的装配器如程序清单6.6所示。

程序清单6.6 装配taco资源的资源装配器

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
package tacos.web.api;
import org.springframework.hateoas.mvc.ResourceAssemblerSupport;
import tacos.Taco;
public class TacoResourceAssembler
extends ResourceAssemblerSupport<Taco, TacoResource> {
public TacoResourceAssembler() {
super(DesignTacoController.class, TacoResource.class);
}
@Override
protected TacoResource instantiateResource(Taco taco) {
return new TacoResource(taco);
}
@Override
public TacoResource toResource(Taco taco) {
return createResourceWithId(taco.getId(), taco);
}
}

TacoResourceAssembler有一个默认的构造器,会告诉超类(ResourceAssemblerSupport)在创建TacoResource中的链接时将会使用DesignTacoController来确定所有URL的基础路径。

instantiateResource()方法进行了重写,以便基于给定的Taco实例化TacoResource。如果TacoResource有默认构造器,那么这个方法是可选的。但是,在本例中,TacoResource的构造过程需要Taco,所以我们要重写它。

最后是toResource()方法,这是在扩展ResourceAssemblerSupport时唯一强制实现的方法。在这里,我们告诉它要通过Taco创建TacoResource,并且要设置一个self链接,这个链接的URL是根据Taco对象的id属性衍生出来的。

在表面上,toResource()和instantiateResource()的用途很相似,但是它们的目的略有不同。instantiateResource()只是为了实例化一个Resource对象,而toResource()的意图不仅是创建Resource对象,还要为其填充链接。在内部,toResource()将会调用instantiateResource()。

现在,我们调整一下recentTacos(),让它使用TacoResourceAssembler:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
@GetMapping("/recent")
public Resources<TacoResource> recentTacos() {
PageRequest page = PageRequest.of(
0, 12, Sort.by("createdAt").descending());
List<Taco> tacos = tacoRepo.findAll(page).getContent();
List<TacoResource> tacoResources =
new TacoResourceAssembler().toResources(tacos);
Resources<TacoResource> recentResources =
new Resources<TacoResource>(tacoResources);
recentResources.add(
linkTo(methodOn(DesignTacoController.class).recentTacos())
.withRel("recents"));
return recentResources;
}

在这里,recentTacos()的返回值不再是Resources<Resource<Taco>>类型,而是利用我们新定义的TacoResource类型返回了一个Resources<TacoResource>。在从repository获取taco之后,我们将Taco对象的列表传递给TacoResourceAssembler的toResources()方法。这个便利的方法会循环所有的Taco对象,调用我们在TacoResourceAssembler中重写的toResource()方法来创建TacoResource对象的列表。

在有了TacoResource列表之后,我们接下来创建了Resources<TacoResource>对象,然后像前面版本的recentTacos()一样,为其填充了recents链接。

此时,对“/design/recent”发起GET请求将会生成taco的一个列表,其中的每个taco都有一个self链接,而列表整体有一个recents链接。但是,配料目前还没有链接。为了解决这个问题,我们需要为配料创建一个新的资源装配器:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
package tacos.web.api;
import org.springframework.hateoas.mvc.ResourceAssemblerSupport;
import tacos.Ingredient;
class IngredientResourceAssembler extends
ResourceAssemblerSupport<Ingredient, IngredientResource> {
public IngredientResourceAssembler() {
super(IngredientController2.class, IngredientResource.class);
}
@Override
public IngredientResource toResource(Ingredient ingredient) {
return createResourceWithId(ingredient.getId(), ingredient);
}
@Override
protected IngredientResource instantiateResource(
Ingredient ingredient) {
return new IngredientResource(ingredient);
}
}

我们可以看到,IngredientResourceAssembler与TacoResourceAssembler非常相似,但是它使用的是Ingredient和IngredientResource对象而不是Taco和TacoResource对象。

谈到IngredientResource对象,它的源码如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
package tacos.web.api;
import org.springframework.hateoas.ResourceSupport;
import lombok.Getter;
import tacos.Ingredient;
import tacos.Ingredient.Type;
public class IngredientResource extends ResourceSupport {
@Getter
private String name;
@Getter
private Type type;
public IngredientResource(Ingredient ingredient) {
this.name = ingredient.getName();
this.type = ingredient.getType();
}
}

与TacoResource类似,IngredientResource扩展了ResourceSupport,并且会将领域类型相关的属性复制到自己的属性中(id属性除外)。

接下来,我们需要修改一下TacoResource,让它能够携带IngredientResource对象,而不是Ingredient对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
package tacos.web.api;
import java.util.Date;
import java.util.List;
import org.springframework.hateoas.ResourceSupport;
import lombok.Getter;
import tacos.Taco;
public class TacoResource extends ResourceSupport {
private static final IngredientResourceAssembler
ingredientAssembler = new IngredientResourceAssembler();
@Getter
private final String name;
@Getter
private final Date createdAt;
@Getter
private final List<IngredientResource> ingredients;
public TacoResource(Taco taco) {
this.name = taco.getName();
this.createdAt = taco.getCreatedAt();
this.ingredients =
ingredientAssembler.toResources(taco.getIngredients());
}
}

这个新版本的TacoResource会创建一个static final的IngredientResourceAssembler实例,并且会使用它的toResource()方法将给定Taco对象的Ingredient列表转换成IngredientResource列表。

我们现在的taco列表已经完全具备了超链接,不仅是它本身(recents链接),而且所有的taco条目和每个taco中的配料都有了超链接。响应的内容应该与程序清单6.3非常相似了。

你可以就此止步并跳到下一节,但是在此之前,我想解决程序清单6.3中一些不太理想的问题。

6.2.3 命名嵌套式的关联关系

如果你仔细看一下程序清单6.3,就会发现顶层的元素如下所示:

1
2
3
4
5
6
7
{
"_embedded": {
"tacoResourceList": [
...
]
}
}

最值得注意的是在embedded之下有一个名为tacoResourceList的属性。之所以有这个名称,是因为Resources对象是通过List<TacoResource>创建出来的。尽管可能性不太大,但是假设我们将TacoResource类重构成了其他的名称,那么结果JSON中的字段名将会随之发生变化。这样,所有依赖该名称的客户端代码都会产生问题。

@Relation注解能够帮助我们消除JSON字段名和Java代码中定义的资源类名之间的耦合。通过为TacoResource添加@Relation注解,我们就能指定SpringHATEOAS该如何命名结果JSON中的字段名:

1
2
3
4
@Relation(value="taco", collectionRelation="tacos")
public class TacoResource extends ResourceSupport {
...
}

在这里,我们指定当在Resources对象中引用TacoResource对象列表时它应该被命名为tacos。虽然在我们的API中没有用到,但是如果在JSON中引用单个TacoResource对象,那么它的名字将会是taco。这样的话,“/design/recent”所返回的JSON将会如下所示(不管我们是否要对TacoResource进行重构,这个结构都不会发生变化):

1
2
3
4
5
6
7
{
"_embedded": {
"tacos": [
...
]
}
}

借助Spring HATEOAS,向API中添加链接变得非常简单直接。尽管如此,它也会添加一些额外的代码。所以,很多开发人员会选择在API中不使用HATEOAS,但是如果API的URL模式发生变化,那么客户端代码就不可用了。所以,我建议你认真考虑一下HATEOAS,不要因为偷懒而忽略在资源中添加超链接。

如果你真的想要偷懒,那么只要你使用Spring Data来实现repository,我们就还有一个双赢的方案。接下来,我们看一下基于在第3章中使用Spring Data所创建的数据repository,如何借助Spring Data REST自动创建API。