摘要: 《大话重构》这本书是我写的第一本书,从今天起我将通过连载的形式逐渐跟大家分享。 阅读全文
posted @ 2014-06-18 09:59 充满诗意的联盟 阅读(1755) 评论(3) 推荐(3) 编辑

这些年,随着软件业的不断发展,软件系统开始变得越来越复杂而难于维护。这时,越来越多的开发团队开始选择实践DDD领域驱动设计。领域驱动设计是一种非常优秀的软件设计思想,它可以非常好地帮助我们梳理复杂业务,解决大规模业务系统的设计开发与更新维护。但是,领域驱动的学习成本却非常高,使得很多同学难于准确地理解DDD,更难于真正落地实际项目的设计编码。为此,我通过这一系列知识分享,让大家真正准确地理解DDD中这些晦涩的概念,特别是让大家理解最终是怎么落地到软件项目的设计开发中的。

今天,我们首先探讨的是在DDD中,让大家最头疼、最晦涩的概念——什么是“值对象”?要理解“值对象”的设计思想,首先我们先来梳理一下领域驱动的核心思想。领域驱动认为,软件的本质就是对真实世界的模拟,软件中所有业务逻辑正确与否,唯一的判定标准就是,是否与真实世界保持一致。如果一致,设计就是OK的,否则用户就会提BUG,或者变更需求。只要理解了这个本质,软件设计就简单了:我们首先理解真实世界的业务,然后将我们对业务的理解形成软件设计。

然而,这里有一个问题,那就是软件是怎么与真实世界对应的呢?这种对应体现在以下三个方面:

1)真实世界有什么事物,软件世界就有什么对象;

2)真实世界中这些事物有什么行为,软件世界这些对象就有什么方法;

3)真实世界中这些事物间有什么关系,软件世界中这些对象就有什么关联。

因此,我们在软件设计时,首先以业务场景为单位,一个一个地分析每个业务场景都有哪些领域对象,以及它们相互之间的行为与关系,形成领域模型。然后再以领域模型为核心,完成软件的设计与开发。

譬如说,我们要设计一套电子商务系统,按照业务需求会划分为很多功能,那么每个功能就是一个业务场景。譬如在“下单”这个业务场景中,在真实世界中都有哪些事物呢?首先有“订单”,可以形成订单对象;每个订单对应一个用户,但一个用户可以有多个订单,所以从订单到用户是一个“多对一”关系;一个用户有多个地址,然而一个订单只能对应一个地址……如下图,我们按照这样的步骤逐一分析领域对象和它们之间的关系。最后,我们对订单有什么操作呢?有“下单”、“支付”、“查看订单”……

这就是领域模型,订单、用户、地址、订单明细等类,都是领域对象。然而,在DDD中还要把领域对象严格区分为“实体”与“值对象”。这时,很多同学就比较晕,什么是实体?什么是值对象?为了准确理解这个地方,我们先看看《领域驱动设计》原著是怎么说的。

实体(Entity):又称为Reference Object,它具备生命周期,并且在生命周期的过程中,其形式和内容都有可能发生变化,但它的标识永远不变。也就是说,每个实体都有一个唯一的标识,用于区分真实世界中的“他”与其他人,比如用户ID。实体是有生命周期的,比如用户的注册与注销;实体中的一切都可能会变,比如小张将自己的账户转让给了李四,但账户ID是不会变的。这个唯一的标识就是主键,以这种形式的设计,用户有用户ID、账户有账户ID、订单有订单ID,就是实体的设计。很显然,这样的设计大家都能够理解,关键是“值对象”。

值对象(Value Object):在《领域驱动设计》这本书中对值对象的描述比较隐晦,但我们大致可以归纳为以下几个特征:对象声明与对象中的属性都是不变的,可以为多个对象所引用与共享(而不是复制),这就是“值对象”。该怎样来理解这几个特征呢?

譬如,在真实世界中,一个用户可以有多个订单,那么在订单查询时,每个订单就是一个订单对象,每个订单对象都有各自不同的订单ID。因此,订单的设计是实体。然而,每个订单都要引用一个用户,也就是指向一个用户对象。那么,同一个用户的多个订单指向的是谁呢?显然,不能将这个用户复制成多个对象,而是所有这几个订单都引用的是这一个对象。也就是说,这里的“用户”对象是值对象。

然而,值对象又必须是不变的,这就是说,如果“用户”是值对象,那么用户及其相关的属性都必须是不变的。但现实情况是,用户及其用户信息都是可变的,我们会对用户信息进行增删改操作。是的,在真实世界中,一切都是在变化,没有什么是不变的。那么,怎么来理解值对象的不变性呢?

在DDD的领域建模中,除了有领域模型以外,还有一个非常重要的设计叫“限界上下文”。领域模型是描述的真实世界,但真实世界又是无限大的,那么领域模型该如何描述真实世界呢?DDD将一个复杂系统的业务划分成很多个限界上下文,然后对每个限界上下文中进行领域建模。这样,每个领域模型都有各自的边界,我只是在这个边界中描述我的业务。这样的设计,就将纷繁复杂的世界“分而治之”,对每个领域模型的分析就变得简单了。

