【DDD】持久化领域对象的方法实践
概述
在实践领域驱动设计(DDD)的过程中,我们会根据项目的所在领域以及需求情况捕获出一定数量的领域对象。设计得足够好的领域对象便于我们更加透彻的理解业务,方便系统后期的扩展和维护,不至于随着需求的扩展和代码量的累积,系统逐渐演变为大泥球(Big Ball of Mud)。
虽然领域驱动设计的思想很诱人,但我们依然会面临各种隐藏的困难,就比如今天我们要讲的主题“持久化”:即使前期我们设计了足够完整的领域对象,但是依然需要持久化它们到数据库中,而普通的关系型数据库可能很难维持领域对象的原有结构,所以我们必须要使用一些特有的手段来处理它。
开篇
本篇文章属于《如何运用领域驱动设计》系列的一个补充,如果您阅读过该系列的其它文章,您就会发现关于“持久化”的这个问题已经不止在一篇博文中提及到了。
那么,到底是什么原因让我们面临这个问题呢? 是的!值对象! 如果您认真的了解过值对象的话(如果还不了解值对象,您可以参考 如何运用领域驱动设计 - 值对象),您会发现值对象是由许多基元类型构成的(比如string,int,double等),所以我们可以理解它为对细粒度基元类型的包裹,构成我们所在领域中的一个基础类型,比如说下面这个例子:
public sealed class City : ValueObject
{
public string Name { get; }
public int Population { get; }
public City(string name, int population)
{
Name = name;
Population = population;
}
}
我们假设现在有一个叫做City的值对象,它是由名称(Name)和人口数量(Population)构成。通常我们这样建立值对象的原因很简单,在该领域中我们一联系到“人口”数量就会和“城市”连同在一起(你不会说我想知道人口数量,而你会说我想知道纽约的人口数量),所以“城市”这一概念成为我们该领域中的小颗粒对象,而该对象在代码实现中是由多个小基元类型构成的,比如该例子就是由一个string和一个int。
这样建模的好处之一就是我们考虑的问题是一个整体,将零碎的点构建为一个整体对象,如果该对象的行为需要发生改变,只需要修改该对象本身就可以了,而不是代码散落在各处需要到处查找(这也是滚成大泥球的原因之一)。
如果您喜欢捕猎有关DDD的知识,您可能不止一次会看到这样一条建议规则:
In the world of DDD, there’s a well-known guideline that you should prefer Value Objects over Entities where possible. If you see that a concept in your domain model doesn’t have its own identity, choose to treat that concept as a Value Object.
该建议的内容就是提倡DDD实践者多使用值对象。当然也不是说无论什么东西都建立成值对象,只是要我们多去发现领域中的值对象。
但是这往往给持久化带来了难度,先来想一下传统的编码持久化方式:一个对象(或者POCO)里面包含了各个基元类型的属性,当需要持久化时,每个属性都对应数据库的一个字段,而该对象就成为了一个表。 但是这在领域驱动设计中就不好使用了,值对象成了我们考虑问题的小颗粒,而它在代码中成了一个类,如果直接持久化它是什么样子呢?表,使用它的实体或者聚合根也是一个表,两个表通过主外键关系链接。
那么这样持久化方式好不好呢? 答案是不确定的,可能了解了下文的这些方案后,您会有自己的见解。
本篇文章的持久化方案都是基于关系型数据库,如果您是非关系型数据库(比如mongodb),那么您应该不会面临这样的问题。
字段 Or 表
将值对象持久化成字段好呢?还是将值对象持久化为表好呢? 这个问题其实也有很多广泛的讨论,就好比.NET好还是Java好(好吧,我php天下**),目前其实也没有个明确的结果:
- 觉得持久化为表字段的原因是 如果持久化为表,必须给表添加一个ID供引用的实体或者聚合关联,这就不满足值对象不应该有ID的准则了。
- 觉得持久化为表的原因是 数据表模型并不代表代码层面的模型,代码里面的值对象其实并没有ID的说法,所以它是符合值对象的,而持久化为字段的话,同一个值对象数据会被复制为多份导致数据冗余。
当然哈,各有各的道理,我们也不用特别偏向于使用哪个结论。应该站在客观的角度,实际的项目需要哪种手段就根据切实的情况来选择。
来说一下持久化为字段的情况
该手段其实在近期来说比较流行,特别是在EFCore2.0之后,为什么呢?因为EF Core2.0提供了一个叫做 从属实体类型 的概念,其实这个技术手段在EF中很早就有了,在EF中有一个叫做Complex的东西,只是在EF Core 1.x时代没有引入而已。
在EFCore引入了Owned之后,微软那个最著名的微服务教程 eShopOnContainers 也顺势推出了用于该特性来持久化值对象的方案:
所以这也是为什么大家都在使用Owned持久化值对象的原因。(当然,大家项目中只有Address被建立为值对象的习惯不知道是不是从这儿养成的 😜)。
来看看Owned好不好使:
首先是一个实体中包含一个值对象的情况,该情况在微软的那个案例中已经实现了,所以我们不用纠结它的功能,肯定是能够实现的。
但是有其它的情况,一个实体包含了一个值对象,该值对象中又包含了另外一个值对象。 您可能会问,怎么可能会有这么复杂。但是如果您按照上面那个多使用值对象的准则的话,这种情况在您的项目中非常的常见。我引用了《如何运用领域驱动设计》中的案例来测试这种实现,代码大致是这样:
public class Itinerary : AggregateRoot<Guid>
{
public ItineraryNote Note { get; private set; }
}
public class ItineraryNote : ValueObject
{
public string Content { get; private set; }
public DateTime NoteTime { get; private set; }
public NotePerson NotePerson { get; private set; }
}
public class NotePerson
{
public string FirstName { get; private set; }
public string LastName { get; private set; }
}
为了达到演示效果,我剔除了有关聚合根的其它属性和行为方法。我们可以清楚的看到聚合根Itinerary 包含了值对象 ItineraryNote ,ItineraryNote 又包含了值对象 NotePerson。 接下来我们来使用EF Core的Owned来看它能否完成这种映射关系:
modelBuilder.Entity<Itinerary>().OwnsOne(s => s.Note).OwnsOne(s => s.NotePerson);
当能够连续打出两个Owns**的时候我就觉得这事儿应该成了,结果看数据库的关系图吧:
是的,它可以!而EFCore对于该持久化的格式是:Entity_Valueobject1_Valueobject2。也就是说我们的值对象可以一直嵌套下去,只是字段名也会跟着一直嵌套而已。
此时,使用其它orm框架的同学们可能就要说了:我没有使用EF,那么我怎么映射,比如是Dapper,对于这种嵌套多层值对象的我怎么办? 别慌哈,后文的另外的方案可能适合您。
来说一下持久化为表的情况
其实这种情况很简单了,如果您不配置Owned的话,EF会为您默认生成表,这种场景我想您可能深有体会,我这里就不再过多阐述了。
怎么持久化集合值对象
是的,如果值对象是一个集合呢?我们又将如何处理它呢?
对了,说到这里还有一个DDD的准则:“尽量少用集合值对象。” 当然,这个观点我觉得很有争议,该观点在 《领域驱动设计模式、原理与实践》 这本权威DDD教材中有被提及。该观点认为我们需要仔细的捕获领域中的值对象,教程中用了“电话号码”来举例,一个人可能有多个号码比如移动电话、座机、传真等,我们可能需要将电话号码建立为值对象,然后建立一个集合值对象,但是教程中认为这样并不好,而是单独将各个类别建立为了值对象,比如移动电话值对象,传真值对象等。
这种做法虽然更贴近于现实建模,但是某些时刻我们真的需要建立一个集合值对象,比如开篇提到的City,如果我在某个场景会用到多个城市信息呢?还有ItineraryNote 里面的 NotePerson 呢,如果是多个人呢? 所以我们的领域或多或少会遇到集合值对象。
将集合值对象存为字段
这种手段非常的常见,最切实的实践方案就是…………………………!对 json! 将集合序列化成json,特别是现在新sqlserver等数据库已经支持json格式的字段了,所以序列化和反序列化的手段也非常容易让我们去持久化值对象。
但是……我的数据库不支持json呢?没关系,还有办法用string,存为strng格式进行反序列化操作也不会消耗太多性能。
还有一种方式:制定属于自己的格式,下面将大家举例为大家说明,用开头的那个City吧:
public sealed class City : ValueObject
{
public string Name { get; }
public int Population { get; }
public City(string name, int population)
{
Name = name;
Population = population;
}
}
假如我们有一个实体中存在一个集合值对象:
public class User : Entity
{
public List<City> Cities { get; set; }
}
第一步,抽象我们的City为另外一个可迭代对象,比如CityList:
public class CityList : ValueObject<CityList>, IEnumerable<City>
{
private List<City> _cities { get; }
public CityList(IEnumerable<City> cities)
{
_cities = cities.ToList();
}
protected override bool EqualsCore(CityList other)
{
return _cities
.OrderBy(x => x.Name)
.SequenceEqual(other._cities.OrderBy(x => x.Name));
}
protected override int GetHashCodeCore()
{
return _cities.Count;
}
public IEnumerator<City> GetEnumerator()
{
return _cities.GetEnumerator();
}
IEnumeratorIEnumerable.GetEnumerator()
{
return GetEnumerator();
}
}
第二步:让CityList能够转换成为string的能力,这个能力怎么来呢? C#为我们提供了explicit和implicit的关键字,方便我们对强类型进行互转(如果您还不了解该关键字,戳这里)。
public static explicit operator CityList(string cityList)
{
List<City> cities = cityList.Split(';')
.Select(x => (City)x)
.ToList();
return new CityList(cities);
}
public static implicit operator string(CityList cityList)
{
return string.Join(";", cityList.Select(x => $"{(string)x.Name}|{(string)x.Population}"));
}
最后,外层的User实体改写为酱紫:
public class User : Entity
{
private string _cities = string.Empty;
public virtual CityListCities
{
get { return (CityList)_cities; }
set { _cities = value; }
}
}
这样提供给ORM的映射的话,可能就会得到像下面的结果:
#Table User
UserID: 1,
CityList: "City1|10;City2|20;"
这种方法的缺点:
当然这种方法虽然能够持久化值对象,但是依然有些很显著的缺点:
- 无法在集合中的单个项中执行有效搜索
- 如果集合中有很多项,这种方法可能会影响性能
- 不支持多层值对象
当然这也并不是说我们就完全不能使用它,在某些简单的值对象场合,该方法可能也是个好的方案。
将集合值对象存为表
这种方案和直接将值对象存为表是一样的,那么还是来看看用EFCore是什么效果吧。EFCore为这种情况推出了OwnsMany的方法,如果我们将上面OwnsOne的案例改为一个值对象集合是什么样子呢?
public class ItineraryNote : ValueObject
{
public string Content { get; private set; }
public DateTime NoteTime { get; private set; }
//改为一个集合
public List<NotePerson> NotePersons { get; private set; }
}
然后将映射的OwnsOne改写为OwnsMany:
modelBuilder.Entity<Itinerary>().OwnsOne(s => s.Note).OwnsMany(s => s.NotePersons);
最后数据库的结果是这样的:
用您的EFCore动手试试吧!
基于快照的数据存储对象
前面的几种方案都是通过EFCore这种重量框架来完成,那么如果使用轻量的ORM框架要自己完成映射配置的如何处理呢?如果自己去配置这种关系非常繁琐,无论是sql操作还是映射操作,都无疑加大了很多的工作量。所以,我们可以尝试引入专门的数据存储对象来供持久化。
回顾一下我们在以前的文章《如何运用领域驱动设计 - 存储库》提到过的一句话:
“领域模型是问题域的抽象,富含行为和语言;数据模式是一种包含指定时间领域模型状态的存储结构,ORM可以将特定的对象(C#的类)映射到数据模型。”
所以当时我就在考虑,既然数据模型是专用于储存的,而领域模型的结构复杂让它难以完成原样持久化,那为什么不在持久化的时候将领域模型转换为专用的数据存储模型呢?这样对数据库也友好,而且也不会破坏领域模型的结构。
还是看那个 Itinerary 例子:
public class Itinerary : AggregateRoot<Guid>
{
public ItineraryNote Note { get; private set; }
}
public class ItineraryNote : ValueObject
{
public string Content { get; private set; }
public DateTime NoteTime { get; private set; }
}
这时我们构建一个专用的数据存储对象,供ORM框架使用:
public class ItinerarySnapshotModel
{
public Guid ID { get; set; }
public string Content { get; set; }
public DateTime NoteTime { get; set; }
}
这个结构您可能再熟悉不过了。是的,它对ORM框架超级友好,这也是面向数据库编程的结构。
这时您可能会问了:“怎么我写DDD,写了半天又回去了?” 这个问题,待会来严肃回答!😝
先来看看领域对象和数据存储对象的互转:
public class Itinerary : AggregateRoot<Guid>, IEntityHasSnapshot<ItinerarySnapshotModel>
{
public ItineraryNote Note { get; private set; }
//must have this ctor
public Itinerary(ItinerarySnapshotModel snapshot)
{
Note = new ItineraryNote(snapshot.Content);
Id = snapshot.ID;
}
public ItinerarySnapshotModel GetSnapshot()
{
return new ItinerarySnapshotModel()
{
Content = Note.Content,
ID = Id,
NoteTime = Note.NoteTime
};
}
}
/// <summary>
/// Provides the ability for entities to create snapshots
/// </summary>
/// <typeparam name="TEntity"><see cref="IEntity"/></typeparam>
public interface IEntityHasSnapshot<TSnapshot>
{
/// <summary>
/// Get a entity snapshot
/// </summary>
TSnapshot GetSnapshot();
}
这样就完成了两种模型的互转。每当ORM需要持久化时,调用aggregateRoot.GetSnapshot()就能得到持久化模型了。而持久化模型的设计在于您自己,您可以根据数据库的情况任意更改,而您只需保证它能和真正的领域对象完成映射就可以了。
好了,来谈谈这种方案的优缺点,以及上面的回到原始面向数据库编程的问题:
先来考虑我们为什么使用领域驱动设计,为的是让项目设计的更加清晰和干净。而领域模型的设计是在设计的前期,甚至领域模型的基本确定还超越了编码开始的时候。我们只捕获领域中重要的对象,而不考虑其它问题(比如持久化、映射框架选择等基础问题),所以这样考虑出来的领域对象才是足够干净和更符合业务实际情况的。
而考虑持久化是在什么时候做的呢?需要与基础构件(比如ORM框架)交互的时期,这时领域对象编码几乎已经完成。其实在持久化之前我们已经完成了领域驱动设计的过程,所以并非是我们退回去使用面向数据库的设计。如果在设计领域对象的时候又考虑数据库等交互,那么想象一下这个打着领域驱动设计旗号的项目最后会成为什么样呢?
那么这种基于快照的数据存储对象方式的优点是什么呢?
- 它解决了持久化的问题。
- 甚至可以将实体OR聚合根的属性完全私有化,这样外界根本无法破坏它的数据。而外界是通过快照的这个数据结构来访问的。
- 您可以随意设计您的数据库结构,哪怕有一天您切换了数据库或者ORM框架,只要您保证转换正确之后,领域的行为是不会被破坏的。
但是它也有个显著的缺点:增大编码量。每一个聚合根都需要增加一个数据储存对象与之对应,而且还需要配置映射规则。但是!!!! 请您相信,这些代码与您项目中的其它代码比起来微不足道,并且它后期为您带来的好处可能更加明显。
比较
上面为大家提供了多种持久化的方案,那么到底哪种更好呢?就好比最初的问题,持久化为字段好还是表好? 依然没有答案,但是我相信您看了上面的内容后,能够找到属于您项目的特有方案,它也会让您落地DDD项目迈出重要的一步。
Table 1
方案 | 优点 | 缺点 |
---|---|---|
持久值对象到表字段 | 数据依附于某条实体或者聚合根 | 数据冗余、会让表拥有太多字段 |
持久化值对象到表 | 数据量不冗余 | 会存在许多表、从数据库层面很难看出它和实体的区别 |
Table 2
方案 | 优点 | 缺点 |
---|---|---|
需要转换对象用作持久化 | 领域对象和数据对象完全独立,对数据对象的操作不会影响到领域对象 | 增大编码量 |
不需要转换对象用作持久化 | 直接将领域对象供给ORM持久化,简单且不需要增加额外的东西 | 配置规则可能比较繁琐,有时候为了让领域模型适配数据而改动领域模型 |
总结
该篇文章文字比较多,也许花费了您太长的时间阅读,但希望本文的这些方案能够对您持久化领域对象有所帮助。这篇博文没有携带GitHub的源码,如果您需要的话可以在下方留言,我写一份上传至Github。哦对了,关于正在写的MiCake(米蛋糕),它也将支持上面所讲的所有方案。
该篇文章属于《如何运用领域驱动设计》的补充篇,为了便于您查看该系列文章和了解文章的更新计划,我在博客首页置顶了该系列的 汇总目录文章(点击跳转),如果您有兴趣的话可以跳转至该文章查看。
对了,该系列的下次更新可能会到下个月了,毕竟还是要过年的嘛。在这儿提前祝大家新年快乐(好像有些太早了哈( ̄▽ ̄)")。但是现在我新增了一个系列博文叫《五分钟的.NET》,是一些关于.NET的小知识,定于每周一和周五在博客园更新,如果您有兴趣的话可以关注哟。