五. Modeling Workflows as Pipelines


现在开始对 Place Order 工作流建模, 下面是我们需要建模的摘要:

workflow "Place Order" =
    input: UnvalidatedOrder
    output (on success):
        OrderAcknowledgmentSent
        AND OrderPlaced (to send to shipping)
        AND BillableOrderPlaced (to send to billing)
    output (on error):
        ValidationError

    // step 1
    do ValidateOrder
    If order is invalid then:
        return with ValidationError

    // step 2
    do PriceOrder

    // step 3
    do AcknowledgeOrder

    // step 4
    create and return the events

显然, 工作流由一系列子步骤组成: ValidateOrderPriceOrder 等. 这是非常常见的情况. 许多业务流程都可以被视为一系列文档的转换, 我们可以以同样的方式对工作流进行建模.

我们将创建一个 pipeline 来表示业务流程, 而这个 pipeline 又由一系列较小的 pipeline 构建而成. 每个较小的 pipeline 将执行一个转换操作, 最后, 我们将这些较小的管道粘合在一起. 这种编程风格有时被称为 “面向转换编程” (transformation-oriented programming).

遵循函数式编程原则, 我们将确保管道中的每个步骤都设计为无状态且无副作用, 这也意味着每个步骤都可以独立理解和测试. 一旦我们设计好了管道的部件, 我们就只需要实现和组合它们.

The Workflow Input

让我们先看一下工作流的输入.

工作流的输入应始终是领域对象(我们假定输入已经从 DTO 中反序列化), 在我们的案例中, 它是 UnvalidatedOrder, 我们之前对此进行了建模:

type UnvalidatedOrder = {
    OrderId : string
    CustomerInfo : UnvalidatedCustomerInfo
    ShippingAddress : UnvalidatedAddress
    // ...
}

Commands as input

工作流与启动它的命令相关联. 从某种意义上说, Place Order 工作流的实际输入实际上不是订单, 而是命令.

对于 Place Order 工作流, 我们将此命令称为 PlaceOrder. 该命令应包含工作流处理该请求所需的所有内容, 在本例中为上面的 UnvalidatedOrder. 我们可能还希望跟踪谁创建了命令, 时间戳以及其它的一些元数据, 因此命令类型可能最终看起来像这样:

type PlaceOrder = {
    OrderForm : UnvalidatedOrder
    Timestamp: DateTime
    UserId: string
    // etc
}

Sharing Common Structures Using Generics

当然, 我们不止这一个命令需要建模, 而每一个命令都会拥有相对关联工作流所需的数据, 但也会有一些所有命令都通用的数据, 例如 TimestampUserId. 我们真的需要一遍又一遍地实现相同的字段吗? 没有共享它们的方法吗?

如果我们在进行面向对象的设计, 则显而易见的解决方案是使用包含公共字段的基类, 然后让每个特定的命令都从它继承.

在函数式的世界中, 我们可以通过使用泛型来实现相同的目标. 我们首先定义一个 Command 类型, 其中包含通用字段和特定于命令的数据的插槽, 如下所示:

type Command<'data> = {
    Data : 'data
    Timestamp: DateTime
    UserId: string
    // etc
}

然后, 我们只需指定数据插槽中的类型即可创建特定于工作流的命令:

type PlaceOrder = Command<UnvalidatedOrder>

Combining Multiple Commands in One Type

在一些情况下, 一个界限上下文的所有命令都在同一输入通道(例如消息队列)上发送, 因此我们可以用某种方式将它们组成为一个可以序列化的数据结构.

解决方案很明显: 只需创建一个包含所有命令的 OR 类型. 例如, 如果需要从 PlaceOrder, ChangeOrderCancelOrder 中进行选择, 则可以创建如下类型:

type OrderTakingCommand =
    | Place of PlaceOrder
    | Change of ChangeOrder
    | Cancel of CancelOrder

该 OR 类型将映射到 DTO 并在输入通道上进行序列化和反序列化. 我们只需要在界限上下文的边缘添加一个新的路由或调度阶段(洋葱架构的基础结构层).

Modeling an Order as a Set of States

