带有Spring Framework的REST API中的并发控制


在现代软件系统中,有成百上千的用户独立并同时与我们的资源进行交互并不罕见。我们通常希望避免一种情况,即一个客户所做的更改被另一个客户所覆盖甚至不知道。为了防止破坏我们的数据完整性,我们经常使用数据库引擎提供的锁定机制,甚至使用JPA之类的工具提供的抽象。

您是否曾经想过并发控制应如何反映在我们的API中?当两个用户同时更新同一记录时会发生什么?我们会发送任何错误消息吗?我们将使用什么HTTP响应代码?我们将附加哪些HTTP标头?

本文的目的是就如何为REST API建模提供全面的指导,以便它支持对资源的并发控制并利用HTTP协议的功能。我们还将在Spring Framework的帮助下实现此解决方案。

请注意,尽管我们简要介绍了并发数据访问,但本文并未涵盖锁,隔离级别或事务如何工作的任何内部信息。我们将严格关注API。

Use Case 我们将要使用的用例基于DDD参考项目– library。想象一下,我们有一个系统可以自动完成顾客搁置书籍的过程。为了简单起见,让我们假设每本书可以处于以下两种可能状态之一:可用和被搁置。仅当图书存在于图书馆中且当前可用时,才可以将其搁置。这是在EventStorming会话期间可以如何建模的方式:

1590002765288.png

每个顾客可以将书置于保留状态(发送命令)。为了做出这样的决定,他/她需要首先查看可用书籍的列表(查看阅读模型)。根据不变量,我们将允许或不允许该过程成功。

我们还假设,我们已做出决定来制作Book我们的主要骨料。可视化的上述过程Web Sequence Diagrams可能如下所示:

Web序列图

1590002810054.png

就像我们在这张图中看到的那样,布鲁斯成功地将书123搁置了,而史蒂夫需要处理4xx异常。我们xx应该在这里放什么?我们将在一秒钟之内回到它。

让我们从提供最小可行的产品开始,暂时不注意并发访问。这是我们的简单测试的样子。

@SpringBootTest(webEnvironment = RANDOM_PORT)
@RunWith(SpringRunner.class)
@AutoConfigureMockMvc
public class BookAPITest {

  @Autowired

  private MockMvc mockMvc;

  @Autowired
  private BookRepository bookRepository;

  @Test
  public void shouldReturnNoContentWhenPlacingAvailableBookOnHold() throws Exception {
    //given
    AvailableBook availableBook = availableBookInTheSystem();

    //when
    ResultActions resultActions = sendPlaceOnHoldCommandFor(availableBook.id());

    //then
    resultActions.andExpect(status().isNoContent());
  }

  private ResultActions sendPlaceOnHoldCommandFor(BookId id) throws Exception {
    return mockMvc
            .perform(patch("/books/{id}", id.asString())
                    .content("{"status" : "PLACED_ON_HOLD"}")
                    .header(CONTENT_TYPE, APPLICATION_JSON_VALUE));
  }

  private AvailableBook availableBookInTheSystem() {
    AvailableBook availableBook = BookFixture.someAvailableBook();
    bookRepository.save(availableBook);
    return availableBook;
  }
}

这是其实现的样子:

@RestController
@RequestMapping("/books")
class BookController {

  private final PlacingOnHold placingOnHold;

  BookController(PlacingOnHold placingOnHold) {
    this.placingOnHold = placingOnHold;
  }

  @PatchMapping("/{bookId}")
  ResponseEntity updateBookStatus(@PathVariable("bookId") UUID bookId,
                                  @RequestBody UpdateBookStatus command) {
    if (PLACED_ON_HOLD.equals(command.getStatus())) {
        placingOnHold.placeOnHold(BookId.of(bookId));
        return ResponseEntity.noContent().build();
    } else {
        return ResponseEntity.ok().build(); //we do not care about it now
    }
  }
}

我们还可以再添加一项检查来补充我们的测试课程:

@Test
public void shouldReturnBookOnHoldAfterItIsPlacedOnHold() throws Exception {
  //given

  AvailableBook availableBook = availableBookInTheSystem();

  //and
  sendPlaceOnHoldCommandFor(availableBook.id());

  //when
  ResultActions resultActions = getBookWith(availableBook.id());

  //then
  resultActions.andExpect(status().isOk())
        .andExpect(jsonPath("$.id").value(availableBook.id().asString()))
        .andExpect(jsonPath("$.status").value("PLACED_ON_HOLD"));
}