譬如在以上案例中,可以首先将对用户及其用户档案的管理划分成一个上下文,叫“用户上下文”。在这个上下文中,用户及其属性是可变的,要进行增删改的操作,因此“用户”对象的设计是实体。然而,在另一个“订单上下文”中,订单是实体,要对其进行增删改,但订单引用的“用户”以及“地址”就是值对象。也就是说,在订单上下文中,“用户”以及“地址”是只读的,仅仅用于订单对象的引用,而不会对其进行增删改操作。这样,“用户”以及“地址”作为值对象,就体现了它的只读与不变性。

此外,站在开发的角度来说,领域模型最终要落实到软件开发。如果采用微服务的设计,就会将“用户上下文”与“订单上下文”划分为用户微服务与订单微服务。在划分微服务的同时,也会划分数据库,用户微服务有用户数据库,订单微服务有订单数据库。有了这样的设计,用户表必然是设计在用户数据库中,而不是在订单数据库中。当订单微服务要查询订单时,通过调用用户微服务的接口获得订单相关的用户对象。然而,这些用户对象只存在于订单微服务的内存中,是只读的,而不会存储在订单数据库中去增删改。这就是“值对象”的概念及其设计实现。

总而言之,DDD的实体在本上下文中是可读可写的,而值对象是只读的。在一个上下文中的核心业务是对实体的增删改,而值对象是与实体相关联的,仅仅用于实体的引用与查询的对象。值对象的特性是只读,但在实际项目中的表现有2种形式:

1)  对其它微服务中对象的引用,需要调用其它微服务的接口获得,数据不在我本地的数据库中,仅仅存在于内存中,并且不能修改,只能查询;

2)  一些类似类型、种类、类别的字典数据,虽然数据存储在本地数据库中,但没有增删改的功能,只有查询与引用。例如会员等级、积分规则、支付方式,等等。

(待续)

