6.3 启用数据后端服务

6.3 启用数据后端服务

正如我们在第3章中所看到的,Spring Data有一种特殊的魔法,它能够基于我们定义的接口自动创建repository实现。但是Spring Data还有另外一项技巧,它能帮助我们定义应用的API。

Spring Data REST是Spring Data家族中的另外一个成员,它会为Spring Data创建的repository自动生成REST API。我们只需要将Spring Data REST添加到构建文件中,就能得到一套API,它的操作与我们定义repository接口是一致的。

为了使用Spring Data REST,我们需要将如下的依赖添加到构建文件中:

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

不管你是否相信,对于已经使用Spring Data自动生成repository的项目来说,我们只需要完成这一步就能对外暴露REST API了。将Spring Data REST starter添加到构建文件中之后,应用的自动配置功能会为Spring Data(包括Spring DataJPA、Spring Data Mongo等)创建的所有repository自动创建REST API。

Spring Data REST所创建的端点和我们自己创建的端点一样好(甚至能够更好一些)。所以,我们可以做一些移除操作,在进行下一步之前将我们已经创建的带有@RestController注解的类移除。

为了尝试Spring Data REST提供的端点,我们可以启动应用并测试一些URL。基于为Taco Cloud定义的repository,我们可以对taco、配料、订单和用户执行一些GET请求。

举例来说,我们可以向“/ingredients”发送GET请求以获取所有配料。借助curl,我们得到的响应大致如下所示(进行了删减,只显示第一种配料):

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
$ curl localhost:8080/ingredients
{
"_embedded" : {
"ingredients" : [ {
"name" : "Flour Tortilla",
"type" : "WRAP",
"_links" : {
"self" : {
"href" : "http://localhost:8080/ingredients/FLTO"
},
"ingredient" : {
"href" : "http://localhost:8080/ingredients/FLTO"
}
}
},
...
]
},
"_links" : {
"self" : {
"href" : "http://localhost:8080/ingredients"
},
"profile" : {
"href" : "http://localhost:8080/profile/ingredients"
}
}
}

太棒了!我们只不过将一项依赖添加到了构建文件中,这样不但得到了针对配料的端点,而且返回的资源中还包含超链接。我们可以假装成这个API的客户端,使用curl继续访问self链接以获取玉米面薄饼的详情:

1
2
3
4
5
6
7
8
9
10
11
12
13
$ curl http://localhost:8080/ingredients/FLTO
{
"name" : "Flour Tortilla",
"type" : "WRAP",
"_links" : {
"self" : {
"href" : "http://localhost:8080/ingredients/FLTO"
},
"ingredient" : {
"href" : "http://localhost:8080/ingredients/FLTO"
}
}
}

为了避免分散注意力,在本书中,我们不再浪费时间深入探究Spring Data REST所创建的每个端点和可选项。但是,我们需要知道,它还支持端点的POST、PUT和DELETE方法。也就是说,你可以发送POST请求至“/ingredients”来创建新的配料,也可以发送DELETE请求到“/ingredients/FLTO”,以便于从菜单中删除玉米面薄饼。

我们想做的另外一件事可能就是为API设置一个基础路径,这样它们会有不同的端点,避免与我们所编写的控制器产生冲突(实际上,如果我们不删除前面自己创建的IngredientsController,就将会干扰Spring Data REST提供的“/ingredients”端点)。为了调整API的基础路径,我们可以设置spring.data.rest.base-path属性:

1
2
3
4
spring:
data:
rest:
base-path: /api

这项配置会将Spring Data REST端点的基础路径设置为“/api”。现在,配料端点将会变成“/api/ingredients”。我们通过请求taco列表来验证一下这个新的基础路径:

1
2
3
4
5
6
7
8
$ curl http://localhost:8080/api/tacos
{
"timestamp": "2018-02-11T16:22:12.381+0000",
"status": 404,
"error": "Not Found",
"message": "No message available",
"path": "/api/tacos"
}

哦,天啊!它并没有按照预期那样运行。我们有了Ingredient实体和IngredientRepository接口之后,Spring Data REST就会暴露“/api/ingredients”。我们也有Taco实体和TacoRepository接口,为什么SpringData REST没有为我们生成“/api/tacos”端点呢?

6.3.1 调整资源路径和关系名称

实际上,Spring Data确实为我们提供了处理taco的端点。虽然Spring Data REST非常聪明,但是在暴露taco端点的时候出现了一点问题。

当为Spring Data repository创建端点的时候,Spring Data REST会尝试使用相关实体类的复数形式。对于Ingredient实体来说,端点将会是“/ingredients”,对于Order和User实体,端点将会是“/orders”和“/users”。到目前为止,一切运行良好。但有些场景下,比如遇到“taco”的时候,它获取到这个单词,为其生成的复数形式就不太正确了。实际上,Spring Data REST将“taco”的复数形式计算成了“tacoes”,所以,为了向taco发送请求,我们必须将错就错,请求“/api/tacoes”地址:

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
% curl localhost:8080/api/tacoes
{
"_embedded" : {
"tacoes" : [ {
"name" : "Carnivore",
"createdAt" : "2018-02-11T17:01:32.999+0000",
"_links" : {
"self" : {
"href" : "http://localhost:8080/api/tacoes/2"
},
"taco" : {
"href" : "http://localhost:8080/api/tacoes/2"
},
"ingredients" : {
"href" : "http://localhost:8080/api/tacoes/2/ingredients"
}
}
}]
},
"page" : {
"size" : 20,
"totalElements" : 3,
"totalPages" : 1,
"number" : 0
}
}

你肯定会想,我是怎么知道“taco”的复数形式被错误计算成了“tacoes”。实际上,Spring Data REST还暴露了一个主资源(home resource),这个资源包含了所有端点的链接。我们只需要向API的基础路径发送GET请求就能得到它的结果:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
$ curl localhost:8080/api
{
"_links" : {
"orders" : {
"href" : "http://localhost:8080/api/orders"
},
"ingredients" : {
"href" : "http://localhost:8080/api/ingredients"
},
"tacoes" : {
"href" : "http://localhost:8080/api/tacoes{?page,size,sort}",
"templated" : true
},
"users" : {
"href" : "http://localhost:8080/api/users"
},
"profile" : {
"href" : "http://localhost:8080/api/profile"
}
}
}

可以看到,这个主资源显示了所有实体的链接。除了tacoes链接之外,其他都很好,在这里关系名和URL地址上都是“taco”错误的复数形式。

好消息是,我们并非必须接受Spring Data REST的这个小错误。通过为Taco添加一个简单的注解,我们就能调整关系名和路径:

1
2
3
4
5
6
@Data
@Entity
@RestResource(rel="tacos", path="tacos")
public class Taco {
...
}

@RestResource注解能够为实体提供任何我们想要的关系名和路径。在本例中,我们将它们都设置成了“tacos”。现在,我们请求主资源的时候,会看到tacos有了正确的复数形式:

1
2
3
4
"tacos" : {
"href" : "http://localhost:8080/api/tacos{?page,size,sort}",
"templated" : true
},

这样就将我们的端点路径整理好了,现在可以向“/api/tacos”发送请求来操作taco资源了。

接下来我们看一下使用Spring Data REST端点如何进行排序。

6.3.2 分页和排序

你可能已经发现,主资源上的所有链接都提供了可选的page、size和sort参数。默认情况下,请求集合资源(比如“/api/tacos”)都会返回第一页的20个条目。但是,我们可以通过在请求中指定page和size参数调整具体的页数和每页的数量。

例如,我们想要请求第一页的taco,但是希望包含5个条目,就可以发送如下的GET请求(使用curl):

1
$ curl "localhost:8080/api/tacos?size=5"

如果taco的数量超过了5个,我们可以通过使用page参数获取第二页的taco:

1
$ curl "localhost:8080/api/tacos?size=5&page=1"

注意,page参数是从0开始计算的,也就是说page值为1的时候会请求第二页的数据(你可能会发现,很多命令行shell遇到请求中的&符号会出错,所以我们在前面的curl命令中为整个URL使用了引号)。

我们可以使用字符串操作来将这些参数拼接到URL上,但是HATEOAS为我们提供了第一页、下一页、上一页和最后一页等链接:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
"_links" : {
"first" : {
"href" : "http://localhost:8080/api/tacos?page=0&size=5"
},
"self" : {
"href" : "http://localhost:8080/api/tacos"
},
"next" : {
"href" : "http://localhost:8080/api/tacos?page=1&size=5"
},
"last" : {
"href" : "http://localhost:8080/api/tacos?page=2&size=5"
},
"profile" : {
"href" : "http://localhost:8080/api/profile/tacos"
},
"recents" : {
"href" : "http://localhost:8080/api/tacos/recent"
}
}

有了这些链接,API的客户端就不需要跟踪当前正处于哪一页,也不用将参数自己拼接到URL上了,只需根据名字查找这些页面导航并进行访问就可以了。

sort参数允许我们根据实体的某个属性对结果进行排序。例如,我们想要获取最近创建的12条taco进行UI展示,就可以混合使用分页和排序参数实现:

1
$ curl "localhost:8080/api/tacos?sort=createdAt,desc&page=0&size=12"

在这里,sort参数指定我们要按照createdAt属性进行排序,并且要按照降序进行排列(所以最新的taco会放在最前面)。page和size参数指定我们想要获取第一页的12个taco。

这恰好是UI展现最近创建的taco所需要的数据。它与我们在本章前文DesignTacoController定义的“/design/recent”端点大致相同。

这里还有一个小问题。UI代码需要硬编码才能请求带有指定参数的taco列表。当然,这可以正常运行。但是,如果让客户端太多地了解如何构建API请求,就会在一定程度上增加脆弱性。如果客户端能够从链接列表中查找URL就太好了。如果URL能够更简洁,就像前面看到“/design/recent”一样,那就更棒了。

6.3.3 添加自定义的端点