状态比较与锁定 好的。我们刚刚提供了搁置书籍的功能。但是,域驱动设计中的聚合应该是不变式的堡垒-它们的主要作用是使所有业务规则始终得到满足并提供操作的原子性。我们在上一节中发现和描述的业务规则之一是,一本书只有在可用时才能被搁置。这个规则是否总是得到遵守?

好吧,让我们尝试分析一下。我们在代码中提供的第一件事是类型系统-从函数式编程中借用的一个概念。Book我们没有提供带有状态字段和大量if语句的多用途类,而是提供了AvailableBookandPlacedOnHoldBook 类。在此设置中,只有AvailableBook该placeOnHold方法。我们的应用程序足以保护不变量吗?

如果两个不同的顾客试图顺序搁置同一本书–答案是肯定的,因为将在这里为我们提供支持的编译器。否则,无论如何我们都需要处理并发访问-这就是我们现在要做的。这里有两个可能的选择:完整状态比较和锁定。在本文中,我们将简要介绍前一种选择,将更多的注意力放在后者上。

完整状态比较 这个术语背后隐藏着什么?好吧,如果我们要保护自己免受所谓的丢失更新的影响,那么在保持聚合状态的同时,我们需要做的是检查同时要更新的聚合是否未被其他人更改。可以通过将更新之前的聚合的属性与数据库中当前的属性进行比较来完成这种检查。如果比较结果为肯定,我们可以保留聚合的新版本。这些操作(比较和更新)必须是原子的。

该解决方案的优点是它不会影响聚合的结构-技术持久性详细信息不会泄漏到域层或上面的任何其他层中。但是,由于我们需要具有聚合的先前状态才能进行完全比较,因此需要通过存储库端口将此状态传递给我们的持久层。反过来,这会影响存储库的签名save方法,并且还需要在应用程序层进行调整。但是,它比第二种解决方案更干净,您将在下一段中看到它。在继续进行之前,还值得注意的是,该解决方案承担了对数据库进行潜在的计算繁重搜索的负担。如果我们的总量很大,那么在数据库上维护完整索引可能会很痛苦。功能索引可能会有所帮助。

锁定 第二种选择是使用锁定机制。从高级的角度来看,我们可以区分两种类型的锁定:悲观锁定和乐观锁定。

前一种类型是我们的应用程序获取特定资源的排他锁或共享锁。如果我们要修改某些数据,则只有排他锁是唯一的选择。然后,我们的客户可以操纵资源,甚至不让任何其他人读取数据。但是,共享锁不允许我们操纵资源,并且对其他仍可以读取数据的客户端的限制较少。

相反,开放式锁定使每个客户端都可以随意读写数据,但有一个限制,即在提交事务之前,我们需要检查同时特定的记录是否未被其他人修改。通常,这是通过添加当前版本或上次修改时间戳属性来完成的。

当写操作的数量与读操作相比不是那么多时,乐观锁定通常是默认选择。

数据访问层中的乐观锁定 在Java世界中,通常使用JPA来处理包括锁定功能在内的数据访问。可以通过在实体中声明版本属性并用@Version注释对其进行标记来启用JPA中的乐观锁定。从测试开始,让我们看一下它的外观。

@SpringBootTest(webEnvironment = NONE)
@RunWith(SpringRunner.class)
public class OptimisticLockingTest {

  @Autowired
  private BookRepositoryFixture bookRepositoryFixture;

  @Autowired
  private BookRepository bookRepository;

  private PatronId somePatronId = somePatronId();

  @Test(expected = StaleStateIdentified.class)
  public void savingEntityInCaseOfConflictShouldResultInError() {
    //given
    AvailableBook availableBook = bookRepositoryFixture.availableBookInTheSystem();

    //and
    AvailableBook loadedBook = (AvailableBook) bookRepository.findBy(availableBook.id()).get();

    PlacedOnHoldBook loadedBookPlacedOnHold = loadedBook.placeOnHoldBy(somePatronId);

    //and
    bookWasModifiedInTheMeantime(availableBook);

    //when
    bookRepository.save(loadedBookPlacedOnHold);

  }

  private void bookWasModifiedInTheMeantime(AvailableBook availableBook) {
    PatronId patronId = somePatronId();
    PlacedOnHoldBook placedOnHoldBook = availableBook.placeOnHoldBy(patronId);
    bookRepository.save(placedOnHoldBook);
  }
}

