【问题标题】:How would one apply command query separation (CQS), when result data is needed from a command?当需要来自命令的结果数据时,如何应用命令查询分离 (CQS)?
【发布时间】:2022-03-23 19:36:53
【问题描述】:

在维基百科对command query separation 的定义中,声明了

更正式地说,方法应该只返回一个值 如果它们是参照透明的 因此没有副作用。

如果我正在发出命令,我应该如何确定或报告该命令是否成功,因为根据这个定义,函数不能返回数据?

例如:

string result = _storeService.PurchaseItem(buyer, item);

此调用中既有命令也有查询,但查询部分是命令的结果。我想我可以使用命令模式重构它,如下所示:

PurchaseOrder order = CreateNewOrder(buyer, item);
_storeService.PerformPurchase(order);
string result = order.Result;

但这似乎增加了代码的大小和复杂性,这不是一个非常积极的重构方向。

当您需要操作的结果时,有人可以给我一个更好的方法来实现命令-查询分离吗?

我错过了什么吗?

谢谢!

注意事项: Martin Fowler 对 cqs CommandQuerySeparation 的限制有这样的说法:

Meyer 喜欢使用命令查询 绝对分离,但有 例外。出栈是个好东西 修饰符的示例 状态。迈耶正确地说你 可以避免使用这种方法,但它 是一个有用的成语。所以我更喜欢 尽可能遵循这个原则,但是 我准备打破它以获得我的 弹出。

在他看来,对命令/查询分离进行重构几乎总是值得的,除了一些小的简单例外。

【问题讨论】:

  • 我相信您在使用 CQRS 时也应该使用event driven architecture
  • 简单的方法是让命令发布一个其他人可以订阅的事件。该事件的处理程序还将接收有关结果的数据。

标签: c# command-query-separation


【解决方案1】:

这个问题很老了,但还没有得到令人满意的答案,所以我将详细说明我大约一年前的评论。

使用事件驱动架构很有意义,不仅可以实现清晰的命令/查询分离,还因为它打开了新的架构选择,并且通常适合异步编程模型(如果您需要扩展架构,这很有用) .通常,您会发现解决方案可能在于对您的域进行不同的建模。

让我们以您的购买为例。 StoreService.ProcessPurchase 将是处理购买的合适命令。这将生成一个PurchaseReceipt。这是一种更好的方法,而不是在Order.Result 中返回收据。为简单起见,您可以从命令返回收据并在此处违反 CQRS。如果您想要更清晰的分隔,该命令将引发您可以订阅的ReceiptGenerated 事件。

如果您考虑一下您的域,这实际上可能是一个更好的模型。当您在收银台结账时,请遵循此流程。在生成收据之前,可能需要进行信用卡检查。这可能需要更长的时间。在同步场景中,您会在收银台等待,无法做任何其他事情。

【讨论】:

  • 如果命令失败怎么办?,您订阅了 ReceiptGenerated 和 ReceiptGenerattionFailed 事件,如果 ReceiptGenerattionFailed 由于某些错误而永远不会发布怎么办?
  • @AlexBurtsev – 由于错误而没有生成ReceiptGenerationFailed 事件似乎是一个巨大的错误。异常处理不会合理地防止这种情况发生吗?
【解决方案2】:

我在上面看到 CQS 和 CQRS 之间存在很多混淆(正如 Mark Rogers 在一个答案中所注意到的那样)。

CQRS 是 DDD 中的一种架构方法,在查询的情况下,您不会从聚合根及其所有实体和值类型构建完整的对象图,而只是在列表中显示的轻量级视图对象。

CQS 是应用程序任何部分的代码级别的良好编程原则。不仅仅是领域领域。该原理比 DDD(和 CQRS)存在的时间更长。它说不要用只返回数据并且可以在不改变任何状态的情况下随时调用的查询来搞乱改变应用程序任何状态的命令。 在我以前使用 Delphi 的时候,语言显示出功能和过程之间的差异。编写“函数过程”代码被认为是一种不好的做法,因为我们也将它们回调了。

