如何设计功能样式的可插拔系统?

时间:2015-03-16 03:09:50

标签: f# functional-programming

声明:
虽然我接受了不可变状态和高阶函数的福音,但我的实际经验仍然是95%面向对象。我想改变这一点,但是更多的是什么。所以我的大脑非常关注OO。

问题:
我经常遇到这种情况:一个业务功能实现为一个小的“核心”加上多个“插件”,共同为用户呈现一个看似坚实的表面。我发现这种“微内核”架构在很多情况下都能很好地工作。另外,非常方便,它与DI容器很好地结合,可以用于插件发现。

那么,我该如何以功能的方式做到这一点

我不认为这种技术的基本思想本质上是面向对象的,因为我刚刚在不使用任何OO术语或概念的情况下对其进行了描述。但是,我不能完全围绕功能性的方式来解决它。当然,我可以将插件表示为函数(或函数桶),但是当插件需要将自己的数据作为整体图片的一部分时,困难的部分就出现了,并且插件插件的数据形状也不同。

下面是一个小的F#片段,它或多或少是C#代码的字面翻译,我将从头开始实现这个模式。 请注意弱点:在CreateData中丢失类型信息,必要时在PersistData进行广播。
我每次都对演员阵容(无论是向上还是向下)都畏缩,但我已经学会了接受它们作为C#中必不可少的邪恶。然而,我过去的经验表明,功能方法通常会为这类问题提供意想不到的,美丽而优雅的解决方案。这是我追求的解决方案。

type IDataFragment = interface end
type PersistedData = string // Some format used to store data in persistent storage
type PluginID = string // Some form of identity for plugins that would survive app restart/rebuild/upgrade

type IPlugin = interface
  abstract member UniqueID: PluginID
  abstract member CreateData: unit -> IDataFragment

  // NOTE: Persistence is conflated with primary function for simplicity. 
  // Regularly, persistence would be handled by a separate component.
  abstract member PersistData: IDataFragment -> PersistedData option
  abstract member LoadData: PersistedData -> IDataFragment
end

type DataFragment = { Provider: IPlugin; Fragment: IDataFragment }
type WholeData = DataFragment list

// persist: WholeData -> PersistedData
let persist wholeData = 
  let persistFragmt { Provider = provider; Fragment = fmt } = 
    Option.map (sprintf "%s: %s" provider.UniqueID) (provider.PersistData fmt)

  let fragments = wholeData |> Seq.map persistFragmt |> Seq.filter Option.isSome |> Seq.map Option.get
  String.concat "\n" fragments // Not a real serialization format, simplified for example

// load: PersistedData -> WholeData
let load persistedData = // Discover plugins and parse the above format, omitted

// Reference implementation of a plugin
module OnePlugin =
  type private MyData( d: string ) = 
    interface IDataFragment
    member x.ActualData = d

  let create() = 
    {new IPlugin with
      member x.UniqueID = "one plugin"
      member x.CreateData() = MyData( "whatever" ) :> _
      member x.LoadData d = MyData( d ) :> _

      member x.PersistData d = 
        match d with
        | :? MyData as typedD -> Some typedD.ActualData
        | _ -> None
    }




一些更新和澄清

  • 我不需要接受“一般”功能编程的教育(或者至少我喜欢这样思考:-)。我确实知道接口是如何与函数相关的,我知道高阶函数是什么,以及函数组合是如何工作的。我甚至理解 monads 温暖蓬松的东西(以及类别理论中的其他一些mumbo-jumbo)。
  • 我意识到我不需要在F#中使用接口,因为功能通常更好。但我的示例中的两个接口实际上都是合理的:IPlugin用于绑定UniqueIDCreateData;如果不是界面,我会使用类似形状的记录。 IDataFragment用于限制数据片段的类型,否则我将不得不使用obj,这样可以减少类型安全性。 (我甚至无法想象如何在Haskell中使用Dynamic,而不是使用Dynamic)

3 个答案:

答案 0 :(得分:5)

我只能同情你的陈述。虽然小编程中的函数式编程已经被人们讨论过,但对于如何在大型函数式编程中进行函数编程却没有什么建议。我认为对于F#而言,随着系统的发展,大多数解决方案都倾向于更加面向对象(或者至少是面向接口的)。我不认为它一定很糟糕 - 但如果有一个令人信服的FP解决方案,我也希望看到它。