为了使此测试通过,我们需要提供一些信息:

BookEntity在基础架构层的JPA中引入上述版本属性

@Entity @Table(name = "book")
class BookEntity {
  //... 
  @Version
  private long version;
  //...
}

将此版本进一步传递到域模型中。由于域模型基于特定于域的抽象定义了存储库(接口),因此为了使基础结构(JPA)检查实体版本成为可能,也要在域中使用该版本。为此,我们引入了Version值对象,并将其添加到Book汇总中。

public class Version {
  private final long value;

  private Version(long value) {
    this.value = value;
  }


  public static Version from(long value) {
    return new Version(value);
  }

  public long asLong() {
    return value;
  }
}
public interface Book { 
  //...
  Version version()
}

引入StaleStateIdentified针对并发访问冲突的特定于域或通用的异常。根据Dependency Inversion Principle,具有较高抽象级别的模块不应依赖于具有较低抽象级别的模块。这就是为什么我们应该将其放置在域模块或支持模块中,而不是基础结构中的原因。由于转换了低级异常,该异常应由基础结构适配器实例化并引发OptimisticLockingFailureException

public class StaleStateIdentified extends RuntimeException {

  private StaleStateIdentified(UUID id) {     
    super(String.format("Aggregate of id %s is stale", id));
  }

  public static StaleStateIdentified forAggregateWith(UUID id) {     
    return new StaleStateIdentified(id);
  }
}

实例化并引发基础架构适配器中的异常,这是由于底层异常的转换而导致的OptimisticLockingFailureException

@Component
class JpaBasedBookRepository implements BookRepository {

    private final JpaBookRepository jpaBookRepository;

    //constructor + other methods

    @Override
    public void save(Book book) {
        try {
            BookEntity entity = BookEntity.from(book);
            jpaBookRepository.save(entity);
        } catch (OptimisticLockingFailureException ex) {

            throw StaleStateIdentified.forAggregateWith(book.id().getValue());
        }
    }
}

interface JpaBookRepository extends Repository<BookEntity, UUID> {
    void save(BookEntity bookEntity);
}

好的。我们的测试现在通过了。现在的问题是,如果StaleStateIdentified引发API会在我们的API中发生什么?默认情况下,500 INTERNAL SERVER ERROR将返回状态,这绝对不是我们希望看到的状态。现在该是我们处理StaleStateIdentified异常的时候了。

在REST API中处理乐观锁定 并发访问冲突时应该怎么办?我们的API应该返回什么?我们的最终用户应该看到什么?

在提出解决方案之前,让我们强调一下,在大多数情况下,开发人员不应该回答这些问题,因为这种冲突通常是业务问题,而不是技术问题(即使我们坚信是这样)。让我们看下面的例子:

Dev:“如果两位顾客试图搁置同一本书,而其中一位却因为第二次尝试而被拒绝,我们该怎么办?”

Business:“告诉他太可惜了。”

Dev:“如果是我们的优质赞助人呢?”

Business:“哦,好吧,我们应该打个电话给他。是的。在这种情况下,请给我发送电子邮件,我将与他联系并为此道歉,并尝试为他找到其他副本。”

我们可以找到无数示例,证明技术解决方案应始终由真实的业务规则来驱动。

为了简单起见,让我们假设,我们只是想告诉客户我们很抱歉。HTTP协议提供的非常基本的机制可以在RFC 7231超文本传输​​协议(HTTP / 1.1)中找到:语义和内容,它与返回409 CONFLICT响应有关。这是文档中说明的内容:

该409 (Conflict)状态代码表示请求无法 完成,因为与目标的当前状态发生冲突 的资源。在用户可能 能够解决冲突并重新提交请求的情况下,将使用此代码。服务器 应该生成一个有效载荷,该有效载荷应包含足够的信息以 使用户能够识别冲突的根源。

响应PUT请求最有可能发生冲突。对于 例如,如果版本正在使用,并表示是 PUT包括与那些制造冲突变为资源 较早的(第三方)的请求,原始服务器可能使用409 响应,表明它无法完成要求。在这种 情况下,响应表示可能会包含 有用的信息,这些信息可用于基于修订历史记录合并差异。

这不是我们想要的东西吗?那好吧。让我们尝试编写一个反映上面所写内容的测试。

