函数式领域建模(五)


现在, 我们构建了一组丰富的领域模型. 接下来要做的是 “保护好” 它, 因此应该采取一些预防措施, 以确保此域中的任何数据都是有效和一致的. 我们的目标是创建一个界限上下文, 该上下文始终包含我们可以信任的数据, 与不受信任的外部世界不同. 如果我们能够确保所有数据始终有效, 则实现可以保持干净, 并且我们可以避免执行防御性编程以及减少单元测试.

先来看下两个很重要的概念, 上一节中也有提到.

  • Integrity (or validity) 意味着一段数据遵循正确的业务规则(Guard).
  • Consistency 意味着领域模型的不同部分要与事实一致(有点类似事务).

Invariants are generally business rules/enforcements/requirements that you impose to maintain the integrity of an object at any given time.

在我们的例子中:

  • Integrity
    • UnitQuantity 介于 1 和 1000 之间.
    • Order 必须始终至少有一个 OrderLine.
    • Order 在发送到运输部门之前必须具有经过验证的运输地址.
  • Consistency
    • Order 的帐单总金额应为 OrderLines 的总和. 如果金额不等, 则数据不一致.
    • 下订单后, 必须创建相应的发票. 如果订单存在, 但发票不存在, 则数据不一致.
    • 如果折扣凭证代码与订单一起使用, 则必须将凭证代码标记为已使用, 以便无法再次使用. 如果订单引用该凭证, 但凭证未标记为已使用, 则数据不一致.

Integrity

Integrity of Simple Values

智能构造器

Units of Measure

f# 独有

Enforcing Invariants with the Type System

自定义数据类型, 通过类型系统进行限制. 例如, 针对 Order 必须始终至少有一个 OrderLine 这个 Invariant, 我们可以自定义一个列表类型, 这个自定义的列表, 不允许为空.

Capturing Business Rules in the Type System

我们来看一个接近真实的领域需求.

假设我们的公司要为其客户存储电子邮件地址. 但是, 并非所有电子邮件地址都以相同的方式进行处理. 某些电子邮件地址已经过验证(即客户收到验证电子邮件并单击了验证链接); 而其他电子邮件地址未进行验证, 我们无法确定它们是否有效. 此外, 假设还有一些基于此差异的业务规则, 例如:

  • 只应向未经验证的电子邮件地址发送验证电子邮件(以避免对现有客户发送垃圾邮件)
  • 只能向已验证的电子邮件地址发送密码重置电子邮件(以防止安全漏洞)

一种常见的方案是添加一个 flag 标识出是否经过验证:

type CustomerEmail = {
    EmailAddress : EmailAddress
    IsVerified : bool
}

但是, 这种方式的问题在于, 首先, 并不清楚何时以及为什么修改 IsVerified 标志. 当然, 我们知道, 如果客户的电子邮件地址更改了, 则应将其重新设置为 false (因为尚未验证新电子邮件). 但是, 代码设计中并没有任何东西可以使该规则明确. 对于开发人员而言, 很容易在更改电子邮件时意外忘记执行此操作, 或者更糟的是完全不了解该规则(因为它可能仅仅被写在注释中).

同样的, 这可能还有安全漏洞 – 开发人员可能会意外地编写代码将未经验证的电子邮件的标志设置为 true, 这将允许将密码重置电子邮件发送到未经验证的地址. 当然我们可以通过防御性编程防止这一点, 但是这不是我们推荐的方式.

那么, 什么是更好的建模方式呢?

Referecne: State Machines

答案是一如既往地关注领域本身. 当领域专家谈论 “已验证” 和 “未验证” 的电子邮件时, 应该将它们建模为独立的事物. 在这种情况下, 当领域专家说 “客户的电子邮件已验证或未验证” 时, 我们应该将其建模为两种类型之间的选择, 例如:

type EmailAddress = EmailAddress of String

type CustomerEmail =
    | Unverified of EmailAddress
    | Verified of EmailAddress

