论代码的表达能力
一直想向部门的同事们推荐领域驱动的设计方法,但是一直找不到一个合适的切入点。直到某年某月某日,看到了快速排序的不同实现方式之后,若有所悟,遂成此文。
快速排序算法
快速排序算法的基本实现方式是找一个值作为标志,凡是比这个值大的值都放到左边的数组里,凡是比这个值小的值都放到右边的数组里,这样递归处理左边和右边的子数组,直到不能再把子数组进行拆分为止
下面是两种实现方式,为了具有可比性,我用支持多种编程范式的Scala实现,代码最接近Java,大部分Java程序员都能看懂
- 实现1
def quicksort(left: Int, right: Int, ary_input: Array[Int]): Array[Int] = {
if (left > right) {
return ary_input;
}
val temp = ary_input(left); //temp中存的就是基准数
var i = left;
var j = right;
while (i != j) {
//顺序很重要,要先从右往左找
while (ary_input(j) >= temp && i < j) {
j -= 1;
}
//再从左往右找
while (ary_input(i) <= temp && i < j) {
i += 1;
}
//交换两个数在数组中的位置
if (i < j) //当哨兵i和哨兵j没有相遇时
{
val t = ary_input(i);
ary_input(i) = ary_input(j);
ary_input(j) = t;
}
}
//最终将基准数归位
ary_input(left) = ary_input(i);
ary_input(i) = temp;
val ary_input_l = quicksort(left, i - 1, ary_input); //继续处理左边的,这里是一个递归的过程
val ary_input_r = quicksort(i + 1, right, ary_input_l); //继续处理右边的,这里是一个递归的过程
return ary_input_r;
}
- 实现2
def quickSort(list: List[Int]): List[Int] =list match {
case Nil => Nil
case head :: tail =>
val (left, right) = tail.partition(_ < head)
quickSort(left) ::: head :: quickSort(right)
}
重点介绍下实现2。match...case在scala里面被称为模式匹配,可以认为是增强版的switch...case。List也是Java的增强版,如果为空,其字面量就是Nil,一个List一般由head和tail组成,head代表List中的第一个元素,tail代表除了第一个元素以外的所有元素,所以第二个case语句可以理解成使用head作为标志值,把tail中的元素分成两部分,第一部分是比这个标志值小的所有值组成的集合,放到left里面。第二部分就是tail中不满足的那些值,放到right里面。scala中最后一行都是return,所以return关键字就省了。 quickSort(left) ::: head :: quickSort(right) 的意思是: 小值的列表+标志值+大值的列表。
把效率和空间占用放到一边,我们讨论下哪种实现方式最接近人类的直觉。
毫无疑问,第二种实现方式是最接近人类的直觉的,能够大幅度降低人们的思维负担。随着年龄的增大,越来越觉得能够降低人类思维负担的代码才能更称得上是好代码;面向人类思维优化的设计方式,才是更优秀的设计方式。
人们发明面向对象的设计方式,就是为了降低人类思维的负担。将属性和行为进行封装,模拟现实世界的事物,可以让人们编写程序时,能够对照现实世界事物真实的运转逻辑。从此人们编写程序的时候,犹如画国画,可具象可写意,无论使用什么技法,让人们看到后,总能知道你画的是什么。
面向对象设计的三大体现:封装、继承、多态。我们刚开始编程的时候就知道 了,但是知易行难,尤其是EJB时代之后,大部分的设计实现,又回到了C语言时代的过程式方式。我们平时使用的JavaBean或者Entity,不过是披着面向对象外衣的结构体(structure)而已。这种JavaBean在领域驱动设计里面,被称之为贫血模型。顾名思义,是一种不健康,甚至不健全的模型,已经失去了能正确描述现实世界的活力。
但是总有那么一群人忍受不了这种不健康的模型。 Eric Evans正是其中的代表,所以他给我们带来了领域驱动设计。
何为领域驱动设计
领域,指的是我们要解决的问题。事实上,领域是一个笼统的概念,它包含了一组相互作用的概念。比如,我要写一个车票预订程序,这个领域里面包含的概念就有乘客,出发地,目的地,车次,坐席规格等诸多概念,这些概念在某些时候是互相制约的。我们要做的其实就是理清这些概念之间的关系,并用代码清晰的表达出来。领域驱动设计指的是搞清楚问题中的概念,并沿着这些概念之间的关系进行设计的一种软件方法。
领域设计中的基本概念
我这里只是尝试以更简单的,非官方的方式介绍下这些概念,各位如果有兴趣可以去下面的链接上仔细阅读下领域驱动设计基本理论知识总结
领域通用语言(UBIQUITOUS LANGUAGE)
还是以上面车票预订程序为例。每当我们的产品,研发,设计,用户谈论乘客的时候,都是谈论的车票预订程序的乘客角色。假设我们的乘客只有两个属性,姓名和身份证号,那么我们的产品人员说乘客的时候,我们的研发立马能意识到说的是拥有这两个属性的实体类。相应的,我们的研发和产品讨论这个实体类的时候,也应该说“乘客”这个词,这样产品就能知道你所要表达的主体就是产品中的一个参与者。这就是领域通用语言,在软件开发中有统一认识的某些概念组成的语言。
实体(Entity)和值对象(Value Object)
这两个之所以拿到一起说,是因为这他们关系非常紧密,通过对比更能明白这两个的相同与不同。
拿12306的账号系统来说,每个账号只能在一个地方登录,这个账号就是一个实体,它具有全局唯一标识,整个系统里只能有一个id为aaa的账号。而aaa和bbb账户都能为身份证号为ccc的人订火车票,这个身份证信息在此处就是 一个值对象,它不与其他账号里面的ccc互斥。但是一旦购买火车票成功之后,你就没有办法再为这个身份证号在这个时间段购买车票了,这时候身份证信息是唯一的吗?显然不是,是由于时间段+身份证号才互斥的,并不是身份证号本身就是互斥的。我们购买火车票的时候,12306会提示我们是否购买保险,我们历史上所买过的保险应该都能看到,显然在保险公司的系统里,我们这个身份证信息对应的实体应该是唯一的(这涉及到了界限上下文的知识,下面有介绍)
还有另外一个不同,那就是Entity是可变对象,Value Object是不可变对象,这怎么理解呢? 每个人都有年龄,随着时间的推移,每个人的年龄都会增加,今年我十五,明年我十六,我还是我,我就是一个Entity对象。十五岁就是一个值对象,十五岁就是十五岁,不是十六岁也不是十四岁,十五岁这个值是不变的。我们不学古龙打机锋,我们用代码展示
public class Person{
String idCard;
String name;
Integer age;// Integer本身是不可变对象,所以当年龄增加的时候,并不是age指向的那个对象变了,而是指向了另一个对象的引用。
}
值对象之所以被设计成不可变对象,是因为它不具备全局唯一的属性,当我们把这个对象传播出去之后,不确定在哪个位置会改变这个对象引起原来持有这个对象的实体的变更。
工厂(Factory) 与仓库(Repository)
工厂和仓库,都是产生领域对象的地方,我们的对象不能永远存在于内存中,一旦其不需要活跃在内存中,就需要使用Repository将其存放起来,Repository就是用来持久化这些对象的。当我们需要从数据库或者其他地方向内存加载这些领域对象的时候,Repository可以帮我们从数据库中加载,组装成一个领域对象。而工厂顾名思义,我们一开始是没有领域对象的,我们需要使用的领域对象的时候,可以使用工厂帮我们生产一个。这里的工厂不只是设计模式中的简单工厂,抽象工厂,它也可以是一个工厂方法,或者建造者。领域对象应该从工厂中来,最后归于仓库
聚合及聚合根(Aggregate,Aggregate Root)
聚合是一个逻辑上的概念,一个聚合应该只有一个实体作为入口。这怎么理解呢? 我改写下上面定义的那个实体
public class Person{
String idCard;
String name;
Integer age;
Set<PersonInfo> friends
}
public class PersonInfo{
String idCard;
String name;
Integer age;
}
可以看到这里的PersonInfo的基本属性和Person一样,唯一的不一样的是Person有一个friends属性。设想一下,小红是小明的朋友,小明必然是小红的朋友,那么如果我们的朋友用Set<Person>表示的话,会发现这是一个剪不断的环,这种环不仅给序列化造成困扰,而且会形成“一表三千里”的场面。引入聚合的概念就是为了解决这种问题,每个聚合都是一个概念簇,入口的entity作为这个概念簇的中心,被称为“聚合根”,所有在这个聚合中的其他概念都可以由这个聚合根“导航”得到。
领域服务(Domain Service)
有些操作并不适合放到领域对象中完成,由此产生了领域服务。这个怎么理解呢?这个一般理解成行为的发起者和目标对象不是同一个主体的时候,应该使用领域对象——似乎我解释的更复杂了,还是以例子来说明吧。我们设计将一个账户的资金转移到另一个账户上的时候,由于账户本身的行为只有增加资金和减少资金,并不能控制另一个账户减少或者增加响应的资金,这时候,我们以一个领域服务完成这两个账户之间的转账就变成了顺理成章的事情。这时候,转账服务就是一个领域服务。
应用服务(Application)
应用服务,人如其名,就是为了完成某个功能而将其他实体,值,领域服务在某处组合起来,此处就被称为应用服务,这个编写应用服务,是我们做应用最终的目的,是我们业务逻辑的所在。
以上,是领域驱动设计的基本组件,我们通过这些组件就足以描述我们的业务需求了。
战术与战略
在有关于领域驱动设计的讨论中,有时人们会提到两个词:战术和战略。
当我们学习完上前一节所描述的那些名词的时候,实际上只是学会了领域驱动设计的战术部分,即如何组织我们的代码,但是比战术部分更重要的是战略部分,即如何组织我们的业务逻辑,只有真正学会了战略部分,我们才能真正的使用领域驱动设计,并发挥其威力。
战略部分的名词比战术部分还要少一些,但是复杂性会高一些。
边界上下文(Bounded Context)
边界上下文是整个战术部分的基础。所有的战略包括战术都是围绕着这个词展开的。其实理解起来很简单,那就是我们讨论一件事物的时候,总是在一个上下文中讨论它。《实现领域驱动设计》这本书中举了这样一个例子来描述这个问题:
考虑一个图书出版机构,他需要处理图书生命周期的不同阶段。粗略地讲,我们可以认为这些不同的阶段对应于以下不同的上下文环境:
- 概念设计,计划出书
- 联系作者,签订合同
- 管理图书编辑过程
- 设计图书布局,包括插图
- 将图书翻译成其他语言
- 出版纸质版或者电子版
- 市场营销
- 将图书卖给销售商或者直接卖给读者
- 将图书发送给销售商或者读者
以上所有阶段中,我们可以用一个单一的概念对图书进行建模吗?显然不行。
当我们不为概念区分边界上下文的时候,我们的模型就会变得臃肿不堪,甚至互相冲突。比如概念设计,图书编辑过程中是允许图书改名字的,但是进入出版发行阶段的时候,书名已经成了一个不能改变的了。通过划分边界上下文,我们的逻辑概念能得到清晰的表达。
协作上下文
不同的上下文是可以协作的,比如书籍编辑阶段完毕就要将书籍信息同步给出版阶段,这两个上下文就是可以协作的。而且不同的边界上下文有以下几种关系
- 合作关系
如果两个界限上下文的团队要么一起成功,要么一起失败,此时他们需要建立起一种合作关系。他们需要一起协调开发计划和集成管理。两个团队应该在接口的演化上进行合作,一同事满足两个系统的需求。应该为互相关联的软件功能制定好计划表,这样可以确保这些功能在同一个发布中完成- 共享内核
对于模型和代码的共享将产生一种紧密的依赖性,对于设计来说,这种依赖可好可坏,我们需要为共享的部分模型制定一个显式的边界,并保持共享内核的小型化。共享内核具有特殊的状态,在没有雨另一个团队协商的情况下,这种状态是不能改变的。我们应该引入一种持续集成过程来保证共享内核与通用语言的一致性。- 客户方-供应方
当两个团队处于一种上游-下游关系时,上游团队可能独立于下游团队完成开发,此时下游团队的开发可能会受到很大的影响,因此,在上游团队的计划中,我们应该顾及下游团队的需求。- 遵奉者
存在在上游-下游关系的两个团队中,如果上游团队已经没有动力提供下游团队之所需,下游团队便孤军无助了,出于利他主义,上游团队可能向下游团队做出种种承诺,但是有很大的可能是:这些承诺是无法实现的。下游团队只能盲目的使用上游的模型。- 防腐层
在集成两个设计的很好的界限上下文时,翻译层可能很简单,甚至可以很优雅的实现。但是,当共享内核,合作关系或者客户方-供应方关系无法顺利实现时,此时翻译将变得复杂。对于下游客户来说,你需要根据自己的领域模型创建一个单独的层,该层作为上下游系统的代理像你的系统提供功能,防腐层通过已有的接口与其他系统进行交互,而其他系统只需要做很小的修改,甚至无须修改。在防腐层内部,他在你自己的模型他方模型进行翻译转换。- 开放主机服务
定义一种协议,让你的子系统通过该协议来访问你的服务。你需要将该协议公开,这样任何想与你集成的人都可以使用该协议。在有新的集成需求时,你应该对协议进行改进或者拓展。对于一些特殊的需求,你可以采用一次性的翻译予以处理,这样可以保持协议的简单性和连贯性- 发布语言
在两个界限上下文之间翻译模型需要一种共用的语言。此时你应该使用一种发布出来的共享语言来完成集成交流。发布语言通常与开放主机服务一起使用。- 另谋他路(SeparateWay)
在确定需求时,我们应该坚持到底。如果两套功能没有显著的关系,那么他们是可以完全解耦的。集成总是昂贵的,有时带给你的好处也不大。声明两个界限上下文之间不存在任何关系,这样使得开发者去另外寻找简单的,专门的方法来解决。- 大泥球
当我们检查已有系统时,经常会发现系统中存在混在一起的模型,他们之间的边界是非常模糊的。此时你应该为整个系统绘制一个边界,然后将其归纳在大泥球的范围之列。在这个边界之内,不要试图使用复杂的建模收单来化解问题。同时,这样的系统有可能会向其他系统蔓延,你应该对此保持警觉。
软件架构
领域驱动设计将传统的软件模块进行了精细化的划分,这样势必影响到软件组织结构和软件架构。一般的,领域驱动将软件组织成以下几层
- Infrastructure 被称为基础设施,与数据库,邮件服务等服务打交道,但是不包括其他领域上下文中的服务。
- Domain 是我们的领域模型,包括Entity,Value Object,Aggregate,Domain Service
- Application 是我们的业务逻辑
- User Interface,不只是UI界面,还包括与其他系统的交互界面等。
在遵循了以上分层之后,领域驱动设计发展出多种架构
面向服务的架构
我们在某个界限上下文中的业务变迁永远在应用程序里面,这样内在的模型发生变化的时候,不会将这样变化传播到外传,从而防止污染其他的上下文。
命令与查询职责分离——CQRS
有时候我们向外暴露服务的时候,会使用一种被称为DTO的数据结构,DTO中可能包含了若干的领域对象一并返回,甚至需要用到聚合查询,这时候为了保持领域模型的纯粹性(单纯的表述业务模型),也为了优化查询速度,我们会对查询视图所展示的数据进行单独存储。这被称为命令与查询分离架构。
上图中,的Es是Event Souring的缩写,被称为事件溯源。我们对领域对象进行修改的时候,会产生一系列的事件,比如对于转账操作,A账户转入金额的时候,会产生一个金额转入的事件。这个事件本身就是转账操作的一个副作用,别人是否对这个事件感兴趣,以及对这个事件感兴趣的操作是否立马执行,转账这个操作是不会关心的。我们只需要把这个事件放到消息总线上,对这个消息感兴趣的服务会由消息总线帮我们触发。
一般的,如果我们把这种机制进行推广——所有的操作都依赖于领域事件,那么当我们把历史上所有的操作都存储到数据库中,那么我们通过反演这些事件,能够得到处于任何时刻的领域模型数据库。这样通过反演,可以纠正任何时刻因为不正确的计算导致的结果。甚至,我们搞一套AB环境,我们只要有这些领域事件,我们就可以在不同的环境中得到不同的结果。