领域驱动设计(DDD)实践之路(三):如何设计聚合

本文首发于 vivo互联网技术 微信公众号 
连接: https://mp.weixin.qq.com/s/oAD25H0UKH4zujxFDRXu9Q
做者:wenbo zhang

【领域驱动设计实践之路】往期精彩文章:前端

这是“领域驱动设计实践之路”系列的第三篇文章,分析了如何设计聚合。聚合这个概念看似很简单,实际上有不少因素致使咱们创建不正确的聚合模型。本文对这些问题逐一进行剖析。redis

聚合这个概念看似很简单,实际上有不少因素致使咱们创建不正确的聚合模型。一方面,咱们可能为了使用上的一时便利将聚合设计得很大。另外一方面,由于边界、职责的模糊性将一些重要的方法放在了其余地方进而致使业务规则的泄露,没有达到聚合对业务边界的保护目的。在开始聚合以前,咱们要区分清楚“实体Entity”“值对象Value Obj”的区别,而且要重视“值对象Value Obj”的真正价值。数据库

(图片来源于网络)缓存

1、实体(Entity) OR 值对象(Value Obj)

领域驱动设计里面有两个重要的概念,“实体Entity”“值对象Value Obj”。不少人讲解时候会举相似这样的例子:用户在某电商平台下单,其收货地址为“XX市YY街道ZZ园区”。现实场景中多个用户的收货地址有多是同一个,因此会把地址建模成Value Obj,借此把Value Obj简单解释成“描述性的、不变的东西,好比地址”。这样的解释彷佛也能说明问题,可是我以为尚未深刻到本质去探究、容易忽略Value Obj的真正要义。安全

一、实体Entity

一些对象不只仅是由它们的属性定义组成的,咱们更关心其延续生命周期内经历的不一样状态阶段,这是咱们业务域的核心。咱们出于追踪的目的,须要给每个实体设置惟一标识。一般的,咱们也会将其持久化到数据库中,实体即表里的一行记录。所以,当咱们须要考虑一个对象的个性特征,或者须要区分不一样的对象时,咱们引入实体这个领域概念。一个实体是一个惟一的东西,而且能够在至关长的一段时间内持续地变化。咱们能够对实体作屡次修改,故一个实体对象可能和它先前的状态大不相同。可是,因为它们拥有相同的身份标识(identity),它们依然是同一个实体。对于某电商平台而言,一个个的用户就是实体,咱们要对他们加以区别而且持续的关注他们的行为。微信

实体有特殊的建模和设计思路。它们具备生命周期,这期间它们的形式和内容可能发生根本改变,但必须保持一种内在的连续性,即全局惟一的id。它们的类定义、职责、属性和关联必须由其标识来决定,而不依赖于其所具备的属性。即便对于那些不发生根本变化或者生命周期不太复杂的实体,也能够在语义上把它们做为实体来对待,这样能够获得更清晰的模型和更健壮的实现。固然,软件系统中的大多数实体能够是任何事物,只要知足两个条件便可,一是它在整个生命周期中具备连续性,二是它的区别并非由那些对用户很是重要的属性决定的。根据业务场景的不一样,实体能够是一我的、一座城市、一辆汽车、一张彩票或一次银行交易。网络

跟踪实体的标识是很是重要的,但为其余全部对象也加上标识会影响系统性能并增长分析工做,并且会使模型变得混乱,由于全部对象看起来都是相同的。软件设计要时刻与复杂性作斗争,咱们必须区别对待问题,仅在真正须要的地方进行特殊处理。好比在上面的例子中,咱们把收货地址“XX市YY街道ZZ园区”建模成具备惟一标识的实体,那么三个用户就会建立三个地址,这对于系统来讲彻底没有必要甚至还会致使性能或者数据一致性问题。数据结构

二、值对象Value Obj

当咱们只关心一个模型元素的属性时,应把它归类为值对象。咱们应该使这个模型元素可以表示出其属性的意义,并为它提供相关功能。值对象应该是不可变的;不要为它分配任何标识,并且不要把它设计成像实体那么复杂。即描述了领域中的一些属性,好比用户的名字、联系方式。固然也会存在一些复杂的描述信息,其自己可能就是一个对象,甚至是另外一个实体概念。闭包

