对象应该只构建一次

18-07-20 banq
                   

规则:创建一个对象时,它应该是一次性完整的、一致的且有效的创建好。

说白了,对于Java来说,一个类只应该有一个构造函数,这样才能保证一次性完整一致地创建它的对象。看看Matthias Noback有关DDD这篇文章怎么说,下面是一半翻译一半掺杂我个人观点:

这个规则来自于更一般的原则,即对象不可能以不一致的状态存在。我认为这是一个非常重要的规则,并会逐渐引导每个人走出可怕的“贫血”模型的沼泽地。

这个规则意味着什么?为什么要有?有什么好处?

嗯,例如,下面PHP语言的Geolocation对象包含经度和纬度两个属性:


final class Geolocation
{
private $latitude;
private $longitude;

//无参数的构造函数
public function __construct()
{
}

public function setLatitude(float $latitude): void
{
$this->latitude = $latitude;
}

public function setLongitude(float $longitude): void
{
$this->longitude = $longitude;
}
}

$location = new Geolocation();
// $location 是在无效状态

$location->setLatitude(-20.0);
// $location 还是在无效状态


为什么说我们已经通过new构建Geolocation对象赋给了$location,而$location则始终处于无效状态?因为Geolocation的构造函数:

public function __construct()
{
}

这是一个默认无参数的构造函数,这样Geolocation的两个属性经度和纬度是没有数据的,从常识来说,你不可能在一个事物没有数据的情况下构建它,因为具有某个纬度和经度的特定值是Geolocation这个地理定位对象的核心基本条件之一,正如你构造一个人对象,但是没有标识,没有男女属性一样,要么是男要么是女是一个人的基本属性,你不可能构造一个抽象的人,没有性别,这在现实中不存在,只有精神世界存在,那是上帝。

对于Geolocation地理定位这个对象来说,经度和纬度是缺一不可,没有它们,地理定位“不能生存”。基本上,如果可能的话,地理定位的整个概念将变得毫无意义。

对象通常需要一些数据来完成有意义的角色。但它也对需要什么类型的数据形成了某些限制,并且只允许宇宙中所有可能值的一些特定子集。作为对象设计阶段的一部分,你应该开始寻找领域中不变性。

如果我们从相关业务领域可以知道什么专业知识可以帮助我们为地理定位这个概念定义一个有意义的模型,那么,我们就知道纬度和经度应该在一定值范围内,即-90到90(含)和-180到180(含)。相反使用任何其他值绝对没有意义,只会使有关地理定位的所有建模行为变得毫无用处。

考虑到所有这些因素,你最终可能会形成一个形成地理定位概念的响亮模型类:


final class Geolocation
{
private $latitude;
private $longitude;

//有参数的构造函数
public function __construct(
float $latitude,
float $longitude
) {
Assertion::between($latitude, -90, 90);
$this->latitude = $latitude;

Assertion::between($longitude, -180, 180);
$this->longitude = $longitude
}
}

$location = new Geolocation(-20.0, 100.0);


通过添加有参数的构造函数,我们在创建这个对象时,必须同时将这个对象的基本属性赋值进去,才使得这个对象有真正意义,这样做有效地保护了地理定位这个领域模型的领域不变性,使得任何人无法构造无效的对象、或者不完整或无用的Geolocation对象。

每当你在应用中遇到此类对象时,都可以确保使用它是安全的。不需要使用某种验证器来验证它!这就是为什么关于“不允许对象以不一致状态存在”这个规则是非常棒的原因。我可以很初步地推荐将其应用到各处。

具有子实体的聚合
然而,这个规则也并非没有问题。例如,我一直在努力将这个规则应用于具有子实体的聚合体对象中,特别是在我正在为所谓的“采购订单”这个案例建模时。它需要发送给供应商一些采购要求:订购一些货物(这些“货物”是有特定数量的某种产品)。领域专家将此称为“带行的标题a header with lines”或“带行的文档a document with lines”。

我决定设计一个聚合根为“Purchase Order”(类名为PurchaseOrder)并里面有代表订购商品条目的子实体(用Lines表示,实际上,每一行都是一个商品条目Line实例)。

