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 项目可通过 AbstractAnnotationConfigDispatcherServletInitializer 的 getServletFilters 方法配置。
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);
}
执行步骤:
- 创建并检索资源,存储 ETag 值
- 发送新请求,携带 "If-None-Match" 头(值为上步存储的 ETag)
- 第二次请求返回 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);
}
执行步骤:
- 创建并检索资源,存储 ETag
- 更新该资源
- 再次发送 GET 请求,携带原 ETag
- 因资源已变更,服务返回 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);
}
执行步骤:
- 创建资源
- 使用 "If-Match" 头携带错误 ETag 检索资源(条件 GET)
- 服务应返回 412 Precondition Failed
6. ETags 的扩展应用
目前仅讨论了 ETags 在读操作中的应用。现有 RFC 草案 试图规范写操作中的 ETag 处理——虽非标准,但值得参考。
ETags 还可用于:
- 乐观锁机制
- 解决“更新丢失”问题
⚠️ 使用时需注意已知陷阱,如:
- 分布式系统中的 ETag 同步问题
- 弱 ETag 与强 ETag 的误用场景
7. 总结
本文仅触及了 Spring 与 ETags 结合的表层能力。完整的 ETag 启用 REST 服务实现及集成测试示例,可参考 GitHub 项目。