从我们对工作流的先前理解中可以清楚地看到, Order 并不是一个静态文档, 而实际上是通过一系列不同的状态转换的:

我们应该如何为这些状态建模? 一个简单的方法是创建一个单一的 AND 类型, 该类型使用标志捕获所有不同的状态, 如下所示:

type Order = {
    OrderId : OrderId
    // ...
    IsValidated : bool // set when validated
    IsPriced : bool // set when priced
    AmountToBill : decimal option // also set when priced
}

但这有很多问题:

  • 状态是隐式的, 并且需要大量条件代码才能进行处理.
  • 有些状态拥有其它状态不需要的数据, 将它们全部记录在一条数据结构中会使设计复杂化. 例如, 仅在 Priced 状态下才需要 AmountToBill, 但由于在其它状态中不存在, 因此我们必须将该字段设为可选.
  • 目前尚不清楚哪些字段与哪些标志相关联. 例如设置 IsPriced 时需要设置 AmountToBill, 但是代码设计中并没有强制措施, 所以我们必须依靠注释提醒来保持数据一致性.

一种更好的建模方式是为订单的每个状态创建一个独立的新类型, 这使我们可以消除隐式状态和条件字段.

可以直接从我们之前创建的领域文档中定义类型. 例如, 下面是 ValidatedOrder 的领域文档:

data ValidatedOrder =
    ValidatedCustomerInfo
    AND ValidatedShippingAddress
    AND ValidatedBillingAddress
    AND list of ValidatedOrderLine

而下面这是 ValidatedOrder 的相应的类型定义, 这是一种直接的翻译(低表示化差异, 除了需要添加 OrderId, 因为必须在整个工作流中维护订单身份):

type ValidatedOrder = {
    OrderId : OrderId
    CustomerInfo : CustomerInfo
    ShippingAddress : Address
    BillingAddress : Address
    OrderLines : ValidatedOrderLine list
}

我们可以用相同的方式为 PricedOrder 创建类型, 并为价格信息添加额外的字段:

type PricedOrder = {
    OrderId : // ...
    CustomerInfo : CustomerInfo
    ShippingAddress : Address
    BillingAddress : Address
    // different from ValidatedOrder
    OrderLines : PricedOrderLine list
    AmountToBill : BillingAmount
}

最后, 我们可以创建一个顶级类型, 它是所有状态之间的选择:

type Order =
    | Unvalidated of UnvalidatedOrder
    | Validated of ValidatedOrder
    | Priced of PricedOrder
    // etc

这是可以代表生命周期中任何阶段的订单对象, 并且是可以持久化或传达给其它上下文的类型.

请注意, 我们不会在这组选择中包括 Quote, 因为这不是订单可以进入的状态, 而是一个完全不同的工作流.

Adding New State Types as Requirements Change

关于为每个状态使用单独类型的一个好处是, 可以在不破坏现有代码的情况下添加新状态. 例如, 如果我们需要支持退款, 则可以添加一个新状态 RefundedOrder 以及该状态所需的任何信息. 因为其它状态是独立定义的, 所以正在使用它们的任何代码都不会受到更改的影响.

State Machines

其实我们在之前也做过类似的设计了. 这些情况在业务建模场景中极为常见. 在典型模型中, 文档或记录可以处于一种或多种状态, 而一种状态到另一种状态的转换由某种类型的命令触发, 这被称为状态机.

我们将在这里讨论的状态机的类型要简单得多, 最多只有少数几种情况, 转换次数很少. 例如:

例子一, 之前提到的, 电子邮件地址可能具有 “未验证” 和 “已验证” 状态, 可以在其中通过要求用户单击确认电子邮件中的链接来从 “未验证” 转换为 “已验证”.

例子二, 购物车的状态可能为 “空”, “有效” 和 “已付款”, 可以通过向购物车中添加商品来从 “空” 转变为 “有效”, 并通过支付将其转换为 “已付款”.

例子三, 包裹交付可能具有 “未交付”, “待交付” 和 “已交付” 三种状态, 您可以通过将包裹放在交付卡车上从 “未交付” 转换为 “已交付”, 依此类推.

Why Use State Machines?

在这些情况下使用状态机有很多好处:

— 每个状态可以具有不同的行为.