@Test
public void shouldSignalConflict() throws Exception {
  //given
  AvailableBook availableBook = availableBookInTheSystem();
  //and
  BookView book = api.viewBookWith(availableBook.id());

  //and
  AvailableBook updatedBook = bookWasModifiedInTheMeantime(bookIdFrom(book.getId()));

  //when Bruce places book on hold
  PatronId bruce = somePatronId();
  ResultActions bruceResult =  api.sendPlaceOnHoldCommandFor(book.getId(), bruce,
        book.getVersion());

  //then
  bruceResult
      .andExpect(status().isConflict())
      .andExpect(jsonPath("$.id").value(updatedBook.id().asString()))
      .andExpect(jsonPath("$.title").value(updatedBook.title().asString()))
      .andExpect(jsonPath("$.isbn").value(updatedBook.isbn().asString()))
      .andExpect(jsonPath("$.author").value(updatedBook.author().asString()))
      .andExpect(jsonPath("$.status").value("AVAILABLE"))
      .andExpect(jsonPath("$.version").value(not(updatedBook.version().asLong())));
}

这里发生的是,我们对系统中可用的书所做的第一件事就是获得其视图。为了启用并发访问控制,视图响应需要包含与我们在域模型中已经拥有的版本属性相对应的版本属性。除其他外,它包含在我们发送的将书置于保留状态的命令中。但是,与此同时,我们修改了该书(强制更新版本属性)。结果,我们期望得到一个409 CONFLICT响应,该响应指示由于与目标资源的当前状态冲突而无法完成该请求。此外,我们希望响应表示形式可能包含有用的信息,这些信息可用于根据修订历史记录合并差异,这就是为什么我们检查响应正文是否包含该书的当前状态。

请注意,在测试方法的最后一行中,我们不检查的确切值version。其背后的原因是,在REST控制器的上下文中,我们不(也不应该)关心此属性的计算和更新方式-它发生变化的事实足以提供信息。因此,我们解决了测试中关注点分离的问题。

准备好测试后,我们可以立即更新REST控制器。

@RestController
@RequestMapping("/books")
class BookHoldingController {

  private final PlacingOnHold placingOnHold;

  BookHoldingController(PlacingOnHold placingOnHold) {
    this.placingOnHold = placingOnHold;

  }

  @PatchMapping("/{bookId}")
  ResponseEntity updateBookStatus(@PathVariable("bookId") UUID bookId,
                                  @RequestBody UpdateBookStatus command) {
    if (PLACED_ON_HOLD.equals(command.getStatus())) {
        PlaceOnHoldCommand placeOnHoldCommand =
            new PlaceOnHoldCommand(BookId.of(bookId), command.patronId(), command.version());
        Result result = placingOnHold.handle(placeOnHoldCommand);
        return buildResponseFrom(result);
    } else {
        return ResponseEntity.ok().build(); //we do not care about it now
    }
  }

  private ResponseEntity buildResponseFrom(Result result) {
    if (result instanceof BookPlacedOnHold) {
        return ResponseEntity.noContent().build();
    } else if (result instanceof BookNotFound) {
        return ResponseEntity.notFound().build();
    } else if (result instanceof BookConflictIdentified) {
        return ResponseEntity.status(HttpStatus.CONFLICT)
                .body(((BookConflictIdentified) result)
                        .currentState()
                        .map(BookView::from)
                        .orElse(null));
    } else {
        return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
    }
  }
}

updateBookStatus方法中的第一个验证是检查是否请求保留书本。如果是这样,将构建一个命令对象,并将其进一步传递给应用程序层服务– placingOnHold.handle()。根据服务调用的结果,我们可以构建适当的API响应。如果处理成功(BookPlacedOnHold),我们将返回204 NO_CONTENT。如果请求尝试修改不存在的资源(BookNotFound),则返回404 NOT_FOUND。在我们的上下文选项中,第三个也是最重要的是BookConflictIdentified。如果得到这样的响应,我们的API将返回409 CONFLICT消息,其中的正文包含最新的书本视图。此时,命令处理的任何其他结果都不是预期的,并且将其视为500 INTERNAL_SERVER_ERROR

