SpringBoot构建REST服务

文章目录

  • 为什么REST?
  • 开始
  • 管理雇员信息
  • HTTP平台
  • 什么才是REST的?
  • 简化链接创建
  • REST API 演化
  • 支持 API 变更
    • 正确响应
  • 构建指向 REST API 的链接
  • 总结
  • 参考
  • 下载

现在,REST已成为事实上的标准,要构建web服务,使用REST很便于生产和消费,微服务架构也有大量的文献描述如何使用REST,下面就来看看如何创建REST服务。

为什么REST?

表征状态转移(Representional State Transfer),是 Roy Fielding( HTTP 规范的主要编写者之一)博士在 2000 年他的博士论文中提出来的一种软件架构风格。它并不是一个标准,而是通过表征(Representional )来描述传输状态的一种原则。其宗旨是从资源的角度来观察整个网络,分布在各处的资源由 URI 确定,而客户端的应用通过 URI 来获取资源的表征。获得这些表征致使这些应用程序转变了其状态。随着不断获取资源的表征,客户端应用不断地在转变着其状态。

让我们来思考一下:
Marcus 是一个农民,他有 4 头猪,12 只鸡和 3 头奶牛。他现在模拟一个 REST API,而我是客户端。如果我想用 REST 来请求当前的农场状态,我仅会问:“State?”Marcus 会回答:“4 头猪、12 只鸡、3 头奶牛”。
这是 REST 最简单的一个例子。Marcus 使用表征来传输农场状态。表征的句子很简单:“4 头猪、12 只鸡、3 头奶牛”。
再往下看,看我如何让 Marcus 用 REST 方式添加 2 头奶牛?
按照常理,可以会这样说:Marcus,请在农场你再添加 2 头奶牛。难道这就是 REST 方式吗?难道就是通过这样的表征来传输状态的吗?不是的!这是一个远程过程调用,过程是给农场添加 2 头奶牛。
Marcus 很愤怒地响应到:“400,Bad Request”,你到底是什么意思?
所以,让我们重新来一次。我们怎样做到 REST 方式呢?该怎样重新表征呢?它应该是 4 头猪、12 只鸡、3 头奶牛。好,让我们再次重新表征……
我:“Marcus,……4 头猪、12 只鸡、 5 头奶牛!”
Marcus:“好的”。
我:“Marcus,现在是什么状态?”
Marcus:“4 头猪、12 只鸡、5 头奶牛”。
我:“好!”
看到了吗?就这样简单。

REST拥抱Web规范和准则,包括其构架、优点和其他。这并不奇怪,因为Roy Fielding参与了十几个治理Web如何运作的规范。
有什么好处呢?Web和其核心协议,HTTP,提供一系列特征栈:

  • 适当的操作(GET、POST、PUT、DELETE…)
  • 缓存
  • 重定向和转发
  • 安全性(加密和身份验证)

这些都是建立弹性服务的关键,但不是全部。Web是基于大量微小的规范构建的,因此,能够轻松进化而不会陷入“标准之争”。
开发人员能够利用第三方工具包来实现这些不同的规范,并立即将客户端和服务器端技术放在触手可及的地方。
通过在 HTTP 上构建,REST API 提供了如下构建的手段:

  • 向后兼容的 API
  • 可进化的 API
  • 可扩展的服务
  • 可部署的服务
  • 一系列无状态到有状态服务

重要的是要认识到,REST,无论多么无处不在,本身都不是标准,而是一种方法、一种风格、一套对架构的限制,可以帮助您构建Web扩展系统。
这里,将使用 SpringBoot 构建一个 RESTful 服务,同时利用 REST 的无堆叠功能(stackless features)。

开始

打开IDEA,创建新项目,如下图所示:
SpringBoot构建REST服务_第1张图片输入项目参数,点击Next,选择SpringBoot依赖,如下图所示:
SpringBoot构建REST服务_第2张图片进入项目目录,运行 git init 命令,初始化 git 版本库,便于代码管理,如下图所示:
git init

管理雇员信息

从最简单的领域类创建开始,现在要构造一个简单的工资服务,需要相应的雇员信息,将相应的对象存储在 H2 数据库中并提供 JPA 进行访问。
首先,创建雇员类,代码如下:

package cn.lut.curiezhang.payroll;

import java.util.Objects;

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

/**
 * 

Package: cn.lut.curiezhang.payroll

*

Class Name: Employee

*

Description:

* 简单的雇员信息 * * @author Curie Zhang * @version Employee.java v1.0 2021/7/10 18:31 curiezhang */
@Entity public class Employee { private @Id @GeneratedValue Long id; private String name; private String role; public Employee() { } public 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 + '\'' + '}'; } }

SpringBoot JPA 存储库支持创建、读取、更新和删除等后端数据存储记录的方法接口。某些存储库还支持数据分页和排序(在某些情况下)。Spring Data 根据接口中方法命名约定来发现对应的实现。
SpringBoot使得数据访问更加容易,只需要声明接口就能够对雇员信息进行访问,代码如下:

package cn.lut.curiezhang.payroll;

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

/**
 * 

Package: cn.lut.curiezhang.payroll

*

Class Name: EmployeeRepository

*

Description:

* * @author Curie Zhang * @version EmployeeRepository.java v1.0 2021/7/10 18:49 curiezhang */
interface EmployeeRepository extends JpaRepository<Employee, Long> { }

使用上述接口就可以对雇员信息进行增删改查等数据库操作。
现在就可以启动项目了,这里包含了系统自动生成的项目启动类,代码如下:

package cn.lut.curiezhang.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);
    }

}

当然,现在的项目启动,不包含数据,我们可以建立一个初始测试数据类,如下所示:

package cn.lut.curiezhang.payroll;

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

/**
 * 

Package: cn.lut.curiezhang.payroll

*

Class Name: LoadDatabase

*

Description:

* 加载测试数据 * * @author Curie Zhang * @version LoadDatabase.java v1.0 2021/7/11 11:06 curiezhang */
@Configuration public class LoadDatabase { private static final Logger log = LoggerFactory.getLogger(LoadDatabase.class); @Bean CommandLineRunner initDatabase(EmployeeRepository repository) { return args -> { log.info("加载 " + repository.save(new Employee("张三", "经理"))); log.info("加载 " + repository.save(new Employee("李四", "主任"))); }; } }

现在,重修启动程序,首先加载应用上下文后,将运行所有 CommandLineRunner 类,这就会调用 EmployeeRepository 类,使用它来创建 2 个实体并存储起来,从控制台可以看到输出日志,通过 H2 控制台就可以看到数据库中的 EMPLOYEE 表的结果,结果如下图所示:
SpringBoot构建REST服务_第3张图片
因此,使用 SpringBoot 很便于进行数据库访问操作。

HTTP平台

要将存储库与 Web 层进行封装,必须使用 Spring MVC。由于 SpringBoot,几乎不需要进行底层编码。这样,就可以专注于具体的动作。现在,创建雇员的 web 端控制器,如下所示:

package cn.lut.curiezhang.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;

/**
 * 

Package: cn.lut.curiezhang.payroll

*

Class Name: EmployeeController

*

Description:

* 雇员的 web 端控制器 * * @author Curie Zhang * @version EmployeeController.java v1.0 2021/7/11 11:50 curiezhang */
@RestController public class EmployeeController { private final EmployeeRepository repository; EmployeeController(EmployeeRepository repository) { this.repository = repository; } @GetMapping("/employees") List<Employee> all() { return repository.findAll(); } @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.setEmployId(id); return repository.save(newEmployee); }); } @DeleteMapping("/employees/{id}") void deleteEmployee(@PathVariable Long id) { repository.deleteById(id); } }

这里有一个 EmployeeNotFoundException 异常,代码如下:

package cn.lut.curiezhang.payroll;

/**
 * 

Package: cn.lut.curiezhang.payroll

*

Class Name: EmployeeNotFoundException

*

Description:

* 没有找到雇员异常 * * @author Curie Zhang * @version EmployeeNotFoundException.java v1.0 2021/7/11 11:57 curiezhang */
public class EmployeeNotFoundException extends RuntimeException { EmployeeNotFoundException(Long id) { super("没有找到雇员:" + id + "!"); } }