在前述的电商例子中地址是一个值对象。但在国家的邮政系统中,国家可能组织为一个由省、城市、邮政区、街区以及最终的我的地址组成的层次结构。这些地址对象能够从它们在层次结构中的父对象获取邮政编码,并且若是邮政服务决定从新划分邮政区,那么全部地址都将随之改变。在这里地址是一个实体。架构

在电力运营公司的软件中,一个地址对应于公司线路和服务的一个目的地。若是几个室友各自打电话申请电力服务,公司须要知道他们实际上是住在同一个地方,由于咱们真实服务的是用户所在地方的电力资源,在这种状况下,咱们会认为地址是一个实体。可是随着思考的深刻,咱们发现能够换种方式,抽象出一个电力服务模型并与地址关联起来。经过这样的设计之后,咱们发现真正的实体是电力服务,地址不过是一个具备描述性的值对象而已。

在房屋设计软件中,能够把每种窗户样式视为一个对象。咱们能够将“窗户样式”连同它的高度、宽度以及修改和组合这些属性的规则一块儿放到“窗户”对象中。这些窗户就是由其余值对象组成的复杂值对象,好比圆形天窗、1m规格平开窗、狭长的哥特式客厅窗户等等。对于“墙”对象而言,所关联的“窗户”就是一个值对象,由于仅仅起到描述的做用,“墙”不会去关心这个窗子昨天是什么样,以致于当咱们以为这个窗户不合适的时候直接用另一个窗户替换便可。

归根结底,咱们使用这个窗户对象来描述墙的窗户属性。可是在该房屋设计软件的素材系统中,它的主要职责就是管理窗户这一类的附属组件,那么对它而言窗户就是一个鲜活的实体。从这个例子中咱们能够看出,所属业务域很重要,这也就是咱们以前所讲述的上下文,即同一对象在不一样上下文中是不同的。

当你决定一个领域概念是不是一个值对象时,你须要考虑它是否拥有如下特征:

  • 它度量或者描述了领域中的某个概念属性;

    当你的模型中的确存在一个值对象时,无论你是否意识到,它都不该该成为你领域中的一件东西,而只是用于度量或描述领域中某件东西的一个概念。一我的拥有年龄,这里的年龄并非一个实在的东西,而只是做为你出生了多少年的一种度量。一我的拥有名字,一样这里的名字也不是一个实在的东西,而是描述了如何称呼这我的。

  • 它能够做为不变量;

    值对象可能会被共享,因此具备不变性,即调用方不能对其执行set操做。

  • 它将不一样的相关的属性组合成一个概念总体;

    一个值对象能够只处理单个属性,也能够处理一组相关联的属性。在这组相关联的属性中,每个属性都是总体属性所不可或缺的组成部分,这和简单地将一组属性组装在对象中是不一样的。若是一组属性联合起来并不能表达一个总体上的概念,那么这种联合并没有多大用处。好比货币与单位、币种应该是一个总体概念,不然很难明白12到底表明什么意思?12美分仍是12元RMB。

  • 当度量和描述改变时,能够用另外一个值对象予以替换;

    好比随着时间推移,用户年龄从21岁变成22岁,即22替换21。

2、聚合(Aggregate)

每一个对象都有生命周期,对象自建立后可能会经历各类不一样的状态,要么被暂存、要么删除直至最终消亡。固然,不少对象是简单的临时对象,仅经过调用构造函数来建立,用来作一些计算,然后由垃圾收集器回收。这类对象不必搞得那么复杂。但有些对象具备更长的生命周期,其中一部分时间不是在活动内存中度过的。它们与其余对象具备复杂的相互依赖性。它们会经历一些状态变化,在变化时要遵照一些固定规则。管理这些对象时面临诸多挑战,稍有不慎就会把本身带入一个大泥坑。

