4.2.3 Spring HATEOAS案例分析
现在,让我们从一个实战案例切入,来演示如何使用Spring HATEOAS实现自解释Web API的开发步骤。我们先来设计一个实体类,如代码清单4-47所示。
代码清单4-47 Employee类定义代码
public class Employee { private final int id; private String firstName; private String lastName; private String role; //省略构造函数和getter/setter }
为了简化演示过程,我们直接构建一个Service层组件EmployeeService,该组件内部使用一个数组来进行内存级别的数据管理,基本就是对Employee对象的CRUD,如代码清单4-48所示。
代码清单4-48 EmployeeService的CRUD操作代码
@Service public class EmployeeService { private static final List<Employee> EMPLOYEES = new ArrayList<>(); private EmployeeService() { create(new Employee("FirstName1", "LastName1", "USER")); create(new Employee("FirstName2", "LastName2", "ADMIN")); } public List<Employee> findAll() { return EMPLOYEES; } public Employee findById(int id) { return EMPLOYEES.get(id); } public Employee findByName(String firstName, String lastName) { return EMPLOYEES.stream().filter(employee -> employee.getFirstName().equals(firstName) && employee.getLastName().equals(lastName)).findFirst().orElseThrow(() -> EmployeeNotFound.byName(firstName + " " + lastName)); } public Employee findByRole(String role) { return EMPLOYEES.stream().filter(employee -> employee.getRole().equals(role)).findFirst().orElseThrow(() -> EmployeeNotFound.byRole(role)); } public Employee create(Employee newEmployee) { Employee newlyCreatedEmployee = new Employee(EMPLOYEES.size(), newEmployee.getFirstName(), newEmployee.getLastName(), newEmployee.getRole()); EMPLOYEES.add(newlyCreatedEmployee); return newlyCreatedEmployee; } public Employee replace(Employee updatedEmployee, int id) { EMPLOYEES.remove(id); EMPLOYEES.add(id, updatedEmployee); return findById(id); } }
定义了领域实体以及Service层组件之后,接下来就可以对资源和链接进行有效的管理。
1. 创建资源和链接
我们先来看一个查询单个Employee的示例。如果使用传统的RESTful风格,我们可以创建一个PlainController,然后实现如代码清单4-49所示的一组HTTP端点。
代码清单4-49 PlainController类代码
@RestController public class PlainController { private final EmployeeService employeeService; public PlainController(EmployeeService employeeService) { this.employeeService = employeeService; } @GetMapping("/plain/employees") public List<Employee> all() { return this.employeeService.findAll(); } @PostMapping("/plain/employees") public Employee create(@RequestBody Employee newEmployee) { return this.employeeService.create(newEmployee); } @GetMapping("/plain/employees/{id}") public Employee single(@PathVariable int id) { return this.employeeService.findById(id); } @PutMapping("/plain/employees/{id}") public Employee update(@RequestBody Employee updatedEmployee, @PathVariable int id) { return this.employeeService.replace(updatedEmployee, id); } }
可以看到,在未引入Spring HATEOAS时,HTTP端点返回的就是一个Employee对象。现在,我们创建一个HypermediaController,并尝试对PlainController中的single()方法进行重构,重构之后的结果如代码清单4-50所示。
代码清单4-50 HypermediaController类代码
@RestController public class HypermediaController { private final EmployeeService employeeService; @GetMapping("/hypermedia/employees/{id}") public EntityModel<Employee> single(@PathVariable int id) { Link selfLink = linkTo(methodOn(HypermediaController.class).single(id)).withSelfRel(); Affordance update = afford(methodOn(HypermediaController.class).update(null, id)); Link aggregateRoot = linkTo(methodOn(HypermediaController.class).all()).withRel("employees"); return EntityModel.of(employeeService.findById(id), selfLink.andAffordance(update), aggregateRoot); } }
首先注意到上述single()方法的返回值是一个EntityModel对象。我们可以通过如代码清单4-51所示的方法来构建一个EntityModel对象。
代码清单4-51 创建EntityModel示例代码
Employee employee = new Employee... EntityModel<Employee> model = EntityModel.of(employee);
当然,如果你想构建包含多个业务对象的CollectionModel,也可以采用类似的实现方式,如代码清单4-52所示。
代码清单4-52 创建CollectionModel示例代码
Collection<Employee> employees = Collections...; CollectionModel<Employee> model = CollectionModel.of(employees);
另外,single()方法包含了一组创建超媒体资源常见的工具方法,其中methodOn()方法相当于为Controller创建了一个代理类,该代理类记录Controller中指定方法的调用。通过methodOn()方法,我们知道需要为哪个方法创建链接,正如代码清单4-50中methodOn(HypermediaController.class). single(id)这行代码的作用对象是HypermediaController中的single()方法。
这里的linkTo()方法比较好理解,就是对methodOn()指定的目标方法创建一个链接。而withRel()方法用于定义链接关系的名称。例如在代码清单4-50中,我们将Hypermedia-Controller中的另一个all()方法命名为employees。对应地,withSelfRel()则使用默认的自链接(Self Link)关系为当前方法指定一个链接名称。
注意,这里还存在一个Affordance对象,Affordance的字面意思就是“功能可见性”。换句话说,我们可以通过Affordances来展示Controller中的其他功能。在代码清单4-50中,我们通过afford(methodOn(HypermediaController.class).update(null, id))语句告诉客户端在HypermediaController中存在一个update()方法,这里的afford()方法会自动获取该方法的HTTP请求方式以及请求参数,从而为客户端提供调用该方法的有效途径。
我们运行Spring Boot应用程序,并通过GET方法访问http://localhost:8080/hypermedia/employees/{id}端点,得到的结果如代码清单4-53所示。
代码清单4-53 HTTP端点响应结果示例代码
{ "id":1, "firstName":"FirstName2", "lastName":"LastName2", "role":"ADMIN", "_links":{ "self":{ "href":"http://localhost:8080/hypermedia/employees/1" }, "employees":{ "href":"http://localhost:8080/hypermedia/employees" } }, "_templates":{ "default":{ "method":"put", "properties":[ { "name":"firstName" }, { "name":"id", "readOnly":true }, { "name":"lastName" }, { "name":"role" } ] } } }
显然,我们可以把上述结果拆分为三大部分,第一部分就是正常返回的一个Employee对象;第二部分则是_links段,分别针对当前请求自身以及根路径提供了两个链接;而第三部分则是_templates段,用来暴露当前Controller中所具备的HTTP方法为put的端点,即前面通过afford()方法所指定的update()方法。这里把该方法所应该传递的各个参数都列举出来,从而提供了API的自解释性。
2. 创建资源装配器
很多时候,我们在Controller层嵌入各种HATEOAS相关的对象并不是一个很好的做法。因为从职责分离的角度讲,Controller的作用是基于业务代码暴露HTTP端点,而不应该过多关注API的表示形式。基于这个考虑,Spring HATEOAS也提供了装配器的概念。装配器的作用就是把Link、Affordance等各种对象进行有效的组合。创建装配器的过程也比较简单,我们直接实现SimpleRepresentationModelAssembler接口即可,示例代码如代码清单4-54所示。
代码清单4-54 SimpleRepresentationModelAssembler接口实现代码
@Service public class HypermediaEmployeeAssembler implements SimpleRepresentationModelAss-embler<Employee> { @Override public void addLinks(EntityModel<Employee> resource) { int id = resource.getContent().getId(); Link selfLink = linkTo(methodOn(HypermediaController.class).single(id)).withSelfRel(); Affordance update = afford(methodOn(HypermediaController.class).update(null, id)); resource.add(selfLink.andAffordance(update)); resource.add(linkTo(methodOn(HypermediaController.class).all()).withRel("employees")); } @Override public void addLinks(CollectionModel<EntityModel<Employee>> resources) { resources.add(linkTo(methodOn(HypermediaController.class).all()).withSelfRel().andAffordance(afford(methodOn(HypermediaController.class).create(null)))); } }
可以看到,这里我们分别针对代表单个实体的EntityModel<Employee>以及代表实体组合的CollectionModel<EntityModel<Employee>>实现了对应的addLinks()方法。而在SimpleRepresentationModelAssembler的toModel()和toCollectionModel()方法中,就会调用这两个addLinks()方法完成组装操作。
现在,我们再回过头来看HypermediaController,它的代码就显得非常简洁。重构之后的完整版HypermediaController如代码清单4-55所示。
代码清单4-55 完整版HypermediaController类代码
@RestController public class HypermediaController { private final EmployeeService employeeService; private final HypermediaEmployeeAssembler assembler; public HypermediaController(EmployeeService employeeService, HypermediaEmployee-Assembler assembler) { this.employeeService = employeeService; this.assembler = assembler; } @GetMapping("/hypermedia/employees") public CollectionModel<EntityModel<Employee>> all() { return assembler.toCollectionModel(employeeService.findAll()); } @PostMapping("/hypermedia/employees") public EntityModel<Employee> create(@RequestBody Employee newEmployee) { return assembler.toModel(employeeService.create(newEmployee)); } @GetMapping("/hypermedia/employees/{id}") public EntityModel<Employee> single(@PathVariable int id) { Link selfLink = linkTo(methodOn(HypermediaController.class).single(id)).withSelfRel(); Affordance update = afford(methodOn(HypermediaController.class).update(null, id)); Link aggregateRoot = linkTo(methodOn(HypermediaController.class).all()).withRel("employees"); return EntityModel.of(employeeService.findById(id), selfLink.andAffordance(update), aggregateRoot); } @PutMapping("/hypermedia/employees/{id}") public EntityModel<Employee> update(@RequestBody Employee updatedEmployee, @PathVariable int id) { return assembler.toModel(employeeService.replace(updatedEmployee, id)); } }
与该案例相关的源代码都放在GitHub上,你可以自己尝试访问这些HTTP端点:https://github.com/tianminzheng/spring-boot-examples/tree/main/SpringHateoasExample。