Spring Data REST能够很好地为执行CRUD操作的Spring Data repository创建端点。但有时候,我们需要脱离默认的CRUD API,创建处理核心问题的端点。

我们当然可以在带有@RestController注解的bean中实现任意的端点,以此来补充Spring Data REST端点的不足。实际上,我们可以重新启用本章前面提到的DesignTacoController,它依然可以与Spring Data REST提供的端点一起运行。

但是在编写自己的API控制器时,它们的端点在有些方面会与Spring Data REST的端点脱节:

  • 我们自己的控制器端点没有映射到Spring Data REST的基础路径下。虽然我们可以强制要求它们映射到任意前缀作为基础路径,包括使用Spring DataREST的基础路径,但是如果基础路径发生变化的话,我们还需要修改控制器的映射,以便于与其匹配。
  • 在自己控制器所定义的端点中,返回资源时并不会自动包含超链接,与Spring Data REST所返回的资源是不同的。这意味着,客户端无法通过关系名发现自定义的端点。

我们首先来解决基础路径的问题。Spring Data REST提供了一个新的注解@RepositoryRestController,这个注解可以用到控制器类上,这样控制器类所有映射的基础路径就会与Spring Data REST端点配置的基础路径相同。简而言之,在使用@RepositoryRestController注解的控制器中,所有映射将会具有和spring.data.rest.base-path属性值一样的前缀(我们之前将这个属性的值配置成了“/api”)。

在这里我们会创建一个只包含recentTacos()方法的新控制器,而不是修改已有的DesignTacoController,因为这个旧的控制器包含了多个我们不再需要的方法。程序清单6.7中的RecentTacosController带有@RepositoryRestController注解,表明它会将Spring Data REST的基础路径用到自己的请求映射上。

程序清单6.7 将Spring Data REST的基础路径用到控制器上

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
package tacos.web.api;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
import java.util.List;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.data.rest.webmvc.RepositoryRestController;
import org.springframework.hateoas.Resources;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import tacos.Taco;
import tacos.data.TacoRepository;
@RepositoryRestController
public class RecentTacosController {
private TacoRepository tacoRepo;
public RecentTacosController(TacoRepository tacoRepo) {
this.tacoRepo = tacoRepo;
}
@GetMapping(path="/tacos/recent", produces="application/hal+json")
public ResponseEntity<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(RecentTacosController.class).recentTacos())
.withRel("recents"));
return new ResponseEntity<>(recentResources, HttpStatus.OK);
}
}

虽然@GetMapping映射到了“/tacos/recent”路径,但是类级别的@Repository RestController注解会确保这个路径添加Spring Data REST的基础路径作为前缀。按照我们的配置,recentTacos()方法将会处理针对“/api/tacos/recent”的GET请求。

需要注意的是,尽管@RepositoryRestController的名称和@RestController非常相似,但是它并没有和@RestController相同的语义。具体来讲,它并不能保证处理器方法返回的值会自动写入响应体中。所以,我们要么为方法添加@ResponseBody注解,要么返回包装响应数据的ResponseEntity。这里我们选择的方案是返回ResponseEntity。

RecentTacosController准备就绪之后,对“/api/tacos/recent”发送请求最多会返回15条最近创建的taco,此时就不需要在URL中添加分页和排序参数了。但是在请求“/api/tacos”的时候,这个地址依然没有出现在结果的超链接列表中。

6.3.4 为Spring Data端点添加自定义的超链接

如果最近taco端点没有出现在“/api/tacos”所返回的超链接中,那么客户端如何知道该怎样获取最近的taco呢?它要么猜测,要么使用分页和排序参数。无论采用哪种方式,都需要在客户端代码中硬编码,这都不是理想的做法。

通过声明资源处理器(resource processor)bean,我们可以为Spring DataREST自动包含的链接列表继续添加链接。Spring Data HATEOAS提供了一个ResourceProcessor接口,能够在资源通过API返回之前对其进行操作。对于我们的场景来说,需要一个ResourceProcessor实现,为PagedResources<Resource<Taco>>类型的资源(也就是“/api/tacos”端点所返回的类型)添加recents链接。程序清单6.8展现了定义这种ResourceProcessor的bean声明方法。

程序清单6.8 为Spring Data REST端点添加自定义的链接

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Bean
public ResourceProcessor<PagedResources<Resource<Taco>>>
tacoProcessor(EntityLinks links) {
return new ResourceProcessor<PagedResources<Resource<Taco>>>() {
@Override
public PagedResources<Resource<Taco>> process(
PagedResources<Resource<Taco>> resource) {
resource.add(
links.linkFor(Taco.class)
.slash("recent")
.withRel("recents"));
return resource;
}
};
}

程序清单6.8中的ResourceProcessor定义了一个匿名内部类,并将其声明为Spring应用上下文中所创建的bean。Spring HATEOAS会自动发现这个bean(以及其他ResourceProcessor类型的bean)并将其应用到对应的资源上。在本例中,如果控制器返回PagedResources<Resource<Taco>>,就会包含一个最近创建的taco链接。这就包括了对“/api/tacos”请求的响应。