1. 概述

本文将深入探讨 在 Spring 中使用 ETags 的实践方法,包括 REST API 的集成测试以及使用 curl 模拟客户端消费场景。

2. REST 与 ETags

根据 Spring 官方文档对 ETag 的说明:

ETag(实体标签)是符合 HTTP/1.1 规范的 Web 服务器返回的 HTTP 响应头,用于判断指定 URL 的内容是否发生变化。

ETags 主要用于两个场景:缓存和条件请求。ETag 值可视为响应体的哈希值。由于服务通常采用加密哈希函数,即使响应体发生微小改动,也会导致 ETag 值显著变化——这仅适用于强 ETag(协议也支持弱 ETag)。

使用 If-* 请求头可将标准 GET 请求转为条件 GET。与 ETag 配合使用的两个核心请求头是 "If-None-Match" 和 "If-Match",具体语义将在后文详述。

3. 使用 curl 模拟客户端通信

ETags 的客户端-服务器交互流程可分解为以下步骤:

首先,客户端发起 REST API 调用——响应中会包含 ETag 头,需存储以备后续使用:

curl -H "Accept: application/json" -i http://localhost:8080/spring-boot-rest/foos/1
HTTP/1.1 200 OK
ETag: "f88dd058fe004909615a64f01be66a7"
Content-Type: application/json;charset=UTF-8
Content-Length: 52

下次请求时,客户端需携带 If-None-Match 请求头(值为上次的 ETag)。若服务端资源未变更,响应将无响应体且状态码为 304 Not Modified

curl -H "Accept: application/json" -H 'If-None-Match: "f88dd058fe004909615a64f01be66a7"'
 -i http://localhost:8080/spring-boot-rest/foos/1
HTTP/1.1 304 Not Modified
ETag: "f88dd058fe004909615a64f01be66a7"

现在,我们先更新资源(模拟变更):

curl -H "Content-Type: application/json" -i 
  -X PUT --data '{ "id":1, "name":"Transformers2"}' 
    http://localhost:8080/spring-boot-rest/foos/1
HTTP/1.1 200 OK
ETag: "d41d8cd98f00b204e9800998ecf8427e" 
Content-Length: 0

最后,再次请求该资源。注意资源已被修改,原 ETag 失效。响应将包含新数据和新 ETag:

curl -H "Accept: application/json" -H 'If-None-Match: "f88dd058fe004909615a64f01be66a7"' -i 
  http://localhost:8080/spring-boot-rest/foos/1
HTTP/1.1 200 OK
ETag: "03cb37ca667706c68c0aad4cb04c3a211"
Content-Type: application/json;charset=UTF-8
Content-Length: 56

至此,ETags 的实际应用已清晰展示——有效节省了带宽。

4. Spring 中的 ETag 支持

Spring 对 ETag 的支持极其简单,且对应用完全透明。只需在 web.xml 中添加一个 Filter 即可启用:

<filter>
   <filter-name>etagFilter</filter-name>
   <filter-class>org.springframework.web.filter.ShallowEtagHeaderFilter</filter-class>