减小设计中的关联有助于简化对象之间的遍历,并在某种程度上限制关系的急剧增多。但大多数业务领域中的对象都具备十分复杂的联系,以致于最终会造成很长、很深的对象引用路径,咱们不得不在这个路径上追踪对象。在某种程度上,这种混乱状态反映了现实世界,由于现实世界中就不多有清晰的边界。但这倒是软件设计中的一个重要问题,幸而咱们能够借助“聚合”来应对。

首先,咱们须要用一个抽象来封装模型中的引用。聚合就是一组相关对象的集合,咱们把它做为数据修改的单元。每一个都有一个根(root)和一个边界(boundary)。边界定义了聚合内部都有什么。根则是聚合所包含的一个特定实体。对聚合而言,外部对象只能够引用根,而边界内部的对象之间则能够互相引用。除根之外的其余实体都有本地标识,但这些标识只在聚合内部才须要加以区别,由于外部对象除了根以外看不到其余对象。

3、一些关于聚合的实践

关于聚合、实体的概念已经描述清楚了,下面我打算借助一个例子来继续深刻探讨聚合的相关知识。

案例:汽车模型设计

约束:首先一辆汽车在车辆登记机构归属于惟一一我的或者企业主体(实际上企业也具备法人,因此即便是企业主体也能够找到对应的归属人);其次,正如你们所常见的,咱们探讨是目前技术所能实现的、且广泛流行的车辆结构,一辆车具备4个轮子、一个引擎;

一、业务边界

Car、Customer很天然的按照实体进行对待;发动机做为一个产品交付时候有惟一序列号,考虑到其可能的特性咱们姑且也视其为实体;由于有4个轮子,可能须要进行区分因此也被视为实体。综上可知,咱们先把4个对象都当作实体。由于是建模汽车相关业务,因此咱们把Car视为根。至此,咱们获得了一个强大的聚合,包含车轮、引擎以及所属人信息。

public class Car {
    private Customer customer;
    /**
    * WheelPositionEnum枚举标识轮子状态
    * FR FL BR BL依次标识前右、前左、后右、后左轮
    * 在聚合内部保持独立
    */
    private Map<String, Wheel> wheels;
    private Engine engine;
     
    //其余属性暂略
}

当咱们分析出聚合之后,事情尚未结束。聚合表达的是业务,那么业务的规则、约束如何来保证呢?

  • 根ENTITY即Car具备全局标识,它最终负责检查固定规则。
  • 根ENTITY具备全局标识。边界内的ENTITY具备本地标识,这些标识只在从聚合内部才是惟一的,好比上面的车轮集合。
  • 删除操做必须一次删除AGGREGATE边界以内的全部对象。(利用垃圾收集机制,这很容易作到。因为除根之外的其余对象都没有外部引用,所以删除了根之后,其余对象均会被回收。)咱们能够想象,当汽车不存在的时候,咱们更不会去关心其车轮状况,“皮之不存毛将焉附”。
  • AGGREGATE外部的对象不能引用除根ENTITY以外的任何内部对象。即咱们不可能先获取到车轮对象,而后去反向获取Car对象,这样就等于创建了Car、Wheel的双向关联而且对调用方而言会很困惑。我什么状况下能够直接使用Wheel、什么时候能够直接使用Car,这是系统走向腐败的第一步。

如今咱们看下代码实现,Car具备全局惟一id用以区分不一样对象;且负责约束的检查,好比是否具备4个轮子、是否有一个引擎,不然不能正常使用。也许咱们平常开发中的作法是调用方获取到一个Car实例之后,去校验这些规则是否知足,这样作的问题就是业务规则的泄露。

public Car getCar(Long id) {
    Car car = carRepostory.ofId(id);
    if (car.getEngine() == null ||
        car.getWheels().keySet().size() != SPECIFIC_WHEEL_SIZE) {
        throw new CarStatusException(id);
    }
    return car;
}
 
/**
*上述代码存在的问题,毕竟现实中有报废、废弃的Car
*1.命名getCar实际上进行了状态检查,命名与实际语义不符;
*2.Car的状态约束泄露到调用方;
*3.虽然面向流程写出的是能够工做的代码,但咱们更推荐
*  面向领域的封装代码;
**/
public Car getWorkableCar(Long id) {
    Car car = carRepostory.ofId(id);
    //业务约束由Car本身承担
    if (!car.workable()) {
        throw new CarStatusException(id);
    }
    return car;
}

