使用 Spring HATEOAS 构建更丰富的超媒体

工程 | Greg L. Turnquist | 2018年1月12日 | ...

Spring 社区的朋友们,您好:

之前所宣布的,我们已在 1.0.0.BUILD-SNAPSHOT 版本中发布了一个新的 Affordances API。在这篇博文中,我们将初步了解这个特性具体能做什么。

Affordances 的历史

首先,什么是 affordance? 进行一番考究,REST 的倡导者 Mike Admundsen 写了一篇文章详细介绍了这个词的起源,至少可以追溯到 1986 年

环境的 affordances 是它提供的……它提供或布置的东西,无论是好的还是坏的。动词 'afford' 在字典中可以找到,但名词 'affordance' 则没有。我创造了这个词(第 126 页)。

— 视觉感知的生态学方法 (Gibson)

它随后出现在 1988 年的一篇心理学论文中

……术语 affordance 指的是事物的感知属性和实际属性,主要是那些决定事物可能如何使用的基本属性。(第 9 页)

— 日常事物的设计|心理学 (Norman)

最后,它出现在 Roy Fielding 于 2008 年关于超媒体的一次演讲中

当我说超文本时,我指的是信息和控件的同时呈现,使得信息成为用户获取选择和选取行动的 affordance(幻灯片 #50)。

— 关于 REST 的幻灯片演示 (Fielding)

在所有这些情境中,“affordance” 指的是周围环境提供的可用行动。在 REST 的语境中,这些是由超媒体详细描述的行动。

过去,当人们从 SOAP 及其基于行动的策略转向时,他们难以记录其 API,许多人并不知道 Roy Fielding 正是为了这个目的而将超媒体构建到 REST 中。通过将数据与控件一起包含,不仅可以查找相关数据,还可以使用数据,这是关键。

使用 HAL 文档,客户端会获得非常简单的 affordances。链接会显示,但没有关于它们的其他信息。关于你可以用这些链接做什么以及如何与它们交互,都没有详细说明。

Affordances 与 Spring HATEOAS

你可以通过定义以下域对象来开始探索这个新的 API

@Data
@Entity
@NoArgsConstructor(access = AccessLevel.PRIVATE)
@AllArgsConstructor
class Employee {

	@Id @GeneratedValue
	private Long id;
	private String firstName;
	private String lastName;
	private String role;

	/**
	 * Useful constructor when id is not yet known.
	 */
	Employee(String firstName, String lastName, String role) {

		this.firstName = firstName;
		this.lastName = lastName;
		this.role = role;
	}
}

这个域对象提供了一个非常简单的 POJO 供我们交互。要持久化这类对象,你需要定义一个相应的 Spring Data JPA 仓库

interface EmployeeRepository extends CrudRepository<Employee, Long> {
}

这个仓库将提供最简单的 CRUD 操作。

有了这些组件,舞台已经搭好。你现在可以开始定义 REST 操作及其相应的 affordances。首先,创建一个 Spring MVC REST 控制器,如下所示

@RestController
class EmployeeController {

	private final EmployeeRepository repository;

	EmployeeController(EmployeeRepository repository) {
		this.repository = repository;
	}

	...
}

这个控制器包含一些关键特性

  • @RestController 表明所有映射会将结果直接写入响应体,而不是渲染视图模板。

  • EmployeeRepository 通过构造函数注入,确保状态一致。

Spring HATEOAS 已经具备了从 Spring MVC 端点构建链接的能力。这个 API 提供的能力是将一个端点与另一个端点连接起来。例如,你可以将用于单个项资源(/employees/{id})的 GET 端点链接到用于更新该员工(/employees/{id})的 PUT 映射。以下代码展示了这种关系

@RestController
class EmployeeController {

	...

	@GetMapping("/employees/{id}")
	ResponseEntity<Resource<Employee>> findOne(@PathVariable long id) {

		return repository.findById(id)
			.map(employee -> new Resource<>(employee, getSingleItemLinks(employee.getId())))
			.map(ResponseEntity::ok)
			.orElse(ResponseEntity.notFound().build());
	}

	@PutMapping("/employees/{id}")
	ResponseEntity<?> updateEmployee(@RequestBody Employee employee, @PathVariable long id) {

		employee.setId(id);
		Employee updatedEmployee = repository.save(employee);

		Resource<Employee> employeeResource = new Resource<>(updatedEmployee, getSingleItemLinks(updatedEmployee.getId()));

		try {
			return ResponseEntity
				.created(new URI(employeeResource.getRequiredLink(Link.REL_SELF).getHref()))
				.body(employeeResource);
		} catch (URISyntaxException e) {
			return ResponseEntity.badRequest().body("Unable to update " + employee);
		}
	}

	...

	private List<Link> getSingleItemLinks(long id) {

		return Arrays.asList(linkTo(methodOn(EmployeeController.class).findOne(id)).withSelfRel()
				.andAffordance(afford(methodOn(EmployeeController.class).updateEmployee(null, id)))
				.andAffordance(afford(methodOn(EmployeeController.class).deleteEmployee(id))),
			linkTo(methodOn(EmployeeController.class).findAll()).withRel("employees"));
	}
}

在这段代码的中间,有几处使用了 .andAffordance(afford(methodOn(…​)))。这里是一个给定的链接(findOneself 链接)连接到同一 URI 上的相关链接(updateEmployee)。

有了这个版本,Spring HATEOAS 现在可以以媒体类型中立的格式获取 Spring MVC 端点的信息,从而可以将其提供给用户。这引出了一个问题——​你如何向用户展示这种链接关系?

介绍 HAL-FORMS

HAL 本身不包含任何用于显示这些 affordances 的格式。如果在给定资源的同一 URI 下有多个链接,HAL 只会简单地显示其中一个。值得庆幸的是,HAL 在 REST 领域越来越流行,这促使衍生规范开始发展。

HAL-FORMS 是一个 HAL 扩展,它看起来像任何其他 HAL 文档,但多了一个字段:_templates。这个字段允许显示方法以及属性。

假设上面提到的单个项 Resource<Employee> 代码将一个 Spring MVC @GetMapping 端点连接到一个 @PutMapping 端点(并且你的数据库中加载了一些员工数据),Spring HATEOAS 将会生成如下所示的 HAL-FORMS 超媒体

{
  "id" : 1,
  "firstName" : "Frodo",
  "lastName" : "Baggins",
  "role" : "ring bearer",
  "_links" : {
    "self" : {
      "href" : "http://localhost:8080/employees/1"
    },
    "employees" : {
      "href" : "http://localhost:8080/employees"
    }
  },
  "_templates" : {
    "default" : {
      "title" : null,
      "method" : "put",
      "contentType" : "",
      "properties" : [ {
        "name" : "firstName",
        "required" : true
      }, {
        "name" : "id",
        "required" : true
      }, {
        "name" : "lastName",
        "required" : true
      }, {
        "name" : "role",
        "required" : true
      } ]
    },
    "deleteEmployee" : {
      "title" : null,
      "method" : "delete",
      "contentType" : "",
      "properties" : [ ]
    }
  }
}

当你发起 GET /employees/1 请求时,这个 HAL-FORMS 文档同时显示数据链接。但更重要的是,它为你提供了一个编辑资源的模板(default 模板)。由于 HAL-FORMS 假定你正对 self 链接进行操作,你可以通过 PUT /employees/1 来进行更新。它期望的属性包括 firstName, id, lastName, 和 role

这些超媒体控件还表明你可以发起 DELETE /employees/1 请求(deleteEmployee 模板)。不涉及任何属性。

乍一看,这可能看起来不太令人印象深刻,因为你已经可以在上面显示的数据中读到这些信息了。但这种格式赋予你编写少量前端 JavaScript 并将该模板转换为以下内容的能力

<form method="put" action="http://localhost:8080/employees/1">
	<input type="text" id="firstName" name="firstName"/>
	<input type="text" id="id" name="id" />
	<input type="text" id="lastName" name="lastName" />
	<input type="text" id="role" name="role" />
	<input type="submit" value="Submit" />
</form>

通过将 self 链接与列出的属性混合,你可以创建一个完全由超媒体驱动的真实 HTML 表单。这通过让服务器将特定领域细节直接推送给网站用户,从而完成了 REST 的协同效应。无需将这些领域知识硬编码到客户端,从而降低了耦合度。相反,只需将超媒体的模板转换成一个表单即可。然后,当服务器端发生领域更新时,客户端可以轻松地适应。

简而言之,HAL-FORMS 旨在显示针对同一 URI 的其他可用行动。

读到这里,你是否问自己这样一个问题:“为什么不直接推送一个 HTML 表单,而不是一些 JSON 呢?”这是一个合理的问题。

Affordances API 完全是中立的,允许 Spring 团队开发除 HAL-FORMS 之外的其他媒体类型。一些正在开发中的包括 UberSIRENCollection+JSON 以及一种 XHTML 形式。

更多示例

本文中的代码和详细信息可在 https://github.com/spring-projects/spring-hateoas-examples 找到,特别是在 Affordances 部分下。

我们期待社区对该 API 的反馈。

致敬!

订阅 Spring 电子报

通过 Spring 电子报保持联系

订阅

抢占先机

VMware 提供培训和认证,助您加速进步。

了解更多

获取支持

Tanzu Spring 通过一个简单的订阅,为 OpenJDK™、Spring 和 Apache Tomcat® 提供支持和二进制文件。

了解更多

近期活动

查看 Spring 社区的所有近期活动。

查看全部