使用 Spring 构建 REST 服务

注:本文章是 spring 官网 guide Tutorials 部分的第一篇文章(Building REST services with Spring)的中文翻译版本。因为译者并非专业翻译因此其中必有不通顺的地方,会进行多次修改和迭代。若发现问题望指正。文章翻译仅供参考!

英文版链接:https://spring.io/guides/tutorials/rest/

项目的 github 地址:https://github.com/spring-guides/tut-rest
在译者的个人博客阅读本文

REST 因其易于搭建,易于使用的特性已经迅速成为了网络上构建网络服务的实际标准。

关于 REST 是如何适于微服务界,要讨论的太多。在本教程中,我们只搭建一下 RESTful 服务。

为什么是REST?REST接受了网络的戒律,包括其架构、好处和其他一切。这并不奇怪,因为它的作者Roy Fielding参与了大概十几个规范,这些规范管理着网络的运作方式。

为什么选择 REST?因为 REST 包含了 web 的规则,包括其架构、优势和其他一切。这并不奇怪,因为它的作者 Roy Fielding 参与了十几个管理网络运行方式的规范。

选则 REST 有什么好处?Web 及其核心协议 HTTP 提供了一系列功能:

  • 适当的操作(GET, POST, PUT, DELETE,…)

  • 缓存

  • 重定向和转发

  • 安全性(加密和身份验证)

这些是搭建弹性服务的关键要素。但也并非全部。网络是由许多微小的规范构成的,因此它能够轻松发展,而不会陷入“标准战争”的泥潭。

开发人员能够利用第三方工具包来实现这些不同的规范,并立即让客户端和服务器技术触手可及。

通过建立在HTTP之上,REST APIs提供了以下搭建方法:

  • 向后兼容的API

  • 可演化的API (原文为:Evolvable APIs 笔者认为可理解为可迭代、拓展、更新的API)

  • 可扩展的服务

  • 可保障的服务

  • 从无状态到有状态的服务范围

重要的是要认识到,REST无论多么普遍,它本身并不是一个标准,而是一种方法、一种风格、一套对你的架构的约束,可以帮助你建立网络规模的系统。在本教程中,我们将使用 Spring 组合来构建一个 RESTful 服务,同时利用 REST 的无堆栈特性。

入门

当我们通过本教程进行项目搭建时,我们将使用 Spring Boot 。进入 Spring Initializr ,在项目中添加以下依赖项:

  • Web
  • JPA
  • H2

将名称改为 “Payroll”,然后选择 “Generate Project”。将下载一个.zip文件。把它解压。在里面你会发现一个简单的、基于Maven的项目,包括一个pom.xml构建文件(注意:你可以使用Gradle。本教程中的例子都是基于Maven的)。

Spring Boot可以在任何IDE中工作。你可以使用Eclipse、IntelliJ IDEA、Netbeans等。Spring Tool Suite是一个开源的、基于Eclipse的IDE分布,它提供了Eclipse的Java EE分布的超集。它包括的功能使使用Spring应用程序更加容易。它决不是必需的。但如果你想让你的按键有额外的动力,可以考虑使用它。

译者使用了 idea ,而 spingguide 中推荐了 STS ,其实并没有任何影响,所以请读者选择自己喜欢的 IDE 进行搭建即可。

到目前为止…

让我们从最简单的东西开始构建。事实上,为了让它尽可能的简单,我们甚至可以不考虑 REST 的概念。(稍后,我们会加入 REST 来理解其中的区别)。

整体流程:我们将创建一个简单的工资单服务来管理公司的员工。我们将员工对象存储在(内存中的H2)数据库中,并访问它们(通过称为 JPA 的东西)。然后,我们将用一些允许通过互联网访问的东西(称为 Spring MVC 层)来包装它。

接下来,在我们的系统中定义了一个 Employee 类。

nonrest/src/main/java/payroll/Employee.java

package payroll;

import java.util.Objects;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
class Employee {

  private @Id @GeneratedValue Long id;
  private String name;
  private String role;

  Employee() {}

  Employee(String name, String role) {

    this.name = name;
    this.role = role;
  }

  public Long getId() {
    return this.id;
  }

  public String getName() {
    return this.name;
  }

  public String getRole() {
    return this.role;
  }

  public void setId(Long id) {
    this.id = id;
  }

  public void setName(String name) {
    this.name = name;
  }

  public void setRole(String role) {
    this.role = role;
  }

  @Override
  public boolean equals(Object o) {

    if (this == o)
      return true;
    if (!(o instanceof Employee))
      return false;
    Employee employee = (Employee) o;
    return Objects.equals(this.id, employee.id) && Objects.equals(this.name, employee.name)
        && Objects.equals(this.role, employee.role);
  }

  @Override
  public int hashCode() {
    return Objects.hash(this.id, this.name, this.role);
  }

  @Override
  public String toString() {
    return "Employee{" + "id=" + this.id + ", name='" + this.name + '\'' + ", role='" + this.role + '\'' + '}';
  }
}

尽管这个Java类很小,但是包含了很多内容:

  • @Entity 是一个 JPA 注释,用于将该对象存储在基于 JPA 的数据存储中。

  • idrolename 是 Employee 域对象的属性。id 用更多的 JPA 注释标记,以表明它是主键,并由 JPA 提供者自动填充。

当我们需要创建一个新实例,但还没有 id 时,就会创建自定义构造函数。

有了这个领域对象的定义,我们现在可以转而让 Spring Data JPA 来处理繁琐的数据库交互。

Spring Data JPA 存储库是具有支持针对后端数据存储创建、读取、更新和删除记录方法的接口。一些存储库还支持数据分页,并在适当时进行排序。Spring Data 根据接口中方法命名的约定来合成实现。

后半句的原文是:Spring Data synthesizes implementations based on conventions found in the naming of the methods in the interface. convention有常规惯例的意思这里翻译为约定…

除了 JPA ,还有多种存储库的实现。你可以使用Spring Data MongoDB、Spring Data GemFire、Spring Data Cassandra等。在本教程中,我们将坚持使用JPA。

Spring 使访问数据变得容易。通过简单地声明下面的 EmployeeRepository 接口,我们将能够自动:

  • 创建新员工数据

  • 更新现有的数据

  • 删除员工数据

  • 查找雇员(一个、全部或通过简单或复杂属性进行搜索)的数据

nonrest/src/main/java/payroll/EmployeeRepository.java

package payroll;

import org.springframework.data.jpa.repository.JpaRepository;

interface EmployeeRepository extends JpaRepository<Employee, Long> {

}

为了获得所有这些免费的功能,我们所要做的就是声明一个扩展 Spring Data JPA 的 JpaRepository 的接口,指定域类型为Employee,id 类型为 Long 。

Spring Data 的存储库解决方案可以使我们避开数据存储细节,转而使用特定于领域的术语解决大部分问题。

信不信由你,这就足以启动一个应用程序了! 一个 Spring Boot 应用程序至少要有一个公共静态 void main 入口点和 @SpringBootApplication 注解。这告诉 Spring Boot 尽可能地向我们提供帮助。

原话为:This tells Spring Boot to help out, wherever possible. 笔者理解为:使用注解以及 void main 入口 将使得 Spring Boot 向我们提供尽可能多的功能以帮助我们实现程序。

