发布于 2026-01-06 8 阅读
0

如何从零开始在 Rails 中构建事件溯源模式 回顾:什么是事件溯源?本文涵盖的内容 启动并运行 Rails 应用 设置测试环境以测试事件 什么是事件? Events::BaseEvent user_events 表和 Events::User::BaseEvent 使用 Events::User::Created 创建新用户 使用 Events::User::Destroyed 删除用户 结论 后续内容 参考资料

如何在 Rails 中从零开始构建事件溯源模式

总结一下:什么是事件溯源?

本文涵盖的内容

启动并运行我们的 Rails 应用程序

搭建测试环境以测试我们的事件

什么是事件?

Events::BaseEvent

桌子user_events,以及Events::User::BaseEvent

创建新用户Events::User::Created

使用以下方式销毁用户Events::User::Destroyed

结论

接下来

参考

此演示的所有代码都可以在此 GitHub 仓库中找到:
https://github.com/isalevine/event-sourcing-user-app

总结一下:什么是事件溯源?

事件溯源是一种系统设计模式,它强调通过不可变事件记录数据的变化。

换句话说:每次数据发生变化时,您都需要将详细信息保存到数据库中。

这些事件永远不会改变或消失。这样,您就拥有了数据如何达到当前状态的永久、不变的历史记录!

本文涵盖的内容

我们将主要借鉴Kickstarter 的活动筹资案例。

要创建我们的事件模式,我们将采取以下步骤:

  • 启动并运行我们的 Rails 应用
    • User模型和控制器
    • 我们的数据库是 PostgreSQL。
  • 设置测试环境以测试我们的事件
    • Postico 将检查我们的数据库
    • REST 客户端失眠症
  • 添加我们的事件模式
    • 什么是事件?我们将把哪些事件数据存储在数据库中?
    • 其他 Event 类将继承的 BaseEvent
    • Events::User::Created
    • Events::User::Destroyed

启动并运行我们的 Rails 应用程序

接下来,我们来创建新的 Rails 应用。

我们将使用 PostgreSQL 设置数据库,--database=postgresql并使用 跳过测试--skip-test,因为我们稍后将手动添加 RSpec。



rails new event-sourcing-user-app --database=postgresql --skip-test


Enter fullscreen mode Exit fullscreen mode

让我们添加我们的User模型

我们的User模型将包含多个字段:

  • name细绳,
  • email细绳,
  • password_digest字符串(用于 bcrypt)
  • deleted布尔值(请记住,事件溯源的一部分原则是我们永远不会删除数据——相反,我们会将某些用户标记为删除,并相应地限定查询范围)
    • 此字段也需要是null: false,并且设置为default: false

我们将从 Rails 单行命令开始:



rails g model User name email password_digest deleted:boolean


Enter fullscreen mode Exit fullscreen mode

在新迁移中,调整t.boolean :deletednull: falsedefault: false



# db/migrate/20200502025357_create_users.rb

class CreateUsers < ActiveRecord::Migration[6.0]
  def change
    create_table :users do |t|
      t.string :name
      t.string :email
      t.string :password_digest
      t.boolean :deleted, null: false, default: false

      t.timestamps
    end
  end
end


Enter fullscreen mode Exit fullscreen mode

添加User控制器和路由

我们的用户控制器需要有两个操作,一个是事件处理操作,另一个createdestroy事件创建操作,用来处理我们想要创建的事件。

让我们手动创建控制器,因为我们不需要生成任何视图。在 `<controller>` 标签中app/controllers,创建一个 `<controller>` 标签users_controller并添加`<action> def create` 和def destroy`<action>` 操作:



# app/controllers/users_controller.rb

class UsersController < ApplicationController
  def create
  end

  def destroy
  end
end


Enter fullscreen mode Exit fullscreen mode

由于我们尚未实现身份验证,因此我们还会添加一个skip_before_action钩子,以便更轻松地测试我们的代码:



# app/controllers/users_controller.rb