例如, 在购物车示例中, 只能为有效的购物车付款. 在上一章中, 当我们讨论 “未验证/已验证” 的电子邮件设计时, 有一条业务规则说只能将密码重置发送到已验证的电子邮件地址. 通过为每个状态使用不同的类型, 我们可以利用编译器确保符合业务规则, 直接在函数签名中对该要求进行编码.

— 所有状态均被明确记录.

(没想好怎么翻译, 先放下原文) It is all too easy to have important states that are implicit but never documented. In the shopping cart example, the “empty cart” has different behavior from the “active cart” but it would be rare to see this documented explicitly in code.

— 它是一种设计工具, 可迫使我们考虑可能发生的每种可能性.

设计中常见的错误原因是某些边缘情况没有得到处理. 状态机强制考虑所有情况. 例如:

  • 如果我们尝试验证已验证的电子邮件, 会发生什么?
  • 如果我们尝试从空的购物车中删除商品, 会发生什么?
  • 如果我们尝试交付已处于 “已交付” 状态的包裹, 会发生什么
  • 等等. 从状态的角度考虑设计会迫使这些问题浮出水面, 并阐明领域逻辑.

How to Implement Simple State Machines in F

这是对购物车使用状态机的示例:

type Item = // ...

type ActiveCartData = { UnpaidItems: Item list }

type PaidCartData = { PaidItems: Item list; Payment: float }

type ShoppingCart =
    | EmptyCart // no data
    | ActiveCart of ActiveCartData
    | PaidCart of PaidCartData

ActiveCartDataPaidCartData 状态各自具有自己的类型. EmptyCart 状态没有与之关联的数据, 因此不需要特殊类型.

而命令的处理程序是一个接受整个状态机(OR 类型)并返回状态机新版本(更新后的 OR 类型)的函数.

假设我们要向购物车中添加商品, 状态转换函数 addItem 带有 ShoppingCart 参数和要添加的项目, 如下所示:

let addItem cart item =
    smatch cart with
    | EmptyCart ->
    // create a new active cart with one item
    ActiveCart { UnpaidItems = [ item ] }
    | ActiveCart { UnpaidItems = existingItems } ->
    // create a new ActiveCart with the item added
    ActiveCart { UnpaidItems = item :: existingItems }
    | PaidCart _ ->
    // ignore
    cart

结果是新的 ShoppingCart 可能处于或未处于新状态(如果处于 “已付费” 状态).

或者说我们要为购物车付款. 状态转换函数 makePayment 带有 ShoppingCart 参数和付款信息, 如下所示:

let makePayment cart payment =
    match cart with
    | EmptyCart ->
    // ignore
    cart
    | ActiveCart { UnpaidItems = existingItems } ->
    // create a new PaidCart with the payment
    PaidCart { PaidItems = existingItems; Payment = payment }
    | PaidCart _ ->
    // ignore
    cart

结果是新的 ShoppingCart 可能处于 “已付款” 状态, 也可能未处于 “已付款” 状态(如果已经处于 “空” 或 “已付款” 状态).

可以看到, 从调用者的角度来看, 状态的集合对于一般操作(ShoppingCart 类型)被视为 “一件事”, 但是在内部处理时, 每个状态都被单独对待.

Modeling Each Step in the Workflow With Types

状态机方法非常适合建模订单处理工作流, 因此, 现在让我们为每个步骤的细节建模.

The Validation Step

让我们从验证开始. 在之前的讨论中, 我们将 ValidateOrder 子步骤记录为:

substep "ValidateOrder" =
    input: UnvalidatedOrder
    output: ValidatedOrder OR ValidationError
    dependencies: CheckProductCodeExists, CheckAddressExists

现在我们可以用刚才讨论的方式定义输入和输出(UnvalidatedOrderValidatedOrder). 但是除了它们, 我们还看到有两个依赖项, 一个依赖项检查产品代码是否存在, 另一个依赖项检查地址是否存在.

我们如何使用类型对这些依赖项建模呢? 简单来说, 我们只把它们当作函数, 函数的类型签名将成为我们稍后需要实现的 “接口”.

