ORM真的不适合DDD

第一篇帖子,先自我介绍一下,我从07年开始关注并实践DDD,也一直在关注jdon的成长(虽然一直没有注册ID并发言)。自己的tech stack是.NET阵营的,一致也在致力推广.NET下DDD的实践。但设计和架构上的思想基本还是一致的。
最近在思考一些ORM和DDD的问题,但是不知道我的思路是否正确。还请banq以及各位大师指点。
问题来自一个非常简单的建模:文章(Article)、类别(Category)。根据当前的应用场景,从聚合划分上看,简单地说,“文章”、“类别”都属于聚合根,由于类别不是文章应具备的一个属性(因为文章本身不会因为没有被归到某个类别而不成其为文章),反之亦然,所以它们之间互相是没有引用的。
稍作分析,可以发现,其实在这个例子中,还有一个隐藏的聚合,称之为“分类(categorization)”,它是对文章与类别之间关系的描述,当然,这个“分类”也无法聚合“文章”和“类别”,因为“文章”、“类别”两者与“分类”的生命周期是不同的,因此只能通过ID关联。于是,模型大致如下(抱歉是C的代码):


public class Article
{
public virtual Guid ID { get; set; }
public virtual string Title { get; set; }
public virtual string Content { get; set; }
}

public class Category
{
public virtual Guid ID { get; set; }
public virtual string Name { get; set; }
}

public class Categorization
{
public virtual Guid ID { get; set; }
public virtual Guid CategoryID { get; set; }
public virtual Guid ArticleID { get; set; }
}

在得到了上面的假设之后,我开始使用NHibernate(Java阵营是Hibernate)试图完成对上述模型的持久化。根据DDD,Repository管理聚合生命周期,Repository模式的引入,让Domain Model能够独立于持久化机制(ORM或者其它),从而剥离出一个非常纯净的Domain Model。于是,结合NHibernate,我原以为实现一个基于NHibernate的Repository,就能将聚合直接由Repository转交给NHibernate,从而使用NHibernate这一ORM产品来实现对象生命周期管理。

不幸的是,我发现我无法在NHibernate中配置用于上述Model的映射方案:Categorization.CategoryID属性是关联到Category.ID的(从数据库角度看,就是一个外键关联),然而要在NHibernate中正确地配置外键关联,就必须要求两个对象之间有一对一、一对多等等属性引用。但一开始我们就分析了:在我们的Domain Model中,Category和Categorization之间、Article和Categorization是无法直接引用的(领域建模不允许这么做)。

于是,我就意识到,DDD社区中所谓的Impedance Mismatch,并不是说Domain Model与关系型数据库之间的Mismatch,而是Domain Model与整套关系模型之间的Mismatch。因此,要解决这里的问题,我们还需要有一层面向NHibernate ORM的对象关系模型(即持久化模型),并在Repository中将Domain Model转换为持久化模型,然后再使用NHibernate。

总结起来,这个过程就是:
Domain Model -> Repository -> (translate to) Persistance Model -> ORM (eg. NHibernate)
换句话说,相对于其它对象数据库和NoSQL而言,ORM无法很好地支持DDD,还需要有一层从Domain Model到Persistance Model的映射。

不知我的理解是否正确。恳请指点。

[该贴被admin于2014-03-09 07:44修改过]

有关系的,Categorization与Article(或Category) 是多对一的关系,

对,我们的理解是有关系的,然而正如我上面的分析,在Domain Model确定这些聚合的时候,情况却不一样。

不太理解啊...既然要求

Category和Categorization之间、Article和Categorization是无法直接引用的(领域建模不允许这么做)

那就不引用了呗...
在hibernate里面不设置 一对一,一对多之类的关系呗...

不就行了么?

当然可以不设置,但在自动产生ID值的时候会存在问题,比如如果使用guid generator,那么当我们创建一个Article的时候,如果同时希望能够将它归类到一个类别中,于是就需要在同一个事物中同时更新categorization,但这时候事实上Article的ID没有生成。
虽然可以使用assigned的方式,由构造函数自己产生ID,但这种做法还是有些不自然。