但是, 这并不能阻止用一个未经验证的电子邮件构造 Verified 实例的意外情况. 例如:

let address = EmailAddress 'xxxx'  // 未经验证
let verifiedEmail = Verified address // 但仍可以构造出 Verified

为了解决这个问题, 我们将创建一个新的 VerifiedEmailAddress 类型用以区别于正常的 EmailAddress 类型. 现在新的类型如下:

type CustomerEmail =
    | Unverified of EmailAddress
    | Verified of VerifiedEmailAddress // different from normal EmailAddress

接下来, 我们可以为 VerifiedEmailAddress 提供一个智能构造器, 以便普通代码无法创建该类型的值 – 只有验证服务可以.

这意味着, 如果我们有一个新的电子邮件地址, 则必须使用 Unverified 构造 CustomerEmail, 因为我们没有 VerifiedEmailAddress, 而获得 VerifiedEmailAddress 的唯一方法是通过电子邮件验证服务本身.

这是函数式编程中一个重要的设计准则: “Make illegal states unrepresentable.”, 我们尝试用类型系统来捕获业务规则. 如果我们能够正确实践此操作, 则代码中永远不会存在无效的情况, 并且我们永远不需要为它们编写单元测试 – 我们改为使用 “编译时” 单元测试.

Make illegal states unrepresentable 没想好怎么翻译…, 这句话的意思是: 使用类型系统让代码本身就无法表现出非法状态. 实践起来基本上就是多用 sum-types 尽可能细粒度捕获类型.

这种方式的另一个重要好处是, 它实际上可以更好地记录领域. 通常, 一旦我们创建了这些更细粒度的类型, 我们就会立即找到它们的用途. 例如, 现在可以显式记录发送密码重置消息的工作流必须采用 VerifiedEmailAddress 作为输入, 而不是作为普通的 EmailAddress.

type SendPasswordResetEmail = VerifiedEmailAddress -> ...

使用此定义, 我们不必担心有人因为没有阅读文档而意外地传入普通的 EmailAddress. 我们不再需要防御性编程和单元测试(或更少).

下面是另一个示例, 假设我们有一个业务规则, 需要某种联系客户的方式: “A customer must have an email or a postal address”.

我们应该如何表示这一点? 显而易见的方式是使用 EmailAddress 属性来创建类型, 如下所示:

type Contact = {
    Name: Name
    Email: EmailContactInfo
    Address: PostalContactInfo
}

但这是一个错误的设计. 这意味着同时需要电子邮件和地址. 好, 现在让它们可选.

type Contact = {
    Name: Name
    Email: EmailContactInfo option
    Address: PostalContactInfo option
}

但这也不正确. 因为有可能电子邮件和地址都为空, 这将破坏业务规则. 当然, 我们可以添加特殊的运行时验证检查(防御性编程), 以确保不会发生这种情况. 但是我们可以做得更好 – Make illegal states unrepresentable.

诀窍是仔细关注领域并查看规则. 我们了解到这客户:

  • Has an email address only, or
  • Has a postal address only, or
  • Has both an email address and a postal address

这只有三种可能性. 我们如何表示这三种可能性呢? 当然是使用 OR 类型!

type BothContactMethods = {
    Email: EmailContactInfo
    Address : PostalContactInfo
}

type ContactInfo =
    | EmailOnly of EmailContactInfo
    | AddrOnly of PostalContactInfo
    | EmailAndAddr of BothContactMethods

type Contact = {
    Name: Name
    ContactInfo : ContactInfo
}

同样, 我们所做的对开发人员有好处 – 少一个测试要写, 因为我们不能意外缺少联系信息; 而且它同样也有利于设计, 代码非常清楚地表明, 只有三种可能的情况, 以及这三种情况的确切内容. 我们不需要看文档, 我们可以只看代码本身 – 代码即文档.

Consistency

Consistency(一致性)是一个业务术语, 而不是一个技术术语, 一致性的含义始终取决于上下文. 例如, 如果一个产品的价格发生了变化, 订单的上价格是否要随着变化, 还是依然维持以前的价格直到新下一个订单? 这个问题没有绝对正确的答案, 它取决于当前领域业务的需求.