nonrest/src/main/java/payroll/PayrollApplication.java

package payroll;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class PayrollApplication {

  public static void main(String... args) {
    SpringApplication.run(PayrollApplication.class, args);
  }
}

@SpringBootApplication 是一个元注释,用于引入组件扫描(component scanning)、自动配置(autoconfiguration)和属性支持( property support)。在本教程中,我们不会深入研究 Spring Boot 的细节,但从本质上讲,它将启动一个 servlet 容器并提供我们的服务。

尽管如此,一个没有数据的应用程序并不是很有趣,所以让我们来预加载它。以下类将被 Spring 自动加载:

nonrest/src/main/java/payroll/LoadDatabase.java

package payroll;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class LoadDatabase {

  private static final Logger log = LoggerFactory.getLogger(LoadDatabase.class);

  @Bean
  CommandLineRunner initDatabase(EmployeeRepository repository) {

    return args -> {
      log.info("Preloading " + repository.save(new Employee("Bilbo Baggins", "burglar")));
      log.info("Preloading " + repository.save(new Employee("Frodo Baggins", "thief")));
    };
  }
}

当它被加载时会发生什么?

  • 一旦应用程序上下文被加载,Spring Boot 将运行所有的 CommandLineRunner Bean

  • 这个运行器将请求一个你刚刚创建的 EmployeeRepository 的副本。

  • 使用该副本,该副本将创建两个实体并存储它们。

右键单击并运行 PayRollApplication ,就会得到如下结果:

显示预加载数据的控制台输出片段:

...
2018-08-09 11:36:26.169  INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=1, name=Bilbo Baggins, role=burglar)
2018-08-09 11:36:26.174  INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=2, name=Frodo Baggins, role=thief)
...

这并不是整个日志,只是预加载数据的关键部分。 (实际上,请查看整个控制台,这是值得称赞的。)

原句:This isn’t the whole log, but just the key bits of preloading data. (Indeed, check out the whole console. It’s glorious.)

HTTP 是平台

为了使用 Web 层来包装你的资源库,你必须使用 Spring MVC 。多亏了 Spring Boot ,我们可以专注于行动,无需过多的关注底层基础设施的搭建。

nonrest/src/main/java/payroll/EmployeeController.java

package payroll;

import java.util.List;

import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;

@RestController
class EmployeeController {

  private final EmployeeRepository repository;

  EmployeeController(EmployeeRepository repository) {
    this.repository = repository;
  }


  // Aggregate root
  // tag::get-aggregate-root[]
  @GetMapping("/employees")
  List<Employee> all() {
    return repository.findAll();
  }
  // end::get-aggregate-root[]

  @PostMapping("/employees")
  Employee newEmployee(@RequestBody Employee newEmployee) {
    return repository.save(newEmployee);
  }

  // Single item
  
  @GetMapping("/employees/{id}")
  Employee one(@PathVariable Long id) {
    
    return repository.findById(id)
      .orElseThrow(() -> new EmployeeNotFoundException(id));
  }

  @PutMapping("/employees/{id}")
  Employee replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) {
    
    return repository.findById(id)
      .map(employee -> {
        employee.setName(newEmployee.getName());
        employee.setRole(newEmployee.getRole());
        return repository.save(employee);
      })
      .orElseGet(() -> {
        newEmployee.setId(id);
        return repository.save(newEmployee);
      });
  }

  @DeleteMapping("/employees/{id}")
  void deleteEmployee(@PathVariable Long id) {
    repository.deleteById(id);
  }
}
  • @RestController 表示每个方法返回的数据将直接写入响应体(response body)中,而不是渲染成一个模板。
  • EmployeeRepository 通过构造函数被注入到控制器(Controller)中。
  • 我们为每个操作(@GetMapping@PostMapping@PutMapping@DeleteMapping,对应于 HTTP GET、POST、PUT 和 DELETE 调用)设置了路由。 (NOTE:阅读每种方法并了解它们的作用对你是有很帮助的。)
  • EmployeeNotFoundException 是一个异常,用于表示当查询一个雇员但没有找到时。

nonrest/src/main/java/payroll/EmployeeNotFoundException.java

package payroll;

class EmployeeNotFoundException extends RuntimeException {

  EmployeeNotFoundException(Long id) {
    super("Could not find employee " + id);
  }
}

当抛出 EmployeeNotFoundException 时,使用这个额外的 Spring MVC 配置来呈现 HTTP 404:

nonrest/src/main/java/payroll/EmployeeNotFoundAdvice.java

package payroll;

import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseBody;
import org.springframework.web.bind.annotation.ResponseStatus;

@ControllerAdvice
class EmployeeNotFoundAdvice {

  @ResponseBody
  @ExceptionHandler(EmployeeNotFoundException.class)
  @ResponseStatus(HttpStatus.NOT_FOUND)
  String employeeNotFoundHandler(EmployeeNotFoundException ex) {
    return ex.getMessage();
  }
}
  • @ResponseBody表示该 advice 将被直接呈现到响应体(ResponseBody)中。
  • @ExceptionHandler 将 advice 配置为只在抛出 EmployeeNotFoundException 时作出响应。
  • @ResponseStatus 表示发出 HttpStatus.NOT_FOUND ,即一个HTTP 404。
  • advice 的主要内容。在这种情况下,它会给出异常消息。

原文:The body of the advice generates the content. In this case, it gives the message of the exception.

要启动应用程序,可以在 PayRollApplication 中右键单击 public static void main ,并选择从 IDE 运行( Run from your IDE),或者:

若 Spring Initializr 使用了 maven 包装,请输入这个:

$ ./mvnw clean spring-boot:run

或者用你安装的 maven 输入以下内容:

$ mvn clean spring-boot:run

当应用程序启动时,我们可以立即对其进行询问。

$ curl -v localhost:8080/employees

以下内容将被呈现:

*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /employees HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: application/json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Thu, 09 Aug 2018 17:58:00 GMT
<
* Connection #0 to host localhost left intact
[{"id":1,"name":"Bilbo Baggins","role":"burglar"},{"id":2,"name":"Frodo Baggins","role":"thief"}]

在这里,你可以看到压缩格式的预加载数据。

如果你试图查询一个不存在的用户:

$ curl -v localhost:8080/employees/99

你将得到以下内容:

*   Trying ::1...
* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> GET /employees/99 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 404
< Content-Type: text/plain;charset=UTF-8
< Content-Length: 26
< Date: Thu, 09 Aug 2018 18:00:56 GMT
<
* Connection #0 to host localhost left intact
Could not find employee 99

这条信息很好地显示了一个 HTTP 404 错误与自定义信息 Could not find employee 99 。

显示当前代码的交互并不难。

如果您使用Windows命令提示符发出cURL命令,下面的命令可能无法正常工作。您必须选择支持单引号参数的终端,或者使用双引号,然后转义JSON中的参数。

笔者note:只需将 :

$ curl -X POST localhost:8080/employees -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}'

改为这样即可:

$ curl -X POST localhost:8080/employees -H "Content-type:application/json" -d ""{\"name\": \"Samwise Gamgee\", \"role\": \"gardener\"}"

为了创建一条新的雇员记录,我们在终端中使用以下命令–开头的$表示后面的内容是终端命令。

(以下遵循原 guide 上代码,如果是 windows 系统 cmd 或 Conemu,请参考笔者上面的转义方式)

$ curl -X POST localhost:8080/employees -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}'

然后,它存储新创建的雇员并将其送回给我们。

{"id":3,"name":"Samwise Gamgee","role":"gardener"}

你可以更新用户。让我们改变他的角色(role)。

$ curl -X PUT localhost:8080/employees/3 -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}'

我们可以从输出中看到变化。

{"id":3,"name":"Samwise Gamgee","role":"ring bearer"}

您构建服务的方式可能会对系统产生重大影响。在这种情景下,我们说的是更新(update),但其实替换(replace)是更贴切的描述。例如,如果服务本身并没有提供名称,我们这样做将会返回空值。

最后,你可以这样删除用户:

$ curl -X DELETE localhost:8080/employees/3

# Now if we look again, it's gone
$ curl localhost:8080/employees/3
Could not find employee 3

这很好,但是我们有 RESTful 服务吗?(如果你没有领会其中的暗示,答案是没有。)

缺少了什么?

什么要素可以使服务变得 RESTful?

到目前为止,你已经拥有了一个基于 web 的服务,该服务处理涉及员工数据的核心操作。但这还不足以让一切变得“RESTful”。

  • /employees/3 这样漂亮的 url 不是 REST 。

  • 仅仅使用 GET、POST 等方法,这不是REST。

  • 把所有的 CRUD 操作摆出来也不是 REST。

事实上,到目前为止,我们所建立的东西被称做 RPC(远程程序调用)更好。这是因为没有办法知道如何与这个服务互动。如果你今天发布这个服务,你还得写一份文档,或者在某个地方托管一个开发人员的门户网站,其中包含项目的所有细节。

Roy Fielding的这句话可能会进一步揭示REST和RPC之间的区别:

我是越来越失望了,许多人把任何基于 HTTP 的接口称作 REST API ,眼前的例子就是 SocialSite REST API 。那是RPC,实实在在的RPC。它与显示如此耦合,再差也莫过于此。

基于超文本概念,如何才能确保清晰的REST架构风格呢?这样来说吧,如果应用程序状态引擎(即API)不是由超文本驱动的,那就不是RESTful也不是REST的API。就这么简单。某些REST方面的破手册是否该修正一下呢?

翻译参考:https://blog.csdn.net/weixin_43318367/article/details/108745724

原文:

I am getting frustrated by the number of people calling any HTTP-based interface a REST API. Today’s example is the SocialSite REST API. That is RPC. It screams RPC. There is so much coupling on display that it should be given an X rating.

What needs to be done to make the REST architectural style clear on the notion that hypertext is a constraint? In other words, if the engine of application state (and hence the API) is not being driven by hypertext, then it cannot be RESTful and cannot be a REST API. Period. Is there some broken manual somewhere that needs to be fixed?

— Roy Fielding
https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven

在我们的表示中不包含超媒体的副作用是客户端必须硬编码 uri 来导航 API 。这导致了我们的服务与网络电子商务兴起之前的网站具有相同的脆弱性。这表明我们的JSON输出需要一些修改。

原文:The side effect of NOT including hypermedia in our representations is that clients MUST hard code URIs to navigate the API. This leads to the same brittle nature that predated the rise of e-commerce on the web. It’s a signal that our JSON output needs a little help.

接下来介绍一下 Spring HATEOAS ,这是一个 Spring 项目,它旨在帮助你编写超媒体驱动的输出( hypermedia-driven outputs)。为将你的服务升级为 RESTful ,请将它添加到你的构建中:

将 Spring HATEOAS 添加到 pom.xml 的依赖项部分:

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

这个迷你库将为我们提供定义 RESTful 服务的构造,然后以客户端可接受的格式呈现服务。

任何基于 rest 的服务的关键组成部分都是 添加相关操作的链接。为了让你的控制器(controller)更 RESTful,添加如下链接:

获取单个项目资源:

@GetMapping("/employees/{id}")
EntityModel<Employee> one(@PathVariable Long id) {

  Employee employee = repository.findById(id) //
      .orElseThrow(() -> new EmployeeNotFoundException(id));

  return EntityModel.of(employee, //
      linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel(),
      linkTo(methodOn(EmployeeController.class).all()).withRel("employees"));
}

本教程基于Spring MVC,使用 WebMvcLinkBuilder 的静态辅助方法(the static helper methods)来构建这些链接。如果你在你的项目中使用 Spring WebFlux,那你必须使用 WebFluxLinkBuilder

这和我们之前的做的事情非常相似,但仍有一些细微的变化:

  • 方法的返回类型从 Employee 更改为 EntityModel<Employee>EntityModel<T>是一个来自 Spring HATEOAS 的通用容器,它不仅包含数据,还包含一组链接。
  • linkTo(methodOn(EmployeeController.class).one(id)).withselfrel()要求Spring HATEOAS构建到EmployeeController one() 方法的链接,并将其标记为 self 链接。
  • linkTo(methodOn(employeecontrolle.class).all()).withrel("employees")请求 Spring HATEOAS 构建到聚合根 连接,all() ,并将其称为 “employees”。

我们所说的“建立链接”是什么意思?Spring HATEOAS 的核心类型之一是 Link 。它包括一个 URI 和一个 rel (关系)。链接赋予网络力量。在万维网出现之前,其他的文档系统会呈现信息或链接,但正是将文档与这种关系元数据(relationship metadata )链接在一起,网络才连接至一起。

原文:What do we mean by “build a link”? One of Spring HATEOAS’s core types is Link. It includes a URI and a rel (relation). Links are what empower the web. Before the World Wide Web, other document systems would render information or links, but it was the linking of documents WITH this kind of relationship metadata that stitched the web together.

Roy Fielding 鼓励使用使网络成功的相同技术来构建 api ,链接(links)就是其中之一。

如果你现在重启应用程序并查询 Bilbo 的员工记录,你会得到一个与之前稍有不同的响应:

如何让 curl 命令看着更舒服一些

当你的curl 输出变得更加复杂,它会变得难以阅读。使用这个或其他技巧来美化 curl 返回的 json :

# The indicated part pipes the output to json_pp and asks it to make your JSON pretty. (Or use whatever tool you like!)
#                                  v------------------v
curl -v localhost:8080/employees/1 | json_pp

单个员工的 RESTful 表示

{
  "id": 1,
  "name": "Bilbo Baggins",
  "role": "burglar",
  "_links": {
    "self": {
      "href": "http://localhost:8080/employees/1"
    },
    "employees": {
      "href": "http://localhost:8080/employees"
    }
  }
}

这个接触压缩格式后的输出不仅显示了前面看到的数据元素( id、name 和 role ),还显示了一个包含两个 URI 的 links 条目。整个文档使用 HAL 格式化。

HAL 是一种轻量级的媒介类型,不仅可以对数据进行编码,还可以对超媒体控制进行编码,提醒消费者可以浏览到 API 的其他部分。在这种情况下,有一个 “self” 链接(有点像代码中的 this 语句)和一个回到聚合根(aggregate root)的链接。

