函数式DDD架构入门
帮助工程团队将函数编程原理应用到高级设计和体系结构与架构的通俗易懂的思想和最佳实践。
关于函数式编程或FP的许多文章都专注于低级编码实践(例如避免副作用)和FP特定模式(例如可怕的monad)。但是,它们不涉及高级设计和体系结构。然而,FP原则可以大规模应用。实际上,从后端的无服务器到前端的Redux / Elm风格的框架,许多流行的框架和架构样式都源于函数式编程。
如果使用得当,FP原理可以降低复杂性,同时提高应用程序的可测试性和可维护性。这是函数架构。
FP原理适用于软件架构
函数式编程的三个原理与软件架构特别相关。首先是函数是独立的值。也就是说,可以像对待其他独立值一样对待它们,例如整数和字符串。可以将它们分配给变量,存储在列表中,作为参数传递,作为结果返回等等。
在函数架构中,基本单元也是函数,但是我喜欢称其为工作流workflow,工作流是函数的基本单元。它可以称为一个功能特性、用例、场景、故事或任何您想调用的。就像函数一样在编码级别是“某个事物”,这些工作流是架构级别的“事物”,是架构的基本构建块。
其次,组合是构建系统的主要方式。只需将一个输出连接到另一个输入即可构成两个简单功能。结果是可以用作更多合成的起点的另一个功能。
组合是一个非常重要的概念,函数式程序员拥有一套标准工具,例如monads,即使输入和输出不完全匹配也可以进行组合。
从体系结构和架构的角度来看,由较小的函数组成较大的函数,其结果最明显是,函数系统看起来像带有输入和输出的管道,而不是面向消息的请求/响应模型。
每个工作流程函数通常具有相同的结构:读取数据、制定业务决策并根据需要转换数据,最后,在另一端输出任何新数据或事件。这些步骤中的每一个都可以依次视为较小的函数。分支和其他类型的复杂性可能会发挥作用,但是即使工作流程变得越来越大和越来越复杂,数据也始终会朝一个方向流动。
这种组合方法意味着我们仅结合了特定业务工作流所需的特定组件。不需要传统的分层体系结构。当我们向系统中添加新功能时,每个新工作流程所需的函数都是独立定义的,而不是分组为数据库或服务层。
如果我们确实需要在不同的工作流中使用完全相同的函数,则可以将该函数一次定义为子函数,然后在需要它的工作流中将其重新用作共享步骤。这就是组合方法如此吸引人的原因:工作流作为独立的单元进行设计和构建,仅包含其所需的功能,但是当需要时,我们仍然可以利用重用和组件化的所有好处。
最后,函数式程序员尝试尽可能多地使用纯函数。纯函数是确定性的(给定的输入始终会导致相同的输出),并且没有副作用(例如突变或I / O)。它们非常易于测试(确定性!),并且易于理解而无需深入研究其实现(无副作用!)。
与外界互动
您不需要鼓励开发人员使用洋葱架构,作为FP 方法的副作用它会自动发生。
当然,在某些时候,我们将需要进行I / O操作-读写文件,访问数据库等等。函数式程序员试图将这种不确定性尽可能地保持在管道的边缘。某些语言(例如Elm和Haskell)对此非常严格,不允许有任何偏差,而其他语言则将其更多地视为准则而不是规则。
该函数模型与众所周知的方法非常相似,例如洋葱架构,六边形架构(也称为端口和适配器架构)以及函数核心,命令式外壳。在所有情况下,核心域(纯业务逻辑)都与基础架构隔离。基础结构代码了解核心域,但并非相反。依赖关系是单向的,I / O保持在边缘。
仅将纯代码用于业务逻辑意味着单元测试和集成测试之间存在明显的区别。单元测试是针对核心领域的,它是确定性和快速的,而集成测试则是从头到尾对工作流进行的。
函数式编程的优点之一是,业务域与基础结构的这种隔离是自然发生的。您不需要鼓励开发人员使用洋葱体系结构。作为FP 方法的副作用它会自动发生。
边界和背景
作为软件设计师和架构师,我们的下一个挑战是决定如何将这些工作流或管道分组为逻辑单元。与往常一样,这更多的是艺术而不是科学。
有许多准则可以提供帮助。低耦合和高内聚的经典原理不仅适用于函数代码,而且适用于面向对象的代码。或者,重述通用封闭原则:一起变化的代码应该一起生活。最近,领域驱动设计(DDD)社区非常重视组件边界以及在何处绘制边界。
在DDD术语中,相关函数的分组称为有界上下文,每个有界上下文在其自身权限中都被视为一个微型域。它通常对应于特定业务功能的逻辑封装。我们用“有界上下文”而不是像之前称为“子系统”,是因为它使我们能够专注于设计解决方案时最重要的事情:了解上下文和边界。(如何定义这些有界上下文正是另一篇文章的主题。)
为什么要上下文?因为每个上下文代表一些专业知识或能力。在上下文中,我们共享一种通用语言,并且设计是连贯一致的。但是,就像在现实世界中一样,从上下文中提取信息可能会造成混乱或无法使用。
太宽或太模糊的边界根本就没有边界。
为什么要有边界?在现实世界中,领域可能具有模糊边界。但是在软件领域,我们希望减少子系统之间的耦合,以便它们可以独立发展。边界是确保子系统保持独立的关键,可以使用标准软件实践(例如使用显式API)和避免依赖项(例如共享代码)来维护子系统。在需求不断变化的复杂项目中,我们必须毫不留情地维护有界上下文的“有界”部分。太宽或太模糊的边界根本就没有边界。
自治是有界上下文的关键方面。具有自主权意味着有界上下文可以做出决策,而不必等待来自其他有界上下文的决策或信息。也就是说,如果一个有界上下文不可用,则其他有界上下文可以继续独立运行,这是重要的分离。
自治也可以应用于开发过程。通常,一个有界上下文最好由一个团队拥有。想想三腿比赛:绑在腿上的两个跑步者比自由地独立跑步的两个跑步者慢得多。软件组件也是如此。如果两个团队在相同的受限环境中做出贡献,那么他们可能最终会随着设计的发展而朝着不同的方向拉动设计。
如果将有界上下文的概念应用于函数体系结构,我们最终会遇到许多小型的、集中的域,每个域都支持许多业务工作流。这些边界的定义方式应使其内部的工作流具有自主性,并且能够在不依赖其他系统的情况下完成其工作。
在某些情况下,长期运行的用例或场景需要多个工作流。在这种情况下,处于不同上下文中的工作流将需要使用事件和其他方法相互通信。但是,重要的是将单个工作流保持在一个有界上下文中,并且不要尝试实现方案在多种情况下“端到端”。允许工作流到达多个服务内部最终将导致我们很好解耦的体系结构演变成无法维护的依赖关系的纠结- “ 一个大麻烦” 。”
实体服务反模式
定义边界可能没有正确的方法,但是肯定有许多错误的方法。
定义边界可能没有一种正确的方法,但是肯定有许多错误的方法。分组功能的常见反模式是“ 实体服务”方法,其中围绕实体而不是工作流构建服务。也就是说,有一个“订单”服务,一个“产品”服务,等等。这通常是由于天真地将面向对象的设计直接转移到面向工作流程的体系结构而导致的。此设计的主要问题是,单个业务工作流通常将需要所有这些服务进行协作。如果其中任何一个不可用,则整个工作流程将失败。而且,如果工作流程需要发展,我们可能需要同时触摸和更新许多服务中的代码,从而破坏了“一起改变的代码应该一起生活”的规则。
此外,仅因为业务工作流程涉及实体,例如“订单”并不意味着它与使用该实体的其他工作流程有任何共同点。例如,“支付订单”工作流和“删除订单”工作流都涉及订单,但是具有完全不同的业务逻辑。不需要他们都依赖于将不同函数集合在一起的“订购”服务。当其他要求(例如安全性,可伸缩性等)开始发挥作用时,我们可能会发现必须以非常不同的方式来管理不同的工作流程。耦合它们只会引起痛苦!
事件
现在,我们的工作流函数已分组到有界上下文中,可以使用了。但是什么触发了这些业务工作流?是什么导致员工,用户或自动化流程启动工作流?
是一个事件。也就是说,在外面的世界有新的变化-客户在点击一个按钮,邮件到达时,警报弹出。这是在一个商业活动的形式捕获-例如,“下订单”或“已收到电子邮件。” 在FP架构中,像这样的业务事件会触发工作流。
此外,使用这种方法,工作流的输出也是一个事件:一个通知,通知所有下游工作流世界上发生了重要变化。特定于特定工作流程且未共享的更改(例如数据库更新)不会作为事件从工作流程中发出。
这就是我们可以从这些较小的工作流程中组装较大的流程的方式。每个工作流程都由一个事件触发,并且该工作流程又会生成更多事件供下游流程使用。但是事件如何在工作流之间传递?这取决于项目的特定要求。如果所有工作流程都可以生活在同一个流程中,那么它可以是一个简单的内存队列。但是,如果需要分别和独立地部署工作流,则首选外部队列,服务总线或Kafka风格的事件日志。
请注意,在所有情况下,工作流都是异步交互的。这使他们在时间和空间上保持独立和分离。很少使用在工作流中直接调用另一个工作流的命令和控制方法。
如果您熟悉事件驱动的体系结构,则将很熟悉这种基于事件的方法。而且,确实,具有单独管道的FP方法非常适合于这种体系结构样式。
逻辑架构与物理架构
对事件触发的单独工作流的描述是逻辑视图,而不是物理视图。
到目前为止,设计目标已经围绕业务需求进行了调整,但是我们还需要考虑技术需求。赞成使用工作流作为设计的单元的一个观点是:它们可以在物理上部署在多种方式-例如作为微服务,独立无服务器函数,或单体架构的组件模块化,或甚至作为在基于代理的系统Erlang或Akka的风格。技术实现的选择取决于开发团队的规模和数量,安全性,可伸缩性需求等。
在某些情况下,逻辑工作流也可能在物理上分为单独的部分。例如,工作流可以通过从前端开始同步API 调用,然后继续在后端执行。
那么,应该清楚的是,系统的逻辑和物理组成是不相同的,不应混为一谈。但这并不意味着我们可以避免就架构的物理和实现方面做出决策。单体或无服务器是在项目早期应考虑的重要决定。同样,编程语言的选择和数据库的选择也没有反映在像这样的逻辑模型中,但它们是至关重要的体系结构决策。
前端函数架构
随着SPA的兴起以及完全写在前端的严肃应用程序,前端软件体系结构变得越来越重要。随着FP强调不变性、单向数据流和边缘处的I / O已被证明对于降低复杂性很有价值,用于前端架构的函数性方法也变得越来越流行。
最常见的功能前端体系结构是Model-View-Update体系结构,也称为Elm体系结构。在此设计中,应用程序包含一个不变的模型(代表应用程序状态)和两个关键功能:一个update功能是在发生来自浏览器的消息或事件(例如单击按钮)时更新模型的view功能,以及一个呈现查看该模型(通常只是HTML)。呈现的视图可以将浏览器事件与域模型中定义的消息相关联,以便随后在浏览器中单击按钮(例如,在浏览器中)将触发域消息,从而触发更新功能,该函数最终将呈现新视图,即再次传递给浏览器。以此类推。
函数式编程原理的力量
从后端的微服务和无服务器到前端的MVU,函数式编程的知识对于理解许多现代架构样式至关重要。
软件体系结构的许多良好做法:凝聚力,去耦,I / O的隔离等等,从应用编程函数的原则自然会出现。例如,我们已经看到,在典型的函数设计中,每个工作流都是独立构建的,仅包含其所需的功能(最大限度地提高了凝聚力),并且在各个层次上都强调了自治性,从单个函数到有界上下文(去耦) 。此外,确保可测试性和可维护性的一种确保方法是将业务逻辑保持在纯确定性的函数中,并使用不可变的数据模型来迫使数据更改变得明确。
有许多方法可以进行软件体系结构,并且没有一种千篇一律的方法。但是,使用函数式编程原理的潜在好处是多方面的,我鼓励您进一步研究这些原理-甚至可以将其应用于下一个项目。祝好运!