六. Implementation: Composing a Pipeline


Understanding Functions

在很多现代语言中, 函数也是一等对象, 但只是使用函数并不意味着在进行函数式编程.

函数式编程范例的关键在于, 函数无处不在, 应有尽有, 程序中的任何问题都有函数式的解决方案.

例如, 假设我们有一个大型程序, 它是由较小的块组成的.

  • 在面向对象的方法中, 这些部分将是类和对象.
  • 在功能式的方法中, 这些部分将是函数.

再比如我们需要参数化程序的某些方面, 或者想减少组件之间的耦合.

  • 在面向对象的方法中, 我们将使用接口和依赖注入.
  • 在函数式的方法中, 我们将使用函数进行参数化.

又比如我们要遵循 “不要重复自己” 的原则, 并在许多组件之间重用代码.

  • 在面向对象的方法中, 我们可能会使用继承或类似装饰者模式的技术.
  • 在函数式的方法中, 我们将所有可重复使用的代码放入函数中, 并使用组合将它们组合在一起.

实际上, 函数式编程是一种完全不同的编程思维方式. 比如我们日常编程中经常会思考的「如何遍历集合」以及「如何实现策略模式」这两个问题, 现在换个角度, 我们原本真的是想要解决这些问题吗?

不是! 这些问题仅仅是「如何对集合的每个元素执行操作」以及「如何对行为进行参数化」的编程解决方案. 换句话说, 我们实际上要解决的是「如何对集合的每个元素执行操作」以及「如何对行为进行参数化」这些 “潜在” 的问题.

作为程序员, 我们面临的这些真正要解决的 “潜在” 问题是相同的, 但函数式编程中使用的解决方案与面向对象编程中使用的解决方案却有很大不同, 这是我们要学习并掌握的地方.

Building an Entire Application from Functions

在函数式编程中, 我们使用 composition 的方式来构建程序.

我们先从程序的最底层几个函数开始:

然后将它们组合成一些服务函数:

接下来, 我们可以使用这些服务函数并将它们粘合在一起, 以创建一个处理完整工作流的函数:

最后, 我们可以通过并行组合这些工作流来构建应用程序, 并创建一个 controller/dispatcher, 该 controller/dispatcher 根据输入来选择要调用的特定工作流.

Implementation: Composing a Pipeline

在上一章中, 我们已经花费了很多时间仅使用类型领域进行建模, 现在是时候使用函数式来实现它了.

回顾上一章中的设计, 可以将工作流视为一系列文档转换(管道):

  1. UnvalidatedOrder 开始, 并将其转换为 ValidatedOrder, 如果验证失败, 则返回错误.
  2. 获取验证步骤的输出(ValidatedOrder), 并通过添加一些额外信息将其转换为 PricedOrder.
  3. 获取定价步骤的输出(PriceOrder), 从中创建确认信并发送.
  4. 创建一组表示发生了什么的事件并将其返回.

首先, 我们将管道中的每个步骤作为独立函数实现, 确保它是无状态的, 并且没有副作用, 因此可以独立地测试和推理.

接下来, 我们将这些较小的函数组合成一个较大的函数. 这听起来很简单, 但正如我们前面提到的, 当我们真正尝试它时, 我们会遇到一个问题. 设计的函数不能很好地组合在一起 – 一个的输出与下一个的输入不匹配. 为了克服这一点, 我们需要学习如何操作每个步骤的输入和输出, 以便可以组合它们.

最终的那一部分代码看起来可能会是这样:

let placeOrder unvalidatedOrder =
    unvalidatedOrder
    |> validateOrder
    |> priceOrder
    |> acknowledgeOrder
    |> createEvents

阻碍我们组合函数的原因有两个:

  • 一些函数有额外的参数, 这些参数不是数据管道的一部分, 而是实现所需的参数, 我们称这些为依赖.
  • 显式指示副作用, 例如通过使用函数签名中的 Result 等包装类型进行错误处理. 这意味着在其输出中具有副作用的函数不能直接连接到仅将纯数据作为输入的函数.

本节我们来解决第一个问题.

Working With Simple Types

在实现函数之前, 首先需要实现 “简单类型”, 如 OrderIdProductCode 等.

由于待创建大多数类型都以某种方式受到限制, 所以我们将用智能构造器的方式来实现.

对于每个简单类型, 我们至少需要两个函数

  • 构造函数, 该函数从基元(如字符串或 int)构造类型. 例如, OrderId.create 将从字符串创建 OrderId, 如果字符串的格式错误, 则引发错误.
  • 提取内部基元值的值函数.

我们通常将这些帮助函数放在与简单类型相同的文件中, 并使用与它们类型名称相同的模块名. 例如, 下面是领域模块中 OrderId 的定义及其帮助函数:

module Domain =
    type OrderId = private OrderId of string

module OrderId =
    // Define a "Smart constructor" for OrderId
    // string -> OrderId
    let create str =
        if String.IsNullOrEmpty(str) then
        // use exceptions rather than Result for now
            failwith "OrderId must not be null or empty"
        elif str.Length > 50 then
            failwith "OrderId must not be more than 50 chars"
        else
            OrderId str

    // Extract the inner value from an OrderId
    // OrderId -> string
    let value (OrderId str) = // unwrap in the parameter!
        str // return the inner value

