6.1 编写RESTful控制器

6.1 编写RESTful控制器

当你翻看本章并阅读简介时,就会发现我重新设想了Taco Cloud的用户界面,希望你不要介意。你之前的工作成果可能比较适合起步,但是在美学方面也许会有所欠缺。

图6.1是新的Taco Cloud外观的示例,看上去很时尚吧?

image-20211014161130041

图6.1 新的Taco Cloud主页

在改善Taco Cloud外观的同时,我还决定使用流行的Angular框架将前端构建为单页应用。最终,这个新的浏览器UI将替换我们在第2章中创建的服务器渲染页面。但是,想要实现这一点,我们需要创建一个REST API,基于Angular1的UI将会与之通信,以保存和获取taco数据。

是否要采用SPA?

在第2章中,我们使用Spring MVC开发了一个传统的多页应用(MultiPageApplication,MPA),现在我们要将其替换为基于Angular的单页应用(Single-Page Application,SPA)。但是,我并不认为SPA始终是比MPA更好的可选方案。

在SPA中,展现和后端处理在很大程度上是解耦的,这样就提供了为相同的后端功能开发多个用户界面(例如原生移动应用)的机会。它还为与其他可以使用API的应用程序集成创造了可能性。但并不是所有的应用程序都需要这种灵活性,如果你只需要在Web页面上显示信息,那么MPA是一种更简单的设计。

这并不一本关于Angular的书,所以本章中的代码将会主要关注后端的Spring代码。我只会给出适当的Angular代码,以便于让你了解客户端是如何运行的。但是,请放心,完整的代码集会包括Angular前端,它们都是本书配套代码的一部分。如果你有兴趣,可以阅读Jeremy Wilken编写的Angular in Action(Manning,2018)以及Yakov Fain和Anton Moiseev编写的AngularDevelopment with TypeScript, Second Edition(Manning,2018)。

本质上来讲,Angular客户端代码将会通过HTTP请求与本章所创建的API进行通信。在第2章中,我们使用@GetMapping注解从服务端获取数据,使用@PostMapping注解往服务器端提交数据。在定义REST API的时候,这些注解依然有用。除此之外,Spring MVC还为各种类型的HTTP请求提供了一些其他的注解,如表6.1所示。

表6.1 Spring MVC的HTTP请求处理注解

epub_29101559_42

a 将HTTP方法映射为创建、读取、更新和删除(CRUD)操作并不是非常恰当,但是在实践中这是常见的使用方式,在我们的Taco Cloud应用中也是这样使用它们的。

要实际看到这些注解的效果,我们需要创建一个简单的REST端点,该端点会检索一些最新创建的taco。

6.1.1 从服务器中检索数据

Taco Cloud应用最酷的一件事就是它允许taco迷设计自己的taco作品,并与taco爱好者分享他们的作品。为此,Taco Cloud需要能够在单击“Latest Designs”链接时显示最近创建的taco列表。

在Angular代码中,我定义了RecentTacosComponent组件,它会展现最新创建的taco。RecentTacosComponent完整的TypeScript代码如程序清单6.1所示。

程序清单6.1 展现最近taco的Angular组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import { Component, OnInit, Injectable } from '@angular/core';
import { Http } from '@angular/http';
import { HttpClient } from '@angular/common/http';
@Component({
selector: 'recent-tacos',
templateUrl: 'recents.component.html',
styleUrls: ['./recents.component.css']
})
@Injectable()
export class RecentTacosComponent implements OnInit {
recentTacos: any;
constructor(private httpClient: HttpClient) { }
ngOnInit() {
this.httpClient.get('http://localhost:8080/design/recent') ⇽--- 从服务器端获取最近的taco
.subscribe(data => this.recentTacos = data);
}
}

我们需要关注ngOnInit()方法。在这个方法中,RecentTacosComponent使用注入的Http模块来针对http://localhost:8080/design/recent地址发送HTTP GET请求,并期望得到一个包含taco设计的列表,它们会被放到名为recentTacos的模型属性中。视图(在recents.component.html中)会将模型数据展现为HTML的形式,以便于在浏览器中渲染。在创建完3个taco之后,最终的结果如图6.2所示。

image-20211014163501952

图6.2 展现最近创建的taco

在我们的拼图中,缺失的一部分就是端点,它会处理针对“/design/recent”的HTTP GET请求并将最近设计的taco列表作为响应。我们需要创建一个新的控制器来处理这种请求。程序清单6.2展现了完成该任务的控制器。

