持久性如何适合纯功能性语言?


18

使用命令处理程序处理持久性的模式如何适合于一种纯函数式语言,在这种语言中,我们希望使与IO相关的代码尽可能的薄?


当以面向对象的语言实现域驱动设计时,通常使用命令/处理程序模式执行状态更改。在这种设计中,命令处理程序位于您的域对象之上,并负责无聊的与持久性相关的逻辑,例如使用存储库和发布域事件。处理程序是您域模型的公开面孔;诸如UI之类的应用程序代码在需要更改域对象的状态时会调用处理程序。

C#中的草图:

public class DiscardDraftDocumentCommandHandler : CommandHandler<DiscardDraftDocument>
{
    IDraftDocumentRepository _repo;
    IEventPublisher _publisher;

    public DiscardDraftCommandHandler(IDraftDocumentRepository repo, IEventPublisher publisher)
    {
        _repo = repo;
        _publisher = publisher;
    }

    public override void Handle(DiscardDraftDocument command)
    {
        var document = _repo.Get(command.DocumentId);
        document.Discard(command.UserId);
        _publisher.Publish(document.NewEvents);
    }
}

document域对象是负责执行业务规则(如“用户应该有权丢弃文档”或“你不能放弃一个已经被丢弃的文件”),并产生我们需要发布域事件(document.NewEvents会是一个IEnumerable<Event>,并且可能包含一个DocumentDiscarded事件)。

这是一个不错的设计-易于扩展(您可以通过添加新的命令处理程序来添加新用例,而无需更改域模型),并且不知道对象的持久化方式(可以轻松地将NHibernate存储库换成Mongo存储库,或将RabbitMQ发布者替换为EventStore发布者),这使得使用伪造品和模拟进行测试变得容易。它还遵循模型/视图分离-命令处理程序不知道批处理作业,GUI或REST API是否正在使用它。


在Haskell这样的纯功能语言中,您可以大致像这样对命令处理程序进行建模:

newtype CommandHandler = CommandHandler {handleCommand :: Command -> IO Result)
data Result a = Success a | Failure Reason
type Reason = String

discardDraftDocumentCommandHandler = CommandHandler handle
    where handle (DiscardDraftDocument documentID userID) = do
              document <- loadDocument documentID
              let result = discard document userID :: Result [Event]
              case result of
                   Success events -> publishEvents events >> return result
                   -- in an event-sourced model, there's no extra step to save the document
                   Failure _ -> return result
          handle _ = return $ Failure "I expected a DiscardDraftDocument command"

这是我努力理解的部分。通常,将有某种“表示”代码调用到命令处理程序中,例如GUI或REST API。因此,现在我们的程序中有两层需要执行IO-命令处理程序和视图-这在Haskell中是一个很大的禁忌。

据我所知,这里有两个相反的作用力:一个是模型/视图分离,另一个是需要保持模型。需要将IO代码保存在某个地方的模型,但是模型/视图分离表示我们不能将其与所有其他IO代码一起放在表示层中。

当然,以“普通”语言,IO可以(并且确实)发生在任何地方。良好的设计要求将不同类型的IO保持分开,但编译器不会强制执行。

因此:当模型需要持久化时,我们如何将模型/视图的分离与将IO代码推送到程序的最边缘的愿望协调起来?我们如何使两种不同类型的IO分开,但仍远离所有纯代码?


更新:赏金会在不到24小时后过期。我觉得目前的答案都没有解决我的问题。@ Ptharien's Flame的评论acid-state似乎很有希望,但这不是答案,而且缺乏详细信息。我讨厌浪费这些要点!


1
看看Haskell中各种持久性库的设计也许会有所帮助;特别是acid-state似乎与您所描述的很接近
Ptharien's Flame 2014年

1
acid-state看起来很棒,感谢您的链接。就API设计而言,它似乎仍然受到约束IO; 我的问题是关于持久性框架如何适合更大的体系结构。您是否知道acid-state与表示层一起使用的任何开源应用程序,并且成功地将两者分开?
本杰明·霍奇森,2014年

实际上,Query和和Updatemonad相差甚远IO。我将尝试在答案中给出一个简单的例子。
Ptharien的圣火,2014年

对于那些正以这种方式使用命令/处理程序模式的读者来说,冒冒题的风险,我真的建议您检查Akka.NET。演员模型在这里感觉很合适。在Pluralsight上有一门很棒的课程。(我发誓我只是一个狂热分子,而不是促销机器人。)
RJB 2015年

Answers:


6

在Haskell中分离组件的一般方法是通过monad变压器堆栈。我将在下面更详细地说明这一点。

想象一下,我们正在构建一个包含多个大型组件的系统:

  • 与磁盘或数据库对话的组件(子模型)
  • 在我们的域(模型)上进行转换的组件
  • 与用户互动的组件(视图)
  • 描述视图,模型和子模型之间的连接的组件(控制器)
  • 启动整个系统的组件(驱动程序)

