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" 类型,这在 一些微格式 中有使用,但尚未被正式标准化。

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 构建,可直接导入运行。


原始标题:HATEOAS for a Spring REST Service