程序清单6.2 处理taco设计API请求的RESTful控制器

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 java.util.Optional;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.hateoas.EntityLinks;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;
import tacos.Taco;
import tacos.data.TacoRepository;
@RestController
@RequestMapping(path="/design", ⇽--- 处理针对“/design”的请求
produces="application/json")
@CrossOrigin(origins="*") ⇽--- 允许跨域请求
public class DesignTacoController {
private TacoRepository tacoRepo;
@Autowired
EntityLinks entityLinks;
public DesignTacoController(TacoRepository tacoRepo) {
this.tacoRepo = tacoRepo;
}
@GetMapping("/recent")
public Iterable<Taco> recentTacos() { ⇽--- 获取并返回最近设计的taco
PageRequest page = PageRequest.of(
0, 12, Sort.by("createdAt").descending());
return tacoRepo.findAll(page).getContent();
}
}

你可能会觉得这个控制器的名字看起来非常熟悉。在第2章中,我们创建了名为DesignTacoController的控制器,它会处理类似的请求类型。但是,当时是用来处理多页Taco Cloud应用的,这个新的DesignTacoController是一个REST控制器,是由@RestController注解声明的。

@RestController注解有两个目的。首先,它是一个类似于@Controller和@Service的构造型注解,能够让类被组件扫描功能发现。但是,与REST最密切相关之处在于,@RestController注解会告诉Spring,控制器中的所有处理器方法的返回值都要直接写入响应体中,而不是将值放到模型中并传递给一个视图以便于进行渲染。

作为替代方案,我们也可以像其他Spring MVC控制器那样为DesignTacoController添加@Controller注解。但是,这样的话,我们就需要为每个处理器方法再添加@ResponseBody注解,这样才能达到相同的效果。另外一种方案就是返回ResponseEntity对象,我们稍后将会对其进行讨论。

类级别的@RequestMapping注解,再加上recentTacos()方法上的@GetMapping注解,两者结合起来指定recentTacos()方法将会负责处理针对“/design/recent”的GET请求(这也正是Angular代码所需要的)。

你还会发现,@RequestMapping注解还设置了一个produces属性。这指明DesignTacoController中的所有处理器方法只会处理Accept头信息包含“application/json”的请求。它不仅会限制API只会生成JSON结果,同时还允许其他的控制器(比如第2章中的DesignTacoController)处理具有相同路径的请求,只要这些请求不要求JSON格式的输出就可以。尽管这样会限制API是基于JSON的,但是我们还可以将produces设置为一个String类型的数组,这样的话就允许我们设置多个内容类型。比如,为了允许生成XML格式的输出,我们可以为produces属性添加“text/xml”:

1
2
@RequestMapping(path="/design",
produces={"application/json", "text/xml"})

在程序清单6.2中,你可能还发现这个类添加了@CrossOrigin注解。因为应用程序的Angular部分将会运行在与API相独立的主机和/或端口上(至少目前是这样的),Web浏览器会阻止Angular客户端消费该API。我们可以在服务端响应中添加CORS(Cross-Origin Resource Sharing,跨域资源共享)头信息来突破这一限制。Spring借助@CrossOrigin注解让CORS的使用更加简单。正如我们所看到的,@CrossOrigin允许来自任何域的客户端消费该API。

recentTacos()方法中的逻辑非常简单直接。它构建了一个PageRequest对象,指明我们想要第一页(序号为0)的12条结果,并且要按照taco的创建时间降序排列。简而言之,我们想要得到12个最近创建的taco设计。PageRequest会被传递到TacoRepository的findAll()方法中,分页的结果内容则会返回到客户端(也就是在程序清单6.2中我们所看到的,它们将会以模型数据展现给用户)。

现在,假设我们想要提供一个按照ID抓取单个taco的端点。通过在处理器方法的路径上使用占位符并让方法接收一个路径变量,我们能够捕获到这个ID,然后就可以借助repository查找Taco对象了:

1
2
3
4
5
6
7
8
@GetMapping("/{id}")
public Taco tacoById(@PathVariable("id") Long id) {
Optional<Taco> optTaco = tacoRepo.findById(id);
if (optTaco.isPresent()) {
return optTaco.get();
}
return null;
}