例如, 要检查产品代码是否存在, 我们需要一个函数, 该函数输入一个 ProductCode, 如果产品目录中存在该代码, 则返回 true, 否则返回 false. 我们可以定义一个 CheckProductCodeExists 类型代表函数:

type CheckProductCodeExists =
    ProductCode -> bool
    // ^input ^output

再来看第二个依赖项, 我们需要一个函数, 该函数输入 UnvalidatedAddress 并在地址有效时下返回正确地址, 或者在地址无效时返回某种验证错误.

我们也许还想区分 CheckedAddress (远程地址检查服务的输出)和 Address 领域对象, 并且有时需要在它们之间进行转换. 但现在, 我们可以暂时只说 CheckedAddress 只是 UnvalidatedAddress 的包装版本:

type CheckedAddress =
    CheckedAddress of UnvalidatedAddress

然后, 该远程地址检查服务将 UnvalidatedAddress 作为输入, 并返回 Result 类型, 其中对于成功案例具有 CheckedAddress 值, 对于失败案例具有 AddressValidationError 值(副作用):

type AddressValidationError =
    AddressValidationError of string

type CheckAddressExists =
    UnvalidatedAddress -> Result<CheckedAddress,AddressValidationError>
    // ^input ^output

定义了依赖项之后就可以定义出 ValidateOrder 函数:

type ValidateOrder =
    CheckProductCodeExists // dependency
    -> CheckAddressExists // dependency
    -> UnvalidatedOrder // input
    -> Result<ValidatedOrder,ValidationError> // output

该函数的总返回值必须为 Result, 因为其中一个依赖项 (CheckAddressExists)返回 Result. 当在任何地方使用 Result 时, 它都会 “污染” 所接触的内容, 并且会传递 “结果”, 直到到达处理它的顶级函数为止.

我们将依赖项放在函数首位, 将主要输入参数放在倒数第二位(输出类型之前). 这样做的原因是使部分应用更容易(在功能上等同于依赖注入).

The Pricing Step

让我们继续设计 PriceOrder 步骤, 这是原始的领域文档:

substep "PriceOrder" =
    input: ValidatedOrder
    output: PricedOrder
    dependencies: GetProductPrice

它也有一个依赖项 – 一个返回给定产品价格的函数.

我们可以定义一个 GetProductPrice 类型来记录这个依赖项:

type GetProductPrice =
    ProductCode -> Price

同样, 请注意我们在这里所做的事情. PriceOrder 函数需要产品目录中的信息, 但是我们没有传递某种重量级的 IProductCatalog 接口, 而是传递了一个函数 (GetProductPrice), 该函数恰好代表了我们现阶段对产品目录的需求. 也就是说, GetProductPrice 充当了一个抽象 – 它隐藏了产品目录的存在, 只向我们提供了所需的功能, 而没有更多(类似接口隔离).

PriceOrder 的签名将如下所示:

type PriceOrder =
    GetProductPrice // dependency
    -> ValidatedOrder // input
    -> PricedOrder // output

这个函数始终成功, 因此无需返回 Result.

The Acknowledge Order Step

下一步是 Acknowledge 步骤, 它将创建一封确认信, 并将其发送给客户.

首先是为 “确认信” 建模. 现在假设它只包含一个 HTML 字符串即可. 我们将 HTML 字符串建模为简单类型, 并将 OrderAcknowledgment 建模为 AND 类型, 其中包含邮件地址和邮件内容(HTML 字符串):

type HtmlString =
    HtmlString of string

type OrderAcknowledgment = {
    EmailAddress : EmailAddress
    Letter : HtmlString
}

我们怎么知道这封信的内容是什么? 有可能是根据客户信息和订单详细信息, 然后从某种模板中创建内容.

但与其将这种逻辑嵌入到工作流中, 不如让它成为其他人的问题! 也就是说, 我们假设某个服务函数将为我们生成内容, 而我们要做的就是将 PricedOrder 提供给这个服务函数.

type CreateOrderAcknowledgmentLetter =
    PricedOrder -> HtmlString

然后我们将这个函数作为 Acknowledge 步骤的依赖项.

一旦有了信件后, 我们需要发送它. 我们应该怎么做? 我们应该直接调用某种 API, 还是将确认信息写入消息队列, 或者是其它的什么方式?

