函数式领域建模(四)


Value Objects

我们已经了解了对领域数据和工作流建模的基本方法. 现在, 让我们继续研究一种对数据类型进行分类的重要方式 – 基于数据类型是否具有持久标识.

在 DDD 术语中, 具有持久身份的对象称为 Entities(实体), 而没有持久身份的对象称为 Value Objects(值对象). 让我们首先讨论值对象.

在许多情况下, 我们正在处理的数据对象没有身份 – 它们是可互换的. 例如, 出现在所有地方值为 W1234 的 WidgetCode 都彼此相等, 我们不需要区分它们.

let widgetCode1 = WidgetCode "W1234"
let widgetCode2 = WidgetCode "W1234"
printfn "%b" (widgetCode1 = widgetCode2) // prints "true"

“values without identity” 的概念在领域模型中经常出现, 无论是复杂类型还是简单类型. 例如, 一个 PersonalName 的 AND 类型可能具有 FirstNameLastName 两个字段, 因此它比简单的字符串复杂, 但它也是一个值对象, 因为具有相同字段的两个个人名称是可以互换的.

let name1 = {FirstName="Alex"; LastName="Adams"}
let name2 = {FirstName="Alex"; LastName="Adams"}
printfn "%b" (name1 = name2) // prints "true

例如 “address” 类型也是值对象, 如果两个值具有相同的街道地址以及城市和邮政编码, 则它们是相同的地址.

Implementing Equality for Value Objects

当我们使用 F# 代数类型系统对领域建模时, 默认情况下, 我们创建的类型基于字段的相等性判断 – 我们不需要自己编写任何判断相等性的代码.

准确地说, 在 F# 中, 如果两个 AND 类型值的所有字段都相等, 则两个值(相同类型)相等; 如果两个 OR 类型的选择情况相同, 则两个选择值相等; 这称为结构平等.

而在其它语言中, 我们可能需要重写 Equals 之类的方法.

Entities

但是, 我们也经常需要对在现实世界中具有独特标识的事物进行建模, 即使它们的组成发生变化, 但它们依然是同一个事物. 例如, 即使我更改了姓名或地址, 我仍然是同一个人.

DDD 术语中, 这些事物被称为 Entities(实体).

在我们实例的上下文中, 实体通常是某种类型的文档: 订单, 报价, 发票, 客户资料, 产品单等. 它们具有生命周期, 并通过各种业务流程从一种状态转换为另一种状态.

值对象与实体之间的区别取决于其所在的上下文. 例如, 考虑手机的生命周期.

  • 在制造过程中, 每部手机都会获得一个唯一的序列号, 因此在这种情况下, 它们将被建模为实体.
  • 在出售时, 序列号无关紧要-所有规格相同的手机都是可以互换的-可以将它们建模为值对象.
  • 一旦将特定手机出售给特定客户, 身份就会再次变得相关, 应该将其建模为一个实体: 即使更换屏幕或电池, 客户也将其视为同一部手机.

Identifiers for Entities

在对实体进行建模时, 我们需要为它们提供唯一的标识符或键, 例如 Order Id, or Customer Id.

例如下面的 Contact 类型, 不管 PhoneNumberEmailAddress 属性怎么更改, 它的 ContactId 属性保持不变.

type ContactId = ContactId of int

type Contact = {
    ContactId : ContactId
    PhoneNumber : ...
    EmailAddress: ...
}

这些标识符从何而来?

有时, 标识符是由真实世界本身提供的, 例如纸质订单和发票上总是写有某种单号; 但有时, 我们需要使用 UUID, 自动递增数据库表, ID 生成服务等技术自己创建一个人工标识符. 在我们的实例中, 仅假设客户已向我们提供了标识符.

Adding Identifiers to Data Definitions

向 AND 类型添加标识符很简单, 只需添加一个字段, 但是如何向 OR 类型添加标识符? 我们应该将标识符放在内部(与每个 case 关联)还是在外部(与任何 case 都不关联)?

例如, 假设我们有两个发票选项: UnpaidPaid.

如果我们使用外部方式对其进行建模, 我们将有一个包含 InvoiceId 的 AND 类型, 然后在该类型内有一个选择类型 InvoiceInfo, 其中包含每种发票类型的信息. 该代码将如下所示:

// Info for the unpaid case (without id)
type UnpaidInvoiceInfo = ...

