编写RESTful控制器
在本质上来讲,REST API与Web站点并没有太大的差异,它们都会应对HTTP的请求。但是,主要的差别在于REST API并不会以HTML的形式响应这些请求,而一般会以面向数据的格式进行响应,比如JSON或XML。
在第2章,我们使用@GetMapping注解从服务端获取数据,使用@PostMapping注解向服务器端提交数据。在定义REST API的时候,这些注解依然有用。除此之外,Spring MVC还为各种类型的HTTP请求提供了一些其他的注解,如表7.1所示。

将HTTP方法映射为创建、读取、更新和删除(常统称为CRUD)操作不太恰当,但是在实践中,这是常见的使用方式,在我们的Taco Cloud应用中也是这样使用它们的。 |
要实际看到这些注解的效果,我们需要创建一个简单的REST端点,该端点会检索一些最新创建的 taco。
从服务器中检索数据
Taco Cloud 应用最酷的一件事就是它允许taco爱好者设计自己的taco作品并与其他人分享。实现该功能的一种方式就是在Web站点上展示最近创建的taco列表。
为了支持该特性,我们需要创建一个端点,处理对“/api/tacos”的GET请求,该请求中将会包含一个“recent”参数。端点会以最近新设计的taco列表作为响应。我们会创建一个新的控制器来处理这样的请求,程序清单7.1展示了完成该任务的控制器。
package tacos.web.api;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.web.bind.annotation.CrossOrigin;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import tacos.Taco;
import tacos.data.TacoRepository;
@RestController
@RequestMapping(path = "/api/tacos", ⇽---处理针对“/api/tacos”的请求
produces = "application/json")
@CrossOrigin(origins = "http://tacocloud:8080") ⇽---允许跨域请求
public class TacoController {
private TacoRepository tacoRepo;
public TacoController(TacoRepository tacoRepo) {
this.tacoRepo = tacoRepo;
}
@GetMapping(params = "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应用中生成HTML结果的,这个新的TacoController则是一个由@RestController注解声明的REST控制器。
@RestController注解有两个目的。首先,它是一个类似于@Controller和@Service的构造型注解,能够让标注的类被组件扫描功能发现。但是,与REST最密切相关的地方在于,@RestController注解会告诉Spring,在控制器中,所有处理器方法的返回值都要直接写入响应体,而不是将值放到模型中并传递给一个视图以便渲染。
作为替代方案,我们也可以像其他Spring MVC控制器那样,为TacoController添加@Controller注解。但是,这样一来,我们就需要为每个处理器方法再添加@ResponseBody注解,以达到相同的效果。另外一种方案就是返回ResponseEntity对象,我们稍后将会对其进行讨论。
类级别的@RequestMapping注解与recentTacos()方法上的@GetMapping注解结合起来,指定了recentTacos()方法将会负责处理针对“/api/tacos?recent”的GET请求。
你还会发现,@RequestMapping注解还设置了一个produces属性。这指明TacoController类中的所有处理器方法只会处理Accept头信息包含“application/json”的请求,表明客户端只能处理JSON格式的响应。通过使用produces能够限制API只生成JSON格式的结果,这样我们就能让其他的控制器(比如第2章中的DesignTacoController)处理具有相同路径的请求,只要这些请求不要求JSON格式的输出。
尽管将produces设置为“application/json”能够限制API是基于JSON的(对于我们的需求要说,这样就可以了),但我们还可以将produces设置为一个String类型的数组,从而使其允许我们设置多个内容类型。比如,为了允许生成XML格式的输出,可以为produces属性添加“text/html”:
@RequestMapping(path = "/api/tacos",
produces = {"application/json", "text/xml"})
在程序清单7.1中,你可能还发现这个类添加了@CrossOrigin注解。对基于JavaScript的用户界面来说(比如使用像Angular或ReactJS这样的框架所编写的用户界面),一种常见的方式是让它们运行在与API相独立的主机或端口上(至少目前是这样的),Web浏览器会阻止客户端消费该API。我们可以在服务端响应中添加跨域资源共享(Cross- Origin Resource Sharing, CORS)头信息来突破这一限制。Spring借助@CrossOrigin注解让CORS的使用更加简单。
正如我们所看到的,@CrossOrigin允许来自localhost且端口为8080的客户端访问API。但是,origins属性也可以接受数组,这样我们就可以声明多个值,如下所示:
@RestController
@RequestMapping(path = "/api/tacos",
produces = "application/json")
@CrossOrigin(origins = {"http://tacocloud:8080", "http://tacocloud.com"})
public class TacoController {
...
}
recentTacos() 方法中的逻辑非常简单直接。它构建了一个 PageRequest 对象,指明我们想要第一页(页号为0)按照taco的创建时间降序排列的12条结果。简言之,我们想要得到12个最近创建的taco设计。PageRequest会传递到TacoRepository的findAll()方法中,分页的结果内容则会返回到客户端(也就是我们在程序清单7.1中看到的,它们将会作为模型数据展现给用户)。
我们已经有了面向客户端的初始Taco Cloud API。在开发中,我们可能还想使用像curl或HTTPie这样的命令行工具来探测该API。比如,如下的命令行展示了如何通过curl获取最新创建的taco:
$ curl localhost:8080/api/tacos?recent
如果你更喜欢HTTPie,也可以通过HTTPie做到这一点:
$ http :8080/api/tacos?recent
最初,数据库是空的,所以这些请求的结果同样也是空的。稍后,我们会看到如何处理 POST 请求以保存 taco。不过,也可以添加一个 CommandLineRunner bean,使用测试数据预加载数据库。下面的 CommandLineRunner bean 方法展示了如何预加载一些配料和 taco:
@Bean
public CommandLineRunner dataLoader(
IngredientRepository repo,
UserRepository userRepo,
PasswordEncoder encoder,
TacoRepository tacoRepo) {
return args -> {
Ingredient flourTortilla = new Ingredient(
"FLTO", "Flour Tortilla", Type.WRAP);
Ingredient cornTortilla = new Ingredient(
"COTO", "Corn Tortilla", Type.WRAP);
Ingredient groundBeef = new Ingredient(
"GRBF", "Ground Beef", Type.PROTEIN);
Ingredient carnitas = new Ingredient(
"CARN", "Carnitas", Type.PROTEIN);
Ingredient tomatoes = new Ingredient(
"TMTO", "Diced Tomatoes", Type.VEGGIES);
Ingredient lettuce = new Ingredient(
"LETC", "Lettuce", Type.VEGGIES);
Ingredient cheddar = new Ingredient(
"CHED", "Cheddar", Type.CHEESE);
Ingredient jack = new Ingredient(
"JACK", "Monterrey Jack", Type.CHEESE);
Ingredient salsa = new Ingredient(
"SLSA", "Salsa", Type.SAUCE);
Ingredient sourCream = new Ingredient(
"SRCR", "Sour Cream", Type.SAUCE);
repo.save(flourTortilla);
repo.save(cornTortilla);
repo.save(groundBeef);
repo.save(carnitas);
repo.save(tomatoes);
repo.save(lettuce);
repo.save(cheddar);
repo.save(jack);
repo.save(salsa);
repo.save(sourCream);
Taco taco1 = new Taco();
taco1.setName("Carnivore");
taco1.setIngredients(Arrays.asList(
flourTortilla, groundBeef, carnitas,
sourCream, salsa, cheddar));
tacoRepo.save(taco1);
Taco taco2 = new Taco();
taco2.setName("Bovine Bounty");
taco2.setIngredients(Arrays.asList(
cornTortilla, groundBeef, cheddar,
jack, sourCream));
tacoRepo.save(taco2);
Taco taco3 = new Taco();
taco3.setName("Veg-Out");
taco3.setIngredients(Arrays.asList(
flourTortilla, cornTortilla, tomatoes,
lettuce, salsa));
tacoRepo.save(taco3);
};
}
现在,如果我们尝试使用 curl 或 HTTPie 来发送请求到最近 taco的端点,将会看到如下所示的响应(为了可读性,响应进行了格式化):
$ curl localhost:8080/api/tacos?recent
[
{
"id": 4,
"name": "Veg-Out",
"createdAt": "2021-08-02T00:47:09.624 + 00:00",
"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" }
]
},
{
"id": 3,
"name": "Bovine Bounty",
"createdAt": "2021-08-02T00:47:09.621 + 00:00",
"ingredients": [
{ "id": "COTO", "name": "Corn Tortilla", "type": "WRAP" },
{ "id": "GRBF", "name": "Ground Beef", "type": "PROTEIN" },
{ "id": "CHED", "name": "Cheddar", "type": "CHEESE" },
{ "id": "JACK", "name": "Monterrey Jack", "type": "CHEESE" },
{ "id": "SRCR", "name": "Sour Cream", "type": "SAUCE" }
]
},
{
"id": 2,
"name": "Carnivore",
"createdAt": "2021-08-02T00:47:09.520 + 00:00",
"ingredients": [
{ "id": "FLTO", "name": "Flour Tortilla", "type": "WRAP" },
{ "id": "GRBF", "name": "Ground Beef", "type": "PROTEIN" },
{ "id": "CARN", "name": "Carnitas", "type": "PROTEIN" },
{ "id": "SRCR", "name": "Sour Cream", "type": "SAUCE" },
{ "id": "SLSA", "name": "Salsa", "type": "SAUCE" },
{ "id": "CHED", "name": "Cheddar", "type": "CHEESE" }
]
}
]
现在,假设我们想要提供一个按照ID抓取单个taco的端点。我们可以在处理器方法的路径上使用占位符并让对应的方法接受一个路径变量,这样就能捕获到这个ID,然后借助存储库查找Taco对象了:
@GetMapping("/{id}")
public Optional<Taco> tacoById(@PathVariable("id") Long id) {
return tacoRepo.findById(id);
}
因为控制器的基础路径是 “/api/tacos”,所以这个控制器方法处理的是针对 “/api/tacos/{id}” 的 GET 请求,其中路径的“{id}”部分是占位符。请求中的实际值会传递给id参数,通过@PathVariable注解与{id}占位符进行匹配。
在tacoById()中,id参数被传递到了存储库的findById()方法中,以便抓取Taco。findById()返回的是Optional<Taco>,因为根据给定的ID可能匹配不到taco。控制器方法只需要简单地返回Optional<Taco>就可以了。
Spring得到这个Optional<Taco>后,会调用其get()来生成响应。如果该ID无法匹配任何已知的taco,响应体将会包含null,并且响应的状态码为200 (OK)。客户端实际上接收到了一个无法使用的响应,但是状态码却提示一切正常。有一种更好的方式是在响应中使用HTTP 404 (NOT FOUND)状态。
按照现在的写法,没有简单的途径可以在tacoById()中返回404状态。但是,我们如果做一些小的调整,就可以将状态码设置成恰当的值了:
@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中,并且会带有HTTP的OK状态(这也是之前的行为)。如果找不到taco,我们会在ResponseEntity中包装一个null,并且带有HTTP的NOT FOUND状态,从而通知客户端试图抓取的taco并不存在。
定义能够返回信息的端点仅仅是第一步。如果我们的API需要从客户端接收数据,又该怎么办呢?接下来,我们看一下如何编写控制器来处理请求的输入。
发送数据到服务器端
到目前为止,我们的API能够返回多个最近创建的taco。但是,这些taco最初又是如何创建的呢?
尽管我们可以借助CommandLineRunner bean将一些测试的taco数据预加载到数据库中,但是taco数据最终还是要来源于由用户制作taco的作品。因此,需要在TacoController中编写一个方法以处理包含taco设计的请求并将其保存到数据库中。通过在TacoController中添加如下的postTaco()方法,我们就能让控制器实现该功能:
@PostMapping(consumes = "application/json")
@ResponseStatus(HttpStatus.CREATED)
public Taco postTaco(@RequestBody Taco taco) {
return tacoRepo.save(taco);
}
因为postTaco()将会处理HTTP POST请求,所以其中使用了@PostMapping注解,而不是@GetMapping。在这里,我们没有指定path属性,因此按照TacoController上类级别的@RequestMapping注解,postTaco()方法将会处理对“/api/tacos”的请求。
但是,我们设置了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来更新数据。
在服务器上更新数据
在编写控制器来处理HTTP PUT或PATCH命令之前,我们应该花点时间直面这个问题:为什么会有两种不同的HTTP方法来更新资源?
尽管PUT经常用来更新资源,但在语义上它其实是与GET对立的。GET请求用来从服务端向客户端传输数据,而PUT请求则从客户端向服务端发送数据。
从这个意义上讲,PUT真正的目的是执行大规模的替换(replacement)操作,而不是更新操作。而HTTP PATCH的目的是对资源数据打补丁或进行局部更新。
例如,假设我们想要更新某个订单的地址信息。通过REST API,一种实现方式是使用如下所示的PUT请求处理:
@PutMapping(path = "/{orderId}", consumes = "application/json")
public TacoOrder putOrder(
@PathVariable("orderId") Long orderId,
@RequestBody TacoOrder order) {
order.setId(orderId);
return repo.save(order);
}
这种方式可以达到目的,但是可能需要客户端将完整的订单数据从PUT请求中提交上来。从语义上讲,PUT意味着 “将数据放到这个URL上”,其本质上就是替换已有的数据。如果省略了订单上的某个属性,该属性的值应该被null覆盖。甚至订单中的taco也需要和订单数据一起设置,否则,它们将会从订单中移除。
如果PUT请求所做的是对资源数据进行大规模替换,那么我们该如何处理局部更新的请求呢?这就是HTTP PATCH请求和Spring的@PatchMapping注解所擅长的事情了。如下代码展示了如何通过控制器方法处理订单的PATCH请求:
@PatchMapping(path = "/{orderId}", consumes = "application/json")
public TacoOrder patchOrder(@PathVariable("orderId") Long orderId,
@RequestBody TacoOrder patch) {
TacoOrder 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.getDeliveryZip());
}
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的语义,方法体需要更智能。在这里,我们不是用新发送过来的数据完全替换已有的订单,而是探查传入TacoOrder对象的每个字段,并将所有非null的值应用到已有的订单上。这种方式允许客户端只发送要改变的属性,并且对于客户端没有指定的属性,服务器端会保留已有的数据。
需要注意,在@PutMapping和@PatchMapping中,请求路径引用的都是要进行变更的资源。这与@GetMapping注解标注的方法在处理路径时的方式是相同的。
我们已经看过了如何使用@GetMapping和@PostMapping获取和发送资源。同时,也看到了使用@PutMapping和@PatchMapping更新资源的两种方式,剩下的就是该如何处理删除资源的请求了。
删除服务器上的数据
有时候,我们可能不再需要某些数据。在这种情况下,客户端应该能够通过HTTP DELETE请求的形式要求移除资源。
Spring MVC的@DeleteMapping注解能够非常便利地声明处理DELETE请求的方法。例如,我们想要有一个能够删除订单资源的API。如下的控制器方法就能做到这一点:
@DeleteMapping("/{orderId}")
@ResponseStatus(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并将其传递给存储库的deleteById()方法,其中的ID是以URL中路径变量的形式提供的。在方法调用的时候,该订单如果存在,就会被删除。如果订单不存在,方法就会抛出EmptyResultDataAccessException。
在这里,我选择捕获该EmptyResultDataAccessException异常,但是什么都没有做。我的想法是:如果尝试删除一个并不存在的资源,那么这样做的结果和删除一个存在的资源是一样的。也就是说,最终的效果都是资源不存在,所以在删除之前资源是否存在并不重要。另外一种办法是让deleteOrder()返回ResponseEntity,在资源不存在的时候将响应体设置为null并将HTTP状态码设置为NOT FOUND。
关于deleteOrder()方法,唯一需要注意的是它使用了@ResponseStatus注解,以确保响应的HTTP状态码为204 (NO CONTENT)。对于已经不存在的资源,我们没有必要返回任何的资源数据给客户端,因此DELETE请求通常并没有响应体。我们需要以HTTP状态码的形式让客户端不要预期得到任何的内容。
现在,Taco Cloud API已经基本成形了。客户端可以很容易地消费我们的API,以便于显示配料、接受订单和展示最近创建的taco。我们将会在7.3节讨论如何编写REST客户端的代码。但现在,我们看一下创建REST API的另外一个方式:基于Spring Data存储库的自动生成。