Event Sourcing + DDD带来的模型重构问题如何解决?

12-09-05 tangxuehua
基于Event Sourcing模式设计的模型如何处理模型重构?

问题背景:ddd的核心是聚合,一个聚合内包含一些实体,其中一个是根实体,这个大家都有共识;另外,如果将DDD与Event Sourcing结合,那就是一个聚合根会产生一些event;那么这里的问题是:如果一个领域对象,一开始是entity,后来升级为聚合根,但是该entity之前根本没有对应的event,因为它不是聚合根。因此它升级后我们如何通过event sourcing获取升级后的聚合根最新状态;同理,相反的例子是聚合根降级为实体,该如何处理。

基于哲学方面的一些思考:
之前ORM时代,数据就是数据,我们直接存储数据,然后读取存储的数据即可,很简单;
现在Event Sourcing了,数据用事件表示,我们不在存储数据本身,而是存储与该数据相关的所有事件,包括数据被创建的事件在内;这种思维是好的,我们希望通过保存数据的“完整的历史”来达到任意时刻都能还原数据的目标。但是我们仅仅保存event就真的保存了“完整的历史”了吗?显然不是,我认为历史包含两部分信息:1)事件;2)逻辑;目前我们只保存事件而没有保存逻辑;但是我们又要希望通过事件溯源还原“完整的历史”,怎么可能?!
但是,我们为了确保能还原数据,所以代码重构都小心翼翼,比如确保尽量不改原来的事件,尽量用新事件实现业务变化或新业务功能。另外,对于处理事件的逻辑也尽量确保能兼容老的事件。之所以要这么别扭是因为我们没办法把历史的事件和历史的事件处理逻辑一同持久化。实际上我们总是在用老的事件与最新的代码逻辑相结合进行重演,这实际上是很危险的事情。

然后碰到我上面提出的尖锐问题,实际上很难有优雅的解决方案了。上面我提出的问题其实很难解决:无论是聚合根升级还是降级,都意味着新对象的事件我们无法获取或者说根本之前没有任何与新对象相关的事件,自然就无法再用事件溯源的方式得到该对象了。而实际上这个对象什么都没做,只是做了个升级或降级处理而已;

那么问题出在哪里呢?我认为是ddd的聚合导致的问题。我们之所以要设计出聚合,主要原因是为了通过聚合的手段确保业务上具有内聚关系具有数据一致性规则(Invariants)的领域对象之间方便的维护其一致性;而事件溯源从概念上来说并不针对整个aggregate,而是针对单个的entity.现在一旦将ddd与event sourcing结合,那势必会导致模型中一些对象没有与其相关的event,这就会给我们后期模型重构带来巨大的问题。

既然问题找到了,那我想解决方案也很容易了。就是如果要用event soucing,就必须抛弃聚合的概念,让一切对象回归平等,所有的entity都相互平等,当然value object还是保持不变,因为其只是一个值而已;然后让每个entity都能产生事件,这样就不会有因为某些entity没有事件而导致重构时遇到巨大问题的情况了。

自此,也许你会说,没有聚合那不就是贫血模型了吗?我不这么认为!聚合的意义有两个:1)更好的表达业务完整概念,因为有些对象却是在概念上就是内聚其他一些对象的,比如一辆汽车有四个轮子,汽车内聚轮子;2)为了维护对象之间的Invariants,这个不多解释了,我想大家都理解;那我认为第一点其实和功能无关,是概念上好理解才这样做;关于第二点维护对象之间的Invariants,我认为有很多方法,不必必须显式的定义聚合来实现,我们只要确保所有的entity都能很好的规定其自身哪些属性必须有,哪些属性不能变,哪些可以变,哪些可以在什么范围内变,等等规则约束。这样也同样能实现不变性约束;实际上这种方式和ddd看起来非常接近,但是绝不是贫血模型,因为贫血模型是所有entity的所有属性当然id除外都有get;set;然后所有逻辑全部在service中以transaction script的方式实现;而我上面说的方式实际上entity该有的职责和业务规则判断还是放在entity内部做掉,但是和经典ddd相比,经典ddd的大部分规则和一致性逻辑都在聚合根内完成,而我的方式则由各个entity合起来实现相同的规则和一致性约束;