// Info for the paid case (without id)
type PaidInvoiceInfo = ...

// Combined information (without id)
type InvoiceInfo =
    | Unpaid of UnpaidInvoiceInfo
    | Paid of PaidInvoiceInfo

// Id for invoice
type InvoiceId = ...

// Top level invoice type
type Invoice = {
    InvoiceId : InvoiceId // "outside" the two child cases
    InvoiceInfo : InvoiceInfo
}

如果使用内部方式, 我们将创建两个单独的类型(UnpaidInvoicePaidInvoice), 这两个类型都有自己的 InvoiceId, 然后是一个在它们之间进行选择的顶级 OR 类型 Invoice. 该代码将如下所示:

type UnpaidInvoice = {
    InvoiceId : InvoiceId // id stored "inside"
    // and other info for the unpaid case
}

type PaidInvoice = {
    InvoiceId : InvoiceId // id stored "inside"
    // and other info for the paid case
}

// top level invoice type
type Invoice =
    | Unpaid of UnpaidInvoice
    | Paid of PaidInvoice

相对于外部方式, 内部方式都易于使用模式匹配, 它将所有的数据都放在一起, 包括 id:

let invoice = Paid {InvoiceId = ...}

match invoice with
    | Unpaid unpaidInvoice ->
      printfn "The unpaid invoiceId is %A" unpaidInvoice.InvoiceId
    | Paid paidInvoice ->
      printfn "The paid invoiceId is %A" paidInvoice.InvoiceId

在实践中, 更常见的是使用内部方法.

Implementing Equality for Entities

前面我们看到, 默认情况下, F# 中的相等性判断使用类型的所有字段. 但是, 当我们比较实体时, 我们只想使用标识符字段. 这意味着, 为了在 F# 中正确建模实体, 我们必须更改默认行为.

一种方法是重写相等性判断, 以便仅使用标识符. 要更改默认判断逻辑, 我们必须:

  1. 重写 Equals 方法.
  2. 重写 GetHashCode 方法.
  3. CustomEqualityNoComparison 属性添加到类型中, 以告知编译器我们要更改默认行为.
[<CustomEquality; NoComparison>]
type Contact = {
    ContactId : ContactId
    PhoneNumber : PhoneNumber
    EmailAddress: EmailAddress
}
with
override this.Equals(obj) =
    match obj with
        | :? Contact as c -> this.ContactId = c.ContactId
        | _ -> false
override this.GetHashCode() =
    hash this.ContactId

Immutability and Identity

在函数式编程中, 值是不可变的, 这意味着到目前为止定义的对象在初始化后都无法更改.

对于值对象, 这非常好. 但对实体而言, 则是另一回事. 因为实体有生命周期, 我们希望与实体相关的数据会随着生命周期变化 – 这就是拥有恒定标识符的全部意义. 那么如何使不可变数据结构实现这一点?

答案是在保留身份的同时使用更改后的数据复制实体. 看起来这些复制操作似乎造成很多额外的工作, 但实际上并不是问题.

下面是一个如何在 F#中更新实体的示例.

其它语言中, 可以使用 Lens(透镜).

首先, 我们将从一个初始值开始:

let initialPerson = {PersonId=PersonId 42; Name="Joseph"}

要在仅更改某些字段的同时复制值, F# 具有 with 关键字, 其用法如下:

let updatedPerson = {initialPerson with Name="Joe"}

复制之后, updatedPerson 具有不同的名称, 但与 initialPerson 具有相同的 PersonId.

使用不可变数据结构的好处是进行任何更改都必须在类型签名中明确表示. 例如, 如果我们要编写一个函数来更改 Person 中的 Name 字段, 则不能使用以下签名的函数:

type UpdateName = Person -> Name -> unit

该函数没有输出, 这意味着没有任何改变(或者说 Person 没有副使用). 我们的函数必须有一个 Person 类型作为输出, 如下所示:

type UpdateName = Person -> Name -> Person

这清楚地表明, 给定一个人和一个名字, 将返回原始人的某种变体.

Aggregates

让我们仔细看看与我们的设计特别相关的两个数据类型: OrderOrderLine.

What is OrderLine?
下订单时, 订购的货物在订单中, 一种产品表现为一行, 也就是一个品项的产品.
也就是说, OrderLine 从属 Order, 一个 Order 包含多个 OrderLine.

