[DDD读书笔记] 重构②SPECIFICATION模式

751 阅读9分钟

前文回顾

上一篇介绍了该书的第三部分“通过重构来加深理解”,讨论如何将构造块装配为实用的模型,从而实现其价值。我们学习了重构的分类,以及从量变到质变的突破

这一篇,我们继续学习该书的第三部分。

挖掘隐含的概念

前文我们提到了从渐变到突破是一个不断累计的过程,那么这个过程是从什么时候开始的呢?其实,当开发人员识别出设计中隐含的某个概念或是在讨论中受到启发而发现一个概念时,就会对领域模型和相应的代码进行许多转换,在模型中加入一个或多个对象或关系,从而将此概念显式地表达出来。

当我们把这些概念在模型中表达出来的时候,渐变的过程就开始了。有时候,这种从隐式概念到显式概念的转换可能是一次突破,使我们得到一个深层模型。

既然被称作隐式的概念,也就是一般很难被发现的。有什么手段可以帮助我们挖掘到这些隐含的概念呢?

作者告诉我们一个好办法:注意倾听领域专家使用的语言。有没有一些术语能够简洁地表达出复杂的概念?他们有没有纠正过你的用词(也许是很委婉的提醒)?当你使用某个特定词语时,他们脸上是否已经不再流露出迷惑的表情?这些都暗示了某个概念也许可以改进模型。

每个领域专家的工作方式不尽相同。如果你足够幸运,遇到一些积极配合建模的专家,可能会愿意一起思考各种想法,并通过模型来进行验证。如果你没那么幸运,你和你的同事就不得不自己思索出不同的想法,让领域专家对这些想法进行判断,并注意观察专家的表情是认同还是反对。

由于经验和需求的不同,不同的领域专家对同样的事情会有不同的看法。即使是同一个人提供的信息,仔细分析后也会发现逻辑上不一致的地方。在挖掘程序需求的时候,我们会不断遇到这种令人烦恼的矛盾,但它们也为深层模型的实现提供了重要线索

此外,作者还告诉我们一个好方法:查阅书籍。看书与咨询领域专家并不冲突。即便能够从领域专家那里得到充分的支持,花点时间从文献资料中大致了解领域理论也是值得的。

对隐含的概念建模

面向对象的设计方法会引导我们发现特定的概念。所有事物(即使是像“应计费用”这种非常抽象的概念)及其操作行为是大部分对象模型的主要部分。它们就是面向对象设计入门书籍所讲到的“名词和动词”。但是,其他重要类别的概念也可以在模型中显式地表现出来。

约束是模型概念中非常重要的类别。它们通常是隐含的,将它们显式地表现出来可以极大地提高设计质量。比如水桶(Bucket)对象必须满足一个固定规则--桶里装的水(contents)必须小于它的容量(capacity)。这个规则可以在每次装水的时候检查。

class Bucket {
    private float capacity;
    private float contents;
    
    public void pourIn(float addedVolume) {
        if (contents + addedVolume > capacity) {
            contents = capacity;
        } else {
            contents = contents + addedVolume;
        }
    }
}

这个逻辑很简单,在更复杂的类中这个约束可能会丢失。让我们把这个约束提取到一个单独的方法中,并用清晰直观的名称来表达它的意义。

class Bucket {
    private float capacity;
    private float contents;
    
    public void pourIn(float addedVolume) {
        float volumePresent = contents + addedVolume;
        contents = constrainedToCapacity(volumePresent);
    }
    
    private float constrainedToCapacity(float volumePlacedIn) {
        return volumePlacedIn > capacity ? capacity : volumePlacedIn;
    }
}

第二个版本与模型的关系更为明显。在很多时候,约束条件是无法用单独的方法来轻松表达的。或者,即使方法自身能够保持其简单性,但它可能会调用一些信息,但对于对象的主要职责而言,这些信息毫无用处。这种规则可能就不适合放到现有对象中。

下面是一些警告信号,表明约束的存在正在扰乱其“宿主对象”(HostObject)的设计。

  1. 计算约束所需的数据从定义上看并不属于这个对象
  2. 相关规则在多个对象中出现,造成了代码重复或导致不属于同一族的对象之间产生了继承关系
  3. 很多设计和需求是以这些约束为中心进行讨论的,而在实现时,它们却隐藏在过程代码中。

在MODEL DRIVEN DESIGN中,我们不希望过程变成模型的主要部分。对象是用来封装过程的,这样我们只需考虑对象的业务目的或意图就可以了。我们也可以用另一种方法来处理过程的执行,那就是将算法本身或其中的关键部分放到一个单独的对象中