posted @ 2024-12-26 22:39 充满诗意的联盟 阅读(63) 评论(0) 推荐(1) 编辑
摘要: 很好,我们终于迈出了重构的第一步,而这第一步我们瞄准了代码问题的重灾区——超级大函数。超级大函数之所以是代码问题的重灾区,就是因为它们往往难于阅读、难于维护。面对大函数我们采取的办法是拆分,以功能为核心将其拆分成一个一个独立的函数。拆分后的程序变得易于阅读了,因为要读懂程序你不再需要读完所有代码,选择性的读取那些顶级函数,只需了了数行代码,你就可以明白整个程序。 但是,当我们将数千行的大函数分解成数十个小函数时,另一个问题出现了。想象一下,数十个函数被杂乱无章地堆放在一个对象中,看看就让人头疼。实际上,我们是不会这样做的。当我们开始了对大函数的分解时,随之而来的就是对大对象的分解。大对象,就是指的那些包含数十个甚至上百个方法或者函数,功能无所不包的超级对象。在很多遗留系统中,总有那么几个超级对象,系统几乎所有的功能都在它的里面有对应方法。这样的对象,密密麻麻的方法让人困惑,更关键的是,各种各样的功能被耦合在一起,稍有修改就会影响到许多功能,甚至让那些毫不相干的功能产生BUG。因此,我们应当合理地拆分我们的大对象。 阅读全文
posted @ 2014-11-17 10:04 充满诗意的联盟 阅读(866) 评论(0) 推荐(0) 编辑
摘要: 使用抽取方法,虽然道理十分简单,但实际操作起来却并不是那么容易的。完成抽取方法最大的困难,就是如何处理抽取函数与原函数的数据交换。如同将一颗大树从土壤里拔出来,盘根错节的根茎,那是剪不断理还乱。当代码还没有被抽取出来之前,它们与其它程序都是在一个函数的内部,因此各个代码段可以毫无顾忌地相互交互数据。但当我们将代码从原函数中抽取出来时,抽取出来的代码与原函数中的代码就形成了一道墙,要交换的数据只能通过参数与返回值进行交互,这将给我们带来诸多麻烦。 阅读全文
posted @ 2014-11-02 19:42 充满诗意的联盟 阅读(720) 评论(0) 推荐(0) 编辑
摘要: 说了那么多理论,我们来看看怎样使用抽取方法来重构遗留系统。如前所述,重构的过程首先是阅读程序代码,边阅读边整理程序。将功能相对独立的代码段放在一起,在前面加上注释。调整一些程序的顺序,将相关的代码尽量放在一起,但要保证程序执行的结果不会发生改变。比较典型的,将变量的定义与使用变量的代码放在一起。这个步骤比较实用,因为许多的遗留系统,其代码都有一个坏毛病,就是在程序开始时定义一大堆变量,但要弄清这些变量都用来做什么,却十分困难。边读边调整,将变量的定义逐渐迁移到使用它的代码段中,将大大提高代码可读性,你甚至会发现并简化一些变量。 阅读全文
posted @ 2014-10-29 09:40 充满诗意的联盟 阅读(994) 评论(0) 推荐(0) 编辑
摘要: 事情总是这样的:当我们对一个遗留系统一忍再忍,再忍,忍,还要忍……终于积攒到某一天,实在忍无可忍了,拍案而起,不能再忍了,重构!!!事情就这样发生了。然而,在这时你突然发现,重构的工作千头万绪,真不知从何开始。堆积如山的问题此起彼伏,期望修改的设计思绪万千。这里有个想法,那里有个思路,什么都想做,却什么都做不了,真是脑子里一团乱麻。这时候,没有一个合理的步骤,清晰的计划,瞎干蛮干是十分危险的,它会为你的重构带来不可预期的未来。无数次的经验告诉我,不论是什么系统,采用什么架构,从分解大函数开始,肯定没有错。 阅读全文
posted @ 2014-10-18 22:05 充满诗意的联盟 阅读(1134) 评论(2) 推荐(1) 编辑
摘要: 第五次重构我们引入了数据库的设计,用户信息要从数据库中读取,问候语库存储在数据库中,并支持添加与更新。数据库的引入使自动化测试变得困难了,因为数据状态总是变化着的,而这种变化使得测试过程不能复现,这是我们不愿看到的。因此,我们在设计时将业务与数据库访问分离,形成了UserDao与GreetingRuleDao。此时,我们的设计应当遵从“依赖反转”原则,即将UserDao与GreetingRuleDao设计成接口,并编写它们的实现UserDaoImpl与GreetingRuleDaoImpl。这样设计就为我们Mock掉UserDao与GreetingRuleDao的实现类创造了条件。 这是我们的设计: 阅读全文
posted @ 2014-09-21 13:10 充满诗意的联盟 阅读(985) 评论(0) 推荐(0) 编辑
摘要: 说了那么多,让我们用示例看看,系统重构是应该怎样做自动化测试的。还是回到前面那个HelloWorld的例子(详见 3.3 小步快跑是这样玩的),该类中有一个sayHello()方法,只要我们输入当前的时间与用户名,就返回对该用户的问候语。如果当前时间是上午,则返回“Hi, XXX. Good morning!”;如果是下午,则返回“Hi, XXX. Good afternoon!”;如果是晚上,则返回“Hi, XXX. Good Night!”,这是HelloWorld这个程序实现的功能。 然后我们开始为这段程序编写测试代码 阅读全文
posted @ 2014-09-18 01:21 充满诗意的联盟 阅读(1442) 评论(6) 推荐(3) 编辑
摘要: 正如许多事情都有其两面性一样,测试方法也是这样。要保证测试方法正确,最简单、最直观地想法就是多写些测试用例,从更多地角度去测试,但这必然增加我们的测试成本。小步快跑要求我们频繁进行测试,假如我们重构的周期是20分钟,但测试却要花掉10分钟,那么这样的成本就实在太大了。假如这种测试还是开发人员手工测试,每天都有对同样的测试反复执行数十遍,那么开发人员估计就要疯掉了。 你可能立即就想到自动化测试了。是的,在许多重构的书籍中,大师们都建议我们在重构开始前,首先建立自动化测试机制。但遗憾的是,我经过多年的实践总结出来的经验是,这几乎不可能实现。 阅读全文
posted @ 2014-09-09 11:07 充满诗意的联盟 阅读(1033) 评论(2) 推荐(2) 编辑
摘要: 通过前面的描述你已经对重构中“小步快跑”的开发模式有了一个清楚的认识。学会和习惯小步快跑的开发模式,对于重构工作极其重要,因为它让这种大范围、大幅度修改代码的重构工作变得不再像以往那样让人胆战心惊。究其原因,虽然从结果上是在大范围、大幅度调整,但每一步却是小范围、小福度调整,并且能保证每一步都是正确的。 然而,这里有一个非常重要的假设条件,那就是“保证每一步都是正确的”,这是怎么保证的呢?就这个问题,我们需要展开来认真讨论讨论。 阅读全文
posted @ 2014-09-08 07:15 充满诗意的联盟 阅读(837) 评论(0) 推荐(0) 编辑
摘要: 说了那么多,相信你对小步快跑的概念有了一个初步的印象,但理解还不是很深。让我们来看一看一个实际工作中的例子,来亲身感受一下什么是大布局,什么是大设计,什么是小设计。 还是回到前面那个Hello World的例子,起初的需求总是简单而清晰的。当用户登录一个网站时,网站往往需要给用户打一个招呼:“hi, XXX! ”。同时,如果此时是上午则显示“Good morning! ”,如果是下午则显示“Good afternoon! ”,除此显示“Good night! ”。对于这样一个需求我们在一个HelloWorld类中写了十来行代码: 阅读全文
posted @ 2014-08-26 11:00 充满诗意的联盟 阅读(1170) 评论(5) 推荐(3) 编辑
点击右上角即可分享
微信分享提示