到这里,其实event sourcing还是面临小范围(单个entity内部)的代码重构的压力,但这我们总能找到相对成本比较轻的解决方案,比如尽量不改原来事件,只新增事件属性,不删除事件属性。即总是采用与原事件兼容的修改方式来修改事件,这其实是可以接受的。

大家觉得怎么样呢?我说了一大堆,也希望能多听听大家的想法。

[该贴被tangxuehua于2012-09-05 23:03修改过]

[该贴被tangxuehua于2012-09-05 23:32修改过]

3
banq
2012-09-06 21:01
2012-09-05 22:52 "@tangxuehua"的内容
目前我们只保存事件而没有保存逻辑;但是我们又要希望通过事件溯源还原“完整的历史”,怎么可能?! ...


不是非常明白,我们把激活我们代码的事件保存下来,然后,从头开始再让这些事件激活以此激活我们的代码,应该可以实现还原,这个非常类似测试软件,比如sproxy这个开源软件,就是把你访问的网址记录下来,实际上记录的是访问事件。使用sproxy录制siege压力测试URL

因为有这个疑问,所以下面没仔细看。是不是我们对Event Sourcing认识不太一致?

tangxuehua
2012-09-07 00:16
banq, 为了能更好的说明问题,我写了个简单的小例子。下面有对这个例子的详细描述,以及基于该例子的问题描述;不好意思,我工作中不用java,例子是用c写的,但是我想这两种语言的语法基本一致的,对你理解应该问题不大我想。

//团队聚合根
public class Team : EntityBase<int>, IAggregateRoot
{
	private IList<Member> _members = new List<Member>();

	public IEnumerable<Member> Members { get { return _members; } }

	public void AddMember(string name, string email)
	{
		ApplyEvent(new MemberAdded(name, email, this.Id));
	}
	public void UpdateMemberName(int memberId, string newName)
	{
		ApplyEvent(new MemberNameUpdated(memberId, newName, this.Id));
	}


	private void OnMemberAdded(MemberAdded evnt)
	{
		_members.AddMember(new Member(evnt.Name, evnt.Email));
	}
	private void OnMemberNameUpdated(MemberNameUpdated evnt)
	{
		var member = _members.FindMemberById(evnt.MemberId);
		member.SetName(evnt.NewName);
	}
}
//团队成员新增事件
public class MemberAdded
{
	public string Name { get; private set; }
	public string Email { get; private set; }
	public int TeamId { get; private set; }

	public MemberAdded(string name, string email, int teamId)
	{
		this.Name = name;
		this.Email = email;
		this.TeamId = teamId;
	}
}
//团队成员名称修改事件
public class MemberNameUpdated
{
	public int MemberId { get; private set; }
	public string NewName { get; private set; }
	public int TeamId { get; private set; }

	public OnMemberNameUpdated(int memberId, string newName, int teamId)
	{
		this.MemberId = memberId;
		this.NewName = newName;
		this.TeamId = teamId;	
	}
}

//团队成员实体
public class Member : EntityBase<int>
{
	public string Name { get; private set; }
	public string Email { get; private set; }

	public Member(string name, string email)
	{
		this.Name = name;
		this.Email = email;
	}

	public void SetName(string name)
	{
		Assert.IsNotNullOrEmpty(name);
		Assert.LengthLessThen(name, 255);

		this.Name = name;
	}
}
<p class="indent">


上面的例子中,有一个聚合根,Team,表示一个团队;Team内聚了一些团队成员,Member;Member是实体;
这里聚合根,实体,就是DDD中的Aggregate Root与Entity。这里没问题吧!另外,上面的例子,我采用了Event Sourcing的方式来实现模型。
Event Sourcing的核心思想有两点:
1)用与某个对象相关的事件来记录对象的每一次变化,一次变化一个事件,对象的创建是第一个事件,如TeamCreated事件表示一个团队被创建了;
2)对象的重建不需通过ORM,而是直接使用之前记录的事件进行逐个重演最终得到对象最新状态,这个重演的过程我们称为事件溯源,英文叫Event Sourcing;