我们决定需要保持这些组件的松散耦合,以保持良好的代码风格。

因此,我们使用各种MTL类来指导我们对每个组件进行多态编码:

  • 子模型中的每个函数都是类型 MonadState DataState m => Foo -> Bar -> ... -> m Baz
    • DataState 是我们数据库或存储状态的快照的纯表示形式
  • 模型中的每个函数都是纯函数
  • 视图中的每个函数都是类型 MonadState UIState m => Foo -> Bar -> ... -> m Baz
    • UIState 是用户界面状态快照的纯表示形式
  • 控制器中的每个功能都是类型 MonadState (DataState, UIState) m => Foo -> Bar -> ... -> m Baz
    • 请注意,控制器可以访问视图状态和子模型状态
  • 驱动程序只有一个定义, main :: IO ()它几乎完成了将其他组件组合到一个系统中的工作
    • 视图和子模型将需要提升为与控制器使用相同的状态类型 zoom或类似组合
    • 该模型是纯模型,因此可以不受限制地使用
    • 最后,一切都存在(与之兼容的类型)StateT (DataState, UIState) IO,然后将其与数据库或存储的实际内容一起运行以产生IO

1
这是极好的建议,而这正是我想要的。谢谢!
本杰明·霍奇森2014年

2
我正在消化这个答案。请您说明一下“子模型”在此体系结构中的作用吗?如何在不执行IO的情况下“与磁盘或数据库对话”?我对您的意思特别感到困惑,“它DataState是我们数据库或存储状态的快照的纯表示”。大概您并不是要将整个数据库加载到内存中!
本杰明·霍奇森2014年

1
我非常乐意看到您对这种逻辑的C#实现的想法。不要以为我可以贿赂你吗?;-)
RJB 2015年

1
@RJB不幸的是,您必须贿赂C#开发团队以允许使用该语言的更高种类,因为如果没有它们,该体系结构将变得平淡。
Ptharien的圣火,2015年

4

因此:当模型需要持久化时,我们如何将模型/视图的分离与将IO代码推送到程序的最边缘的愿望协调起来?

该模型是否需要保留?在许多程序中,需要保存模型,因为状态是不可预测的,任何操作都可能以任何方式使模型发生变异,因此,了解模型状态的唯一方法是直接访问模型。

如果在您的场景中,事件序列(已被验证并接受的命令)始终可以生成状态,那么需要持久化的是事件,而不必是状态。始终可以通过重播事件来生成状态。

话虽这么说,状态经常被存储,但只是为了避免重放命令而作为快照/缓存,而不是作为必需的程序数据。

因此,现在我们的程序中有两层需要执行IO-命令处理程序和视图-这在Haskell中是一个很大的禁忌。

接受命令后,事​​件将传递到两个目的地(事件存储和报告系统),但位于程序的同一层。

另请参阅
事件源
热切读取派生


2
我熟悉事件源(我在上面的示例中使用了它!),并且为了避免头发分裂,我仍然会说事件源是解决持久性问题的一种方法。无论如何,事件源并不能消除在命令处理程序中加载域对象的需要。命令处理程序不知道对象是来自事件流,ORM还是存储过程-它只是从存储库中获取对象。
本杰明·霍奇森2014年

1
您的理解似乎将视图和命令处理程序耦合在一起以创建多个IO。我的理解是,处理程序将生成事件,并且不再有兴趣。在这种情况下,视图充当一个单独的模块(即使在技术上在同一应用程序中也是如此),并且未耦合到命令处理程序。
FMJaguar 2014年

1
我认为我们可能出于不同目的而在谈论。当我说“视图”时,我指的是整个表示层,它可能是REST API或模型-视图-控制器系统。(我同意将视图从MVC模式中的模型中分离出来。)我的基本意思是“无论调用命令处理程序如何”。
本杰明·霍奇森

2

您正在尝试为所有非IO活动在IO密集型应用程序中放置空间;不幸的是,像您这样谈论的典型CRUD应用程序除了IO以外没有其他功能。

我认为您理解相关的分隔法很好,但是在您尝试将持久性IO代码放置在与表示代码相距一定数量的层的情况下,此问题的一般事实是在控制器中您应该调用的位置持久层,可能感觉与您的演示文稿过于接近-但这只是这种应用程序的巧合而已。

呈现和持久性基本上构成了我认为在此描述的应用程序类型的全部。

如果您想到了一个类似的应用程序,其中包含许多复杂的业务逻辑和数据处理,那么我想您会发现自己能够想象如何与演示性IO和持久性IO很好地分离开来,它既不需要也不知道。您现在遇到的问题只是一种感性的问题,它是由试图在不具有该问题的应用程序类型中查看问题的解决方案引起的。


1
您是说CRUD系统可以将持久性和表示方式结合起来。对我来说,这似乎很合理。但是我没有提到CRUD。我要特别问的是DDD,您在哪里有复杂的交互作用的业务对象,一个持久层(命令处理程序)和一个表示层。如何在保持 IO包装的同时将两个IO层分开?
本杰明·霍奇森

