在 Node.js 中设计一种 flexible 的模式(CQRS/ES/Onion) (译)

原文连接

在这篇文章中, 我介绍的是一个使用 CQRSEvent Sourcing 模式的项目, 它使用了 onion architecture, 用 Typescript 编写.

"flexible" how?

我使用 flexible 来推广这种能够适应不同环境的架构. 更确切地说, 我试图:

  • 核心业务逻辑与实现细节相互分离
  • 独立于任何 数据库(database), 框架(framework), 服务(service)
  • 尽可能地使用简单的 纯函数(pure function)
  • 使项目易于横向扩展
  • 使项目易于测试
  • 使用类型系统主要是为了 通用语言(ubiquitous language) 与核心领域之间的交流

注意: 项目中的某些地方可能过渡设计!

CQRS 可以不与 Event Sourcing 一同使用, 你也不一定要遵循onion architecture和类型系统. 当然, 我将这些结合在一起主要是为了测试和了解它们, 因此我希望你能够选择适合于你的技术.

Project details

我正在构建的项目是一个平台, 可以帮助作者(开发者, 艺术家, 作家等)尽早收到反馈,并将他们的工作传达给受众, 无论他们受欢迎的程度如何. 你可以在 project readme 中了解更多信息, 但是对于这篇文章, 只需要理解以下三个实体对象塑造的领域模型:

  • article——作者的提交的稿件(比如博客文章或者是youtube视频)
  • journal——只有满足期刊的一系列规则后才能被收录的文章集
  • user——作者或编辑, 以及他们相应的权限(类似于 StackOverflow 的排名系统)

Every action causes a reaction

我将努力尝试使用我的项目作为示例来解释 Event sourcing. 但是, 如果你是第一次接触这种模式, 我建议你同时看一下这段视频.

在 Event Sourcing 中, 我们能够看到系统中的一些行为, 每一个行为都会引起反应. 在这种情况下, 行为能够通过 命令 实现, 反应能够通过 事件 实现. (source)

然而, 对当前的项目来说, 以反方向展示更加容易——从反应到行为.

因此, 无需再费周折, 下面是当前项目中所使用的事件列表:

注意: "Journal", "Article"和"User"是单独的单元,我将其称为 aggregates(即使它的"面向对象的定义"不完全适合我的模型, 但是目的是相同的)

在对事件列表感到满意后, 我对每种类型做了更详细的定义(使用 TypeScript):

注意: 在项目的当前版本中, 事件不再以这种方式定义, 而是使用 io-ts 转换(稍后将详细介绍).

应用根据这些事件是用于捕获系统中的更改还是用作报告数据源, 分为两个方面:

  • 写入(或者是命令)——处理事件存储问题, 确保业务规则和处理命令
  • 读取(或者是查询)——获取写入端生成的事件, 并使用它们来构建和维护适合客户端查询的模型

简而言之, 这是典型的CQRS应用, 我们将命令(写入请求)和查询(读取请求)之间的责任分离:

在这篇文章中, 我将主要介绍应用程序的写入方面(通常更复杂).

Mental model for storing events

Event Sourcing 系统中, 用于存储事件的心智模型十分简单——新事件将追加到列表中, 以后可以从列表中检索. 此外, 存储时, 永远不会删除或更新事件.

因此, 它基本上是从 CRUD 到 CR 的转换.

当我第一次开始学习 Event Sourcing 时, 我经常想象我每次进行更改时都必须加载的大量事件列表(我认为我应该使用所有事件来补充单一聚合).

但是这种方法存在问题, 它会导致数据库级别的阻塞问题或更新失败(由于悲观并发)

注意: 有关"一致性边界"的更详细解释, 我建议阅读 "Patterns, Principles and Practices of DDD"(第19章 ——聚合).

长话短说, 考虑事件存储的更好方法是: 有多个事件列表(事件流), 每个事件列表包含与不同聚合对应的事件.

举个例子, 对于 journals, 它看上去是这样的:

注意: 有关事件如何在SQL或NoSQL数据库中持久化, 以及悲观或乐观锁如何完成, 不在本文的讨论范围之内.

The big picture

即使"行为导致反应"这种说法正确, 但它并没有真正告诉你如何创建, 捕获或验证命令, 如何确保不变量(业务规则), 如何处理关注点的耦合和分离.

为了解释这一点, 提前了解"大局"是有用的:

但是由于系统中存在如此多的组件, 因此很难"透过现象看本质".

这就是为什么在解释每个组件的作用之前, 我会先介绍一个架构, 这就是为了将技术复杂性与领域的复杂性分开.

它被称为"onion architecture":

该体系结构使用简单的规则将软件划分为多个层: 外层可以依赖于较低层, 但较低层中的代码不依赖于外层中的任何代码.

  • 架构的核心是一个 领域模型(包含业务规则), 仅使用纯函数实现(易于测试)
  • 命令处理程序 可以使用领域模型并仅通过 repository 与外部通信(易于模拟)
  • 最外层可以访问所有内层, 它提供了 repository 接口的实现, 系统的入口点(REST API), 与数据库的连接(事件存储)等.

