DDD实战与进阶 - 值对象

DDD实战与进阶 - 值对象

概述

做为领域驱动设计战术模式中最为核心的一个部分-值对象。一直是被大多数愿意尝试或者正在使用DDD的开发者说起最多的概念之一。可是在学习过程当中,你们会由于受到传统开发模式的影响,每每很难去运用值对象这一律念,以及在对值对象进行持久化时感到很是的迷惑。本篇文章会从值对象的概念出发,解释什么是值对象以及怎么运用值对象,而且给出相应的代码片断(本教程的代码片断都使用的是C#,后期的实战项目也是基于 DotNet Core 平台)。数据库

何为值对象

首先让咱们来看一看原著 《领域驱动设计:软件核心复杂性应对之道》 对值对象的解释:编程

不少对象没有概念上的表示,他们描述了一个事务的某种特征。
用于描述领域的某个方面而自己没有概念表示的对象称为Value Object(值对象)。c#

此时做者是这样的:
而咱们是这样的:编程语言

而后做者用“地址”这一律念给你们扩充了一下什么是值对象,咱们应该怎么去发现值对象。因此你会发现如今不少的DDD文章中都是用这个例子给你们来解释。固然读懂了的人就会有一种醍醐灌顶的感受,而像我这种菜鸡,之后运用的时候感受除了地址这个东西会给他抽象出来以外,其余的仍是该咋乱写咋写。性能

For Example :学习

public class DemoClass
{
    public Address  Address { get; set; } 

    //…………
}

OK,如今咱们来仔细理解和分析一下值对象,虽然概念有一点抽象,可是至少有一关键点咱们可以很清晰的捕捉到,那就是值对象没有标识,也就是说这个叫作Value Object的东西他没有ID。这一点也十分关键,他方便后面咱们对值对象的深刻理解。
既然值对象是没有ID的一个事物(东西),那么咱们来考虑一下什么状况下咱们不须要经过ID来辨识一个东西:this

  • “在超市购物的时候:我有五块钱,你也有五块钱” 这里会关心个人钱和你的钱是同一张,同一个编码,同一个组合方式(一张五块,五张一块)吗? 显然不会。由于它们的价值是同样的,就购买东西来讲,因此它是不须要ID的。编码

  • “去上厕所的时候:同时有两个空位,都是同样的马桶,都同样的干净” 这里你会关心你要上的马桶是哪个生产规格,哪个编码吗?显然不会,你只关心它是否结构无缺,可以使用。 固然有的人可能要说:“我上厕所的时候,我每次都认准要上第一排的第一号厕所。” 那么,反思一下,当十份内急的时候,你还会考虑这个问题吗? 虽然这个例子举的有点奇葩,但却值得咱们反思,在开发过程当中咱们所发现的一些事物(类),它是否真的须要一个身份ID。设计

经过上面的两个例子,相信你一个没有身份ID的事物(类)已经在你脑壳里面留下了一点印象。那么让咱们再来看一下原著中所提供给咱们的一个案例:

  • 当一个小孩画画的时候,他注意的是画笔的颜色和笔尖的粗细。但若是有两只颜色和粗细相同的画笔,他可能不会在乎使用哪一支。若是有一支笔弄丢了,他能够从一套新笔中拿出一支同颜色的笔来继续画画,根本不会在乎已经换了一支笔。

值对象是基于上下文的

请注意,这是一个很是重要的前提。你会发如今上面的三个案例中,都有一个一样的前缀:“???的时候”。也就是说,咱们考虑值对象的时候,是基于实际环境因素和语境条件(上下文)的。这个问题很是好理解:好比你是一个孩子的爸爸,当你在家里面的时候,听到了有孩子叫“爸爸”,哪怕你没有看到你的孩子,你也知道这个爸爸指的是你本身;当你在地铁上的时候,忽然从旁边车箱传来了一声“爸爸”,你不会认为这个是在叫你。因此,在实现领域驱动的时候,全部的元素都是基于上下文所考虑的,一切脱离了上下文的值对象是没有做用的

当前上下文的值对象多是另外一个上下文的实体

实体是战术模式中一样重要的一个概念,可是如今咱们先不作讨论,咱们只须要明白实体是一个具备ID的事物就好了。也就是说一个一样的东西在当前环境下可能没有一个独有的标识,但可能在另外一个环境下它就须要一个特殊的ID来识别它了。考虑上面的例子:

  • 一样的五块钱,此时在一个货币生产的环境下。它会考虑这一样的一张五块钱是否重号,显然重号的货币是不容许发行的。因此每一张货币必须有一个惟一的标识做为判断。

  • 一样的马桶,此时在一个物管环境中。它会考虑该马桶的出厂编码,若是马桶出现故障,它会被返厂维修,而且经过惟一的id进行跟踪。

显然,一样的东西,在不一样的语境中竟然有着不一样的意义。

怎么运用值对象

此时,你应该能够根据你本身的所在环境和语境(上下文)捕获出属于你本身的值对象了,好比货币呀,姓名呀,颜色呀等等。下面咱们来考虑如何将它放在实际代码中。

以第一个五块钱的值对象例子来做为说明,此时咱们在超市购物的上下文中,咱们可能已经捕获倒了一个叫作“钱”(Money)的值对象。按照以往咱们的写法,来看一看会有一个什么样的代码:

public class MySupmarketShopping
{
    public decimal Money { get; set; } 

    public int MoneyCurrency { get; set;}
}

尽可能避免使用基元类型

仔细看上面的代码,你会发现,这没有问题呀,代表的很正确。我在超市购物中,我所具备的钱经过了一个属性来代表。这也很符合咱们以往写类的风格。
固然,这个写法也并不能说明它是错的。只是说没有更好的代表咱们当前环境所要代表的事物。
这个逻辑可能很抽象,特别是咱们写了这么多年的代码,已经养成了这样的定性思惟。那么,来考虑下面的一个问卷:

运动调查表(1)
姓名 ________
性别 ________ (字符串)
周运动量 ________(整型)
经常使用运动器材 ________(整型)
运动调查表(2)
姓名 ________
性别 ________ (男\女)
周运动量 ________(0~1000cal\1000-1000cal)
经常使用运动器材 ________(跑步机\哑铃\其余)

如今应该比较清晰的可以理解该要点了吧。从运动表1中,仿佛出了性别以外,咱们都不知道后面的空须要表达什么意思,而运动表2加上了该环境特有的名称和选项,一下就能让人读懂。若是将运动表1转换为咱们熟悉的代码,是否相似于上面的MySupmarketShopping类呢。所谓的基元类型,就是咱们熟悉的(int,long,string,byte…………)。而多年的编码习惯,让咱们认为他们是代表事物属性再正常不过的单位,可是就像两个调查表所给出的答案同样,这样的代码很迷惑,至少会给其余读你代码的人形成一些小障碍。

值对象是内聚而且能够具备行为

接下来是实现咱们上文那个Money值对象的时候了。这是一个生活中很常见的一个场景,因此有可能咱们创建出来的值对象是这样的:

class  Money
{
    public int Amount { get; set; }
    public Currency Currency { get; set; }

    public Money(int amount,Currency currency)
    {
        this.Amount = amount;
        this.Currency = currency;
    }
}

Money对象中咱们还引入了一个叫作币种(Currency)的对象,它一样也是值对象,代表了金钱的种类。
接下来咱们更改咱们上面的MySupmarketShopping

public class MySupmarketShopping
{
    public Money Amountofmoney { get; set; } 
}

你会发现咱们将原来MySupmarketShopping类中的币种属性,经过转换为一个新的值对象后给了money对象。由于币种这个概念实际上是属于金钱的,它不该该被提取出来从而干扰个人购物。

此时,Money值对象已经具有了它应有的属性了,那么就这样就完成了吗?
仍是一个问题的思考,也许我在国外的超市购物,我须要将个人人民币转换成为美圆。这对咱们编码来讲它是一个行为动做,所以多是一个方法。那么咱们将这个转换的方法放在哪儿呢? 给MySupmarketShopping? 很显然,你一下就知道若是有Money这个值对象在的话,转换这个行为就不该该给MySupmarketShopping,而是属于Money。而后Money类就理所固然的被扩充为了这个样子:

class  Money
{
    public int Amount { get; set; }
    public Currency Currency { get; set; }

    public Money(int amount,Currency currency)
    {
        this.Amount = amount;
        this.Currency = currency;
    }

    public Money ConvertToRmb(){
        int covertAmount = Amount / 6.18;
        return new Money(covertAmount,rmbCurrency);
    }
}

请注意:在这个行为完成后,咱们是返回了一个新的Money对象,而不是在当前对象上进行修改。这是由于咱们的值对象拥有一个很重要的特性,不可变性

值对象是不可变的:一旦建立好以后,值对象就永远不能变动了。相反,任何变动其值的尝试,其结果都应该是建立带有指望值的整个新实例。

来看一个例子

其实咱们在平时的编码过程当中,有些类型就是典型的值对象,只是咱们当时并无这个完整的概念体系去发现。
好比在.NET中,DateTime类就是一个经典的例子。有的编程语言,他的基元类型实际上是没有日期型这种说法的,好比Go语言中是经过引入time的包实现的。
尝试一下,若是不用DateTime类你会怎么去表示日期这一个概念,又如何实现日期之间的相互转换(好比DateTime所提供的AddDaysAddHours等方法)。

这是一个现实项目中的一个案例,也许你能经过它加深值对象概念在你脑海中的印象。

该案例的需求是:将一个时间段内的一部分时间段扣除,而且返回剩下的小时数。好比有一个时间段 12:00 - 14:00.另外一个时间段 13:00 - 14:00。 返回小时数1。
//代码片断 1

string StartTime_ = Convert.ToDateTime(item["StartTime"]).ToString("HH:mm");
    string EndTime_ = Convert.ToDateTime(item["EndTime"]).ToString("HH:mm");
    string CurrentStart_ = Convert.ToString(item["CurrentStart"]);
    string CurrentEnd_ = Convert.ToString(item["CurrentEnd"]);
    //计算开始时间
    string[] s = StartTime_.Split(':');
    double sHour = double.Parse(s[0]);
    double sMin = double.Parse(s[1]);
    //计算结束时间
    string[] e = EndTime_.Split(':');
    double eHour = double.Parse(e[0]);
    double eMin = double.Parse(e[1]);

    DateTime startDate_ = hDay.AddHours(sHour).AddMinutes(sMin);
    DateTime endDate_ = hDay.AddHours(eHour).AddMinutes(eMin);

    TimeSpan ts = new TimeSpan();
    if (StartDate <= startDate_ && EndDate >= endDate_)
    {
        ts = endDate_ - startDate_;
    }
    else if (StartDate <= startDate_ && EndDate >= startDate_ && EndDate < endDate_)
    {
        ts = EndDate - startDate_;
    }
    else if (StartDate > startDate_ && StartDate <= endDate_ && EndDate >= endDate_)
    {
        ts = endDate_ - StartDate;
    }
    else if (StartDate > startDate_ && StartDate < endDate_ && EndDate > startDate_ && EndDate < endDate_)
    {
        ts = EndDate - StartDate;
    }
    if (OverTimeUnit == "minute")
    {
        Duration_ = Duration_ > ts.TotalMinutes ? Duration_ - ts.TotalMinutes : 0;
    }
    else if (OverTimeUnit == "hour")
    {
        Duration_ = Duration_ > ts.TotalMinutes ? Duration_ - ts.TotalMinutes : 0;
    }

//代码片断 2

DateTimeRange oneRange = new DateTimeRange(oneTime,towTime);
    DateTimeRange otherRange = new DateTimeRange(oneTime,towTime);
    var resultHours = oneRange.GetRangeHours() - oneRange.GetAlphalRange(otherRange);

首先来看一看代码片断1,使用了传统的方式来实现该功能。可是里面使用大量的基元类型来描述问题,可读性和代码量都很复杂。
接下来是代码片断2,在实现该过程时,咱们先尝试寻找该问题模型中的共性,所以提取出了一个叫作时间段(DateTimeRange)类的值对象出来,而赋予了该值对象应有的行为和属性。

//展现了DateTimeRange代码的部份内容
public class DateTimeRange
{
    private DateTime _startTime;
    public DateTime StartTime
    {
        get { return _startTime; }
    }

    private DateTime _endTime;
    public DateTime EndTime
    {
        get { return _endTime; }
    }

    public DateTimeRange GetAlphalRange(DateTimeRange timeRange)
    {
        DateTimeRange reslut = null;

        DateTime bStartTime = _startTime;
        DateTime oEndTime = _endTime;
        DateTime sStartTime = timeRange.StartTime;
        DateTime eEndTime = timeRange.EndTime;

        if (bStartTime < eEndTime && oEndTime > sStartTime)
        {
            // 必定有重叠部分
            DateTime sTime = sStartTime >= bStartTime ? sStartTime : bStartTime;
            DateTime eTime = oEndTime >= eEndTime ? eEndTime : oEndTime;

            reslut = new DateTimeRange(sTime, eTime);
        }
        return reslut;
    }
}

经过寻找出的该值对象,而且丰富值对象的行为。为咱们编码带来了大量的好处。

值对象的持久化

有关值对象持久化的问题一直是一个很是棘手的问题。这里咱们提供了目前最为常见的两种实现思路和方法供参考。而该方法都是针对传统的关系型数据库的。(由于Nosql的特性,因此无需考虑这些问题)

将值对象映射在表的字段中

该方法也是微软的官方案例Eshop中提供的方案,经过EFCore提供的固有实体类型形式来将值对象存储在依赖的实体表字段中。具体的细节能够参考 EShop实现值对象。经过该方法,咱们最后持久化出来的结果比较相似于这样:

将值对象单独用做表来存储

该方式在持久化时将值对象单独存为一张表,而且以依赖对象的ID主为本身的主键。在获取时用Join的方式来与依赖的对象造成关联。
可能持久化出来的结果就像这样:

可能没有完美的持久化方式

正如这个小标题同样,目前可能并无完美的一个持久化方式来供关系型数据库持久化值对象。方式一的方式可能会形成数据大量的冗余,毕竟对值对象来讲,只要值是同样的咱们就认为他们是相等的。假若有一个地址值对象的值是“四川”,那么有100w个用户都是四川的话,那么咱们会将该内容保存100w次。而对于一些文本信息较大的值对象来讲,这可能会损耗过多的内存和性能。而且经过EFCore的映射获取值对象也有一个问题,你很难获取倒组合关系的值对象,好比值对象A中有值对象B,值对象B中有值对象C。这对于建模值对象来讲多是一个很正常的事情,可是在进行映射的时候确很是困难。
对于方式二来讲,建模中存在了大量的值对象,咱们在持久化时不得不对他们都一一创建一个数据表来保存,这样形成数据库表的无限增多,而且对于习惯了数据库驱动开发的人员来讲,这多是一个噩梦,当尝试经过数据库来还原业务关系时这是一项很是艰难的任务。
总之,仍是那句话,目前依旧没有一个完美的解决方案,你只能经过本身的自身条件和从业经验来进行对以上问题的规避,从而达到一个折中的效果。

总结

总结可能就是没有总结了吧。有时间的话继续扩充战术模式中其它关键概念(实体,仓储,领域服务,工厂等)的文章。

相关文章
相关标签/搜索