为了使聚合根也更加RESTful,需要包括顶层链接,同时也要包括其中的任何RESTful组件。

所以我们将之前的代码

获取聚合根:

@GetMapping("/employees")
List<Employee> all() {
  return repository.findAll();
}

变成

获取聚合根资源:

@GetMapping("/employees")
CollectionModel<EntityModel<Employee>> all() {

  List<EntityModel<Employee>> employees = repository.findAll().stream()
      .map(employee -> EntityModel.of(employee,
          linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(),
          linkTo(methodOn(EmployeeController.class).all()).withRel("employees")))
      .collect(Collectors.toList());

  return CollectionModel.of(employees, linkTo(methodOn(EmployeeController.class).all()).withSelfRel());
}

wow! 这个方法,以前只返回了 repository.findAll(),而现在已经成熟了! 不用担心。现在让我们来对它进行拆解。

  • CollectionModel<>是另一个 Spring HATEOAS 容器;它的目标是封装资源集合——而不是单个资源实体,如前面的EntityModel<>

  • CollectionModel<>,也允许你包括链接。

    不要漏掉了第一次的申明!“ 封装集合 ” 是什么意思?封装员工的集合么?

不完全是

因为我们讨论的是 REST ,所以它应该封装员工资源的集合。

这就是为什么你要获取所有的雇员信息,但随后将其转化为 EntityModel<Employee> 对象的列表。(感谢Java 8 Streams!)

如果你重新启动应用程序并获取聚合根,你可以看到它现在是什么样子。

雇员资源集合的 RESTful 表示如下:

{
  "_embedded": {
    "employeeList": [
      {
        "id": 1,
        "name": "Bilbo Baggins",
        "role": "burglar",
        "_links": {
          "self": {
            "href": "http://localhost:8080/employees/1"
          },
          "employees": {
            "href": "http://localhost:8080/employees"
          }
        }
      },
      {
        "id": 2,
        "name": "Frodo Baggins",
        "role": "thief",
        "_links": {
          "self": {
            "href": "http://localhost:8080/employees/2"
          },
          "employees": {
            "href": "http://localhost:8080/employees"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/employees"
    }
  }
}

这个提供员工资源集合的聚合根有一个顶级的“self”链接。该 “集合” 被列在 “_embedded” 部分下方;这就是 HAL 表示集合的方式。

集合中的每个成员都有自己的信息和相关链接。

添加所有这些链接的意义是什么?它使得 REST 服务的发展成为可能。维护现有的链接的同时也可以在将来添加新的链接。较新的客户机可以利用新的链接,而遗留客户机可以在旧链接上维持自身。如果服务被重新定位和移动,这些设定将派上大用场。只要保持链接结构,客户端仍然可以查找并与事物进行交互。

简化创建链接

在前面的代码中,你是否注意到在单个雇员链接的创建中存在重复现象?为一个雇员提供单一链接的代码,以及为聚合根创建 “employees” 链接的代码,被显示了两次。如果这引起了你的关注,很好!这有一个解决方案。

简单地说,你需要定义一个函数,将 Employee 对象转换为 EntityModel<Employee> 对象。虽然你可以很容易地自己编写这个方法,但实现 Spring HATEOAS 的 RepresentationModelAssembler 接口有很多优点,它将为你完成这项工作。

evolution/src/main/java/payroll/EmployeeModelAssembler.java

package payroll;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
import org.springframework.stereotype.Component;

@Component
class EmployeeModelAssembler implements RepresentationModelAssembler<Employee, EntityModel<Employee>> {

  @Override
  public EntityModel<Employee> toModel(Employee employee) {

    return EntityModel.of(employee, //
        linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(),
        linkTo(methodOn(EmployeeController.class).all()).withRel("employees"));
  }
}

这个简单的接口有一个方法:toModel()。它基于将一个非模型对象(Employee)转换成一个基于模型的对象(EntityModel<Employee>)。

你之前在控制器中看到的所有代码都可以移到这个类中。而通过应用 Spring 框架的 @Component 注解,当应用程序启动时,装配器将被自动创建。

Spring HATEOAS 的所有模型的抽象基类是 RepresentationModel 。但为简单起见,我建议使用 EntityModel 作为您的机制,以便轻松地将所有 POJO 包装为模型。

为了利用这个汇编器(assembler),你只需要在构造函数中注入汇编器(assembler),就可以改变 EmployeeController 。

将 EmployeeModelAssembler 注入到控制器中

@RestController
class EmployeeController {

  private final EmployeeRepository repository;

  private final EmployeeModelAssembler assembler;

  EmployeeController(EmployeeRepository repository, EmployeeModelAssembler assembler) {

    this.repository = repository;
    this.assembler = assembler;
  }

  ...

}

在这里,你可以在单一的 employee 方法中使用这个汇编器:

使用汇编器获取单个项资源

@GetMapping("/employees/{id}")
EntityModel<Employee> one(@PathVariable Long id) {

  Employee employee = repository.findById(id) //
      .orElseThrow(() -> new EmployeeNotFoundException(id));

  return assembler.toModel(employee);
}

这段代码几乎是一样的,只是你没有在这里创建 EntityModel<Employee> 实例,而是将其委托给了汇编器。也许这看起来并不起眼。

在聚合根(the aggregate root)控制器方法中使用同样的操作,加深我们对该汇编器的认识。

使用汇编程序获取聚合根(the aggregate root)资源:

@GetMapping("/employees")
CollectionModel<EntityModel<Employee>> all() {

  List<EntityModel<Employee>> employees = repository.findAll().stream() //
      .map(assembler::toModel) //
      .collect(Collectors.toList());

  return CollectionModel.of(employees, linkTo(methodOn(EmployeeController.class).all()).withSelfRel());
}

代码几乎没变,只不过你可以用 map(assemblyler::toModel) 来代替所有的 EntityModel<Employee> 创建逻辑。多亏了 Java 8 的方法引用,你可以很容易地把它插入并简化你的控制器。

Spring HATEOAS 的一个关键设计目标是让做正确的事情变得更容易。在这个场景中: 在不进行任何硬编码的情况下向您的服务中添加超媒体。

在这个阶段,您已经创建了一个Spring MVC REST控制器,它实际生成了超媒体支持的内容!不使用HAL语言的客户在使用纯数据时可以忽略额外的位。使用HAL语言的客户可以导航至您授权的API。

但这并不是用 Spring 构建一个真正的 RESTful 服务所需要的唯一东西。

不断进化的 REST APIs

通过一个额外的库和几行额外的代码,你已经为你的应用程序添加了超媒体。但这并不是使你的服务成为 RESTful 的唯一需要的东西。REST 的一个重要方面是,它既不是一个技术栈,也不是一个单一的标准。

REST 是一组体系结构约束的集合,采用这些约束可以使应用程序更具弹性。弹性的一个关键因素是,当您升级迭代您的服务时,您的客户不会遭受停机的痛苦。

在“过去”的日子里,升级以破坏客户而臭名昭著。换句话说,对服务器的升级需要对客户机的更新。在当今这个时代,花费数小时甚至数分钟的停机时间进行升级可能会损失数百万美元的收入。

有些公司要求你向管理层提交一个计划,以尽量减少停机时间。在过去,你可以在周日凌晨2点进行升级,此时负载最小。但在当今以互联网为基础的电子商务中,与其他时区的国际客户打交道,这种策略就不那么有效了。

基于 SOAP 的服务和基于 CORBA 的服务非常脆弱。推出同时支持新老客户端的服务器是很困难的。有了基于 rest 的实践,这就简单多了。特别是使用Spring堆栈。

支持对 API 的修改

想象一下这样一个设计问题:你已经推出了一款具有这种基于雇员记录的系统。该系统是一个大热门。你已经把你的系统卖给了无数的企业。突然间,你需要将雇员的名字分成 firstName 和 lastName 。

oh, 我没考虑到这一点。

在您打开 Employee 类并将单个字段名称替换为 firstName 和 lastName 之前,请停下来想一想。这会打断任何用户吗?升级它们需要多长时间。您能够控制所有访问您服务的客户端吗?

停机时间 = 损失金钱。管理层准备好接受这种情况了么?

有一个比 REST 早很多年的老策略:

永远不要删除数据库中的一个列。 – 无名

你总是可以在数据库表中增加列(字段)。但不要删除一列。RESTful 服务中的原则也是如此。

在你的JSON表述中添加新的字段,但不要删除任何字段。像这样:

支持多个客户端的JSON:

{
  "id": 1,
  "firstName": "Bilbo",
  "lastName": "Baggins",
  "role": "burglar",
  "name": "Bilbo Baggins",
  "_links": {
    "self": {
      "href": "http://localhost:8080/employees/1"
    },
    "employees": {
      "href": "http://localhost:8080/employees"
    }
  }
}

注意这个格式如何显示 firstName、lastName 和 name 。虽然它强调信息的重复,但其目的是同时支持新老客户。这意味着您可以在不要求客户端同时升级的情况下升级服务器。这是一个可以减少停机时间的好方法。

而且,你不仅应该以 "旧方式 "和 "新方式 "显示这些信息,你还应该以两种方式处理传入的数据。

怎么做?很简单。像这样。

同时处理 "老 "和 "新 "客户的雇员记录:

package payroll;

import java.util.Objects;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;

@Entity
class Employee {

  private @Id @GeneratedValue Long id;
  private String firstName;
  private String lastName;
  private String role;

  Employee() {}

  Employee(String firstName, String lastName, String role) {

    this.firstName = firstName;
    this.lastName = lastName;
    this.role = role;
  }

  public String getName() {
    return this.firstName + " " + this.lastName;
  }

  public void setName(String name) {
    String[] parts = name.split(" ");
    this.firstName = parts[0];
    this.lastName = parts[1];
  }

  public Long getId() {
    return this.id;
  }

  public String getFirstName() {
    return this.firstName;
  }

  public String getLastName() {
    return this.lastName;
  }

  public String getRole() {
    return this.role;
  }

  public void setId(Long id) {
    this.id = id;
  }

  public void setFirstName(String firstName) {
    this.firstName = firstName;
  }

  public void setLastName(String lastName) {
    this.lastName = lastName;
  }

  public void setRole(String role) {
    this.role = role;
  }

  @Override
  public boolean equals(Object o) {

    if (this == o)
      return true;
    if (!(o instanceof Employee))
      return false;
    Employee employee = (Employee) o;
    return Objects.equals(this.id, employee.id) && Objects.equals(this.firstName, employee.firstName)
        && Objects.equals(this.lastName, employee.lastName) && Objects.equals(this.role, employee.role);
  }

  @Override
  public int hashCode() {
    return Objects.hash(this.id, this.firstName, this.lastName, this.role);
  }

  @Override
  public String toString() {
    return "Employee{" + "id=" + this.id + ", firstName='" + this.firstName + '\'' + ", lastName='" + this.lastName
        + '\'' + ", role='" + this.role + '\'' + '}';
  }
}

这个类与以前版本的 Employee 非常相似。让我们来看看这些更改:

  • 字段 name 已被 firstNamelastName 替换。
  • 定义了一个用于旧名称属性的 “虚拟” getter,getName()。它使用 firstName 和 lastName 字段来产生一个值。
  • 还定义了旧 name 属性的 “虚拟” setter,setName()。它解析传入的字符串并将其存储到适当的字段中。

当然,并不是每个 API 的更改都像拆分字符串或合并两个字符串那样简单。但是,对于大多数场景,提出一组转换肯定不是不可能的,对吗?

不要忘记去改变你预加载数据库的方式(在 LoadDatabase 中),以使用这个新的构造函数。

log.info("Preloading " + repository.save(new Employee("Bilbo", "Baggins", "burglar")));
log.info("Preloading " + repository.save(new Employee("Frodo", "Baggins", "thief")));

合适的响应

另一个正确的步骤是确保你的每个 REST 方法都能返回一个正确的响应。像这样更新 POST 方法:

处理“旧”和“新”客户端请求的POST:

@PostMapping("/employees")
ResponseEntity<?> newEmployee(@RequestBody Employee newEmployee) {

  EntityModel<Employee> entityModel = assembler.toModel(repository.save(newEmployee));

  return ResponseEntity //
      .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()) //
      .body(entityModel);
}
  • 新的 Employee 对象和以前一样被保存。但所产生的对象是用 EmployeeModelAssembler 来包装的。

  • Spring MVC 的 ResponseEntity 被用来创建一个 HTTP 201 Created 状态消息。这种类型的响应通常包括一个 Location 响应头,并且我们使用从模型的自相关链接派生的 URI 。

  • 另外,返回保存对象的基于模型的版本。

有了这些调整,你可以使用同一个端点来创建一个新的雇员资源,并使用传统的 name 字段:

$ curl -v -X POST localhost:8080/employees -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}'

输出结果如下所示:

> POST /employees HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type:application/json
> Content-Length: 46
>
< Location: http://localhost:8080/employees/3
< Content-Type: application/hal+json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Fri, 10 Aug 2018 19:44:43 GMT
<
{
  "id": 3,
  "firstName": "Samwise",
  "lastName": "Gamgee",
  "role": "gardener",
  "name": "Samwise Gamgee",
  "_links": {
    "self": {
      "href": "http://localhost:8080/employees/3"
    },
    "employees": {
      "href": "http://localhost:8080/employees"
    }
  }
}

这不仅使所产生的对象以 HAL 形式呈现 ( 包括名称以及firstName/lastName ),而且还使 Location 头填充了 http://localhost:8080/employees/3 。一个超媒体驱动的客户端可以选择 " 冲浪( surf )" 到这个新资源,并继续与之互动。

PUT 控制器方法也需要类似的调整:

为不同的客户处理一个 PUT :

@PutMapping("/employees/{id}")
ResponseEntity<?> replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) {

  Employee updatedEmployee = repository.findById(id) //
      .map(employee -> {
        employee.setName(newEmployee.getName());
        employee.setRole(newEmployee.getRole());
        return repository.save(employee);
      }) //
      .orElseGet(() -> {
        newEmployee.setId(id);
        return repository.save(newEmployee);
      });

  EntityModel<Employee> entityModel = assembler.toModel(updatedEmployee);

  return ResponseEntity //
      .created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()) //
      .body(entityModel);
}