banq,不知我上述对Event Sourcing的描述是否和你一理解的一致?

好了,我帖子中提到的关于“历史不仅仅由事件组成,还必须由处理该事件的逻辑组成”。这句话的意思是,事件要进行重演,必须与一定的逻辑结合,事件本质上只是一些数据,包含了某次变化的相关信息,它不包含逻辑,是静态的值对象;那逻辑是什么呢?主要指两方面:
1)上面Team类里的OnMemberAdded和OnMemberNameUpdated这两个方法,这两个方法实际上是事件的处理函数,职责是负责更新聚合的相关状态;
2)这些事件处理函数在更新聚合状态时实际上是依赖于当前聚合的内部结构的;

所以,事件要能够顺利的按照和历史的方式完全一致的重演,依赖于三个要素必须和历史一致:1)事件不变;2)聚合内部的事件处理逻辑不变,或者即便要变也必须和以前的逻辑兼容;3)事件处理逻辑依赖的聚合的内部结构不变,或者即便要变也必须和以前的结构兼容;

而我们现在做到的只是第一个要素不变,第二和第三个要素我们很可能会进行重构;
当然你可能会说,第二点你也基本不会变,因为你的事件处理逻辑一般都是“简单的属性赋值,即简单的更改聚合相关属性的状态”,那行,如果你真这样做,那确实问题不大;实际上也必须这样做!
但是第三个要素呢?第三个要素实际上就是我说的模型结构重构,最严重的重构情况则是:聚合根降级为实体,或者实体升级为聚合根,简称聚合根的升级与降级;

对于这两种情况,在应用了Event Sourcing的情况下,那是很可怕的。因为从上面我的代码中可以看出Member起初只是个实体,它没有自己的事件,所有的事件都只和聚合根关联,即Team。
但是我们之后如果想重构,把Member升级为聚合根了,这个重构之前在ORM时代,那时非常简单的事情,基本什么都不必变,但是在Event Sourcing的模式下,就有大问题。
因为我们没有与Member对应的事件,自然就无法应用事件溯源来重建Member聚合根了。这里实际上就是我说的上面的第三个要素发生了结构性变化,导致我们无法通过事件溯源重建对象

至于解决方案,你可以再详细看一下我上面的帖子。不知道你现在能否理解我说的问题。

[该贴被tangxuehua于2012-09-07 00:32修改过]

banq
2012-09-07 14:04
我可能有些明白了,一个系统的领域模型应该是系统最稳定的部分,体现领域本质,是dna,一般一旦进入事件编程不会发生大的重构,行为才是一个系统最大变数。

clonalman
2012-09-07 14:13
逻辑与实体关系都重构,EventSourcing是不可能的,放弃以前Event记录,
按重构的逻辑关系重新做Event Sourcing不行吗? 保留原来Event Sourcing的理由什么?

tangxuehua
2012-09-07 14:26
Event一旦生成,是不可能改变的,不管领域模型如何重构,事件都不会变,也不应该变。

我觉得banq似乎在回避问题哦,呵呵。我已经用Event Sourcing实践过一个项目,很多时候不是因为需求变化导致模型重构,而是因为我们之前设计的模型不正确而需要重构。

clonalman
2012-09-07 15:29
2012-09-07 14:26 "@tangxuehua"的内容
Event一旦生成,是不可能改变的,不管领域模型如何重构,事件都不会变,也不应该变。 ...


事件如果是错误的结果,你让它存在还有什么意义....

tangxuehua
2012-09-07 16:29
事件一旦生成,就不能动,也不能删除,事件就是历史,是以发生过的东西。无法改变了。修改或删除事件等同于抹杀历史,这是不可能的。
请banq帮忙澄清一下。

tangxuehua
2012-09-07 16:30
即便事件是错误的,那也是历史,错误的并不代表要被销毁。正确的做法应该是追加新的事件去纠正错误的事件。