应用程序的表示, 持久性和域逻辑问题将以不同的速率和不同的原因发生变化; 分离这些问题可以适应变化, 而不会对代码库中不相关的区域造成不良影响.(Patterns, Principles and Practices of DDD)

这种架构的另一个优点是它更利于定义你的目录结构:

注意: 在大多数onion architecture的例子中, "commandHandlers"通常是应用层的一部分. 但是, 在我的例子中, 处理命令是这一层目前唯一正在做的事情, 所以我决定将它称之为"commandHandlers"(如果将来我还需要用它处理更多的东西, 我可能会将它重命名为"application") 如果你听说过"clean architecture"或"hexagonal architecture"(端口和适配器), 请注意它们与"onion architecture"几乎相同.

Authentication & formation of a command

命令是被用户请求的一些更改所触发的对象.

它通常与结果事件一一对应:

CreateJournal => JournalCreated
AddJournalEditor => JournalEditorAdded
ConfirmJournalEditor => JournalEdditorConfirmed
...
复制代码

但它有时会触发多个事件:

ReviewArticle => [ArticleReviewed, ArticlePromoted, ArticleAccepted]
ReviewArticle => [ArticleReviewed, ArticleRejected]
复制代码

有许多方法可以生成和处理命令, 但是对于这个项目, 我使用一个简单的 REST 端口(/command)来接受JSON对象:

{
  name: 'AddJournalEditor',
  payload: {
    journalId: 'journal-1',
    editorInfo: {
      email: 'editor@gmail.com'
    },
    timestamp: 1511865224832
  }
}
复制代码

此对象通过POST请求被接收, 然后转换为:

{
  userId: 'xyz',
  payload: {
    journalId: 'journal-1',
    editorInfo: {
      email: 'editor@gmail.com'
    },
    timestamp: 1511865224832
  }
}
复制代码

注意: userId 属性的背后是一个完整的身份验证过程,这不是一件容易的事。为此,我决定使用 Auth0 服务(类似于"Firebase身份验证"或"Amazon Cognito"), 但当然, 你可以使用自己的实现. 这里需要注意的是命令处理程序不会因身份验证的复杂性而变得臃肿, 并且假设发送 userId 的服务是可信的.

然后将命令对象(包含 userId)传递给适当的命令处理程序(由命令名称找到).

以下是此过程的简化示例:

Command handler — validating the input data

正如 CQRS常见问题解答中所述, 这是命令处理程序遵循的常见步骤序列( 与原始版本略有不同):

  1. 验证自身行为的命令
  2. 验证聚合当前状态的命令
  3. 如果验证成功, 尝试保留新事件. 如果在此步骤中存在并发冲突, 放弃或重试

在第一步("验证自身行为的命令"), 命令处理程序检查命令对象是否有任何缺失的属性, 无效的电子邮件, URL等.

为此, 我正在使用 io-ts ——一种用于IO验证的运行时类型系统, 它与 TypeScript 兼容(但也可以在没有它的情况下使用).

它通过组合这些简单类型来工作(完整示例):

更复杂的命令类型(完整示例):

然后验证 REST API 发送的输入数据.

注意: 如果验证成功, TypeScript 将推断命令的类型:

此时, 命令处理程序必须执行第二步: "根据聚合的当前状态验证命令".

或者换句话说, 它必须决定应该存储哪些事件, 或者应该通过抛出错误来拒绝命令(如果某些业务规则被破坏).

该决定是在 领域模型 的帮助下做出的.

Using a domain model to check business rules

领域模型是应用程序定义业务规则的一部分. 它的实现应该尽可能简单(因此即使是非程序员也可以理解)并与系统的其余部分分离(这是 onion architecture 模式的重点).

继续"adding an editor"的示例, 这是一个 命令处理程序(函数的高亮部分使用了领域模型):

addEditor 属于 journal 聚合, 它的实现是一个简单的纯函数, 返回结果事件或抛出错误(如果任何业务规则被破坏):
参数 userInfotimestamp 源自 命令对象. "聚合的当前状态"由 userjournal 对象表示, 这些对象使用 Repository 检索.

注意: 如果你不喜欢看到硬编码的字符串, 请记住我正在使用 TypeScript, 如果不以正确的方式使用, 它会让你抓狂:

除了编译时错误之外, 使用"重命名符号功能"重命名任何属性或字符串适用于项目中的任何文件(在vs code中测试).

Retrieving the current state of the aggregate with a repository

userStatejournalState 使用被注入的依赖项( userRepositoryjournalRepository)检索:
这些存储库通常包含一个名为 getById 的方法.

这个方法的作用你应该已经猜到了, 通过传入的 id 得到一个聚合状态.

因此, 对于 journal 聚合, 它应该返回这种类型的对象:

但是, 事件存储对 journal 聚合的格式一无所知, 如图所示, 它只存储事件:
这就是我必须使用 reducer 将这些事件转换为必需状态的原因.