我在类似场景中看到过的一种模式是拥有一对接口,一个是打字的,一个是非打字的,还有一个基于反射的机制。所以在你的场景中你会有这样的事情:

type IPlugin =
    abstract member UniqueID: PluginID
    abstract member DataType: System.Type
    abstract member CreateData: unit -> IDataFragment

type IPlugin<'data> = 
    inherit IPlugin

    abstract member CreateData: unit -> 'data
    abstract member PersistData: 'data -> PersistedData option
    abstract member LoadData: PersistedData -> 'data

并且实现看起来像这样:

let create() = 
    let createData () = MyData( "whatever" )
    {
        new IPlugin with
            member x.UniqueID = "one plugin"
            member x.DataType = typeof<MyData>                
            member x.CreateData() = upcast createData()
        interface IPlugin<MyData> with
            member x.LoadData d = MyData( d )
            member x.PersistData (d:MyData) = Some d.ActualData
            member x.CreateData() = createData()            
    }

请注意,CreateData是两个界面的一部分 - 它只是为了说明在打字和无类型界面之间重复多少以及需要多长时间之间取得平衡跳过篮球在他们之间转换。理想情况下,CreateData IPlugin不应该出现在IPlugin,但如果它能节省您的时间,我将不会再回头两次。

IPlugin<'a>转到IPlugin你需要一个基于反射的辅助函数,但至少你明确知道了类型参数,因为它是{{1}的一部分接口。虽然它并不漂亮,但至少类型转换代码包含在代码的单个部分中,而不是分散在所有插件中。

答案 1 :(得分:3)

不必定义接口,以便在F#中构建可插入的体系结构。功能已经可以组合。

您可以从外部编写系统,从系统所需的整体行为开始。例如,这是我最近编写的一个函数,它将Polling Consumer从没有收到消息的状态转换为新状态:

let idle shouldSleep sleep (nm : NoMessageData) : PollingConsumer =
    if shouldSleep nm
    then sleep () |> Untimed.withResult nm.Result |> ReadyState
    else StoppedState ()

这是高阶函数。在我编写它时,我发现它依赖于辅助函数shouldSleepsleep,所以我将它们添加到参数列表中。然后编译器自动推断出例如shouldSleep必须具有NoMessageData -> bool类型。该函数是依赖sleep函数也是如此。

作为第二步,事实证明shouldSleep函数的合理实现最终看起来像这样:

let shouldSleep idleTime stopBefore (nm : NoMessageData) =
    nm.Stopped + idleTime < stopBefore

别介意你不知道这一切是怎么回事。这是重要的功能组合。在这种情况下,我们了解到,此特定shouldSleep函数的类型为TimeSpan -> DateTimeOffset -> NoMessageData -> bool,而 NoMessageData -> bool相同。

但它非常接近,您可以使用部分功能应用来完成剩下的距离:

let now' = DateTimeOffset.Now
let stopBefore' = now' + TimeSpan.FromSeconds 20.
let idleTime' = TimeSpan.FromSeconds 5.
let shouldSleep' = shouldSleep idleTime' stopBefore'

shouldSleep'函数是shouldSleep函数的部分应用程序,具有所需类型NoMessageData -> bool。您可以将此函数与idle函数及其sleep依赖项的实现一起组成。{/ p>

由于低阶函数具有正确的类型(正确的函数签名),它只是点击到位;为了达到这个目的,不需要铸造。

可以在不同的模块中,在不同的库中定义idleshouldSleepshouldSleep'函数,并且可以使用类似于Pure DI的过程将它们全部拉到一起

如果您想要查看从单个函数中编写整个应用程序的更全面的示例,我在Functional Architecture with F# Pluralsight课程中提供了一个示例。

答案 2 :(得分:-1)

函数式编程全都与函数的显式组成有关。没魔术如果需要插件,请通过一些配置来组合功能。就是这样。

好的,还不止这些。您在询问有关处理状态的信息。

在FP编程中,只有两个选项。

1)如Mark所述手动进行部分应用。

2)通过Reader,State或其他monad自动进行部分应用。

通过monad,核心功能可以是纯函数,而危险状态则由monad本身处理。

现在有意义吗?