【DDD】持久化领域对象的方法实践

概述

在实践领域驱动设计(DDD)的过程当中,咱们会根据项目的所在领域以及需求状况捕获出必定数量的领域对象。设计得足够好的领域对象便于咱们更加透彻的理解业务,方便系统后期的扩展和维护,不至于随着需求的扩展和代码量的累积,系统逐渐演变为大泥球(Big Ball of Mud)。html

虽然领域驱动设计的思想很诱人,但咱们依然会面临各类隐藏的困难,就好比今天咱们要讲的主题“持久化”:即便前期咱们设计了足够完整的领域对象,可是依然须要持久化它们到数据库中,而普通的关系型数据库可能很难维持领域对象的原有结构,因此咱们必需要使用一些特有的手段来处理它。git

开篇

本篇文章属于《如何运用领域驱动设计》系列的一个补充,若是您阅读过该系列的其它文章,您就会发现关于“持久化”的这个问题已经不止在一篇博文中说起到了。github

那么,究竟是什么缘由让咱们面临这个问题呢? 是的!值对象! 若是您认真的了解过值对象的话(若是还不了解值对象,您能够参考 如何运用领域驱动设计 - 值对象),您会发现值对象是由许多基元类型构成的(好比string,int,double等),因此咱们能够理解它为对细粒度基元类型的包裹,构成咱们所在领域中的一个基础类型,好比说下面这个例子:sql

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。mongodb

这样建模的好处之一就是咱们考虑的问题是一个总体,将零碎的点构建为一个总体对象,若是该对象的行为须要发生改变,只须要修改该对象自己就能够了,而不是代码散落在各处须要处处查找(这也是滚成大泥球的缘由之一)。数据库

若是您喜欢捕猎有关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.json

该建议的内容就是提倡DDD实践者多使用值对象。固然也不是说不管什么东西都创建成值对象,只是要咱们多去发现领域中的值对象。c#

可是这每每给持久化带来了难度,先来想一下传统的编码持久化方式:一个对象(或者POCO)里面包含了各个基元类型的属性,当须要持久化时,每一个属性都对应数据库的一个字段,而该对象就成为了一个表。 可是这在领域驱动设计中就很差使用了,值对象成了咱们考虑问题的小颗粒,而它在代码中成了一个类,若是直接持久化它是什么样子呢?表,使用它的实体或者聚合根也是一个表,两个表经过主外键关系连接。

那么这样持久化方式好很差呢? 答案是不肯定的,可能了解了下文的这些方案后,您会有本身的看法。

本篇文章的持久化方案都是基于关系型数据库,若是您是非关系型数据库(好比mongodb),那么您应该不会面临这样的问题。

字段 Or 表

将值对象持久化成字段好呢?仍是将值对象持久化为表好呢? 这个问题其实也有不少普遍的讨论,就比如.NET好仍是Java好(好吧,我php天下**),目前其实也没有个明确的结果:

  • 以为持久化为表字段的缘由是 若是持久化为表,必须给表添加一个ID供引用的实体或者聚合关联,这就不知足值对象不该该有ID的准则了。
  • 以为持久化为表的缘由是 数据表模型并不表明代码层面的模型,代码里面的值对象其实并无ID的说法,因此它是符合值对象的,而持久化为字段的话,同一个值对象数据会被复制为多份致使数据冗余。

固然哈,各有各的道理,咱们也不用特别偏向于使用哪一个结论。应该站在客观的角度,实际的项目须要哪一种手段就根据切实的状况来选择。

来讲一下持久化为字段的状况

该手段其实在近期来讲比较流行,特别是在EFCore2.0以后,为何呢?由于EF Core2.0提供了一个叫作 从属实体类型 的概念,其实这个技术手段在EF中很早就有了,在EF中有一个叫作Complex的东西,只是在EF Core 1.x时代没有引入而已。

在EFCore引入了Owned以后,微软那个最著名的微服务教程 eShopOnContainers 也顺势推出了用于该特性来持久化值对象的方案:

x

因此这也是为何你们都在使用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 包含了值对象 ItineraryNoteItineraryNote 又包含了值对象 NotePerson。 接下来咱们来使用EF Core的Owned来看它可否完成这种映射关系:

modelBuilder.Entity<Itinerary>().OwnsOne(s => s.Note).OwnsOne(s => s.NotePerson);

当可以连续打出两个Owns**的时候我就以为这事儿应该成了,结果看数据库的关系图吧:

x

是的,它能够!而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#为咱们提供了explicitimplicit的关键字,方便咱们对强类型进行互转(若是您还不了解该关键字,戳这里)。

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);

最后数据库的结果是这样的:

x

用您的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的小知识,定于每周一和周五在博客园更新,若是您有兴趣的话能够关注哟。

相关文章
相关标签/搜索