028-86261949

当前位置:首页 > 学科资讯 > RESTAPI与Spring框架下的并发控制

RESTAPI与Spring框架下的并发控制

2020/06/10 15:01 分类: 学科资讯 浏览:1

在现代软件系统中,有数百或数千名用户独立地同时与我们的资源交互是很常见的。我们通常希望避免这样的情况,即一个客户端所做的更改被另一个客户端覆盖而不知道。为了防止数据完整性受到侵犯,我们经常使用数据库引擎提供的锁定机制,甚至使用JPA等工具提供的抽象。

您有没有想过并发控制应该如何反映在我们的API中?当两个用户同时更新相同的记录时会发生什么?我们会发送错误信息吗?我们将使用什么HTTP响应代码?我们将附加哪些HTTP头?

本文的目的是全面说明如何对RESTAPI建模,使其支持资源的并发控制,并利用HTTP协议的特性。我们还将在Spring框架的帮助下实现此解决方案。

请注意,尽管我们对并发数据访问做了简短介绍,但本文并没有介绍锁、隔离级别或事务的工作方式。我们将严格关注API。

 

用例

我们将要处理的用例是基于DDD参考项目-图书馆。想象一下,我们有一个系统,使读者搁置图书的过程自动化。为了简单起见,让我们假设每本书都可以处于两种可能的状态之一:可用和搁置。只有当一本书存在于图书馆并且目前可用时,它才能被搁置。这是如何在事件风暴会议:

Book availiability modeling

图书可用性建模

每一位赞助人都可以搁置这本书(发出命令)。为了做出这样的决定,他/她需要首先看到可用书籍的列表(查看一个阅读模型)。根据不变量,我们将允许或不允许进程成功。

我们还假设,我们做了一个决定Book我们的主要集合体。上面的过程可视化了Web Sequence Diagrams可能是这样的:

Web sequence diagram

Web序列图

 

就像我们在这个图表中看到的,布鲁斯成功地把这本书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使用Status字段和成吨的if语句初始化,我们传递了AvailableBookPlacedOnHoldBook而是上课。在这个设置中,它只是AvailableBook它有placeOnHold方法。对于我们的应用程序来说,保护不变量就足够了吗?

如果两位不同的读者试图按部就班地放置同一本书,答案是肯定的,因为在这里支持我们的将是编译器。否则,我们无论如何都需要处理并发访问--这就是我们现在要做的事情。这里有两个可能的选项:完全状态比较和锁定。在本文中,我们将简要介绍前一个选项,更多地关注后者。

 

全状态比较

这个词背后隐藏着什么?好吧,如果我们想保护自己免受所谓的丢失更新,我们需要做的是在保持聚合的同时,检查我们想要更新的聚合是否还没有被其他人更改。这种检查可以通过比较更新之前的聚合属性和数据库中当前的属性来完成。如果比较的结果是肯定的,我们可以坚持新版本的我们的总和。这些操作(比较和更新)需要是原子的。

该解决方案的优点是它不影响聚合的结构-技术持久性细节不会泄漏到域层或上面的任何其他层。但是,由于我们需要拥有聚合的前一个状态才能进行完全的比较,所以我们需要通过存储库端口将这个状态传递给我们的持久性层。这反过来又会影响存储库的签名。save方法,并要求在应用层进行调整。然而,它比第二个解决方案要干净得多,您将在下面的段落中看到这一点。在我们继续之前,还值得注意的是,该解决方案承担了数据库上潜在的计算量很大的搜索负担。如果我们的聚合很大,那么在我们的数据库上维护一个完整的索引可能会很痛苦。功能指数可能会起到挽救作用。

 

锁紧

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

前一种类型是我们的应用程序获取特定资源的独占或共享锁。如果我们想要修改一些数据,拥有独占锁是唯一的选择。然后,我们的客户端可以操纵资源,甚至不让任何其他客户读取数据。但是,共享锁不允许我们操作资源,而且对于其他仍然可以读取数据的客户端来说,限制更小一些。

相反,乐观锁定允许每个客户端随意读取和写入数据,但限制是在提交事务之前,我们需要检查某个特定记录是否在此期间没有被其他人修改。这通常是通过添加当前版本或最后修改时间戳属性来完成的。

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

 

数据访问层的乐观锁定

在Java世界中,我们通常使用JPA来处理数据访问,包括锁定功能。可以通过在实体中声明版本属性并将其标记为@Version注释让我们看看它的样子,从测试开始。

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

 

为了通过这个测试,我们需要提供以下几点:

  • 在JPA中引入上述版本属性BookEntity在基础设施层