Create 函数中, 由于我们现在正在避免副作用的问题, 因此暂时对错误使用异常, 而不是返回 Result.

Using Function Types to Guide the Implementation

上一章节中, 我们定义了一些函数类型来表示工作流的每个步骤.

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

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

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

type CreateEvents =
    PricedOrder -> PlaceOrderEvent list

现在是时候实现它们了. 为了清楚地说明我们正在实现某个特定的函数类型, 我们将函数记为一​​个值, 用函数类型作为其类型, 并将函数主体写为 lambda. 看起来像这样:

let validateOrder : ValidateOrder =
    fun checkProductCodeExists  // dependency
        checkAddressExists  // dependency
        unvalidatedOrder ->  // dependency
            //...

Implementing Steps

Validation Step

我们在上章节中将此步骤的函数类型建模为:

type CheckProductCodeExists =
    ProductCode -> bool

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

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

正如刚才所说, 本章节只关注额外的参数, 因此我们可以删除 AsyncResult 部分, 留给我们这样模型:

type CheckProductCodeExists =
    ProductCode -> bool

type CheckAddressExists =
    UnvalidatedAddress -> CheckedAddress

type ValidateOrder =
    CheckProductCodeExists  // dependency
    -> CheckAddressExists  // AsyncResult dependency
    -> UnvalidatedOrder  // input
    -> CheckedAddress // output

现在开始实现. 从 UnvalidatedOrder 创建出 ValidatedOrder 的步骤如下:

  1. 从未验证订单中相应的 OrderId 字符串创建 OrderId 领域类型.
  2. 从未验证订单中相应的 UnvalidatedCustomerInfo 字段中创建 CustomerInfo 领域类型.
  3. 从未验证顺序中相应的 ShippingAddress 字段中创建 UnvalidatedAddress 领域类型.
  4. BillingAddress 和所有其它属性进行同样的操作.
  5. 一旦我们 ValidatedOrder 的所有组件可用, 就可以使用通常的方式创建 ValidatedOrder.
let validateOrder : ValidateOrder =
    fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
        let orderId =
            unvalidatedOrder.OrderId
            |> OrderId.create
        let customerInfo =
            unvalidatedOrder.CustomerInfo
            |> toCustomerInfo // helper function
        let shippingAddress =
            unvalidatedOrder.ShippingAddress
            |> toAddress // helper function
        // and so on, for each property of the unvalidatedOrder

        // when all the fields are ready, use them to
        // create and return a new "ValidatedOrder" record
        {
            OrderId = orderId
            CustomerInfo = customerInfo
            ShippingAddress = shippingAddress
            BillingAddress = // ...
            Lines = // ...
        }

可以看到, 我们使用了一些尚未定义帮助函数, 例如 toCustomerInfotoAddress. 这些函数负责从未验证的类型构造出领域类型. 例如, toAddressUnvalidatedAddress 转换为相应的 Address 领域类型, 如果 UnvalidatedAddress 中的某些元素不符合约束(例如非空且长度小于 50 个字符), 则会引发错误. 一旦具备所有这些帮助函数, 将未验证订单(或任何非领域类型)转换为领域类型的逻辑就很简单了.

Create Customer Info

下面是 toCustomerInfo 的代码示例:

let toCustomerInfo (customer:UnvalidatedCustomerInfo) : CustomerInfo =
    // create the various CustomerInfo properties
    // and throw exceptions if invalid
    let firstName = customer.FirstName |> String50.create
    let lastName = customer.LastName |> String50.create
    let emailAddress = customer.EmailAddress |> EmailAddress.create

    // create a PersonalName
    let name : PersonalName = {
        FirstName = firstName
        LastName = lastName
    }
    // create a CustomerInfo
    let customerInfo : CustomerInfo = {
        Name = name
        EmailAddress = emailAddress
    }

    // ... and return it
    customerInfo

Creating a Valid, Checked, Address

toAddress 函数稍微复杂一些, 因为它不仅需要将原始数据转换为领域对象, 而且还必须检查地址是否存在(使用 CheckAddressExists 服务). 下面是完整的实现:

let toAddress (checkAddressExists:CheckAddressExists) unvalidatedAddress =
    // call the remote service
    let checkedAddress = checkAddressExists unvalidatedAddress
    // extract the inner value using pattern matching
    let (CheckedAddress checkedAddress) = checkedAddress

    let addressLine1 =
        checkedAddress.AddressLine1 |> String50.create
    let addressLine2 =
        checkedAddress.AddressLine2 |> String50.createOption
    let addressLine3 =
        checkedAddress.AddressLine3 |> String50.createOption
    let addressLine4 =
        checkedAddress.AddressLine4 |> String50.createOption
    let city =
        checkedAddress.City |> String50.create
    let zipCode =
        checkedAddress.ZipCode |> ZipCode.create
    // create the address
    let address : Address = {
        AddressLine1 = addressLine1
        AddressLine2 = addressLine2
        AddressLine3 = addressLine3
        AddressLine4 = addressLine4
        City = city
        ZipCode = zipCode
    }
    // return the address
    address