二、警戒性能问题

在具备复杂关联的模型中,要保证对象更改的一致性是很困难的。不只互不关联的对象须要遵照一些固定规则,并且紧密关联的各组对象也要遵照一些固定规则。然而,过于谨慎的锁定机制又会致使多个用户之间毫无心义地互相干扰,从而使系统不可用。引用自《领域驱动设计》P82。

在上面的模型中,Engine被视为Car聚合内的一个实体,这就意味着要对Engine作修改必须先拥有Car全部权。如今咱们遇到一个需求:发动机制造商忽然发现其交付的产品存有安全隐患,须要跟踪运行效果以及经过网络进行补丁安装。

(1)如何解决争用问题?

Car对象自身对Engine存有一些写的逻辑,好比更新发动机的使用状况;发动机制造商也要对Engine作一些升级。这里面可能有一些业务限制,好比发动机升级期间不提供对外服务,这里面为了规避并发可能要进行一些加锁操做,这就会致使性能问题。

(2)如何解决效率问题?

制造商不能直接获取到Engine对象,由于对外部而言拥有Car实例才能有渠道去得到Engine实例。这就致使了效率问题,由于制造商不得已只能去遍历全部Car实体。

所以咱们考虑把发动机做为一个单独的业务域,Car聚合里面只须要记录EngineId。不管是发动机的运行数据或者发动机的监控、升级等操做,都由发动机本身负责。同时由于Car聚合记录了EngineId,必要的状况下咱们能够方便的从EngineRepository中得到Engine对象,这也算是作到了懒加载。能够想象,系统中假如存在千万级别的Car实例,按照最初的方案就会有千万级别的Engine对象,可是我相信并非每一次对Car实例的调用都须要获取其Engine信息,这就形成了大量的内存消耗。相对于最初的方案,咱们的聚合或更小,也更灵活。

public class Car {
    private Customer customer;
    private Map<String, Wheel> wheels;
    //咱们构造单独的Engine聚合。
    //此处只记录EngineId,须要时候再去获取实例。懒加载。
    //从实体转为值对象
    private String engineId;
     
    //......
}

在聚合中,若是你认为有些被包含的部分应该建模成一个实体,此时你该怎么办呢?首先,思考一下,这个部分是否会随着时间而改变,或者该部分是否能被所有替换。若是能够所有替换,那么请将其建模成值对象,而非实体。有时,建模成实体也是有必要的。可是不少状况下,许多建模成实体的概念均可以重构成值对象。聚合的内部建模成值对象有不少好处的。根据你所选用的持久化机制,值对象能够随着根实体而序列化,好比咱们能够把EngineId和Car一块儿存放;而实体则须要单独的存储区域予以跟踪,此外实体还会带来某些没必要要的操做,好比咱们须要对多张表进行联合查询。可是对单张表进行读取要快得多,而使用值对象也更加方便与安全。再者因为值对象是不变的,测试起来也相对简单。

在实际项目中,即便没有并发锁、没有大事务,咱们依然还会遇到写操做性能问题。Car被废弃处理之后,咱们可能不只仅是更新对应数据库记录信息。咱们还须要在车辆登记机构进行销户操做;对应的车轮、发动机相关的数据记录如何处理等等。若是你期望一个方法体里面处理完这些逻辑,我敢保证你的代码响应时间会很是之久,甚至致使“汽车报废”业务不可用。所以咱们要去思考这个过程,哪些是核心逻辑,哪些容许必定的时延,对复杂的逻辑进行异步处理。好比:咱们发布CarAbandonedEvent进而由相应的handler去处理后续的业务规则。

三、值对象-无反作用