过程是应该被显式表达出来,还是应该被隐藏起来呢?区分的方法很简单:它是经常被领域专家提起呢,还是仅仅被当作计算机程序机制的一部分?

SPECIFICATION模式

SPECIFICATION(规格)模式提供了用于表达特定类型的规则的精确方式,它把这些规则从条件逻辑中提取出来,并在模型中把它们显式地表示出来。

业务规则通常不适合作为ENTITY或VALUE OBJECT的职责,而且规则的变化和组合也会掩盖领域对象的基本含义。但是将规则移出领域层的结果会更糟糕,因为这样一来,领域代码就不再表达模型了。

逻辑编程提供了一种概念,即“谓词”这种可分离、可组合的规则对象,值得我们参考。我们可以借用谓词概念来创建可计算出布尔值的特殊对象。那些用于测验的方法,都是些小的真值测试,可以提取到单独的VALUE OBJECT中。而这个新对象则可以用来计算另一个对象,看看谓词对那个对象的计算是否为“真”。

image.png

这个新的对象(InvoiceDelinquency)就是一个规格。SPECIFICATION(规格)中声明的是限制另一个对象状态的约束,被约束对象可以存在,也可以不存在。SPECIFICATION有多种用途,其中一种体现了最基本的概念,这种用途是:SPECIFICATION可以测试任何对象以检验它们是否满足指定的标准

何时采用SPECIFICATION

我们应当为特殊目的而创建谓词形式的显式的VALUE OBJECT。SPECIFICATION就是一个谓词,可用来确定对象是否满足某些标准。SPECIFICATION最有价值的地方在于它可以将看起来完全不同的应用功能统一起来。否则,相同的规则可能会表现为不同的形式,甚至有可能是相互矛盾的形式。

出于以下3种原因,我们需要用到SPECIFICATION对象:

  1. 验证对象,检查它是否能满足某些需求或者是否已经为实现某个目标做好了准备。
  2. 从集合中选择一个对象(如上述例子中的查询过期发票)。
  3. 指定在创建新对象时必须满足某种需求。

SPECIFICATION示例

假设有一个仓库,里面用类似于货车车厢的大型容器,容器可以放置多个桶用于存放各种化学品。有些化学品是惰性的,可以随意摆放。有些则是易挥发的,必须放于特制的通风容器中。还有一些是易爆品,必须保存于特制的防爆容器中。我们的目标是编写出一个软件,用于寻找一种安全而高效地在容器中放臵化学品的方式。模型如下:

image.png

每种化学品Chemical都对应一个容器规格ContainerSpecification。

image.png

我们可以编写一个ContainerSpecification的isSatisfiedBy()方法来验证容器是否包含化学品所需的容器特性ContainerFeature。

public class ContainerSpecification {
    private ContainerFeature requiredFeature;
    
    public ContainerSpecification(ContainerFeature requiredFeature) {
        this.requiredFeature = requiredFeature;
    }
    
    public boolean isSatisfiedBy(Container aContainer) {
        return aContainer.getFeatures().contains(requiredFeature);
    }
}

比如对于化学品TNT来说,需要设置防爆特性。 image.png

TNT设置容器规格的代码如下:

tnt.setContainerSpecification(new ContainerSpecification(ARMORED));

Container对象的isSafelyPacked()方法用来确保容器包含化学品所需的特性。


boolean isSafelyPacked() {
    for (Drum drum : contents) {
        if (! drum.containerSpecification().isSatisfiedBy(this)) {
            return false;
        }
    }
    
    return true;
}

基于这个SPECIFICATION模型,我们可以定一个简单的接口来提供一个服务,这个服务接受Drum和Container的集合并按照规则打包。


public interface WarehousePacker {
    public void pack(Collection containerToFill, Collection drumToPack)
        throws NoAnswerFoundException;
}

我们可以快速开发一个简单而不完美的原型来实现这个服务。这里插播一句,如何通过可工作的原型来摆脱开发僵局。

通常一个稍具规模的项目会存在多个团队,有的团队必须要等待另一个团队编写出代码后才可以继续工作。而这两个团队都要等到代码完全整合后才可以测试组件或从用户那里获取反馈。这种僵局通常可以通过关键组件的模型驱动原型来缓解,即使原型并不满足所有需求也可以。当实现与接口分离时,只要有可以工作的实现,项目工作就可以并行地开展下去。时机成熟的时候,可以用更为高效的实现来替代原型。同时,系统中的其他部分也能在开发期间与原型进行交互。

系列文章