6.1编写RESTful控制器

6.1编写RESTful控制器

当翻页并阅读本章的介绍时,Taco Cloud的用户界面已经被重新设计了。一直在做的事情在开始的时候是可以的,但是在美学方面却有欠缺。

6.1只是新的Taco Cloud的一个示例,很时髦的,不是吗?

6.1新的Taco Cloud主页

![6.1新的Taco Cloud主页](E:\Document\spring-in-action-v5-translate\第二部分 集成Spring\6章 创建REST服务\6.1新的Taco Cloud主页.jpg)

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

SPA还是不用?

在第2章中,使用Spring MVC开发了一个传统的多页面应用程序(MPA,现在将用一个基于Angular的单页面应用程序(SPA)取代它,但并不总是说SPA是比MPA更好的选择。

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

这不是一本关于Angular的书,所以这一章的代码主要着重于后端的Spring代码。我将展示足够多的Angular代码,让你了解客户端是如何工作的。请放心,完整的代码集,包括Angular前端,都可以作为可下载代码的一部分,在 https://github.com/habuma/springing-inaction-5-samples 中找到。你可能还会对Jeremy Wilken2018年传)的《Angular实战》以及Yakov FainAnton Moiseev2018年出版)合著的《基于TypeScriptAngular开发(第二版》感兴趣。

简而言之,Angular客户端代码将通过HTTP请求的方式与本章中创建的API进行通信。在第2章中,使用@GetMapping@PostMapping注解来获取和发送数据到服务器。在定义REST API时,这些相同的注释仍然很有用。此外,Spring MVC还为各种类型的HTTP请求支持少量其他注解,如表6.1所示。

6.1 Spring MVC HTTP请求处理注解

注解 HTTP方法 典型用法
@GetMapping HTTP GET请求 读取资源数据
@PostMapping HTTP POST请求 创建资源
@PutMapping HTTP PUT请求 更新资源
@PatchMapping HTTP PATCH请求 更新资源
@DeleteMapping HTTP DELETE请求 删除资源
@RequestMapping 通用请求处理

要查看这些注释的实际效果,将首先创建一个简单的REST端点,该端点获取一些最近创建的taco

6.1.1从服务器获取数据

Taco Cloud最酷的事情之一是它允许Taco狂热者设计他们自己的Taco作品,并与他们的Taco爱好者分享。为此,Taco Cloud需要能够在单击最新设计链接时显示最近创建的Taco的列表。

Angular代码中,我定义了一个RecentTacosComponent,它将显示最近创建的tacosRecentTacosComponent的完整TypeScript代码在下面程序清单中。程序清单6.1展示最近tacoAngular组件

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")
      .subscribe((data) => (this.recentTacos = data));
  }
}

请注意ngOnInit()方法,在该方法中,RecentTacosComponent使用注入的Http模块执行对http://localhost:8080/design/recentHttp GET请求,期望响应将包含taco设计的列表,该列表将放在recentTacos模型变量中。视图(在recents.component.HTML中)将模型数据以HTML的形式呈现在浏览器中。在创建了三个tacos之后,最终结果可能类似于图6.2

6.2显示最近创建的tacos

![6.2显示最近创建的tacos](E:\Document\spring-in-action-v5-translate\第二部分 集成Spring\6章 创建REST服务\6.2显示最近创建的tacos.jpg)

这个版面中缺失的部分是一个端点,它处理 /design/recent 接口的GET请求 ,并使用一个最新设计的taco列表进行响应。后面将创建一个新的控制器来处理这样的请求,下面的程序清单显示了怎么去做的。程序清单6.2 taco设计API请求的RESTful控制器

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", 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() {
        PageRequest page = PageRequest.of(
            0, 12, Sort.by("createdAt").descending());
        return tacoRepo.findAll(page).getContent();
    }
}

你可能认为这个控制器的名字听起来很熟悉。在第2章中,创建了一个处理类似类型请求的DesignTacoController。但是这个控制器是用于多页面Taco Cloud应用程序的,正如@RestController注解所示,这个新的DesignTacoController是一个REST控制器。

@RestController注解有两个用途。首先,它是一个像@Controller@Service这样的原型注解,它通过组件扫描来标记一个类。但是与REST的讨论最相关的是,@RestController注解告诉Spring,控制器中的所有处理程序方法都应该将它们的返回值直接写入响应体,而不是在模型中被带到视图中进行呈现。

或者,可以使用@Controller来注解DesignTacoController,就像使用任何Spring MVC控制器一样。但是,还需要使用@ResponseBody注解所有处理程序方法,以获得相同的结果。另一个选项是返回一个ResponseEntity对象,我们稍后将讨论它。

