Spring Boot进阶:原理、实战与面试题分析
上QQ阅读APP看书,第一时间看更新

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