本文于2020年2月16日源于https://spring.io/guides/tutorials/rest/ 中Building REST services with Spring一篇翻译。
食用方式:为表达的更加直白,已经将部分专有名词翻译成中文,这可能会减少部分内容匹配性,但会帮助更多的人能看懂这个东西。
因为REST易于开发和使用的特性使得它很快成为业内构建WEB应用程序服务的标准。
尽管关于REST如何适应微服务的场景有更多不同争论,但是对于本教程而言,我们只需要关注于如何构建一个RESTful服务。
为什么要选择REST?因为REST严格遵循WEB规范,包含它的架构,优点,和其他任何方面。因为REST的作者是Roy Thomas Fielding,而他参与了WEB运行的十几个操作规范的设计工作,所以REST具备这一点并不觉得奇怪。
它又有哪些优点呢?WEB及其核心协议提供了这些特性:
- 恰当的行为定义(GET, POST, PUT, DELETE...)
- 缓存
- 重定向与转发
- 安全(加密和认证)
上述的几点都是构建一个弹性服务的关键因素,但这些并不代表所有,WEB是由很多微小的技术特性构成的(强调低耦合),因此它能够很容易的更新与扩展,而不会被各种标准所束缚。
开发者可以利用第三方的工具包实现不同的特性,并能很快的掌握客户端和服务端技术。
基于HTTP协议,REST API提供了如何更加灵活的构建API的方式:
- 支持高版本兼容低版本
- 可扩展API
- 可伸缩服务
- 支持安全服务
- 支持无状态请求与状态请求
虽然REST很普遍,但要意识到它并不是一个标准这很重要。它本质上时一种方式,一种风格,而他架构上的约束可以帮助你更好的构建WEB网络系统。在这个教程中我们将会使用Spring开发套件并利用REST的无堆栈特性来构建一个RESTful服务。
准备开始
在我们学习教程的过程中,我们将会使用Spring Boot。转到Spring Initializr页面并选择以下内容:
-
Web
-
JPA
-
H2
-
Lombok
然后选择生成项目(Generate Project),我们将会得到一个A .zip文件。将它解压,你会发现它是一个包含pom.xml构建文件的简易Maven工程(你同样可以使用Gradle,但在本教程中将会使用基于Maven的方式构建项目)。
Spring Boot 可以在任何IDE工具中使用它,你可以使用Eclipse,IDEA,Netbeans,等等。 Spring Tool Suite是开源的,基于Eclipse的IDE发行版,提供了Eclipse的Java EE发行版的超集,它包含的特性可以使Sping应用程序的开发变得更加简单,而这不是必须的,但你如果想让你使用时更加酷炫,那你可以考虑一下它。这里有一个视频演示了如何开始使用STS和Spring Boot,它包含了这个工具基本特性的介绍。
如果你使用IDEA作为你本教程的IDE,你需要安装Lombok插件,为了了解如何在IDEA中安装插件,请移步managing-plugins。插件安装完成后,要确保已勾选Enable annotation processing选项,这个设置在 Preferences → Compiler → Annotation Processors,详情可移步这里。
这是视频,可能需要特殊的上网方式才能打开。 : -)
HTTP是REST的平台
要想将资源用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
@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.setId(id);
return repository.save(newEmployee);
});
}
@DeleteMapping("/employees/{id}")
void deleteEmployee(@PathVariable Long id) {
repository.deleteById(id);
}
}
- @RestController 表明每个方法返回的数据都会被直接写入请求返回的body体中,而不是被渲染为页面(直接返回数据,而不是HTML页面表现)。
- EmployeeRepository 是一个被构造注入到Controller的对象。
- 我们有完整的请求路径与映射。(@GetMapping,@PostMapping, @PutMapping, @DeleteMapping适应了Http的GET,POST,PUT,DELETE调用)
- EmployeeNotFoundException 是一个异常,当一个employee被查询但无数据的情况会被抛出。
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配置来渲染一个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 表明这个方法的返回对象会直接返回插入到返回体中(body)
- @ExceptionHandler 表明当抛出
EmployeeNotFoundException
异常的情况会被该方法处理 - @ResponseStatus 表示一个返回状态码常量
建议返回的内容直接插入返回体(body),在这个案例中,它抛出了异常的信息。
在IDE的PayRollApplication类中右键public static void main方法选择运行,或者选以下其一的方法都可以运行该程序:
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
这条信息很完美的显示出了我们在404页面自定义的“Could not find employee 99”信息。
对程序员来说,用这种方式对显示当前代码的交互行为来说并不困难。
$ curl -X POST localhost:8080/employees -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}'
我们尝试创建一条新的Employee记录,并将内容返回:
{"id":3,"name":"Samwise Gamgee","role":"gardener"}
你可以改变这个用户:
$ 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"}
产生的影响取决于你构建服务的方式,对这种情况来说,“替换”是“更新”更好的描述方式。例如,如果没有使用名称查询,它将会被替换为null。
你也可以使用删除:
$ curl -X DELETE localhost:8080/employees/3 $ curl localhost:8080/employees/3 Could not find employee 3
这些虽然是个不错的方式,但我们有RESTful服务了吗?(如果你现在心里没有答案,那这就不是RESTful。)
那么缺少的是什么呢?
什么才是RESTful?
目前为止,你已经有了一个WEB的基础服务用来处理 employee的数据。但这对RESTful来说并不是全部的特性。
- 像/employees/3这样更漂亮的URL并不是REST
- 仅仅使用GET,POST等等并不是REST
- 分别映射好所有CRUD方法也不是REST
事实上,我们目前为止构建的东西把他描述为RPC(Remote Procedure Call 远程过程调用)更加形象。这是因为调用者无法获得一个返回预期用来更好的与服务进行交互。如果你今天将它发布了,还必须去写一个文档,或者在一个地方托管开发者门户网站用来写上你更新了什么。
或许Roy Fielding的这份声明为REST和RPC之间的区别提供了更加清晰的线索:
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?
在这个上述例子中不包含多媒体会产生必须将URI硬编码的方式存入客户端的影响。这就导致了WEB电子商务兴起之前一样的脆弱性。而这表明我们在使用JSON输出的时候需要一些改进。
让我们来看看 Spring HATEOAS,一个主要帮你编写多媒体驱动输出的Sping项目。让我们更新你的RESTful服务,将它加入到你的构建过程中吧:
将Spring HATEOAS依赖加入到pom.xml中
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
这个简单的依赖引用将会给我们构造一个默认的RESTful服务,并且可以将返回数据转换为客户端可以接受的格式。
新增一个链接关联到具体实现中是一个RESTful服务关键的组成部分,它能让你的controller对RESTful的处理更加强大,添加一个这样的链接:
获取单个资源:
@GetMapping("/employees/{id}")
Resource<Employee> one(@PathVariable Long id) {
Employee employee = repository.findById(id)
.orElseThrow(() -> new EmployeeNotFoundException(id));
return new Resource<>(employee,
linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel(),
linkTo(methodOn(EmployeeController.class).all()).withRel("employees"));
}
导入相关声明
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.Resources;
这与我们之前做的非常相似,但是需要做些小的修改:
- 方法的返回类型从
Employee
改为Resource<Employee>。Resource<T>是Spring HATEOAS的一个基本容器,不仅包含数据,还包含了链接的集合。 - linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel() 表示要求Spring HATEOAS 构建一个链接到EmployeeController的one()方法上,并将其标记为自身引用。
- linkTo(methodOn(EmployeeController.class).all()).withRel("employees") 表示要求Spring HATEOAS 构建一个链接到聚合的根路径下,并且使用“employees”调用。
上文中我们所说的“构建链接”是什么意思呢?这是因为Spring HATEOAS的核心类型是链接(Link).它包含了一个URI和一个REL(关系),链接使WEB的资源表现形式更加丰满。在万维网出现之前,其他的文档系统想要提供信息或者链接,而将网络连接在一起的其实是文档和链接数据。
Roy Fielding鼓励使用WEB成功的相似的技术来构建自己的API,而链接(Link)只是他们中的一个。
如果你重启了应用程序并且查询了Bilbo记录,你就会看到返回的结果和之前稍有不同。
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),并且_links对象包含了2个URI。这个实体对象使用了HAL格式。
HAL是一种轻量级的媒体类型,允许数据编码和多媒体控件进行编码,提醒接口消费者导航到去其他的API,既然这样,这是一个它自己的链接(类似于java代码中‘this’的声明一样),可以根据这个链接定位到API的根节点。
为了使聚合的根节点更加的符合RESTful,就必须在第一层包含链接,同时也包含了RESTful组件。
获取一个聚合根资源
@GetMapping("/employees")
Resources<Resource<Employee>> all() {
List<Resource<Employee>> employees = repository.findAll().stream()
.map(employee -> new Resource<>(employee,
linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(),
linkTo(methodOn(EmployeeController.class).all()).withRel("employees")))
.collect(Collectors.toList());
return new Resources<>(employees,
linkTo(methodOn(EmployeeController.class).all()).withSelfRel());
}
在上述的方法中,repository.findAll()后面的语句变得更多了,我们来分析一下,Resources<>是Spring HATEOAS容器封装的集合,它也允许你包含链接。让我来回顾一下第一句话,“封装集合”是什么意思呢?是employees对象的集合吗?
然而这样回答并不完全正确,
既然我们使用的是REST,它就应该封装employee 集合。
这就是什么你获取了所有employees数据的时候,再将它们转换成资源<Employee>对象的列表。(感谢Java 8 Stream API)。
如果你重启了应用程序并获取到了聚合根节点,你可以看到它像这样。
employee 资源集合的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"
}
}
}
对于这个聚合根来说,它提供了employee资源集合,这是一个顶级self链接,而collection在_embedded部分下被列出,这就是一个HAL所代表的集合格式。
集合中的每一个属性都有他们自己的信息和相关链接。
将这些链接加入到集合中的本质意义是什么呢?首先,它可以让REST服务自己进行演变,而不是依赖于开发人员编写API的方式。在将来添加新链接时可以维护现有链接,而API消费者可以在服务端更新后直接获得性的调用方式。而未更新的客户端可以参照着他们自己之前更新的老版本进行运作。这对服务迁移和接口变更非常有帮助。只要链接还在维护中,客户端就仍然可以通过它找到它想要的东西。
简化链接创建方式
你注意到在创建单一employee 链接对象中的重复操作了吗?这段代码中单链接employee对象和employees多多对象在聚合根处出现了2次,如果你意识到了这个问题,很棒,但是别担心,来看看下面的解决方式。
简单来说,你需要定义一个方法,将Employee
对象转换为Resource<Employee>对象。你自己可以通过简单的代码实现,但这里还有更简便的方法:实现Spring HATEOAS’s的 ResourceAssembler
接口。
evolution/src/main/java/payroll/EmployeeResourceAssembler.java
package payroll;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceAssembler;
import org.springframework.stereotype.Component;
@Component
class EmployeeResourceAssembler implements ResourceAssembler<Employee, Resource<Employee>> {
@Override
public Resource<Employee> toResource(Employee employee) {
return new Resource<>(employee,
linkTo(methodOn(EmployeeController.class).one(employee.getId())).withSelfRel(),
linkTo(methodOn(EmployeeController.class).all()).withRel("employees"));
}
}
这个简单的接口有一个方法toResource(),它是用来将非标准资源对象(Employee)转换为标准资源对象(Resource<Employee>)的一个方法。
你可以在之前controller的方法中的一些代码可以通过现在这个类来实现,再通过Spring框架的@Component注解,在应用程序启动的时候将会自动装配创建。
Spring HATEOAS’s的最顶层抽象类是ResourceSupport,但为了使用方便,推荐你们使用Resource<T>的转换原理作为你们包装所有POJO类的实现依据。
对于这种方式,你可以仅修改EmployeeController类的构造函数,
EmployeeResourceAssembler 将会被自动注入到其中。
@RestController
class EmployeeController {
private final EmployeeRepository repository;
private final EmployeeResourceAssembler assembler;
EmployeeController(EmployeeRepository repository,
EmployeeResourceAssembler assembler) {
this.repository = repository;
this.assembler = assembler;
}
...
}
在上述情况中,你可以使用它单条数据的employee方法:
使用assembler获取单条数据
@GetMapping("/employees/{id}")
Resource<Employee> one(@PathVariable Long id) {
Employee employee = repository.findById(id)
.orElseThrow(() -> new EmployeeNotFoundException(id));
return assembler.toResource(employee);
}
在这段代码除了创建Resource<Employee>方法之外,其他的和上面一样,它将Resource<Employee>的创建过程委托给了assembler,或许这样做更好。
在返回聚合根数据的controller方法中也是同样的用法:
使用assembler获取多条聚合根数据
@GetMapping("/employees")
Resources<Resource<Employee>> all() {
List<Resource<Employee>> employees = repository.findAll().stream()
.map(assembler::toResource)
.collect(Collectors.toList());
return new Resources<>(employees,
linkTo(methodOn(EmployeeController.class).all()).withSelfRel());
}
这段代码也是惊人的相似,然而,这里使用map(assembler::toResource)这种简单的方式去替换所有Resource<Employee>,这里要感谢Java 8 的方法参考特性,让我们能很简单的将数据插入并且简化当前类。
Spring HATEOAS 一个关键的设计思想是用最简单的方式做最正确的事。在这个场景中,将多媒体加入到服务的过程中并不困难。
现在,你已经创建了一个用来处理多媒体数据的Spring MVC REST控制器!当客户端没有使用HAL的时候,可以参照客户端以前的方式来消费数据。当客户端使用HAL时,它可以实时导航到所需要的已授权业务API。
上面描述了这么多,仍然不是通过Spring来创建RESTful 服务具备的所有关键点。
构建链接到你的 REST API
目前为止,你已经构建了一个基本的可扩展链接的API。扩展你的API,与服务端的更好适配,你需要面对“多媒体作为应用程序状态的关键部分”概念。
这是什么意思?在这部分,你将会得到答案。
业务逻辑不可避免的构建涉及流程的规则,这种系统的风险在于我们需要经常使用的服务器端到客户端的逻辑是强耦合的。REST是用来分解这些连接并且减少这些耦合。
为了说明如何在不触发客户端变更的情况下应对请求状态的变化,可以设想添加一个订单的系统来解决。
首先,我们来定义一个Order
实体对象
links/src/main/java/payroll/Order.java
package payroll;
import lombok.Data;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
import javax.persistence.Table;
@Entity
@Data
@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;
}
}
- 这个类需要JPA的@Table注解,将表名改为CUSTOMER_ORDER,因为
ORDER
对表来说不是一个有效的名称。 - 实体包含了
description
和status
字段描述
顾客提交订单后,订单状态会经过一系列的转变,会变成完成订单或取消订单,这一状态可以用Java 的枚举来表示:
links/src/main/java/payroll/Status.java
package payroll;
enum Status {
IN_PROGRESS,
COMPLETED,
CANCELLED;
}
这个枚举类包含了订单在各个生命周期可能转变的状态,对于这个教程,我们同样使用简便方式。
为了支持数据库中的订单能与业务互相交互,你必须定义一个Spring Data 资源库:
Spring Data JPA’s JpaRepository 基础接口
interface OrderRepository extends JpaRepository<Order, Long> {
}
现在,你可以定义一个OrderController
links/src/main/java/payroll/OrderController.java
@RestController
class OrderController {
private final OrderRepository orderRepository;
private final OrderResourceAssembler assembler;
OrderController(OrderRepository orderRepository,
OrderResourceAssembler assembler) {
this.orderRepository = orderRepository;
this.assembler = assembler;
}
@GetMapping("/orders")
Resources<Resource<Order>> all() {
List<Resource<Order>> orders = orderRepository.findAll().stream()
.map(assembler::toResource)
.collect(Collectors.toList());
return new Resources<>(orders,
linkTo(methodOn(OrderController.class).all()).withSelfRel());
}
@GetMapping("/orders/{id}")
Resource<Order> one(@PathVariable Long id) {
return assembler.toResource(
orderRepository.findById(id)
.orElseThrow(() -> new OrderNotFoundException(id)));
}
@PostMapping("/orders")
ResponseEntity<Resource<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.toResource(newOrder));
}
}
- 包含与你之前controller相同的REST设置
- 注入了
OrderRepository
与OrderResourceAssembler对象 - 前两个Spring MVC请求路由配置会对聚合根数据和单一订单数据进行处理。
- 第三个请求路由配置用来处理新订单的创建,将订单状态置为IN_PROGRESS。
- 整个controller的所有方法返回的数据都是Spring HATEOAS’s
ResourceSupport
的子类返回适当的多媒体类型之一。(或包装为对应类型)
让我们看一看构建OrderResourceAssembler之前还需要做什么,你还需要对订单的三个流程,如:Status.IN_PROGRESS
, Status.COMPLETED
, 和Status.CANCELLED进行建模,当客户端请求数据的时候,让客户端自己根据自身应用负载情况来决定自己需要什么数据。
但是,这样做会有一个问题。
当你需要对流程中的状态进行改变的时候应该怎么做呢?将改变状态按钮放在UI界面上并不是一个好的做法。
如果你要改变每个状态的名称,应该怎么做呢?
在编码的时候,支持国际化,并且将每个状态的场景描述都显示出来?但如此庞大的数据量将会导致客户端崩溃。
进入HATEOAS 或者多媒体作为应用程序状态引擎,它可以帮客户端解析负载,再将这些映射连接到每个有效的conrtoller中,这样做就可以将数据的负载与应用的处理有效解耦。也就是说,当当前订单可以变为【取消】或【完成】状态时,将可选状态动态的将他们加入到消息体返回的链接中,客户端只需要让用户从已返回的链接中选择按钮显示并选择。
这种松耦合的方式可以让客户端不必在请求前确认请求是否有效,降低服务器和客户端的状态变化逻辑不一致的风险。
既然已经接受了Spring HATEOAS 的 ResourceAssembler
组件概念,在OrderResourceAssembler中加入这段逻辑是这次业务的最好食用方式。
links/src/main/java/payroll/OrderResourceAssembler.java
package payroll;
import static org.springframework.hateoas.mvc.ControllerLinkBuilder.*;
import org.springframework.hateoas.Resource;
import org.springframework.hateoas.ResourceAssembler;
import org.springframework.stereotype.Component;
@Component
class OrderResourceAssembler implements ResourceAssembler<Order, Resource<Order>> {
@Override
public Resource<Order> toResource(Order order) {
// Unconditional links to single-item resource and aggregate root
Resource<Order> orderResource = new Resource<>(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) {
orderResource.add(
linkTo(methodOn(OrderController.class)
.cancel(order.getId())).withRel("cancel"));
orderResource.add(
linkTo(methodOn(OrderController.class)
.complete(order.getId())).withRel("complete"));
}
return orderResource;
}
}
在组装返回结果的时候,分别包含了它自己的链接,查询单条数据链接和查询所有数据链接。同时也包含了2个查询条件OrderController.cancel(id)和OrderController.complete(id),而这种返回结果仅当订单状态为进行中(Status.IN_PROGRESS)的时候才会返回。
如果客户端可以使用HA语言并且能够使用读取返回体中的链接来代替老式JSON数据的做法,他们就通过返回体中的链接交换订单系统的领域知识,这就自然而然的减少了客户端和服务端的耦合。这样做可以更好的让客户端去调试订单流程,而不会对现有服务产生流程中断。
现在完成订单流程,将cancel
操作加入到OrderController
中:
在OrderController类加入“取消(cancel)”操作
@DeleteMapping("/orders/{id}/cancel")
ResponseEntity<ResourceSupport> 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.toResource(orderRepository.save(order)));
}
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(new VndErrors.VndError("Method not allowed", "You can't cancel an order that is in the " + order.getStatus() + " status"));
}
在订单被取消前先检查了订单的状态是否为进行中,如果不是有效的状态,他就会返回一个Spring HATEOAS 的多媒体类的VndError容器错误,如果订单状态在进行中,则将订单状态置为取消(CANCELLED)。
并且将它添加到OrderController中以完成订单
。
在 OrderController创建一个complete操作
@PutMapping("/orders/{id}/complete")
ResponseEntity<ResourceSupport> 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.toResource(orderRepository.save(order)));
}
return ResponseEntity
.status(HttpStatus.METHOD_NOT_ALLOWED)
.body(new VndErrors.VndError("Method not allowed", "You can't complete an order that is in the " + order.getStatus() + " status"));
}
这段代码主要实现逻辑是用来预防一个订单从非正确的状态被完成。
通过新增一些扩展的初始代码到LoadDatabase:
更新数据库预加载信息
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文档部分立即就显示出了每个订单基于它当前状态的不同链接,
- 第一个订单最开始就是完成状态,因为它的状态已经不可改变(已完成的订单不可转变为进行中或取消状态),所以可变更状态链接未显示。
- 第二个订单时从“进行中”开始的,所以它有“取消”和“完成”两种状态的链接
我们现在去取消订单:
$ 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/hal+json;charset=UTF-8 < Transfer-Encoding: chunked < Date: Mon, 27 Aug 2018 15:03:24 GMT < { "logref": "Method not allowed", "message": "You can't cancel an order that is in the CANCELLED status" }
你可以看到返回了 HTTP 405 Method Not Allowed。这个请求成为了一个无效的操作,VndError
的返回对象清楚的表明了不允许你取消一个在被取消(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/hal+json;charset=UTF-8 < Transfer-Encoding: chunked < Date: Mon, 27 Aug 2018 15:05:40 GMT < { "logref": "Method not allowed", "message": "You can't complete an order that is in the CANCELLED status" }
到此为止,(REST)的所有条件都已具备,你的订单服务现在可以在不同订单状态的有效服务了,并且也可以防止无效操作。
借助于多媒体协议和链接,客户端可以构建出一个强壮并且更不会因为数据变化导致崩溃的软件架构。但 Spring HATEOAS仅仅是简化了你多媒体的构建过程,你还是需要自己去完成这个行为。
总结
通过这个教程,你学习到了通过各种方式去构建你的REST API。后来发现,REST并不只有用来美化URI和用JSON来代替XML 的作用。
反而,以下的方式可以帮助你在构建REST API的时候对旧版本客户端产生不好的影响。
- 不要删除陈旧字段,相反,要保留他们
- 使用基于关联关系的链接,这样就不会产生硬代码
- 尽可能的保留旧的链接,即使你必须要对URI做出改变,你也必须保留旧的链接,以便老版本客户端可以正常访问。(这开闭原则)
- 当应用各种状态驱动操作可用时,尽量的使用链接,而不是使用数据来进行负载。(PS:当REST的状态机制完善时,尽量使用HAL的方式返回数据对客户端提供参考,而不是运行多版本接口来数据适配)
这个工作可能看起来比较冗余,它需要去对每个资源类型实现ResourceAssembler
接口,并且在你所有controller中使用这个组件。服务器端仅需要对它做一些额外操作(感谢 Spring HATEOAS让操作变得简单),就可以保证客户端(更重要的是你未察觉的客户端)可以以简便的方式对服务进行更新迭代。
这是关于如何使用Spring去构建RESTful服务的最后部分,教程中的每一个部分都是在GITHUB中作为子项目管理的:
- nonrest 不包含多媒体的Spring MVC应用程序
- rest Spring MVC + Spring HATEOAS的每个以HAL表示的资源
- evolution 一个字段被迭代,但是旧的数据被保留以向后兼容的REST应用程序
- links 对客户端使用状态改变策略的REST应用程序
想要知道Spring HATEOAS的更多信息,可以转到:https://github.com/spring-projects/spring-hateoas-examples.