当抛出此异常时,通过 Spring MVC 配置可以显示 HTTP 404。
其实,可以使用控制器建议来提供处理,代码如下:

package cn.lut.curiezhang.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;

/**
 * 

Package: cn.lut.curiezhang.payroll

*

Class Name: EmployeeNotFoundAdvice

*

Description:

* 雇员没有找到异常处理建议 * * @author Curie Zhang * @version EmployeeNotFoundAdvice.java v1.0 2021/7/11 12:08 curiezhang */
@ControllerAdvice class EmployeeNotFoundAdvice { @ResponseBody @ExceptionHandler(EmployeeNotFoundException.class) @ResponseStatus(HttpStatus.NOT_FOUND) String employeeNotFoundHandler(EmployeeNotFoundException ex) { return ex.getMessage(); } }

现在,如果没有找到雇员信息,将直接显示异常信息。
运行代码,就可以在浏览器中访问数据库中的数据了,如下图所示:
SpringBoot构建REST服务_第4张图片
尝试查询不存在的雇员,结果如下:
SpringBoot构建REST服务_第5张图片
可使用 Postman 来创建新雇员,如下图所示:
SpringBoot构建REST服务_第6张图片
当然,也可以使用 curl 命令,但是 Windows 下的 curl 命令可能会出错,就不建议使用了,而且,使用 Postman 非常方便,各种 HTTP 请求都很容易实现,这里不再一一介绍。

什么才是REST的?

目前为止,已经实现了 Employee 数据的基于 Web 的服务核心操作,能够进行 CRUD 操作,但这还不足以达到 REST。

  • 严谨的 URL (例如:/employees/66)不是 REST;
  • 仅仅使用 GET、POST 等不是 REST;
  • 实现所有的 CRUD 操作也不是 REST。

事实上,现在所构建的这些是 RPC(远程过程调用)的,因为,无法知道如何与这些服务进行交互。如果你要发布信息,还必须编写文档或在某个开发者门户中详细说明这些信息。
不讨论超媒体在表示层的副作用,客户必须硬编码 URI 来导航 API,这导致了 Web 的脆弱性,也是 JSON 输出需要改进的信号。
SpringBoot 引入了 HATEOAS 帮助编写超媒体驱动的输出,为此,在项目中进入如下依赖,将服务升级成 REST 的。

<dependency>
	<groupId>org.springframework.bootgroupId>
	<artifactId>spring-boot-starter-hateoasartifactId>
dependency>

下面,为 REST 服务添加相关链接操作,使控制器更加 REST,添加链接如下:

    /**
     * linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel()要求 
     * HATEOAS 建立一个链接到 EmployeeController 的 one() 方法的自链接
     * linkTo(methodOn(EmployeeController.class).all()).withRel("employees") 
     * 要求 HATEOAS 建立一个链接到集合根 all() 方法的链接叫 "employees"
     * 
     * @param id
     * @return EntityModel 是 HATEOAS 的通用容器,不仅包括数据也链接集合
     */
    @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"));
    }