@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长大了?默认情况下,500 INTERNAL SERVER ERROR身份将被退回,这绝对不是我们希望看到的。现在是我们着手处理StaleStateIdentified那么例外。

 

在RESTAPI中处理乐观锁定

在并发访问冲突的情况下应该发生什么?我们的API应该返回什么?我们的最终用户应该看到什么?

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

发展:“如果两位顾客试图搁置同一本书,其中一位被拒绝了,就像他一秒钟后试过的那样,我们该怎么办?”

商业“告诉他太糟糕了”

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

商业“哦,好吧,我们应该给他打个电话。”是。在这种情况下,给我发一封电子邮件,我会联系他,并为此道歉,试图为他找一些其他的副本。“

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

为了保持简单,让我们假设,我们只是想告诉我们的客户,我们很抱歉。HTTP协议提供的非常基本的机制RFC 7231超文本传输协议(HTTP/1.1):语义和内容它是关于返回409 CONFLICT回应。以下是文件中所述的内容:

这个409 (Conflict)状态代码表示请求不能
由于与目标当前状态发生冲突而完成
资源。此代码用于用户可能
能够解决冲突并重新提交请求。服务器
应该生成包含足够信息的有效负载。
识别冲突来源的用户。

冲突最有可能发生在响应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())));
}

 

这里发生的事情是,我们对系统中可用的一本书所做的第一件事就是获取它的视图。为了启用并发访问控制,视图响应需要包含与我们在域模型中已经拥有的版本属性相对应的版本属性。除其他外,它还包含在命令中,我们发送命令将书搁置。不过,在此期间,我们修改了图书(强制更新Version属性)。因此,我们希望得到一个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和状态比较。不好的是,版本属性需要添加到我们的域、应用程序和API级别,从而导致持久性层的技术细节泄漏。但是,好的是,现在为了执行更新,WHERE子句可限于aggregate IDversion田野。简化是基于这样一个事实:状态现在由一个参数来表示,而不是一个完整的集合。对于冲突情况下的API响应,情况几乎是一样的。这两种方法都迫使我们的客户分析响应,并决定是否可以重传。

从实际的角度来看这个问题,我们可以给出一些赞成使用乐观锁定的论点。

  • 域是脏的,但是api是清晰的、简洁的,而且使用先决条件要容易得多(在后面的章节中将更多地介绍这个主题)。
  • Version业务有时会希望达到审计之类的目的,因此我们可能会获得更多的好处。
  • 如果version仍然很难被接受,我们可以用Last-Modified属性并将其发送到标题中。在许多业务中,对资源进行最后一次修改的时间可能更有意义。

 

ETag报头

您注意到了吗?在前面提到的两种方法中,我们实际上都在数据库上执行条件更新吗?这不意味着我们的要求是有条件的吗?是的,它确实如此,因为我们允许我们的客户更新这本书,只有在此期间它还没有被修改。在第一种情况下,我们需要比较聚合的所有属性,而在第二种情况下,我们只需要检查versionaggregate ID都是一样的。所有属性一致性和基于版本的一致性都定义了满足请求的先决条件。

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

条件请求是HTTP请求[RFC 7231],它包含一个或多个标头字段,指示在将方法语义应用于目标资源之前要测试的先决条件。

RFC 7232区分条件读和写请求。前者通常用于有效的缓存机制,这超出了本文的范围。后一种要求是我们将要关注的问题。让我们继续讲一些理论吧。

条件请求处理的基本组件是ETag (Entity Tag)应该在任何时候使用GET使用一些不安全的方法请求或更新它。ETag是由拥有资源的服务器生成的不透明文本验证器(令牌),并在当前时间点与其特定表示相关联。它必须启用对资源状态的唯一标识。理想情况下,实体状态(响应体)及其元数据(例如内容类型)中的每一项更改都反映在更新中。ETag价值。

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

 

ETag验证

我们将开始描述ETag从它的验证,而不是产生,而不是偶然。简而言之,我们创建它的方式取决于所选择的验证先决条件的方法。有两种类型的验证-强(默认)和弱。

ETag当它的值被更新时,当特定资源表示的内容发生变化时,它被认为是强的,并且可以在200 OKGET请求。重要的是,该值在同一资源的不同表示之间是唯一的,除非这些表示具有相同形式的序列化内容。更具体地说:如果某个特定资源的主体以类型表示
application/vnd+company.category+jsonapplication/json两者是相同的,它们可以共享相同的ETag值,强制使用不同的值。

ETag如果资源表示形式的每一次更改都可能更新其值,则被认为是弱的。使用弱标记的原因可能取决于计算它们的算法的局限性。例如,我们可以使用时间戳解析或无法确保同一资源的不同表示之间的唯一性。