class UsersController < ApplicationController
  skip_before_action :verify_authenticity_token, only: [:create, :destroy]

  def create
  end

  def destroy
  end
end


Enter fullscreen mode Exit fullscreen mode

接下来,让我们手动添加 POST 和 DELETE 路由,它们将分别跳转到控制器中的`get`create和 `get`操作:destroy



# config/routes.rb

Rails.application.routes.draw do
  post 'users/create', to: 'users#create'
  delete 'users/destroy', to: 'users#destroy'
end


Enter fullscreen mode Exit fullscreen mode

在控制台中运行rails routes以下命令,查看路由是否已正确设置:



[13:29:44] (master) event-sourcing-user-app
// ♥ rails routes 

Prefix           Verb     URI Pattern                 Controller#Action
users_create     POST     /users/create(.:format)     users#create
users_destroy    DELETE   /users/destroy(.:format)    users#destroy


Enter fullscreen mode Exit fullscreen mode

运行数据库迁移

现在,让我们按照通常的两步流程创建数据库并运行迁移:



rails db:create
rails db:migrate


Enter fullscreen mode Exit fullscreen mode

搭建测试环境以测试我们的事件

设置 Postico 以查看我们的 PostgreSQL 数据库

如果您还不熟悉Postico,它是一款用于 PostgreSQL 的数据库管理工具和查看器,并提供很棒的免费试用版。

从他们的网站下载并安装,然后打开。接下来,使用其默认设置Connect进行操作:localhost

Postico 的主页

点击localhost顶部的按钮,即可查看可用数据库列表:

本地主机内的 Postico 登陆页面

现在,我们应该可以选择我们的开发数据库了:

Postico页面列出了可用的数据库

选择我们的users餐桌:

Postico 页面位于开发数据库中,显示用户表

太好了——我们的用户模型就在这里,它有四个字段:

Postico 用户表

配置 Insomnia 发送 HTTP 请求

同样,如果您不熟悉Insomnia,它是一个用于发送 HTTP 请求以测试 RESTful API 的工具。我们将使用Insomnia Core

下载、安装并打开它:

失眠症核心主页

为我们的项目创建一个文件夹event-sourcing-user-app

失眠症显示新项目文件夹

让我们创建第一个请求。我们将使用 POST 请求,并将其用于创建用户路由:

失眠症显示新请求被设置为 POST

最后,我们将目标 URL 设置为localhost:3000/users/create稍后进行测试的地址:

失眠症显示创建用户请求的目标 URL

太好了,现在 Insomnia 已经准备就绪!创建好事件后,我们只需要在请求正文中添加一个哈希值即可。

测试该create动作与byebug失眠

byebug您可以通过在控制器操作中添加以下代码来测试路由:



# app/controllers/users_controller.rb

def create
  byebug
end


Enter fullscreen mode Exit fullscreen mode

启动Insomnia rails s,并向目标服务器发送 POST 请求localhost:3000/users/create。在控制台中,您将看到byebug会话信息:

屏幕截图显示了 Insomnia 请求,以及 byebug 会话中的控制台。

太好了,我们的路线运行正常!

现在,我们准备构建事件模式!

什么是事件?

在我们的事件溯源系统中,每个 Event 都是一个 Rails 模型,用于存储有关数据更改的信息

我们的目标是举办两项赛事:

  • Events::User::Created——这将记录:
    • payload:包含用于创建用户的参数name哈希emailpassword
    • user_id:创建的用户,在其关系id中使用belongs_to
    • event_type:一个字符串,表示这user_event是“已创建”类型
    • 时间戳
  • Events::User::Destroyed——这将记录:
    • payloadid:包含要标记为已删除用户的哈希值
    • user_id:目标用户的,在其关系id中使用belongs_to
    • event_type:一个字符串,表示这user_event是“已销毁”类型
    • 时间戳

当我们的 Rails 应用创建或销毁用户时,也会触发创建一个新的事件。

这些事件将被保存到我们的数据库中,并且不可更改,作为永久变更日志。