要回答所提出的问题: 人们可以想出一种方法来解决执行命令并返回结果的问题。例如,通过提供具有 void 执行方法和只读命令结果属性的命令对象(命令模式)。

但坚持 CQS 的主要原因是什么? 保持代码的可读性和可重用性,无需查看实现细节。你的代码应该是值得信赖的,不会引起意想不到的副作用。 所以如果命令想要返回一个结果,而函数名或返回对象明确表明它是一个带有命令结果的命令,我会接受CQS规则的例外。没有必要让事情变得更复杂。 我同意 Martin Fowler(上面提到的)的观点。

顺便说一句:严格遵守这条规则不会破坏整个流畅的api原则吗?

【讨论】:

  • 我欢迎区分 CQS 和 CQRS 以及原问题关注 CQS。
  • " 我将接受 CQS 规则的例外情况。" 我不同意。在 CQS 中,命令是通过由处理器编排的 CommandHandlers 处理的。在处理器中,您可以混合命令和查询处理程序来获得所需的结果:首先处理命令​​,如果没有错误,则处理检索新对象状态的查询。是的,这意味着 2 次数据存储交互,但也是有利的一次,因为您的查询模型可能与您的命令模型完全不同。这将为您的代码带来清晰性和有效性。
  • @Hellraiser 看来您在这里混淆了 CQS 和 CQRS。 CQS 与 CommandHandlers 无关。数据查询不应该有意想不到的副作用只是一个原则。它是幂等的。这条规则有一些常见的例外:从堆栈中弹出一个项目,调用一个 Increment() 函数以原子方式增加和返回值,或者结合一个流畅的 API 修改一个对象。在这些情况下,状态已经改变,函数返回 void 以外的东西。但该方法的意图足够明确,不会引起问题。
  • @RemcoteWierik “这只是一个原则,数据查询不应该有意想不到的副作用” 是的,我同意......理论上。将该理论模式应用于具体用例可以看到由 CommandHandlers 处理的命令。当然这不是强制性的,但它被广泛使用(根据我的经验)
【解决方案3】:

问题是;当需要命令的结果时,如何应用 CQS?

答案是:你不知道。如果你想运行一个命令并返回一个结果,你没有使用 CQS。

然而,黑白教条的纯洁性可能是宇宙的死亡。总是存在边缘情况和灰色区域。问题是您开始创建一种 CQS 形式的模式,但不再是纯 CQS。

Monad 是一种可能性。您可以返回 Monad,而不是您的 Command 返回 void。 “void” Monad 可能如下所示:

public class Monad {
    private Monad() { Success = true; }
    private Monad(Exception ex) {
        IsExceptionState = true;
        Exception = ex;
    }

    public static Monad Success() => new Monad();
    public static Monad Failure(Exception ex) => new Monad(ex);

    public bool Success { get; private set; }
    public bool IsExceptionState { get; private set; }
    public Exception Exception { get; private set; }
}

现在你可以有一个像这样的“命令”方法:

public Monad CreateNewOrder(CustomerEntity buyer, ProductEntity item, Guid transactionGuid) {
    if (buyer == null || string.IsNullOrWhiteSpace(buyer.FirstName))
        return Monad.Failure(new ValidationException("First Name Required"));

    try {
        var orderWithNewID = ... Do Heavy Lifting Here ...;
        _eventHandler.Raise("orderCreated", orderWithNewID, transactionGuid);
    }
    catch (Exception ex) {
        _eventHandler.RaiseException("orderFailure", ex, transactionGuid); // <-- should never fail BTW
        return Monad.Failure(ex);
    }
    return Monad.Success();
}

灰色区域的问题在于它很容易被滥用。将诸如新的 OrderID 之类的返回信息放入 Monad 将允许消费者说,“忘记等待事件,我们在这里得到了 ID!!!”此外,并非所有命令都需要 Monad。您确实应该检查应用程序的结构,以确保您确实达到了边缘情况。

使用 Monad,现在您的命令消耗可能如下所示:

//some function child in the Call Stack of "CallBackendToCreateOrder"...
    var order = CreateNewOrder(buyer, item, transactionGuid);
    if (!order.Success || order.IsExceptionState)
        ... Do Something?

