文末我會說說為什麼測試必須學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
, 對應HTTPGET
,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的這段話,是如何區別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?
— 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
'sone()
方法,並標記為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,這樣才能接觸到更完整的生態。讓自己的測試眼界更寬廣。
版權申明:本文為博主原創文章,轉載請保留原文連結及作者。