最简单实现方法如下:

final class PurchaseOrder
{
private $lines = [];
//无参数构造函数
public function __construct()
{
// no lines!
}

public function addLine(int $productId, int $quantity): void
{
$this->lines[] = new Line(
count($this->lines) + 1,
$productId,
$quantity
);
}
}

// in the application service:
$purchaseOrder = new PurchaseOrder();
$purchaseOrder->addLine(...);
$purchaseOrder->addLine(...);
$purchaseOrder->addLine(...);


这看起来很棒,先构造一个空的订单对象,然后将订单条目一个赋值进去,这种方式无法保证我们最关心的一个域不变性约束,当在构造空订单对象时,里面其实是没有任何订单条目的,这就无法保证每个订单至少有一个条目。

我们可以将这种验证放到到后端存储库。但是,有一个validate()方法PurchaseOrder看起来不太好,将保护领域不变量的责任委托给存储库根本不是一个安全的选择。基本在做这种傻事:在这个对象上抛出一堆数据并在之后验证它......

要考虑的一个重要领域约束是:“每个采购订单至少必须有一行条目”。毕竟,没有采购条目的订单是没有意义的。在尝试应用此设计规则时,我的第一直觉是提供行条目的列表作为构造函数参数。一个简化的实现(注意我在这些示例中没有使用正确的值对象!)看起来像这样:


final class PurchaseOrder
{
private $lines;

/**
* 构造函数带有条目集合
* @param Line[] $lines
*/

public function __construct(array $lines)
{
Assertion::greaterThan(count($lines), 1,
'A purchase order should have at least one line');

$this->lines = $lines;
}
}

final class Line
{
private $lineNumber;
private $productId;
private $quantity;

public function __construct(
int $lineNumber,
int $productId,
int $quantity
) {
$this->lineNumber = $lineNumber;
$this->productId = $productId;
$this->quantity = $quantity;
}
}

// inside the application service:
$purchaseOrder = new PurchaseOrder(
[
new Line(...),
new Line(...)
]
);


这种设计有个特点,创建创建PurchaseOrder聚合对象时,首先需要创建Line子实体,因为PurchaseOrder的构造函数需要它,在哪里创建一系列对象呢?当然是在为PurchaseOrder服务的服务类中,这就使的Line子实体的构造成为创建PurchaseOrder聚合的服务的责任。其中一个问题是Line实体创建时需要唯一标识ID:lineNumber,标识每一行序列,代表采购单上第一行条目还是第二行条目。因此,在构造这些Line实体时,应用程序服务也应该为它提供一个ID:

在服务中创建一次Line实体,然后再在PurchaseOrder构造函数中将其他数据setXXX补偿放置进去,这种分两次构造Line实体的方式不是一个好的解决方案,因为现在一个Line可能会以无效状态存在,因为有不完整状态 - 没有行号。

相反,我们应该让自己PurchaseOrder创建这些Line实体。我们只需要提供原始数据(product ID, quantity)作为构造函数参数,例如


public function __construct(array $linesData)
{
foreach (array_values($linesData) as $index => [$productId, $quantity]) {
$this->lines[] = new Line(
$index + 1,
$productId,
$quantity
);
}
}


但是,我并不满足于$linesData只是一个无名的数据结构,不代表任何实际业务意义,我们可以为它引入有业务意义类似的东西 - 比如LineWithoutLineNumber但这会更加愚蠢。

我们现在这里碰到的问题就是,$linesData作为PurchaseOrder的构造函数,却是没有任何业务意义的数据结构,这是我们DDD设计中不允许的,你能解释给领域专家说,我们创建采购单之前需要创建一个数据结构,如同和他们说需要从数据库里面取出数据一样毫无任何业务意义,我们现在需要寻找更好地构造PurchaseOrder方式。

总之,我们目的是:既要实现保证一个采购订单中有一个采购条目的不变性约束,也要不能将采购订单的构造细节暴露给外界,比如这里将$linesData作为PurchaseOrder的构造函数,实际上暴露更多细节给外部去处理本属于PurchaseOrder内部的$linesData元素。

