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

avatar
vivo互联网技术 @vivo互联网

本文首发于 vivo互联网技术 微信公众号
链接:mp.weixin.qq.com/s/oAD25H0UK…
作者:wenbo zhang

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

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

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

(图片来源于网络)

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

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

1、实体Entity

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

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

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

2、值对象Value Obj

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

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

在电力运营公司的软件中,一个地址对应于公司线路和服务的一个目的地。如果几个室友各自打电话申请电力服务,公司需要知道他们其实是住在同一个地方,因为我们真实服务的是用户所在地方的电力资源,在这种情况下,我们会认为地址是一个实体。但是随着思考的深入,我们发现可以换种方式,抽象出一个电力服务模型并与地址关联起来。通过这样的设计以后,我们发现真正的实体是电力服务,地址不过是一个具有描述性的值对象而已。

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

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

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

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

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

  • 它可以作为不变量;

    值对象可能会被共享,所以具有不变性,即调用方不能对其执行set操作。

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

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

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

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

二、聚合(Aggregate)

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

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

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

三、一些关于聚合的实践

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

案例:汽车模型设计

约束:首先一辆汽车在车辆登记机构归属于唯一一个人或者企业主体(实际上企业也具有法人,所以即使是企业主体也可以找到对应的归属人);其次,正如大家所常见的,我们探讨是目前技术所能实现的、且普遍流行的车辆结构,一辆车具有4个轮子、一个引擎;

1、业务边界

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;
}

2、警惕性能问题

在具有复杂关联的模型中,要保证对象更改的一致性是很困难的。不仅互不关联的对象需要遵守一些固定规则,而且紧密关联的各组对象也要遵守一些固定规则。然而,过于谨慎的锁定机制又会导致多个用户之间毫无意义地互相干扰,从而使系统不可用。引用自《领域驱动设计》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去处理后续的业务规则。

3、值对象-无副作用

值对象的方法应该被设计成一个无副作用函数,即只用于生成输出而不会修改对象的状态。对于不变的值对象而言,所有的方法都必须是无作用的函数,因为它们不能破坏值对象的属性值才能安全的被共享。我们要意识到值对象绝不仅仅是一个属性容器,其真正的强大特性“无副作用函数”。比如上面的窗户对象,当其被实例化出来以后各个属性就不能被肆意修改了,我们通用的做法是在构造方法里面进行赋值或者基于工厂方法获得,总之千万拒绝提供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);
    }
}

4、聚合的构造与保存

当创建一个对象或创建整个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来完成。

5、展示聚合

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

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

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

6、不要抛弃领域服务

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

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

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

要获取到一个无状态的AuthenticationService,然后调用它的authenticate()方法即可。这种方式将所有的认证细节放在领域服务中,而不是应用服务。在需要的情况下,领域服务 可以使用在何领域对象来完成操作,包括对密码的加密过程。客户端不需要知道任何认证细节。此时,通用语言也得到了满足,因为我们将所有的领域术语都放在了身份管理这个领域中,而不是一部分放在领域模型中,另一部分 放在客户端中。

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

那么我们来总结一下,以下几种情况我们可以使用领域服务来实现:

  • 执行一个显著的业务操作过程;

  • 对领域对象进行转换;

  • 以多个领域对象作为输入进行计算,结果产生一个值对象;

7、再谈命名

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

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

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

8、领域核心能力

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

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

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

四、聚合与六边形架构

在之前的系列文章中,我多次提到了六边形架构。但更多的是理念上的解释,现在讲解了聚合以后我们就来看看六边形架构的代码风格是什么样的,其端口到底为何物。还是参照之前的做法,在一个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,因为查找/装载并不隐含需要返回结果的意思。无论如何我们都要明白,任何一种架构都同时存在正面的和负面的影响。

五、演进的聚合

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

  • 设计没有表达出团队对领域的最新理解;

  • 重要的概念被隐藏在设计中了(而且你已经发现了把它们呈现出来的方法);

  • 发现了一个能令某个重要的设计部分变得更灵活的机会;

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

(图片来源于网络)

参考文献:

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

  2. 《实现领域驱动设计》

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

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