使用一个附加的库和几行附加的代码,我们已经将超媒体添加到应用汇总。但这不是使服务成为 RESTful 所需的唯一条件。REST 的一个重要方面是它既不是技术堆栈也不是单一标准。
REST 是体系结构约束的集合,采用这些约束可使我们的应用更具弹性。弹性的关键因素是,当我们升级服务时,可以不回遭受停机的困扰。
在过去的日志里,升级时臭名昭著的,因为它破坏了客户端。换句话说,对服务器的升级需要对客户端的更新。在当今时代,升级话费的停机时间数小时甚至数分钟可能会导致数百万美元的收入损失。
一些公司要求我们先管理层提出计划,以最大程度地减少停机时间。过去,我们可以在负载最小的星期日的凌晨 2:00 进行升级。但是,在当今与国际客户进行的基于英特网的电子商务中,这种策略并不那么有效。
基于 SOAP 的服务和基于 CORBA 的服务非常脆弱。很难推出可以同时支持新旧客户端的服务器。使用基于 REST 的做法,它变得容易得多。特别是使用 Spring 堆栈。
想象一下这个设计问题:我们已经推出了一个基于 Employee
记录的系统。该系统的使用量非常大。我们已经将系统卖给了无数企业。突然,需要将员工姓名分为 firstName
和 lastName
。
哦不,之前没想到。
在打开 Employee
类并将单个字段 name
替换为 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 lombok.Data;
import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.Id;
@Data
@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];
}
}
该类与 Employee
的早期版本非常相似。我们来看一下这些更改:
- 字段
name
已被firstName
和lastName
替换。Lombok 将为这些生成 getter 和 setter; - 为旧的
name
属性定义了一个 “虚拟的” getter,getName()
,它使用firstName
和lastName
字段生成一个值; - 还为旧
name
属性定义了 “虚拟的” setter,setName()
。它解析输入的字符串并将其存储到适当的字段中。
当然,对我们的 API 所做的每一次改动都不像拆分字符串或合并两个字符串那样简单。但对于大多数情况,一定要想出一套转换方法,对吗?
另一个微调是确保我们的每个 REST 方法返回正确的响应。像这样更新 POST 方法:
POST 处理 “旧的” 和 “新的” 客户端请求
@PostMapping("/employees")
ResponseEntity<?> newEmployee(@RequestBody Employee newEmployee) throws URISyntaxException {
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 呈现的结果对象(name
以及 firstName
/lastName
),而且还以 http://localhost:8080/employees/3 添加了 Location 头信息。具有超媒体功能的客户端可以选择 “浏览” 该新资源并继续与之交互。
PUT 控制器方法需要类似的调整:
为不同的客户端处理 PUT
@PutMapping("/employees/{id}")
ResponseEntity<?> replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) throws URISyntaxException {
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()
方法,可以检索具有 SELF
关联的 EmployeeModelAssembler
创建的 Link
。该方法返回一个链接,必须使用 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 操作:
处理 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
对
Employee
类中的字段进行更改将需要与数据库团队进行协调,以便他们可以将现有内容正确地迁移到新列中。
现在,我们可以进行升级了,它不会打扰现有的客户端,而新的客户端可以利用这些增强功能!
顺便说一句,我们是否担心通过网络发送太多信息?在某些每个字节都很重要的系统中,API 的发展可能需要退居二线。但是在进行测量之前,不要追求这种过早的优化。