banq
2012-09-08 12:02
我个人不敢苟同为了事件而放弃聚合根这样结构表达,实际是为了动态行为而放弃静态结构,这实际又回到过去老路,ddd的核心精华就是用面向对象的结构层次封装状态,一个对象引用另外一个对象形成聚合高低层次,都变成扁平松散一个个不相互引用,这实际是简单数据,而不是对象。

正如我们在统一语言那个贴中讨论一样,业务本质实际是有状态的,结构是现实世界一个本质体现。

所以,这里有一个以谁为核心的问题,是事件绕着代表结构的聚合根转,还是反过来?这也是理解event sourcing的一个隐式前提。

相关主题:

危险的DDD聚合根

业务模型统一描述

[该贴被banq于2012-09-08 15:37修改过]

[该贴被banq于2012-09-08 15:49修改过]

tangxuehua
2012-09-08 20:26
感谢banq提供的链接,关于flyzb发表的关于“危险的DDD聚合根”。我看了他的观点,他强调每个人对业务认识的角度、深度和广度都不同,得出的聚合根也就会不同。但是因为聚合根不仅仅只是一个实体,而是内聚了一堆实体。那他的这种内聚结构对于后期因各种原因发现原来的聚合是错误的时候,此时模型重构的成本和代价会相对于“设计一个所有Entity对象都是平等的模型”的重构成本会更大,特别是在引入了Event Sourcing时问题更加凸显,这点在我的这个帖子中已经做了详细描述。

同时,他也强调了复杂的业务要求软件架构必须具备很强的适应能力,DDD本身就是为了解决复杂业务的软件开发问题的。而为什么ddd的核心概念“聚合”却显示出它不是适应业务变化的一个最佳设计呢?

[该贴被tangxuehua于2012-09-08 20:28修改过]

wee
2012-09-09 08:28
我说下我的看法,

在应用event sourcing,最大的问题是处理变更,变更对serialization和replay都带来麻烦,但是并不是没有办法来处理这些变更,只是要小心和付出成本,当然这些办法不是我想出来的,是大家的解决方案。

首先楼主提到的结构重构,实体A升级为聚根,实体A的事件只是集成到了另外的聚根里B,所以我们要分解B的事件,将B的有关于A事件分解成若干的事件B1,B2,B3...,如果我没理解错的话,楼主可以用B3去处理A,这样A的事又回来了, 而无需去放弃聚根,未免有点舍本逐末。

对于serialization,可以用version event来处理,保留原来的事件,也可以用protobuf等,因为它们可以容忍相对多的变更,例如字段增减等。


我个人以为在用event sourcing之前要小心,因为要付出成本代价,你是否真心需要,对你有何种好处,目前并不是可以普通应用,特别是团队合作的项目。

tangxuehua
2012-09-09 09:05
wee,
你理解我的意思了,大家正视而不回避这个问题是我所希望的。

我的观点是,从背后偏底层哲学的角度去怀疑聚合+EventSourcing引起的矛盾点;从而引出通过设计出所有对象都平等的领域模型来达到平衡,而且在我看到flyzb跟我一样的想法后更加坚定了这一想法,我觉得这样的模型才是最自然最符合事物相互作用的本质。但这种方式绝不是回归以前的贫血模型。放弃聚合概念并不等于放弃不变性规则,实际上不变性规则该由的还都会有,只不过之前都是由聚合根一个对象来维护整个聚合的数据一致性,现在变为由每个单独的实体负责维护属于它自己的一致性,我认为只要每个单独实体正确维护了它的一致性,同样能实现模型的整体数据一致性;所谓贫血模型,是指所有实体只有get;set;实体不需要行为,不需要约束,只作为数据的载体,然后所有逻辑都在service中以过程化的方式完成。这种实现方式本质上是一种面向过程开发。所有业务功能都由函数之间相互调用实现。因此,显然我的方法和贫血模型的做法是不同的,我的做法是DDD与贫血模型两者之间的一种平衡;