用一张纸比喻建模
谈到“建模方式”,我发现想想“纸质采购订单”的样子是非常有用。这不是牵强附会,因为即使是领域专家也会谈到“文档”。

因此,请想想我们是如何处理纸质采购订单文档的样子。我们拿一张空白纸,注意在一些虚线上填写基本信息(供应商名称,地址等),然后我们会看到一些空白区域,我们可以为每个要订购的产品编写条目行。我们可以将这篇文档“不完整”暂时停留住,这段时间可以和同事讨论这个问题,之后我们会做出一些更正修改,或者我们甚至可以扔掉它并从头开始,但有时我们实际上会把它发送到供应商以确认。

将这种比喻转换回代码,我们可能会发现,在生命周期中确实有两个不同的“阶段” PurchaseOrder,它们都有自己的不变性。当订单处于“草稿”阶段时,我们只需要提供基本信息,但我们可以随意添加(也可以删除)条目行。一旦我们“完成”采购订单,我们就声称它已准备好发送,并且此时我们可以保证其他一些不变性。

我们只需要添加一个方法来PurchaseOrder“完成”它。为了与DDD兼容,我们寻找我们的领域专家使用的一个词。这个词原来是“place” - 我们逐渐填写所有细节,然后我们下订单。所以,我们重新从第一次的好像最愚蠢的代码开始:


final class PurchaseOrder
{
private $lines = [];
private $isPlaced;

//无参数构造函数
public function __construct(...)
{
// ...
}

public function addLine(int $productId, int $quantity): void
{
if ($this->isPlaced) {
throw new \LogicException(
'You cannot add a line to an order that was already placed'
);
}

$this->lines[] = new Line(
count($this->lines) + 1,
$productId,
$quantity
);
}

public function place(): void
{
Assertion::greaterThan(count($this->lines), 1,
'A purchase order should have at least one line');

$this->isPlaced = true;
}
}

// in the application service:
$purchaseOrder = new PurchaseOrder();

$purchaseOrder->addLine(...);
$purchaseOrder->addLine(...);
$purchaseOrder->addLine(...);

$purchaseOrder->place();


请注意,除了添加place()方法之外,我们还修改了addLine()以防止在下订单后再添加新的条目行。在纸质采购单,这也是不允许的,因为文档已经发送给供应商,因此如果在我们已经确认place的采购订单中添加条目行,则会非常混乱。

还要注意,该place()方法使聚合根处于某种状态,之后不再一切都是可能的,这可能会提醒一个状态机的概念,我实际上发现实体通常很像状态机,鉴于某个状态,对其的操作是有限的,状态转换也是有限的。例如,在下订单之前,可以取消订单而不会产生任何后果,但在下订单之后,系统需要采取各种补偿措施(向供应商发送消息,说明订单已被取消等 )。

结论
我发现将嵌套对象的类型和构造细节应该留给它们的父对象会导致更“柔软”的设计,从某种意义上说,这其实是“旧的OOP知识” 。

我们在本文中通过构造一个无参数的new PurchaseOrder()隐藏了PurchaseOrder与Line之间打交道的实现细节(例如,它是使用普通旧数组还是集合对象?我们是否需要一个Line类,等等。 )。因此,当我们重构PurchaseOrder聚合,则无需在代码库中更新PurchaseOrder聚合的所有客户端。

这是传统DDD建议的一部分,它使聚合根成为与任何其他聚合交互的唯一入口点:


选择一个实体作为每个聚合的根,并通过根控制对边界内对象的所有访问。

Eric Evans,“领域驱动设计”,第二部分,第六章:“域对象的生命周期”


因此,即使我们最终得到了更好的设计,我们也不得不重新考虑保证“一个订单应至少有一行”领域不变性约束。

因此本文中我们还讨论了其他建模工具,例如编写“纸质采购单”的方式,这种方式能有效地让我们意识到:在采购订单的生命周期中实际上有两个不同的阶段。

如果你发现自己遇到了建模问题,请寻找改变视角和方法。

Objects should be constructed in one go — Matthias

                   

1