//领域对象
public class Contact
{
public string ContactName { get;set;}
public AddressDTO Address { get;set;}
}
public class AddressDTO
{
public string Country { get;set;}
public string Province { get;set;}
public string Street { get;set;}
}


//数据库对象
public class AddressInfo
{
public Guid ID { get;set;}
public Guid ContactID { get;set;}
public string Country { get;set;}
public string Province {get;set;}
public string Street { get;set;}
}

不管你用不用ORM,从数据库对象到领域对象都必须手工编码转换。AddressDTO类可能并不在领域层,而是在横切层,用于数据传输。

一行DataRow是无法表达任何信息的,而领域对象中包含的数据传输对象则可以表达明确的含义。那么将DataRow转换为DTO的工作由谁来完成呢?我认为可以放在横切层。

Repository 发出消息请求加载数据,消息转递到横切层后,横切层分析消息并将消息拆分为若干个子消息,转发给持久层。数据加载完毕之后,横切层分析数据库中加载过来的数据是否合法,如果合法,则返回DTO集合。

这样处理又带来了新的问题,AddressDTO和AddressInfo有类似的字段,能否重用呢?当然不行。。。。但是我们可以使用动态编译技术,在程序集加载时自动检索数据库,并生成强类型的AddressInfo。这样一来,持久层就被我们藏起来了,程序不用手工操作ORM。


public static Assembly Compile(string filePath, string modelDefinition)
{
CompilerParameters objCompilerParameters = new CompilerParameters();
objCompilerParameters.ReferencedAssemblies.Add("mscorlib.dll");
objCompilerParameters.ReferencedAssemblies.Add(
"System.dll");
objCompilerParameters.OutputAssembly = filePath;
objCompilerParameters.CompilerOptions =
"/target:library /optimize";
objCompilerParameters.IncludeDebugInformation = false;
objCompilerParameters.GenerateExecutable = false;
objCompilerParameters.GenerateInMemory = true;

CSharpCodeProvider objCSharpCodePrivoder = new CSharpCodeProvider(new Dictionary<string, string>
{
{
"CompilerVersion", "v3.5" }
});
CompilerResults cr = objCSharpCodePrivoder.CompileAssemblyFromSource(objCompilerParameters, modelDefinition);
if (!cr.Errors.HasErrors)
{
Assembly objAssembly = cr.CompiledAssembly;
return objAssembly;
}
return null;
}

2013-10-08 21:40 "@gameboyLV
"的内容
不管你用不用ORM,从数据库对象到领域对象都必须手工编码转换。 ...

理论上的确是这样的,正如我在本帖正文中所述,Persistance Model(PM)和Domain Model(DM)是不同的。

Repository的职责就是管理聚合的生命周期,所以Repository的操作对象就是聚合(当然是通过聚合根来将关联对象一并处理,因为这些对象的生命周期相同)。在实践中,是否需要将DM转换为PM,我觉得还是跟选取的持久化技术有关。如果使用ORM,Impedance Mismatch效应就比较明显,因此有必要进行DM/PM转换,但如果是OODB,或者NoSQL等其它解决方案,或许直接存取整个聚合就变得非常直接自然,此时DM直接作为PM也未尝不可。

这就有点像Domain Model和View Model之间的差异一样:VM专注于表示层,其结构和关联关系由表示层决定,因此DM不能直接用作VM,但在某些情况下,或许DM的结构正好能够适应表示层的数据展示,那么将DM用作VM也不会有太大问题。

我发这个帖子的主要目的就是阐述了我的一种感受,并希望能够验证这样的想法:在实践中让我觉得,ORM的Impedance Mismatch使其相较于其它持久化技术而言,不太适合DDD实践。

