原文地址Patterns for designing flexible architecture in node.js (CQRS/ES/Onion)
在这篇文章中,我介绍了一个使用CQRS和Event Sourcing模式的项目。 它使用洋葱式架构编写,并使用TypeScript编写。
“flexible” how?
我使用“flexible”这个术语来推广能够适应不同环境的架构。 更确切地说,我试图:
- 将核心业务逻辑与实现细节分开
- 独立于任何数据库,框架或服务
- 尽可能试用简单的纯函数
- 使项目易于“水平”扩展
- 使项目便于测试
- 使用类型系统(type systerm)主要是为了传达核心领域的“普及语言” (use type system primarily to communicate the “ubiquitous language” of the core domain)
谨记:项目的某些部分可能过度设计!
CQRS模式可以在没有事件源的情况下使用,您不必遵循“洋葱式架构”或使用类型系统(type systerm)。
不过,我把所有这些东西结合在一起主要是为了测试和了解它们,所以我希望你能挑出对你有用的技术。
Project details
我正在构建的这个项目可以被描述为一个平台,它可以帮助作者(开发者,艺术家,作家等)尽早得到反馈,并且不管他们的受欢迎程度如何,但是可以使观众了解他们的正在干什么。
这是一个科学期刊出版和同行评审的在线改编。(?)
有关更多详细信息,请参阅项目readme。
但是,对于这篇文章来说,了解有三个主要实体构建应用程序的模型就足够了:
- 文章 - 作者正在提交的提交(如博客文章或YouTube视频)
- 杂志 - 只有在杂志定义的一套规则得到满足的情况下才能被接受的文章集合
- 用户 - 作者或同行审稿人与授予他们一些特权的排名(类似于StackOverflow的排名系统)
Every action causes a reaction
我将尝试以我的项目为例来说明 Event sourcing 模式。 但是,如果这是你第一次听到这个消息,我建议你也看看这个话题。
在事件采购(ES)中,我们可以看一个系统,并说有行动,每一个行动引起的反应。 在这种情况下,这个动作可以作为命令和作为事件的反应来实现。 (资源)
然而,反过来展示这个项目要容易得多 - 从 reactions 到 actions.
所以,这里是项目当前使用的事件列表:
注意:“杂志”,“文章”和“用户”是分开的单位,我将其称为聚合(即使其“面向对象的定义”不完全符合我的模型,其通用目的是相同的)
在对事件列表感到满意后,我更详细地定义了每种类型(使用TypeScript):
注意:在当前版本的项目中,事件不再以这种方式定义,而是使用io-ts(后面会详细介绍)转换为TypeScript类型。
根据这些事件是用于捕获系统变化还是作为报告数据的来源,应用程序分为两个部分:
- write side – 解决事件存储问题,确保业务规则和处理命令
- read side – 写入由写入方产生的事件,并使用它们来构建和维护适合于回答客户查询的模型
简而言之,这是一个典型的CQRS应用程序,我们将命令(写入请求)和查询(读取请求)之间的责任分开:
Mental model for storing events
在Event Sourcing系统中存储事件的心智模式非常简单 - 新事件被附加到列表中,稍后可以从列表中检索。 另外,存储时,事件不会被删除或更新。
所以,它基本上是从CRUD模型(创建,读取,更新,删除)切换到CR模型(创建,读取)。
216/5000
当我第一次开始学习Event Sourcing时,我总是想象一个巨大的事件清单,每次我必须做出改变(我认为我应该使用所有的事件来补充一个集合)。
但这种方法存在问题,导致在数据库级别阻塞问题或导致更新失败(由于 pessimistic 并发)。
注:关于“一致性边界”的更详细的解释,我建议阅读“DDD的模式,原则和实践”(第19章 - 总结)。
长话短说,考虑事件存储的一个更好的方法是:
有多个事件列表(事件流),每个事件列表包含与不同聚合对应的事件
例如,对于看起来像这样的期刊
:
注:有关事件如何在SQL或NoSQL数据库中实际存在的确切细节,或者如何执行pessimistic或optimistic锁定,不在本博文的范围之内。
The big picture
尽管“action触发reaction”的说法是正确的,但是并没有真正地告诉你如何创建,捕获或验证一个命令,如何确保不变量(业务规则)或如何处理关注(concerns)的耦合和分离。
为了解释这一点,首先意识到“big picture”是有用的:
这就是为什么在解释每个组件做什么之前,我将首先提出一个架构,其主要目标是将技术复杂性与领域(domain)的复杂性分开。
这就是所谓的“洋葱建筑(onion architecture)”:
该体系结构使用一个简单的规则将软件划分为多个层:外层可以依赖于较低层,但下层的代码不能依赖于外层的任何代码。
- 架构的核心是一个
(包含业务规则),只使用纯函数(容易测试) - 命令处理程序可以使用一个领域模型,并通过使用注入的存储库实现存储库接口(易于模拟)与外部进行通信
- 最外层可以访问所有的内层。 它提供了存储库接口,系统入口点(REST API),数据库连接(Event store)以及类似的实现。
应用程序的呈现,持久性和领域逻辑关注会以不同的速率和不同的原因发生变化; 分离这些问题的体系结构可以适应变化,而不会对代码库的不相关域产生不良影响。 (DDD的模式,原则和实践)
这种架构的另一个很好的特点是它倾向于定义你的目录结构:
注:在洋葱架构的大多数例子中,“commandHandlers”通常是“应用程序”层的一部分。 但是,因为在我的情况下,处理命令是这个层目前唯一正在做的事情,所以我决定把它叫做什么(如果将来我需要更多的东西,我可能会重命名为“应用程序”)
如果您听说过“clean architecture”或“hexagonal architecture”(端口和适配器),请注意它们几乎与“onion architecture”相同。
Authentication & formation of a command
命令是用户请求进行更改的一个对象。
它通常与结果事件“一对一”映射:
1 | CreateJournal => JournalCreated |
但是它有时会触发多个事件:
1 | ReviewArticle => [ArticleReviewed, ArticlePromoted, ArticleAccepted] |
有很多方法可以生成和处理命令,但是对于这个项目,我使用了一个接受JSON对象的REST端点(/ command):
1 | { |
该对象作为POST请求的参数,然后转换为:
1 | { |
注:“后面”userId属性是整个身份验证过程,这是一个大任务。 为此,我决定使用Auth0服务(类似于“Firebase Authentication”或“Amazon Cognito”),当然,您可以自己实现。
这里重要的一点是命令处理程序不会因为身份验证的复杂性而臃肿,并且假设从这个服务发送的userId是受信的。
然后将命令对象(包含userId)传递给相应的命令处理程序(通过命令名称找到)。
这是这个过程的简单例子:
Command handler — validating the input data
正如CQRS的这个常见问题一样,下面是命令处理程序遵循的一些常见步骤(从原来的略有改变):
1.根据自身的优点验证命令
2.验证聚合的当前状态的命令
3.如果验证成功,则尝试坚持新的事件。 如果在此步骤中发生并发冲突,请放弃或重试
在第一步(“根据自己的优点验证命令”)中,命令处理程序会检查命令对象是否有任何缺少的属性,无效的电子邮件,URL和类似信息。
为此,我使用io-ts - 一种运行时类型的系统进行IO验证,它与TypeScript兼容(当然也可以不使用它)。
它通过结合简单的类型来工作(完整的例子):
变成更复杂的命令类型(完整的例子)
然后用它来验证REST API发送的输入数据。
注:如果验证成功,TypeScript将判断命令的类型:
此时,命令处理程序必须执行第二个步骤:“根据聚合的当前状态验证命令”。
换句话说,它必须决定应该存储哪个事件,或者是否应该通过抛出一个错误(如果一些业务规则被破坏)来拒绝一个命令。
这个决定是在一个domain model(领域模型)的帮助下完成的。
使用领域模型来检查业务规则
领域模型是定义业务规则的应用程序的一部分。 它的实现应该尽可能简单(即使是非程序员也可以理解),并与系统的其余部分(这是洋葱架构模式的要点)隔离。
继续“addEditor”的例子,下面是一个命令处理程序(带有一个突出显示的部分,其中使用了来自领域模型的函数):
addEditor属于日志聚合,它被实现为一个简单的纯函数,它返回结果事件或抛出一个错误(如果任何业务规则被破坏):
参数userInfo和timestamp 源于命令对象。 “聚集的当前状态”由用户和日志对象表示,这些用户和日志对象使用系统中的另一个组件 - 一个Repository进行检索。
注:如果你不喜欢看到硬编码的字符串,请记住,我正在使用TypeScript,如果没有以正确的方式使用你将抓狂:
除了编译时错误之外,使用“重命名符号特性”重命名任何属性或字符串,都可以在项目中的所有文件中使用(在vs code中进行测试)。
Retrieving the current state of the aggregate with a repository
userState和journalState使用注入的依赖关系检索:userRepository和journalRepository:
这些存储库通常包含一个名为getById的方法。
这个方法的工作是,你已经猜到了,通过它的id获得一个聚合状态。
所以,在日志聚合的情况下,它应该返回这种类型的对象:
但是,Event store对日志聚合的格式一无所知,如前面所示,它只存储事件:
这就是为什么我必须将这些事件转换为所需的状态,使用 - reducer。
注意:请记住,为了获得当前的汇总状态,您不必使用事件采购。 有时更适合检索一个完整的对象(使用MongoDB或类似的),并跳过部分减少和保存事件。
但是,如果你和我一样,而且你希望你的模型是“灵活的”(所以你可以随时轻易地改变聚合状态的格式),你必须处理“reducer”。
reducer只是一个(纯)功能(类似于Redux reducer),也是在领域模型中定义的:
注:同样,对于TypeScript,您可以安全地使用硬编码的字符串,对于每种情况,应该推断出事件类型:
Saving events in the Event Store
命令处理程序的最后一步是:如果验证成功,则尝试保留新事件。 如果在此步骤中发生并发冲突,请放弃或重试。
除了聚合状态之外,存储库还将返回用于保存事件的保存功能:
在hood下,使用optimistic concurrency 来保持事件以确保一致性(类似于mongoDB方法,但没有“自动重试”)。
注意:我正在使用基于在写入端而不是读取端检索的事件版本的optimistic concurrency。 这是我为我的domain 做出的一个有意识的决定,如果您尝试为自己使用这个解决方案,请确保您了解权衡(这篇博客文章已经足够长了,我不会解释)
但是,如果您决定使用在读取端获取的版本,则可以传递如下版本号:save(events,expectedVersion)
Summary of the application flow on the write side
- Commond是用户发送的对象(来自UI)
- REST API接收一个Commond并处理用户认证
- 然后“authenticated command”被发送到command handler
- Command handler向聚合状态的repository 发出请求
- Repository从事件存储中检索事件,并使用领域模型中定义的reducer将其转换为聚合状态
- Command handler 使用以响应结果事件的领域模型验证集合当前状态的命令
- Command handler将结果事件发送到repository
- Repository 尝试在事件存储中保存接收到的数据,同时使用 optimistic locking确保一致性
The Read Side
使用事件重建聚合状态不是很复杂也不昂贵。
但是,事件存储不适合在集合中运行查询。 例如,像“选择所有以xyz开头的所有期刊”这样的查询将需要重新构建所有聚合成本太高的成本(更不用说更复杂的查询,这是单块CRUD应用程序中的“资金流失”的主要来源)。
这是读取方面解决的一个问题。
简而言之,读取方将监听从写入方发布的事件,将这些事件作为本地模型的更改进行投影,并允许在该模型(源)上进行查询。
通过构建维护专门用于任何类型查询的数据库的模型(或多个模型),您可以节省大量的处理能力。
1 | 如果你的托管账单是不合理的<a href="https://www.forexfactory.com/attachment.php?attachmentid=2253285&stc=1&d=1490980308">YUGE</a>! 主要是由于复杂的查询 - 你应该考虑CQRS / ES架构。 |
由于读取方总是“落在”写入方(即使是几分之一秒),应用程序变得“最终一致”。 这就是为什么它更便宜和易于扩展的主要原因,而且这也是写单面与单片CRUD应用相比更复杂的原因。
Conclusion
我喜欢在事件中思考。 它使我专注于一个领域,而不是一个数据库模式,你甚至不必是一个程序员来理解他们。 这个事实使你更容易与域名专家沟通(DDD的一大部分)。
而且,这种架构的本质迫使我不要把一致性看作是理所当然的,从而更多地了解它(当使用微服务时这是非常有益的)。
但是,作为一切,这些模式都有其成本。 如果你不想把它们全部用在一起,也许你可以使用它的一部分。
正如我已经提到的, CQRS模式可以在没有事件源的情况下使用,您不必遵循“洋葱体系结构”或使用类型系统。 例如,您可以:
- 使用一个类似的模型,其中的事件被保存在NoSQL数据库中的对象替代(没有事件源)
- 从客户查询的写模型中使用reducer(无CQRS)
- 使用洋葱体系结构更容易地构建无服务器应用程序(使用“lamdas”或“云功能”)(在“开发阶段”模拟基础架构层)
- 以类似的方式使用类型,其中域以细粒度,自我记录的方式呈现(类型优先的开发)
- 使用运行时类型系统进行IO验证(如io-ts)