Contents

ETags 与 Spring 集成

1. 概述

本文将重点介绍在 Spring中使用 ETags、REST API 的集成测试以及使用curl的消费场景。

2. REST 和 ETag

来自有关 ETag 支持的官方 Spring 文档:

ETag (实体标签)是由符合 HTTP/1.1 的 Web 服务器返回的HTTP 响应标头,用于确定给定 URL 的内容更改。

我们可以将 ETag 用于两件事——缓存和条件请求。ETag 值可以被认为是从响应体的字节中计算出来的哈希值。因为服务可能使用加密散列函数,所以即使是对主体的最小修改也会极大地改变输出,从而改变 ETag 的值。这仅适用于强 ETag——该协议也提供了弱 Etag

**使用 If-* 标头将标准 GET 请求转换为条件 GET。**与 ETag 一起使用的两个 If-* 标头是“ If-None-Match ”和“ If-Match ”——每个都有自己的语义,如本文后面所述。

3. 使用curl的客户端-服务器通信

我们可以将涉及 ETag 的简单客户端-服务器通信分解为以下步骤:

首先,客户端进行 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

最后,我们发出最后一个请求以再次检索 Foo。请记住,自上次请求以来我们已经对其进行了更新,因此之前的 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 支持:在 Spring 中使用 ETag 非常容易设置并且对应用程序完全透明。我们可以通过在web.xml中添加一个简单的过滤器来启用支持

<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>

我们将过滤器映射到与 RESTful API 本身相同的 URI 模式上。过滤器本身是自 Spring 3.0 以来 ETag 功能的标准实现。

实现是浅层的——应用程序根据响应计算 ETag,这将节省带宽但不会节省服务器性能。

因此,受益于 ETag 支持的请求仍将作为标准请求处理,消耗它通常会消耗的任何资源(数据库连接等),并且只有在将其响应返回给客户端之前,ETag 支持才会启动在。

此时,ETag 将从响应主体中计算出来并设置在资源本身上;此外,如果在请求上设置了If-None-Match标头,它也会被处理。

ETag 机制的更深层次的实现可能会提供更大的好处——例如从缓存中处理一些请求并且根本不必执行计算——但实现肯定不会像浅层方法那样简单,也不像可插入的方法描述here。

4.1.基于 Java 的配置

让我们通过在 Spring 上下文中声明一个ShallowEtagHeaderFilter bean来看看基于 Java 的配置是什么样子的:

@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 值

例如,假设我们使用版本化实体作为 Optimist Locking 机制 来访问我们的数据库信息。

我们可以使用版本本身作为 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. 测试 ETag

让我们从简单的开始——我们需要验证检索单个资源的简单请求的响应是否实际上会返回“ 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 行为的路径。**如果从服务器检索Resource的请求使用正确的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);
}

一步步:

  • 我们首先创建和检索Resource——并存储ETag值以供进一步使用
  • 然后我们更新相同的Resource
  • 发送一个新的 GET 请求,这次使用“ If-None-Match ”标头指定我们之前存储的ETag
  • 在第二个请求中,服务器将返回200 OK以及完整的资源,因为ETag值不再正确,因为我们同时更新了资源

最后,最后一个测试——由于该功能尚未在 Spring 中实现而无法 工作——是If-Match HTTP 标头的支持:

@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);
}

一步步:

  • 我们创建一个资源
  • 然后使用指定错误ETag值的“ If-Match ”标头检索它——这是一个有条件的 GET 请求
  • 服务器应该返回412 Precondition Failed

6. ETag 很大

**我们只将 ETag 用于读取操作。**存在一个RFC ,试图阐明实现应该如何处理写操作中的 ETags——这不是标准的,但读起来很有趣。

当然,ETag 机制还有其他可能的用途,例如用于乐观锁定机制以及处理相关的“丢失更新问题”

在使用 ETag 时,还有几个已知的潜在陷阱和注意事项 需要注意。