值对象的方法应该被设计成一个无反作用函数,即只用于生成输出而不会修改对象的状态。对于不变的值对象而言,全部的方法都必须是无做用的函数,由于它们不能破坏值对象的属性值才能安全的被共享。咱们要意识到值对象毫不仅仅是一个属性容器,其真正的强大特性“无反作用函数”。好比上面的窗户对象,当其被实例化出来之后各个属性就不能被肆意修改了,咱们通用的作法是在构造方法里面进行赋值或者基于工厂方法得到,总之千万拒绝提供public的set方法,由于你不知道哪一个小伙伴在你不知情的状况setBomb。当管理窗户的附属资源系统进行升级,可能致使某低版本的窗户对象不可用时候只须要对系统发送一个WindowsUpgradedEvent,进而由各个业务方去检查是否替换使用新的窗户对象。

一个值对象容许对传入的实体对象进行修改吗?若是值对象中的确有方法会修改实体对象,那么该方法仍是无反作用的吗?该方法容易测试吗?所以,若是一个值对象方法将一个实体对象做为参数时,最好的方式是,让实体对象使用该方法的返回结果来修改其自身的状态。

好比某车辆养护机构提供喷绘功能,用户基于三原色自由组合本身喜好的颜料。咱们定义了Paint对象,其颜色由red、yellow、blue构成。在这里“颜色”是一个很是重要的概念。你能够想象某种网红流行颜色必然会被你们追捧,在这段期间频繁地被系统建立出来。经过前面的论述,咱们试着显示定义PigmentColor专门用于三原色的管理。其自己也会做为一个值对象被Paint使用。

public class Paint {
    private PigmentColor pigmentColor;
    private Double volume;
     
    //必定量的颜料A能够与其余颜料混合配比使用,那么咱们可能定义一个mixedWith方法
    //还有一个疑问就是混合后的Paint对象究竟是不是原来的?
    public void mixedWith(Paint anotherPaint){
        //1.add volume
        //2.颜料混合
        //3.then, but...who am I
    }
}

把PigmentColor分离出来以后,确实比先前表达了更多信息,但混合计算的逻辑该怎么实现也是一个头疼的事情。当把颜色数据移出来后,与这些数据有关的行为也应该一块儿移出来。可是在作这件事以前,要注意PigmentColor是一个值对象,所以应该是不可变的。当咱们混合调配时,Paint对象自己被改变了,它是一个具备生命周期的实体。相反,表示基个色调(棕色、黑色、白色)的PigmentColor则一直表示那种颜色。Paint的结果是产生一个新的PigmentColor对象,用于表示新的颜色。

public class PigmentColor {
    //mixedwith做为值对象的无反作用方法,返回一个新的对象由调用方决定是否使用。
    public PigmentColor mixedwith(PigmentColor otherPigment, Double ratio) {
        //混合的逻辑
        return 新的PigmentColor对象;
    }
}
 
/**
*
* 若是一个操做把逻辑或计算与状态改变混合在一块儿,那么咱们
* 就应该把这个操做重构为两个独立的操做。
* 逻辑计算能够视为命令,咱们对于结果的获取视为查询。这也
* 符合命令查询分离的原则。
*/
public class Paint {
    public void mixedwith(Paint other) {
        this.volume += other.getVolume();
        Double ratio = other.getVolume() / this.volume;
        //用新返回的颜料对象替换当前的颜料对象,
        //经过能够替换的值对象维护Paint实体的完整性。
        this.pigmentColor =
                this.pigmentColor.mixedwith(other.getPigmentColor(), ratio);
    }
}

四、聚合的构造与保存

当建立一个对象或建立整个AGGREGATE时,若是建立工做很复杂,或者暴露了过多的内部结构,则能够使用FACTORY进行封装。就比如咱们不可能让调用方来构造咱们的Car聚合,由于调用方并不知道咱们WheelPositionEnum与Wheel的映射关系,不知道如何去构造Wheel信息。复杂的对象建立是领域层的职责,不管是实体、值对象,其建立过程自己就是一个主要操做,有时候被建立的对象自身并不适合承担复杂的装配操做。将这些职责混在一块儿可能产生难以理解的拙劣设计,比如咱们的Car必然不是本身生产出来的,而是产自于某个“工厂”。