建立链接意味着什么? HATEOAS 的核心类型是链接,包括一个 URI 和一个 rel(关系),链接增强 Web 功能,WWW 之前,其他文档系统会提供信息或链接,但含有这种关系元数据的文档的链接可以和 Web 结合得更好。
重新启动应用,可以得到更有效的展示,如下图所示:
SpringBoot构建REST服务_第7张图片
整个文档可以用 HAL(Hypertxt Application Language)进行格式化。HAL 是轻量级的媒体类型,不仅允许编码数据,还提供超媒体控制,提醒消费者并可导航到 API 的其他部分,就像这里的,自链接以及链接回聚合根。
类似的,获取聚合根:

    /**
     * linkTo(methodOn(EmployeeController.class).one(employee.getEmployId())))
     * .withSelfRel()要求 HATEOAS 建立一个链接到 EmployeeController 的 one() 
     * 方法的自链接
     * linkTo(methodOn(EmployeeController.class).all()).withRel("employees")
     * 要求 HATEOAS 建立一个链接到集合根 all() 方法的链接叫 "employees"
     * 
     * @return CollectionModel>, HATEOAS 
     * 容器,封装资源集合,像 EntityModel 一样,而不是单一资源实体,
     * CollectionModel>也允许包含链接地址。
     */
    @GetMapping("/employees")
    CollectionModel<EntityModel<Employee>> all() {
        List<EntityModel<Employee>> employees = repository.findAll().stream()
                .map(employee -> EntityModel.of(employee,
                        linkTo(methodOn(EmployeeController.class).
                                one(employee.getEmployId())).withSelfRel(),
                        linkTo(methodOn(EmployeeController.class).
                                all()).withRel("employees")))
                .collect(Collectors.toList());

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

这里的处理方法与前面类似,给每个雇员添加一个自链接,顶层封装聚合根。
重新启动应用,可以看到如下结果:
SpringBoot构建REST服务_第8张图片
集合的每个成员都有他们的信息以及相关链接。
添加所有这些链接有什么意义呢?它使 REST 服务能够随着时间的推移而发展。可以维护现有链接,将来可以添加新的链接。新客户可能会利用新链接,而老客户可以在旧链接上维护。如果服务被迁移动,这尤其有益。只要保持链接结构,客户仍然可以找到它并与之互动。

简化链接创建

在前面的代码中,单个雇员链接创建有重复?向雇员提供单个链接,同时创建指向聚合根的“employees”链接,显示了两次。如果这引起了你的关注,这里有一个解决方案。
简单地说,需要定义一个将 Employee 对象转换为 EntityMode l< Employee > 对象的函数。虽然可以自己编写此方法代码,但 Spring HATEOAS 的 RepresentationModelAssembler 接口将完成这项工作,代码如下:

package cn.lut.curiezhang.payroll;

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

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

/**
 * 

Package: cn.lut.curiezhang.payroll

*

Class Name: EmployeeModelAssembler

*

Description:

* * @author Curie Zhang * @version EmployeeModelAssembler.java v1.0 2021/7/12 10:29 curiezhang */
@Component public class EmployeeModelAssembler implements RepresentationModelAssembler<Employee, EntityModel<Employee>> { /** * 将 Employee 对象转换为 EntityModel 对象 * @param employee Employee对象 * @return EntityModel 实体对象 */ @Override public EntityModel<Employee> toModel(Employee employee) { return EntityModel.of(employee, // linkTo(methodOn(EmployeeController.class) .one(employee.getEmployId())).withSelfRel(), linkTo(methodOn(EmployeeController.class) .all()).withRel("employees")); } }

前面控制器中的类似处理都以及提取出来,移到这个类中,通过应用 Spring 框架的 @Component 注解,组装程序将在应用启动时自动创建。
要使用组装器,需要在控制器中注入,代码如下(只有这部分代码):

@RestController
public class EmployeeController {
    private final EmployeeRepository repository;

    private final EmployeeModelAssembler assembler;

    EmployeeController(EmployeeRepository repository,
                       EmployeeModelAssembler assembler) {
        this.repository = repository;
        this.assembler = assembler;
    }

可以使用单项雇员方法中的组装器,获取单个项目资源,代码如下:

    @GetMapping("/employees/{id}")
    EntityModel<Employee> one(@PathVariable Long id) {
        Employee employee = repository.findById(id) //
                .orElseThrow(() -> new EmployeeNotFoundException(id));

        return assembler.toModel(employee);
    }

这段代码和前面没有什么区别,都是创建 EntityModel < Employee > 实例,但是,现在将该工作委托给了组装器。
在聚合根控制器方法中有类似的应用,使用组装器获取聚合根资源,代码如下:

    @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(assembler::toModel) 替换 EntityModel< Employee > 的创建,使用 Java 8 的流可以很方便地插入到控制器中并简化它。
现在,已经创建了一个 Spring MVC REST 控制器,该控制器实际生成超媒体驱动的内容!不支持 HAL 的客户端可以在使用纯数据时忽略附加的位。支持 HAL 的客户端可以浏览授权的 API。但这并不是用 Spring 构建真正的 REST 服务的全部。

REST API 演化

通过添加一个库和几行额外的代码,可以在应用程序中添加超媒体。但这不是 REST 服务的全部。REST 的一个重要方面是,它既不是一个技术栈,也不是一个简单的标准。
REST 是一系列架构约束,当使用时会使应用程序更具弹性。弹性的一个关键因素是,当升级服务时,客端户不会遭受宕机的影响。
早期,升级会对客户造成破坏而臭名昭著。换句话说,服务器升级需要客户端进行更新。在当今这个时代,升级所花费的停机时间可能损失数百万美元。
有些公司如果要您向管理层提交一个尽量减少停机时间的计划。过去,您可以在负载最低的星期日凌晨 2 点进行升级。但是,在当今以互联网为基础的电子商务中,与其他时区的国际客户合作,这种策略并不凑效。
基于 SOAP 的服务和基于 CORBA 的服务是非常脆弱的。很难推出能够支持新旧客户端的服务。而采用基于 REST 的实践,就要容易得多。特别是使用 Spring 技术。

支持 API 变更

想象一下这样一个设计问题:您推出了一个基于 Employee 记录的系统,这个系统遭遇到了巨大打击,你已经把你的系统卖给了无数的企业,突然之间,雇员姓名需要分割成姓和名两部分。
哦,没想到会这样。
在你打开 Employee 类,用 firstName 和 lastName 来取代 name,停下来考虑一会。这会破坏客户端吗?升级它们需要多长时间?您是否可以控制所有访问服务的客户端?
停机时间=亏损,管理层准备好了吗?
在 REST 之前,有一句老话,“切勿删除数据库中的列”。您可以随时将列(字段)添加到数据库表中,但不要拿走一列。服务中的原则是相同的。
在 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 的?虽然这里有重复的信息,目的是支持新旧客户端。这意味着可以在不要求客户端升级的情况下升级服务器,这样应该能减少停机时间。现在,不仅能以"旧方式"和"新方式"显示信息,还应该双向处理传入的数据。下面就看看如何处理"旧"和"新"的客户端的雇员记录,在 Employee 类中添加代码如下:

    private String firstName;
    private String lastName;
    
    public Employee(String firstName, String lastName, String role) {
        this.firstName = firstName;
        this.lastName = lastName;
        this.role = role;
    }

    public String getFirstName() {
        return firstName;
    }

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

    public String getLastName() {
        return lastName;
    }

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

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

这个类和以前的 Employee 版本很相似。下面就来看一下其中的变化:

  • 字段 name 可以被替换为 firstName 和 lastName,如果 name 字段其他地方也在使用,可以新添加 firstName 和 lastName。
  • 定义了旧属性的"虚拟" getter 方法。它使用 firstName 和 lastName 字段生成 name 值。
  • 还定义了旧属性的"虚拟" setter 方法。它解析传入的 name 字符串,并存储到适当的字段。

当然,并不是对 API 的每一次变更都像拆分字符串或合并两个字符串一样简单。当然,对于大多数场景,这是可行的。

正确响应

现在要确保每个 REST 方法都返回适当的响应。更新 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 创建状态消息。此类响应类型通常包括位置响应头,和从模型的自链接中派生的 URI。
  • 返回已保存对象的基于模型的版本。

通过这些改进,可以使用相同的端点创建新的雇员信息,并使用传统的 name 字段,如下图所示:
SpringBoot构建REST服务_第9张图片
这不仅在 HAL 中呈现了结果对象( name 以及 firstName / lastName),而且还用 http://localhost:8080/employees/646 填充位置头。超媒体支持的客户端可以选择"冲浪"到这个资源,并与它互动。
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.setEmployId(id);
                    return repository.save(newEmployee);
                });

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

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

这里,使用 save() 操作创建 Employee 对象,然后用 EmployeeModelAssembler 组装器封装到 EntityModel < Employee > 对象中。使用 getRequiredLink() 方法,可以检索由 EmployeeModelAssembler 创建的带有自链接的 Link,该方法使用 toUri 方法转换成一个 URI 并返回 Link。
要想有一个比 200 OK更详细的 HTTP 响应代码,可以使用 Spring MVC 的 ResponseEntity 封装器。它有一个静态方法 created(),可以建立资源的URI。PUT 操作如下图所示:
SpringBoot构建REST服务_第10张图片
该雇员资源现已更新,URI 的位置已发回。最后,更新删除操作,处理删除请求,代码如下:

    @DeleteMapping("/employees/{id}")
    ResponseEntity<?> deleteEmployee(@PathVariable Long id) {
        Optional<Employee> rec = repository.findById(id);
        if (rec.isPresent()) {
            repository.deleteById(id);
            return ResponseEntity.accepted().body("Success");
        } else {
            return ResponseEntity.notFound().build();
        }
    }

如果操作成果,则返回”Success“,否则返回 404 错误,如下图所示:
SpringBoot构建REST服务_第11张图片
SpringBoot构建REST服务_第12张图片
现在就可以升级啦,不会干扰现有客户端,而且新的客户端可以利用增强功能!顺便说一下,你担心会发送太多信息吗?在每个节都很重要的系统中,API 的演变可能要退居二线。但是,在度量之前,不要追求这种过早的优化。

构建指向 REST API 的链接

现在,已经建立了一个可演化的、具有最基本内容链接的 API。要想增强 API 并更好地为客户服务,需要采纳 HATEOAS(Hypermedia as the Engine of Application State)的概念。
什么意思呢?这里将进行详细的探讨。
业务逻辑不可避免地会建立涉及流程规则,系统的风险在于,将服务器端逻辑引入客户端并建立强耦合,REST 就是要打破这种联系,尽量减少这种耦合。下面,展示如何在不触及客户端重大变更的情况下处理状态改变,想象一下向系统中添加一个订单处理。
首先,定义 Order 记录,代码如下:

package cn.lut.curiezhang.order;
import java.util.Objects;

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

/**
 * 

Package: cn.lut.curiezhang.order

*

Class Name: Order

*

Description:

* * @author Curie Zhang * @version Order.java v1.0 2021/7/13 9:46 curiezhang */
@Entity @Table(name = "CUSTOMER_ORDER") public class Order { private @Id @GeneratedValue Long id; private String description; private Status status; public Order() {} public 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 + '}'; } }

从客户提交订单并完成或取消订单时起,订单经历一系列状态转换,这可以用 enum 来描述,代码如下:

package cn.lut.curiezhang.order;

/**
 * 

Package: cn.lut.curiezhang.order

*

Class Name: Status

*

Description:

* * @author Curie Zhang * @version Status.java v1.0 2021/7/13 9:49 curiezhang */
public enum Status { IN_PROGRESS, // 正在处理 COMPLETED, // 已完成 CANCELLED // 已取消 }

要支持与数据库中的订单交互,必须定义相应的 Spring 数据存储库,使用 Spring Data JPA 的基本接口 JpaRepository,代码如下:

package cn.lut.curiezhang.order;

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

/**
 * 

Package: cn.lut.curiezhang.order

*

Class Name: OrderRepository

*

Description:

* * @author Curie Zhang * @version OrderRepository.java v1.0 2021/7/13 9:58 curiezhang */
public interface OrderRepository extends JpaRepository<Order, Long> { }

有了这个存储库,和 employee 类似,就可以定义一个基本的 OrderController,代码如下:

package cn.lut.curiezhang.order;

import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.hateoas.MediaTypes;
import org.springframework.hateoas.mediatype.problem.Problem;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;

import java.util.List;
import java.util.stream.Collectors;

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

/**
 * 

Package: cn.lut.curiezhang.order

*

Class Name: OrderController

*

Description:

* 订单控制器 * * @author Curie Zhang * @version OrderController.java v1.0 2021/7/13 10:04 curiezhang */
@RestController public 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)); } @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("方法不允许!") // .withDetail("不能取消处于 " + order.getStatus() + " 状态的订单!")); } @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("方法不允许!") // .withDetail("不能完成处于 " + order.getStatus() + " 状态的订单!")); } }