注意: 请记住, 为了获得当前的聚合状态, 你不必使用 Event Sourcing. 有时它更适合检索一个完整的对象(使用 MongoDB 或类似的东西)并跳过部分减少和保存事件. 但是, 如果你像我一样, 希望你的模型"flexible"(这样你就可以在以后轻松更改聚合状态的格式), 你必须处理"reducers".

reducer 只是一个(纯)函数(类似于 Redux reducer), 也在 领域模型 中定义:

* 注意: 同样,使用 TypeScript, 您可以安全地使用硬编码字符串, 其中对于每种情况的事件类型都会被推断出来:

Saving events in the Event Store

命令处理程序的最后一步是: 如果验证成功, 则尝试保留新事件. 如果在此步骤中存在并发冲突, 就放弃或重试.

除了聚合状态之外, 存储库还将返回 save 函数, 然后该函数用于持久化事件:

在hook下, 使用乐观并发控制来保持事件以确保一致性(类似于 mongoDB approach, 但没有"自动重试").

注意: 我使用的乐观锁基于写入端检索的事件版本而不是读取端. 这是根据我的领域做出的有意识的决定, 如果你试图使用这个解决方案在你的项目上, 请确保你理解 tradeoffs(我不会在这篇文章中解释, 因为它已经足够长了) 但是, 如果你决定使用从读取端检索的版本, 你可以这样传递版本号: save(events, expectedVersion)

Summary of the application flow on the write side

  1. 命令 是用户发送的对象(来自UI)
  2. REST API 接收命令并处理用户身份验证
  3. 将"已验证的命令"发送到 命令处理程序
  4. 命令处理程序repository 发出 聚合状态 请求
  5. Repository事件存储 中检索事件,并使用 领域模型 中定义的 reducer 将它们转换为聚合状态
  6. 命令处理程序 使用响应结果事件的 领域模型 验证聚合当前状态的命令
  7. 命令处理程序 将结果事件发送到 repository
  8. Repository 尝试在事件存储中保留接收的数据, 同时使用乐观锁确保一致性

The Read Side

使用事件重新创建聚合状态既不复杂也不昂贵.

但是, 事件存储不适合跨聚合查询. 例如, 像"选择名称以xyz开头的所有日志"这样的查询将需要重建所有的聚合, 过于昂贵(更不用说在单一的 CRUD 应用程序中一些更复杂的查询, 这是"资金消耗"的重要来源).

这是读取端需要解决的问题.

简而言之, 读取端监听从写入端发布的事件, 将这些事件作为对本地模型的更改进行投影, 并允许在该模型上进行查询.(source)

通过构建维护专用于任何类型查询的数据库的模型(或多个模型), 你可以节省大量处理能力。

我认为这值得重申:

If your your hosting bill is unjustifiably YUGE! mostly due to complex queries - you should consider CQRS/ES architecture.

由于读取端总是"落后"写入端(即使只有几分之一秒), 应用程序变得"最终一致". 这就是为什么它更便宜, 更易于扩展, 但这也是为什么写入方面与单一 CRUD 应用程序相比更复杂的原因.

Conclusion

我喜欢在事件中思考. 它使我专注于领域而不是数据库架构, 甚至不必是程序员也能理解这些. 这使你更容易与领域专家沟通(DDD 的很大一部分).

此外, 这种架构的性质迫使我不要将一致性视为理所当然, 因此我从中了解了更多信息(这在使用微服务时非常有用).

但是, 使用这些模式都有其成本. 如果你觉得没有必要全部使用, 也许你可以使用它的一部分.

正如我已经提到的那样; CQRS 模式可以在没有 Event Sourcing 的情况下使用, 你也不必遵循"onion architecture"或使用类型系统. 例如, 你可以:

  • 使用类似的模型, 其中事件被 NoSQL 数据库中持久存储的对象替换(无事件源)
  • 使用写入模型中的 reducers 进行客户端查询(无 CQRS)
  • 用 onion architecture 更容易(通过在"开发阶段"模拟基础设施层)地构建无服务器应用程序(使用"lamdas"或"cloud functions")
  • 以类似的方式使用类型, 其中领域以一种精细的, 自我记录的方式呈现(类型优先开发)
  • 使用运行时类型系统进行IO验证(如io-ts)
  • ...

对于这些, 我自己还处于学习阶段, 还没有完成这个项目. 如果你对展示的模式或项目本身有任何建议, 请随时发表评论, 发布问题或亲自与我联系.

Resources

  • Martin Fowler — Event Sourcing (video, article)
  • Martin Fowler — CQRS (article)
  • Greg Young — Event Sourcing (video)
  • Alberto Brandolini — Event Storming (article)
  • Chris Richardson — Developing microservices with aggregates (video)
  • Scott Millett — Patterns, Principles, and Practices of DDD (book)
  • CQRS.nu — FAQ (article)
  • MSDN — Introducing Event Sourcing (article)
  • Scott Wlaschin — Domain Driven Design (video, article)
  • Mark Seemann — Functional architecture is Ports and Adapters (article, video)
  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值