由于我们最终可能会有很多与用户相关的事件,因此我们也在event_type用户事件中添加了该字段,以便我们可以将它们全部存储在一个user_events表中——并且以后可以轻松地添加更多!

Events::BaseEvent

我们的事件将通过继承构建。在继承链的顶端,我们将定义Events::BaseEvent大部分事件功能所在的位置。

由于我们所有的事件都将是 Rails 模型,请继续在内部创建一个新/events目录app/models

现在我们可以创建基础事件了:



# app/models/events/base_event.rb

class Events::BaseEvent < ActiveRecord::Base
end


Enter fullscreen mode Exit fullscreen mode

abstract_class

由于 BaseEvent 仅用于继承,我们可以将其设为非abstract_class空类型,以便 Rails 知道不要尝试为其加载任何记录:



# app/models/events/base_event.rb

class Events::BaseEvent < ActiveRecord::Base
  self.abstract_class = true
end


Enter fullscreen mode Exit fullscreen mode

apply(aggregate)apply_and_persist

每个事件都需要定义自己的apply方法。该方法会接收一个aggregate对象(在本例中是 User 模型),并更新其属性。
(该术语aggregate源自 Kickstarter 的众筹系统,您可以在这里了解更多信息。简而言之,aggregates对象是接收更改的模型events。)

在 BaseEvent 上,我们只需引发一个异常NotImplementedError。这将强制我们在每个事件上显式定义它,从而通过继承覆盖错误。

BaseEvent 还会有一个before_create钩子调用 `getUpdate() apply_and_persist`。这将调用 `getUpdate()` apply,然后save!更新数据库。
(它还会设置事件的 `id` aggregate_id,特别是对于 Created 事件,因为在调用 `getUpdate id()` 之前 `id` 并不存在save!。)

我们来看一下要添加的代码:



# app/models/events/base_event.rb

before_create :apply_and_persist

def apply(aggregate)
  raise NotImplementedError
end

private def apply_and_persist
  # Lock the database row! (OK because we're in an ActiveRecord callback chain transaction)
  aggregate.lock! if aggregate.persisted?

  # Apply!
  self.aggregate = apply(aggregate)

  #Persist!
  aggregate.save!

  # Update aggregate_id with id from newly created User
  self.aggregate_id = aggregate.id if aggregate_id.nil?
end


Enter fullscreen mode Exit fullscreen mode

after_initializeevent_type

无论我们实例化哪种类型的事件,都有几个属性需要立即设置:

  • event_typeuser_events— 每个事件都需要明确分类,才能作为BaseEvent记录存储在表中。
  • payload— 由于我们始终期望payload以哈希形式访问(并以 JSON 格式存储在 PostgreSQL 数据库中),因此我们将添加一个运算符,用于在事件不接受任何参数时||=将其设置为 true。{}

所以,我们将添加一个after_initialize钩子来设置这些属性:



# app/models/events/base_event.rb

after_initialize do
  self.event_type = event_type
  self.payload ||= {}
end

def event_type
  self.attributes["event_type"] || self.class.to_s.split("::").last
end


Enter fullscreen mode Exit fullscreen mode

上面我们定义了,如果从我们的数据库加载,event_type则可以通过快速访问其自身类型attributes;或者在首​​次创建时,从 Event 类的名称推断其类型。

self.payload_attributes(*attributes)

在我们创建的每个事件类中,我们都希望可以选择定义payload_attributes我们想要记录的内容。

在 BaseEvent 事件中,self.payload_attributes我们将创建有效负载字段的 getter 和 setter:



# app/models/events/base_event.rb

def self.payload_attributes(*attributes)
  @payload_attributes ||= []

  attributes.map(&:to_s).each do |attribute|
    @payload_attributes << attribute unless @payload_attributes.include?(attribute)

    define_method attribute do
      self.payload ||= {}
      self.payload[attribute]
    end

    define_method "#{attribute}=" do |argument|
      self.payload ||= {}
      self.payload[attribute] = argument
    end
  end

  @payload_attributes
