测试必须学spring RESTful Service(上)

文末我会说说为什么测试必须学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
//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": "//localhost:8080/employees/1"
    },
    "employees": {
      "href": "//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": "//localhost:8080/employees/1"
          },
          "employees": {
            "href": "//localhost:8080/employees"
          }
        }
      },
      {
        "id": 2,
        "name": "Frodo Baggins",
        "role": "thief",
        "_links": {
          "self": {
            "href": "//localhost:8080/employees/2"
          },
          "employees": {
            "href": "//localhost:8080/employees"
          }
        }
      }
    ]
  },
  "_links": {
    "self": {
      "href": "//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(下)》

参考资料

//spring.io/guides/tutorials/rest/

测试为什么必须学spring

高级测试,需要懂架构,需要懂开发,需要能和开发在同一个Level交流。除了公司项目以外,业务时间是很少有合适的方式去学习一些开发技术的。尤其是对于我这种对代码不太敏感,对技术反应有些迟钝的。光靠自己零零散散的学习,是很难真正提升的。那么有一个比较好的方式,就是去看一些成熟的成体系的东西。对于Web来说,没有任何一个框架比得上Java Spring成熟。在spring里面可以了解到很多开发的技术点,这对了解整个技术栈是很有效的方式。虽然我平时写Python比较多(毕竟生产力很强大),但仍然喜欢学Java,这样才能接触到更完整的生态。让自己的测试眼界更宽广。

版权申明:本文为博主原创文章,转载请保留原文链接及作者。


Tags: