REST易于构建和使用,因此已迅速成为在Web上构建Web服务的实际标准。
关于REST如何适合微服务世界,还有很多讨论,但是-在本教程中-让我们来看构建RESTful服务。
为什么要REST?REST包含Web的戒律,包括其体系结构,优势和其他所有内容。鉴于其作者Roy Fielding参与了十二个规范网络操作的规范,这不足为奇。
有什么好处?Web及其核心协议HTTP提供了一系列功能:
-
适当的动作(
GET
,POST
,PUT
,DELETE
,...) -
快取
-
重定向和转发
-
安全性(加密和认证)
这些都是构建弹性服务的关键因素。但这还不是全部。网络是建立在许多微小的规格之上的,因此它能够轻松发展,而不会陷入“标准之战”。
开发人员可以利用实现这些不同规格的第三方工具包,立即拥有客户端和服务器技术。
因此,基于HTTP的REST API提供了构建灵活的API的方法,这些API可以:
-
支持向后兼容
-
可进化的API
-
可扩展的服务
-
安全的服务
-
无状态到有状态服务的范围
重要的是要认识到,REST 本身无处不在,而是一个标准,而是架构上的一种方法,一种样式,一组约束 ,可以帮助您构建Web规模的系统。在本教程中,我们将使用Spring产品组合构建RESTful服务,同时利用REST的无堆栈功能。
入门
在学习本教程时,我们将使用Spring Boot 。转到Spring Initializr并选择以下内容:
-
网页
-
JPA
-
H2
-
龙目岛
然后选择“生成项目”。一种.zip
将下载。解压缩。在内部,您将找到一个基于Maven的简单项目,其中包括pom.xml
构建文件(注意:您可以使用Gradle。本教程中的示例将基于Maven。)
Spring Boot可以与任何IDE一起使用。您可以使用Eclipse,IntelliJ IDEA,Netbeans等。Spring Tool Suite是基于Eclipse的开源IDE发行版,提供Eclipse的Java EE发行版的超集。它包含的功能使使用Spring应用程序更加容易。绝不是必需的。但是,如果您想为按键提供额外的魅力 ,请考虑一下。这是一个演示如何开始使用STS和Spring Boot的视频。这是使您熟悉这些工具的一般介绍。
如果选择IntelliJ IDEA作为本教程的IDE,则必须安装lombok插件。为了了解我们如何在IntelliJ IDEA中安装插件,请看一下manage-plugins 。此后,您必须确保在“首选项”→“编译器”→“注释处理器”下选中“启用注释处理”复选框,如https://stackoverflow.com/questions/14866765/building-with-lomboks-slf4j-and- Intellij无法找到符号日志
到目前为止的故事...
让我们从我们可以构造的最简单的东西开始。实际上,为了使其尽可能简单,我们甚至可以省略REST的概念。 (稍后,我们将添加REST以了解它们之间的区别。)
我们的示例为管理公司员工的简单工资服务建模。简而言之,您需要将员工对象存储在H2内存数据库中,并通过JPA访问它们。这将被Spring MVC层包装以进行远程访问。
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 name;
private String role;
Employee() {}
Employee(String name, String role) {
this.name = name;
this.role = role;
}
}
尽管很小,但此Java类包含许多内容:
-
@Data
是Lombok批注,用于创建所有吸气剂,设置器,equals
,hash
和toString
方法,基于字段。 -
@Entity
是一个JPA批注,以使该对象准备好存储在基于JPA的数据存储区中。 -
id
,name
和role
是我们域对象的属性,第一个被标记了更多的JPA注释以指示它是主键,并由JPA提供程序自动填充。 -
当我们需要创建一个新实例但还没有ID时,便会创建一个自定义构造函数。
有了这个领域对象定义,我们现在可以转向Spring Data JPA来处理繁琐的数据库交互。Spring Data存储库是接口,这些接口具有支持针对后端数据存储读取,更新,删除和创建记录的方法。在适当的情况下,某些存储库还支持数据分页和排序。Spring Data根据在接口中的方法命名中找到的约定来综合实现。
除了JPA,还有多种存储库实现。您可以使用Spring Data MongoDB,Spring Data GemFire,Spring Data Cassandra等。对于本教程,我们将坚持使用JPA。 |
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尽可能地提供帮助。
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会自动加载follow类:
package payroll;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
@Slf4j
class LoadDatabase {
@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
一旦应用程序上下文被加载,beans。 -
该跑步者将要求提供一份
EmployeeRepository
您刚刚创建的。 -
使用它,它将创建两个实体并将其存储。
-
@Slf4j
是Lombok批注,用于自动创建基于Slf4j的LoggerFactory
如log
,使我们可以记录这些新创建的“员工”。
右键单击并运行 PayRollApplication
,这就是您得到的:
... 2018-08-09 11:36:26.169 INFO 74611 --- [main] payroll.LoadDatabase : Preloading Employee(id=1, name=Bilbo Baggins, role=burglar) 2018-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,几乎没有基础代码可以编写。相反,我们可以专注于操作:
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
指示每种方法返回的数据将直接写入响应主体中,而不呈现模板。 -
一个
EmployeeRepository
由构造函数注入到控制器中。 -
我们为每个操作提供路线(
@GetMapping
,@PostMapping
,@PutMapping
和@DeleteMapping
,对应于HTTPGET
,POST
,PUT
和DELETE
电话)。(注意:阅读每种方法并了解它们的作用非常有用。) -
EmployeeNotFoundException
是一个例外,用于指示何时查找员工但找不到员工。
package payroll;
class EmployeeNotFoundException extends RuntimeException {
EmployeeNotFoundException(Long id) {
super("Could not find employee " + id);
}
}
当一个EmployeeNotFoundException
抛出该异常,Spring MVC配置的这个额外花絮用于呈现HTTP 404 :
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
表示此建议已直接呈现到响应主体中。 -
@ExceptionHandler
将建议配置为仅在以下情况下响应EmployeeNotFoundException
被抛出。 -
@ResponseStatus
说要发出HttpStatus.NOT_FOUND
,即HTTP 404 。 -
建议的主体生成内容。在这种情况下,它会给出异常消息。
要启动该应用程序,请右键单击public static void main
在PayRollApplication
并选择从IDE 运行 ,或者:
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
该消息很好地显示了HTTP 404错误和自定义消息“ 找不到雇员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"}
根据您构建服务的方式,可能会产生重大影响。在这种情况下, replace比update是更好的描述。例如,如果未提供名称,则将其清空。 |
您可以删除...
$ curl -X DELETE localhost:8080/employees/3 $ curl localhost:8080/employees/3 Could not find employee 3
这一切都很好,但是我们有RESTful服务吗?(如果您没有收到提示,则答案为否。)
少了什么东西?
是什么使RESTful变得有趣?
到目前为止,您已经有了基于Web的服务,该服务可以处理涉及员工数据的核心操作。但这还不足以使事情变得“ RESTful”。
-
像/ employees / 3这样的漂亮URL并不是REST。
-
仅仅使用
GET
,POST
等不是REST。 -
安排所有CRUD操作不是REST。
实际上,到目前为止,我们更好地描述了RPC ( 远程过程调用 )。那是因为没有办法知道如何与该服务进行交互。如果您今天发布了此文档,则还必须编写文档或在其中包含所有详细信息的位置托管开发人员门户。
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来导航API。这导致了与电子商务在网络上兴起之前一样的脆弱性。这表明我们的JSON输出需要一点帮助。
介绍Spring HATEOAS ,这是一个Spring项目,旨在帮助您编写超媒体驱动的输出。要将服务升级为RESTful,请将其添加到您的构建中:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
这个小小的库将为我们提供构造以定义RESTful服务,然后将其呈现为可接受的格式以供客户端使用。
任何RESTful服务的关键要素是添加到相关操作的链接 。为了使您的控制器更加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
。Resource
是Spring HATEOAS的通用容器,不仅包含数据,还包含链接的集合。 -
linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel()
要求Spring HATEOAS建立指向EmployeeController
的one()
方法,并将其标记为自我链接。 -
linkTo(methodOn(EmployeeController.class).all()).withRel("employees")
要求Spring HATEOAS构建到聚合根的链接,all()
,并将其称为“员工”。
“建立链接”是什么意思?Spring HATEOAS的核心类型之一是Link
。它包括一个URI和一个rel (关系)。链接是赋予网络力量的力量。在万维网出现之前,其他文档系统会提供信息或链接,但是文档与数据的链接将网络缝合在一起。
Roy Fielding鼓励使用使网络成功的相同技术来构建API,链接就是其中之一。
如果重新启动应用程序并查询Bilbo的雇员记录,您将得到与之前稍有不同的响应:
{
"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的其他部分。在这种情况下,存在一个“自我”链接(有点像this
代码中的语句)以及返回聚合根的链接。
为了使聚合根ALSO更加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容器。它还允许您包含链接。不要让第一个陈述漏掉。“封装集合”何时表示?员工收款?
不完全的。
由于我们在谈论REST,因此它应该封装员工资源的集合。
这就是为什么您获取所有员工,然后将其转换为Resource
对象。(感谢Java 8 Stream API!)
如果重新启动应用程序并获取聚合根,则可以看到它的外观。
{
"_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"
}
}
}
对于服务于员工资源集合的聚合根,有一个顶层“自我”链接。“集合”列在“ _embedded”部分的下面。这就是HAL表示集合的方式。
并且集合中的每个成员都有其信息以及相关链接。
添加所有这些链接的意义何在?随着时间的推移,它可以发展REST服务。在将来添加新链接时,可以维护现有链接。较新的客户端可以利用新链接,而旧客户端可以在旧链接上维持自己的位置。如果服务被重新定位和移动,这将特别有用。只要保持链接结构,客户端就可以查找并与事物进行交互。
简化链接创建
您是否注意到在创建单个员工链接时重复进行?两次显示了提供指向员工的单个链接以及指向聚合根的“员工”链接的代码。如果那引起您的关注,那就好!有一个解决方案。
简而言之,您需要定义一个函数来转换Employee
反对Resource
对象。尽管您可以轻松地自己编写此方法,但在实现Spring HATEOAS的过程中仍有很多好处ResourceAssembler
接口。
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
)。
您之前在控制器中看到的所有代码都可以移入此类。并通过应用Spring Framework的@Component
,该组件将在应用启动时自动创建。
Spring HATEOAS针对所有资源的抽象基类是ResourceSupport 。但为简单起见,我建议使用Resource 作为将所有POJO轻松包装为资源的机制。 |
要利用此汇编器,只需更改EmployeeController
通过将汇编程序注入构造函数中。然后
@RestController
class EmployeeController {
private final EmployeeRepository repository;
private final EmployeeResourceAssembler assembler;
EmployeeController(EmployeeRepository repository,
EmployeeResourceAssembler assembler) {
this.repository = repository;
this.assembler = assembler;
}
...
}
从这里,您可以在单项员工方法中使用它:
@GetMapping("/employees/{id}")
Resource<Employee> one(@PathVariable Long id) {
Employee employee = repository.findById(id)
.orElseThrow(() -> new EmployeeNotFoundException(id));
return assembler.toResource(employee);
}
这段代码几乎相同,除了不是创建Resource
在这里,您将其委托给汇编器。也许看起来不多?
在聚合根控制器方法中应用相同的内容会更加令人印象深刻:
@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());
}
再次,代码几乎相同,但是您必须替换所有代码Resource
与创建逻辑map(assembler::toResource)
。感谢Java 8方法参考,将其插入并简化控制器非常容易。
Spring HATEOAS的主要设计目标是使“正确的事情”变得更容易。在这种情况下,无需对事物进行硬编码即可将超媒体添加到您的服务中。 |
在此阶段,您已经创建了一个Spring MVC REST控制器,该控制器实际上可以生成超媒体支持的内容!不使用HAL的客户端在使用纯数据时可以忽略多余的位。会说HAL的客户可以浏览您的授权API。
但这不是用Spring构建真正的RESTful服务所需要的唯一东西。
不断发展的REST API
使用一个附加的库和几行附加的代码,您已将超媒体添加到您的应用程序中。但这不是使服务成为RESTful所需的唯一条件。REST的一个重要方面是它既不是技术堆栈也不是单一标准。
REST是体系结构约束的集合,采用这些约束可使您的应用程序更具弹性。弹性的关键因素是,当您升级服务时,您的客户不会遭受停机的困扰。
在过去,升级是臭名昭著的,因为它破坏了客户。换句话说,对服务器的升级需要对客户端的更新。在当今时代,升级花费的停机时间甚至数小时甚至数分钟可能导致数百万美元的收入损失。
一些公司要求您向管理层提出计划,以最大程度地减少停机时间。过去,您可以避免在周日凌晨2:00进行升级,此时负载最小。但是,在当今与国际客户进行的基于Internet的电子商务中,这种策略并不那么有效。
基于SOAP的服务和基于CORBA的服务非常脆弱。很难推出可以同时支持新旧客户端的服务器。借助基于REST的实践,它变得容易得多。特别是使用Spring堆栈。
想象一下这个设计问题:您已经使用此工具推出了一个系统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"
}
}
}
注意这种格式的显示方式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
。龙目岛将为这些生成getter和setter。 -
旧的“虚拟”吸气剂
name
属性,getName()
被定义为。它使用firstName
和lastName
产生价值的字段。 -
旧的“虚拟”安装员
name
属性也已定义setName()
。它解析传入的字符串并将其存储到适当的字段中。
当然,对您的API所做的每一次更改都不像拆分字符串或合并两个字符串那样简单。但是对于大多数情况,肯定要提出一组转换,不是吗?
另一个微调是确保您的每个REST方法返回正确的响应。像这样更新POST方法:
@PostMapping("/employees")
ResponseEntity<?> newEmployee(@RequestBody Employee newEmployee) throws URISyntaxException {
Resource<Employee> resource = assembler.toResource(repository.save(newEmployee));
return ResponseEntity
.created(new URI(resource.getId().expand().getHref()))
.body(resource);
}
-
新的
Employee
对象将像以前一样保存。但是结果对象使用EmployeeResourceAssembler
。 -
Spring MVC
ResponseEntity
用于创建HTTP 201已创建状态消息。这种类型的响应通常包括位置响应标头,我们使用新形成的链接。 -
此外,返回已保存对象的基于资源的版本。
进行此调整后,您可以使用同一端点创建新的员工资源,并使用旧版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
),还填充了Location标头http://localhost:8080/employees/3
。具有超媒体功能的客户端可以选择“浏览”该新资源并继续与之交互。
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);
});
Resource<Employee> resource = assembler.toResource(updatedEmployee);
return ResponseEntity
.created(new URI(resource.getId().expand().getHref()))
.body(resource);
}
的Employee
从save()
然后使用EmployeeResourceAssembler
变成一个Resource
宾语。由于我们需要比200 OK更详细的HTTP响应代码,因此我们将使用Spring MVC的ResponseEntity
包装纸。它有一个方便的静态方法created()
我们可以在其中插入资源的URI。
通过抓住resource
您可以通过获取它的“自我”链接getId()
方法调用。此方法产生一个Link
您可以将其变成Java URI
。为了更好地捆绑在一起,您可以注入resource
本身进入body()
方法。
在REST中,资源的ID是该资源的URI。因此,Spring HATEOAS不会给您id 基础数据类型的字段(没有客户端应具有),而是它的URI。而且不要混淆ResourceSupport.getId() 与Employee.getId() 。 |
如果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操作:
@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的发展可能需要退居二线。但是在进行测量之前,不要追求这种过早的优化。
将链接构建到REST API中
到目前为止,您已经建立了具有裸露骨骼链接的可演化API。为了增加您的API并更好地为您的客户服务,您需要接受Hypermedia作为应用程序状态引擎的概念。
这意味着什么?在本节中,您将详细研究它。
业务逻辑不可避免地建立涉及流程的规则。此类系统的风险在于,我们经常将此类服务器端逻辑带入客户端并建立牢固的耦合。REST旨在断开此类连接并最小化此类耦合。
为了说明如何在不触发客户端变更的情况下应对状态变化,请设想添加一个可以执行订单的系统。
第一步,定义一个Order
记录:
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 enum
:
package payroll;
enum Status {
IN_PROGRESS,
COMPLETED,
CANCELLED;
}
这个enum
捕获各种状态Order
可以占领。对于本教程,让我们保持简单。
为了支持与数据库中的订单进行交互,您必须定义一个相应的Spring Data存储库:
JpaRepository
基本介面interface OrderRepository extends JpaRepository<Order, Long> {
}
有了这个,您现在可以定义一个基本的OrderController
:
@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));
}
}
-
它包含与迄今为止构建的控制器相同的REST控制器设置。
-
它同时注入
OrderRepository
以及(尚未构建)OrderResourceAssembler
。 -
Spring的前两条路由处理聚合根以及单个项
Order
资源请求。 -
第三个Spring MVC路线通过在
IN_PROGRESS
州。 -
所有的控制器方法都返回Spring HATEOAS之一
ResourceSupport
子类,以正确呈现超媒体(或围绕此类的包装器)。
在建立之前OrderResourceAssembler
,让我们讨论一下需要发生的事情。您正在建模之间的状态流Status.IN_PROGRESS
, Status.COMPLETED
和Status.CANCELLED
。向客户端提供此类数据时,很自然的事情是让客户端根据此有效负载决定它可以做什么。
但这是错误的。
在此流程中引入新状态时会发生什么?UI上各种按钮的放置可能是错误的。
如果您在更改国际支持并显示每个州的特定于语言环境的文本时更改了每个州的名称怎么办?那很可能会破坏所有客户。
输入HATEOAS或Hypermedia作为应用程序状态引擎 。与其让客户端解析有效负载,不如给他们链接以发出有效动作的信号。将基于状态的操作与数据的有效负载分离。换句话说,当CANCEL和COMPLETE是有效动作时,将它们动态添加到链接列表中。链接存在时,客户端仅需要向用户显示相应的按钮。
这使客户端不必知道何时这些操作有效,从而减少了服务器及其客户端在状态转换逻辑上不同步的风险。
已经接受Spring HATEOAS的概念ResourceAssembler
组件,将这样的逻辑放在OrderResourceAssembler
将是捕获此业务规则的理想场所:
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;
}
}
此资源汇编器始终包括指向单项资源的自身链接以及指向聚合根的链接。但它也包括两个条件链接OrderController.cancel(id)
以及OrderController.complete(id)
(尚未定义)。这些链接仅在订单状态为时显示Status.IN_PROGRESS
。
如果客户可以采用HAL并具有读取链接的能力,而不是简单地读取普通的旧JSON数据,则可以交易对订单系统领域知识的需求。这自然减少了客户端和服务器之间的耦合。它为调整订单执行流程打开了大门,而不会破坏流程中的客户。
要完善订单履行,请将以下内容添加到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"));
}
它检查Order
状态,然后才能将其取消。如果状态无效,则返回Spring HATEOAS VndError
,一个支持超媒体的错误容器。如果转换确实有效,则转换Order
至CANCELLED
。
并将其添加到OrderController
以及订单完成:
@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"));
}
这实现了类似的逻辑以防止Order
除非处于适当状态,否则状态将无法完成。
通过向其中添加一些额外的初始化代码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文档根据其当前状态立即显示每个订单的不同链接。
-
第一个命令,而只完成了导航链接。状态转换链接未显示。
-
第二个订单( IN_PROGRESS)另外具有取消链接和完整链接。
尝试取消订单:
$ 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方法不允许响应。删除已成为无效操作。的VndError
响应对象清楚地表明不允许您“取消”已经处于“已取消”状态的订单。
此外,尝试完成相同的订单也会失败:
$ 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" }
完成所有这些操作后,您的订单履行服务便可以有条件地显示可用的操作。它还可以防止无效操作。
通过利用超媒体和链接协议,可以使客户端更坚固,并且仅因数据更改而导致崩溃的可能性就较小。Spring HATEOAS简化了构建服务于客户所需的超媒体的工作。
摘要
在本教程中,您参与了各种构建REST API的策略。事实证明,REST不仅涉及漂亮的URI,而且还返回JSON而不是XML。
相反,以下策略有助于使您的服务不太可能破坏您可能控制或可能无法控制的现有客户端:
-
不要删除旧字段。相反,支持他们。
-
使用基于rel的链接,这样客户端就不必对URI进行硬编码。
-
尽可能保留旧的链接。即使必须更改URI,也请保留rel,以便较旧的客户端可以使用较新的功能。
-
使用链接(而不是有效负载数据)来指示客户端何时可以进行各种状态驱动操作。
建立起来似乎有些努力ResourceAssembler
每种资源类型的实现,并在所有控制器中使用这些组件。但是,服务器端设置的这一额外点(借助Spring HATEOAS可以轻松实现)可以确保您控制的客户端(更重要的是,那些您不需要的客户端)可以在开发API时轻松升级。
这结束了我们关于如何使用Spring构建RESTful服务的教程。本教程的每个部分在单个github存储库中作为单独的子项目进行管理:
-
nonrest -没有超媒体的简单Spring MVC应用
-
rest - Spring MVC的+Spring HATEOAS 应用与每个资源的HAL表示
-
演进 -REST应用程序,在该应用程序中可扩展字段,但保留旧数据以实现向后兼容性
-
链接 -REST应用程序,其中使用条件链接向客户端发送有效状态更改信号
要查看更多使用Spring HATEOAS的示例,请参见https://github.com/spring-projects/spring-hateoas-examples 。
要进行更多探索,请查看Spring队友Oliver Gierke的以下视频:
是否要编写新指南或为现有指南做出贡献?查看我们的贡献准则 。
所有指南均以代码的ASLv2许可证和写作的Attribution,NoDerivatives创作共用许可证发布 。 |