package payroll;
import java.util.Objects;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.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 + '\'' + '}';
}
}
使用 Spring 构建 REST 服务
REST 已迅速成为在 Web 上构建 Web 服务的实际标准,因为 REST 服务易于构建和消费。
关于 REST 如何融入微服务世界,可以进行更广泛的讨论。然而,对于本教程,我们只关注构建 RESTful 服务。
为什么是 REST?REST 拥抱 Web 的原则,包括其架构、优势以及其他一切。考虑到其作者 (Roy Fielding) 参与了许多管理 Web 运作方式的规范,这不足为奇。
有哪些优势?Web 及其核心协议 HTTP 提供了一系列特性:
-
适合的动作(
GET
、POST
、PUT
、DELETE
等) -
缓存
-
重定向和转发
-
安全(加密和认证)
这些都是构建弹性服务时的关键因素。然而,这还不是全部。Web 由许多微小的规范构建而成。这种架构使其能够轻松演进,而不会陷入“标准战争”的泥沼。
开发者可以利用实现这些不同规范的第三方工具包,并立即拥有客户端和服务器技术。
通过构建在 HTTP 之上,REST API 提供了构建以下功能的方法:
-
向后兼容的 API
-
可演进的 API
-
可扩展的服务
-
可保障安全的服务
-
从无状态到有状态的服务范围
请注意,REST 尽管无处不在,但其本身不是一个标准,而是一种方法、一种风格,是对你的架构施加的一系列约束,可以帮助你构建 Web 规模的系统。本教程使用 Spring 产品组合来构建 RESTful 服务,同时利用 REST 的无栈特性。
入门
要开始,你需要:
-
你喜欢的文本编辑器或 IDE,例如:
-
Java 17 或更高版本
在我们完成本教程的过程中,我们使用 Spring Boot。转到 Spring Initializr 并添加以下依赖到项目中:
-
Spring Web
-
Spring Data JPA
-
H2 Database
将 Name 改为 "Payroll",然后选择 Generate Project。一个 .zip
文件会下载。解压它。在里面,你会找到一个简单的、基于 Maven 的项目,其中包含一个 pom.xml
构建文件。(注意:你可以使用 Gradle。本教程中的示例将基于 Maven。)
要完成本教程,你可以从头开始一个新项目,或者查看 GitHub 中的解决方案仓库。
如果你选择创建自己的空白项目,本教程将指导你按顺序构建应用程序。你不需要多个模块。
GitHub 上完整的仓库没有提供一个单一的最终解决方案,而是使用模块将解决方案分为四个部分。GitHub 解决方案仓库中的模块是相互构建的,其中 links
模块包含最终解决方案。模块对应以下标题:
目前进展
本教程首先构建 nonrest 模块中的代码。 |
我们从能构建的最简单的事物开始。实际上,为了尽可能简单,我们甚至可以省略 REST 的概念。(稍后,我们再添加 REST,以理解区别。)
总体思路:我们将创建一个简单的工资服务,管理公司的员工。我们将 employee 对象存储在 (H2 内存) 数据库中,并访问它们(通过称为 JPA 的东西)。然后我们用允许通过互联网访问的东西(称为 Spring MVC 层)来包装它。
以下代码定义了我们系统中的一个 Employee
。
nonrest/src/main/java/payroll/Employee.java
尽管代码量小,但这个 Java 类包含很多东西:
-
@Entity
是一个 JPA 注解,使此对象准备好存储在基于 JPA 的数据存储中。 -
id
、name
和role
是我们Employee
领域对象的属性。id
用更多的 JPA 注解标记,表明它是主键并由 JPA provider 自动填充。 -
当我们需要创建一个新实例但还没有
id
时,会创建一个自定义构造函数。
有了这个领域对象定义,我们现在可以转向 Spring Data JPA 来处理繁琐的数据库交互。
Spring Data JPA 仓库是包含方法支持对后端数据存储进行创建、读取、更新和删除记录的接口。一些仓库还支持数据分页和排序,视情况而定。Spring Data 根据接口中方法命名中的约定合成实现。
除了 JPA 之外,还有多种仓库实现。你可以使用 Spring Data MongoDB、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 应用程序至少是一个 public static void main
入口点以及 @SpringBootApplication
注解。这告诉 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
是一个元注解,引入了组件扫描、自动配置和属性支持。在本教程中,我们不会深入探讨 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。 -
这个 runner 请求一份你刚刚创建的
EmployeeRepository
。 -
runner 创建两个实体并存储它们。
右键点击并运行 PayRollApplication
,你将得到:
控制台输出片段,显示数据预加载。
... 20yy-08-09 11:36:26.169 INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=1, name=Bilbo Baggins, role=burglar) 20yy-08-09 11:36:26.174 INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=2, name=Frodo Baggins, role=thief) ...
这不是完整的日志,只是数据预加载的关键部分。
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(() -> {
return repository.save(newEmployee);
});
}
@DeleteMapping("/employees/{id}")
void deleteEmployee(@PathVariable Long id) {
repository.deleteById(id);
}
}
-
@RestController
表示每个方法返回的数据会直接写入响应体,而不是渲染模板。 -
一个
EmployeeRepository
通过构造函数注入到 controller 中。 -
我们为每个操作都有路由(
@GetMapping
、@PostMapping
、@PutMapping
和@DeleteMapping
,对应于 HTTPGET
、POST
、PUT
和DELETE
调用)。(我们建议阅读每个方法并理解它们的作用。) -
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.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
@RestControllerAdvice
class EmployeeNotFoundAdvice {
@ExceptionHandler(EmployeeNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
String employeeNotFoundHandler(EmployeeNotFoundException ex) {
return ex.getMessage();
}
}
-
@RestControllerAdvice
表明此 advice 会直接渲染到响应体中。 -
@ExceptionHandler
配置此 advice 仅在抛出EmployeeNotFoundException
时响应。 -
@ResponseStatus
表示发出一个HttpStatus.NOT_FOUND
——即一个 HTTP 404 错误。 -
advice 的主体生成内容。在本例中,它提供异常的消息。
要启动应用程序,你可以右键点击 PayRollApplication
中的 public static void main
,并从 IDE 中选择运行。
或者,Spring Initializr 创建了一个 Maven wrapper,因此你可以运行以下命令:
$ ./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 20yy 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 20yy 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 内部的引号进行转义。 |
要创建新的 Employee
记录,请在终端中使用以下命令(开头的 $
表示后面跟着的是一个终端命令):
$ curl -X POST localhost:8080/employees -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}'
然后它存储新创建的员工并将其发送回给我们:
{"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 # Now if we look again, it's gone $ curl localhost:8080/employees/3 Could not find employee 3
这都很好,但是我们现在有一个 RESTful 服务了吗?(答案是否定的。)
缺少什么?
是什么让一个服务成为 RESTful 的?
到目前为止,你已经有了一个基于 Web 的服务,可以处理涉及员工数据的核心操作。然而,这还不足以使服务成为“RESTful”的。
-
漂亮的 URL,例如
/employees/3
,不是 REST。 -
仅仅使用
GET
、POST
等也不是 REST。 -
具备所有的 CRUD 操作也不是 REST。
事实上,我们目前构建的服务更适合描述为 RPC(远程过程调用),因为没有办法知道如何与这个服务交互。如果你今天发布这个服务,你还需要编写文档或在某个地方托管一个开发者门户,详细说明所有细节。
Roy Fielding 的这段话可能进一步揭示了 REST 和 RPC 之间的区别:
我对把任何基于 HTTP 的接口都称为 REST API 的人数感到沮丧。今天的例子是 SocialSite REST API。那是 RPC。它大喊着 RPC。耦合程度之高简直应该被打上限制级(X 级)。
需要做些什么才能让 REST 架构风格明确超文本是一种约束的概念?换句话说,如果应用程序状态的引擎(也就是 API)不是由超文本驱动的,那么它就不可能是 RESTful 的,也不能是 REST API。就这样。是不是有什么错误的文档需要修正?
https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven
在我们的表示中不包含超媒体的副作用是,客户端必须硬编码 URI 来导航 API。这导致了与 Web 电子商务兴起之前相同的脆弱性。这意味着我们的 JSON 输出需要一些帮助。
Spring HATEOAS
现在我们可以介绍 Spring HATEOAS,一个 Spring 项目,旨在帮助你编写超媒体驱动的输出。要将你的服务升级为 RESTful,请在你的构建中添加以下内容:
将 Spring HATEOAS 添加到 pom.xml
的 dependencies
部分。
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
这个小巧的库为我们提供了定义 RESTful 服务的构建块,然后以客户端可以接受的格式渲染它。
任何 RESTful 服务的关键要素是添加链接指向相关操作。为了使你的 controller 更 RESTful,请将以下链接添加到 EmployeeController
中现有的 one
方法中:
获取单个项资源
@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"));
}
你还需要包含新的导入:
详情
import org.springframework.hateoas.EntityModel;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
本教程基于 Spring MVC,并使用 |
这与我们之前的代码非常相似,但有一些变化:
-
方法的返回类型从
Employee
更改为EntityModel<Employee>
。EntityModel<T>
是 Spring HATEOAS 中的一个泛型容器,它不仅包含数据,还包含一组链接。 -
linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel()
要求 Spring HATEOAS 构建一个链接指向EmployeeController
的one
方法,并将其标记为自引用链接(self link)。 -
linkTo(methodOn(EmployeeController.class).all()).withRel("employees")
要求 Spring HATEOAS 构建一个链接指向聚合根all()
,并将其命名为 "employees"。.
“构建链接”是什么意思?Spring HATEOAS 的核心类型之一是 Link
。它包含一个 URI 和一个 rel(关系)。链接是赋予 Web 力量的东西。在万维网之前,其他文档系统也会渲染信息或链接,但正是文档与这种关系元数据的链接将 Web 缝合在一起。
Roy Fielding 鼓励使用使 Web 成功的相同技术来构建 API,链接就是其中之一。
如果你重启应用程序并查询 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
),还包含一个 _links
条目,其中包含两个 URI。整个文档使用 HAL 格式化。
HAL 是一种轻量级的 媒体类型,它不仅允许编码数据,还允许编码超媒体控件,提示消费者可以导航到的 API 的其他部分。在本例中,有一个“self”链接(有点像代码中的 this
语句),以及一个指向聚合根的链接。
为了使聚合根也更 RESTful,你想包含顶级链接,同时还包含其中的任何 RESTful 组件。
因此我们修改以下代码(位于已完成代码的 nonrest
模块中):
获取聚合根
@GetMapping("/employees")
List<Employee> all() {
return repository.findAll();
}
我们希望得到以下结果(位于已完成代码的 rest
模块中):
获取聚合根资源
@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());
}
那个之前仅仅是 repository.findAll()
的方法,“长大”了。不用担心。现在我们可以来解读它。
CollectionModel<>
是另一个 Spring HATEOAS 容器。它旨在封装资源集合,而不是单个资源实体,例如之前的 EntityModel<>
。CollectionModel<>
也允许你包含链接。
不要忽略第一句话。“封装集合”是什么意思?
员工集合?
不完全是。
由于我们谈论的是 REST,它应该封装员工资源的集合。
这就是为什么你获取所有员工,然后将他们转换为 EntityModel<Employee>
对象的列表。(感谢 Java Streams!)
curl -v localhost:8080/employees | json_pp
如果你重启应用程序并获取聚合根,你现在可以看到它的样子:
{
"_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"
}
}
}
员工资源集合的 RESTful 表示
对于这个提供员工资源集合的聚合根,有一个顶级的“self”链接。“集合”列在 _embedded
部分下方。HAL 就是这样表示集合的。
集合中的每个单独成员都有他们的信息以及相关的链接。
添加所有这些链接的意义是什么?它使得 REST 服务随时间演进成为可能。现有链接可以保留,同时未来可以添加新的链接。新版本的客户端可以利用新链接,而旧版本的客户端可以继续使用旧链接。如果服务被重新定位或移动,这尤其有用。只要链接结构得到维护,客户端仍然可以找到并与之交互。
简化链接创建 |
如果你正在跟随解决方案仓库,下一节将切换到evolution
模块。
在前面的代码中,你注意到创建单个员工链接时的重复了吗?提供单个链接指向员工以及创建指向聚合根的“employees”链接的代码显示了两次。如果这让你有所担忧,那很好!有一个解决方案。
你需要定义一个函数,将 Employee
对象转换为 EntityModel<Employee>
对象。虽然你可以轻松地自己编写这个方法,但 Spring HATEOAS 的 RepresentationModelAssembler
接口为你完成了这项工作。创建一个新类 EmployeeModelAssembler
:
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"));
}
}
evolution/src/main/java/payroll/EmployeeModelAssembler.java
这个简单的接口有一个方法:toModel()
。它的基础是将一个非模型对象(Employee
)转换为一个基于模型的对象(EntityModel<Employee>
)。
你之前在 controller 中看到的所有代码都可以移到这个类中。此外,通过应用 Spring Framework 的 @Component 注解,当应用程序启动时,assembler 会自动创建。 |
Spring HATEOAS 所有模型的抽象基类是 RepresentationModel
。然而,为了简单起见,我们建议使用 EntityModel<T>
作为将所有 POJO 轻松包装为模型的一种机制。
要利用这个 assembler,你只需要修改 EmployeeController
,在构造函数中注入 assembler。
@RestController
class EmployeeController {
private final EmployeeRepository repository;
private final EmployeeModelAssembler assembler;
EmployeeController(EmployeeRepository repository, EmployeeModelAssembler assembler) {
this.repository = repository;
this.assembler = assembler;
}
...
}
将 EmployeeModelAssembler 注入到 controller 中
从这里开始,你可以在 EmployeeController
中已经存在的单个项员工方法 one
中使用这个 assembler。
@GetMapping("/employees/{id}") EntityModel<Employee> one(@PathVariable Long id) { Employee employee = repository.findById(id) // .orElseThrow(() -> new EmployeeNotFoundException(id)); return assembler.toModel(employee); }
使用 assembler 获取单个项资源
这段代码几乎相同,除了不再在这里创建 EntityModel<Employee>
实例,而是委托给 assembler。也许这看起来并不令人印象深刻。
在聚合根 controller 方法中应用同样的事情更令人印象深刻。这个更改也是针对 EmployeeController
类。
@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());
}
使用 assembler 获取聚合根资源
代码再次几乎相同。然而,你可以用 map(assembler::toModel) 替换所有那些创建 EntityModel<Employee> 的逻辑。感谢 Java 方法引用,插入和简化你的 controller 变得超级容易。 |
Spring HATEOAS 的一个关键设计目标是让“做正确的事™”变得更容易。在这个场景中,这意味着在不硬编码任何东西的情况下为你的服务添加超媒体。
在这个阶段,你已经创建了一个 Spring MVC REST controller,它实际上生成了由超媒体驱动的内容。不支持 HAL 的客户端可以忽略额外的内容,同时消费纯数据。支持 HAL 的客户端可以导航你的“赋能”的 API。
但这并不是使用 Spring 构建一个真正 RESTful 服务所需的全部。
演进 REST API
只需要一个额外的库和几行额外的代码,你就为你的应用程序添加了超媒体。但这并不是使你的服务成为 RESTful 所需的全部。REST 的一个重要方面在于,它既不是技术栈,也不是单一标准。
REST 是一系列架构约束的集合,当采用这些约束时,你的应用程序会变得更加健壮。弹性的一个关键因素是,当你升级服务时,你的客户端不会遭受停机。
在“过去”,升级经常导致客户端崩溃。换句话说,服务器升级需要客户端也进行更新。在当今时代,升级导致的数小时甚至数分钟停机可能会造成数百万美元的收入损失。
一些公司要求你向管理层提交一个最小化停机时间的计划。过去,你可以在周日凌晨 2:00(负载最低时)进行升级,从而避免问题。但在今天面向国际客户的基于互联网的电子商务中,这种策略不再那么有效。
基于 SOAP 和 基于 CORBA 的服务非常脆弱。很难部署一个既能支持旧客户端又能支持新客户端的服务器。采用基于 REST 的实践,特别是使用 Spring 技术栈,会容易得多。
支持 API 更改
想象一下这个设计问题:你已经推出了一个基于 Employee
记录的系统。系统大获成功。你已将你的系统卖给了无数企业。突然间,出现了需要将员工姓名拆分为 firstName
和 lastName
的需求。
糟糕。
你没有考虑到这一点。在你打开 Employee
类并将单个字段 name
替换为 firstName
和 lastName
之前,请停下来思考一下。这会破坏任何客户端吗?升级它们需要多长时间?你是否甚至能控制所有访问你的服务的客户端?
停机时间 = 收入损失。管理层对此做好准备了吗?
有一个比 REST 早很多年的老策略。
— 未知
你总是可以向数据库表中添加列(字段)。但不要删除一个。RESTful 服务中的原则也是一样的。
向你的 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"
}
}
}
支持多个客户端的 JSON
这种格式显示了 firstName
、lastName
和 name
。虽然这带来了信息的重复,但目的是为了支持旧客户端和新客户端。这意味着你可以在不要求客户端同时升级的情况下升级服务器。这是一个很好的举措,应该能减少停机时间。
你不仅应该以“旧方式”和“新方式”同时显示这些信息,你还应该以两种方式处理传入的数据。
package payroll;
import java.util.Objects;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.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 记录
-
这个类与之前的
Employee
版本相似,但有一些变化: -
字段
name
已被firstName
和lastName
替换。 -
为旧
name
属性定义了一个“虚拟”的 getter 方法getName()
。它使用firstName
和lastName
字段生成一个值。
还定义了一个为旧 name
属性的“虚拟” setter 方法 setName()
。它解析传入的字符串并将其存储到相应的字段中。
当然,并非所有 API 更改都像拆分或合并两个字符串一样简单。但为大多数场景找到一套转换方案肯定不是不可能的,对吧?
|
不要忘记更改如何预加载数据库(在 LoadDatabase
中)以使用这个新的构造函数。
恰当的响应
朝正确方向迈出的另一步是确保你的每个 REST 方法都返回恰当的响应。更新 EmployeeController
中的 POST 方法(newEmployee
):
@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);
}
处理“旧”客户端和“新”客户端请求的 POST 方法
详情
import org.springframework.hateoas.IanaLinkRelations;
import org.springframework.http.ResponseEntity;
-
你还需要添加导入:
-
新的
Employee
对象像之前一样被保存。然而,结果对象被EmployeeModelAssembler
包装。 -
Spring MVC 的
ResponseEntity
用于创建 HTTP 201 Created 状态消息。这种类型的响应通常包含一个 Location 响应头,我们使用从模型的自引用链接派生的 URI。
此外,还会返回已保存对象的模型版本。
$ curl -v -X POST localhost:8080/employees -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}' | json_pp
有了这些调整,你可以使用同一个 endpoint 创建新的员工资源,并使用旧的 name
字段:
详情
> 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 20yy 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
),而且 Location 头被填充了 http://localhost:8080/employees/3
。一个支持超媒体的客户端可以选择“浏览”到这个新资源并继续与之交互。
EmployeeController
中的 PUT controller 方法(replaceEmployee
)需要类似的调整:
@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(() -> {
return repository.save(newEmployee);
});
EntityModel<Employee> entityModel = assembler.toModel(updatedEmployee);
return ResponseEntity //
.created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri()) //
.body(entityModel);
}
处理不同客户端的 PUT 请求
由 save()
操作构建的 Employee
对象随后被 EmployeeModelAssembler
包装以创建 EntityModel<Employee>
对象。使用 getRequiredLink()
方法,你可以检索由 EmployeeModelAssembler
创建的带有 SELF
rel 的 Link
。这个方法返回一个 Link
,必须使用 toUri
方法将其转换为 URI
。
$ curl -v -X PUT localhost:8080/employees/3 -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}' | json_pp
详情
* 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 20yy 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" } } }
由于我们想要比 200 OK 更详细的 HTTP 响应码,我们使用 Spring MVC 的 ResponseEntity
包装器。它有一个方便的静态方法(created()
),我们可以将资源的 URI 插入其中。HTTP 201 Created 是否携带正确的语义是值得商榷的,因为我们不一定“创建”了一个新资源。然而,它预加载了一个 Location 响应头,所以我们使用它。重启应用程序,运行以下命令,并观察结果:
该员工资源现在已更新,并且位置 URI 已被发送回。最后,更新 EmployeeController
中的 DELETE 操作(deleteEmployee
):
@DeleteMapping("/employees/{id}")
ResponseEntity<?> deleteEmployee(@PathVariable Long id) {
repository.deleteById(id);
return ResponseEntity.noContent().build();
}
处理 DELETE 请求
$ 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 20yy 21:30:26 GMT
这将返回一个 HTTP 204 No Content 响应。重启应用程序,运行以下命令,并观察结果: |
对 Employee
类中的字段进行更改需要与你的数据库团队协调,以便他们能够正确地将现有内容迁移到新列中。
你现在已经准备好进行一次升级,既不打扰现有客户端,同时新版本的客户端可以利用增强功能。
顺便说一句,你是否担心通过网络发送太多信息?在一些对每个字节都斤斤计较的系统中,API 的演进可能需要退居次要地位。然而,在衡量你的更改影响之前,你不应该追求这种过早的优化。
将链接构建到你的 REST API 中 |
到目前为止,你已经构建了一个带有基本链接的可演进 API。要发展你的 API 并更好地服务客户端,你需要拥抱作为应用程序状态引擎的超媒体(Hypermedia as the Engine of Application State)概念。
这是什么意思?本节将详细探讨它。
业务逻辑不可避免地会构建涉及流程的规则。这类系统的风险在于,我们常常将服务器端逻辑带入客户端,并建立强耦合。REST 旨在打破这种连接并最小化这种耦合。
为了展示如何在不触发客户端破坏性更改的情况下应对状态变化,想象一下添加一个处理订单的系统。
作为第一步,定义一个新的 Order 记录:
package payroll;
import java.util.Objects;
import jakarta.persistence.Entity;
import jakarta.persistence.GeneratedValue;
import jakarta.persistence.Id;
import jakarta.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 + '}';
}
}
-
links/src/main/java/payroll/Order.java
-
该类需要一个 JPA
@Table
注解,将表的名称更改为CUSTOMER_ORDER
,因为ORDER
不是有效的表名。
它包含一个 description
字段以及一个 status
字段。
订单必须经历一系列特定的状态转换,从客户提交订单直到订单完成或取消。这可以表示为一个名为 Status
的 Java 枚举:
package payroll;
enum Status {
IN_PROGRESS, //
COMPLETED, //
CANCELLED
}
links/src/main/java/payroll/Status.java
这个枚举捕获了 Order
可以处于的各种状态。对于本教程,我们保持简单。
为了支持与数据库中的订单交互,你必须定义一个相应的 Spring Data 仓库,称为 OrderRepository
:
interface OrderRepository extends JpaRepository<Order, Long> {
}
Spring Data JPA 的 JpaRepository
基本接口
详情
package payroll;
class OrderNotFoundException extends RuntimeException {
OrderNotFoundException(Long id) {
super("Could not find order " + id);
}
}
我们还需要创建一个新的异常类,称为 OrderNotFoundException
:
有了这些,你现在可以定义一个基本的 OrderController
,并包含所需的导入:
import java.util.List;
import java.util.stream.Collectors;
import static org.springframework.hateoas.server.mvc.WebMvcLinkBuilder.*;
import org.springframework.hateoas.CollectionModel;
import org.springframework.hateoas.EntityModel;
import org.springframework.http.ResponseEntity;
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.RequestBody;
import org.springframework.web.bind.annotation.RestController;
导入声明
@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));
}
}
-
links/src/main/java/payroll/OrderController.java
-
它包含了与你目前为止构建的 controller 相同的 REST controller 设置。
-
它注入了一个
OrderRepository
和一个(尚未构建的)OrderModelAssembler
。 -
前两个 Spring MVC 路由处理聚合根以及单个项的
Order
资源请求。 -
第三个 Spring MVC 路由通过将新订单开始于
IN_PROGRESS
状态来处理新订单的创建。
所有 controller 方法都返回 Spring HATEOAS 的 RepresentationModel
子类之一,以正确渲染超媒体(或此类类型的包装器)。
在构建 OrderModelAssembler
之前,我们应该讨论需要完成什么。你正在建模 Status.IN_PROGRESS
、Status.COMPLETED
和 Status.CANCELLED
之间的状态流转。向客户端提供此类数据时,自然的想法是让客户端根据此负载决定它们可以执行的操作。
但那样是错误的。
当你在此流程中引入新状态时会发生什么?UI 上各种按钮的位置可能会出错。
如果你改变每个状态的名称,也许是在编码国际化支持时显示每个状态的本地化文本?那很可能会破坏所有客户端。
这使得客户端不必知道何时此类操作有效,降低了服务器及其客户端在状态转换逻辑上不同步的风险。
既然已经接受了 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
时显示。
如果客户端可以采用 HAL 并具备读取链接的能力,而不是仅仅读取普通的 JSON 数据,它们就可以不再需要关于订单系统的领域知识。这自然减少了客户端和服务器之间的耦合。它还为调整订单履行流程打开了大门,而不会在此过程中破坏客户端。
为了完善订单履行,请在 OrderController
中为 cancel
操作添加以下内容
在 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"));
}
它在允许取消之前检查 Order
状态。如果状态无效,它将返回一个支持超媒体的错误容器 RFC-7807 Problem
。如果转换确实有效,它会将 Order
转换为 CANCELLED
。
现在我们还需要将此添加到 OrderController
中以完成订单
在 OrderController 中创建“complete”操作
@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"));
}
这实现了类似的逻辑,以防止 Order
状态在未处于适当状态时被完成。
让我们更新 LoadDatabase
,除了之前加载的 Employee
对象之外,也预加载一些 Order
对象。
更新数据库预加载器
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 | json_pp
详情
{ "_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 | json_pp
您可能需要根据数据库中的特定 ID 替换前述 URL 中的数字 4。该信息可以通过之前的 /orders 调用找到。 |
详情
> 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 20yy 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 | json_pp
详情
* 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 20yy 15:03:24 GMT < { "title": "Method not allowed", "detail": "You can't cancel an order that is in the CANCELLED status" }
您会看到 HTTP 405 Method Not Allowed 响应。DELETE 已成为无效操作。Problem
响应对象清楚地表明您不允许“cancel”一个已经处于“CANCELLED”状态的订单。
此外,尝试完成同一个订单也会失败
$ curl -v -X PUT localhost:8080/orders/4/complete | json_pp
详情
* 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 20yy 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,也要保留 rel,以便旧客户端有通往新功能的路径。
-
使用链接而非负载数据来告知客户端何时可以使用各种驱动状态的操作。
为每种资源类型构建 RepresentationModelAssembler
实现并在所有控制器中使用这些组件可能看起来需要一些努力。然而,这些额外的服务器端设置(得益于 Spring HATEOAS 而变得容易)可以确保您控制的客户端(以及更重要的是,您不控制的客户端)随着您 API 的演进而轻松升级。
本教程结束了关于如何使用 Spring 构建 RESTful 服务的讲解。本教程的每个部分都作为独立子项目在一个 github 仓库中管理。
-
nonrest — 没有超媒体的简单 Spring MVC 应用
-
rest — 具有每种资源的 HAL 表示的 Spring MVC + Spring HATEOAS 应用
-
evolution — 字段经过演进但旧数据为保持向后兼容性而保留的 REST 应用
-
links — 使用条件链接向客户端发出有效状态变化信号的 REST 应用
要查看更多使用 Spring HATEOAS 的示例,请参阅 https://github.com/spring-projects/spring-hateoas-examples。
要进行更多探索,请查看 Spring 团队成员 Oliver Drotbohm 的以下视频
想写新指南或为现有指南做贡献?查看我们的贡献指南。
所有指南的代码均以 ASLv2 许可证发布,文字内容以 署名-禁止演绎创意共享许可证 发布。 |