不过, 一致性确实给设计带来了很大的负担, 而且成本很高, 因此, 如果可以的话, 我们希望避免需要它. 通常, 在需求收集期间, 产品所有者会要求一个不可取且不切实际的一致性级别. 但是, 在许多情况下, 可以避免或延迟对一致性的需求.

最后, 必须认识到持久化的原子性和一致性是相互关联的. 例如, 如果订单不会以原子方式持久化, 则确保订单在内部一致是没有意义的. 因为如果某一个部分保存失败, 那么以后任何人都将会加载到内部不一致的订单.

Consistency Within a Single Aggregate

聚合既充当一致性边界, 也充当持久性单元. 让我们看看这在实践中是如何工作的.

假设我们要求订单的总金额应为各个订单行的总和. 确保一致性的最简单方法是从原始数据中计算信息, 而不是存储计算结果. 在这种情况下, 我们可以在每次需要总计时(在内存中或使用 SQL 查询)对订单行求和.

/// We pass in three parameters:
/// * the top-level order
/// * the id of the order line we want to change
/// * the new price
let changeOrderLinePrice order orderLineId newPrice =

    // find orderLine in order.OrderLines using orderLineId
    let orderLine = order.OrderLines |> findOrderLine orderLineId

    // make a new version of the OrderLine with new price
    let newOrderLine = {orderLine with Price = newPrice}

    // create new list of lines, replacing old line with new line
    let newOrderLines =
        order.OrderLines |> replaceOrderLine orderLineId newOrderLine

    // make a new AmountToBill
    let newAmountToBill = newOrderLines |> List.sumBy (fun line -> line.Price)

    // make a new version of the order with the new lines
    let newOrder = {
        order with
        OrderLines = newOrderLines
        AmountToBill = newAmountToBill
    }

    // return the new order
    newOrder

如果确实需要保留额外的数据(例如存储在顶级订单中的额外金额), 那么我们需要确保它能一直保持同步, 也就是说, 如果其中某一行已更新, 则还必须更新总额以保持数据一致. 显然, 知道如何保持一致性的唯一组件是顶级的订单组件. 这是在订单级别而不是订单行级别执行所有更新的一个很好的理由 – 订单是强制执行一致性边界的聚合.

另外, 聚合也是原子性的单位, 因此, 如果我们要将此订单保存到数据库中, 我们必须确保在同一事务中插入或更新订单头和订单行.

Consistency Between Different Contexts

如果我们需要协调不同的上下文, 该怎么办?让我们看一下第二个示例:

  • 下订单后, 必须创建相应的发票. 如果订单存在, 但发票不存在, 则数据不一致.

开票是计费领域的一部分, 而不是接单领域, 这是否意味着我们需要进入另一个域并操作其中对象? 当然不是. 我们必须使每个界限上下文保持隔离和接偶.

那是否可以使用计费上下文的公共 API 呢, 例如:

Ask billing context to create invoice
    If successfully created:
        create order in order-taking context

此方法比看起来要棘手得多, 因为还需要处理任一更新失败. 当然, 有一些方法可以正确同步不同系统之间的更新(如两阶段提交), 但实际上很少需要这样做. 在现实世界中, 企业通常并不要求每个流程都进入锁定步骤, 等待所有子系统完成一个阶段, 然后再进入下一阶段. 相反, 协调是使用消息异步完成的. 有时, 事情会出错, 但处理罕见错误的成本通常比保持所有内容同步的成本要低得多.