如果消费者得到409,则需要解释状态码并分析内容,以确定可能是冲突的根源。根据RFC 5789,这些是patch确定使用者是否可以按原样重新发出请求,重新计算补丁或失败的应用程序和格式。在我们的情况下,我们无法重试保留其格式的消息。其背后的原因是该version属性已更改。即使我们应用了新版本,在重新发送消息之前,我们也需要检查冲突的根源–仅当冲突不是由于将书的状态更改为PLACED_ON_HOLD(我们只能保留可用的图书)。不影响状态的任何其他更改(标题,作者等)都不会影响业务不变式,从而允许消费者重新发出请求。

值得指出的是,将乐观锁定与version传递给API的属性一起使用和状态比较之间存在差异。不好的是,需要将version属性添加到我们的域,应用程序和API级别,从而导致持久层泄漏技术细节。不过,好处是,现在为了执行更新,该WHERE子句可以限制为aggregate IDandversion字段。简化基于以下事实:状态现在由一个参数而不是整个参数表示。关于发生冲突时的API响应,情况几乎相同。两种方法都迫使我们的客户分析响应并做出是否可以重传的决定。

务实地看待这个问题,我们可以提出一些赞成使用乐观锁定的论点。

Domain很脏,但是API简洁明了,并且使用前提条件的方式更容易(在后续章节中将对此主题进行详细介绍) Version 有时可能出于业务目的,例如出于审计目的,因此我们有可能获得更多 如果version仍然难以接受,我们可以使用Last-Modifiedattribute并将其发送到标头中。在许多企业中,最后修改资源的时间可能具有更大的意义。 ETag Header 您是否发现在前面提到的两种方法中,我们实际上都在数据库上执行条件更新?这不是说我们的请求是有条件的吗?是的,确实可以,因为我们仅允许客户在此期间未对其进行修改的情况下才对其进行更新。在第一种情况下,我们需要比较集合的所有属性,而在第二种情况下,我们仅检查version和aggregate ID是否相同。所有属性一致性和基于版本的一致性都定义了要满足请求的前提条件。

HTTP协议中有一种处理条件请求的显式标准方法。RFC 7232定义了此概念,包括一组指示资源状态和前提条件的元数据标头:

条件请求是HTTP请求[RFC7231],其中包括一个或多个标头字段,这些标头字段指示在将方法语义应用于目标资源之前要测试的前提条件。

RFC 7232区分条件读取和写入请求。前者通常用于有效的缓存机制,这不在本文的讨论范围之内。后面的请求是我们将要重点关注的。让我们继续一些理论。

条件请求处理的最基本组成部分是ETag(Entity Tag)标头,只要我们通过GET请求读取资源或使用某种不安全的方法对其进行更新,都应返回()标头。ETag是由拥有资源的服务器生成的不透明文本验证器(令牌),该服务器在当前时间点与其特定表示相关联。它必须启用资源状态的唯一标识。理想情况下,实体状态(响应主体)及其元数据(例如,内容类型)的每次更改都将反映在更新后的ETag值中。

有人可能会问:为什么我们需要ETag一个Last-Modified标头?实际上有两个原因,但是从不安全方法执行的角度来看,值得注意的是,根据RFC 7231 Last-Modified标头模式,时间分辨率仅限于秒。在不足的情况下,我们根本不能依靠它。

ETag验证 我们将从描述ETag的有效性开始描述,而不是偶然生成。简而言之,我们创建它的方式取决于选择的验证前提条件的方式。验证分为两种类型:强(默认)和弱。

ETag只要特定资源表示的内容发生更改并且可以200 OK响应GET请求而观察到它的值时,an的值就会被视为强。至关重要的是,该值在同一资源的不同表示形式之间是唯一的,除非这些表示形式具有相同形式的序列化内容。更具体地说:如果特定资源的主体以类型表示 application/vnd+company.category+jsonapplication/json两者相同,则它们可以共享相同的ETag值,否则将强制使用不同的值。

ETag当an的值可能不会随着资源表示的每次更改而更新时,被认为是弱的。使用弱标签的原因可能是由计算它们的算法的局限性决定的。例如,我们可以采用时间戳解析或无法确保在同一资源的不同表示形式之间唯一性的方法。

ETag我们应该使用哪个?这取决于。强大ETags可能很难,甚至不可能高效地产生。ETags但是,就资源状态比较而言,弱被认为更易于生成,但可靠性较差。选择应取决于我们的数据的详细信息,支持的表示形式的媒体类型以及最重要的一点–我们有能力确保单个资源的不同表示形式之间的唯一性。

ETag Generation ETag应该按照以下模式构建:

ETag = [W/]"{opaque-tag}"

