结合DDD和Spring Boot实现基于REST API的并发控制 -DZone Java


在多用户环境中,处理并发访问是我们的主要工作。并发控制可以而且应该反映在我们的API中,特别是因为HTTP提供了一组标头和响应代码来支持它。
首选的方法是将version属性添加到我们的读取模型中,并在不安全的方法中进一步传递它。如果在服务器端检测到冲突,我们可以409 CONFLICT通过包含所有必要信息的消息来返回状态,以使客户端知道问题的根源。
条件请求是更高级的解决方案。GET方法应该返回ETag或Last-Modified标头,并且它们的值应相应地放在不安全方法的If-Match或If-Unmodified-Since标头中。发生冲突时,服务器返回412 PRECONDITION FAILED。
如果我们想强制客户使用条件请求,则在缺少前提条件的情况下,服务器将返回428 PRECONDITION REQUIRED。
本文中使用的代码示例可在此处找到。

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

书籍可用性建模
每个顾客可以将书置于保留状态(发送命令)。为了做出这样的决定,他/她需要首先查看可用书籍的列表(查看阅读模型)。根据不变式,我们将允许或不允许该过程成功。 
我们还假设,我们已经决定要制作Book主要的汇总。可视化的上述过程Web Sequence Diagrams可能如下所示:


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

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

有两种解决方案:
1.完整状态比较
如果我们要保护自己免受更新丢失的影响,那么在保持聚合状态的同时,我们需要做的是检查同时要更新的聚合是否未被其他人更改。
可以通过将更新之前的聚合属性与数据库中当前的属性进行比较来完成这种检查。如果比较结果为肯定,我们可以保留聚合的新版本。这些操作(比较和更新)必须是原子的。
该解决方案的优点是不会影响聚合的结构-技术持久性详细信息不会泄漏到域层或以上任何其他层中。但是,由于我们需要具有聚合的先前状态才能进行完全比较,因此需要通过存储库端口将此状态传递给持久层。反过来,这会影响存储库的签名save方法,并且还需要在应用程序层进行调整。
该解决方案承担了对数据库进行潜在的计算繁重搜索的负担。如果我们的总量很大,那么在数据库上维护完整索引可能会很痛苦。功能索引可能会有所帮助。

2.锁
第二种选择是使用锁定机制。从高级的角度来看,我们可以区分两种类型的锁定:悲观锁定和乐观锁定。
前一种类型是我们的应用程序获取特定资源的排他锁或共享锁。如果要修改某些数据,则只有排他锁是唯一的选择。然后,我们的客户可以操纵资源,甚至不让任何其他人读取数据。但是,共享锁不允许我们操纵资源,并且对其他仍可以读取数据的客户端的限制较少。
相反,开放式锁定允许每个客户端随意读写数据,但有一个限制,即在提交事务之前,我们需要检查特定记录是否在此期间未被其他人修改。通常通过添加当前版本或上次修改时间戳属性来完成。
当写操作的数量与读操作相比不是那么大时,乐观锁定通常是默认选择。

数据访问层中的乐观锁定:在Java世界中,通常使用JPA来处理包括锁定功能在内的数据访问。可以通过在实体中声明版本属性并用@Version注释对其进行标记来启用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应该返回什么?我们的最终用户应该看到什么?
在提出解决方案之前,让我们强调一下,在大多数情况下,开发人员不应该回答这些问题,因为这种冲突通常是业务问题,而不是技术问题(即使我们坚信是这样)。让我们看下面的例子:
开发人员:“如果两位顾客试图搁置同一本书,而其中一位却因为第二次尝试而被拒绝,我们该怎么办?”
生意:“告诉他太糟糕了。”
开发人员:“如果是我们的优质赞助人呢?”
生意:“哦,好吧,我们应该打给他。是。在这种情况下,请给我发送电子邮件,我将与他联系并为此道歉,并尝试为他找到其他副本。”
我们可以找到无数示例,证明技术解决方案应始终由真实的业务规则驱动。
为了简单起见,让我们假设,我们只是想告诉客户我们很抱歉。HTTP协议提供的非常基本的机制可以在RFC 7231超文本传输​​协议(HTTP / 1.1)中找到:语义和内容,它与返回409 CONFLICT响应有关。这是文档中说明的内容:

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

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

@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控制器的上下文中,我们不(也不应该)关心此属性的计算和更新方式-它发生变化的事实足以提供信息。因此,我们解决了测试中关注点分离的问题。 

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

(banq注:上述控制器代码中if else状态判断应该放入领域模型,属于重要业务规则,不能泄露到API控制器中)
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 IDand version字段。简化基于以下事实:状态现在由一个参数而不是整个参数表示。关于发生冲突时的API响应,情况几乎相同。两种方法都迫使我们的客户分析响应并决定是否可以重传。
务实地看待这个问题,我们可以提出一些赞成使用乐观锁定的论点。
  • Domain很脏,但是API简洁明了,并且使用前提条件的方式更容易(在后续章节中将对此主题进行详细介绍)
  • Version 有时可能出于业务目的(例如审计),所以我们有可能获得更多
  • 如果version仍然难以接受,我们可以使用Last-Modifiedattribute并将其发送到标头中。在许多企业中,最后修改资源的时间可能具有更大的意义。

ETag标头
您是否发现在上述两种方法中我们实际上都在数据库上执行条件更新?这不是说我们的请求是有条件的吗?是的,确实如此,因为我们仅允许客户在此期间未对其进行修改的情况下才对其进行更新。在第一种情况下,我们需要比较集合的所有属性,而在第二种情况下,我们仅检查version和aggregate ID是否相同。所有属性一致性和基于版本的一致性都定义了要满足请求的前提条件。
HTTP协议中有一种处理条件请求的显式标准方法。RFC 7232定义了此概念,包括一组指示资源状态和前提条件的元数据标头:

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

RFC 7232区分条件读取和写入请求。前者通常用于有效的缓存机制,这不在本文的讨论范围之内。后面的请求是我们将要重点关注的。让我们继续一些理论。
条件请求处理的最基本组成部分是ETag(Entity Tag)标头,只要我们通过GET请求读取资源或使用某种不安全的方法对其进行更新,都应返回()标头。ETag是由拥有资源的服务器生成的不透明文本验证器(令牌),该服务器在当前时间点与其特定表示相关联。它必须启用对资源状态的唯一标识。理想情况下,实体状态(响应主体)及其元数据(例如,内容类型)的每次更改都将反映在更新后的ETag值中。
你可能会问:为什么我们需要ETag一个Last-Modified标头?实际上有两个原因,但是从不安全方法执行的角度来看,值得注意的是,根据RFC 7231 Last-Modified标头模式,时间分辨率仅限于秒。在不足的情况下,我们根本不能依靠它。
...
更详细的Etag标头其他方式点击标题见原文