因为控制器的基础路径是“/design”,所以这个控制器方法处理的是针对“/design/{id}”的GET请求,其中路径的“{id}”部分是占位符。请求中的实际值将会传递给id参数,它通过@PathVariable注解与{id}占位符进行匹配。

在tacoById()中,id参数被传递到了repository的findById()方法中,以便于抓取Taco。findById()返回的是一个Optional<Taco>,因为根据给定的ID可能获取不到taco,所以在返回值的时候我们需要确定该ID是否能够匹配一个taco。如果能够匹配,我们可以调用Optional<Taco>对象的get()方法返回实际的Taco。

如果该ID无法匹配任何已知的taco,我们将会返回null。但是,这种做法并不完美。如果我们返回null,客户端将会接收到空的响应体以及值为200(OK)的HTTP状态码。客户端实际上接收到了一个无法使用的响应,但是状态码却提示一切正常。有一种更好的方式是在响应中使用HTTP 404 (NOT FOUND)状态。

按照现在的写法,我们没有简单的途径在tacoById()中返回404状态。但是,如果我们做一些小的调整,就可以将状态码设置成很恰当的值了:

1
2
3
4
5
6
7
8
@GetMapping("/{id}")
public ResponseEntity<Taco> tacoById(@PathVariable("id") Long id) {
Optional<Taco> optTaco = tacoRepo.findById(id);
if (optTaco.isPresent()) {
return new ResponseEntity<>(optTaco.get(), HttpStatus.OK);
}
return new ResponseEntity<>(null, HttpStatus.NOT_FOUND);
}

现在,tacoById()返回的不是一个Taco对象,而是ResponseEntity<Taco>。如果找到taco,我们就将Taco包装到ResponseEntity中,并且会带有OK的HTTP状态(这也是之前的行为)。如果找不到taco,我们就将会在ResponseEntity中包装一个null,并且会带有NOT FOUND的HTTP状态,从而表明客户端试图抓取的taco并不存在。

我们已经有了面向Angular客户端的初始Taco Cloud API,当然它也可以用于其他类型的客户端。在开发中,我们可能还想使用像curl或HTTPie这样的命令行工具来探测该API。比如,如下的命令行展示了如何通过curl获取最新创建的taco:

1
$ curl localhost:8080/design/recent

如果你更喜欢HTTPie,那如下所示:

1
$ http :8080/design/recent

定义能够返回信息的端点仅仅是第一步。如果我们的API需要从客户端接收数据又该怎么办呢?接下来,我们看一下如何编写控制器来处理请求的输入。

6.1.2 发送数据到服务器端

到目前为止,我们的API能够返回多个最近创建的taco。但是,这些taco又是怎样创建的呢?

我们还没有删掉第2章的任何代码,所以原始的DesignTacoController还存在,它会展现taco的设计表单并处理表单的提交。这是获取测试数据来验证我们所创建的API的一个好办法。如果我们想要将Taco Cloud转换成单页应用,那么我们需要创建Angular组件以及对应的端点,以此来替换第2章中的taco设计表单。

在客户端代码方面,我们通过一个名为DesignComponent(在名为design.component.ts的文件中)的新Angular组件来处理taco设计表单。因为要处理表单提交,所以DesignComponent中有一个onSubmit()方法,如下所示:

1
2
3
4
5
6
7
8
onSubmit() {
this.httpClient.post(
'http://localhost:8080/design',
this.model, {
headers: new HttpHeaders().set('Content-type', 'application/json'),
}).subscribe(taco => this.cart.addToCart(taco));
this.router.navigate(['/cart']);
}

在onSubmit()方法中,我们调用了HttpClient的post()方法而不是get()方法。这意味着我们不再是从API中抓取数据,而是向API发送数据。具体来讲,我们将一个taco设计(存放到model变量中)借助HTTP POST请求发送至“/design”的API端点上。

因此,我们需要在DesignTacoController中编写一个方法处理该请求并保存该taco设计。通过在DesignTacoController中添加如下的postTaco()方法,我们就能让控制器实现该功能:

1
2
3
4
5
@PostMapping(consumes="application/json")
@ResponseStatus(HttpStatus.CREATED)
public Taco postTaco(@RequestBody Taco taco) {
return tacoRepo.save(taco);
}

因为postTaco()将会处理HTTP POST请求,所以它使用了@PostMapping注解,而不是@GetMapping。在这里,我们没有指定path属性,因此按照DesignTacoController上的类级别@RequestMapping注解,postTaco()方法将会处理对“/design”的请求。