1
注意,我在问题中描述的领域可能非常复杂。丢弃草稿文件可能需要进行一些相关的权限检查,或者可能需要处理同一草稿的多个版本,或者需要发送通知,或者操作需要其他用户的批准,或者草稿要经过多个检查工作。最终确定之前的生命周期阶段…
Benjamin Hodgson 2014年

2
@BenjaminHodgson我强烈建议不要将DDD或其他固有的OO设计方法混入您的脑海中,这只会造成混淆。是的,虽然您可以在纯FP中创建诸如碎片和气泡之类的对象,但基于它们的设计方法不一定是您的首要目标。在您所描述的场景中,正如我上面提到的,一个在两个IO和纯代码之间进行通信的控制器:表示IO进入控制器并向其发出请求,控制器将其传递给各个纯节和持久性节。
Jimmy Hoffa 2014年

1
@BenjaminHodgson您可以想象一个气泡,您所有的纯代码都将生活在这里,无论您喜欢什么设计,都可能想要所有层次和幻想。这个气泡的切入点将是一个很小的片段,我称其为“控制器”(也许是错误的),它负责表示,持久性和纯片段之间的通信。这样,您的持久性就不了解表示形式,也不了解纯净的内容,反之亦然-并将您的IO内容保持在纯净系统气泡上方的薄层中。
Jimmy Hoffa 2014年

2
@BenjaminHodgson您所说的这种“智能对象”方法对于FP而言本质上是一种不好的方法,FP中的智能对象的问题是它们耦合得太多而泛化得太少。最终,您获得了与其相关的数据和功能,其中FP希望您的数据与功能之间具有松散的耦合,以便您可以实现泛化的功能,然后它们可以跨多种数据类型工作。在这里阅读我的答案:programmers.stackexchange.com/questions/203077/203082#203082
Jimmy Hoffa 2014年

1

在我能理解您的问题(我可能不会,但以为我要花2美分)的范围之内,由于您不一定有权访问对象本身,因此需要拥有自己的对象数据库,随时间过期)。

理想情况下,可以增强对象本身的状态以存储其状态,以便当它们“传递”时,不同的命令处理器将知道它们正在使用什么。

如果这是不可能的,(唯一的办法是拥有一些类似DB的通用密钥,您可以使用该密钥将信息存储在设置为可在不同命令之间共享的存储中),希望, “打开”界面和/或代码,以便任何其他命令编写者在保存和处理元信息时也将采用您的界面。

在文件服务器领域,samba具有不同的存储方式,例如访问列表和备用数据流,具体取决于主机操作系统提供的内容。理想情况下,samba托管在文件系统上,可提供文件的扩展属性。“ Linux”上的示例“ xfs”-更多命令正在将扩展属性与文件一起复制(默认情况下,Linux上的大多数utils“长大”而没有想到扩展属性)。

一种替代解决方案-适用于来自操作通用文件(对象)的不同用户的多个samba进程,如果文件系统不支持像扩展属性那样将资源直接附加到文件,则使用实现一个虚拟文件系统层,以模拟samba进程的扩展属性。只有samba知道它,但是它的优点是可以在对象格式不支持它的情况下工作,但是仍然可以与不同的samba用户(参见命令处理器)一起工作,这些用户根据文件的先前状态对其进行处理。它将元信息存储在文件系统的通用数据库中,这有助于控制数据库的大小(并且不会

如果您需要特定于正在使用的实现的更多信息,这可能对您没有用,但是从概念上讲,相同的理论可以应用于两个问题集。因此,如果您正在寻找算法和方法来完成所需的工作,那可能会有所帮助。如果您需要某些特定框架中的更多特定知识,那么可能就没有太大帮助... ;-)

顺便说一句-我提到“自我失效”的原因-尚不清楚您是否知道其中存在哪些对象以及它们可以保留多长时间。如果您没有直接知道何时删除对象的方法,则必须修剪自己的metaDB,以防止它填充用户早已删除对象的旧的或古老的元信息。

如果您知道对象何时过期/删除,那么您就处于领先地位,并且可以同时将其从metaDB中过期,但是尚不清楚是否可以使用该选项。

干杯!


1
在我看来,这似乎是对一个完全不同的问题的答案。在域驱动设计的背景下,我一直在寻找有关纯功能编程中的体系结构的建议。您能否阐明您的观点?
本杰明·霍奇森

您在用纯功能编程范例询问数据持久性。引用维基百科:“纯功能性是计算中的一个术语,用于描述算法,数据结构或编程语言,不包括程序运行环境中实体的破坏性修改(更新)。” ====根据定义,数据持久性是无关紧要的,它对没有修改数据的东西没有用。严格来说,您的问题没有答案。我正在尝试对您写的内容进行更宽松的解释。
阿斯塔拉
By using our site, you acknowledge that you have read and understand our Cookie Policy and Privacy Policy.
Licensed under cc by-sa 3.0 with attribution required.