表征状态转移(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,提供一系列特征栈:
这些都是建立弹性服务的关键,但不是全部。Web是基于大量微小的规范构建的,因此,能够轻松进化而不会陷入“标准之争”。
开发人员能够利用第三方工具包来实现这些不同的规范,并立即将客户端和服务器端技术放在触手可及的地方。
通过在 HTTP 上构建,REST API 提供了如下构建的手段:
重要的是要认识到,REST,无论多么无处不在,本身都不是标准,而是一种方法、一种风格、一套对架构的限制,可以帮助您构建Web扩展系统。
这里,将使用 SpringBoot 构建一个 RESTful 服务,同时利用 REST 的无堆叠功能(stackless features)。
打开IDEA,创建新项目,如下图所示:
输入项目参数,点击Next,选择SpringBoot依赖,如下图所示:
进入项目目录,运行 git init 命令,初始化 git 版本库,便于代码管理,如下图所示:
从最简单的领域类创建开始,现在要构造一个简单的工资服务,需要相应的雇员信息,将相应的对象存储在 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 很便于进行数据库访问操作。
要将存储库与 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();
}
}
现在,如果没有找到雇员信息,将直接显示异常信息。
运行代码,就可以在浏览器中访问数据库中的数据了,如下图所示:
尝试查询不存在的雇员,结果如下:
可使用 Postman 来创建新雇员,如下图所示:
当然,也可以使用 curl 命令,但是 Windows 下的 curl 命令可能会出错,就不建议使用了,而且,使用 Postman 非常方便,各种 HTTP 请求都很容易实现,这里不再一一介绍。
目前为止,已经实现了 Employee 数据的基于 Web 的服务核心操作,能够进行 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 结合得更好。
重新启动应用,可以得到更有效的展示,如下图所示:
整个文档可以用 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());
}
这里的处理方法与前面类似,给每个雇员添加一个自链接,顶层封装聚合根。
重新启动应用,可以看到如下结果:
集合的每个成员都有他们的信息以及相关链接。
添加所有这些链接有什么意义呢?它使 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 服务的全部。REST 的一个重要方面是,它既不是一个技术栈,也不是一个简单的标准。
REST 是一系列架构约束,当使用时会使应用程序更具弹性。弹性的一个关键因素是,当升级服务时,客端户不会遭受宕机的影响。
早期,升级会对客户造成破坏而臭名昭著。换句话说,服务器升级需要客户端进行更新。在当今这个时代,升级所花费的停机时间可能损失数百万美元。
有些公司如果要您向管理层提交一个尽量减少停机时间的计划。过去,您可以在负载最低的星期日凌晨 2 点进行升级。但是,在当今以互联网为基础的电子商务中,与其他时区的国际客户合作,这种策略并不凑效。
基于 SOAP 的服务和基于 CORBA 的服务是非常脆弱的。很难推出能够支持新旧客户端的服务。而采用基于 REST 的实践,就要容易得多。特别是使用 Spring 技术。
想象一下这样一个设计问题:您推出了一个基于 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 版本很相似。下面就来看一下其中的变化:
当然,并不是对 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);
}
通过这些改进,可以使用相同的端点创建新的雇员信息,并使用传统的 name 字段,如下图所示:
这不仅在 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 操作如下图所示:
该雇员资源现已更新,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 错误,如下图所示:
现在就可以升级啦,不会干扰现有客户端,而且新的客户端可以利用增强功能!顺便说一下,你担心会发送太多信息吗?在每个节都很重要的系统中,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);
});
};
}
}
重新启动应用,就可以查看订单信息,如下图所示:
取消订单,如下图所示:
如果再次取消订单,将显示不允许该操作,如下图所示:
这里介绍了各种构建 REST API 的策略。事实证明,REST 不仅仅是优雅的 URI,而且可返回 JSON,而不是XML。下面的策略有助于降低服务中断:
为每个资源类型构建 RepresentationModelAssembler 实现并在控制器中使用这些组件,这样,服务器端设置因为 Spring HATEOAS 而变得简单易用,可以确保受控的和非受控的客户端可以随着 API 的演化而轻松升级。
这里展示了如何使用 Spring 构建 REST 服务。
这是整个项目的内容。
Building REST services with Spring
源代码 poy-roll.zip