写点什么

SpringWeb 服务构建轻量级 Web 技术体系:SpringHATEOAS

  • 2023-06-18
    湖南
  • 本文字数:6851 字

    阅读完需:约 22 分钟

SpringHATEOAS

当下,RESTful 架构风格被广泛应用于基于 HTTP 的 Web 应用程序开发过程,已经成为前后端交互的基本实现机制。事实上,REST 中还有一个成熟度的概念,当谈论这个概念时,常常会引用如图所示的 REST 成熟度模型。

可以看到,使用超媒体是整个模型的最高层次。那么如何开发基于超媒体的 Web 服务呢?我们可以使用超媒体应用状态引擎,即 HATEOAS,该引擎是 REST 中的一个重要组件。对于前后端开发人员而言,HATEOAS 的核心价值在于能够提供自解释的 Web API。本节中,我们就基于 Spring Boot 框架来讨论如何实现 HATEOAS。

HATEOAS 和 HAL

要使用 Spring HATEOAS,我们首先需要理解什么是 HATEOAS,而要解释 HATEOAS 这个概念先要解释什么是超媒体。接下来,我们就从超媒体开始讲起。

1. HATEOAS

我们已经知道什么是多媒体(Multimedia),以及什么是超文本(Hypertext)。其中超文本特有的优势是拥有超链接(Hyperlink)。如果我们把超链接引入到多媒体中,那就得到了超媒体(Hypermedia),因此这里的关键角色还是超链接。从 HATEOAS 的字面上进行理解,使用超媒体作为应用状态的引擎,指的就是应用的状态变更将由客户端访问不同的超媒体资源来驱动。这个概念听起来有点复杂,让我们通过一个简单的示例来进行理解,代码如下所示:

//请求GET https://api.example.com/profile//响应{	"name": "tianyalan",	"picture": {		"large": "https://somecdn.com/pictures/1200x1200.png",		"medium": "https://somecdn.com/pictures/100x100.png",		"small": "https://somecdn.com/pictures/10x10.png"	}}
复制代码

可以看到,上述响应结果包含了链接地址,因此使用该 API 的客户端就能够自由选择要下载的具体图片。这些链接告知了客户端有哪些可供选择的图片,以及它们的地址在哪里。这样一来,客户端就能够根据不同的场景,做出符合自身需要的选择。而且,如果客户端只需要一种格式的图片,那就无须下载全部三种版本的图片。因此,这种表现形式有很多优势,既减少了网络负载,又提高了客户端的灵活性,更增进了 API 的可探索性。


HATEOAS 的重要性在于打破了客户端和服务器之间严格的契约,使得客户端可以更加智能和自适应,而 RESTful 服务本身的演化和更新也变得更加容易。我们知道在使用普通的 RESTful API 时,客户端需要根据服务器提供的相关文档来了解所暴露的资源和对应的操作。而基于 HATEOAS 的 RESTful API 可以实现服务端和客户端的解耦,客户端通过服务器提供的资源来智能地发现可执行操作,这就是所谓的自解释 Web API。

2. HAL

HATEOAS 更多是一种概念,而 HAL(Hypertext Application Language,超文本应用语言)是 HATEOAS 的一种实现方式。与普通的 RESTful 风格不同,对每个资源,HAL 又将其细分成状态(State)、链接(Links)和子资源(Embedded Resource)三个标准部分,如图所示:

这里的资源状态是指资源本身固有的属性,链接定义了与当前资源相关的一组资源的链接集合,而子资源则描述当前资源的具体内容,提供嵌套资源的定义。


举例来说,在不使用 HAL 的场景下,我们设计一个 RESTful 风格的 API 一般会采用如下所示的表现形式:

//请求GET http://api.example.com/users/tianyalan//响应Content-Type: application/json{	"id": "user1",	"name": "tianyalan",	"email": "tianyalan@email.com"}
复制代码

而为了让 API 返回的数据更具有关联性,我们使用 HAL+JSON 格式,这时候返回的格式就会变成如下所示的表现形式。注意到这里多了_links 属性,其中有一个 self.href 链接指向当前 user 资源。

//请求GET http://api.example.com/users/tianyalan//响应Content-Type: application/json{	_links: {		self: {			href: "/users/tianyalan"		}	}	"id": "user1",	"name": "tianyalan",	"email": "tianyalan@email.com"}
复制代码

HAL 的出现主要弥补了普通 JSON 格式在 API 交互中的不足,让 JSON 更具有自描述性和导航性。同时,我们也注意到 Web 应用程序是将 API 组合起来为系统提供服务的开发模式。在组合 API 时,JSON 在格式上缺乏描述性的缺陷就体现得非常明显。我们要为 API 编写文档,要为 API 之间的数据关系和交互方式提供详细的说明。而如果使用 HAL+JSON 来描述如下所示的一个带有 location 属性的 user 信息,我们就可以很清晰地知道 user 信息和 location 信息的来源,这些信息都在子资源中有所体现。