但是,我们设置了consumes属性。consumes属性用于指定请求输入,而produces用于指定请求输出。在这里,我们使用consumes属性,表明该方法只会处理Content-type与application/json相匹配的请求。

方法的Taco参数带有@RequestBody注解,表明请求应该被转换为一个Taco对象并绑定到该参数上。这个注解是非常重要的,如果没有它,Spring MVC将会认为我们希望将请求参数(要么是查询参数,要么是表单参数)绑定到Taco上。但是,@RequestBody注解能够确保请求体中的JSON会被绑定到Taco对象上。

在postTaco()接收到Taco对象之后,就会将该对象传递给TacoRepository的save()方法。

你可能也注意到了,我为postTaco()方法添加了@ResponseStatus(HttpStatus.CREATED)注解。在正常的情况下(没有异常抛出的时候),所有响应的HTTP状态码都是200 (OK),表明请求是成功的。尽管我们始终都希望得到HTTP 200,但是有些时候它的描述性不足。在POST请求的情况下,201 (CREATED)的HTTP状态更具有描述性。它会告诉客户端,请求不仅成功了,还创建了一个资源。在适当的地方使用@ResponseStatus将最具描述性和最精确的HTTP状态码传递给客户端是一种更好的做法。

我们已经使用@PostMapping创建了新的Taco资源,除此之外,POST请求还能用来更新资源。尽管如此,POST请求通常用来创建资源,而PUT和PATCH请求通常用来更新资源。接下来,让我们看一下该如何使用@PutMapping@PatchMapping来更新数据。

6.1.3 在服务器上更新数据

在编写控制器来处理HTTP PUT或PATCH命令之前,我们应该花点时间直面这个问题:为什么会有两种不同的HTTP方法来更新资源?

尽管PUT经常被用来更新资源,但它在语义上其实是GET的对立面。GET请求用来从服务端往客户端传输数据,而PUT请求则是从客户端往服务端发送数据。

从这个意义上讲,PUT真正的目的是执行大规模的替换(replacement)操作,而不是更新操作。HTTP PATCH的目的是对资源数据打补丁或局部更新。

例如,假设我们想要更新某个订单的地址信息。借助REST API,其中有一种实现方式就是借助如下所示的PUT请求处理器:

1
2
3
4
@PutMapping("/{orderId}")
public Order putOrder(@RequestBody Order order) {
return repo.save(order);
}

这种方式可以运行,但是它可能需要客户端将完整的订单数据从PUT请求中提交上来。从语义上讲,PUT意味着“将数据放到这个URL上”,其本质上就是替换已有的数据。如果省略了订单上的某个属性,那么该属性的值应该被null所覆盖,甚至订单中的taco也需要和订单数据一起设置,否则,它们将会从订单中移除。

如果PUT请求所做的是对资源数据的大规模替换,那么我们该如何处理局部更新的请求呢?这就是HTTP PATCH请求和Spring的@PatchMapping注解所擅长的事情了。如下展示了如何编写控制器方法来处理订单的PATCH请求:

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
@PatchMapping(path="/{orderId}", consumes="application/json")
public Order patchOrder(@PathVariable("orderId") Long orderId,
@RequestBody Order patch) {
Order order = repo.findById(orderId).get();
if (patch.getDeliveryName() != null) {
order.setDeliveryName(patch.getDeliveryName());
}
if (patch.getDeliveryStreet() != null) {
order.setDeliveryStreet(patch.getDeliveryStreet());
}
if (patch.getDeliveryCity() != null) {
order.setDeliveryCity(patch.getDeliveryCity());
}
if (patch.getDeliveryState() != null) {
order.setDeliveryState(patch.getDeliveryState());
}
if (patch.getDeliveryZip() != null) {
order.setDeliveryZip(patch.getDeliveryState());
}
if (patch.getCcNumber() != null) {
order.setCcNumber(patch.getCcNumber());
}
if (patch.getCcExpiration() != null) {
order.setCcExpiration(patch.getCcExpiration());
}
if (patch.getCcCVV() != null) {
order.setCcCVV(patch.getCcCVV());
}
return repo.save(order);
}

这里需要关注的第一件事情就是patchOrder()方法使用了@PatchMapping注解,而不是@PutMapping注解,这表示它应该处理HTTP PATCH请求,而不是PUT请求。