类级别的@RequestMapping注解与recentTacos()方法上的@GetMapping注解一起工作,以指定recentTacos()方法负责处理 /design/recent 接口的GET请求(这正是Angular代码所需要的

注意,@RequestMapping注解还设置了一个produces属性。这指定了DesignTacoController中的任何处理程序方法只在请求的Accept头包含 “application/json” 时才处理请求。这不仅限制了API只生成JSON结果,还允许另一个控制器(可能是第2章中的DesignTacoController)处理具有相同路径的请求,只要这些请求不需要JSON输出。尽管这将API限制为基于JSON的,但是欢迎将produces设置为多个内容类型的字符串数组。例如,为了允许XML输出,可以向produces属性添加 “text/html”:

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

在程序清单6.2中可能注意到的另一件事是,该类是用@CrossOrigin注解了的。由于应用程序的Angular部分将运行在独立于API的主机或端口上(至少目前是这样web浏览器将阻止Angular客户端使用API。这个限制可以通过在服务器响应中包含CORS(跨源资源共享)头来克服。Spring使得使用@CrossOrigin注解应用CORS变得很容易。正如这里所应用的,@CrossOrigin允许来自任何域的客户端使用API

recentTacos()方法中的逻辑相当简单。它构造了一个PageRequest对象,该对象指定只想要包含12个结果的第一个(第0个)页面,结果按照taco的创建日期降序排序。简而言之就是你想要一打最新设计的tacosPageRequest被传递到TacoRepositoryfindAll()方法的调用中,结果页面的内容被返回给客户机(如程序清单6.1所示,它将作为模型数据显示给用户

现在,假设需要提供一个端点,该端点通过其ID获取单个taco。通过在处理程序方法的路径中使用占位符变量并接受path变量的方法,可以捕获该ID并使用它通过存储库查找taco对象:

@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参数传递给存储库的findById()方法来获取TacofindById()返回一个可选的 ,因为可能没有具有给定IDTaco。如果匹配,则在可选的 对象上调用get()以返回实际的Taco

如果ID不匹配任何已知的taco,则返回null,然而,这并不理想。通过返回null,客户端接收到一个空体响应和一个HTTP状态码为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对象包装在HTTP状态为OKResponseEntity中(这是之前的行为。但是,如果没有找到taco,则在ResponseEntity中包装一个null,并加上一个HTTP status(NOT FOUND,以指示客户端试图获取一个不存在的taco

现在已经开始为Angular客户端或任何其他类型的客户端创建Taco Cloud API了。出于开发测试的目的,可能还希望使用curlHTTPie(https://httpie.org/)等命令行实用程序来了解API。例如,下面的命令行显示了如何使用curl获取最近创建的taco

$ curl localhost:8080/design/recent

如果更喜欢HTTPie,可以用下面这种方式:

$ http :8080/design/recent

但是,定义返回信息的端点只是开始。如果API需要从客户端接收数据呢?让我们看看如何编写处理请求输入的控制器方法。

6.1.2向服务器发送数据

到目前为止,API能够返回12个最近创建的tacos。但是这些tacos是如何产生的呢?

还没有从第2章中删除任何代码,所以仍然拥有原始的DesignTacoController,它显示一个taco设计表单并处理表单提交。这是获得一些测试数据以测试创建的API的好方法。但是,如果要将Taco Cloud转换为单页面应用程序,则需要创建Angular组件和相应的端点来替代第2章中的Taco设计表单。

已经通过定义一个名为DesignComponent的新Angular组件(在一个名为design.component.ts的文件中)处理了taco设计表单的客户端代码。与处理表单提交相关,DesignComponent有一个onSubmit()方法,如下所示:

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()方法中,调用HttpClientpost()方法,而不是get()。这意味着不是从API获取数据,而是将数据发送到API。具体地说,使用HTTP POST请求将模型变量中包含的taco设计发送到API/design 端点。

这意味着需要在DesignTacoController中编写一个方法来处理该请求并保存设计。通过将以下postTaco()方法添加到DesignTacoController中,可以让控制器做到这一点:

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

因为postTaco()将处理HTTP POST请求,所以它使用@PostMapping而不是@GetMapping进行注解。这里没有指定path属性,所以postTaco()方法将处理DesignTacoController上的类级@RequestMapping中指定的 /design 请求。

但是,确实设置了consumer属性。consumer属性用于处理输入,那么produces就用于处理输出。这里使用consumer属性,表示该方法只处理Content-typeapplication/json匹配的请求。

方法的Taco参数添加了@RequestBody注解,以指示请求体应该转换为Taco对象并绑定到参数。这个注解很重要 —— 如果没有它,Spring MVC会假设将请求参数(查询参数或表单参数)绑定到Taco对象。但是@RequestBody注解确保将请求体中的JSON绑定到Taco对象。

postTaco()接收到Taco对象后,将其传递给TacoRepository上的save()方法。

这里在postTaco()方法上使用了@ResponseStatus(HttpStatus.CREATED)注解。在正常情况下(当没有抛出异常时,所有响应的HTTP状态码为200(OK,表示请求成功。尽管HTTP 200响应总是好的,但它并不总是具有足够的描述性。对于POST请求,HTTP状态201(CREATED)更具描述性,它告诉客户机,请求不仅成功了,而且还创建了一个资源。在适当的地方使用@ResponseStatus将最具描述性和最准确的HTTP状态代码传递给客户端总是一个好想法。

虽然已经使用@PostMapping创建了一个新的Taco资源,但是POST请求也可以用于更新资源。即便如此,POST请求通常用于创建资源,PUTPATCH请求用于更新资源。让我们看看如何使用@PutMapping@PatchMapping更新数据。

6.1.3更新服务器上的资源

在编写任何处理HTTP PUTPATCH命令的控制器代码之前,应该花点时间考虑一下这个问题:为什么有两种不同的HTTP方法来更新资源呢?

虽然PUT经常用于更新资源数据,但它实际上是GET语义的对立面。GET请求用于将数据从服务器传输到客户机,而PUT请求用于将数据从客户机发送到服务器。

从这个意义上说,PUT实际上是用于执行大规模替换操作,而不是更新操作。相反,HTTP PATCH的目的是执行补丁或部分更新资源数据。

例如,假设希望能够更改订单上的地址,我们可以通过REST API实现这一点,可以用以下这种方式处理PUT请求:

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

这可能行得通,但它要求客户端在PUT请求中提交完整的订单数据。从语义上讲,PUT的意思是“把这个数据放到这个URL上”,本质上是替换任何已经存在的数据。如果订单的任何属性被省略,该属性的值将被null覆盖。甚至订单中的taco也需要与订单数据一起设置,否则它们将从订单中删除。

如果PUT完全替换了资源数据,那么应该如何处理只进行部分更新的请求?这就是HTTP PATCH请求和Spring@PatchMapping的好处。可以这样写一个控制器方法来处理一个订单的PATCH请求:

@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的语义,该方法的主体需要更多语句。它不是用发送进来的新数据完全替换订单,而是检查传入订单对象的每个字段,并将任何非空值应用于现有订单。这种方法允许客户机只发送应该更改的属性,并允许服务器为客户机未指定的任何属性保留现有数据。

使用PATCH的方法不止一种

PATCH方式应用于patchOrder()方法时,有两个限制:

  • 如果传递的是null值,意味着没有变化,那么客户端如何指示字段应该设置为null
  • 没有办法从一个集合中移除或添加一个子集。如果客户端想要从集合中添加或删除一条数据,它必须发送完整的修改后的集合。

对于应该如何处理PATCH请求或传入的数据应该是什么样子,确实没有硬性规定。客户端可以发送应用于特定PATCH请求的描述,这个描述包含着需要被应用于数据的更改,而不是发送实际的域数据。当然,必须编写请求处理程序来处理PATCH指令,而不是域数据。

@PutMapping@PatchMapping中,请注意请求路径引用了将要更改的资源。这与@GetMappingannotated方法处理路径的方式相同。

现在已经了解了如何使用@GetMapping@PostMapping来获取和发布资源。已经看到了使用@PutMapping@PatchMapping更新资源的两种不同方法,剩下的工作就是处理删除资源的请求。

6.1.4从服务器删除数据

有时数据根本就不再需要了。在这些情况下,客户端需要发起HTTP DELETE请求删除资源。

Spring MVC@DeleteMapping可以方便地声明处理DELETE请求的方法。例如,假设需要API允许删除订单资源,下面的控制器方法应该可以做到这一点:

@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} 的删除请求。

该方法中的代码实际用于执行删除订单操作。在本例中,它接受作为URL中的路径变量提供的订单ID,并将其传递给存储库的deleteById()方法。如果调用该方法时订单存在,则将删除它。如果订单不存在,将抛出一个EmptyResultDataAccessException异常。

我选择捕获EmptyResultDataAccessException而不做任何事情。我的想法是,如果试图删除一个不存在的资源,其结果与在删除之前它确实存在的结果是一样的,也就是说,资源将不存在。它以前是否存在无关紧要。或者,我也可以编写deleteOrder()来返回一个ResponseEntity,将body设置为null,将HTTP状态代码设置为NOT FOUND

deleteOrder()方法中需要注意的惟一一点是,它使用@ResponseStatus进行了注解,以确保响应的HTTP状态是204(NO CONTENT。对于不再存在的资源,不需要将任何资源数据发送回客户机,因此对删除请求的响应通常没有正文,因此应该发送一个HTTP状态代码,让客户机知道不需要任何内容。

Taco Cloud API已经开始成形了,客户端代码现在可以轻松地使用这个API来显示配料、接受订单和显示最近创建的tacos。但是还可以做一些事情来让客户端更容易地使用这个API。接下来,让我们看看如何将超媒体添加到Taco Cloud API中。

下一页