该模式看起来很简单,但是需要澄清一下:

W/是区分大小写的弱验证可选指标。如果存在,它将告知您ETag将被确认为弱者。我们将在本文的以下部分中找到更多有关此内容的信息。 opaque-tag是必需的字符串值,用双引号引起来。由于服务器和客户端之间的转义/转义问题,建议避免在中使用双引号opaque-tags。 下面我们将找到几个有效的ETag的示例:

  • ""
  • "123"
  • W/"my-weak-tag"

如我们所见, ETag可能包含许多类型的事物,但是现在的问题是:我们应该如何生成它?我们应该用什么代替不透明标签?它可能是特定于实现的版本号,与内容类型分类器结合在一起,后者是根据内容表示形式计算得出的哈希值。它甚至可以是具有亚秒级分辨率的时间戳。

Comparison 正如我们已经知道如何生成弱和强一样ETags,我们现在唯一想念的是如何实际检查给定值是否通过了相应的验证。有一条规则:

ETags当且仅当两个都不弱并且它们的值相同时,两个才在强比较中相等。 如果两个ETags相等,则在弱比较中两个opaque-tags相等。

请在下表中找到示例:

ETag #1 ETag #2 Strong comparison Weak comparison
“123” “123” match match
“123” W/”123″ no match match
W/”123″ W/”123″ no match match
W/”123″ W/”456″ no match no match

开始实施之前,让我们从测试开始,检查一本书的表示形式是否包含ETag标题。在我们的示例中,我们将直接从book的version属性生成它。为了简单起见,我们还假设仅支持一种表示形式,在此过程中我们将其省略。

@Test
    public void shouldIncludeETagBasedOnVersionInBookViewResponse() throws Exception {
        //given
        Version version = someVersion();
        AvailableBook availableBook = bookRepositoryFixture.availableBookInTheSystemWith(version);

        //when
        ResultActions resultActions = api.getBookWith(availableBook.id());

        //then
        resultActions
                .andExpect(status().isOk())
                .andExpect(header().string(ETAG, String.format("\"%d\"", version.asLong())));
    }

为了使此测试通过,我们需要在构建响应时包括标头。

@RestController
@RequestMapping("/books")
class BookFindingController {

    private final FindingBook findingBook;

    public BookFindingController(FindingBook findingBook) {
    this.findingBook = findingBook;
    }

    @GetMapping("/{bookId}")
    ResponseEntity<?> findBookWith(@PathVariable("bookId") UUID bookIdValue) {
        Optional<BookView> book = findingBook.findBy(BookId.of(bookIdValue));
        return book
                .map(it -> ResponseEntity.ok().eTag(ETag.of(Version.from(it.getVersion())).getValue()).body(it))
                .orElse(ResponseEntity.notFound().build());
    }
}

正如我们所看到的,eTag()响应构建器中有一个方法,我们可以利用该方法来设置我们选择的标头。Spring框架为管理ETag头提供了自动支持,但仅限于缓存控制机制。不安全的方法处理取决于我们。

如果我们ETag基于version属性构建,则在响应主体中可能不再需要它(假设它没有业务价值)。因此,我们可以通过以下声明来增强我们的测试:

.andExpect(jsonPath("$.version").doesNotExist())

并从带有@JsonIgnore注释的序列化中排除该属性:

public class BookView {
    //...
    @JsonIgnore
    private final long version;
    //...
}

最后,我们可以从命令中删除该字段,但现在就让它保留,因为这会带来进一步的后果。

前提条件 我们知道是什么ETags,如何计算和比较它们。现在该是条件请求的时候了。为了创建一个条件要求,我们需要利用ETag由服务器返回,并把它的值写入条件标题之一:If-Match,If-Not-MatchedIf-Modified-SinceIf-Unmodified-Since,或If-Range。在本文中,我们将仅着重于If-MatchIf-Unmodified-Since标头,因为它们是唯一适用于不安全方法的标头。

评估 无论我们使用什么标题,我们都需要知道何时应该评估这些标题中嵌入的条件。这是我们可以在RFC 7232中找到的内容:

如果服务器对同一请求的响应没有其他条件,则它必须忽略所有收到的前提条件,而不是2xx(成功)或412(前提条件失败)以外的状态代码。换句话说,重定向和失败优先于条件请求中前提条件的评估。