首先, Order 是实体还是值对象? 显然, 这是一个实体 – Order 的详细信息可能会随着时间的流逝而变化(待验证->已验证…), 但是它是相同的一个 Order.

OrderLine 呢? 如果我们更改特定 OrderLine 的数量, 它仍然是同一 OrderLine 吗?
在大多数设计中, 是这样的, 即使数量或价格随时间发生了变化, 它仍然是相同的 OrderLine. 因此, OrderLine 也是一个具有其自身标识符的实体.

那么问题是, 如果我们更改了一个 OrderLine, 是否也要更改它所在的 Order?
我们的例子中, 答案很明显是更改了 OrderLine 也要更改整个 Order.

但事实上, 因为使用了不可变的数据结构, 如果有一个包含不可变 OrderLine 列表的不可变 Order, 那么仅仅创建一份 OrderLine 的副本并不会创建 Order 的副本.

所以, 为了更改 Order 中包含的 OrderLine, 需要在 Order 级别进行更改, 而不是 OrderLine 级别. 例如, 下面是一些用于更新 OrderLine 价格的伪代码(一个函数):

/// 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 =
    // 1. find the line to change using the orderLineId
    let orderLine = order.OrderLines |> findOrderLine orderLineId

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

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

    // 4. make a new version of the entire order, replacing
    // all the old lines with the new lines
    let newOrder = {order with OrderLines = newOrderLines}

    // 5. return the new order
    newOrder

最终结果(函数的输出)是包含新 OrderLine 列表的新 Order, 其中某一个 OrderLine 具有新价格.

可以看到, 数据的不变性会导致数据结构中的连锁反应, 因此更改一个低级组件也要强制更改更高级别的组件. 此例中, 即使我们只是需要更改其 “子实体” 之一(OrderLine), 也总是必须对 Order 本身进行操作.

这是一个非常常见的情况: 我们有一个实体的集合, 每个实体都有自己的 ID 以及一些包含它们的 “顶级” 实体. 在 DDD 术语中, 像这样的实体集合称为聚合, 顶层实体称为聚合根.

在我们的例子中, 聚合包括 OrderOrderLine 的列表, 聚合根是 Order 本身.

Aggregates Enforce Consistency and Invariants

在更新数据时, 聚合起着重要作用. 聚合充当一致性边界 – 当聚合的一部分更新时, 可能还需要更新其他部分以确保一致性.

例如, 我们可能会扩展此设计, 以便在顶级 Order 中存储额外的 TotalPrice. 那么, 如果其中某一个 OrderLine 更改了价格, 则还必须更新 TotalPrice 以保持数据一致. 上面的 changeOrderLinePrice 函数完成了这个操作. 显然, 知道如何保持一致性的唯一组件是顶级 Order(聚合根), 因此这是在 Order 级别而不是 OrderLine 级别执行更新的另一个原因.

聚合也是确保不变性(Invariants)的地方. 假设有一个规则, Order 中始终至少有一个 OrderLine. 然后, 如果尝试删除 OrderLine, 则聚合应可确保在仅剩一个 OrderLine 时出现错误. 后续会有章节讨论这个话题.

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

Aggregate References

此引用非其它语言中的引用类型

现在, 假设我们需要有关客户的信息与订单相关联. 可能会诱使你将客户添加为订单的字段, 如下所示:

type Order = {
    OrderId : OrderId
    Customer : Customer // info about associated customer
    OrderLines : OrderLine list
    // etc
}

但是, 想想不变性的连锁反应 —— 如果改变了客户的任何部分, 也必须改变订单. 那真的是我们想要的吗?

更好的设计是存储客户的引用, 而不是整个客户本身. 也就是说, 我们只将 CustomerId 存储在订单类型中, 如下所示:

type Order = {
    OrderId : OrderId
    CustomerId : CustomerId // reference to associated customer
    OrderLines : OrderLine list
    // etc
}

使用这种方式, 如果我们需要有关客户的完整信息, 先从订单中获取 CustomerId, 然后从数据库中单独加载相关的客户数据, 而不是将其作为订单的一部分加载. 也就是说, 客户和订单是不同且独立的聚合. 它们各自负责自己的内部一致性, 它们之间的唯一连接是通过聚合根的对象标识符.

这导致聚合的另一个重要方面: 它们是持久性的基本单位. 如果要从数据库中加载或保存对象, 则应加载或保存整个聚合. 每个数据库事务都应使用单个聚合, 并且不包括多个聚合或跨聚合边界. 后续章节会有案例参考.