幸运的是, 我们现在无需决定这些问题. 我们可以先不讨论确切的实现, 而只关注需要的接口. 和以前一样, 现在我们所需要做的设计就是定义一个函数, 该函数将 OrderAcknowledgment 作为输入并为我们发送出去 – 我们不在乎具体怎么发.

type SendOrderAcknowledgment =
    OrderAcknowledgment -> unit

在这里, 该函数未返回任何结果, 我们使用 unit 来表示存在一些我们不关心的副作用.

如果我们想要从 Place Order 工作流中返回一个 OrderAcknowledgmentSent 事件, 但是上面这种设计, 我们无法确认是否成功发送. 因此, 我们需要进行更改, 一个明显的选择是返回一个布尔值, 然后我们可以根据它来决定是否创建事件:

type SendOrderAcknowledgment =
    OrderAcknowledgment -> bool

但是, 在设计中布尔值通常是一个错误的选择, 因为布尔值信息不多. 最好使用简单的 Sent/NotSent OR 类型而不是bool:

type SendResult = Sent | NotSent

type SendOrderAcknowledgment =
    OrderAcknowledgment -> SendResult

或者, 我们应该让服务本身返回 OrderAcnowledgmentSent 事件?

type SendOrderAcknowledgment =
    OrderAcknowledgment -> OrderAcknowledgmentSent option

但如果这样做, 我们会因为事件类型在领域和服务之间创建了耦合. 因此, 我们现在将继续使用 Sent/NotSent 方案(以后需要的话可以再更改它).

最后是定义 Acknowledge Order 的输出 – OrderAcknowledgmentSent:

type OrderAcknowledgmentSent = {
    OrderId : OrderId
    EmailAddress : EmailAddress
}

现在, 让我们将所有这些放在一起以定义此步骤的函数类型:

type AcknowledgeOrder =
    CreateOrderAcknowledgmentLetter // dependency
    -> SendOrderAcknowledgment // dependency
    -> PricedOrder // input
    -> OrderAcknowledgmentSent option // output

该函数返回一个可选事件, 因为可能 acknowledgement 没有成功发送.

Creating the Events To Return

上一步将为我们创建 OrderAcknowledgmentSent 事件, 但是我们仍然需要创建 OrderPlaced 事件(用于运输)和 BillableOrderPlaced 事件(用于计费).

这些很容易定义: OrderPlaced 事件可以只是 PricedOrder 的别名, 而 BillableOrderPlaced 只是 PricedOrder 的一个子集:

type OrderPlaced = PricedOrder

type BillableOrderPlaced = {
  OrderId : OrderId
  BillingAddress: Address
  AmountToBill : BillingAmount
  }

要实际返回事件, 我们可以创建一个特殊的类型来保存它们, 如下所示:

type PlaceOrderResult = {
    OrderPlaced : OrderPlaced
    BillableOrderPlaced : BillableOrderPlaced
    OrderAcknowledgmentSent : OrderAcknowledgmentSent option
}

但是在以后我们很可能会在此工作流中添加新的事件, 定义这样的特殊 AND 类型使更改变得更加困难.

所以, 为什么我们不让工作流返回事件列表, 其中事件可以是 OrderPlaced, BillableOrderPlaced, OrderAcknowledgmentSent 中的一个.

也就是说, 我们将定义一个 OrderPlacedEvent, 它是这样的 OR 类型:

type PlaceOrderEvent =
    | OrderPlaced of OrderPlaced
    | BillableOrderPlaced of BillableOrderPlaced
    | AcknowledgmentSent of OrderAcknowledgmentSent

然后, 工作流的最后一步将发出这些事件的列表:

type CreateEvents =
    PricedOrder -> PlaceOrderEvent list

如果我们需要处理新事件, 可以将其添加到选项中, 而不会破坏整个工作流. 而且, 如果发现相同的事件出现在领域中的多个工作流中, 我们甚至可以升级并创建一个更通用的 OrderTakingDomainEvent 作为领域中所有事件的选择.

Documenting Effects

前面的讨论中, 我们提到了在类型签名中记录副作用: 此函数可以产生什么效果? 会返回错误吗? 它有 I/O 吗?