咱们应该将建立复杂对象的实例和AGGREGATE的职责转移给单独的对象,提供一个封装全部复杂装配操做的接口。在建立AGGREGATE时要把它做为一个总体,并确保它知足固定规则。咱们能够视其为“工厂FACTORY”。FACTORY有不少种设计方式,包括FACTORY METHOD(工厂方法)、ABSTRACT FACTORY(抽象工厂)和BUILDER(构建器)。

这里要强调的是,BUILDER(构建器)也是咱们经常使用的一种工厂方法。咱们能够对Car聚合设计一个工厂方法buildWheels,其接受必需要的参数进而转换为知足业务规则的映射关系。这里面更重要的是业务约束的检查,每一个建立方法都是原子的,并且要保证被建立对象或AGGREGATE的全部固定规则。在生成ENTITY时,这意味着建立知足全部固定规则的整个AGGREGATE,但在建立完成后能够向聚合添加可选元素。在建立不变的VALUE OBJECT时,这意味着全部属性必须被初始化为正确的最终状态。若是FACTORY经过其接口收到了一个建立对象的请求,而它又没法正确地建立出这个对象,那么它应该抛出一个异常,或者采用其余机制,以确保不会返回错误的值。

不少场景中,聚合被建立出来之后其生命周期会持续一段时间。咱们在稍后的代码里面仍旧须要使用,考虑到复杂聚合的生成过程比较繁琐,因此咱们有必要找到一个地方将这些还须要使用的聚合“暂存”起来。不然咱们就须要时刻把这些聚合当作参数进行传递。为每种须要全局访问的对象类型建立一个“容器”即REPOSITORY,并经过一个众所周知的全局接口来提供访问。提供添加和删除对象的方法,用这些方法来封装在数据存储中实际插入或删除数据的操做。提供根据具体条件来挑选对象的方法,并返回属性值知足查询条件的对象或对象集合,从而将实际的存储和查询技术封装起来。只为那些确实须要直接访问的AGGREGATE根提供REPOSITORY。让客户始终聚焦于模型,而将全部对象的存储和访问操做交给REPOSITORY来完成。

五、展现聚合

首先咱们应该明确DDD里面有清晰严格的“层”概念,一般状况下展现层须要的信息会分散在多个聚合里面,可是每一个聚合里面也有一些本次展示所不须要的信息;而每个聚合可能又是有几个数据库实体记录构成的。这就致使了一个展现对象涉及了屡次数据库查询且存在屡次数据对象的转换。这也许会成为你的吐槽点。

但可能有些读者会选择直接在数据结构中使用业务实体对象(即在展现层、数据库设计时候也使用领域层聚合)。毕竟,业务实体与请求/响应模型之间有不少相同的数据。但请必定不要这样作!这两个对象存在的意义是很是不同的。随着时间的推移,这两个对象会以不一样的缘由、不一样的速率发生变动。因此将它们以任何方式整合在一块儿都是对共同闭包原则(CCP)和单一职责原则(SRP)的违反。总有一天,当你想要从新设计底层存储时候会致使展现层的问题;或者迫于展现层的需求去修改底层的表结构。

针对一开始的吐槽,咱们能够借助懒加载去避免没必要要的查询以及转换;还能够把一些经常使用的数据缓存起来。但若是使用redis一类的内存数据库时候,要考虑对象的序列化消耗。由于若是把一个层级较深、比较复杂的大聚合缓存在redis中,在高频读取的状况下序列化也会令你抓狂。在这样的状况下,咱们可能须要从新设计缓存结构,尽量接近于viewObj.setAttribute(redis.getXXX())。很大程度上,对象之间的转换可能不能彻底避免,因此咱们要综合考虑以上几种因素去权衡实践。

六、不要抛弃领域服务

不少人认为DDD中的聚合就是在与贫血模型作抗争,因此在领域层是不能出现“service”的,这等因而破坏了聚合的操做性。但有些重要的领域操做没法放到实体或值对象中,这当中有些操做从本质上讲是一些活动或动做,而不是对象。好比咱们的身份认证、支付转帐业务,咱们很难去抽象一个金融对象去协调转帐、收付款等业务逻辑;有时候咱们也不太可能让对象本身执行auth逻辑。由于这些操做从概念上来说不属于任何业务对象,因此咱们考虑将其实现成一个service,而后注入到业务领域或者说是业务域委托这些service去实现某些功能。