哪一个ETag我们要用吗?那得看情况。强壮ETags很难,甚至不可能有效地产生。瘦弱ETags然而,在资源状态比较方面,被认为更容易生成,但不太可靠。选择应该取决于数据的具体情况、支持的表示媒体类型以及最重要的是什么--我们确保单个资源的不同表示之间的唯一性的能力。

 

ETag生成

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

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

这个模式看起来很简单,但需要一些澄清:

  • W/是区分大小写的弱验证可选指示器。如果有-它告诉我们ETag将被确认为弱者。我们将在本文的下一节中找到更多关于这一点的内容。
  • opaque-tag是强制性字符串值,由双引号包围。由于服务器和客户端之间存在转义/未转义问题,建议在opaque-tags.

下面我们将找到几个有效的eTags示例:

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

正如我们所看到的,ETag可能包含很多类型的东西,但现在的问题是:我们应该如何生成它?我们应该用什么来代替不透明的标签?它可能是一个与内容类型分类器相结合的特定于实现的版本号,这是根据内容的表示计算出来的哈希值。它甚至可以是一个亚秒分辨率的时间戳。

 

比较

因为我们已经知道如何产生弱者和强者ETags,我们现在错过的唯一一件事是如何实际检查给定的值是否通过了各自的验证。有一条规则:

  • ETags在强比较中是相等的当且仅当它们都不是弱的并且它们的值是相同的。
  • ETags在弱比较中是相等的,如果它们的opaque-tags是平等的。


请在下表中找到例子:

ETag#1 ETag#2 强比较
弱比较
“123” “123” 匹配 匹配
“123” W/“123” 不匹配 匹配
W/“123” W/“123” 不匹配 匹配
W/“123” W/“456” 不匹配 不匹配

 

进入实现阶段,让我们从测试开始,检查一本书的表示是否包含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-MatchIf-Not-MatchedIf-Modified-SinceIf-Unmodified-Since,或If-Range。在这篇文章中,我们将只关注If-Match,和If-Unmodified-Since头,因为这些是唯一适用于不安全方法的头。

 

评价

不管我们使用的标头是什么,我们都需要知道什么时候应该评估这些头中嵌入的条件。这里是我们能找到的RFC 7232:

如果服务器对相同请求的响应没有这些条件,则必须忽略所有接收到的先决条件,而不是2xx(成功)或412(先决条件失败)以外的状态代码。换句话说,在有条件请求中,重定向和失败优先于计算先决条件。

这意味着,如果我们在服务器端有任何验证,最终会在404422,或4xx一般情况下,我们应该首先执行返回的消息。但是,我们需要记住,在对目标资源应用实际的方法语义之前,也必须进行前提检查。

如果匹配

的想法If-MatchHeader是向服务器提供关于客户端希望它拥有的特定资源的表示的信息。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

 

而不是修改现有方法(用于返回的方法)409在版本冲突的情况下)我们添加了新的版本,这需要If-Match标题将出现。这有两个原因。首先,我们可以在不破坏API客户端的情况下部署我们的新方法。其次,我们可以让客户选择使用有条件请求的商品,还是坚持“经典”解决方案。第二个解决方案承担将版本属性保留在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


先决条件优先

就像我们已经说过的,If-Match当涉及到有条件的不安全请求时,头不是唯一要使用的选项。如果我们决定回来Last-Modified标头GET请求时,相应的条件请求头为If-Unmodified-Since。根据RFC 7232, If-Unmodified-Since只有在请求中不包含if-Match标头时,才能在服务器端验证。

结论

在多用户环境中,处理并发访问是我们的主要任务。并发控制可以而且应该反映在我们的API中,特别是因为HTTP提供了一组头部和响应代码来支持它。

要选择的第一个选项是将Version属性添加到读取模型中,并将其进一步传递到不安全的方法中。如果在服务器端检测到碰撞,我们可以返回。409 CONFLICT状态包含所有必要信息,以使客户端知道问题的根源。

更高级的解决方案是条件请求。GET方法应该返回ETagLast-Modified头,它们的值应该相应地放在If-MatchIf-Unmodified-Since不安全方法的标题。如果发生冲突,服务器将返回412 PRECONDITION FAILED.

如果要强制客户端使用条件请求,则在缺少先决条件的情况下,服务器将返回428 PRECONDITION REQUIRED.

Spring框架不支持我们在API中直接对并发访问进行建模。尽管如此,通过测试驱动我们的API显示,SpringWeb中可用的非常基本的机制使它在我们的指尖上。

#标签:RESTAPI,Spring框架