你的观点是,聚合必须要有,正确无误。如果我们放弃了聚合,等于舍本逐末,对吧?我的疑问是:凭什么聚合概念一定是对的一定是本?我认为“本”不是“聚合”,而是“对象”,聚合时附加在对象上的一种特殊的约束,实际上就是一种对象间强烈的关系约束,最重要的是这种约束只是你基于你当时对领域的认识的角度、深度、广度而得到的,很可能你以后还会调整聚合或者完全推翻整个聚合。你将这种对象间强烈的不是非常稳定的关系约束看成是“本”,我觉得是有问题的。我觉得一个个普通的强调平等地位的对象才是“本”。

至于你说的解决方法,我觉得理论上可行,但实际操作性不强;
1)采用Event Sourcing的系统,事件就是数据,你修改事件(包括拆分事件、合并事件、修改单个事件的属性)对系统影响的复杂度远比你想的要复杂,只要你实践过Event Sourcing的项目你就会明白;
2)这种方式要求停机发布吗?如果要求那事件迁移的成本相对要稍微低一点;但是如果不允许停机发布,那复杂度又会大大增加;因为这意味着系统要在某个短暂的时刻“发布过程中”同时兼容老事件和新事件;
3)代码方面重构的成本也过大,本来所有子实体的事件都是叫ChildEntityAdded,ChildEntityRemoved,等,这些事件定义名称时都是从之前的聚合根的角度去定义名称的。而现在如果该子实体独立出来,就应该以它自己的角度去命名与它相关的事件。从哲学角度来说,你这样做等于在“改写历史结构”,或者说“改写历史动态演进的过程”。这是一件非常复杂的事件,也是自己给自己找麻烦的事情,历史需要改变吗?!;

最后,关于你提到的Event Sourcing不适合普通团队的普通项目,只有你认为必须需要的时候才引入,这个我认同。程序员水平没达到一定层次,是没办法是用Event Sourcing的。但我前面说这么多的前提是假设我们已经采用了Event Sourcing的前提下而遇到的问题,所以我们不必讨论Event Sourcing适合什么样的项目,什么时候该采用Event Sourcing,因为这是两个不同问题。我们要思考的是:如何正视该问题,如何思考,如何解决,最好要有实际可操作的解决方案,而不是回避;

[该贴被tangxuehua于2012-09-09 09:19修改过]

flyzb
2012-09-09 10:57
  同意tangxuehua的观点,因为“适应变化”是软件开发需要面对的永恒主题。每一次重构中最苦恼的问题就是“明明正确的逻辑还要换一种方式重写一遍”,那种架构能够尽可能就减少这种问题,那么这种架构就能存活下去。
  其实可以换一种更有意思的观点去看待聚合根的问题:我和tangxuehua都相信“众生平等”(对象之间是平等的),没有什么高低之分,只是人(对象)做的事情(行为)不同而让人(对象)的地位有了不同;而聚合根的思路是要先假定人(对象)是有高低不同的,身份不同的,因而做的事情不同。
  呵呵。。我相信“民主”是一定会实现的,而“阶级地位”的不同是会随着时代不同而变化的。

wee
2012-09-09 12:58
聚根和EVENT RESOURCING之间的矛盾,问题在于Eventsourcing, 我是这么认为的,即使你将实体平行化,也只是解决部分问题,event sourcing没有open to change,但是聚根是可以的,并没有说某个实体一定是或不是聚根,看需求变化,如果觉得聚根阻碍了Eventsourcing,不用也是无可厚非,也是很好的,但是如果说否定聚根还是不妥。

对于DDD,CQRS,ES等我也是谨慎的态度,但是不得不承认它们是开阔我们的思路,有很多很好的想法,应该也是实践的提炼,虽然对于划分聚根我也经过一段时间的纠结,从我目前的有限的使用的经验来说,我对聚根至少是认可的,无害的。这种方式的内聚可以减少复杂度,相对易于维护。

谢谢楼主的分享,希望有进一步的关于实体平等化实践的心得。

猜你喜欢
2Go 1 2 下一页