//AuthenticationService注册到了DomainRegistry
UserDescriptor userDescriptor = DomainRegistry
                .authenticationService()
                .authenticate(userId, password);

以上方式是简单的,也是优雅的。客户端只需

要获取到一个无状态的AuthenticationService,而后调用它的authenticate()方法便可。这种方式将全部的认证细节放在领域服务中,而不是应用服务。在须要的状况下,领域服务 能够使用在何领域对象来完成操做,包括对密码的加密过程。客户端不须要知道任何认证细节。此时,通用语言也获得了知足,由于咱们将全部的领域术语都放在了身份管理这个领域中,而不是一部分放在领域模型中,另外一部分 放在客户端中。

AuthenticationService和那些与用户身份相关的业务定义在相同的package中,但对于该接口的实现类,咱们能够选择性地将其存放在不一样的地方。若是你正使用依赖倒置原则或六边形架构,那么你可能会将这个多少有些技术性的实现类放置在领域模型以外的某个设施层。

那么咱们来总结一下,如下几种状况咱们能够使用领域服务来实现:

  • 执行一个显著的业务操做过程;
  • 对领域对象进行转换;
  • 以多个领域对象做为输入进行计算,结果产生一个值对象;

七、再谈命名

类以及函数的命名一直以来都是使人困惑的话题,根因在于它提及来很简单,但要作好确实太难了。试想一下若是开发人员为了使用一个组件而必需要去研究它的实现,那么就失去了封装的价值。当某我的开发的对象或操做被别人使用时,若是使用这个组件的新的开发者不得不根据其实现来推测其用途,那么他推测出来的可能并非那个操做或类的主要用途。若是这不是那个组件的用途,虽然代码暂时能够工做,但设计的概念基础已经被误用了,两位开发人员的意图也是背道而驰。当咱们把概念显式地建模为类或方法时,为了真正从中获取价值,必须为这些程序元素赋予一个可以反映出其概念的名字。类和方法的名称为开发人员之间的沟通创造了很好的机会,也可以改善系统的抽象。

所以在命名类和操做时要描述它们的效果和目的,而不要表露它们是经过何种方式达到目的的。这样能够使客户开发人员没必要去理解内部细节。在建立一个行为以前先为它编写一个测试,这样能够促使你站在客户开发人员的角度上来思考它。测试驱动的另外一个价值就是要求咱们写出易于(测试)使用的代码。试想一下,咱们本身编写测试都很困难的时候,别人又如何明白呢?

一般的全部复杂的机制都应该封装到抽象接口的后面, 接口只代表意图,而不代表方式。在领域的公共接口中,能够把关系和规则表述出来,但不要说明规则是如何实施的;能够把事件和动做描述出来,但不要描述它们是如何执行的。

八、领域核心能力

当咱们对现实领域进行思考时候,很容易被“表象”所迷惑。好比咱们的Car聚合内部会有一个导航服务,通常状况咱们可能须要按照最短路径导航、躲避拥堵、高速优先等状况。经过前面的学习,咱们抽象一个“导航”服务并将其注入或者注册到Car聚合。

随着导航要求的多样化,不可避免的该类会变得臃肿继而难以维护。所以咱们借助策略模式,抽象一个导航策略,一切问题都变得更加清晰。

如上图所示设计,咱们获得了清晰明确的导航模型以及一个被明确提炼出来的导航策略。不管咱们导航需求如何变化,咱们只须要去增长实现类便可,这就是咱们架构原则所提倡的对扩展开放。这虽然是一个很小的例子,可是其背后的意义重大,让咱们学会区分什么是行为、什么是策略。由于行为是固定的,策略是变化的。当咱们将两者区分之后,就能更加聚焦于领域的核心行为能力。

4、聚合与六边形架构