end


Enter fullscreen mode Exit fullscreen mode

最终,这将允许我们在每个新的 Event 类的顶部定义如下属性:payload_attributes :name, :email, :password

find_or_build_aggregate

我们希望事件能够感知其聚合体(在本例中为目标用户),并能够查找该聚合体或创建一个新的聚合体。

我们将添加一个钩子(在生命周期的早期阶段before_validation调用),该钩子将根据事件的初始化参数中是否提供了 a 来查找或创建聚合:.createuser_id



# app/models/events/base_event.rb

before_validation :find_or_build_aggregate

private def find_or_build_aggregate
  self.aggregate = find_aggregate if aggregate_id.present?
  self.aggregate = build_aggregate if self.aggregate.nil?
end

def find_aggregate
  klass = aggregate_name.to_s.classify.constantize
  klass.find(aggregate_id)
end

def build_aggregate
  public_send "build_#{aggregate_name}"
end


Enter fullscreen mode Exit fullscreen mode

aggregate设置者、获取者和获取其名称者

为了完善事件的功能,我们需要一些设置器和获取器,以及一些可以轻松返回其类型或类名的方法:

  • aggregate=(model)并将aggregate设置并获取用户我们的事件目标
  • aggregate_id=(id)并将aggregate_id映射到user_id我们user_events桌子上的字段。
  • self.aggregate_name使 Event 类能够感知其belongs_to关系的关联目标类(#=> User
  • delegate :aggregate_name, to: :class将返回聚合类名的符号(#=> :user
  • def event_klass会将我们的 Event 类的::BaseEvent命名空间转换为其相应的事件类型(#=> Events::User::Created


# app/models/events/base_event.rb

def aggregate=(model)
  public_send "#{aggregate_name}=", model
end

def aggregate
  public_send aggregate_name
end

def aggregate_id=(id)
  public_send "#{aggregate_name}_id=", id
end

def aggregate_id
  public_send "#{aggregate_name}_id"
end

def self.aggregate_name
  inferred_aggregate = reflect_on_all_associations(:belongs_to).first
  raise "Events must belong to an aggregate" if inferred_aggregate.nil?
  inferred_aggregate.name
end

delegate :aggregate_name, to: :class

def event_klass
  klass = self.class.to_s.split("::")
  klass[-1] = event_type
  klass.join('::').constantize
end


Enter fullscreen mode Exit fullscreen mode

好的,让我们看看整个过程Events::BaseEvent



# app/models/events/base_event.rb

# Kickstarter code reference:
# https://github.com/pcreux/event-sourcing-rails-todo-app-demo/blob/master/app/models/lib/base_event.rb

class Events::BaseEvent < ActiveRecord::Base
  before_validation :find_or_build_aggregate
  before_create :apply_and_persist

  self.abstract_class = true

  def apply(aggregate)
    raise NotImplementedError
  end

  after_initialize do
    self.event_type = event_type
    self.payload ||= {}
  end

  def self.payload_attributes(*attributes)
    @payload_attributes ||= []

    attributes.map(&:to_s).each do |attribute|
      @payload_attributes << attribute unless @payload_attributes.include?(attribute)

      define_method attribute do
        self.payload ||= {}
        self.payload[attribute]
      end

      define_method "#{attribute}=" do |argument|
        self.payload ||= {}
        self.payload[attribute] = argument
      end
    end

    @payload_attributes
  end

  private def find_or_build_aggregate
    self.aggregate = find_aggregate if aggregate_id.present?
    self.aggregate = build_aggregate if self.aggregate.nil?
  end

  def find_aggregate
    klass = aggregate_name.to_s.classify.constantize
    klass.find(aggregate_id)
  end

  def build_aggregate
    public_send "build_#{aggregate_name}"
  end

  private def apply_and_persist
    # Lock the database row! (OK because we're in an ActiveRecord callback chain transaction)
    aggregate.lock! if aggregate.persisted?

    # Apply!
    self.aggregate = apply(aggregate)

    #Persist!
    aggregate.save!
    self.aggregate_id = aggregate.id if aggregate_id.nil?
  end

  def aggregate=(model)
    public_send "#{aggregate_name}=", model
  end

  def aggregate
    public_send aggregate_name
  end

  def aggregate_id=(id)
    public_send "#{aggregate_name}_id=", id
  end

  def aggregate_id
    public_send "#{aggregate_name}_id"
  end

  def self.aggregate_name
    inferred_aggregate = reflect_on_all_associations(:belongs_to).first
    raise "Events must belong to an aggregate" if inferred_aggregate.nil?
    inferred_aggregate.name
  end

  delegate :aggregate_name, to: :class

  def event_type
    self.attributes["event_type"] || self.class.to_s.split("::").last
  end

  def event_klass
    klass = self.class.to_s.split("::")
    klass[-1] = event_type
    klass.join('::').constantize
  end

end


Enter fullscreen mode Exit fullscreen mode

桌子user_events,以及Events::User::BaseEvent

我们之前提到过,我们将把多种类型的用户相关事件存储在一个user_events表中。

为了实现这一点,并方便我们以后添加更多事件,我们将创建一个Events::User::BaseEvent事件管理器,指示Events::User::命名空间中的所有事件保存到user_events表中。我们还将belongs_to在此处定义与用户的关系。

user_events桌子

接下来,我们将user_events在数据库中创建表格。

Kickstarter 的事件溯源示例描述了每个aggregate用户都有一个事件表user_events。这些事件表将具有类似的结构——我们将对其进行一些调整以符合我们的描述:

每个聚合(例如:订阅)都关联一个事件表(例如:subscription_events)。
……
与某个聚合相关的所有事件都存储在同一个表中。所有事件表都具有类似的模式:
id, aggregate_id, type, data (json), metadata (json), created_at

我们将对代码进行一些调整:

  • aggregate_id将被取代user_id
  • type将被event_type(为了更明确地说明)取代
  • data将被替换为payload,并且仍为 JSON 类型。
  • metadata由于我们的活动相对简单,因此目前暂不包含在内。
  • created_at不会包含在内,因为我们将直接使用 ActiveRecord 的默认时间戳。

我们将user_events使用 Rails 迁移来创建表格:



rails g migration CreateUserEvents


Enter fullscreen mode Exit fullscreen mode

这将创建一个已create_table为我们设置的迁移块:



# db/migrate/20200502192018_create_user_events.rb

class CreateUserEvents < ActiveRecord::Migration[6.0]
  def change
    create_table :user_events do |t|
    end
  end
end


Enter fullscreen mode Exit fullscreen mode

我们想添加四个字段:

  • belongs_to与……的关系:user
  • 一个event_type字符串
  • 一个payloadJSON
  • 时间戳


# db/migrate/20200502192018_create_user_events.rb

class CreateUserEvents < ActiveRecord::Migration[6.0]
  def change
    create_table :user_events do |t|
      t.belongs_to :user, null: false, foreign_key: true
      t.string :event_type
      t.json :payload

      t.timestamps
    end
  end
end


Enter fullscreen mode Exit fullscreen mode

运行迁移:



rails db:migrate


Enter fullscreen mode Exit fullscreen mode

打开 Postico 查看新user_events表格:

Postico 页面显示了已选择的 user_events 表。

我们的餐桌和田地都准备就绪了!

Postico 页面显示 user_events 表字段

Events::User::BaseEvent

在我们的目录下app/models/events,创建一个新user目录。

在该目录下创建一个新文件base_event.rb。这将为我们提供创建此类所需的命名空间:



# app/models/events/user/base_event.rb

class Events::User::BaseEvent < Events::BaseEvent
  self.table_name = "user_events"
end


Enter fullscreen mode Exit fullscreen mode

有了它self.table_name = “user_events”,我们创建的任何继承自该类的新 Event 类Events::User::BaseEvent都会自动保存并从user_events表中检索!

belongs_to :userhas_many :events

由于我们所有与用户相关的事件都以用户为目标,因此has_many / belongs_to在命名空间中创建用户和事件之间的关系是有意义的Events::User::

由于我们身处一个使用了相同名称的命名空间深处User,为了让 Rails 查找常规的顶级User模型,我们需要::在类名前添加 `@classnames`。这会告诉我们的 `@classnames`has_many和 `@ belongs_toclassnames` 关系在当前命名空间之外查找。

让我们更新我们的类Events::User::BaseEventUser使其包含这些关系:



# app/models/events/user/base_event.rb

class Events::User::BaseEvent < Events::BaseEvent
  self.table_name = "user_events"

  belongs_to :user, class_name: "::User"
end


# app/models/user.rb

class User < ApplicationRecord
  has_many :events, class_name: "Events::User::BaseEvent" 
end


Enter fullscreen mode Exit fullscreen mode

太好了!现在,当我们把用户加载到一个user变量中时,我们就可以调用函数user.events从表中加载所有相关的事件了user_events

我们现在已经准备好创建一些真正可用的活动了!

创建新用户Events::User::Created

有了 BaseEvent 模式,我们现在可以构建我们的第一个事件了!

Events::User::Created将记录用于创建用户的参数、新用户的 ID 以及事件的时间戳。

构建Events::User::Created班级

在 中app/models/events/user,创建一个新created.rb文件。我们的类将继承自Events::User::BaseEvent同一目录下的 类:



# app/models/events/user/created.rb

class Events::User::Created < Events::User::BaseEvent
end


Enter fullscreen mode Exit fullscreen mode

正如我们在顶层定义的那样Events::BaseEvent,我们必须定义一个apply方法,该方法将接受一个 User 实例作为其aggregate参数:



# app/models/events/user/created.rb

class Events::User::Created < Events::User::BaseEvent
  def apply(user)
  end
end


Enter fullscreen mode Exit fullscreen mode

既然我们知道创建用户需要参数,包括 getter name、 setteremailpasswordgett,我们也可以将它们作为符号列表添加到payload_attributesgetter 和 setter 中:



# app/models/events/user/created.rb

class Events::User::Created < Events::User::BaseEvent
  payload_attributes :name, :email, :password

  def apply(user)
  end
end


Enter fullscreen mode Exit fullscreen mode

向该apply方法添加逻辑

事件apply方法中的逻辑是事件强大之处的所在。它:

  • 接收一个用户实例
  • 将更改应用于由用户实例提供的用户实例。payload_attributes
  • 返回已修改的 User 实例 =>顶级 BaseEvent 在这里接收 User 实例,并调用函数save!将更改持久化到数据库中!

由于我们收到了传递给 `getUser()` 的属性列表payload_attributes,我们可以直接在方法内部调用这些属性apply来更新 User 实例:



# app/models/events/user/created.rb

payload_attributes :name, :email, :password

def apply(user)
  user.name = name
  user.email = email
  user.password_digest = password

  user
end


Enter fullscreen mode Exit fullscreen mode

完美!现在,我们只需要告诉 Insomnia 传递包含 `<string>`、`<string>` 和字符串的参数nameemail我们password的事件就会将它们映射到 User 模型的 `<string>` nameemail`<string>` 和 ` password_digest<string>` 字段。
(记住: `<string> password_digest` 与功能相关bcrypt,我们将在另一篇文章中探讨。)

更新控制器,使其能够创建事件并使用强参数。

回到我们的系统中users_controller,我们需要更新两件事:

  • create需要采取行动Events::User::Created.create(payload: user_params)
  • 添加强参数以保护user_params我们将要传递的内容.create(payload: user_params)

对于强参数,我们将要求键中user_params包含 ` name, email` 和 `,`,并且password这些参数嵌套在user键中:



# app/controllers/users_controller.rb

private def user_params
  params.require(:user).permit(:name, :email, :password)
end


Enter fullscreen mode Exit fullscreen mode

现在我们可以安全地进入user_params行动Events::User::Created.create(payload: user_params)阶段create了:



# app/controllers/users_controller.rb

def create
  Events::User::Created.create(payload: user_params)
end

private def user_params
  params.require(:user).permit(:name, :email, :password)
end


Enter fullscreen mode Exit fullscreen mode

让我们用 Insomnia 和 Postico 来测试一下我们的活动吧!

如果我们通过 POST 请求发送正确的参数localhost:3000/users/create,我们预期会出现以下几种情况:

  • 表格中新增一条记录user_events,内容如下:
    • event_type “Created”
    • payloaduser_params
      • 请注意,数据password将以明文形式存储 =>这是不安全的行为,因为我们尚未实现 bcrypt 加密!
    • user_id使用新创建的用户id
  • 表格中新增一条记录user,内容如下:
    • 正确的name
    • 正确的email
    • password_digest这是明文password=>这是不安全的行为,因为我们还没有实现 bcrypt 加密!

我们来测试一下!

启动rails s并打开失眠症。

在我们的Create User请求中,将请求体设置为 JSON:

失眠页面显示身体类型设置为 JSON

然后,创建一个 JSON 哈希,其中包含一个键,该”user”指向一个包含以下元素的”name”哈希”email””password”

失眠页面显示包含用户参数的 JSON 正文

现在点击Send,让我们来看看数据库表!

首先,让我们看看user_events表中是否有事件记录:

Postico 表格包含第一个 Created 事件记录,叠加在 Insomnia 请求正文上。

目前一切顺利!
(请记住:以明文形式存储密码是不安全的行为,这是因为我们尚未实现 bcrypt 加密!

现在,我们来看一下users表格:

Postico 表包含第一个用户记录,叠加在 Insomnia 请求正文上。

太棒了!我们现在有了新用户,ongo_gablogian以及创建他的事件和参数记录!

《费城永远阳光灿烂》中丹尼·德维托饰演的翁戈·加布洛吉安(Ongo Gablogian)的动图,该角色是对安迪·沃霍尔的戏仿。

好了!我们的事件溯源系统现在可以捕获数据的变化了!

只要我们不更改表格中的数据user_events,我们就能可靠地记录数据是如何达到当前状态的!

《发展受阻》中“任务完成”横幅的截图

使用以下方式销毁用户Events::User::Destroyed

现在我们已经有了模式,创建一个新事件并将其记录到我们的user_events表中就非常简单了!

由于我们不希望丢失数据,因此我们deleted在 User 模型中实现了一个布尔字段。当创建一个新用户时,该字段的默认值为 true false

让我们创建一个新事件,Events::User::Destroyed该事件会将deleted字段设置为true

创建app/models/events/user/destroyed.rb文件

在与我们类相同的目录下Events::User::Created,创建一个等效的Events::User::Destroyed类:



# app/models/events/user/destroyed.rb

class Events::User::Destroyed < Events::User::BaseEvent
  def apply(user)
    user
  end
end


Enter fullscreen mode Exit fullscreen mode

上面,我们首先介绍一个简单的apply方法,该方法仅返回传入的 User 实例。

要删除用户,我们只需要一个删除操作id。让我们添加payload_attributes删除操作:



# app/models/events/user/destroyed.rb

class Events::User::Destroyed < Events::User::BaseEvent
  payload_attributes :id
end


Enter fullscreen mode Exit fullscreen mode

我们将使apply方法更新传入的 Userdeleted字段为true



# app/models/events/user/destroyed.rb

class Events::User::Destroyed < Events::User::BaseEvent
  payload_attributes :id

  def apply(user)
    user.deleted = true

    user
  end
end


Enter fullscreen mode Exit fullscreen mode

好了——我们的新活动完成了!

更新destroy操作users_controller

在我们的代码中users_controller,我们将destroy通过创建一个新Events::User::Destroyed事件来执行我们的操作。

由于我们在顶层 BaseEvent 中定义了find_or_build_aggregateand方法,如果提供了参数,此事件将自动查找 User 。aggregate_id”Destroyed”user_id

首先,让我们id在强参数列表中添加以下内容user_params



# app/controllers/users_controller.rb

private def user_params
  params.require(:user).permit(:name, :email, :password, :id)
end


Enter fullscreen mode Exit fullscreen mode

现在,我们的控制器destroy操作可以接受一个user_params包含必要信息的事件id。我们还将使用它user_params[:id],以便事件可以查找目标用户的记录:



# app/controllers/users_controller.rb

def destroy
  Events::User::Destroyed.create(user_id: user_params[:id], payload: user_params)
end


Enter fullscreen mode Exit fullscreen mode

我们准备开始对失眠症进行测试!

在 Insomnia 中测试 DELETE 请求

让我们开始吧rails s

在 Insomnia 中,创建一个名为Destroy User“delete”的新请求:

失眠页面显示新的“销毁用户”操作被设置为类型“删除”。

将其目标 URL 设置为localhost:3000/users/destroy

失眠页面显示删除请求的目标 URL

将正文类型设置为 JSON,并添加一个哈希表,该”user”哈希表的键指向包含冒号的哈希表”id”

失眠页面显示了包含用户 ID 的 DELETE 请求的 JSON 正文。

点击“发送”,然后检查数据库以查看事件是否已创建:

Postico 用户事件表显示了新的已销毁事件记录,并叠加在 Insomnia 请求上。

最后,让我们检查一下数据库,看看我们的用户是否已deleted设置为true

Postico 用户表仅显示已删除字段设置为 true 的用户记录。

太好了!我们既保留了用户记录,又实现了双重目标deleted——鱼与熊掌兼得!

游戏《传送门》中蛋糕的截图
这可不是谎话!

只需这些步骤即可将新活动添加到我们的活动资源系统中!

结论

哇,我们讲了很多内容!让我们回顾一下我们实现事件溯源系统所采取的步骤:

  • 创建一个新的 Rails 应用,包含 User 模型和控制器,并使用 PostgreSQL 作为数据库。
  • 创建一个Events::BaseEventapp/models/events来处理事件逻辑:
    • 查找或创建聚合(用户)
    • 创建 getter 和 setterpayload_attributes
    • 推断其自身event_type
    • 用于自动应用更改并保存到数据库的钩子
  • 创建user_events表迁移
  • 创建一个表Events::User::BaseEvent,将命名空间中的所有事件保存Events::User::user_events表中。
  • 创建一个Events::User::Created将应用于user_params新用户实例的事件
  • 创建一个Events::User::Destroyed事件,该事件将查找 Userid并将其deleted字段设置为true

这个极简系统使我们能够做到以下几点:

  • 记录创建和销毁用户的事件
  • 永久保留所有用户数据,并可deleted根据需要限定保留范围。
  • 这种模式允许我们轻松添加新事件,并将这些事件保存到同一张user_events表中。

此演示的所有代码都可以在此 GitHub 仓库中找到:
https://github.com/isalevine/event-sourcing-user-app

接下来

我们还有很多工作可以做,以改进我们的事件溯源系统,尤其是在安全性和数据验证方面!下一篇文章我们将探讨以下内容:

  • 在 Event 中安全地存储敏感信息payloads,例如密码
  • 根据 Kickstarter 的示例Commands将创建事件封装在 `<Event>`标签内。
  • 添加验证Commands

参考

特别感谢Philippe CreuxKickstarter分享他们的 Event Sourcing 示例

感谢Martin Fowler事件溯源重要贡献

感谢ArkencyRailsEventStore 库所做的出色工作。

最后,感谢 Dev.to 用户Alfredo Motta多年前分享了这篇文章并一直保留着,让我能够及时了解!)。

文章来源:https://dev.to/isalevine/building-an-event-source-pattern-in-rails-from-scratch-355h