然后,使用 EmployeeModelAssembler 将从 save() 操作构建的 Employee 对象包装为一个 EntityModel<Employee> 对象。使用getRequiredLink()方法,您可以检索由 EmployeeModelAssemblerSELF rel 创建的链接。该方法返回一个必须用 toUri 方法转换为 URI 的链接。

由于我们想要一个比 200 OK 更详细的 HTTP 响应代码,我们将使用 Spring MVC 的 ResponseEntity 包装器。它有一个方便的静态方法 created() ,我们可以把资源的 URI 插进去。HTTP 201 Created 的语义是否正确值得商榷,因为我们不一定要 "创建 "一个新资源。但它预装了一个 Location 响应头,所以要用它来运行。

$ curl -v -X PUT localhost:8080/employees/3 -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}'

* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> PUT /employees/3 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
> Content-Type:application/json
> Content-Length: 49
>
< HTTP/1.1 201
< Location: http://localhost:8080/employees/3
< Content-Type: application/hal+json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Fri, 10 Aug 2018 19:52:56 GMT
{
	"id": 3,
	"firstName": "Samwise",
	"lastName": "Gamgee",
	"role": "ring bearer",
	"name": "Samwise Gamgee",
	"_links": {
		"self": {
			"href": "http://localhost:8080/employees/3"
		},
		"employees": {
			"href": "http://localhost:8080/employees"
		}
	}
}

该员工资源现在已被更新,位置 URI 已被发回。最后,适当地更新 DELETE 操作:

处理删除请求:

@DeleteMapping("/employees/{id}")
ResponseEntity<?> deleteEmployee(@PathVariable Long id) {

  repository.deleteById(id);

  return ResponseEntity.noContent().build();
}

这将返回一个 HTTP 204 No Content 响应。

$ curl -v -X DELETE localhost:8080/employees/1

* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> DELETE /employees/1 HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 204
< Date: Fri, 10 Aug 2018 21:30:26 GMT

您现在已经准备好进行升级,这不会影响现有的客户,而新的客户可以利用增强的功能。

顺便问一下,您是否担心通过网络发送过多的信息?在一些每个字节都很重要的系统中,api 的进化可能需要退居次要地位。但是,在进行度量之前,不要追求这种过早的优化。

将链接构建到REST API中

到目前为止,您已经构建了一个带有基本链接的可演化API。为了发展API并更好地服务于客户端,你需要接受超媒体作为应用状态引擎的概念。

那是什么意思?在本节中,你将详细地探讨它。

业务逻辑不可避免地会建立起涉及流程的规则。这种系统的风险在于我们经常把这种服务器端的逻辑带到客户端,并建立起强耦合。REST 就是要打破这样的连接,把这样的耦合降到最低。

为了演示如何在不触发客户端中断更改的情况下处理状态更改,请想象添加一个执行订单的系统。

作为第一步,定义一个 Order 记录:

links/src/main/java/payroll/Order.java

package payroll;

import java.util.Objects;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;

@Entity
@Table(name = "CUSTOMER_ORDER")
class Order {

  private @Id @GeneratedValue Long id;

  private String description;
  private Status status;

  Order() {}

  Order(String description, Status status) {

    this.description = description;
    this.status = status;
  }

  public Long getId() {
    return this.id;
  }

  public String getDescription() {
    return this.description;
  }

  public Status getStatus() {
    return this.status;
  }

  public void setId(Long id) {
    this.id = id;
  }

  public void setDescription(String description) {
    this.description = description;
  }

  public void setStatus(Status status) {
    this.status = status;
  }

  @Override
  public boolean equals(Object o) {

    if (this == o)
      return true;
    if (!(o instanceof Order))
      return false;
    Order order = (Order) o;
    return Objects.equals(this.id, order.id) && Objects.equals(this.description, order.description)
        && this.status == order.status;
  }

  @Override
  public int hashCode() {
    return Objects.hash(this.id, this.description, this.status);
  }

  @Override
  public String toString() {
    return "Order{" + "id=" + this.id + ", description='" + this.description + '\'' + ", status=" + this.status + '}';
  }
}
  • 该类需要一个 JPA @Table 注释将表的名称更改为 CUSTOMER_ORDER ,因为 ORDER 不是表的有效名称。
  • 它包括一个 description 字段和一个 status 字段。
  • 从客户提交订单并完成或取消订单开始,订单必须经历一系列状态转换。这可以被捕获为一个Java enum

links/src/main/java/payroll/Status.java

package payroll;

enum Status {

  IN_PROGRESS, //
  COMPLETED, //
  CANCELLED
}

这个枚举捕获 Order 可以占据的各种状态。对于本教程,让我们保持简单.

为了支持与数据库中的订单交互,你必须定义一个相应的 Spring Data 存储库:

Spring Data JPA 的 jarepository 基本接口:

links/src/main/java/payroll/OrderController.java

@RestController
class OrderController {

  private final OrderRepository orderRepository;
  private final OrderModelAssembler assembler;

  OrderController(OrderRepository orderRepository, OrderModelAssembler assembler) {

    this.orderRepository = orderRepository;
    this.assembler = assembler;
  }

  @GetMapping("/orders")
  CollectionModel<EntityModel<Order>> all() {

    List<EntityModel<Order>> orders = orderRepository.findAll().stream() //
        .map(assembler::toModel) //
        .collect(Collectors.toList());

    return CollectionModel.of(orders, //
        linkTo(methodOn(OrderController.class).all()).withSelfRel());
  }

  @GetMapping("/orders/{id}")
  EntityModel<Order> one(@PathVariable Long id) {

    Order order = orderRepository.findById(id) //
        .orElseThrow(() -> new OrderNotFoundException(id));

    return assembler.toModel(order);
  }