同样地,创建一个组装器,代码如下:

package cn.lut.curiezhang.order;

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

import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.linkTo;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.methodOn;

/**
 * 

Package: cn.lut.curiezhang.order

*

Class Name: OrderModelAssembler

*

Description:

* * @author Curie Zhang * @version OrderModelAssembler.java v1.0 2021/7/13 10:05 curiezhang */
@Component public class OrderModelAssembler implements RepresentationModelAssembler<Order, EntityModel<Order>> { @Override public EntityModel<Order> toModel(Order order) { // 无条件链接单一资源和聚合根 EntityModel<Order> orderModel = EntityModel.of(order, linkTo(methodOn(OrderController.class) .one(order.getId())).withSelfRel(), linkTo(methodOn(OrderController.class) .all()).withRel("orders")); // 基于订单状态的条件链接 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; } }

现在,数据库初始化操作中加入 order 数据初始化,代码如下:

package cn.lut.curiezhang.payroll;

import cn.lut.curiezhang.order.Order;
import cn.lut.curiezhang.order.OrderRepository;
import cn.lut.curiezhang.order.Status;
import org.springframework.context.annotation.Configuration;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;

/**
 * 

Package: cn.lut.curiezhang.payroll

*

Class Name: LoadDatabase

*

Description:

* 加载测试数据 * * @author Curie Zhang * @version LoadDatabase.java v1.0 2021/7/11 11:06 curiezhang */
@Configuration public class LoadDatabase { private static final Logger log = LoggerFactory.getLogger(LoadDatabase.class); @Bean CommandLineRunner initDatabase(EmployeeRepository employeeRepository, OrderRepository orderRepository) { return args -> { log.info("加载 " + employeeRepository.save(new Employee("张", "三", "经理"))); log.info("加载 " + employeeRepository.save(new Employee("李", "四", "主任"))); employeeRepository.findAll().forEach(employee -> log.info("加载 " + employee)); orderRepository.save(new Order("MacBook Pro", Status.COMPLETED)); orderRepository.save(new Order("iPhone", Status.IN_PROGRESS)); orderRepository.findAll().forEach(order -> { log.info("加载 " + order); }); }; } }

重新启动应用,就可以查看订单信息,如下图所示:

SpringBoot构建REST服务_第13张图片
取消订单,如下图所示:
SpringBoot构建REST服务_第14张图片
如果再次取消订单,将显示不允许该操作,如下图所示:
SpringBoot构建REST服务_第15张图片

总结

这里介绍了各种构建 REST API 的策略。事实证明,REST 不仅仅是优雅的 URI,而且可返回 JSON,而不是XML。下面的策略有助于降低服务中断:

  • 不要移除旧字段,应该提供支持。
  • 使用基于关系的链接,客户端就不必硬编码 URI。
  • 尽可能长地保留旧链接。即使必须更改 URI,请保留关系,以便老的客户端能够使用新功能。
  • 使用链接(而非有效载荷数据)指引客户进行各种状态操作。

为每个资源类型构建 RepresentationModelAssembler 实现并在控制器中使用这些组件,这样,服务器端设置因为 Spring HATEOAS 而变得简单易用,可以确保受控的和非受控的客户端可以随着 API 的演化而轻松升级。
这里展示了如何使用 Spring 构建 REST 服务。

  • 非 rest 的——简单的 Spring MVC 应用程序,没有超媒体
  • rest 的——Spring MVC+Spring HATEOAS 应用程序与每个资源的 HAL 表示
  • 演化——REST 应用程序,其中一个字段发生变化,但保留旧数据提供为向后兼容性
  • 链接——REST 应用程序,使用有条件链接向客户端发送有效状态更改信号

这是整个项目的内容。

参考

Building REST services with Spring

下载

源代码 poy-roll.zip

你可能感兴趣的:(开发技术,SpringBoot,java,REST)