//请求GET http://api.example.com/users/tianyalan//响应Content-Type: application/json{	_links: {		self: {			href: "/users/tianyalan"		}	}	"id": "user1",	"name": "tianyalan",	"email": "tianyalan@email.com"	_embedded: {		location: {			_links: {				self: {					href: 'http:// api.locationservices.com/locations/1'				}			},			id: 1,			city: 'hangzhou'		}	}}
复制代码

讲到这里,你可能会好奇,我们应该使用什么框架来开发类似这样的 WebAPI 呢?Spring HATEOAS 就是这样一个功能强大的开发框架。事实上,SpringBoot 框架内部也大量使用了 Spring HATEOAS 来对外暴露自解释的 Web API。

引入 Spring HATEOAS

Spring HATEOAS 为 Spring 带来了超媒体支持。它提供了一组类和资源装配器(Assembler),当资源从 Spring WebMVC 控制器返回时,可以实现在这些资源之前添加对应的链接。想要在 Spring Boot 应用程序中启用超媒体,需要将如下所示的 HATEOAS 依赖项添加到项目中。

<dependency>	<groupId>org.springframework.boot</groupId>	<artifactId>spring-boot-starter-hateoas</artifactId></dependency>
复制代码

对于 Spring HATEOAS 而言,它试图解决的核心问题是链接的创建和表示的组装。在 1.0 版本之前,Spring HATEOAS 提供了两种代表超链接的主要类型,即 Resource 和 Resources,它们都是 ResourceSupport 的子类。其中,Resource 表示单个资源,而 Resources 是资源的集合。这两种类型都能够承载指向其他资源的链接。


而在 1.0 版本之后,Spring HATEOAS 中的模型发生了巨大的调整。


ResourceSupport、Resource 和 Resources 分别被 RepresentationModel、EntityModel 和 CollectionModel 对象取代。

Spring HATEOAS 案例分析

现在,让我们从一个实战案例切入,来演示如何使用 Spring HATEOAS 实现自解释 Web API 的开发步骤。我们先来设计一个实体类,代码如下所示:

public class Employee {	private final int id;	private String firstName;	private String lastName;	private String role;	//省略构造函数和getter/setter}
复制代码

为了简化演示过程,我们直接构建一个 Service 层组件 EmployeeService,该组件内部使用一个数组来进行内存级别的数据管理,基本就是对 Employee 对象的 CRUD,代码如下所示:

@Servicepublic 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,然后实现如下所示的一组 HTTP 端点。

@RestControllerpublic 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()方法进行重构,重构之后的结果如下所示:

@RestControllerpublic 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 对象。我们可以通过如下所示的方法来构建一个 EntityModel 对象。

Employee employee = new Employee...EntityModel<Employee> model = EntityModel.of(employee);
复制代码

当然,如果你想构建包含多个业务对象的 CollectionModel,也可以采用类似的实现方式,代码如下所示:

Collection<Employee> employees = Collections...;CollectionModel<Employee> model = CollectionModel.of(employees);
复制代码

另外,single()方法包含了一组创建超媒体资源常见的工具方法,其中 methodOn()方法相当于为 Controller 创建了一个代理类,该代理类记录 Controller 中指定方法的调用。通过 methodOn()方法,我们知道需要为哪个方法创建链接,正如上面 methodOn(HypermediaController.class).single(id)这行代码的作用对象是 HypermediaController 中的 single()方法。


这里的 linkTo()方法比较好理解,就是对 methodOn()指定的目标方法创建一个链接。而 withRel()方法用于定义链接关系的名称。我们将 Hypermedia-Controller 中的另一个 all()方法命名为 employees。对应地,withSelfRel()则使用默认的自链接(Self Link)关系为当前方法指定一个链接名称。


注意,这里还存在一个 Affordance 对象,Affordance 的字面意思就是“功能可见性”。换句话说,我们可以通过 Affordances 来展示 Controller 中的其他功能。


我们通过 afford(methodOn(HypermediaController.class).update(null, id))语句告诉客户端在 HypermediaController 中存在一个 update()方法,这里的 afford()方法会自动获取该方法的 HTTP 请求方式以及请求参数,从而为客户端提供调用该方法的有效途径。


我们运行 Spring Boot 应用程序,并通过 GET 方法访 http://localhost:8080/hypermedia/employees/{id}端点,得到的结果如下所示:

{"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 的表示形式。基于这个考虑,SpringHATEOAS 也提供了装配器的概念。装配器的作用就是把 Link、Affordance 等各种对象进行有效的组合。创建装配器的过程也比较简单,我们直接实现 SimpleRepresentationModelAssembler 接口即可,示例代码如下所示:

@Servicepublic 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 代码如下所示:

@RestControllerpublic 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-bootexamples/tree/main/SpringHateoasExample

用户头像

加VX:bjmsb02 凭截图即可获取 2020-06-14 加入

公众号:程序员高级码农

评论

发布
暂无评论
SpringWeb服务构建轻量级Web技术体系:SpringHATEOAS_互联网架构师小马_InfoQ写作社区