使用SpringBoot構建REST服務-什麼是REST服務

路上的程式設計師發表於2020-07-02

前言:

本文按照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 GETPOST, 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 資源被刪除

二、restfulhttp服務

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拆分為fristnamelastname,同時通過增加虛擬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"
        }
    }
}

 

相關文章