</filter>
<filter-mapping>
   <filter-name>etagFilter</filter-name>
   <url-pattern>/foos/*</url-pattern>
</filter-mapping>

我们将过滤器映射到 REST API 的相同 URI 路径。该过滤器是 Spring 3.0 以来 ETag 功能的标准实现。

当前实现是浅层(Shallow)的——应用基于响应计算 ETag,虽节省带宽但未优化服务器性能。

因此,受益于 ETag 的请求仍会作为标准请求处理,消耗正常资源(如数据库连接),仅在返回响应前触发 ETag 逻辑。此时会根据响应体计算 ETag 并设置到响应头,同时处理请求中的 If-None-Match 头。

深度 ETag 实现可能带来更大收益(如直接从缓存响应,避免计算),但实现复杂度远高于此处的浅层方案。

4.1. Java 配置方式

通过 在 Spring 上下文中声明 ShallowEtagHeaderFilter Bean 实现配置:

@Bean
public ShallowEtagHeaderFilter shallowEtagHeaderFilter() {
    return new ShallowEtagHeaderFilter();
}

若需额外配置,可改用 FilterRegistrationBean

@Bean
public FilterRegistrationBean<ShallowEtagHeaderFilter> shallowEtagHeaderFilter() {
    FilterRegistrationBean<ShallowEtagHeaderFilter> filterRegistrationBean
      = new FilterRegistrationBean<>( new ShallowEtagHeaderFilter());
    filterRegistrationBean.addUrlPatterns("/foos/*");
    filterRegistrationBean.setName("etagFilter");
    return filterRegistrationBean;
}

⚠️ 非 Spring Boot 项目可通过 AbstractAnnotationConfigDispatcherServletInitializergetServletFilters 方法配置。

4.2. 使用 ResponseEntity 的 eTag() 方法

Spring 4.1 引入此方法,用于精确控制单个接口的 ETag 值

例如,将版本化实体作为乐观锁机制访问数据库时,可直接用版本号作为 ETag:

@GetMapping(value = "/{id}/custom-etag")
public ResponseEntity<Foo>
  findByIdWithCustomEtag(@PathVariable("id") final Long id) {

    // ...Foo foo = ...

    return ResponseEntity.ok()
      .eTag(Long.toString(foo.getVersion()))
      .body(foo);
}

当请求的条件头与缓存数据匹配时,服务将返回 304 Not Modified 状态。

5. 测试 ETags

从基础测试开始——验证资源检索响应是否包含 ETag

@Test
public void givenResourceExists_whenRetrievingResource_thenEtagIsAlsoReturned() {
    // Given
    String uriOfResource = createAsUri();

    // When
    Response findOneResponse = RestAssured.given().
      header("Accept", "application/json").get(uriOfResource);

    // Then
    assertNotNull(findOneResponse.getHeader("ETag"));
}

下一步验证 ETag 的标准行为:若请求携带正确的 ETag,服务端不返回资源体:

@Test
public void givenResourceWasRetrieved_whenRetrievingAgainWithEtag_thenNotModifiedReturned() {
    // Given
    String uriOfResource = createAsUri();
    Response findOneResponse = RestAssured.given().
      header("Accept", "application/json").get(uriOfResource);
    String etagValue = findOneResponse.getHeader(HttpHeaders.ETAG);

    // When
    Response secondFindOneResponse= RestAssured.given().
      header("Accept", "application/json").headers("If-None-Match", etagValue)
      .get(uriOfResource);

    // Then
    assertTrue(secondFindOneResponse.getStatusCode() == 304);
}

执行步骤:

  1. 创建并检索资源,存储 ETag 值
  2. 发送新请求,携带 "If-None-Match" 头(值为上步存储的 ETag)
  3. 第二次请求返回 304 Not Modified,因资源未变更

最后验证资源变更场景

@Test
public void 
  givenResourceWasRetrievedThenModified_whenRetrievingAgainWithEtag_thenResourceIsReturned() {
    // Given
    String uriOfResource = createAsUri();
    Response findOneResponse = RestAssured.given().
      header("Accept", "application/json").get(uriOfResource);
    String etagValue = findOneResponse.getHeader(HttpHeaders.ETAG);

    existingResource.setName(randomAlphabetic(6));
    update(existingResource);

    // When
    Response secondFindOneResponse= RestAssured.given().
      header("Accept", "application/json").headers("If-None-Match", etagValue)
      .get(uriOfResource);

    // Then
    assertTrue(secondFindOneResponse.getStatusCode() == 200);
}

执行步骤:

  1. 创建并检索资源,存储 ETag
  2. 更新该资源
  3. 再次发送 GET 请求,携带原 ETag
  4. 因资源已变更,服务返回 200 OK 及完整资源

❌ 以下测试当前无法通过(因Spring 尚未实现 If-Match 支持):

@Test
public void givenResourceExists_whenRetrievedWithIfMatchIncorrectEtag_then412IsReceived() {
    // Given
    T existingResource = getApi().create(createNewEntity());

    // When
    String uriOfResource = baseUri + "/" + existingResource.getId();
    Response findOneResponse = RestAssured.given().header("Accept", "application/json").
      headers("If-Match", randomAlphabetic(8)).get(uriOfResource);

    // Then
    assertTrue(findOneResponse.getStatusCode() == 412);
}

执行步骤:

  1. 创建资源
  2. 使用 "If-Match" 头携带错误 ETag 检索资源(条件 GET)
  3. 服务应返回 412 Precondition Failed

6. ETags 的扩展应用

目前仅讨论了 ETags 在读操作中的应用。现有 RFC 草案 试图规范写操作中的 ETag 处理——虽非标准,但值得参考。

ETags 还可用于:

⚠️ 使用时需注意已知陷阱,如:

  • 分布式系统中的 ETag 同步问题
  • 弱 ETag 与强 ETag 的误用场景

7. 总结

本文仅触及了 Spring 与 ETags 结合的表层能力。完整的 ETag 启用 REST 服务实现及集成测试示例,可参考 GitHub 项目


原始标题:ETags for REST with Spring