22-07-25
banq
假设我们要实现购物车。我们有以下要求:
- 客户只能在打开购物车后将产品添加到购物车中。
- 在选择产品并将其添加到购物篮时,客户需要提供选择的数量。系统根据当前价目表计算产品价格。
- 客户可以从购物车中移除具有给定价格的产品。
- 客户可以确认购物车并开始订单履行流程。
- 客户可以取消购物车并拒绝所有选择的产品。
- 购物车确认或取消后,该产品将无法再从购物车中添加或移除。
一张好图能说出一千多个字,对吧?你有它。
我们可能已经注意到我们的购物车是一个简单的状态机。它要么待定,我们可以向其中添加产品,要么已关闭(确认或取消)。如果我们是面向对象的 Java 开发人员,我们可以将购物车建模为:
public class ShoppingCart { private UUID clientId; private ShoppingCartStatus status; private List<PricedProductItem> productItems; private OffsetDateTime confirmedAt; private OffsetDateTime canceledAt; // (...) horde of the public getters and setters } |
这对于许多场景来说可能已经足够好了,但它并没有告诉我们太多关于预期行为的信息。我们不知道是否以及何时设置确认和取消日期。我们的对象结构将允许我们随时更改。甚至设置为空。当然,我们可以在构造函数中添加更多的验证逻辑等。但这只会使我们的代码更加模糊。如果编译器帮助我们发现错误不是很好吗?如果我们可以用代码表达状态机并显式地建模状态转换,那就太好了,对吧?
幸运的是,Java 17 版可以帮助我们。如何?让我们从记录开始。它们是类的简化语法,简化了它们的定义并使它们的实例不可变。不变性是它自己的文章的主题。现在,让我们专注于我们想从他们那里得到什么。我们希望通过准确知道何时进行状态转换来使我们的代码可预测。这样,我们可以限制意外的修改。我们知道,从我们建立状态的地方,我们可能会信任它,并且不需要每次使用它时都对其进行验证。这减少了 IF 的数量和所需的单元测试。
例如,我们可以定义ETag值对象来限制正确的格式:
public record ETag(String value) { private static final Pattern ETagPattern = Pattern.compile("\"([^\"]*)\""); @JsonCreator public ETag { if (value != null) { var regexMatcher = ETagPattern.matcher(value); if (!regexMatcher.find()) throw new IllegalArgumentException("Not an ETag header"); } } public static ETag weak(Object value) { return new ETag("W/\"%s\"".formatted(value.toString())); } public boolean isEmpty() { return value == null; } public long toLong() { if (value == null) { throw new IllegalStateException("Header is empty"); } var regexMatcher = ETagPattern.matcher(value); regexMatcher.find(); return Long.parseLong(regexMatcher.group(1)); } } |
回到我们的购物车,我们还可以让我们的实体成为一个记录。然而,这对我们来说还不够有趣和帮助。可预测性非常好,但我们想明确地为我们的状态机建模。我们可以使用另一个新概念,即密封接口。它们允许限制我们接口的实现集。检查我们可以用它们做什么:
sealed public interface ShoppingCart { record EmptyShoppingCart() implements ShoppingCart { } record PendingShoppingCart( UUID id, UUID clientId, ProductItems productItems ) implements ShoppingCart { } record ConfirmedShoppingCart( UUID id, UUID clientId, ProductItems productItems, OffsetDateTime confirmedAt ) implements ShoppingCart { } record CanceledShoppingCart( UUID id, UUID clientId, ProductItems productItems, OffsetDateTime canceledAt ) implements ShoppingCart { } } |
乍一看,语法可能看起来很奇怪,但你可以习惯它。当我们这样做时,通过查看代码,我们将知道购物车的实例将是空的(未初始化)、待处理、已确认或已取消。这很强大,因为它具有表现力,而且密封接口不允许任何人对我们的接口进行牛仔实现。另外,它与新的Java 模式匹配功能配合得很好。我们稍后再谈。
密封接口与Scala中的特征概念相同,或类似于Kotlin 中的密封类和TypeScript 中的联合类型。如果您正在寻找兔子洞,请检查代数类型,您最喜欢的语言似乎也支持它们。
使用密封接口,我们还可以定义一组命令来准确了解我们的购物车会发生什么。
public sealed interface ShoppingCartCommand { record OpenShoppingCart( UUID shoppingCartId, UUID clientId ) implements ShoppingCartCommand { } record AddProductItemToShoppingCart( UUID shoppingCartId, ProductItem productItem ) implements ShoppingCartCommand { } record RemoveProductItemFromShoppingCart( UUID shoppingCartId, PricedProductItem productItem ) implements ShoppingCartCommand { } record ConfirmShoppingCart( UUID shoppingCartId ) implements ShoppingCartCommand { } record CancelShoppingCart( UUID shoppingCartId ) implements ShoppingCartCommand { } } |
现在我们的代码准确地告诉我们我们可能期望什么业务状态以及我们可以在购物车上执行的所有操作。现在,我们剩下的就是定义命令处理。
public final class ShoppingCartService { public static ProductItemAddedToShoppingCart addProductItem( ProductPriceCalculator productPriceCalculator, AddProductItemToShoppingCart command, ShoppingCart shoppingCart ) { if (!(shoppingCart instanceof ShoppingCart.PendingShoppingCart pendingShoppingCart)) throw new IllegalStateException("Removing product item for cart in '%s' status is not allowed.".formatted(shoppingCart.getClass().getName())); var pricedProductItem = productPriceCalculator.calculate(command.productItem()); pendingShoppingCart.productItems().add(pricedProductItem); return new ProductItemAddedToShoppingCart( command.shoppingCartId(), pricedProductItem ); } public static ProductItemRemovedFromShoppingCart removeProductItem( RemoveProductItemFromShoppingCart command, ShoppingCart shoppingCart ) { if (!(shoppingCart instanceof ShoppingCart.PendingShoppingCart pendingShoppingCart)) throw new IllegalStateException("Removing product item for cart in '%s' status is not allowed.".formatted(shoppingCart.getClass().getName())); pendingShoppingCart.productItems().hasEnough(command.productItem()); return new ProductItemRemovedFromShoppingCart( command.shoppingCartId(), command.productItem() ); } public static ShoppingCartConfirmed confirm(ConfirmShoppingCart command, ShoppingCart shoppingCart) { if (!(shoppingCart instanceof ShoppingCart.PendingShoppingCart pendingShoppingCart)) throw new IllegalStateException("Removing product item for cart in '%s' status is not allowed.".formatted(shoppingCart.getClass().getName())); return new ShoppingCartConfirmed( pendingShoppingCart.id(), OffsetDateTime.now() ); } public static ShoppingCartCanceled cancel(CancelShoppingCart command, ShoppingCart shoppingCart) { if (!(shoppingCart instanceof ShoppingCart.PendingShoppingCart pendingShoppingCart)) throw new IllegalStateException("Removing product item for cart in '%s' status is not allowed.".formatted(shoppingCart.getClass().getName())); return new ShoppingCartCanceled( pendingShoppingCart.id(), OffsetDateTime.now() ); } } |
如您所见,这里没有发生什么壮观的事情。我们有一组功能:获取命令和当前状态,处理业务逻辑并返回事件作为结果。哦,等等,有人提到事件吗?如果我没有在我的示例中添加一点事件溯源,我就不会是我自己。它与我们采用的方法非常匹配。我们专注于对业务逻辑进行建模并直接通过代码公开它。事件溯源可以帮助了解意图并记录我们业务逻辑的结果。这里的事件是我们业务工作流程的事实和检查点。
让我们定义可以为购物车注册的事件:
public sealed interface ShoppingCartEvent { record ShoppingCartOpened( UUID shoppingCartId, UUID clientId ) implements ShoppingCartEvent { } record ProductItemAddedToShoppingCart( UUID shoppingCartId, PricedProductItem productItem ) implements ShoppingCartEvent { } record ProductItemRemovedFromShoppingCart( UUID shoppingCartId, PricedProductItem productItem ) implements ShoppingCartEvent { } record ShoppingCartConfirmed( UUID shoppingCartId, OffsetDateTime confirmedAt ) implements ShoppingCartEvent { } record ShoppingCartCanceled( UUID shoppingCartId, OffsetDateTime canceledAt ) implements ShoppingCartEvent { } } |
在事件溯源中,应用程序状态存储在事件中。经典实体表示为一系列称为流的事件。要获取当前状态,我们需要: 获取给定流的所有事件。我们根据流标识符(从业务对象/记录 ID 派生)来选择它们。事件存储按添加顺序保留给定流的事件;检索应保留顺序。创建一个默认的空实体。将每个事件按顺序应用于实体。我们正在采用当前状态并将其演变为每个事件的新状态。基于事件转换状态的方法通常被称为when、apply或evolve。在如何从事件中获取当前实体状态中阅读更多内容?.
让我们为我们的案例定义它:
sealed public interface ShoppingCart { // (...) static ShoppingCart evolve( ShoppingCart state, ShoppingCartEvent event ) { return switch (event) { case ShoppingCartEvent.ShoppingCartOpened shoppingCartOpened: { if (!(state instanceof EmptyShoppingCart)) yield state; yield new PendingShoppingCart( shoppingCartOpened.shoppingCartId(), shoppingCartOpened.clientId(), ProductItems.empty() ); } case ShoppingCartEvent.ProductItemAddedToShoppingCart productItemAddedToShoppingCart: { if(!(state instanceof PendingShoppingCart pendingShoppingCart)) yield state; yield new ShoppingCart.PendingShoppingCart( pendingShoppingCart.id(), pendingShoppingCart.clientId(), pendingShoppingCart.productItems().add(productItemAddedToShoppingCart.productItem()) ); } case ShoppingCartEvent.ProductItemRemovedFromShoppingCart productItemRemovedFromShoppingCart: { if (!(state instanceof PendingShoppingCart pendingShoppingCart)) yield state; yield new ShoppingCart.PendingShoppingCart( pendingShoppingCart.id(), pendingShoppingCart.clientId(), pendingShoppingCart.productItems().remove(productItemRemovedFromShoppingCart.productItem()) ); } case ShoppingCartEvent.ShoppingCartConfirmed shoppingCartConfirmed: { if (!(state instanceof PendingShoppingCart pendingShoppingCart)) yield state; yield new ShoppingCart.ConfirmedShoppingCart( pendingShoppingCart.id(), pendingShoppingCart.clientId(), pendingShoppingCart.productItems(), shoppingCartConfirmed.confirmedAt() ); } case ShoppingCartEvent.ShoppingCartCanceled shoppingCartCanceled:{ if (!(state instanceof PendingShoppingCart pendingShoppingCart)) yield state; yield new ShoppingCart.ConfirmedShoppingCart( pendingShoppingCart.id(), pendingShoppingCart.clientId(), pendingShoppingCart.productItems(), shoppingCartCanceled.canceledAt() ); } }; } } |
是的,它也可以是我们购物车定义的一部分。多亏了这一点,我们可以在一个地方看到所有可能的状态以及如何从事件序列中获得它们。这也是自记录代码成为业务流程和实现的真实来源的绝佳示例。我们还使用了这里提到的新模式匹配。它们非常酷,因为如果我们忘记在专用 switch 分支中处理新添加的事件,编译器会保护我们并失败。
需要注意的重要一点是,我们可以使用instanceof来确保我们当前的状态符合预期。我们不应该在这里抛出异常,因为我们的业务逻辑应该检查所有不变量。如果我们抛出异常,那么我们就没有机会纠正流中的错误。阅读更多从事件重建状态时是否应该抛出异常?.
现在,让我们总结一下,进入总决赛!
我们可以将我们的命令处理分组到一个名为decision的方法中:
public final class ShoppingCartService { public static ShoppingCartEvent[] decide( Supplier<ProductPriceCalculator> getProductPriceCalculator, ShoppingCartCommand command, ShoppingCart state ) { return new ShoppingCartEvent[]{ switch (command) { case OpenShoppingCart openCommand -> open(openCommand); case AddProductItemToShoppingCart addCommand -> addProductItem(getProductPriceCalculator.get(), addCommand, state); case RemoveProductItemFromShoppingCart removeProductCommand -> removeProductItem(removeProductCommand, state); case ConfirmShoppingCart confirmCommand -> confirm(confirmCommand, state); case CancelShoppingCart cancelCommand -> cancel(cancelCommand, state); } }; } |
此方法接受命令和当前状态并运行业务逻辑。这是一个很好的例子,说明仅使用纯函数如何实现可组合性。我们有更好的封装,我们的业务逻辑的单一入口点。我们可以对联合类型进行建模,通过密封接口传递特定参数,然后对我们的对象进行强类型化和信任。另外,我们正在获得一个易于测试的自记录代码。不变性只会加强这一点。
我们可以概括所有这些并定义以下类型:
public record Decider<State, Command, Event>( BiFunction<Command, State, Event[]> decide, BiFunction<State, Event, State> evolve, Supplier<State> getInitialState ) { } |
它将业务逻辑(决定功能)、状态演变和重建(演变)与初始状态组合在一起。它在某种程度上类似于聚合模式,因为它代表一个特定的决策过程,确保检查所有业务不变量并保护一致性。它基于对我而言的功能组合,因为它帮助我专注于特定的业务流程,而不是如何将员工凝聚在一起。
对于我们的购物车,这将如下所示:
public final class ShoppingCartService { public static Decider<ShoppingCart, ShoppingCartCommand, ShoppingCartEvent> shoppingCartDecider( Supplier<ProductPriceCalculator> getProductPriceCalculator ) { return new Decider<>( (command, state) -> ShoppingCartService.decide(getProductPriceCalculator, command, state), ShoppingCart::evolve, EmptyShoppingCart::new ); } } |
我们可以将通用命令处理程序定义为:
public class CommandHandler<State, Command, Event> { private final EventStore eventStore; private final Decider<State, Command, Event> decider; CommandHandler( EventStore eventStore, Decider<State, Command, Event> decider ) { this.eventStore = eventStore; this.decider = decider; } public AppendResult handle( String streamId, Command command, ETag eTag ) { var events = eventStore.<Event>read(streamId); var state = events.stream() .collect(foldLeft(decider.getInitialState(), decider.evolve())); var newEvents = decider.decide().apply(command, state); return eventStore.append( streamId, eTag, Arrays.stream(newEvents).iterator() ); } } |
这样的处理程序可以在常规 API 控制器中使用:
@RestController @RequestMapping("api/shopping-carts") class ShoppingCartsController { private final CommandHandler<ShoppingCart, ShoppingCartCommand, ShoppingCartEvent> commandHandler; ShoppingCartsController( CommandHandler<ShoppingCart, ShoppingCartCommand, ShoppingCartEvent> commandHandler ) { this.commandHandler = commandHandler; } @PostMapping("{id}/products") ResponseEntity<Void> addProduct( @PathVariable UUID id, @RequestBody ShoppingCartsRequests.AddProduct request, @RequestHeader(name = HttpHeaders.IF_MATCH) @Parameter(in = ParameterIn.HEADER, required = true, schema = @Schema(type = "string")) @NotNull ETag ifMatch ) { if (request.productItem() == null) throw new IllegalArgumentException("Product Item has to be defined"); var result = commandHandler.handle( "shopping_cart-%s".formatted(id), new AddProductItemToShoppingCart( id, new ProductItem( request.productItem().productId(), request.productItem().quantity() ) ), ifMatch ); if(!(result instanceof Success success)) return ResponseEntity.status(HttpStatus.PRECONDITION_FAILED).build(); return ResponseEntity .ok() .eTag(success.nextExpectedRevision().value()) .build(); } // (...) } |
其余处理程序将使用完全相同的模式。
检查我的仓库中的完整示例:https ://github.com/oskardudycz/EventSourcing.JVM/pull/34 。