有一点你肯定也注意到了,那就是patchOrder()方法比putOrder()方法要更复杂一些。这是因为Spring MVC的映射注解,虽然包括了@PatchMapping和@PutMapping,但是它们只能用来指定某个方法能够处理什么类型的请求,这些注解并没有规定该如何处理请求,尽管PATCH在语义上代表局部更新,但是在处理器方法中实际编写代码执行更新的还是我们自己。

对于putOrder()方法来说,我们得到的是完整的订单数据,然后将它保存起来,这样就完全符合HTTP PUT的语义。但是,对于patchMapping()来说,为了符合HTTP PATCH的语义,方法体需要更多的智慧才行。在这里,我们不是用新发送过来的数据完全替换已有的订单,而是探查传入Order对象的每个字段,并将所有非null的值应用到已有的订单上。这种方式允许客户端只发送要改变的属性就可以,并且对于客户端没有指定的属性,服务器端会保留已有的数据。

还有更多的方式来实现PATCH

patchOrder()方法中的PATCH操作还有一些限制:

  • - 如果null意味着没有变化,那么客户端该如何指定一个字段真的要设置为null?
  • - 我们没有办法移除或添加集合的子集。如果客户端想要添加或移除集合中的条目,那么它必须将变更的完整集合发送到服务器端。

关于PATCH请求该如何处理以及传入的数据该是什么样子并没有硬性的规定。客户端可以发送一个PATCH请求特定的变更描述,而不是发送真正的领域数据。当然,如果是这样,那么请求处理器方法就会改写为处理patch指令,而不是领域数据。

@PutMapping@PatchMapping中,需要注意引用的请求路径都是要进行变更的资源。这与@GetMapping注解标注的方法在处理路径时的方式是相同的。

我们已经看过了如何使用@GetMapping@PostMapping获取和发送资源。同时,也看到了使用@PutMapping@PatchMapping更新资源的两种方式。剩下的就是该如何处理删除资源的请求了。

6.1.4 删除服务器上的数据

有时,有些数据可能不再需要了。在这种场景下,客户端应该能够通过HTTPDELETE请求的形式要求移除某个资源。

Spring MVC的@DeleteMapping注解能够非常便利地声明处理DELETE请求的方法。例如,我们想要有一个能够删除订单资源的API。如下的控制器方法就能做到这一点:

1
2
3
4
5
6
7
@DeleteMapping("/{orderId}")
@ResponseStatus(code=HttpStatus.NO_CONTENT)
public void deleteOrder(@PathVariable("orderId") Long orderId) {
try {
repo.deleteById(orderId);
} catch (EmptyResultDataAccessException e) {}
}

现在,再向你解释这个映射注解就有些啰唆了。我们已经见过了@GetMapping@PostMapping@PutMapping@PatchMapping,每个注解都能够指定某个方法可以处理对应类型的HTTP请求。毫无疑问,@DeleteMapping会指定deleteOrder()方法负责处理针对“/orders/{orderId}”的DELETE请求。

这个方法中的代码会负责真正删除订单。在本例中,它会接收订单ID并将其传递给repository的deleteById()方法,其中这个ID是以URL中路径变量的形式提供的。如果方法调用的时候该订单存在,就将会删除这个订单。如果订单不存在,就会抛出EmptyResultDataAccessException。

在这里,我选择捕获该EmptyResultDataAccessException异常,但是什么都没有做。在这里,我的想法是如果你尝试删除一个并不存在的资源,那么它的结果和删除之前存在这个资源是一样的。也就是,最终的效果都是资源不复存在。所以在删除之前资源是否存在并不重要。另外一种办法就是可以让deleteOrder()返回ResponseEntity,在资源不存在的时候将响应体设置为null并将HTTP状态码设置为NOT FOUND。

deleteOrder()方法唯一需要注意的是它使用了@ResponseStatus注解,以确保响应的HTTP状态码为204(NO CONTENT)。对于已经不存在的资源,我们没有必要返回任何的资源数据给客户端,因此DELETE请求通常并没有响应体,我们需要以HTTP状态码的形式让客户端知道不要期望得到任何的内容。

现在,Taco Cloud API已经基本成形了。客户端的代码可以很容易地消费我们的API,以便于显示配料、接收订单和展示最近创建的taco。但是,我们还可以更进一步,让API更易于客户端消费。接下来,我们看一下如何为Taco Cloud API添加超媒体功能。