请注意, 我们引用了 String50 模块中的另一个构造函数 createOption, 它允许输入为 null 或为空, 并为此情况返回 None.

toAddress 函数需要调用 checkAddressExists, 因此我们将其添加为参数, 必须从父函数 validateOrder 传递该函数给 toAddress:

let validateOrder : ValidateOrder =
    fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
        let orderId =  // ...
        let customerInfo =  //...
        let shippingAddress =
            unvalidatedOrder.ShippingAddress
            |> toAddress checkAddressExists  // new parameter, partial application
        // ...

Creating the Order Lines

创建订单行列表会更加复杂. 首先, 我们需要一种将单个未验证订单行转换为已验证订单行的方法 toValidatedOrderLine:

let toValidatedOrderLine checkProductCodeExists (unvalidatedOrderLine:UnvalidatedOrderLine) =
    let orderLineId =
        unvalidatedOrderLine.OrderLineId
        |> OrderLineId.create
    let productCode =
        unvalidatedOrderLine.ProductCode
        |> toProductCode checkProductCodeExists // helper function
    let quantity =
        unvalidatedOrderLine.Quantity
        |> toOrderQuantity productCode // helper function
    let validatedOrderLine = {
        OrderLineId = orderLineId
        ProductCode = productCode
        Quantity = quantity
        }
    validatedOrderLine

这与上面的 toAddress 函数类似. 有两个帮助函数, toProductCodetoOrderQuantity, 我们稍后将讨论.

我们可以使用 List.map 来一次性转换整个列表中的所有元素, 从而提供一个可以在 ValidatedOrder 中使用的 ValidatedOrderLines:

let validateOrder : ValidateOrder =
    fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
        let orderId =  // ...
        let customerInfo =  // ...
        let shippingAddress =  // ...
        let orderLines =
            unvalidatedOrder.Lines
            // convert each line using `toValidatedOrderLine`
            |> List.map (toValidatedOrderLine checkProductCodeExists)
        // ...

接下来, 我们来看一下 toOrderQuantity 帮助函数. 这是一个很好的示例: 输入是从 UnvalidatedOrderLine 中获得的原始未验证小数, 但输出(OrderQuantity)是一个 OR 类型, 每个 case 有不同的验证过程. 代码如下所示:

let toOrderQuantity productCode quantity =
    match productCode with
    | Widget _ ->
        quantity
        |> int // convert decimal to int
        |> UnitQuantity.create // to UnitQuantity
        |> OrderQuantity.Unit // lift to OrderQuantity type
    | Gizmo _ ->
        quantity
        |> KilogramQuantity.create // to KilogramQuantity
        |> OrderQuantity.Kilogram // lift to OrderQuantity type

我们使用 OR 类型 ProductCode 来指导构造函数. 例如, 如果 ProductCode 是一个小部件, 则我们将原始小数转换为 int, 然后创建出 UnitQuantity.

但我们不能止步于此. 因为如果一个分支返回 UnitQuantity, 另一个返回 KilogramQuantity, 编译器就会报错, 因为它们是不同的类型. 但通过将两个分支都转换为 OR 类型 OrderQuantity, 就可以确保两个分支返回相同的类型.

另一个帮助函数 toProductCode 的实现应该是很一目了然的. 我们希望尽可能使用管道编写函数, 因此代码应如下所示:

let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
    productCode
    |> ProductCode.create
    |> checkProductCodeExists
    // a problem, returns a bool :(

但现在我们有一个问题. 我们希望 toProductCode 函数返回 ProductCode, 但 checkProductCodeExists 函数返回一个 bool, 这意味着整个管道返回一个 bool. 让我们看看怎样在不改变 checkProductCodeExists 实现的前提下让管道返回 ProductCode.

Creating Function Adapters

我们有一个返回 bool 的函数, 但我们真的想要一个返回 ProductCode 的函数. 于其改变这个函数本身, 不如创建一个适配器函数, 这个函数以原始函数为输入, 并返回一个满足要求的新函数.

下面是一个实现:

let convertToPassthru checkProductCodeExists productCode =
    if checkProductCodeExists productCode then
        productCode
    else
        failwith "Invalid Product Code"

有趣的是, 编译器已经确定这个函数是完全通用的 – 它不特定于我们的特定案例! 如果我们查看函数签名, 可以看到没有提及 ProductCode 类型:

val convertToPassthru :
    checkProductCodeExists:('a -> bool) -> productCode:'a -> 'a

事实上, 我们意外地创建了一个通用适配器, 该适配器将任何的判断函数转换为适合管道的 “传递” 函数.

将参数称为 checkProductCodeExistsproductCode 现在并不适用了, 因为现在这两个函数并不代表特定的案例. 这就是为什么许多标准库函数具有如此短的参数名称的原因, 例如函数参数的 fg, 以及其他值的 xy.

让我们重写函数以使用更抽象的名称, 然后, 如下所示:

let predicateToPassthru f x =
    if f x then
        x
    else
        failwith "Invalid Product Code"

现在硬编码的错误消息仍然有类似的问题, 所以让我们参数化. 下面是最终版本:

let predicateToPassthru errorMsg f x =
    if f x then
        x
    else
        failwith errorMsg

请注意, 我们先将错误消息放在参数的第一个位置, 以便我们可以使用部分应用固定它.

现在, 该函数的签名是:

val predicateToPassthru :
    errorMsg:string -> f:('a -> bool) -> x:'a -> 'a

这种抽象技术在函数式编程中非常常见, 因此了解发生了什么并识别出模式是非常重要的. 即使是不起眼的 List.map 函数也可以被视为函数转换器, 它将 “正常” 函数 ‘a -> ‘b 转换为在列表上工作的函数 ‘a list -> ‘b list.

好了, 现在让我们看下新版本的 toProductCode 函数:

let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
    // create a local ProductCode -> ProductCode function
    // suitable for using in a pipeline
    let checkProduct productCode =
        let errorMsg = sprintf "Invalid: %A" productCode
        predicateToPassthru errorMsg checkProductCodeExists productCode
    // assemble the pipeline
    productCode
    |> ProductCode.create
    |> checkProduct

这就是它 - 现在我们有一个 validateOrder 实现的基本草图, 我们可以在此基础上构建. 请注意, 低级验证逻辑(如 a product must start with a W or G)并未在我们的验证函数中显式实现, 而是内置到受约束的简单类型的构造函数中, 类似 OrderIdProductCode.

PriceOrder Step

以下是 PriceOrder 的原始定义:

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

现在我们先消除副作用:

type GetProductPrice = ProductCode -> Price

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

下面是大概的实现. 它只需将每个订单行转换为 PricedOrderLine, 并使用它们构建新的 PricedOrder:

let priceOrder : PriceOrder =
    fun getProductPrice validatedOrder ->
        let lines =
            validatedOrder.Lines
            |> List.map (toPricedOrderLine getProductPrice)
        let amountToBill =
            lines
            // get each line price
            |> List.map (fun line -> line.LinePrice)
            // add them together as a BillingAmount
            |> BillingAmount.sumPrices
        let pricedOrder : PricedOrder = {
            OrderId = validatedOrder.OrderId
            CustomerInfo = validatedOrder.CustomerInfo
            ShippingAddress = validatedOrder.ShippingAddress
            BillingAddress = validatedOrder.BillingAddress
            Lines = lines
            AmountToBill = amountToBill
            }
        pricedOrder

顺便一提, 如果你的管道中有许多步骤, 并且暂时还不想实现它们(或不知道如何实现), 则只需使用如下所示的 not implemented 消息来表示失败, 在绘制实现草图时, 使用 not implemented 异常会很方便. 它能确保我们的项目在任何时候都是完全可编译的.

let priceOrder : PriceOrder =
    fun getProductPrice validatedOrder ->
        failwith "not implemented"

priceOrder 的实现中, 我们引入了两个新的帮助函数: toPricedOrderLineBillingAmount.sumPrices.

我们将 sumPrices 函数添加到了共享的 BillingAmount 模块. 它只是将价格列表加起来并将其包装为 BillingAmount. 为什么我们首先定义 BillingAmount 类型?因为它与 Price 不同, 所以验证规则可能有所不同.

// Sum a list of prices to make a billing amount
// Raise exception if total is out of bounds
let sumPrices prices =
    let total = prices |> List.map Price.value |> List.sum
    create total

函数 toPricedOrderLine 与我们以前看到的函数类似. 它是仅转换一行的帮助函数:

// Transform a ValidatedOrderLine to a PricedOrderLine
let toPricedOrderLine getProductPrice (line:ValidatedOrderLine) : PricedOrderLine =
    let qty = line.Quantity |> OrderQuantity.value
    let price = line.ProductCode |> getProductPrice
    let linePrice = price |> Price.multiply qty
    {
        OrderLineId = line.OrderLineId
        ProductCode = line.ProductCode
        Quantity = line.Quantity LinePrice = linePrice
    }

在此函数中, 我们引入了另一个帮助函数 Price.multiply, 将价格乘以数量.

// Multiply a Price by a decimal qty.
// Raise exception if new price is out of bounds.
let multiply qty (Price p) =
    create (qty * p)

定价步骤现已完成!

Acknowledgement Step

这是移除了副作用后的 Acknowledgement 定义:

type HtmlString = HtmlString of string
type CreateOrderAcknowledgmentLetter =
    PricedOrder -> HtmlString

type OrderAcknowledgment = {
    EmailAddress : EmailAddress
    Letter : HtmlString
}
type SendResult = Sent | NotSent
type SendOrderAcknowledgment =
    OrderAcknowledgment -> SendResult

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

下面是它的实现:

let acknowledgeOrder : AcknowledgeOrder =
    fun createAcknowledgmentLetter sendAcknowledgment pricedOrder ->
        let letter = createAcknowledgmentLetter pricedOrder
        let acknowledgment = {
            EmailAddress = pricedOrder.CustomerInfo.EmailAddress
            Letter = letter
        }

        // if the acknowledgement was successfully sent,
        // return the corresponding event, else return None
        match sendAcknowledgment acknowledgment with
        | Sent ->
            let event = {
                OrderId = pricedOrder.OrderId
                EmailAddress = pricedOrder.CustomerInfo.EmailAddress
            }
            Some event
        | NotSent ->
            None

实现非常简单, 不需要帮助器函数, 所以这很容易!

但是, sendAcknowledgment 依赖项呢? 在某个时候, 我们将必须决定它的实现. 然而, 现在我们可以不去管它. 这是使用函数对依赖项进行参数化的巨大好处之一 – you can avoid making decisions until the last responsible moment, yet you can still build and assemble most of the code.

CreateEvents Step

最后的步骤是创建从工作流返回的事件. 假设只有在计费金额大于 0 时才应发送 billing 事件. 设计是:

// Event to send to shipping context
type OrderPlaced = PricedOrder

// Event to send to billing context
// Will only be created if the AmountToBill is not zero
type BillableOrderPlaced = {
    OrderId : OrderId
    BillingAddress: Address
    AmountToBill : BillingAmount
    }

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

type CreateEvents =
    PricedOrder  // input
    -> OrderAcknowledgmentSent option  // input (event from previous step)
    -> PlaceOrderEvent list  // output

我们不需要创建 OrderPlaced 事件, 因为它和 PricedOrder 一样, 而 OrderAcknowledgmentSent 事件是在上一步中创建的, 因此我们也不需要创建它.

但是 BillableOrderPlaced 事件需要我们手动创建, 所以要构建一个 createBillingEvent 函数. 而由于还需要区别非零计费金额, 所以这个函数必须返回 optional 类型.

// PricedOrder -> BillableOrderPlaced option
let createBillingEvent (placedOrder:PricedOrder) : BillableOrderPlaced option =
    let billingAmount = placedOrder.AmountToBill |> BillingAmount.value
    if billingAmount > 0M then
        let event = {
            OrderId = placedOrder.OrderId
            BillingAddress = placedOrder.BillingAddress
            AmountToBill = placedOrder.AmountToBill
            }
        Some event
    else
        None

现在我们所有的事件都已经有了, 那么应该如何返回它们呢?

我们之前决定为所有的事件类型创建一个 OR 类型, 然后返回这些类型的列表. 因此, 首先我们需要将每个事件转换为 OR 类型. 对于 OrderPlaced 事件, 我们只需直接使用 PlaceOrderEvent.OrdePlaced 构造函数, 但对于另外两个事件, 我们需要使用 Option.map 函数, 如果是 None, 就返回 None; 如果是 Some(x), 返回 Some(f x), f 是给定的函数.

let createEvents : CreateEvents =
    fun pricedOrder acknowledgmentEventOpt ->
    let event1 =
        pricedOrder
        |> PlaceOrderEvent.OrderPlaced
    let event2Opt =
        acknowledgmentEventOpt
        |> Option.map PlaceOrderEvent.AcknowledgmentSent
    let event3Opt =
        pricedOrder
        |> createBillingEvent
        |> Option.map PlaceOrderEvent.BillableOrderPlaced
    // return all the events, how?
    // ...

现在它们都调用了各自的构造函数, 但有些是 optional 类型. 我们应该如何处理才能把它们放到一个列表里呢?好吧, 我们可以再次执行之前的一个技巧, 将它们全部转换为更通用的类型.

对于 OrderPlaced 可以使用 list.singleton 将其转换为列表, 而对于 option, 则可以创建一个称为 listOfOption 的帮助函数:

// convert an Option into a List
let listOfOption opt =
    match opt with
        | Some x -> [x]
        | None -> []

这样, 所有的事件类型都相同了, 我们可以将它们放到另一个列表中返回:

let createEvents : CreateEvents =
    fun pricedOrder acknowledgmentEventOpt ->
    let event1 =
        pricedOrder
        |> PlaceOrderEvent.OrderPlaced
        |> List.singleton
    let event2Opt =
        acknowledgmentEventOpt
        |> Option.map PlaceOrderEvent.AcknowledgmentSent
        |> listOfOption
    let event3Opt =
        pricedOrder
        |> createBillingEvent
        |> Option.map PlaceOrderEvent.BillableOrderPlaced
        |> listOfOption
    // return all the events
    [
        yield! events1
        yield! events2
        yield! events3
    ]

这种将不兼容的东西转换或 “提升” 为共享类型的方法是处理组合问题的关键技术.

Composing the Pipeline Steps Together

现在, 我们已准备好通过将各步骤的实现组合到管道中来完成工作流. 我们希望代码能像下面这样:

let placeOrder : PlaceOrderWorkflow =
    fun unvalidatedOrder ->
        unvalidatedOrder
        |> validateOrder
        |> priceOrder
        |> acknowledgeOrder
        |> createEvents

但是有一个问题, 那就是 validateOrder 除了 UnvalidatedOrder 之外还有两个额外的输入. 就目前情况而言, 没有办法直接将 PlaceOrder 工作流连接到 validateOrder 函数, 因为参数不匹配.

同样, validateOrder 函数也不能连接到 priceOrder 函数, 因为 priceOrder 函数有两个输入.

之前所言, 像这样用不同的 “形状” 组合函数是函数编程中的主要挑战之一, 并且已经有了许多技术来解决这个问题.

大多数解决方案都涉及可怕的 monad, 而在这里, 我们将使用一种非常简单的方法, 那就是部分应用. 我们将只应用 validateOrder 三个参数中的两个(两个依赖项), 这会给我们生成一个只有一个输入的新函数.

let validateOrderWithDependenciesBakedIn =
    validateOrder checkProductCodeExists checkAddressExists

// new function signature after partial application:
// UnvalidatedOrder -> ValidatedOrder

当然, 这是一个可怕的名字! 幸运的是, 在 F# 中, 可以在本地对新函数使用与原函数相同的名称 – 这称为 “shadowing”:

let validateOrder =
    validateOrder checkProductCodeExists checkAddressExists

或者, 可以在名称中使用刻度号来显示它是原始函数的变体, 如下所示:

let validateOrder' =
    validateOrder checkProductCodeExists checkAddressExists

接下来, 我们可以用同样的方式处理掉 priceOrderacknowledgeOrder, 然后我们会得到三个只有一个参数的新函数.

最终, 工作流主函数 placeOrder 如下所示:

let placeOrder : PlaceOrderWorkflow =
    // set up local versions of the pipeline stages
    // using partial application to bake in the dependencies
    let validateOrder =
        validateOrder checkProductCodeExists checkAddressExists
    let priceOrder =
        priceOrder getProductPrice
    let acknowledgeOrder =
        acknowledgeOrder createAcknowledgmentLetter sendAcknowledgment

    // return the workflow function
    fun unvalidatedOrder ->
        // compose the pipeline from the new one-parameter functions
        unvalidatedOrder
        |> validateOrder
        |> priceOrder
        |> acknowledgeOrder
        |> createEvents

除此之外, 还有一个问题. 在我们的例子中, acknowledgeOrder 的输出只有事件, 而没有 PricedOrder, 因此它与 createEvents 的输入不匹配.

我们可以为此编写一个适配器, 或者可以简单地改用更命令化的代码样式, 为每个步骤的输出显式地分配一个值, 如下所示:

let placeOrder : PlaceOrderWorkflow =
    // return the workflow function
    fun unvalidatedOrder ->
        let validatedOrder =
            unvalidatedOrder
            |> validateOrder checkProductCodeExists checkAddressExists
        let pricedOrder =
            validatedOrder
            |> priceOrder getProductPrice
        let acknowledgementOption =
            pricedOrder
            |> acknowledgeOrder createAcknowledgmentLetter sendAcknowledgment
        let events =
            createEvents pricedOrder acknowledgementOption
        events

它不像管道那么优雅, 但仍然易于理解和维护.

那么, 剩下的问题就是从哪里获取那两个依赖项? 我们不想把它们变成全局函数, 所以接下来让我们看看如何 “注入” 这些依赖项.

Injecting Dependencies

(这段话总翻译不好, 还是看原文吧.)
We have a number of low-level helper functions such as toValidProductCode that take a function parameter representing a service. These are quite deep in the design, so how do we get dependencies from the top level down to the functions that need them?

在面向对象设计中, 我们将使用依赖注入(dependency injection) 和 IoC 容器. 但在函数式编程中, 我们不想那么做, 因为那会让依赖项变得隐式. 相反, 我们始终希望将依赖作为显式参数传递, 以确保依赖是显式的.

在函数式编程中, 有很多技术可以做到这一点, 例如 “Reader Monad” 和 “Free Monad”. 但由于这是本入门书籍, 因此我们将使用最简单的方法, 那就是将所有的依赖项放到顶层函数, 然后将它们传递给内部函数, 内部函数又将它们向下传递到更内部的函数, 依此类推.

例如, 假设我们已经实现了我们之前定义的辅助函数, 它们都有一个明确的参数显式的表明依赖性:

// low-level helper functions
let toAddress checkAddressExists unvalidatedAddress =  // ...
let toProductCode checkProductCodeExists productCode = // ...

现在, 作为创建订单行的一部分, 我们需要创建产品代码, 那意味着 toValidatedOrderLine 需要使用 toProductCode, 也就意味着 toValidatedOrderLine 需要有 checkProductCodeExists 参数:

let toValidatedOrderLine
    checkProductExists  // needed for toProductCode, below
    unvalidatedOrderLine =
        // create the components of the line
        let orderLineId = // ...
        let productCode =
            unvalidatedOrderLine.ProductCode
            |> toProductCode checkProductExists  // use service
        // ...

再向上移动一个级别, 因为 validateOrder 函数需要同时使用 toAddresstoValidatedOrderLine, 所以它需要 checkAddressExistscheckProductCodeExists 作为额外的参数传入:

let validateOrder : ValidateOrder =
    fun checkProductExists // dependency for toValidatedOrderLine
        checkAddressExists // dependency for toAddress
        unvalidatedOrder ->
        // build the validated address using the dependency
        let shippingAddress =
            unvalidatedOrder.ShippingAddress
            |> toAddress checkAddressExists
        // ...
        // build the validated order lines using the dependency
        let lines =
            unvalidatedOrder.Lines
            |> List.map (toValidatedOrderLine checkProductExists)
        // ...

以此类推, 直到找到一个可以预构建好所有依赖项的顶级函数. 在面向对象设计中, 此顶级函数通常称为 “组合根”, 这里我们也使用相同的术语.

placeOrder 工作流函数是否可以充当组合根?

不, 因为构建服务通常涉及访问配置文件(副作用). 最好也为 placeOrder 工作流本身提供它所需的依赖作为参数, 如下所示:

let placeOrder
    checkProductExists  // dependency
    checkAddressExists  // dependency
    getProductPrice  // dependency
    createOrderAcknowledgmentLetter  // dependency
    sendOrderAcknowledgment  // dependency
    : PlaceOrderWorkflow =
        fun unvalidatedOrder ->
            // ...

这样做还有一个额外的好处, 即整个工作流很容易测试, 因为所有依赖项都是可伪造的 (fake-able).

实际上, 组合根函数应尽可能接近应用程序的入口点 – 控制台应用的 main 函数或长时间运行的应用(如 Web 服务)的 OnStartup/Application_Start 处理程序. 例如:

let app : WebPart =
    // setup the services used by the workflow
    let checkProductExists =  // ...
    let checkAddressExists =  // ...
    let getProductPrice =  // ...
    let createOrderAcknowledgmentLetter =  // ...
    let sendOrderAcknowledgment =  // ...
    let toHttpResponse = // ...

    // partially apply the services to the workflows
    let placeOrder =
        placeOrder
        checkProductExists
        checkAddressExists
        getProductPrice
        createOrderAcknowledgmentLetter
        sendOrderAcknowledgment

    let changeOrder =  // ...
    let cancelOrder =  // ...

    // set up the routing
    choose
        [ POST >=> choose
            [ path "/placeOrder"
                >=> deserializeOrder  // convert JSON to UnvalidatedOrder
                >=> placeOrder  // do the workflow
                >=> postEvents  // post the events onto queues
                >=> toHttpResponse  // return 200/400/etc based on the output
              path "/changeOrder"
                >=>  // ...
              path "/cancelOrder"
                >=>  // ...
            ]
        ]

Too Many Dependencies?

validateOrder 只有两个依赖. 如果它需要四个, 五个, 甚至更多呢? 如果还有其它步骤也需要大量的依赖项, 则最终的依赖会是爆发式增长. 发生这种情况时, 应该怎么做?

首先, 可能是函数做了太多的事情. 能把拆分它吗? 如果不行, 则可以将依赖分组到单个结构体中, 并将该结构体作为参数传递.

常见的情况是子函数的依赖特别复杂. 例如, 假设 checkAddressExists 函数正在与需要 URI endpointcredentials 的 Web 服务通信:

let checkAddressExists endPoint credentials = ...

我们是否必须让这个函数的调用者(toAddress)也具备那两个参数? 像这样:

let toAddress
    checkAddressExists
    endPoint  // only needed for checkAddressExists
    credentials   // only needed for checkAddressExists
    unvalidatedAddress =
        // call the remote service
        let checkedAddress = checkAddressExists endPoint credentials unvalidatedAddress
        // ...

以此类推到更上层的函数:

let validateOrder
    checkProductExists
    checkAddressExists
    endPoint // only needed for checkAddressExists
    credentials // only needed for checkAddressExists
    unvalidatedOrder =
        // ...

不, 当然不需要这么设计. 这些中间函数不需要知道与 checkAddressExists 函数的依赖有关的任何信息.

更好的方法是在 validateOrder 函数之外再预构建一个所有依赖项都已内置的帮助函数, 然后传递这个帮助函数即可.

例如, 在下面的代码中, 我们在准备期间将 uricredentials 内置到 checkAddressExists 函数中, 以便以后可以将其作为只有一个参数的函数使用:

仅仅是例子, 实际上 set up 阶段应该在更上层(例如 WebPart), 而不是在 placeOrder 中.

let placeOrder : PlaceOrderWorkflow =
    // initialize information (e.g from configuration)
    let endPoint =  //...
    let credentials =  //...

    // make a new version of checkAddressExists
    // with the credentials baked in
    let checkAddressExists = checkAddressExists endPoint credentials
    // etc

    // set up the steps in the workflow
    let validateOrder =
        validateOrder
        checkProductCodeExists
        checkAddressExists  // the new checkAddressExists
    // etc

    // return the workflow function
    fun unvalidatedOrder ->
        // compose the pipeline from the steps ...
        // ...

这种通过 “预构建” 来减少参数的方法是一种常见的技术, 有助于隐藏复杂性. 当一个函数传递到另一个函数时, “接口”(函数类型)应尽可能少, 并隐藏所有依赖项.

Testing Dependencies

像这样传递依赖关系的一大好处是, 它使核心函数非常易于测试, 因为它很容易 fake 出有效的依赖, 而无需任何特殊的模拟库 (mocking library).

例如, 假设我们要测试 validation 的代码是否有效. 一个测试应该检查, 如果 checkProductCodeExists 成功, 则整个验证成功. 另一个测试应该检查, 如果 checkProductCodeExists 失败, 则整个验证都会失败. 让我们看看现在如何编写这些测试.

这是一些 success case 的代码, 使用 Arrange/Act/Assert 模块进行测试:

open NUnit.Framework

[<Test>]
// F# allows you to create identifiers with spaces and punctuation in them
let ``If product exists, validation succeeds``() =
    // arrange: set up stub versions of service dependencies
    let checkAddressExists address =
        CheckedAddress address // succeed
    let checkProductCodeExists productCode =
        true // succeed

    // arrange: set up input
    let unvalidatedOrder = //...

    // act: call validateOrder
    let result = validateOrder checkProductCodeExists checkAddressExists // ...

    // assert: check that result is a ValidatedOrder, not an error
    // ...

可以看到 checkAddressExistscheckProductCodeExists 函数的 stub 版本(代表服务)编写起来很简单, 可以在测试中直接进行定义.

要为 failure case 编写代码, 我们需要做的就是将 checkProductCodeExists 函数更改为对于任何产品代码都失败:

let checkProductCodeExists productCode =
    false // fail

这只是一个小例子. 测试是一个很大的主题, 我们这里没有空间可以进入.

The Assembled Pipeline

在本章中, 我们已经看到了分散片段中的所有代码. 让我们将所有这些组合在一起, 并展示如何组装完整的管道.

  • 我们将实现特定工作流的所有代码放在同一模块中, 该模块以工作流命名(例如 PlaceOrderWorkflow.fs).
  • 在文件的顶部, 我们放置类型定义.
  • 之后, 我们放置每个步骤的实现.
  • 最底层, 我们将各步骤组装到主工作流函数中.

当然, 我们这里只显示代码内容的大纲.

module PlaceOrderWorkflow =
    // make the shared simple types (such as
    // String50 and ProductCode) available.
    open SimpleTypes

    // make the public types exposed to the
    // callers available
    open API

    // ==============================
    // Part 1: Design
    // ==============================
    // NOTE: the public parts of the workflow -- the API --
    // such as the `PlaceOrderWorkflow` function and its
    // input `UnvalidatedOrder`, are defined elsewhere.
    // The types below are private to the workflow implementation.

    // ----- Validate Order -----
    type CheckProductCodeExists =
        ProductCode -> bool
    type CheckedAddress =
        CheckedAddress of UnvalidatedAddress
    type CheckAddressExists =
        UnvalidatedAddress -> CheckedAddress
    type ValidateOrder =
        CheckProductCodeExists  // dependency
        -> CheckAddressExists   // dependency
        -> UnvalidatedOrder  // input
        -> ValidatedOrder  // output

    // ----- Price order -----
    type GetProductPrice = // ...
    type PriceOrder = // ...
    // etc

    // ==============================
    // Part 2: Implementation
    // ==============================

    // ------------------------------
    // ValidateOrder implementation
    // ------------------------------
    let toCustomerInfo (unvalidatedCustomerInfo: UnvalidatedCustomerInfo) =
        // ...
    let toAddress (checkAddressExists:CheckAddressExists) unvalidatedAddress =
        // ...
    let predicateToPassthru = // ...
    let toProductCode (checkProductCodeExists:CheckProductCodeExists) productCode =
        // ...
    let toOrderQuantity productCode quantity = // ...
    let toValidatedOrderLine checkProductExists (unvalidatedOrderLine:UnvalidatedOrderLine) =
        // ...
    // Implementation of ValidateOrder step
    let validateOrder : ValidateOrder =
        fun checkProductCodeExists checkAddressExists unvalidatedOrder ->
        let orderId =
            unvalidatedOrder.OrderId
            |> OrderId.create
        let customerInfo = // ...
        let shippingAddress = // ...
        let billingAddress = // ...
        let lines =
            unvalidatedOrder.Lines
            |> List.map (toValidatedOrderLine checkProductCodeExists)
        let validatedOrder : ValidatedOrder = {
                OrderId  = orderId
                CustomerInfo = customerInfo
                ShippingAddress = shippingAddress
                BillingAddress = billingAddress
                Lines = lines
            }
            validatedOrder

    // ------------------------------
    // The complete workflow
    // ------------------------------
    let placeOrder
    checkProductExists  // dependency
    checkAddressExists  // dependency
    getProductPrice  // dependency
    createOrderAcknowledgmentLetter // dependency
    sendOrderAcknowledgment // dependency
    : PlaceOrderWorkflow = // definition of function
        fun unvalidatedOrder ->
            let validatedOrder =
                unvalidatedOrder
                |> validateOrder checkProductExists checkAddressExists
            let pricedOrder =
                validatedOrder
                |> priceOrder getProductPrice
            let acknowledgementOption =
                pricedOrder
                |> acknowledgeOrder createOrderAcknowledgmentLetter sendOrderAcknowledgment
            let events =
                createEvents pricedOrder acknowledgementOption
            events