在很远的代码库中。 . .

_eventHandler.on("orderCreated", transactionGuid, out order)
_storeService.PerformPurchase(order);

在很远的 GUI 中。 . .

var transactionID = Guid.NewGuid();
OnCompletedPurchase(transactionID, x => {...});
OnException(transactionID, x => {...});
CallBackendToCreateOrder(orderDetails, transactionID);

现在你已经拥有了你想要的所有功能和适当性,只需要一点点用于 Monad 的灰色区域,但要确保你不会意外地通过 Monad 暴露出糟糕的模式,所以你限制了你可以做的事情它。

【讨论】:

    【解决方案4】:

    哦,这很有趣。大概我也有话要说。

    最近,我一直在使用非正统的 CQS(对某些人来说可能根本不是 CQS,但我并不在意)方法,这有助于避免混乱的存储库(因为谁使用规范模式,嗯?)实现和服务层类随着时间的推移而绝对增长,尤其是在大型项目中。问题是即使其他一切都很好并且开发人员非常熟练,它也会发生,因为(惊喜)如果你有一个大类,它并不总是意味着它首先违反了 SRP。我在此类项目中经常看到的常见方法是“哦,我们有大量的类,让我们划分它们”,这种划分主要是合成的,而不是自然演变的。那么,人们如何应对这种情况呢?他们把一门课做成了几门课。但是当你突然有比以前多几倍的课程时,在一个巨大的项目中使用 DI 会发生什么?不是很漂亮的图片,因为 DI 可能已经充满了注射。因此,出现了外观模式等变通方法(如果适用),其含义是我们: 不要阻止问题;只处理后果并为此花费大量时间;经常使用“综合”的方式进行重构;减少邪恶而不是增加邪恶,但这仍然是邪恶的。

    我们该怎么做呢?作为第一步,我们将 KISS 和 YAGNI 应用于 CQS。

    1. 使用命令/CommandHandlers 和 Queries/QueryHandlers。
    2. 对包含结果和错误的查询和命令使用通用返回对象(哎呀!)。
    3. 默认情况下避免标准服务和存储库实现 - 仅在绝对必要时。

    这种方法解决了哪些问题?

    1. 早期预防代码混乱,更易于使用和扩展(面向未来)。
    2. 信不信由你,对于中型项目,我们既没有服务类也没有存储库。项目越大,这种方法就越有益(如果我们假设不需要 CQRS 和 ES,并且仅与标准服务 + 数据层进行比较)。 我们对此非常满意,因为它对于大多数中型项目的成本和效率来说已经绰绰有余了。

    那我建议你怎么做?

    1. 为正确的工作使用正确的工具。使用可以解决您的问题的方法,如果它为 您的 案例带来不必要的复杂性,请避免按照书本行事,“只是因为这就是原因”。顺便说一句,您多久会看到一次完全 RESTful Level 3 API?..
    2. 如果您不需要任何东西,尤其是如果您不了解它,请不要使用它,因为如果您真的不了解,则弊大于利。 CQRS 适用于某些情况,并且仍然很容易理解,但要付出开发和支持的代价; ES 相当难以理解,甚至更难以构建和支持。

    【讨论】:

      【解决方案5】:

      花点时间思考一下为什么要使用命令查询分离。

      “它让您可以随意使用查询,而不必担心更改系统状态。”

      因此,可以从命令中返回一个值以让调用者知道它成功了,因为如果仅仅为了查明先前的命令是否正常工作而创建单独的查询是很浪费的。在我的书中这样的事情是可以接受的:

      boolean purchaseSucceeded = _storeService.PurchaseItem(buyer, item);
      

      您的示例的一个缺点是您的方法返回的内容并不明显。

      string result = _storeService.PurchaseItem(buyer, item);
      

      不清楚“结果”到底是什么。

      使用 CQS(命令查询分离)可以让事情变得更明显,类似于下面:

      if(_storeService.PurchaseItem(buyer, item)){
      
          String receipt = _storeService.getLastPurchaseReceipt(buyer);
      }
      

      是的,这是更多的代码,但更清楚的是发生了什么。

      【讨论】:

        【解决方案6】:

        我喜欢其他人给出的事件驱动架构建议,但我只是想换一种观点。也许你需要看看你为什么实际上从你的命令中返回数据。你真的需要它的结果吗,或者如果它失败了你可以抛出异常吗?

        我并不是说这是一个通用的解决方案,而是切换到更强大的“失败异常”而不是“发回响应”模型帮助我使分离在我自己的代码中真正起作用。当然,你最终不得不编写更多的异常处理程序,所以这是一个折衷方案……但这至少是另一个需要考虑的角度。

        【讨论】:

        • 超级迟到,但八年后仍然是一个有趣的讨论:我也总是想在失败时抛出异常,但这可能违反了一条不同的规则,即只应在真正异常时抛出异常案例。如果命令完全有可能失败,那么抛出异常将是不好的。这是一个艰难的困境。但是,我仍然发现抛出异常比设置事件和事件处理程序来查看命令是否成功更清晰、更易读。您添加的每个事件都会使课程更加复杂。
        【解决方案7】:

        我真的迟到了,但是还有一些没有提到的选项(虽然,不确定它们是否真的那么好):

        我以前从未见过的一个选项是为命令处理程序创建另一个接口来实现。也许是命令处理程序实现的ICommandResult&lt;TCommand, TResult&gt;。然后当普通命令运行时,它会在命令结果上设置结果,然后调用者通过 ICommandResult 接口提取结果。使用 IoC,您可以使它返回与命令处理程序相同的实例,以便您可以将结果拉回。不过,这可能会破坏 SRP。

        另一种选择是拥有某种共享存储,它可以让您以查询可以检索的方式映射命令结果。例如,假设您的命令有一堆信息,然后有一个 OperationId Guid 或类似的东西。当命令完成并获得结果时,它将以 OperationId Guid 作为键的数据库或另一个类中的某种共享/静态字典推送答案。当调用者重新获得控制权时,它会调用一个 Query 以根据给定的 Guid 的结果拉回。

        最简单的答案是将结果推送到命令本身,但这可能会让某些人感到困惑。我看到提到的另一个选项是事件,从技术上讲你可以做到,但如果你在网络环境中,那就更难处理了。

        编辑

        在处理了更多之后,我最终创建了一个“CommandQuery”。显然,它是命令和查询的混合体。 :) 如果在某些情况下您需要此功能,那么您可以使用它。但是,这样做需要有充分的理由。它不可重复,也无法缓存,因此与其他两个相比存在差异。

        【讨论】:

          【解决方案8】:

          嗯,这是一个很老的问题,但我发布这个只是为了记录。 每当您使用事件时,您都可以使用委托。如果您有许多相关方,请使用事件,否则使用回调样式的委托:

          void CreateNewOrder(Customer buyer, Product item, Action<Order> onOrderCreated)
          

          你也可以为操作失败的情况设置一个块

          void CreateNewOrder(Customer buyer, Product item, Action<Order> onOrderCreated, Action<string> onOrderCreationFailed)
          

          这降低了客户端代码的圈复杂度

          CreateNewOrder(buyer: new Person(), item: new Product(), 
                        onOrderCreated: order=> {...},
                        onOrderCreationFailed: error => {...});
          

          希望这可以帮助任何迷失的灵魂......

          【讨论】:

            【解决方案9】:

            CQS 主要用于实现领域驱动设计,因此您应该(正如 Oded 所说)使用事件驱动架构来处理结果。因此,您的 string result = order.Result; 将始终位于事件处理程序中,而不是直接在代码中。

            查看this great article,它显示了 CQS、DDD 和 EDA 的组合。

            【讨论】:

            • 很酷的文章,但我认为他们可能会将 CQS 与 CQRS 混淆。 CQS 更多的是关于函数签名,而 CQRS 则关注本文所描述的内容。从我读到的 CQRS 与事件驱动架构和 DDD 的联系,就像你说的,但 CQS 是一种通用的低级编程技术。
            猜你喜欢
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 2023-03-19
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            • 1970-01-01
            相关资源
            最近更新 更多