让我们快速回顾一下我们所有的依赖关系, 并仔细检查是否需要明确说明此类副作用.

Effects in the Validation Step

验证步骤具有两个依赖性: CheckProductCodeExistsCheckAddressExists.

先来看一看 CheckProductCodeExists:

type CheckProductCodeExists =
    ProductCode -> bool

这个函数是远程调用吗? 或者可能会返回错误吗? 让我们假设这些都没有. 我们希望可以使用产品目录的本地缓存, 我们可以快速访问它.

另外我们已经知道 CheckAddressExists 函数是远程调用, 而不是领域内的本地服务, 因此它具有 AsyncResult 副作用. 实际上, AsyncResult 经常一起出现, 因此我们通常使用 AsyncResult 别名将它们组合为一种类型:

type AsyncResult<'success,'failure> =
    Async<Result<'success,'failure>>

这样, 我们现在可以将 CheckAddressExists 的返回类型从 Result 更改为 AsyncResult, 以指示该函数具有异步和错误副作用:

type CheckAddressExists =
    UnvalidatedAddress -> AsyncResult<CheckedAddress,AddressValidationError>

现在从类型签名中可以明显看出 CheckAddressExists 函数正在执行 I/O, 并且可能会失败. 之前谈到界限上下文时, 我们说自治(autonomy)是一个关键因素, 那么是否意味着我们应该尝试创建地址验证服务的本地版本? 这取决于 Ollie(业务领域专家) 提起此服务时是否要求具有很高的可用性(此例中没有).

请记住, 想要自治的主要原因不是性能, 而是致力于高可用的服务.

就像 Result 一样, Async 对于包含它的任何代码都具有感染力. 因此必须更改整个 ValidateOrder 步骤以也返回 AsyncResult:

type ValidateOrder =
    CheckProductCodeExists // dependency
    -> CheckAddressExists // AsyncResult dependency
    -> UnvalidatedOrder // input
    -> AsyncResult<ValidatedOrder,ValidationError list> // output

Effects in the Pricing Step

PriceOrder 步骤只有 GetProductPrice 一个依赖. 我们将再次假设产品目录是本地的(例如缓存在内存中), 因此这个依赖项不是异步的, 而且据我们所知也不会有错误. 因此 GetProductPrice 没有任何副作用.

但是PriceOrder 步骤本身很可能会返回错误. 假设某商品定价错误, 因此整个 AmountToBill 很大(或负数). 这是我们应该捕获的东西. 所以现在我们还需要一个错误类型, 我们将其称为 PricingError.

PriceOrder 函数现在如下所示:

type PricingError =
    PricingError of string

type PriceOrder =
    GetProductPrice // dependency
    -> ValidatedOrder // input
    -> Result<PricedOrder,PricingError> // output

Effects in the Acknowledge Step

AcknowledgeOrder 步骤具有两个依赖项:

  • CreateOrderAcknowledgmentLetter
  • SendOrderAcknowledgment

CreateOrderAcknowledgmentLetter 函数会返回错误吗? 也许不会, 我们将假定它是本地的, 并使用已缓存的模板. 因此, 总的来说, CreateOrderAcknowledgmentLetter 函数没有任何需要在类型签名中记录的副作用.

另一方面, 我们知道 SendOrderAcknowledgment 将执行 I/O, 因此有异步副作用. 那是否有错误呢?此处我们不在乎错误的详细信息, 即使有错误, 我们也要忽略它并继续执行. 因此, 这意味着修订后的 SendOrderAcknowledgment 将具有 Async 类型, 而不是 Result 类型:

type SendOrderAcknowledgment =
    OrderAcknowledgment -> Async<SendResult>

type AcknowledgeOrder =
    CreateOrderAcknowledgmentLetter // dependency
    -> SendOrderAcknowledgment // Async dependency
    -> PricedOrder // input
    -> Async<OrderAcknowledgmentSent option> // Async output

Composing the Workflow From the Steps

现在我们定义了所有步骤, 当我们实现它们时, 应该能够将一个步骤的输出连接到下一个步骤的输入, 从而建立整个工作流.