好, 现在假设我们只需向计费域发送消息(或事件), 然后继续处理订单的其余部分, 而不是等待创建发票. 当然, 会出现一些失败情况, 例如消息丢失或者某种原因创建发票失败, 此时, 我们该怎么处理?

  • 一种选择是什么都不做. 然后, 客户得到免费的东西, 企业必须注销成本. 如果错误很少且成本很小, 这可能是完全适当的解决方案.
  • 另一个选项是检测消息是否丢失并重新发送. 这基本上就是一个调度程序要作的事: 比较两组数据, 如果它们不匹配, 修正错误.
  • 第三个选项是创建 “补偿” 操作, 以撤消以前的操作或修复错误. 在我们例子中, 这相当于取消订单, 并要求客户将产品发送回来. 更现实地讲, 补偿操作可能被用来执行诸如更正订单中的错误或退款等操作.

在所有这三种情况下, 都不需要在界限上下文之间进行严格地同步操作.

如果我们对一致性有要求, 那么我们需要实现第二个或第三个选项. 但这种一致性不会立即生效. 相反, 只有在一段时间过后, 系统才会变得一致 – 最终一致性. 最终的一致性不是 “可选一致性”, 它要确保系统在未来某个时候保持一致性.

下面是一个示例. 假设产品价格已更改, 我们确实希望更新尚未发货的所有订单的价格. 如果我们想要立即的一致性, 我们必须在同一事务中更新所有所有订单的价格, 这可能需要一些时间.

相反, 如果使用异步, 我们可以会创建 PriceChanged 事件, 从而触发一系列 UpdateOrderWithChangedPrice 命令以更新未结订单. 这些命令将在产品更改后一段时间进行处理, 也许在几秒钟后, 也许在数小时后. 最终, 订单将更新, 系统将一致.

Consistency Between Aggregates in the Same Context

假设两个聚合需要彼此一致, 我们应该在同一事务中一起更新它们, 还是使用最终一致性单独更新它们? 我们应该采取哪种方法?

通常, 有用的准则是每个事务只更新一个聚合. 如果涉及多个聚合, 则应使用上一小节的消息机制和最终一致性, 即使两个聚合都在同一界限上下文中. 但有时, 特别是如果业务认为工作流是单个事务, 则可能值得将所有受影响的实体都包括在一个事务中.

一个典型的例子是在两个账户之间转账, 其中一个帐户增加而另一个减少.

Start transaction
Add X amount to accountA
Remove X amount from accountB
Commit transaction

如果帐户由帐户聚合表示, 那么我们将在同一事务中更新两个不同的聚合. 这不一定是问题, 但它可能是一个可以重构以获得对领域有更深入了解的线索.

例如, 在这种情况下, 事务通常有自己的标识符, 这意味着它本身就是一个实体. 所以, 为什么不这样建模它:

type MoneyTransfer = {
    Id: MoneyTransferId
    ToAccount : AccountId
    FromAccount : AccountId
    Amount: Money
}

更改后, 帐户实体仍然存在, 但它们将不再直接负责添加或删除资金. 账户的当前余额现在可以通过迭代引用该账户的 MoneyTransfer 记录来计算. 我们不仅重构了设计, 还了解了有关该领域的一些知识.

这也表明, 如果没有必要, 您就不必重用聚合. 如果您只需要针对一个用例进行这样的新聚合, 请继续.

下面这段是原话, 我没弄懂是什么意思…

This also shows that you shouldn’t feel obligated to reuse aggregates if it doesn’t make sense to do so. If you need to make a new aggregate like this just for one use-case, go ahead.

Multiple Aggregates Acting on the Same Data

我们之前强调过, 聚合会强制执行 Integrity 约束, 因此, 如果我们有多个对同一数据起作用的聚合, 那么如何确保约束被一致地实施? 例如, 我们可能有一个帐户汇总, 但也有一个 MoneyTransfer 汇总, 两者都需要确保余额不会变为负数.

在许多情况下, 如果使用类型对约束进行约束, 则可以在多个聚合之间共享约束. 例如, 可以使用 NonNegativeMoney 类型来模拟帐户余额不得低于零的要求. 如果这不适用, 则可以使用共享验证功能. 这是功能模型相对于面向对象模型的优势 –– 验证功能不附加至任何特定对象, 也不依赖于全局状态, 因此可以轻松地在不同的工作流程中重用它们.