这意味着,如果我们对服务器端的任何验证,在结束了404,422或者4xx一般的存在返回的消息,我们首先应该执行它们。但是,我们需要记住,前提条件检查也必须在对目标资源应用实际的方法语义之前进行。

If-Match If-Match标头的想法是为服务器提供有关客户端期望其具有的特定资源的表示形式的信息。If-Match标头可以等于:

  • * –回应的任何表示形式都很好,在我们的案例中,其有用程度最低(如果有的话)
  • ETag先前从对GET请求的响应中检索到的一个特定值
  • 以逗号分隔的ETag值列表

在我们的情况下,最合适的选择是将If-Match标头与单个ETag值一起使用。让我们编写一个测试。

@Test
public void shouldSignalPreconditionFailed() throws Exception {
    //given
    AvailableBook availableBook = availableBookInTheSystem();
    //and

    ResultActions bookViewResponse = api.getBookWith(availableBook.id());
   BookView book = api.parseBookViewFrom(bookViewResponse);

    String eTag = bookViewResponse.andReturn().getResponse().getHeader(ETAG);
    //and
    bookWasModifiedInTheMeantime(bookIdFrom(book.getId()));
    //when Bruce places book on hold
    PatronId bruce = somePatronId();
    TestPlaceOnHoldCommand command = placeOnHoldCommandFor(book.getId(), bruce, book.getVersion())
            .withIfMatchHeader(eTag);
    ResultActions bruceResult = api.send(command);
    //then
    bruceResult.andExpect(status().isPreconditionFailed());
}

为了使此测试通过,我们需要对以下内容进行一些更改BookHoldingController

@RestController
@RequestMapping("/books")
class BookHoldingController {

    private final PlacingOnHold placingOnHold;

    BookHoldingController(PlacingOnHold placingOnHold) {
        this.placingOnHold = placingOnHold;
    }

    @PatchMapping(path = "/{bookId}", headers = "If-Match")
    ResponseEntity<?> updateBookStatus(@PathVariable("bookId") UUID bookId,
                                       @RequestBody UpdateBookStatus command,
                                       @RequestHeader(name = HttpHeaders.IF_MATCH) ETag ifMatch) {
        if (PLACED_ON_HOLD.equals(command.getStatus())) {
            Version version = Version.from(Long.parseLong(ifMatch.getTrimmedValue()));
            PlaceOnHoldCommand placeOnHoldCommand = PlaceOnHoldCommand.commandFor(BookId.of(bookId), command.patronId())
                    .with(version);
            Result result = placingOnHold.handle(placeOnHoldCommand);
            return buildConditionalResponseFrom(result);
        } else {
            return ResponseEntity.ok().build(); //we do not care about it now
        }
    }

    @PatchMapping(path = "/{bookId}", headers = "!If-Match")
    ResponseEntity<?> updateBookStatus(@PathVariable("bookId") UUID bookId,
                                       @RequestBody UpdateBookStatus command) {
        //...
    }

