測試必須學spring RESTful Service(上)

東方er發表於2020-08-30

文末我會說說為什麼測試必須學spring。

REST

REST,是指REpresentational State Transfer,有個精闢的解釋什麼是RESTful,

  • 看url就知道要什麼
  • 看method就知道幹什麼
  • 看status code就知道結果如何

實際上,RESTful API已經成為構建微服務的標準了,因為RESTful API容易build和consume。

為什麼選擇REST?REST信奉web的規則,包括架構、效益和其他一切。這並非偶然,因為spring的作者Roy Fielding參與了十幾項web規則的定義,這些規則決定了web怎麼執行。

效益是什麼?web和它的核心協議,HTTP,提供了一系列特性,

  • 適當的actions (GET, POST, PUT, DELETE, …)
  • 快取
  • 重定向和轉發
  • 安全(加密和鑑權)

這些都是建立可伸縮性services的關鍵因素。但也不是全部。

web是由大量細小的規則構成的,所以不會存在什麼“標準之爭”,因此就能輕鬆的發展。

開發們(Javaer)可以使用第三方工具來實現這些不同的規則,在指尖就可以立即擁有client和server的技術。

所以建立在HTTP之上的REST APIs提供了建立靈活APIs的方法,

  • 支援向後相容
  • 可擴充套件的API
  • 可伸縮性的services
  • 安全性的services
  • 無狀態到有狀態的services

但是,REST並不是一個普遍的標準,而是一個方法,一個style,一系列架構上的約束,來幫你建立web-scale的系統,區別這一點很重要。

下載示例程式碼

Spring Initializr這個網址選擇,

  • Web
  • JPA
  • H2

然後生成專案。下載.zip檔案。解壓。就有了一個基於Maven的示例專案,包括一個pom.xml檔案。

Spirng Boot可以用任何IDE,包括Eclipse、IntelliJ IDEA、Netbeans等。

Eclipse可以使用一個工具STS(The Spring Tool Suite)。

先從非REST說起

我們先以最簡單的示例開始。先拋棄REST的概念,後面再新增REST,在示例中感受到不同之處。

示例建模了一個簡單的工資單service,管理公司employees。簡言之,需要儲存employee objects到一個H2記憶體資料庫(Java編寫的嵌入式資料庫引擎),然後通過JPA(Java Persistence API,把實體物件持久化到資料庫,是一種ORM規範,Hibernate是具體實現的框架)訪問。它會被封裝到Spring MVC layer進行遠端訪問。

nonrest/src/main/java/payroll/Employee.java

package payroll;

import java.util.Objects;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.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 + '\'' + '}';
  }
}

程式碼不多,這個Java class包含了,

  • @Entity 是JPA註解,標記這個object是儲存在基於JPA的資料庫的。
  • id, name, 和 role 是domain object的屬性,第一個被多個JPA註解標記的,是主鍵,通過JPA provider實現了自增。
  • 當建立新例項的時候,就會建立custom constructor,但是還沒有id。

domain object定義好以後,就可以用Spring Data JPA來處理冗長的資料庫互動。Spring Data repositories是一些介面,可以對後端資料庫進行reading, updating, deleting, 和 creating記錄。一些repositories也支援適當的data paging和sorting。Spring Data基於介面中的methods的命名約定來合成實現。

Spring Data JPA是Spring Data家族成員之一,只需要寫repository介面,包括custom finder methods,Spring會自動提供實現。除了JPA之外,還有多種repository實現,如Spring Data MongoDB, Spring Data GemFire, Spring Data Cassandra等。

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,定義domain type為Employee,id type為Long。這個介面表面上是空的,但是支援,

  • Creating new instances
  • Updating existing ones
  • Deleting
  • Finding (one, all, by simple or complex properties)

Spring Data的repository solution可以避開資料儲存細節,使用domain-specific術語來解決大部分問題。