同样, 如果要序列化对象以将其进行传递, 则始终发送整个聚合, 而不是发送其中的一部分.

明确一点, 并不是所有实体的集合都能成为聚合. 例如, 客户列表是实体的集合, 但它不是 DDD 所说的 “聚合”, 因为它没有顶级实体作为聚合根, 并且它一个也不是一致性边界.

Important Role Of Aggregates

以下是聚合在领域模型中的重要作用摘要:

  • 聚合是领域对象的集合, 可以被视为单个单元, 顶级实体充当聚合根.
  • 对聚合内对象的所有更改都必须通过聚合根进行, 并且聚合充当一致性边界, 以确保聚合内的所有数据同时正确更新.
  • 聚合是持久化、数据库事务和数据传输的原子单位.

定义聚合是设计过程中的一个重要部分. 有时, 相关的实体是同一聚合(OrderLineOrder)的一部分, 有时它们不是(CustomerOrder). 这是与领域专家协作至关重要的地方: 只有他们才能帮助我们了解实体之间的关系和一致性边界.

Putting It All Together

我们已经创建了许多类型, 让我们回顾一下它们如何作为一个完整的领域模型组合在一起.

首先, 我们将所有这些类型放在一个称为 OrderOrder.Domain 的命名空间中, 该空间用于将这些类型与其他命名空间分开. 换句话说, 我们使用 F# 中的命名空间来指示 DDD 界限上下文, 至少目前是这样.

首先是一些值对象, 它们不需要标识符.

然后是一些实体, 例如订单, 它是一个实体, 具有身份标识, 因此我们必须使用 ID 对其进行建模. 但我们现在不知道 IDstring, 还是 int 还是 guid, 但我们知道我们需要它, 因此, 现在让我们使用 Undefined. 我们将以同样的方式处理其他标识符.

最后, 让我们以工作流本身结束. 工作流的输入 UnvalidatedOrder 将从订单表单 “原样” 生成, 因此将仅包含 intstring 等基础类型. 工作流的输出需要两种类型: 工作流成功时的事件类型以及失败类型.

namespace OrderTaking.Domain

//
//
// 这些都是值对象, 不需要标识符.
//
//
// Product code related
type WidgetCode = WidgetCode of string
// constraint: starting with "W" then 4 digits
type GizmoCode = GizmoCode of string
// constraint: starting with "G" then 3 digits
type ProductCode =
    | Widget of WidgetCode
    | Gizmo of GizmoCode

// Order Quantity related
type UnitQuantity = UnitQuantity of int
type KilogramQuantity = KilogramQuantity of decimal
type OrderQuantity =
    | Unit of UnitQuantity
    | Kilos of KilogramQuantity

//
//
// 一些 Undefined 标识符, 以及一些实体.
//
//
type Undefined = exn
type OrderId = Undefined
type OrderLineId = Undefined
type CustomerId = Undefined
type CustomerInfo = Undefined
type ShippingAddress = Undefined
type BillingAddress = Undefined
type Price = Undefined
type BillingAmount = Undefined

type Order = {
    Id : OrderId // id for entity
    CustomerId : CustomerId // customer reference
    ShippingAddress : ShippingAddress
    BillingAddress : BillingAddress
    OrderLines : OrderLine list
    AmountToBill : BillingAmount
}

and OrderLine = {
    Id : OrderLineId // id for entity
    OrderId : OrderId
    ProductCode : ProductCode
    OrderQuantity : OrderQuantity
    Price : Price
}

//
//
// 定义工作流及其输入和输出.
//
//
type UnvalidatedOrder = {
    OrderId : string
    CustomerInfo : ...
    ShippingAddress : ...
    ...
}

type PlaceOrderEvents = {
    AcknowledgmentSent : ...
    OrderPlaced : ...
    BillableOrderPlaced : ...
}

type PlaceOrderError =
    | ValidationError of ValidationError list
    | ... // other errors

and ValidationError = {
    FieldName : string
    ErrorDescription : string
}

/// The "Place Order" process
type PlaceOrder =
    UnvalidatedOrder -> Result<PlaceOrderEvents, PlaceOrderError>

但我们的模型尚未完成. 例如, 该如何对订单的不同状态进行建模: 验证、定价等?