  @PostMapping("/orders")
  ResponseEntity<EntityModel<Order>> newOrder(@RequestBody Order order) {

    order.setStatus(Status.IN_PROGRESS);
    Order newOrder = orderRepository.save(order);

    return ResponseEntity //
        .created(linkTo(methodOn(OrderController.class).one(newOrder.getId())).toUri()) //
        .body(assembler.toModel(newOrder));
  }
}
  • 它包含与您迄今为止构建的控制器相同的 REST 控制器设置。
  • 它注入了一个 OrderRepository和一个(尚未构建的)OrderModelAssembler
  • 前两个 Spring MVC 路由处理聚合根以及单个项目 ·Order` 资源请求。
  • 第三个 Spring MVC 路由通过在 IN_PROGRESS 状态下启动新订单来处理新订单的创建。
  • 所有的控制器方法都返回一个 Spring HATEOAS 的具象模型子类来正确地呈现超媒体(或围绕这种类型的包装器)。
  • 在构建OrderModelAssembler之前,让我们讨论一下需要发生什么。你正在模拟Status.IN_PROGRESSStatus.COMPLETEDStatus.CANCELLED 之间的状态流。

但这是错误的。

当您在这个流中引入一个新状态时会发生什么? UI上 各种按钮的放置可能是错误的。

如果您更改了每个州的名称,可能是在编码国际支持并显示每个州的区域设置特定文本时会怎样?这很可能会破坏所有客户。

输入 HATEOASHypermedia 作为应用程序的引擎状态。与其让客户机解析有效负载,不如给它们链接以发出有效操作的信号。将基于状态的操作与数据负载解耦。换句话说,当 CANCEL 和 COMPLETE 是有效操作时,动态地将它们添加到链接列表中。当链接存在时,客户端只需要向用户显示相应的按钮。

这样客户端就不必知道这些操作何时有效,从而减少了服务器及其客户端在状态转换逻辑上不同步的风险。

在已经接受了Spring HATEOAS RepresentationModelAssembler 组件的概念之后,将这样的逻辑放在OrderModelAssembler中,这是捕获这一业务规则的完美场所。

links/src/main/java/payroll/OrderModelAssembler.java

package payroll;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;

import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.server.RepresentationModelAssembler;
import org.springframework.stereotype.Component;

@Component
class OrderModelAssembler implements RepresentationModelAssembler<Order, EntityModel<Order>> {

  @Override
  public EntityModel<Order> toModel(Order order) {

    // Unconditional links to single-item resource and aggregate root

    EntityModel<Order> orderModel = EntityModel.of(order,
        linkTo(methodOn(OrderController.class).one(order.getId())).withSelfRel(),
        linkTo(methodOn(OrderController.class).all()).withRel("orders"));

    // Conditional links based on state of the order

    if (order.getStatus() == Status.IN_PROGRESS) {
      orderModel.add(linkTo(methodOn(OrderController.class).cancel(order.getId())).withRel("cancel"));
      orderModel.add(linkTo(methodOn(OrderController.class).complete(order.getId())).withRel("complete"));
    }

    return orderModel;
  }
}

这个资源组装器总是包含到单项资源的 self 链接以及回聚合根的链接。但它还包括两个到 OrderController.cancel(id)OrderController.complete(id) (尚未定义)的条件链接。这些链接只在订单状态为 status . in_progress 时显示。

为了完成订单执行,在 OrderController 中添加以下取消操作:

OrderController 中创建一个 “cancel” 操作:

@DeleteMapping("/orders/{id}/cancel")
ResponseEntity<?> cancel(@PathVariable Long id) {

  Order order = orderRepository.findById(id) //
      .orElseThrow(() -> new OrderNotFoundException(id));

  if (order.getStatus() == Status.IN_PROGRESS) {
    order.setStatus(Status.CANCELLED);
    return ResponseEntity.ok(assembler.toModel(orderRepository.save(order)));
  }

  return ResponseEntity //
      .status(HttpStatus.METHOD_NOT_ALLOWED) //
      .header(HttpHeaders.CONTENT_TYPE, MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE) //
      .body(Problem.create() //
          .withTitle("Method not allowed") //
          .withDetail("You can't cancel an order that is in the " + order.getStatus() + " status"));
}

它在允许取消订单之前检查订单状态。如果它不是一个有效的状态,它将返回 RFC-7807 Problem,这是一个支持超媒体的错误容器。如果转换确实有效,则将 Order 转换为 CANCELLED

并将其添加到 OrderController 中以完成订单:

OrderController 中创建一个“完整的”操作:

@PutMapping("/orders/{id}/complete")
ResponseEntity<?> complete(@PathVariable Long id) {

  Order order = orderRepository.findById(id) //
      .orElseThrow(() -> new OrderNotFoundException(id));

  if (order.getStatus() == Status.IN_PROGRESS) {
    order.setStatus(Status.COMPLETED);
    return ResponseEntity.ok(assembler.toModel(orderRepository.save(order)));
  }

  return ResponseEntity //
      .status(HttpStatus.METHOD_NOT_ALLOWED) //
      .header(HttpHeaders.CONTENT_TYPE, MediaTypes.HTTP_PROBLEM_DETAILS_JSON_VALUE) //
      .body(Problem.create() //
          .withTitle("Method not allowed") //
          .withDetail("You can't complete an order that is in the " + order.getStatus() + " status"));
}

这实现了类似防止订单在非正确状态下完成的逻辑。

原文:This implements similar logic to prevent an Order status from being completed unless in the proper state.

让我们更新 LoadDatabase 来预加载一些 Order 和它之前加载的 Employee 。

更新数据库预加载程序:

package payroll;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
class LoadDatabase {

  private static final Logger log = LoggerFactory.getLogger(LoadDatabase.class);

  @Bean
  CommandLineRunner initDatabase(EmployeeRepository employeeRepository, OrderRepository orderRepository) {

    return args -> {
      employeeRepository.save(new Employee("Bilbo", "Baggins", "burglar"));
      employeeRepository.save(new Employee("Frodo", "Baggins", "thief"));

      employeeRepository.findAll().forEach(employee -> log.info("Preloaded " + employee));

      
      orderRepository.save(new Order("MacBook Pro", Status.COMPLETED));
      orderRepository.save(new Order("iPhone", Status.IN_PROGRESS));

      orderRepository.findAll().forEach(order -> {
        log.info("Preloaded " + order);
      });
      
    };
  }
}

现在您可以进行测试了!

要使用新创建的订单服务,只需执行以下操作:

$ curl -v http://localhost:8080/orders

{
  "_embedded": {
    "orderList": [
      {
        "id": 3,
        "description": "MacBook Pro",
        "status": "COMPLETED",
        "_links": {
          "self": {
            "href": "http://localhost:8080/orders/3"
          },
          "orders": {
            "href": "http://localhost:8080/orders"
          }
        }
      },
      {
        "id": 4,
        "description": "iPhone",
        "status": "IN_PROGRESS",
        "_links": {
          "self": {
            "href": "http://localhost:8080/orders/4"
          },
          "orders": {
            "href": "http://localhost:8080/orders"
          },
          "cancel": {
            "href": "http://localhost:8080/orders/4/cancel"
          },
          "complete": {
            "href": "http://localhost:8080/orders/4/complete"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "http://localhost:8080/orders"
    }
  }
}

该 HAL 文档根据订单的当前状态,立即显示每个订单的不同链接。

  • 第一个订单,是 COMPLETED ,只有导航链接。状态转换链接没有显示。
  • 第二个订单,是 IN_PROGRESS ,另外还有取消(cancel )链接和完整(complete)链接。

试着取消订单:

$ curl -v -X DELETE http://localhost:8080/orders/4/cancel

> DELETE /orders/4/cancel HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 200
< Content-Type: application/hal+json;charset=UTF-8
< Transfer-Encoding: chunked
< Date: Mon, 27 Aug 2018 15:02:10 GMT
<
{
  "id": 4,
  "description": "iPhone",
  "status": "CANCELLED",
  "_links": {
    "self": {
      "href": "http://localhost:8080/orders/4"
    },
    "orders": {
      "href": "http://localhost:8080/orders"
    }
  }
}

这个响应显示了一个 HTTP 200 状态代码,表明它是成功的。响应 HAL 文件显示该订单的新状态(CANCELLED)。而改变状态的链接消失了。

如果你再次尝试同样的操作…

$ curl -v -X DELETE http://localhost:8080/orders/4/cancel

* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> DELETE /orders/4/cancel HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 405
< Content-Type: application/problem+json
< Transfer-Encoding: chunked
< Date: Mon, 27 Aug 2018 15:03:24 GMT
<
{
  "title": "Method not allowed",
  "detail": "You can't cancel an order that is in the CANCELLED status"
}

你将看到一个 HTTP 405 方法不允许的响应。DELETE 已成为无效操作。Problem 响应对象清楚地表明不允许“取消(cancel)”已经处于“CANCELLED”状态的订单。

此外,试图完成同一个订单也会失败:

$ curl -v -X PUT localhost:8080/orders/4/complete

* TCP_NODELAY set
* Connected to localhost (::1) port 8080 (#0)
> PUT /orders/4/complete HTTP/1.1
> Host: localhost:8080
> User-Agent: curl/7.54.0
> Accept: */*
>
< HTTP/1.1 405
< Content-Type: application/problem+json
< Transfer-Encoding: chunked
< Date: Mon, 27 Aug 2018 15:05:40 GMT
<
{
  "title": "Method not allowed",
  "detail": "You can't complete an order that is in the CANCELLED status"
}

有了这一切,你的订单执行服务就能够有条件地显示哪些操作是可用的。它还能防止无效的操作。

通过利用超媒体和链接的协议,可以构建更坚固的客户机,并且不太可能仅仅因为数据更改而中断。Spring HATEOAS 简化了为客户提供服务所需的超媒体的构建。

总结

在本教程中,您学习了构建 REST api 的各种策略。事实证明,REST 不仅仅是关于漂亮的 uri 和返回 JSON 而不是XML。

相反,以下策略有助于使你的服务不太可能破坏你可控或不可控的现有客户。

  • 不要移除旧的域。相反,要支持它们。
  • 使用基于 rel 的链接,这样客户机就不必对 URI 进行硬编码。
  • 尽可能长时间地保留旧链接。即使您必须更改 URI ,也要保留这些参数,以便老客户端有通向新特性的路径。
  • 当各种状态驱动操作可用时,使用链接而不是负载数据来指示客户端。

为每种资源类型构建具象 modelassembler 实现,并在所有控制器中使用这些组件,这似乎要花费一些精力。但是这个额外的服务器端设置(由于 Spring HATEOAS 而变得简单)可以确保您控制的客户端(更重要的是,那些您不控制的客户端)可以随着 API 的发展而轻松升级。

关于如何使用 Spring 构建 RESTful 服务的教程到此结束。本教程的每一部分都是作为一个单独的子项目在一个 github repo 管理:

  • nonrest — 不包含超媒体的简单 spring MVC 程序
  • rest — Spring MVC + Spring HATEOAS 应用程序,其中每个资源都有 HAL 表示。
  • evolution — REST 应用程序,其中一个字段(filed)被更新,但为了向后兼容,旧数据被保留了下来。
  • links — REST 应用程序,其中条件链接被用来向客户发出有效的状态变化信号

要查看更多使用Spring HATEOAS的示例,请参考:https://github.com/spring-projects/spring-hateoas-examples

为了做更多的深入了解,请查看以下由 Spring 的成员 Oliver Gierke 拍摄的视频

想要编写一个新的指南或对现有指南作出贡献?查看我们的贡献指南

所有的指南都是通过ASLv2许可证发布的,以及写作的署名、NoDerivatives 创作共用许可。

  • 0
    点赞
  • 2
    收藏
    觉得还不错? 一键收藏
  • 3
    评论
### 回答1: 使用Spring Boot和Vue构建系统非常容易,可以使用Spring Boot的REST API作为Vue的后端,然后将Vue的前端部署到Spring Boot中。这样,就可以使用Spring Boot和Vue构建功能强大的系统了。 ### 回答2: 使用Spring Boot和Vue构建系统的步骤如下: 1. 首先,在Spring Boot中创建一个新的项目,可以使用Spring Initializr来快速创建基本的项目结构。选择所需的依赖项,例如Web和Spring Boot DevTools。 2. 创建一个Vue项目,并将其与Spring Boot项目集成。可以使用Vue CLI来创建一个新的Vue项目。在创建项目时,选择“手动模式”以自定义项目配置。 3. 在Vue项目的项目根目录下创建一个新的文件夹,名为“src/main/resources/static”。将Vue项目的dist文件夹中的所有内容复制到这个新的文件夹中。 4. 编辑Spring Boot项目的pom.xml文件,添加适当的插件和依赖项以支持Vue项目。例如,添加maven插件来将Vue项目打包到可执行的Spring Boot jar文件中。 5. 在Spring Boot项目的src/main/java目录下创建一个新的包,并在其中创建一个新的Java类。该类将充当后端控制器,并处理与Vue前端的交互。使用Spring MVC注解来定义这个控制器的URL映射。 6. 在Vue项目中,编辑vue.config.js文件以配置Vue的开发和构建选项。设置后端API的代理,以便在开发过程中能够与后端进行交互。 7. 开始开发!可以在Vue项目中创建组件和路由来创建前端页面,并在Spring Boot项目中创建服务和数据访问对象来处理后端逻辑和数据持久化。 8. 构建和部署项目。在开发过程中,可以使用Vue CLI来启动和测试Vue项目。但在部署阶段,可以使用Maven来构建Spring Boot项目,并将其打包成一个可执行的jar文件。 通过以上步骤,你将能够使用Spring Boot和Vue构建一个完整的系统。Spring Boot提供后端支持,处理业务逻辑和数据持久化,而Vue负责前端展示和用户交互。这种分离的架构使得系统更加模块化和易于维护。 ### 回答3: 使用Spring Boot和Vue构建系统可以按照以下步骤进行。 第一步是创建Spring Boot项目,可以使用Spring Initializr快速生成一个空的Spring Boot项目。在创建项目时,需要选择适当的依赖,例如Spring Web和Spring Data JPA等。 第二步是在Spring Boot项目中配置前后端分离的架构。可以将前端和后端代码分别放置在不同的文件夹中,这样可以更好地分离两者的职责。还可以通过配置CORS(跨源资源共享)来允许前端访问后端接口。 第三步是创建Vue项目,可以使用Vue CLI来初始化一个Vue项目。 第四步是在Vue项目中配置代理,将所有与后端接口相关的请求转发到Spring Boot应用程序。可以使用vue.config.js文件来配置代理。 第五步是使用Axios等工具对后端接口进行访问。使用Axios可以发送HTTP请求,并处理响应结果。 第六步是在Vue项目中开发前端页面,并将接口数据展示在页面中。可以使用Vue Router进行页面路由管理,使用Vuex进行全局状态管理。 第七步是将整个Vue项目打包,并将打包后的静态文件放置到Spring Boot项目的resources/static文件夹中。这样,当Spring Boot应用程序启动时,可以直接访问到Vue项目的页面和静态资源。 最后,需要对整个系统进行测试和部署。可以使用JUnit等测试框架对后端接口进行单元测试,使用Postman或Swagger等工具对接口进行集成测试。可以使用Docker等工具将应用程序进行打包,并通过云服务器等方式进行部署和发布。 通过以上步骤,可以使用Spring Boot和Vue构建一个完整的系统,实现前后端分离开发,并保证系统的稳定性和可维护性。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论 3
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值