    private ResponseEntity<?> buildConditionalResponseFrom(Result result) {
        if (result instanceof BookPlacedOnHold) {
            return ResponseEntity.noContent().build();
        } else if (result instanceof BookNotFound) {
            return ResponseEntity.notFound().build();
        } else if (result instanceof BookConflictIdentified) {
            return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
        } else {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    private ResponseEntity<?> buildResponseFrom(Result result) {
        if (result instanceof BookPlacedOnHold) {
            return ResponseEntity.noContent().build();
        } else if (result instanceof BookNotFound) {
            return ResponseEntity.notFound().build();
        } else if (result instanceof BookConflictIdentified) {
            return ResponseEntity.status(HttpStatus.CONFLICT)
                    .body(((BookConflictIdentified) result)
                            .currentState()
                            .map(BookView::from)
                            .orElse(null));
        } else {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

}

我们没有修改现有方法(在409版本冲突的情况下曾经返回的方法),而是添加了新方法,该方法要求提供If-Match标头。有两个原因。第一个是我们可以部署新方法而不会破坏我们的API的客户端。其次,我们可以让客户选择是否要使用有条件请求的商品或坚持“经典”解决方案。第二种解决方案承担了将version属性保留在PATCH请求正文中的负担。

缺少前提条件 在这里,我们到达了需要决定是否要使这两个解决方案并行运行的时刻。有没有一种方法可以强制API客户端使用条件请求?

在RFC 6585中,我们可以阅读:

428状态代码指示原始服务器要求该请求是有条件的。 它的典型用法是避免“丢失更新”问题,在这种情况下,客户端会获取资源的状态,然后对其进行修改,然后将其重新放置到服务器上,而此时第三方已在服务器上修改了状态,从而导致冲突。通过要求请求是有条件的,服务器可以确保客户端使用正确的副本。

当我们决定强制使用前提条件时,我们从以下测试开始:

@Test
public void shouldSignalPreconditionRequiredWhenIfMatchIsHeaderMissing() throws Exception {
    //given
    AvailableBook availableBook = bookRepositoryFixture.availableBookInTheSystem();

    //when
    TestPlaceOnHoldCommand command = placeOnHoldCommandFor(availableBook, patronId).withoutIfMatchHeader();
    ResultActions resultActions = api.send(command);

    //then
    resultActions
            .andExpect(status().isPreconditionRequired())
            .andExpect(jsonPath("$.message").value(equalTo("If-Match header is required")));
}

为了使此测试通过,我们需要摆脱与处理有关的所有内容409 CONFLICT。清理之后,我们的控制器将如下所示:

@RestController
@RequestMapping("/books")
class BookHoldingController {

    private final PlacingOnHold placingOnHold;

    BookHoldingController(PlacingOnHold placingOnHold) {
        this.placingOnHold = placingOnHold;

    }

    @PatchMapping(path = "/{bookId}")
    ResponseEntity<?> updateBookStatus(@PathVariable("bookId") UUID bookId,
                                       @RequestBody UpdateBookStatus command,
                                       @RequestHeader(name = HttpHeaders.IF_MATCH, required = false) ETag ifMatch) {
        if (PLACED_ON_HOLD.equals(command.getStatus())) {
            return Optional.ofNullable(ifMatch)
                    .map(eTag -> handle(bookId, command, eTag))
                    .orElse(preconditionFailed());
        } else {
            return ResponseEntity.ok().build(); //we do not care about it now
        }
    }

    private ResponseEntity<?> handle(UUID bookId, UpdateBookStatus command, ETag ifMatch) {
        Version version = Version.from(Long.parseLong(ifMatch.getTrimmedValue()));
        PlaceOnHoldCommand placeOnHoldCommand = PlaceOnHoldCommand.commandFor(BookId.of(bookId), command.patronId())
                .with(version);
        Result result = placingOnHold.handle(placeOnHoldCommand);
        return buildResponseFrom(result);
    }

    private ResponseEntity<?> buildResponseFrom(Result result) {
        if (result instanceof BookPlacedOnHold) {

            return ResponseEntity.noContent().build();
        } else if (result instanceof BookNotFound) {
            return ResponseEntity.notFound().build();
        } else if (result instanceof BookConflictIdentified) {
            return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build();
        } else {
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).build();
        }
    }

    private ResponseEntity preconditionFailed() {
        return ResponseEntity
                .status(HttpStatus.PRECONDITION_REQUIRED)
                .body(ErrorMessage.from("If-Match header is required"));
    }

}

前提条件优先 就像我们已经提到的那样,If-Match对于有条件的不安全请求,标头不是唯一使用的选项。如果我们决定返回Last-Modified报头GET的请求,相应的条件请求头If-Unmodified-Since。根据RFC 7232,If-Unmodified-Since仅当请求中不包含If-Match标头时,才可以在服务器端进行验证。

结论 在多用户环境中,处理并发访问是我们的主要工作。并发控制可以并且应该反映在我们的API中,尤其是因为HTTP提供了一组标头和响应代码来支持它。

首选的方法是将version属性添加到我们的读取模型中,并在我们不安全的方法中进一步传递它。如果在服务器端检测到冲突,我们可以409 CONFLICT通过包含所有必要信息的消息返回状态,以使客户端知道问题的根源。

条件请求是更高级的解决方案。GET方法应该返回ETag或Last-Modified标头,并且它们的值应相应地放在不安全方法的If-Match或If-Unmodified-Since标头中。发生冲突时,服务器返回412 PRECONDITION FAILED。

如果我们要强制我们的客户使用条件请求,则在缺少前提条件的情况下,服务器将返回428 PRECONDITION REQUIRED。

Spring框架不支持我们在现成的API中为并发访问建模。尽管如此,通过测试来驱动我们的API仍然表明,Spring Web中可用的非常基本的机制使我们触手可及。


原文链接:http://codingdict.com