函数式领域建模(三)


Reviewing the Domain Model

TODO: 补充需求.

现在让我们看下已有领域模型的伪代码 (在与领域专家讨论需求时记录下来的):

context: Order-Taking
// ----------------------
// Simple types
// ----------------------
// Product codes
data ProductCode = WidgetCode OR GizmoCode
data WidgetCode = string starting with "W" then 4 digits
data GizmoCode = ...

// Order Quantity
data OrderQuantity = UnitQuantity OR KilogramQuantity
data UnitQuantity = ...
data KilogramQuantity = ...

// ----------------------
// Order lifecycle
// ----------------------
// ----- unvalidated state -----
data UnvalidatedOrder =
    UnvalidatedCustomerInfo
    AND UnvalidatedShippingAddress
    AND UnvalidatedBillingAddress
    AND list of UnvalidatedOrderLine

data UnvalidatedOrderLine =
    UnvalidatedProductCode
    AND UnvalidatedOrderQuantity

// ----- validated state -----
data ValidatedOrder = ...
data ValidatedOrderLine = ...

// ----- priced state -----
data PricedOrder = ...
data PricedOrderLine = ...

// ----- output events -----
data OrderAcknowledgmentSent = ...
data OrderPlaced = ...
data BillableOrderPlaced = ...

// ----------------------
// Processes
// ----------------------
process "Place Order" =
    input: UnvalidatedOrder
    output (on success):
        OrderAcknowledgmentSent
        AND OrderPlaced (to send to shipping)
        AND BillableOrderPlaced (to send to billing)
    output (on error):
        InvalidOrder

// etc

我们的目标是将此转换为真实的代码.

Modeling Datas with Types

Seeing Patterns in a Domain Mode

组合的类型系统是实践领域驱动设计的绝佳帮助, 因为只需将类型混合在一起, 即可快速创建复杂的模型. 并且, 在函数式领域建模中, 也有一些常用的模式:

  • Simple values. 基础类型的包装. 因为不会直接使用像 int/string 之类的 “原始” 语言.
  • Combinations of values with AND. 也许是它语言中的结构体或类.
  • Choices with OR. 某种程序上类似枚举
  • Processes. 具有输入和输出的流程

Modeling Simple Values

领域专家们一般不会使用 int 之类的术语进行思考, 他们使用领域术语 –– OrderIdProductCode. 此外, 使用领域术语不容易混淆一些概念, 比如 OrderIdProductCode 都是 int, 但并不意味着它们可以互换. 所以, 为了明确这些类型是不同的, 我们将创建 “包装类型” –– 一种包装基础数据类型的类型.

F# 中创建 Simple values 的最简单方法是创建 “single-case” 联合类型 –– 只有一个选项的 OR 类型, 比如:

type CustomerId =
    | CustomerId of int

type CustomerId = CustomerId of int // 缩写成一行

let customerId = CustomerId 42  // 构造值

let (CustomerId innerValue) = customerId  // 解构, 模式匹配, innerValue is set to 42

通常这种 “single-case” 的类型名与构造子名相同.

现在我们可以审视一下领域模型, 并转换成部分代码:

type WidgetCode = WidgetCode of string
type UnitQuantity = UnitQuantity of int
type KilogramQuantity = KilogramQuantity of decimal

这里我们暂时忽视掉取值范围的约束, 后续会说明怎么建模有约束的 Simple values.

另外, 遍历 Simple values 的列表要比直接遍历基础数据类型的列表多花费一些开销, 这是因为内存不连续引起的.
当然, 这些开销通常不大需要关注, 除非我们的领域非常在意性能. 如果是这样的话, 可以使用下面这种方式代替直接建模 Simple values 的列表.

// type CustomerIds = CustomerIds of CustomerId[]
type CustomerIds = CustomerIds of int[]

Modeling Complex Data

复杂的类型就要借助到代数数据类型了.

在领域模型中, 我们看到许多数据结构都是 AND 型关系, 例如, 我们最初的简单订单模型定义为:

data Order =
    CustomerInfo
    AND ShippingAddress
    AND BillingAddress
    AND list of OrderLines
    AND AmountToBill

这可以很方便的直接转换成 F# 代码:

type Order = {
    CustomerInfo : CustomerInfo
    ShippingAddress : ShippingAddress
    BillingAddress : BillingAddress
    OrderLines : OrderLine list
    AmountToBill : ...
}

建模的时候, 我们会发现存在一些未解答的领域问题.
例如应该用什么类型来表示 AmountToBill? ShippingAddressBillingAddress 是相同的类型吗? 等等.

理想情况是继续请求领域专家的帮助. 例如, 如果他们将帐单地址和发货地址作为不同内容进行讨论, 那么即使它们具有相同的结构, 也最好将它们逻辑上分开. 当然我们不需要立即去寻求帮助, 因为我们可以对未知类型进行建模.

Modeling Unknown Types

在设计的早期阶段, 通常不会对某些建模问题给出明确答案. 例如我们知道待建模的类型的名字, 但并不清楚它的内部结构.

这不是问题 –– 我们可以将它们建模为显式的未定义的类型, 该类型充当占位符, 直到在设计过程后期有更好的理解.

如果要在 F# 中表示未定义的类型, 可以使用异常类型 exn 并将其别名为 Undefined; 然后, 就可以在设计模型时使用这个别名, 如下所示:

type Undefined = exn

type CustomerInfo = Undefined
type ShippingAddress = Undefined
type BillingAddress = Undefined
type OrderLine = Undefined
type BillingAmount = Undefined

type Order = {
    CustomerInfo : CustomerInfo
    ShippingAddress : ShippingAddress
    BillingAddress : BillingAddress
    OrderLines : OrderLine list
    AmountToBill : BillingAmount
}

此方法意味着可以继续使用类型对领域进行建模, 并且编译代码; 但当尝试编写处理这些类型的函数时, 会被强制用更好一点的 “东西” 去替换 Undefined.

Modeling with Choice Types

在我们的领域中, 我们也看到许多 OR 类型, 例如:

data ProductCode =
    WidgetCode
    OR GizmoCode

data OrderQuantity =
    UnitQuantity
    OR KilogramQuantity

我们可以使用 Choices with OR 对它们进行建模.

type ProductCode =
    | Widget of WidgetCode
    | Gizmo of GizmoCode

type OrderQuantity =
    | Unit of UnitQuantity
    | Kilogram of KilogramQuantity

在这种情况下, 区别于 “single-case”, 类型名与构造子名并不需要相同, 例如 WidgetWidgetCode.

Modeling Workflows with Functions

现在我们已经对数据结构 – “the nouns of the ubiquitous language” 进行了建模. 接下来, 我们将对工作流进行建模 – “the verbs of the ubiquitous language”.

例如, 如果我们有一个验证订单表单的工作流, 我们可能会将其记录为:

type ValidateOrder = UnvalidatedOrder-> ValidatedOrder

显而易见, 验证订单流程会将未验证的订单转换为已验证的订单.

Working with Complex Inputs and Outputs

每个函数只有一个输入和一个输出, 但某些工作流可能具有多个输入和输出 –– 我们如何建模?

我们将从输出开始. 如果工作流具有 outputAoutputB, 则可以创建 AND 类型来存储它们. 我们在 order-placing 工作流中看到了这一点: 输出需要三个不同的事件. 因此, 让我们创建一个复合类型来将它们建模为一条记录:

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

然后, 可以将 order-placing 工作流建模为函数类型:

type PlaceOrder = UnvalidatedOrder -> PlaceOrderEvents

另一方面, 如果工作流具有 outputAoutputB, 则可以创建一个 OR 类型来存储它们. 例如, 我们简要讨论了将客户邮件分类为报价或订单. 这个过程对产出至少有两种不同的选择:

process "Categorize Inbound Mail" =
    input: Envelope contents
    output:
        QuoteForm (put on appropriate pile)
        OR OrderForm (put on appropriate pile)
        OR ...

很容易对此进行建模: 只需创建一个新的 OR 类型(例如 CategorizedMail)来表示结果, 然后让 CategorizeInboundMail 过程返回该类型. 最后, 我们的模型可能如下所示:

type CategorizedMail =
    | Quote of QuoteForm
    | Order of OrderForm

type CategorizeInboundMail = EnvelopeContents -> CategorizedMail

现在, 让我们来看看建模输入. 如果工作流具有不同的输入选择, 则可以创建 OR 类型. 但是, 如果流程有多个必需的输入, 例如下面的 “Calculate Prices, 我们可以在两种可能的方法之间进行选择.

"Calculate Prices" =
    input: OrderForm, ProductCatalog
    output: PricedOrder

第一个最简单的方法是将每个输入作为单独的参数传递, 如下所示:

type CalculatePrices = OrderForm -> ProductCatalog -> PricedOrder

或者, 我们可以创建新的 AND 类型来同时包含它们, 例如:

type CalculatePricesInput = {
    OrderForm : OrderForm
    ProductCatalog : ProductCatalog
}

现在函数如下所示:

type CalculatePrices = CalculatePricesInput -> PricedOrder

哪一种方式更好?

在上面的例子中, 如果 ProductCatalog 是依赖项而不是 “实际” 输入, 则我们希望使用第一种方法(单独的参数). 这使我们能够使用函数式编程中的依赖注入. 我们将在后面 <<依赖注入>> 章节中详细讨论这一点, 届时我们将实现订单处理管道.

另一方面, 如果两个输入始终是必需的, 并且彼此紧密相连, 则应使用 AND 类型.(在某些情况下, 可以使用 tuples 作为简单 AND 类型的替代方法, 但通常最好使用命名类型.)

Documenting Effects in the Function Signature

我们刚刚看到 ValidateOrder 可以这样编写:

type ValidateOrder = UnvalidatedOrder -> ValidatedOrder

但是, 这假定了验证过程始终有效, 并且始终返回已验证订单. 实际上, 这个过程可能会出错, 因此最好通过在函数签名中返回 Result 类型(Either in Haskell)来指示这一点:

type ValidateOrder =
    UnvalidatedOrder -> Result<ValidatedOrder, ValidationError list>

type ValidationError = {
    FieldName : string
    ErrorDescription : string
}

此签名显示输入是 UnvalidatedOrder, 如果成功, 则输出为 ValidatedOrder, 但如果验证失败, 则结果为 ValidationError 列表, 该列表又包含错误描述及其应用于哪个字段的说明.

函数编程人员使用术语 “effects” 来描述函数除了其主要输出之外另外执行的事情(函数副作用). 通过使用 Result 类型, 我们现在已经表明出 ValidateOrder 可能具有 “error effects” – 类型签名中明确说明, 我们不能保证函数始终成功, 并且我们应该准备好处理错误.

同样, 我们也可能会希望记录进程是异步的. 我们怎样才能做到这一点?

F# 中, 我们使用 Async 类型来表示函数将具有异步效果. 因此, 如果 ValidateOrder 具有异步效应和错误效果, 我们将编写如下函数类型:

type ValidateOrder =
    UnvalidatedOrder -> Async<Result<ValidatedOrder,ValidationError list>>

此类型签名现在明确表明:

  1. 当我们尝试获取返回值的内容时, 代码不会立即返回.
  2. 当它真的返回结果时, 结果可能是错误.

像这样显式地列出所有效果很有用, 但它确实使类型签名变得丑陋且复杂, 因此我们通常会为此创建一个类型别名, 使其看起来更美观.

type ValidationResponse<'a> = Async<Result<'a,ValidationError list>>

type ValidateOrder =
    UnvalidatedOrder -> ValidationResponse<ValidatedOrder>