让我们把那些步骤的定义拿出来放在一起, 并删除依赖项, 以便观察仅列出输入和输出.

type ValidateOrder =
    UnvalidatedOrder // input
    -> AsyncResult<ValidatedOrder,ValidationError list> // output

type PriceOrder =
    ValidatedOrder // input
    -> Result<PricedOrder,PricingError> // output

type AcknowledgeOrder =
    PricedOrder // input
    -> Async<OrderAcknowledgmentSent option> // output

type CreateEvents =
    PricedOrder // input
    -> PlaceOrderEvent list // output

PriceOrder 的输入需要一个 ValidatedOrder, 但是 ValidateOrder 的输出是 AsyncResult, 这似乎根本不匹配. 同样, PriceOrder 步骤的输出不能用作 AcknowledgeOrder 的输入, 依此类推.

为了组合这些函数, 我们将不得不处理输入和输出类型, 以便它们兼容并可以装配在一起. 在进行类型驱动的设计时(type-driven design), 这是一个常见的挑战, 我们将在[实现章节]中了解如何做到这一点.

Are Dependencies Part of the Design?

在上面的代码中, 我们将对其他上下文的调用(例如 CheckProductCodeExistsValidateAddress)视为依赖项记录. 我们为工作流的每个子步骤的依赖都设计了明确的额外参数:

type ValidateOrder =
    CheckProductCodeExists // explicit dependency
    -> CheckAddressExists // explicit dependency
    -> UnvalidatedOrder // input
    -> AsyncResult<ValidatedOrder,ValidationError list> // output

type PriceOrder =
    GetProductPrice // explicit dependency
    -> ValidatedOrder // input -> Result<PricedOrder,PricingError> // output

有人可能会争辩说, 任何流程如何执行其工作都应该对我们隐藏, 我们是否真的在乎它需要与哪些系统协作(指明确的额外参数)以实现其目标? 如果从这一角度出发, 流程定义将简化为仅输入和输出, 如下所示:

type ValidateOrder =
    UnvalidatedOrder // input
    -> AsyncResult<ValidatedOrder,ValidationError list> // output

type PriceOrder =
    ValidatedOrder // input
    -> Result<PricedOrder,PricingError> // output

哪一种方式更好呢? 设计永远不会有正确的答案, 但总有些准则可以遵循:

  • 对于中公开出去的 API, 请对调用者隐藏依赖信息.
  • 对于内部使用的函数, 请明确说明其依赖关系.

在这种情况下, 不应公开顶级 PlaceOrder 工作流函数的依赖项, 因为调用者不需要了解它们. 签名应仅显示输入和输出, 如下所示:

type PlaceOrderWorkflow =
    PlaceOrder // input
    -> AsyncResult<PlaceOrderEvent list,PlaceOrderError> // output

但是, 对于工作流中的每个内部步骤, 都应该像在原始设计中那样明确显示依赖性. 这有助于记录每个步骤实际需要的内容. 如果某个步骤的依赖关系发生变化, 那么我们可以更改该步骤的功能定义, 这又将迫使我们更改实现.

The Complete Pipeline

我们已经完成了设计的第一步, 让我们再回顾一下. 首先, 我们将记录下公开 API 的类型. 通常, 我们会将它们全部放在一个文件中, 例如 DomainApi.fs 或其它语言中类似的文件.

首先是输入的类型定义(也就是触发 Place Order 工作流的命令 – PlaceOrderCommand):

// ----------------------
// Input data
// ----------------------
type UnvalidatedOrder = {
    OrderId : string
    CustomerInfo : UnvalidatedCustomer
    shippingAddress : UnvalidatedAddress
} and UnvalidatedCustomer = {
    Name : string
    Email : string
} and UnvalidatedAddress = // ...

// ----------------------
// Input Command
// ----------------------
type Command<'data> = {
    Data : 'data
    Timestamp: DateTime
    UserId: string
    // etc
}

type PlaceOrderCommand = Command<UnvalidatedOrder>

接下来是输出和工作流本身的定义:

/// Success output of PlaceOrder workflow
type OrderPlaced = // ...

type BillableOrderPlaced = // ...

type OrderAcknowledgmentSent = //...