如果楼主所说的Article, Category是指博客系统中的文章和分类的话,那我觉得首先:它们是独立的聚合根,但是我觉得没必要设计Categorization这个聚合根了。
1. 因为在业务上,我们没有明确的场景会创建Categorization聚合根,这个聚合根对用户来说是没意义的,感知不到的。
2.聚合根一个很重要的职责就是对规则建模。这里的Categorization我看不出内聚了什么规则,这个聚合只是说明了文章和分类之间有某种关系,但具体是什么关系不得而知,比如是1:1,还是1:N,还是M:N呢?其实都有可能。所以这个聚合实际上业务含义很模糊。
3.Article,Category之间,从业务上来说,category是不关心article,所以category不必存放关于article的任何信息,这点没问题;但是article我们在新建或修改时,都会关心当前article属于哪个分类(比如博客园里我们发表文章的时候就要选择所属分类,可以选择多个)。所以,我觉得很明显,在这种业务场景下,article需要关联一些category,但是为了聚合边界之间的清晰,我们只用ID关联,所以就可以设计一个IList<CategoryId>这样的值对象属性categoryIds在article上即可。这个categoryIds属性表达了当前文章和哪些分类关联。同时还包含了一个潜在的业务规则,就是一个文章不能关联两个相同的category。这个业务规则如果用Categorization是没办法在聚合层面做到的。这里categoryIds这个属性是完全内聚在article这个聚合内的,和category无任何关系了。因为categoryId只是一个值对象。
4.如果设计出Categorization这个聚合根,那就意味着创建文章的时候,会同时创建Categorization这个聚合根,这就导致一个command同时修改了两个聚合,这已经违背了“一个命令影响一个聚合”的原则。此时我们就得想想是否聚合设计的有问题了。

非常不错,雪华说得很有道理,之前在设计这个聚合模型的时候,我就遇到了这样的困惑,经过你的指点,我觉得你的设计更为合理,更切合DDD的本意。
另外对于ORM是否适合DDD的问题,我想,虽然我的例子举得有点问题,但是还是能从侧面反映ORM的这个问题的。简单地说,用于ORM机制的存储模型与领域模型应该是两个完全不同的概念。虽然仓储应该直接对聚合进行管理,但在仓储的具体实现中,就不一定能够直接将聚合一股脑塞给持久化机制(如果是使用OODB的话,或许也可以)。

2013-10-10 07:57 "@daxnet
"的内容
对于ORM是否适合DDD的问题 ...

DDD和CQRS是一对姐妹,DDD易于理解,但难以解释实现,而CQRS易于解释实现,但难于理解,两者结合就很自然。

在CQRS架构中,我们甚至只使用数据库来保存导致状态变化的事件,这样通过eventSourcing来保证聚合内部状态变化的一致性,根本不使用数据库来保存状态,这样完全杜绝数据库用来持久化聚合对象的用法,ORM等工具毫无用处之地。

参考:
http://www.jdon.com/45796

不错,从CQRS的角度考虑这个问题,就会更加清楚。事实上最终需要保存的就是这些事件,这样不仅让数据库结构变得非常简单(或许只有1张数据表就能实现),大大提高了效率,甚至还可以不使用数据库产品来实现事件的存储。或许我不应该从我所描述的这个案例中去理解这个问题。

恩,CQRS架构下,command端不需要使用数据库了,所有聚合根是in-memory的,用redis等nosql存储即可;eventstore里所有事件的结构都一致,且永远只有append,所以非常适合用nosql来实现。

我现在的框架中,eventstore用mongodb,redis都有实现;in-memory,目前使用redis来实现;

in-memory挂掉,则因为redis支持持久化,重启后就能恢复in-memory的数据,就算丢失一部分数据,也能根据eventstore恢复。