不管你信不信,反正我信了!現在已經足夠來啟動一個應用了!Spring Boot應用至少有一個public static void main entry-point,和@SpringBootApplication註解。

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是元註解,引入了component scanning, autoconfiguration, 和property support。Spring Boot會啟動一個servlet container,併為我們的service服務。

然而,沒有資料的應用有點搞笑,先添點資料。下面這個類會由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載入這個類的時候會發生什麼?

  • 一旦應用上下文載入後,Spring Boot就會執行所有的CommandLineRunner beans
  • runner會請求剛才建立的EmployeeRepository的copy
  • 然後建立2個物件,並儲存

右鍵Run 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 layer封裝repository,必須轉Spring MVC。Spring Boot簡化了這部分工作,基礎程式碼只有一點點,從而把編碼重心放到actions,

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

  @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 表明了每個方法返回的資料會直接寫入到響應的body裡面,而不是render一個template。
  • constructor注入了一個EmployeeRepository 到controller(依賴注入)。
  • 每個operations的路由 (@GetMapping, @PostMapping, @PutMapping@DeleteMapping, 對應HTTP GET, POST, PUT, 和 DELETE )。
  • EmployeeNotFoundException 是當employee找不到時丟擲的異常。

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 configuration會render一個HTTP 404

nonrest/src/main/java/payroll/EmployeeNotFoundAdvice.java

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 表示advice會直接render到response body。
  • @ExceptionHandler 配置了只有拋EmployeeNotFoundException 異常的時候,advice才會響應。
  • @ResponseStatus 表示發出 HttpStatus.NOT_FOUND, 比如 HTTP 404
  • advice的body生成具體內容。示例中,返回了異常的message。

執行應用有多種方式,可以右鍵PayRollApplication中的public static void main,然後選擇IDE的Run

如果是Spring Initializr,可以輸入命令列,

$ ./mvnw clean spring-boot:run

如果是本地安裝了maven,可以輸入命令列,

$ mvn clean spring-boot:run

一旦應用啟動了,可以檢視http通訊,

$ 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"}]

能看到預載入的資料。

如果請求一個不存在的employee,

$ 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 error,並列印了message,Could not find employee 99

使用-X發個POST請求,建立新的Employee

$ curl -X POST localhost:8080/employees -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "gardener"}'

使用PUT更新,

$ curl -X PUT localhost:8080/employees/3 -H 'Content-type:application/json' -d '{"name": "Samwise Gamgee", "role": "ring bearer"}'

使用Delete刪除,

$ curl -X DELETE localhost:8080/employees/3
$ curl localhost:8080/employees/3
Could not find employee 3

怎麼變得RESTful

現在已經實現了基於web的service,但是是非REST的,

  • /employees/3這種漂亮的URLs,不一定是REST
  • 只用了 GET, POST 等,不一定是REST
  • 實現了所有CRUD操作,不一定是REST

那到底怎麼樣才算REST?

實際上現在建立的這個應該叫做RPC (Remote Procedure Call 遠端過程呼叫)。因為並不知道以何種方式來和這個service互動。如果釋出這個程式碼,還必須寫個文件或者搞個開發入口網站,來把所有細節描述清楚。

看看Roy Fielding的這段話,是如何區別RESTRPC的,

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?

— Roy Fielding
https://roy.gbiv.com/untangled/2008/rest-apis-must-be-hypertext-driven

大概意思就是,應用狀態引擎(API)不是超文字驅動的(我的理解是,像超文字一樣攜帶一個地址,可以定址定位資訊,如超文字的link和id屬性),就不是RESTful。

不包括hypermedia的壞處,就是clients必須硬編碼URIs來導航API。這導致了電子商務興起之前同樣的脆弱特性。JSON output需要優化。

Spring HATEOAS,是一個spring專案,旨在幫你寫hypermedia-driven outputs。

接下來RESTful開搞,先新增Spring HATEOAS到pom.xml,

<dependency>
	<groupId>org.springframework.boot</groupId>
	<artifactId>spring-boot-starter-hateoas</artifactId>