在以前的系列文章中,我屡次提到了六边形架构。但更多的是理念上的解释,如今讲解了聚合之后咱们就来看看六边形架构的代码风格是什么样的,其端口到底为什么物。仍是参照以前的作法,在一个DDD没有彻底普及的项目中,咱们依然提供一个CarFacade供外部调用,以避免花费很长时间去和他们争论到底该不应建模一个充血的Car对象。

//经过RPC调用获得Car聚合信息,进而转换成前端展现所须要的ViewObject
CarData carData = carFacade.OfId(carId);
CarVO carVO = CarVOFactory.build(carData.getValue());

一般应用服务被设计成了具备输入和输出的API,而传入数据转换器的目的即在于为客户端生成特定的输出类型。在六边形架构中咱们可能会使得服务返回void类型,数据隐式的在端口流转。经过这一点,咱们能够看出六边形架构更强调数据流转而不像传统开发方式那样注重数据的返回或加工。

public class CarFacadeImpl {
    public void OfId(Long carId){
        //领域层逻辑
        Car car = this.carRepository.OfId(carId);
        //应用层逻辑
        //这里的输出端口是一个位于位于应用程序的边缘特殊的端口;
        //在使用Spring时,该端口类能够被注入到应用服务中;
        //在本例中其职责是把Car聚合转换成前端展现所须要的ViewObject;
        //若是咱们使用SpringMVC一类的框架,该端口还负责把数据返回给HttpResponse;
        this.carHttpOutputPort().write(car);
    }
}

固然咱们可能会有多个输出端口,而各个端口的隔离实现又避免了逻辑的污染,为未来任意扩展端口场景提供了可能性。在write()方法执行后,每个注册的读取器都会将端口的输出做为本身的输入。这里最大的问题就是,不了解六边形架构的人会抱怨“你的getXXX方法居然没有返回值”。因此咱们在方法命名时候尽量避免使用get字样,一般我会取而代之find/load,由于查找/装载并不隐含须要返回结果的意思。不管如何咱们都要明白,任何一种架构都同时存在正面的和负面的影响。

5、演进的聚合

提到“重构”,咱们头脑中就会出现这样一幅场景:几位开发人员坐在键盘前面,发现一些代码能够改进,而后当即动手修改代码(固然还要用单元测试来验证结果)。固然这个过程应该一直进行下去,但它并非重构过程的所有。与传统重构观点不一样的是,即便在代码看上去很整洁的时候也可能须要重构,缘由是模型是否与真实的业务一致,或者现有模型致使新需求不能被天然的实现完成。重构的缘由也可能来自学习:当开发人员经过学习得到了更深入的理解,从而发现了一个获得更清晰或更有用的模型。综合起来如下几点的出现就说明你应该从新审视你的聚合了,固然咱们重构也好、演进也罢,也仍是要基于实际项目的状况。

  • 设计没有表达出团队对领域的最新理解;
  • 重要的概念被隐藏在设计中了(并且你已经发现了把它们呈现出来的方法);
  • 发现了一个能令某个重要的设计部分变得更灵活的机会;

最后仍是延续前面文章的一向风格,本文讲述了不少有关聚合的细节,即便在非DDD的项目中,这些有效实践依然大有裨益。咱们但愿设计的聚合具备柔性特征,但这每每很难。可以清楚地代表它的意图;令人们很容易看出代码的运行效果,所以也很容易预计修改代码的结果。柔性设计主要经过减小依赖性和反作用来减轻人们的思考负担。这样的设计是以深层次的领域模型为基础的,在模型中,只有那些对用户最重要的部分才具备较细的粒度。在这样的模型中,那些常常须要修改的地方可以保持很高的灵活性,而其余地方则相对比较简单。这也就是我一再强调的“行为”“策略”的区别。当咱们这样去思考问题之后,编码以及设计思路会有很大变化,从原来那样的流程代码中脱离出来,进而站在一个更高的抽象层次上去实现系统。

(图片来源于网络)

参考文献:

  1. 《领域驱动设计:软件核心复杂性应对之道》
  2. 《实现领域驱动设计》

更多内容敬请关注 vivo 互联网技术 微信公众号

注:转载文章请先与微信号:Labs2020 联系

相关文章
相关标签/搜索