而eventstore,因为不能丢数据,虽然也是用redis,但是要利用redis的aof的方式来确保事件都会持久化,aof刷盘的频率是1s,这样可以基本确保数据不会丢失。如果要绝对不会丢失,我觉得现在的服务器都有UPS电源,服务器掉电后,至少应该能坚持1s吧,呵呵,所以坚持到日志都刷入到磁盘肯定没问题了。所以我不担心数据丢失的问题。说句题外话,目前的nosql产品,为了高性能,都是用日志append的方式顺序写入cmd,然后系统恢复的时候,读取日志,然后重新执行这些cmd。这个过程是不是和event sourcing很像啊,呵呵。只不过人家是用command sourcing啦,哈哈。

这样的方式,由于redis的单实例性能就非常突出,单个redis实例的写入性能,官方测试结果是11W次每秒,读取时8W次每秒。所以整个架构可以说性能非常之高。

如果担心单个redis放不下所有数据,那可以通过sharding(因为redis服务端本身不支持sharding)的方式,将不同的业务数据的事件放到不同的redis实例内。也就是我们需要提前做好容量规划。同时,in-memory也是一样要做好规划。

另外,如果要更可靠点,那需要做replication了。目前redis只支持master-slave模式,不支持master-master模式,这方面有点薄弱,但redis的作者提供了一个presharding的方案,个人认为还是靠谱的,可以简单的做到在线扩容,具体大家可以再去查一下。

至于CQRS的查询端,我目前还是使用普通的关系型数据库,反正数据更新时异步的嘛,慢慢更新好了。如果用户希望能实时看到一些数据,那我们在查询端也可以设计一些缓存,通过event handler更新缓存,对查询延迟要求高的系统,可以走缓存。不高的场景,走mysql就行了。

最后一点就是消息队列,目前最新的思路是想用:zeromq+redis,zeromq性能高的离谱,但是无持久化,而持久化就用redis来做,感觉应该可行;我不需要非要强大的消息队列,只要支持我所需要的场景即可;RabbitMQ这种虽然强大稳定,但是效率和zeromq比起来,不是一个数量级的;在cqrs+event sourcing+in memory这样的架构面前,性能我觉得是第一位的,呵呵。关于消息队列,淘宝有一个开源的metaq,发现性能也不错,功能很强大,是分布式的,目前没有.net的客户端,但只要有java的客户端,那自己写一个.net的客户端也问题不大;性能测试报告见:http://alibaba.github.io/metaq/document/benchmark/benchmark.htm

最近正在晚上和打算进行测试这个架构。
[该贴被tangxuehua于2013-10-10 18:08修改过]
[该贴被tangxuehua于2013-10-10 18:15修改过]

而eventstore,因为不能丢数据,虽然也是用redis,但是要利用redis的aof的方式来确保事件都会持久化,aof刷盘的频率是1s,这样可以基本确保数据不会丢失。如果要绝对不会丢失,我觉得现在的服务器都有UPS电源,服务器掉电后,至少应该能坚持1s吧


我觉得你这样考虑有些问题,假设数据量非常大,redis在1s中无法全部保存呢,那么你的数据必然丢失
而且command端中的in-memory 需不需要用到redis也是个疑问,in-memory中的domain model 根本就没有分布式的意义

也许整个系统的数据量产生非常快,如果真是这样,那我们可以水平分割数据呀。如果全部写到一个redis实例,redis来不及持久化,那就应该sharding,也就是把数据分片,不同的数据写到不同的redis实例中,这样每个redis实例的写入请求不会太高。
磁盘的append速度是非常快的,1s内可以刷很多数据的。另外,对于这种存储的服务器,我们还可以用ssd提高写入速度。

对于domain in-memory采用分布式的意义是,可以让应用服务器不存放任何domain数据,分布式的方式来存放domain in-memory的意义是数据被所有应用服务器共享。这样就能让应用服务器可以随便挂,反正无状态。

才用分片的情况下确实能解决非常大的数据量的问题,但是还是存储保存不及时的情况下死机,断电等情况

才用分片的情况下,对于redis也没什么作用,同一条数据不可能被两个应用服务器使用的,如果出现这种情况,就会产生并发问题