type PlaceOrderEvent =
    | OrderPlaced of OrderPlaced
    | BillableOrderPlaced of BillableOrderPlaced
    | AcknowledgmentSent of OrderAcknowledgmentSent

/// Failure output of PlaceOrder workflow
type PlaceOrderError = // ...


// ----------------------
// Public API
// ----------------------
type PlaceOrderWorkflow =
    PlaceOrderCommand // input command
    -> AsyncResult<PlaceOrderEvent list,PlaceOrderError> // output events

The internal steps

在单独的实现文件(例如 PlaceOrderWorkflow.fs)中记录内部子步骤的类型定义, 在这些定义后面, 我们将添加实现.

首先是代表订单生命周期的内部状态:

// bring in the types from the domain API module
open DomainApi

// ----------------------
// Order lifecycle
// ----------------------

// validated state
type ValidatedOrderLine = // ...

type ValidatedOrder = {
    OrderId : OrderId
    CustomerInfo : CustomerInfo
    ShippingAddress : Address
    BillingAddress : Address
    OrderLines : ValidatedOrderLine list
}
and OrderId = Undefined
and CustomerInfo = // ...
and Address = // ...

// priced state
type PricedOrderLine = // ...

type PricedOrder = // ...

// all states combined
type Order =
    | Unvalidated of UnvalidatedOrder
    | Validated of ValidatedOrder
    | Priced of PricedOrder
    // etc

然后定义每个内部子步骤::

// ----------------------
// Definitions of Internal Steps
// ----------------------

// ----- Validate order -----
// services used by ValidateOrder
type CheckProductCodeExists =
    ProductCode -> bool

type AddressValidationError = // ...

type CheckedAddress = // ...

type CheckAddressExists =
    UnvalidatedAddress
    -> AsyncResult<CheckedAddress,AddressValidationError>

type ValidateOrder =
    CheckProductCodeExists // dependency
    -> CheckAddressExists // dependency
    -> UnvalidatedOrder // input
    -> AsyncResult<ValidatedOrder,ValidationError list> // output
and ValidationError = // ...

// ----- Price order -----
// services used by PriceOrder
type GetProductPrice =
    ProductCode -> Price

type PricingError = // ...

type PriceOrder =
    GetProductPrice // dependency
    -> ValidatedOrder // input
    -> Result<PricedOrder,PricingError> // output

// etc

现在我们已经所有类型集中在一起了, 并随时可以指导实现.

Long Running Workflows

对于管道(pipeline), 有一个重要的假设, 那就是, 即使有远程系统调用, 该管道也将在大约几秒钟的短时间内完成.

但是, 如果这些外部服务需要更长的时间才能完成该怎么办? 例如, 如果验证是由人而不是机器来完成的, 那可能会花掉整整一整天的时间, 又或者, 如果定价是由其他某个部门完成的, 那也有可能要花很长时间. 如果这些事都是真的, 它们会影响到哪些设计?

首先, 我们需要在调用远程服务之前将状态保存到存储中, 然后等待一条消息告诉我们该服务已完成, 然后我们必须从存储中重新加载状态并继续执行该工作流中的下一步. 这比使用普通的异步调用要 “重” 得多, 因为我们需要在每个步骤之间保持状态.

通过这样做, 我们将原始工作流分解为较小的独立块, 每个块均由事件触发. 我们甚至可以将其视为一系列单独的迷你工作流, 而不是一个工作流.

在这里, 状态机模式是帮助我们思考的宝贵工具. 在执行每个步骤之前, 从存储中加载订单的当前状态, 然后迷你工作流将订单从当前状态转换为新状态, 最后, 新状态再次保存回存储中.

这类长期运行的工作流有时称为 Sagas. 每当涉及 “慢人” 时,它们很常见. 另外, 在要将工作流分解成由事件(例如微服务)联系在一起的分离的独立组件时, 也可以使用它们.

在我们的示例中, 工作流非常简单. 如果事件和状态的数量增加, 并且转换变得复杂, 则可能需要创建一个特殊的组件, 即流程管理器. 该组件负责处理传入的消息, 根据当前状态确定应采取的操作, 然后触发适当的工作流.