在实现此效果之前,我们先来捋一下思路,用思维导图来设计一下我们的实现步骤,如下:
你可以审查元素,下载数字背景图片,复制图片地址,或者使用其他背景图片、背景颜色
然后作者用“地址”这一概念给大家扩充了一下什么是值对象,我们应该怎么去发现值对象。所以你会发现现在很多的DDD文章中都是用这个例子给大家来解释。当然读懂了的人就会有一种醍醐灌顶的感觉,而像我这种菜鸡,以后运用的时候感觉除了地址这个东西会给他抽象出来之外,其他的还是该咋乱写咋写。
首先让我们来看一看原著 《领域驱动设计:软件核心复杂性应对之道》 对值对象的解释:
很多对象没有概念上的表示,他们描述了一个事务的某种特征。
用于描述领域的某个方面而本身没有概念表示的对象称为Value Object(值对象)。
页面渲染完毕后,我们来让数字滚动起来,设计如下两个方法,其中 increaseNumber
需要在 Vue
生命周期 mounted
函数中调用
思考:背景框中有了数字以后,我们现在来思考一下,背景框中的文字,一定是 0-9
之前的数字,要在不打乱以上 html
结构的前提下,如何让数字滚动起来呢?这个时候我们的魔爪就伸向了一个 CSS
属性: writing-mode
,下面是它属性的介绍:
For Example :
// CSS代码
// htm代码
1
OK,现在我们来仔细理解和分析一下值对象,虽然概念有一点抽象,但是至少有一关键点我们能够很清晰的捕捉到,那就是值对象没有标识,也就是说这个叫做Value Object的东西他没有ID。这一点也十分关键,他方便后面我们对值对象的深入理解。
既然值对象是没有ID的一个事物(东西),那么我们来考虑一下什么情况下我们不需要通过ID来辨识一个东西:
// html部分
0123456789
// style部分
.box-item {
display: inline-block;
width: 54px;
height: 82px;
background: url(./number-bg.png) no-repeat center center;
background-size: 100% 100%;
font-size: 62px;
line-height: 82px;
text-align: center;
position: relative;
writing-mode: vertical-lr;
text-orientation: upright;
/* overflow: hidden; */
}
.box-item span {
position: absolute;
top: 10px;
left: 50%;
transform: translateX(-50%);
letter-spacing: 10px;
}
通过上面的两个例子,相信你一个没有身份ID的事物(类)已经在你脑袋里面留下了一点印象。那么让我们再来看一下原著中所提供给我们的一个案例:
值对象是基于上下文的
计算滚动
如果我们想让数字滚动到 5
,那么滚动的具体到底是多少?
答案是:向下滚动 -50%
那么其他的数字呢?
得益于我们特殊的实现方法,每一位数字的滚动距离有一个通用的公式:
transform: `translate(-50%,-${number * 10}%)
有了以上公式,我们让数字滚动到 5
,它的效果如下:
当前上下文的值对象可能是另一个上下文的实体
实体是战术模式中同样重要的一个概念,但是现在我们先不做讨论,我们只需要明白实体是一个具有ID的事物就行了。也就是说一个同样的东西在当前环境下可能没有一个独有的标识,但可能在另一个环境下它就需要一个特殊的ID来识别它了。考虑上面的例子:
同样的五块钱,此时在一个货币生产的环境下。它会考虑这同样的一张五块钱是否重号,显然重号的货币是不允许发行的。所以每一张货币必须有一个唯一的标识作为判断。
同样的马桶,此时在一个物管环境中。它会考虑该马桶的出厂编码,如果马桶出现故障,它会被返厂维修,并且通过唯一的id进行跟踪。
显然,同样的东西,在不同的语境中居然有着不同的意义。
以第一个五块钱的值对象例子来作为说明,此时我们在超市购物的上下文中,我们可能已经捕获倒了一个叫做“钱”(Money)的值对象。按照以往我们的写法,来看一看会有一个什么样的代码:
尽量避免使用基元类型
仔细看上面的代码,你会发现,这没有问题呀,表明的很正确。我在超市购物中,我所具有的钱通过了一个属性来表明。这也很符合我们以往写类的风格。
此时,你应该可以根据你自己的所在环境和语境(上下文)捕获出属于你自己的值对象了,比如货币呀,姓名呀,颜色呀等等。下面我们来考虑如何将它放在实际代码中。
运动调查表(1) | |
---|---|
姓名 | ________ |
性别 | ________ (字符串) |
周运动量 | ________(整型) |
常用运动器材 | ________(整型) |
运动调查表(2) | |
---|---|
姓名 | ________ |
性别 | ________ (男\女) |
周运动量 | ________(0~1000cal\1000-1000cal) |
常用运动器材 | ________(跑步机\哑铃\其他) |
值对象是内聚并且可以具有行为
接下来是实现我们上文那个Money值对象的时候了。这是一个生活中很常见的一个场景,所以有可能我们建立出来的值对象是这样的:
现在应该比较清晰的能够理解该要点了吧。从运动表1中,仿佛出了性别之外,我们都不知道后面的空需要表达什么意思,而运动表2加上了该环境特有的名称和选项,一下就能让人读懂。如果将运动表1转换为我们熟悉的代码,是否类似于上面的MySupmarketShopping类呢。所谓的基元类型,就是我们熟悉的(int,long,string,byte…………)。而多年的编码习惯,让我们认为他们是表明事物属性再正常不过的单位,但是就像两个调查表所给出的答案一样,这样的代码很迷惑,至少会给其他读你代码的人造成一些小障碍。
.box-item span {
position: absolute;
top: 10px;
left: 50%;
transform: translate(-50%,-50%);
letter-spacing: 10px;
}
Money对象中我们还引入了一个叫做币种(Currency)的对象,它同样也是值对象,表明了金钱的种类。
接下来我们更改我们上面的MySupmarketShopping。
你会发现我们将原来MySupmarketShopping类中的币种属性,通过转换为一个新的值对象后给了money对象。因为币种这个概念其实是属于金钱的,它不应该被提取出来从而干扰我的购物。
public class MySupmarketShopping
{
public Money Amountofmoney { get; set; }
}
此时,Money值对象已经具备了它应有的属性了,那么就这样就完成了吗?
还是一个问题的思考,也许我在国外的超市购物,我需要将我的人民币转换成为美元。这对我们编码来说它是一个行为动作,因此可能是一个方法。那么我们将这个转换的方法放在哪儿呢? 给MySupmarketShopping? 很显然,你一下就知道如果有Money这个值对象在的话,转换这个行为就不应该给MySupmarketShopping,而是属于Money。然后Money类就理所当然的被扩充为了这个样子:
setInterval(() => {
let number = document.getElementById('Number')
let random = getRandomNumber(0,10)
number.style.transform = `translate(-50%, -${random * 10}%)`
}, 2000)
function getRandomNumber (min, max) {
return Math.floor(Math.random() * (max - min + 1) + min)
}
请注意:在这个行为完成后,我们是返回了一个新的Money对象,而不是在当前对象上进行修改。这是因为我们的值对象拥有一个很重要的特性,不可变性。
值对象是不可变的:一旦创建好之后,值对象就永远不能变更了。相反,任何变更其值的尝试,其结果都应该是创建带有期望值的整个新实例。
其实我们在平时的编码过程中,有些类型就是典型的值对象,只是我们当时并没有这个完整的概念体系去发现。
比如在.NET中,DateTime类就是一个经典的例子。有的编程语言,他的基元类型其实是没有日期型这种说法的,比如Go语言中是通过引入time的包实现的。
尝试一下,如果不用DateTime类你会怎么去表示日期这一个概念,又如何实现日期之间的相互转换(比如DateTime所提供的AddDays,AddHours等方法)。
这是一个现实项目中的一个案例,也许你能通过它加深值对象概念在你脑海中的印象。
该案例的需求是:将一个时间段内的一部分时间段扣除,并且返回剩下的小时数。比如有一个时间段 12:00 - 14:00.另一个时间段 13:00 - 14:00。 返回小时数1。
//代码片段 1
// 定时增长数字
increaseNumber () {
let self = this
this.timer = setInterval(() => {
self.newNumber = self.newNumber + getRandomNumber(1, 100)
self.setNumberTransform()
}, 3000)
},
// 设置每一位数字的偏移
setNumberTransform () {
let numberItems = this.$refs.numberItem
let numberArr = this.computeNumber.filter(item => !isNaN(item))
for (let index = 0; index < numberItems.length; index++) {
let elem = numberItems[index]
elem.style.transform = `translate(-50%, -${numberArr[index] * 10}%)`
}
}
//代码片段 2
接下来是代码片段2,在实现该过程时,我们先尝试寻找该问题模型中的共性,因此提取出了一个叫做时间段(DateTimeRange)类的值对象出来,而赋予了该值对象应有的行为和属性。
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);
}
}
首先来看一看代码片段1,使用了传统的方式来实现该功能。但是里面使用大量的基元类型来描述问题,可读性和代码量都很复杂。
通过寻找出的该值对象,并且丰富值对象的行为。为我们编码带来了大量的好处。
将值对象映射在表的字段中
该方法也是微软的官方案例Eshop中提供的方案,通过EFCore提供的固有实体类型形式来将值对象存储在依赖的实体表字段中。具体的细节可以参考 EShop实现值对象。通过该方法,我们最后持久化出来的
有关值对象持久化的问题一直是一个非常棘手的问题。这里我们提供了目前最为常见的两种实现思路和方法供参考。而该方法都是针对传统的关系型数据库的。(因为Nosql的特性,所以无需考虑这些问题)
将值对象单独用作表来存储
该方式在持久化时将值对象单独存为一张表,并且以依赖对象的ID主为自己的主键。在获取时用Join的方式来与依赖的对象形成关联。
正如这个小标题一样,目前可能并没有完美的一个持久化方式来供关系型数据库持久化值对象。方式一的方式可能会造成数据大量的冗余,毕竟对值对象来说,只要值是一样的我们就认为他们是相等的。假如有一个地址值对象的值是“四川”,那么有100w个用户都是四川的话,那么我们会将该内容保存100w次。
可能没有完美的持久化方式
总之,还是那句话,目前依旧没有一个完美的解决方案,你只能通过自己的自身条件和从业经验来进行对以上问题的规避,从而达到一个折中的效果。
而对于一些文本信息较大的值对象来说,这可能会损耗过多的内存和性能。并且通过EFCore的映射获取值对象也有一个问题,你很难获取倒组合关系的值对象,比如值对象A中有值对象B,值对象B中有值对象C。这对于建模值对象来说可能是一个很正常的事情,但是在进行映射的时候确非常困难。
对于方式二来说,建模中存在了大量的值对象,我们在持久化时不得不对他们都一一建立一个数据表来保存,这样造成数据库表的无限增多,并且对于习惯了数据库驱动开发的人员来说,这可能是一个噩梦,当尝试通过数据库来还原业务关系时这是一项非常艰难的任务。
//展示了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;
}
}
总结可能就是没有总结了吧。有时间的话继续扩充战术模式中其它关键概念(实体,仓储,领域服务,工厂等)的文章。