1. 概述
这篇文章重点讲解如何在 Spring REST 服务中实现“可发现性”(Discoverability),并满足 HATEOAS 约束的要求。
本文以 Spring MVC 为基础进行说明。如果你使用的是 Spring Boot,可以参考我们的另一篇文章:Spring HATEOAS 入门。
2. 使用事件解耦可发现性逻辑
将“可发现性”作为一个独立的切面(aspect)或关注点(concern),应当与处理 HTTP 请求的 Controller 解耦。为此,Controller 在执行完主要操作后,可以通过事件机制通知其他组件对响应进行进一步处理。
我们先定义两个事件类:
public class SingleResourceRetrieved extends ApplicationEvent {
private HttpServletResponse response;
public SingleResourceRetrieved(Object source, HttpServletResponse response) {
super(source);
this.response = response;
}
public HttpServletResponse getResponse() {
return response;
}
}
public class ResourceCreated extends ApplicationEvent {
private HttpServletResponse response;
private long idOfNewResource;
public ResourceCreated(Object source,
HttpServletResponse response, long idOfNewResource) {
super(source);
this.response = response;
this.idOfNewResource = idOfNewResource;
}
public HttpServletResponse getResponse() {
return response;
}
public long getIdOfNewResource() {
return idOfNewResource;
}
}
接着是 Controller 示例,提供两个基础操作:根据 ID 查询资源 和 创建新资源:
@RestController
@RequestMapping(value = "/foos")
public class FooController {
@Autowired
private ApplicationEventPublisher eventPublisher;
@Autowired
private IFooService service;
@GetMapping(value = "/{id}")
public Foo findById(@PathVariable("id") Long id, HttpServletResponse response) {
Foo resourceById = Preconditions.checkNotNull(service.findOne(id));
eventPublisher.publishEvent(new SingleResourceRetrieved(this, response));
return resourceById;
}
@PostMapping
@ResponseStatus(HttpStatus.CREATED)
public void create(@RequestBody Foo resource, HttpServletResponse response) {
Preconditions.checkNotNull(resource);
Long newId = service.create(resource).getId();
eventPublisher.publishEvent(new ResourceCreated(this, response, newId));
}
}
✅ 我们可以通过监听这些事件来实现具体的可发现性增强逻辑,这些监听器彼此解耦,各司其职,共同完成 HATEOAS 的目标。
注意:这些监听器不对外暴露,仅作为内部处理组件使用。
3. 新建资源 URI 的可发现性
在 之前关于 HATEOAS 的文章 中提到,创建资源的操作应在响应头中返回该资源的 URI,通常放在 Location
头中。
我们使用一个监听器来实现这个功能:
@Component
class ResourceCreatedDiscoverabilityListener
implements ApplicationListener<ResourceCreated> {
@Override
public void onApplicationEvent(ResourceCreated resourceCreatedEvent) {
Preconditions.checkNotNull(resourceCreatedEvent);
HttpServletResponse response = resourceCreatedEvent.getResponse();
long idOfNewResource = resourceCreatedEvent.getIdOfNewResource();
addLinkHeaderOnResourceCreation(response, idOfNewResource);
}
void addLinkHeaderOnResourceCreation(HttpServletResponse response, long idOfNewResource) {
URI uri = ServletUriComponentsBuilder.fromCurrentRequestUri()
.path("/{idOfNewResource}")
.buildAndExpand(idOfNewResource)
.toUri();
response.setHeader("Location", uri.toASCIIString());
}
}
⚠️ 这里我们借助了 ServletUriComponentsBuilder
来构建当前请求上下文中的 URI,非常方便。如果使用 ResponseEntity
,也可以直接利用其内置的 Location
支持。
4. 单个资源的可发现性
当客户端获取单个资源时,应该能从响应中发现获取该类资源集合的 URI。
我们通过监听器来实现:
@Component
class SingleResourceRetrievedDiscoverabilityListener
implements ApplicationListener<SingleResourceRetrieved> {
@Override
public void onApplicationEvent(SingleResourceRetrieved resourceRetrievedEvent) {
Preconditions.checkNotNull(resourceRetrievedEvent);
HttpServletResponse response = resourceRetrievedEvent.getResponse();
addLinkHeaderOnSingleResourceRetrieval(response);
}
void addLinkHeaderOnSingleResourceRetrieval(HttpServletResponse response) {
String requestURL = ServletUriComponentsBuilder.fromCurrentRequestUri()
.build()
.toUri()
.toASCIIString();
int positionOfLastSlash = requestURL.lastIndexOf("/");
String uriForResourceCreation = requestURL.substring(0, positionOfLastSlash);
String linkHeaderValue = LinkUtil.createLinkHeader(uriForResourceCreation, "collection");
response.addHeader("Link", linkHeaderValue);
}
}
📌 链接关系语义使用了 "collection"
类型,这在 一些微格式 中有使用,但尚未被正式标准化。
创建 Link 头的工具类:
public class LinkUtil {
public static String createLinkHeader(String uri, String rel) {
return "<" + uri + ">; rel=\"" + rel + "\"";
}
}
5. 根路径的可发现性
API 的入口是根路径(/
),这是客户端首次接触 API 的地方。如果要真正实现 HATEOAS,所有主要资源的 URI 都应该从根路径可发现。
来看一个示例 Controller:
@GetMapping("/")
@ResponseStatus(value = HttpStatus.NO_CONTENT)
public void adminRoot(final HttpServletRequest request, final HttpServletResponse response) {
String rootUri = request.getRequestURL().toString();
URI fooUri = new UriTemplate("{rootUri}{resource}").expand(rootUri, "foos");
String linkToFoos = LinkUtil.createLinkHeader(fooUri.toASCIIString(), "collection");
response.addHeader("Link", linkToFoos);
}
⚠️ 这只是一个示例,仅展示了 foos
资源的链接。在实际项目中应列出所有公开的资源接口。
5.1. 可发现性 ≠ URI 可变
虽然 HATEOAS 强调客户端应通过服务端响应来发现 URI,但这并不意味着 URI 必须频繁变化。实际上,好的 RESTful API 的 URI 应该是稳定的(Cool URIs don't change)。
但客户端应始终优先通过响应中提供的链接进行导航,而不是硬编码 URI。这样在 API 升级时,旧 URI 仍可访问,同时新客户端可以自动适应变化。
✅ 所以,HATEOAS 是 API 演进过程中非常有价值的实践。
6. 可发现性的局限性
虽然 HATEOAS 的目标是“尽可能减少文档依赖”,让客户端通过响应理解 API 用法,但在实践中仍有不少挑战:
- 当前规范和框架支持仍在演进中
- 客户端解析 Link 头或 HAL 格式需要额外开发工作
- 不同客户端对可发现性的支持程度不同
因此,在实际项目中,我们往往需要在“完全 HATEOAS”和“实用主义”之间做平衡。
7. 总结
本文展示了如何在 Spring MVC 环境下实现 REST 服务的可发现性,包括:
- 使用事件机制解耦可发现性逻辑
- 在资源创建时返回 Location 头
- 在获取单个资源时提供集合 URI 链接
- 在根路径提供所有主要资源的入口
✅ 所有示例代码均可在 GitHub 项目 中找到,项目基于 Maven 构建,可直接导入运行。