</dependency>

這個小library會提供定義RESTful service的constructs,然後以可接受的格式render,以便client消費。

對任何RESTful service來說,一個關鍵的要素,是給相關的操作新增links

Getting a single item resource

@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"));
}

跟之前非REST有些類似,但也有不同,

  • 方法的返回值從 Employee 變成了 EntityModel<Employee>EntityModel<T> 是Spring HATEOAS的通用container,不僅包含data,也包含links集合。
  • linkTo(methodOn(EmployeeController.class).one(id)).withSelfRel() 讓Spring HATEOAS建立link到 EmployeeController 's one() 方法,並標記為self link。
  • linkTo(methodOn(EmployeeController.class).all()).withRel("employees") 讓Spring HATEOAS建立link到aggregate root(聚合根), all(),叫做"employees"。

建立link是什麼意思?Spring HATEOAS的核心types之一就是Link,包括一個 URI 和 一個 rel (relation)。正是Links改變了web。

在World Wide Web之前,其他的document systems會render information or links,但正是帶有這種關係metadata的documents link把web連在了一起。

Roy Fielding鼓勵使用相同的技術來建立APIs,links便是其中之一。

如果重啟應用,查詢employee Bilbo,會有一些不同,

RESTful representation of a single employee

{
  "id": 1,
  "name": "Bilbo Baggins",
  "role": "burglar",
  "_links": {
    "self": {
      "href": "http://localhost:8080/employees/1"
    },
    "employees": {
      "href": "http://localhost:8080/employees"
    }
  }
}

不只有id, name and role,還有 _links,包括2個URLs。整個文件是採用HAL格式化的。

HAL是一個輕量的mediatype,不僅允許encoding data,也能hypermedia controls,提醒consumers到能導航到的API的其他部分。在本示例中,就是"self"(類似於程式碼裡的this) link和能返回到aggregate root的link。

為了讓aggregate root也更RESTful,那麼會希望包含top level links,和包含其他RESTful components,

Getting an aggregate root resource

@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的另外一個container,用於封裝集合,以及links。

封裝集合?employees集合?

不完全是。

既然已經在說REST了,那麼封裝的是employee resources的集合。

這就是為什麼獲取了所有employees後,還需要轉換為EntityModel<Employee>的list。

重啟之後,獲取aggregate root,

{
  "_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"
    }
  }
}

這個aggregate root,提供了employee resources的集合,有一個top-level "self" link。 "collection"列在"_embedded"下面。這就是HAL怎麼表示集合。

集合中每個獨立的成員,都有information和關聯的links。

新增links到底有什麼意義?它使得隨著時間的推移發展REST services成為可能。已存在的links能保留,新的links在未來被新增。新的clients可能用新的links,同時遺留clients仍然能用老的links。如果services需要重定位和移動,那這就會非常有用。只要link結構保留,clients就仍然能查詢和互動。

後續內容請等待《測試必須學spring RESTful Service(下)》

參考資料

https://spring.io/guides/tutorials/rest/

測試為什麼必須學spring

高階測試,需要懂架構,需要懂開發,需要能和開發在同一個Level交流。除了公司專案以外,業務時間是很少有合適的方式去學習一些開發技術的。尤其是對於我這種對程式碼不太敏感,對技術反應有些遲鈍的。光靠自己零零散散的學習,是很難真正提升的。那麼有一個比較好的方式,就是去看一些成熟的成體系的東西。對於Web來說,沒有任何一個框架比得上Java Spring成熟。在spring裡面可以瞭解到很多開發的技術點,這對了解整個技術棧是很有效的方式。雖然我平時寫Python比較多(畢竟生產力很強大),但仍然喜歡學Java,這樣才能接觸到更完整的生態。讓自己的測試眼界更寬廣。

版權申明:本文為博主原創文章,轉載請保留原文連結及作者。


測試必須學spring RESTful Service(上)

相關文章