本文测试并总结Spring官网构建RESTful风格的HTTP服务,RESTful不是标准,只是架构上的一种方法,一种样式和一组约束。
前言:
本文按照Spring官网构建REST服务的步骤测试,可以得到结论:
到底什么样的风格才是RESTful风格呢?
1,约束请求命令如下:
- GET,获取资源。例如:/employees表示获取列表资源,/employees/{id}表示获取单个对象资源。
- POST,新增。例如:/employees,body为json对象,表示新增。
- PUT,更新。例如:/employees/{id},body为json对象,表示更新。
- DELETE,删除。例如: /employees/{id},表示删除。
2,约束返回结果: 返回数据为列表,则每个对象资源附加自己的资源链接、列表资源链接以及可操作性链接。
官网demo按照如下步骤介绍如何使用SpringBoot构建REST服务,并强调
- Pretty URLs like /employees/3 aren’t REST.
- Merely using
GET
,POST
, etc. aren’t REST. - Having all the CRUD operations laid out aren’t REST.
一、普通的http服务
pom.xml文件如下所示:
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<groupId>com.htkm.demo</groupId>
<artifactId>demo-restful</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>demo-restful</name>
<description>Demo project for Spring Boot</description>
<properties>
<java.version>1.8</java.version>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
<spring-boot.version>2.3.0.RELEASE</spring-boot.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-dependencies</artifactId>
<version>${spring-boot.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-compiler-plugin</artifactId>
<configuration>
<source>1.8</source>
<target>1.8</target>
<encoding>UTF-8</encoding>
</configuration>
</plugin>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
JPA类型的实体类:
@Data
@Entity
public class Employee {
@Id
@GeneratedValue
private Long id;
private String name;
private String role;
public Employee() {}
public Employee(String name, String role) {
this.name = name;
this.role = role;
}
}
使用H2内存数据库,创建测试数据库:
@Configuration
@Slf4j
public 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")));
};
}
}
JPA类型的DAO:
public interface EmployeeRepository extends JpaRepository<Employee, Long> {}
EmployeeContrller控制器:
@RestController
public class EmployeeController {
@Autowired
private EmployeeRepository repository;
@GetMapping("/employees")
List<Employee> all() {
return repository.findAll();
}
@PostMapping("/employees")
Employee newEmployee(@RequestBody Employee newEmployee) {
return repository.save(newEmployee);
}
@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);
}
}
自定义异常:
public class EmployeeNotFoundException extends RuntimeException {
public EmployeeNotFoundException(Long id) {
super("Could not find employee " + id);
}
}
增加@ControllerAdvice注解,实现异常处理器:
@ControllerAdvice
public class EmployeeNotFoundAdvice {
@ResponseBody
@ExceptionHandler(EmployeeNotFoundException.class)
@ResponseStatus(HttpStatus.NOT_FOUND)
String employeeNotFoundHandler(EmployeeNotFoundException ex) {
return ex.getMessage();
}
}
使用postman或者curl工具测试执行:
1,GET http://localhost:8080/employees
[
{
"id": 1,
"name": "Bilbo Baggins",
"role": "burglar"
},
{
"id": 2,
"name": "Frodo Baggins",
"role": "thief"
}
]
2,GET http://localhost:8080/employees/1
{
"id": 1,
"name": "Bilbo Baggins",
"role": "burglar"
}
3,DELETE http://localhost:8080/employees/1 资源被删除
二、restful的http服务
pom.xml增加hateoas依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>
控制器更改,在原来的返回json基础之上附加操作链接,红色加粗部分可以重构简化,在下一章节。
@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"))) // 附加all操作链接
.collect(Collectors.toList());
return CollectionModel.of(employees, linkTo(methodOn(EmployeeController.class).all()).withSelfRel()); // 附加自身链接
}
@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")); // 附加all操作链接
}
Postman测试执行
1,GET http://localhost:8080/employees,可以看到附加链接
{
"_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"
}
}
}
2,GET http://localhost:8080/employees/1
{
"id": 1,
"name": "Bilbo Baggins",
"role": "burglar",
"_links": {
"self": {
"href": "http://localhost:8080/employees/1"
},
"employees": {
"href": "http://localhost:8080/employees"
}
}
}
三、扩展的restful服务
扩展实体,将name拆分为fristname和lastname,同时通过增加虚拟的get/set保留name属性:
@Data
@Entity
public class Employee {
@Id
@GeneratedValue
private Long id;
private String firstName;
private String lastName;
private String role;
public Employee() {}
public 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];
}
}
重构简化代码,增加对象包装处理类
@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"));
}
}
控制器更改:
@RestController
public class EmployeeController {
@Autowired
private EmployeeRepository repository;
@Autowired
private EmployeeModelAssembler assembler;
@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()); // 附加自身链接
}
@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); // 返回状态码201,增加Location头 http://localhost:8080/employees/3
}
@GetMapping("/employees/{id}")
EntityModel<Employee> one(@PathVariable Long id) {
Employee employee = repository.findById(id) //
.orElseThrow(() -> new EmployeeNotFoundException(id));
return assembler.toModel(employee); // 包装对象
}
@PutMapping("/employees/{id}")
ResponseEntity<?> replaceEmployee(@RequestBody Employee newEmployee, @PathVariable Long id) {
Employee updatedEmployee = repository.findById(id) //
.map(employee -> {
employee.setName(newEmployee.getName());
employee.setRole(newEmployee.getRole());
return repository.save(employee);
}) //
.orElseGet(() -> {
newEmployee.setId(id);
return repository.save(newEmployee);
});
EntityModel<Employee> entityModel = assembler.toModel(updatedEmployee);
return ResponseEntity
.created(entityModel.getRequiredLink(IanaLinkRelations.SELF).toUri())
.body(entityModel); // 返回状态码201,增加Location头 http://localhost:8080/employees/3
}
@DeleteMapping("/employees/{id}")
ResponseEntity<?> deleteEmployee(@PathVariable Long id) {
repository.deleteById(id);
return ResponseEntity.noContent().build();// 返回状态码204
}
}
curl测试执行
$ curl -v -X POST localhost:8080/employees -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}'
请求相应状态为201,包含Location 响应头
> 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"
}
}
}
$ curl -v -X PUT localhost:8080/employees/3 -H 'Content-Type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}'
> 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"
}
}
}
$ curl -v -X DELETE localhost:8080/employees/1
> 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
四、附加可操作链接的restful服务
订单实体转换器,如果订单状态为可执行的订单则附加取消和完成链接
@Component
public 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")); // 附加cancel链接
orderModel.add(linkTo(methodOn(OrderController.class).complete(order.getId())).withRel("complete")); // 附加complete链接
}
return orderModel;
}
}
控制器中取消和完成操作
@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"));
}
@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"));
}
PostMan测试执行:
1,GET http://localhost:8080
{
"_links": {
"employees": {
"href": "http://localhost:8080/employees"
},
"orders": {
"href": "http://localhost:8080/orders"
}
}
}
2,GET 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"
}
}
}
3,DELETE http://localhost:8080/orders/3/cancel
{
"title": "Method not allowed",
"detail": "You can't cancel an order that is in the COMPLETED status"
}
4,DELETE http://localhost:8080/orders/4/cancel
{
"id": 4,
"description": "iPhone",
"status": "CANCELLED",
"_links": {
"self": {
"href": "http://localhost:8080/orders/4"
},
"orders": {
"href": "http://localhost:8080/orders"
}
}
}
5,POST localhost:8080/orders
设置header为Content-Type:application/json
body为{"name": "Samwise Gamgee", "role": "gardener"}'
{
"id": 5,
"description": "新的订单",
"status": "IN_PROGRESS",
"_links": {
"self": {
"href": "http://localhost:8080/orders/5"
},
"orders": {
"href": "http://localhost:8080/orders"
},
"cancel": {
"href": "http://localhost:8080/orders/5/cancel"
},
"complete": {
"href": "http://localhost:8080/orders/5/complete"
}
}
}
五、总结
RESTful风格的http服务,包含以下特征(百度百科摘录):
- 1、每一个URI代表1种资源;
- 2、客户端使用GET、POST、PUT、DELETE4个表示操作方式的动词对服务端资源进行操作:GET用来获取资源,POST用来新建资源(也可以用于更新资源),PUT用来更新资源,DELETE用来删除资源;
- 3、通过操作资源的表现形式来操作资源;
- 4、资源的表现形式是XML或HTML;
- 5、客户端与服务端之间的交互在请求之间是无状态的,从客户端到服务端的每个请求都必须包含理解请求所必需的信息。
官网demo描述:
What’s important to realize is that REST, however ubiquitous, is not a standard, per se, but an approach, a style, a set of constraints on your architecture that can help you build web-scale systems.
到底什么样的风格才是为RESTful风格呢?
首先约束请求命令如下:
- GET,获取资源。例如:/employees表示获取列表资源,/employees/{id}表示获取单个对象资源。
- POST,新增。例如:/employees,body为json对象,表示新增。
- PUT,更新。例如:/employees/{id},body为json对象,表示更新。
- DELETE,删除。例如: /employees/{id},表示更新。
其次约束返回结果: 返回数据为列表,则每个对象资源附加自己的资源链接、列表资源链接以及可操作性链接。
以下例子是可能的返回结果:
{
"_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"
}
}
}