65.9K
CodeProject 正在变化。 阅读更多。
Home

CQRS 设计器

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.97/5 (58投票s)

2015 年 12 月 27 日

CPOL

18分钟阅读

viewsIcon

101064

downloadIcon

1663

一个图形化工具,允许您设计 CQRS 模型并从中生成代码和文档(C# 或 VB.NET)

引言

在 .NET 社区中,阻碍命令查询职责模式(CQRS)和相关事件溯源(ES)技术普及的因素之一是缺乏一种工具,能够以熟悉实体框架用户的方式生成和操作这些模型——也就是说,通过组合图形化方式,然后将最终模型转化为代码。

这个 Visual Studio 插件设计器是弥合这一差距的早期尝试。它允许您以图形化的方式设计领域模型,包括聚合标识符、事件、项目、命令、查询和身份组。

如果您不熟悉事件溯源,我建议阅读 这篇文章 作为起点,或者如果您有 45 分钟时间,还有这个 YouTube 视频。

必备组件

设计器库需要使用 Visual Studio 2015 可视化和建模 SDK,它默认不安装——您需要从 这里 安装它。

它还需要以下 NuGet 包:“Microsoft.Net.Compilers”、“Microsoft.CodeDom.Providers.DotNetCompilerPlatform”、“System.Reflection”和“System.Runtime”。

对于 Visual Studio 2017,您无需单独从链接安装 SDK,而是使用安装程序修改安装,并在“**扩展性**”部分添加“**文本模板**”和“**建模**”组件。

术语

在该工具(以及本文)中,使用了以下术语。我的用法可能与其它 CQRS 文档不完全匹配,因此建议在继续之前快速回顾一下。

聚合标识符

聚合标识符是一个可以唯一标识并发生与之相关的事件的事物。它可以对应一个物理事物(如汽车、办公室、人)或一个逻辑实体,与实体关系模型中的实体完全相同。

发生的一切都只与一种聚合标识符的一个实例有关。此外,每个聚合都必须有一个唯一的标识符,或一个系统提供的唯一密钥(这可能是一个递增的整数或 GUID)。如果存在有业务意义的名称,则可以使用该名称;如果不存在,则简单地命名为“Key”或“Identifier”。

聚合可能包含实例数据成员,但其最纯粹的形式应仅与身份(实例是什么)相关,而不是任何传递状态。

事件

事件是某件对聚合标识符所标识的对象发生的重要事件的记录。

事件按发生顺序存储,这使得事件溯源的最强大功能得以实现——通过重放事件来重建物体在任何给定时间点的状态。(应用事件时聚合状态的这些视图由投影生成。)

每个事件只能链接到一个聚合标识符。

每个事件类型都必须有一个唯一的名称才能被唯一标识。

在后台,可以添加一个序列和时间戳属性来指示事件记录的顺序,以及它与真实世界时间的关联。

Projection

投影接收针对给定聚合标识符记录的事件流,并使用它们来创建对象在任何给定时间点的状态视图,该对象由聚合表示。

一个投影只能应用于一个聚合标识符。如果要投影多个聚合标识符的状态,则必须为每个标识符运行一个单独的投影,但这完全是解耦的操作,因此可以以高度并行的方式执行。

投影可以过滤它处理或不处理的事件——对投影状态没有影响的事件可以被忽略。对于已处理的事件,投影属性可以根据正在处理的事件的属性进行更新。

查询定义/查询处理程序

查询定义定义了如何从系统中获取信息。该定义标识它将运行的聚合、它将提供的返回数据类型以及查询的任何附加参数。

设计查询定义时,应从用户体验的角度进行考虑。实际上,这意味着要专注于用户想要知道什么(以及如何知道),而不是告诉他们什么。

当为查询定义生成代码时,会为定义和处理程序创建单独的类。这使得定义可以作为 MVC(或 MVVM)应用程序中的模型使用,而无需关心实际的查询处理如何在后端发生。

命令定义/命令处理程序

命令定义和处理程序是如何将信息输入系统或引起状态更改的。命令具有定义的参数,这些参数为系统提供额外的数据负载,并且必须唯一标识应用于它的聚合。

每个命令实例都有一个唯一的实例标识符,可用于记录命令的影响。

请注意,命令不一定必须来自人工操作员——任何有意添加事件或引起状态更改的内容都可以表示为命令。

当为命令定义生成代码时,会为定义和处理程序创建单独的类。这使得定义可以作为 MVC(或 MVVM)应用程序中的输入模型使用,而无需关心实际的命令处理如何在后端发生。

身份组

身份组是零个或多个聚合标识符实例的业务含义分组。使用命名身份组是为了让查询定义和投影能够以业务为中心的方式进行组合。

例如,身份组“英超联赛”将标识“足球队”实例的有意义的业务集合,或者身份组“非居民账户”可以标识银行账户的有意义的业务集合。

每个身份组都有自己的底层事件流,其中包含两个非常简单的事件——一个 `IdentityAdded` 事件,用于将已标识的聚合添加到列表中;一个 `IdentityRemoved` 事件,用于添加或删除组中的项目。这个事件流可以被回放,以在任何给定时间点重新生成组的成员资格。

身份组成员资格由一种称为**分类器**的特殊形式的投影来评估。它运行在聚合标识符的事件流上,以决定它们是否属于身份组。

设计领域模型

要创建新的领域模型,请在 Visual Studio 中选择“**添加新项**”菜单,然后选择“**CQRS DSL**”。

此时,您可以设置模型级别的属性来描述正在建模的领域。

从初始的空模型开始,唯一能添加的是新的聚合标识符(因为其他所有内容都链接到聚合标识符)。您可以从工具箱中选择聚合标识符工具,并将其拖到图表窗格上。

完成此操作后,您可以设置该聚合标识符的属性。特别是,您需要设置键数据类型来描述如何唯一标识聚合的实例,如果该标识符具有业务含义,那么您还需要设置键名称以反映这一点。

注释和描述将用于生成领域模型文档,因此最好在其中添加一些详细信息。

下一步是开始向聚合标识符添加事件——它会发生什么,以及在事件发生时我们可以记录哪些信息。

添加尽可能多的事件,无论您当时是否认为它们会直接有用。这个阶段有用的分析会话通常被称为“事件风暴”。

最好给事件起一个过去式的动词名称,并使用有业务含义的术语,而不是描述计算机系统中发生的事件。

一旦定义了一组好的事件,下一步就是定义一些投影,这些投影可以将这些事件转化为任何给定时间点的聚合状态的“视图”。要做到这一点,请将一个投影从工具箱拖到聚合上并设置其属性。

然后使用投影事件连接器工具连接该投影将处理的事件。

您向投影添加属性以存储您希望在此视图中看到的输出属性,并且对于每个处理的事件,您需要设置属性如何受事件影响。

查询定义和命令定义以相同的方式添加到模型中。

对于命令定义,您需要添加所有将传递到系统中以引起状态更改的输入参数。

对于查询定义,您需要定义将传递的输入参数(如果有)以及将返回的输出属性。查询可以设置为返回单个记录,或者,如果您将“**多行结果**”标志设置为 `true`,则返回一个集合。

查询可以指定一个“身份组”在其上运行。这允许您将查询运行的聚合限制为仅属于有业务意义的集合(称为身份组)的成员。

要定义新的身份组,请将身份组图标拖到您想要作为其分组的聚合标识符上。

身份组可以设置为全局组(返回所有已知的聚合实例),或个人组(返回指定的实例(如果存在)),或对于更复杂的场景,可以附加一个分类器。

分类器是一个在聚合的事件流上运行的函数,它根据处理事件流时执行的函数,决定该实例是否属于该组。

与投影类似,分类器在遇到事件时执行操作。这些评估决定了聚合是否属于身份组。

代码生成

(请参阅 下载生成的源代码 获取从模型生成的代码示例。)

目前,此 CQRS 模型有两种可能的代码生成目标语言:C# 或 VB.NET。目标语言以及目标源代码文件夹是领域模型可以设置的属性。

此项目的代码生成部分位于 `CodeGeneration` 文件夹的一个单独项目中。这是为了允许您根据需要进行任何自定义,而无需重新构建和重新部署整个工具。

要运行代码生成过程,请右键单击 CQRS 模型的图表窗格,然后在出现的上下文菜单中选择“**生成代码**”选项。代码将根据您设置的模型设置生成——代码语言为 C# 或 VB.NET。

您会立即注意到创建了大量代码文件。这是因为模型中的每个对象(聚合标识符、事件、查询定义等)都首先生成为接口,然后是实现该接口的局部具体类。这样做的目的是通过快速模拟您需要的类来进行单元测试。

例如,为事件定义生成的代码将是

'------------------------------------------------------------------------------
' <auto-generated>
'     This code was generated by a tool.
'     Runtime Version:4.0.30319.42000
'
'     Changes to this file may cause incorrect behavior and will be lost if
'     the code is regenerated.
' </auto-generated>
'------------------------------------------------------------------------------

Option Strict Off
Option Explicit On

Imports CQRSAzure
Imports CQRSAzure.Aggregation
Imports CQRSAzure.EventSourcing
Imports Football_League.Team

Namespace Football_League.Team.eventDefinition
    
    '''<summary>
    '''A fixture was fulfilled
    '''</summary>
    Partial Public Class GamePlayed
        Inherits Object
        Implements IGamePlayed
        
        #Region "Private members"
        Private _Venue As String
        
        Private _HomeTeamScore As Integer
        
        Private _AwayTeamScore As Integer
        #End Region
        
        '''<summary>
        '''Empty constructor for serialization
        '''This should be removed if serialization is not needed
        '''</summary>
        Sub New()
            MyBase.New
        End Sub
        
        '''<summary>
        '''Create and populate a new instance of this class from the underlying interface
        '''</summary>
        '''<remarks>
        '''This should be called when the event is created from an event stream
        '''</remarks>
        Sub New(ByVal GamePlayedInit As IGamePlayed)
            MyBase.New
            _Venue = GamePlayedInit.Venue
            _HomeTeamScore = GamePlayedInit.HomeTeamScore
            _AwayTeamScore = GamePlayedInit.AwayTeamScore
        End Sub
        
        '''<summary>
        '''Create and populate a new instance of this class from the underlying properties
        '''</summary>
        '''<remarks>
        '''This should be called when the event is created from an event stream
        '''</remarks>
        Sub New(ByVal Venue_In As String, 
               ByVal HomeTeamScore_In As Integer, 
               ByVal AwayTeamScore_In As Integer)
            MyBase.New
            _Venue = Venue_In
            _HomeTeamScore = HomeTeamScore_In
            _AwayTeamScore = AwayTeamScore_In
        End Sub
        
        '''<summary>
        '''Where was the match played?
        '''</summary>
        Public ReadOnly Property Venue() As String
            Get
                Return _Venue
            End Get
        End Property
        
        '''<summary>
        '''How many goals did the home team score?
        '''</summary>
        Public ReadOnly Property HomeTeamScore() As Integer
            Get
                Return _HomeTeamScore
            End Get
        End Property
        
        '''<summary>
        '''How many goals did the away team score?
        '''</summary>
        Public ReadOnly Property AwayTeamScore() As Integer
            Get
                Return _AwayTeamScore
            End Get
        End Property
    End Class
End Namespace

投影的代码包括您定义的属性操作,这些操作在投影处理给定事件时更新投影属性。

'------------------------------------------------------------------------------
' <auto-generated>
'     This code was generated by a tool.
'     Runtime Version:4.0.30319.42000
'
'     Changes to this file may cause incorrect behavior and will be lost if
'     the code is regenerated.
' </auto-generated>
'------------------------------------------------------------------------------

Option Strict Off
Option Explicit On

Imports CQRSAzure
Imports CQRSAzure.Aggregation
Imports CQRSAzure.EventSourcing
Imports Herd.Cow
Imports Herd.Cow.eventDefinition

Namespace Herd.Cow.projection
   
    Partial Public Class Location
        Inherits Object
        Implements ILocation
       
        #Region "Private members"
        Private _In_Shed As Boolean
       
        Private _Location As String
        #End Region
       
        '''<summary>
        '''Is the animal indoors
        '''</summary>
        Public ReadOnly Property In_Shed() As Boolean Implements ILocation.In_Shed
            Get
                Return _In_Shed
            End Get
        End Property
       
        '''<summary>
        '''The name of the location of the animal
        '''</summary>
        Public ReadOnly Property Location() As String Implements ILocation.Location
            Get
                Return _Location
            End Get
        End Property
       
        '''<summary>
        '''Animal was moved to a different field
        '''</summary>
        Public Overloads Sub HandleEvent(ByVal eventToHandle As IMoved_To_Field) _
       Implements CQRSAzure.EventSourcing.IHandleEvent(Of IMoved_To_Field).HandleEvent
            'On Moved To Field, For In Shed, unset the flag
            _In_Shed = False
            'The name of the field the animal was moved to
            _Location = eventToHandle.Moved_To
        End Sub

        '''<summary>
        '''Animal was moved to a shed
        '''</summary>
        Public Overloads Sub HandleEvent(ByVal eventToHandle As IMoved_To_Shed) _
       Implements CQRSAzure.EventSourcing.IHandleEvent(Of IMoved_To_Shed).HandleEvent
            'Flag the animal as being in the shed
            _In_Shed = True
            'On Moved To Shed, For Location, set to the value Shed Name
            _Location = eventToHandle.Shed_Name
        End Sub
    End Class
End Namespace

此外,您在图表上填写的描述和注释设置(如果非空)将作为代码标记注释添加到类中,这样当您使用工具从注释生成文档时,它们将出现在**备注**部分。

您还可以指定事件的日期属性之一是**生效日期**,这意味着该属性可用于对该事件流执行任何“截至特定时间点”的查询。这将被标记为事件生成的代码上的一个属性。

        /// <summary>
        /// The date as of which the account was closed
        /// </summary>
        [CQRSAzure.EventSourcing.EventAsOfDateAttribute()]
        public System.DateTime Date_Closed
        {
            get
            {
                return _Date_Closed;
            }
        }

事件序列化

为了允许在项目生命周期中向事件定义添加或删除属性,我添加了一个增量版本号属性,开发人员可以设置它来表示定义已更改。

此版本号用于生成的部分类文件的文件名,该文件名又可用于控制如何将旧事件反序列化为事件定义的新版本。

此外,还有两种不同的代码生成类型与事件序列化相关。第一种是将二进制流序列化为或从二进制流反序列化,这通常用于将事件流保存在二进制文件中。对于 NoSQL 表等数据存储或人类可读的文件,事件序列化还会生成可以将事件序列化为字典或从字典反序列化的代码,作为 `name::value` 对。

文档生成

(请参阅 下载 Documentation.zip 获取生成的文档示例。)

文档生成部分也位于 `DocumentationGeneration` 文件夹的一个单独项目中。这是为了允许您根据需要进行任何自定义,而无需重新构建和重新部署整个工具。

该文档旨在帮助非程序员理解系统所基于的有效模型。它以(非常基本的)HTML 和一个粗略的级联样式表的形式生成,您可以根据公司标准进行修改。

实现

一旦生成了描述域的代码,您就需要将其放在基础架构代码之上。所有生成的代码都通过接口引用底层基础架构组件,允许您按照自己的意愿进行连接。

为了尽可能地将业务领域类与实际实现细节分开,我通过“包装事件”的概念实现了事件流。

在包装事件中,使用 CQRS 设计器设计的业务特定事件数据被包装在一个提供实例标识(基本上是事件在事件流中的序列号)的类中,该类又被包装在一个提供事件上下文(用户名、时间戳以及您想存储的任何其他关于事件的信息,而不是事件的业务数据)的类中。

''' <summary>
''' Marker interface for an event pertaining to an aggregation
''' </summary>
''' <typeparam name="TAggregate">
''' The type which identifies the aggregation
''' </typeparam>
Public Interface IEvent(Of TAggregate As CQRSAzure.EventSourcing.IAggregationIdentifier)
    Inherits IEvent

End Interface

''' <summary>
''' Interface to allow unique identification of an event
''' </summary>
''' <typeparam name="TAggregate">
''' The type which identifies the aggregation
''' </typeparam>
''' <remarks>
''' These are the infrastructure elements of an event that do not have any business meaning.
''' </remarks>
Public Interface IEventIdentity_
  (Of TAggregate As CQRSAzure.EventSourcing.IAggregationIdentifier)

    ''' <summary>
    ''' Get the identifier by which this events aggregate is uniquely known
    ''' </summary>
    ''' <remarks>
    ''' Most implementation use a GUID for this but if you have a known unique identifier 
    ''' then that can be used instead - e.g. ISBN, CUSIP, VIN etc.
    ''' </remarks>
    Function GetAggregateIdentifier() As String

    ''' <summary>
    ''' The event sequence - this is the order in which the events occurred for the aggregate
    ''' </summary>
    ReadOnly Property Sequence As UInteger

    ''' <summary>
    ''' The event that is identified by this event identity
    ''' </summary>
    ReadOnly Property EventInstance As IEvent(Of TAggregate)

End Interface

''' <summary>
''' Additional context information about an event 
''' </summary>
''' <remarks>
''' Different domains often require additional 
''' context information about events that occurred
''' By having a separate context interface 
''' you can segregate these from the actual event itself
''' </remarks>
Public Interface IEventContext
    Inherits IEventInstance

    ''' <summary>
    ''' Which user caused the event to occur
    ''' </summary>
    ''' <remarks>
    ''' This can be empty in the case of timer or state triggered events
    ''' </remarks>
    ReadOnly Property Who As String

    ''' <summary>
    ''' The time at which this event occurred
    ''' </summary>
    ''' <remarks>
    ''' This should be stored as UTC or have timezone information
    ''' </remarks>
    ReadOnly Property Timestamp As Date

    ''' <summary>
    ''' The source from whence this event originated
    ''' </summary>
    ReadOnly Property Source As String

    ''' <summary>
    ''' Sequence for holding events in a queue or queue-like storage
    ''' </summary>
    ReadOnly Property SequenceNumber As Long

    ''' <summary>
    ''' Any additional comments attached to the event for audit purposes for example
    ''' </summary>
    ReadOnly Property Commentary As String

End Interface

在某些情况下,例如当您在 SQL 数据库之上构建事件流时,很可能每个事件都需要与事件一起存储身份和上下文数据,但在其他情况下,您可能能够从其他源派生身份和上下文。在 Azure Blob 存储中存储事件时,我会为每个聚合创建一个 blob,并可能使用每个事件记录开始处的二进制偏移量作为其标识,从而减少需要存储的实际数据量。

实践示例:银行账户

为了以熟悉的“hello-world”风格展示这如何融入应用程序架构,下面是一个使用此设计器开发的非常基础的银行账户示例。

此领域的中心是银行账户——聚合(实体),可以向其发生事件。在现有的银行业务领域中,每个账户都已经获得了一个唯一的银行账户号码,我们将其存储为字符串。每个银行账户的事件流都由其银行账户号码键唯一标识。

银行账户可能发生的事件是账户被打开、存款、取款和账户被关闭。随着业务分析阶段的继续,您可能会发现更多与(例如)利息支付、所有权变更等相关的事件需要添加到您的银行账户聚合中。

“运行余额”投影通过处理银行账户的事件流并响应“存款”和“取款”事件,为我们提供了银行账户金额的实时视图。在前者事件中,存款金额被添加到当前余额中;在后者事件中,取款金额从余额中扣除。您会注意到,在投影中没有应用业务规则(例如,处理透支账户),因为投影只是对已发生事件的视图。如果需要应用业务规则,它们应该阻止事件的发生,而不是在投影中使用事件时触发。

模型中还有两个身份组——“已开户”组是任何未被关闭的账户,“信用账户”组是余额大于零的账户。第一个身份组由一个简单的分类器填充,该分类器仅处理“账户已开户”和“账户已关闭”事件,以确定账户是否属于该组。对于信用账户组,将执行“运行余额”投影,然后应用规则以包含结果余额大于零的账户。

“获取开户余额”查询定义在“已开户”身份组的所有账户上运行,并对其执行“运行余额”投影,返回账户号码和当前余额金额的集合。

还有一个名为“应用利息”的命令,在“已开户”身份组上运行,并将根据当前余额应用一定金额的利息。此业务领域还需要用于开户、充值、取款和关闭账户的命令,但为简单起见,此处未显示。

从该模型生成的代码随后应用于应用程序的不同层。命令和查询定义将属于前端层(用户界面),身份组和投影将存在于业务层,事件定义和聚合标识符(以及由此产生的持久事件流)将存在于数据层——与当前使用数据库支持的应用程序的方式非常相似。

Using the Code

在运行本文附加的代码之前,您需要执行两个步骤。第一个是将启动项目设置为“DSLPackage”(如果尚未设置)。(此设置似乎是按用户存储的,因此当您打开解决方案时可能与您不同。)

下一步是将调试命令行指向存储虚拟解决方案文件的位置(或者您可以自己创建一个新解决方案并更改调试参数指向它)。

后续步骤

该项目的下一步是在 Azure 存储和 Azure 队列的基础上构建一个示例……并整合对此设计器初始版本的任何反馈。

故障排除

编译此代码有一些棘手的地方,因此以下几点值得检查:

  1. 确保“DslPackage”设置为启动项目(出于某种原因,它可能会更改为错误的库)。
  2. 确保测试项目(上方)的相对路径设置正确。
  3. 逐个组件编译——有时 `CodeGeneration` 项目会因为 Roslyn 编译器版本更改而无法编译,因此您需要及时捕获。

历史

  • 2015 年 12 月 29 日 - 发布初稿供评论/测试。此版本尚未“生产就绪”,但应被视为概念验证级别。(我提前发布是为了在陷入更深的技术难题之前获得任何反馈和其他想法。)
  • 2016 年 3 月 20 日 - 添加了为每个事件定义生成序列化器的代码,以便实现代码可以处理事件定义中的不同版本号。
  • 2016 年 4 月 7 日 - 将代码生成选项添加到模型本身,以便使用的语言和子文件夹设置保存在 DSL 模型中。
  • 2016 年 8 月 25 日 - 添加了故障排除部分。
  • 2016 年 10 月 31 日 - *重大更改* 向模型添加了属性,允许您将事件定义的某个日期属性指定为“生效日期”属性,从而实现“截至日期”风格的查询定义。
  • 2017 年 1 月 12 日 - 添加了实践示例,解释了模型各个组件的组合方式以及如何将该代码整合到您的应用程序中。
  • 2017 年 3 月 11 日 - 为大型领域模型添加了缩放和缩略图功能,使其更容易使用。
  • 2018 年 1 月 14 日 - 添加了序列化代码生成,以便更快地序列化/反序列化事件。
© . All rights reserved.