发现实体及其本质特征

接下来,让我们看看SaaSOvation团队都学到了什么...

开始.CollabOvmion团队便遇到了陷阱,他们在Java代码中进行了大量的实体-关系建模。他们将太多的关注点放在了数据库.表.列和对象映射上。这样导致的结果是:他们所创建的模型实际上只是含有大量getter和setter的贫血领域模型[Fowler. Anemic]。他们应该在DDD上有更多的思考。那时正值他们将安全处理机制从核心域中分离之际.他们学到了如何使用通用语言来更好地辅助建模。在本节中.我们将看到新组建的身份与访问上下文团队是如何从Collaboration团队身上学到经验教训的。

限界上下文中的通用语言向我们提供了设计领域模型的概念术语。通用语言不是平白产生的,它必须通过与领域专家详细讨论之后才能得到。在通用语言的术

语中,名词用于给概念命名,形容词用于描述这些概念,而动词则表示可以完成的操作。但是,如果我们认为对象就是一组命名的类和在类上定义的操作,除此之外并不包含其他内容,那么,我们就错了。在领域模型中还可以包含很多其他内容。团队讨论和规范文档可以帮助我们创建更有意义的通用语言。到最后,团队可以直接使用通用语言来进行对话,而此时的模型也能够非常准确地反映通用语言。

如果一些特定的领域场景会在今后继续使用,这时可以用一个轻量的文档将它们记录下来。简单形式的通用语言可以是一组术语和一些简单的用例场景。但是,如果我们就此认为通用语言只包含术语和用例场景,那么我们又错了。在最后,通用语言应该直接反映在代码中,而要保持设计文档的实时更新是非常困难的,甚至是不可能的。

揭开实体及其本质特征的神秘面纱

让我们来看一个非常简单的例子。在身份与访问上下文中,SaaSOvation团队成员知道他们应该创建一个User模型。诚然,这里的建模例子并不是来自于核心域(2),但是之后我们会转到核心域中。


以下是团队成员根据软件需求(不是通过用例或用户故事)对于User的理解.这种理解大致能反映出通用语言.但是还有改进的空间:

  • User存在于某个Tenant之下,并受该Tenant控制
  • 必须对系统中的User进行认证
  • User可以处理自己的个人信息,包括名字和联系方式等
  • User的个人信息可以被其本人和Manager修改
  • User的安全密码是可以修改的

团队成员应该仔细地读.仔细地听。一旦他们发现"修改"这个词时,他们便应该知道此时的User是一个实体。当然修改"也可以被定义为"替换一个值".而不是"改变一个实体”。另外.请注意"认证"这个词.它暗示着团队所开发的系统需要提供查找功能。如果你有一大堆东西.而你需要从中找出一件东西.那么你便需要一个唯一标识将它与其他东西区分开来。在身份与访问上下文中.对于某个租户下的多个用户.查找功能可以精确地定位其中的一个用户。


但是上面提到的“受该租户控制”又是什么意思呢?这是不是意味着这里的实体应该为Tenant,而不是User呢?对此,我们需要对聚合(10)展开讨论,请参考第10章。简单而言,答案既是,也不是。回答为“是”,是说确实存在一个Tenant实体;回答为“不是”,是说User实体同样也是存在的。Tenant和User都是实体。要理解Tenant和User为什么分别表示两种不同聚合的根(10),请参考第10章。是的,User和Tenant是两种不同类型的聚合,但是SaaSOvation的团队成员起初并没有意识到这点。

一个User应该具有唯一的标识,以区别于其他User。一个User同时还应该支持在其生命周期中的各种修改。显然,此时的User是一个实体。这里,我们并不关心如何对User的内部进行建模。


团队成员需要对以上的第一条需求做个澄清:

  • User存在于某个Tenam之下.并受该Tenant控制

    团队本来可以添加一些注释或者修改一下用词.以此来说明这里的意思是-Tenant拥有 Use「.但是他们并没有这么做。此时.团队成员们需要格外小心.因为他们不应该陷入技术和战术建模这样的细节中。最后.他们对User的描述做了以下修改:

  • Tenant可以遨诮多个User进行注册
  • Tenant可以处于激活状态或失活状态
  • 系统必须对User进行认证.并且只有当Tenam处于激活状态时才能对User进行认证
  • ...

惊喜吧.通过更多的讨论.团队成员可以进一步修改用词.同时使需求更加明晰。他们发现先前的"Tenant控制User"的说法并不完整。亊实上是一个User向一个Tenant进行注册. 并且只有在接受到邀请的时候才能注册。另外值得一提的是.一个Tenant可以处于激活状态和失活状态.并且只有在Tenam处于激活状态的情况下.才能对User进行认证。

以上关于User的解释并没有提及由谁来管理User的生命周期.但是却済楚地表明:不管是谁拥有User.都有可能存在一个User不可用的情况^这些是SaaSOvation成员需要考虑的重要场景t

现在看来, 团队成员已经有了通用语言的一套术语。但是.他们依然没有提炼出一套好的定义。

他们有了一些已知的实体对象,如图5.5所示。接下来.我们应该知道如何区分这些不同的实体.另外还应该知道应该向这些实体中加入哪些属性。

图5.5先前所发现的2个实体:Tenant和User

团队成员决定使用应用程序生成的UUID来作为Tenant的唯一标识。他们使用了长文本的标识值.这不仅可以保证实体的唯一性.还可以增加订阅方访问时的安全性.因为要伪造一个UUID是困难的。同时.还可以将属于不同Tenant的实体显式地区分开来。这样一来.系统中的每一个实体都拥有唯一标识.要对这些实体进行查找也变得非常简单。

Tenant的唯一标识本身并不是实体.而只是一种值对象而已。问题是.这个标识值需要有特殊的类型呢,还是可以使用简单的字符串?

似乎没有必要在标识上实现无副作用函数(6),它只是一个16进制数所对应的字符串文本而已。但是.实体的唯一标识会用在很多地方.它可以用在不同限界上下文的所有实体上。在这种情况下,使用一个强类型的实体标识是有好处的。通过定义Tenamld值对象.53心0^^(^团队可以保证所有订阅方所持有的实体都能使用正确的标识。图5.6展示了这样的建模过程.其中同时包含有Tenant和User实体。

图5.6在发现并命名实体之后,找到那些起唯一标识作用的属性

Tenant实体必须有个名字.它的name属性可以是简单的字符串.因为name并没有特殊的行为。Tenant中的name属性有助于查询操作,比如帮助中心的服务人员可以通过name属性来查找出需要服务的Tenant。因此.name属性是必要的.并且是Tenant实体的"本质特征"。同时.我们也可以将唯_性约束加在name属性上.但这并不是我们目前的重点。

我们还可以向Tenant中添加另外的属性,比如售后支持联系人信息.支付信息等.但是这些都是业务层面的概念.而不是安全层面的。对于身份与访问上下文来说.团队成员能有以上认识.已经非常不错了。

售后支持可以通过另_个限界上下文来管理。在通过name找到对应的Tenant之后.系统便可以使用Tenant的唯一标识Tenamld 了。该Tenantld可以进一步用于售后支持上下文中.付账上下文或者客户关系管理上下文。售后支持联系人.租户地址和租户联系人与安全没有什么关系。另外.将名字name属性加在Tenant上也可以售后支持人员快速地为客户提供服务。

在Tenant之后.团队将关注点转向了User实体。什么可以作为User实体的唯一标识呢?多数身份系统都为User定义了一个唯一的用户名username。一个username由什么组成并不重要.只要它能够在一个Tenant中唯一地表示一个User即可(相同的username可以出现在不同的Tenant中)。通常来说, username由用户自己指定。如果订阅租户对于用户名做出了限制.或者用户名由联合安全集成机制所决定.那么注册用户需要服从这些限制。对于SaaSOvation团队来说.他们简单地在User实体上定义了一个username属性。

SaaSOvation团队需要满足的另一个需求是用户需要提供安全密码。对此.团队成员向 User实体添加了password属性。他们认为,password属性绝对不能使用可读文本来表示.而是需要对password属性进行加密。由于在将password赋给User之前.password属性需要加密.这暗示着需要某种形式的领域服务(Domain Service, 7)。之前.团队成员已经在通用语言的术语中为领域服务预留了一个位置。现在.是时候使用它了。此时的术语包括:

  • 租户:一个有名字的企业订阅方.它提供身份与访问服务,同时还包括其他的在线服务。租户向用户发出注册邀请.并处理用户注册过程。
  • 用户:一个租户下的注册用户.包含有个人名字和联系信息。一个用户拥有唯一的用户名和密码。
  • 加密服务:对密码或其他敏感信息进行加密。

还有一个问题没有解决: password应该作为User唯一标识的一部分吗?毕竟.password 也用于对User实体的查找。如果是.我们可能希望将username和password合为一个值对象.比如名为SecuHtyPHncipal.这样可以更加清晰地表达安全概念。这是一个很有趣的想法.但是它忽略了一个重要的需求:密码是可以修改的。另外.有时在查找User的时候.我们并不需要提供密码(考虑一些检查用户角色的情形.我们不能每次在检查用户的安全权限时都提供密码)。此时的密码并不是实体标识。当然.我们依然可以在单个认证查询中同时包含username 和 password 属性信息。

但是.创建一个SecuHtyPhncipal值对象的想法本身则是一个不错的建模主张.我们将在后面进行讨论。此外,我们还遗漏了另外_些概念.比如如何发出注册邀请.如何提供用户名和联系方式的一些细节信息等。SaaSOvation团队将在下一个迭代中处理到这些。


挖掘实体的关键行为

在识别出实体的重要属性之后,SaaSOvation团队开始转向实体的行为...

SaaSOvation团队回顾了一下先前的需求,现在他们开始考虑Tenant和User的行为:

  • Tenant可以处于激活状态或失活状态

当我们思考激活(Activate)或禁用(Deactivate) —个Tenant时,我们想的可能是一个布尔开关,至于如何实现这个开关在这里并不重要。如果我们将一个activate属性添加在Tenant类图中,别人在看到这张类图时,她/他能够知道activate表示什么意思吗?在Tenant类中,下面的属性能够表达出它的意图吗?

public class Tenant extends Entity {
    ...
    private boolean active;
    ...

上面的activate恐怕并不能完全地表达出它的意图。在开始的时候,我们将关注点放在对身份和查询有用的属性上,之后我们希望通过相似的方法加人一些与服务相关的信息。


团队也许会定义一个 setActive(boolean)的方法,虽然这个方法并不能很好地表达需求术语。这里并不是说公有的setter方法不合适.而是说只有在符合通用语言的情况下才能使用setter方法.也或者.只有当我们不必使用多个setter方法来完成单个请求时.才有道理使用setter方法。多个setter方法使意图充满了歧义.同时也使发布领域事件变得更加复杂.因为_个领域事件应该对应于逻辑上的单个命令。

考虑到通用语言,团队成员意识到领域专家使用的是"激活"和"冻结"这两个动作。为了准确地体现这些术语,他们将setter方法改成了activate ()和deactivate()方法。

以下代码是意图展现接口(Intention Revealing Interface) [Evans].它符合 SaaSOvation团队所处理的通用语言:

public class Tenant extends Entity {
    ...
    public void activate() {
         // TODO: implement
    }

    public void deactivate() {
        // TODO: implement
    }
    ...

为了更好地表达出Tenant的意图.他们首先编写了测试代码:

public class TenantTest ... {
    public void testActivateDeactivate() throws Exception {
        Tenant tenant = this.tenantFixture();
        assertTrue(tenant.isActive());

        tenant.deactivate();
        assertFalse(tenant.isActive());

        tenant.activate();
        assertTrue(tenant.isActive());
    }
}

此时.团队对于Tenant类的质量有了充足的信心。通过编写测试.他们意识到还需要另一个方法一一isActivateU。以上3个方法如图5.7所示。通用语言中的术语也随之增加:

  • 激活租户:通过该操作激活一个租户.激活后再对租户的当前状态进行确认
  • 禁用租户:通过该操作禁用一个租户,在禁用一个租户时.用户可能还没有被认证。
  • 认证服务:协调对用户的认证过程,首先需要保证他们所属的租户处于激活状态。

图5.7在第一个迭代中,Tenant中被加人了一些不合适的行为。处于复杂性考虑,有些行为被省略了,当然,我们可以在之后加人

最后一条术语表明.SaaSOvation团队发现了另外一个领域服务。在对User实例进行匹配之前.他们需要调用Tenant的isActivate()方法来检查租户的活跃状态。我们也可以通过以下需求看出该认证服务的必要性:

  • 系统必须对User进行认证.并且只有当Tenant处于激活状态时才能对User进行认证

可以看出.提供User的username和password信息只是认证用户的其中一个步骤,因此我们需要一个更高层面的认证协调者。领域服务便能很好地完成这样的任务。我们可以在后面再加入额外的细节,对于SaaSOvation团队来说,此时重要的是提出AuthenticationService这个概念.并将其加入到通用语言中。看来测试驱动的确有用啊!

团队同时考虑到了以下需求:

  • 通过邀请,租户允许用户进行注册

当他们开始仔细分析这项需求时.他们发现该需求比先前所想象的要复杂。这里似乎需要存在一个诸如Invitation的对象.但是需求并没有向他们提供足够的信息.管理邀请的行为也不清晰。因此.SaaSOvation团队决定推迟这个建模过程.等到有了领域专家和早期客户提供更多的输入信息时才继续。然而.他们还是创建了 registerUserU方法.该方法对于创建User实例来说非常重要(请参考下文的"创建"一节)。

对于他们先前对User类的理解:

  • User处理自己的信息.包括名字和联系方式

  • User个人的信息可以被其本人和Manager修改

  • User的安全密码是可以被修改的

这里我们使用两种经常联合使用的安全模式一一用户和基本身份(Fundamental Identity) 1很明显,"个人"的概念伴随着"User"概念。基于以上对User的理解.团队提出了一些组合概念和与之相关的行为。

1. 请参考我发布的模式: http://vaughnvernon.co/

团队创建了一个Person类.以避免将过多的职责放在User类上。上面的"个人的"一词使得团队将"个人"加入到通用语言中:

  • 个人: 包含并管理用户的个人信息.包括名字和联系方式等。

这里的Person是实体还是值对象呢?同样,"修改"一词是关键。我们似乎没有必要在一个用户修改电话号码时就将整个Person对象替换掉,因此SaaSOvation团队将其建模成了实体.如图5.8所示。该Person实体包含了两个值对象-Contactlnformation和Name,这些都是比较模糊的概念,之后在必要的时候我们将对其进行重构。

图5.8 User的基本行为导致了更多的关联关系。团队成员们还创建了一些额外的对象

这里.我们还需要思考一下如何管理用户的名字和联系信息。客户端可以访问到Use「中的Person对象吗?团队中的一员质疑到一个User是否总是一个Person。如果一个User表示的是_个外部的系统.又该怎么办呢?虽然这并不是当前的需求.但是这样的考虑是有价值的。如果我们允许客户访问Person.那么客户端代码可能需要做出相应的重构。

反之.如果团队成员将Person的行为直接放在User上,这可能会避免一些麻烦。在编写了测试来模拟对User的使用后.他们发现这样做是正确的.修改之后的User对象如图5.8所示。

还有另外的考虑。SaaSOvation团队是应该完全地将Person暴露给外界呢.还是应该向客户隐藏起来?现在.团队决定将Person暴露给外界,目的是为了获得查询信息。之后.他们会对此进行重新设计以服务于Principal接口 .而Person和System分别是两种特殊的Principal。当团队有了更深的理解之后.他们将做出这样的重构。

团队保持了以往的节奏.此时团队开始考虑最后一条需求所反映的通用语言:

  • User的安全密码是可以被修改的

User拥有一个changePasswordU行为方法。该方法反映了以上需求.领域专家对此也表示满意。客户是绝对不能访问到密码的.哪怕是加密之后的密码也不行。在设置了密码之后.该密码是不会暴露在聚合边界之外的。所有需要和安全认证打交道的代码都必须通过AuthenticationService。

团队还意识到.在成功执行所有的修改行为之后都需要向外发布领域事件。和上文提到的邀请用户注册样.这比团队先前所想象的要复杂。但是.他们的确意识到了事件的必要性。事件至少可以完成两项功能。首先,有了事件.我们可以对对象的整个生命周期进行跟踪(稍后讨论)。其次.事件可以通知外界订阅方完成同步操作.从而使这些订阅方具有潜在的自治性。


这些话题将在事件(3)和集成限界上下文(13)中进行讨论。


角色和职责

建模的一个方面便是发现对象的角色和职责。通常来说,对角色和职责分析是可以应用在领域对象上的。这里我们特别关注的是实体的角色和职责。

对于“角色”这个概念,我们需要一些上下文来理解。在身份与访问上下文中,一个角色是一个实体,同时是身份安全领域中的一个聚合根。客户可以询问一个用户是否拥有一种安全角色。该“角色”和我现在要讲的“角色”是两个全然不同的概念。我们这里所讨论的,是模型中的对象可以扮演什么样的角色。

领域对象扮演多种角色

在面向对象编程中,通常由接口来定义实现类的角色。在正确设计的情况下,一个类对于每一个它所实现的接口来说,都存在一种角色。如果一个类没有显式的角色一一即该类没有实现任何显式接口,那么在默认情况下它扮演的即是本类的角色。也即,该类的公有方法表示该类的隐式接口。比如,上面的User类并没有实现任何接口,但是它依然扮演了一种角色,即User角色。

我们可以使一个对象同时扮演User和Person的角色,虽然这并不是我所建议的,但就目前而言,让我们假设这是一个好的主意。这样一来,我们便没有必要在User中引用一个Person了,而是只需创建一个对象来同时扮演这两种角色即可。

那我们为什么要这么做呢?通常是因为两个或多个对象既有相似之处,又有不同之处。此时,这些对象上重叠的属性可以通过一个实现了多个接口的对象来表示。比如,我们可以创建一个HumanUser对象,该对象既是一个User,又是一个Person:

public interface User {
    ...
}

public interface Person {
    ...
}

public class HumanUser implements User, Person {
    ...
}

以上代码看似合乎情理的,但是它也可能使事情变得复杂。如果两个接口都是复杂的,那么HumanUser对象实现起来将是困难的。另外,如果User不是一个人,而是一个系统又该怎么办呢?此时我们可能需要3个接口,而要设计一个实现了这3个接口的对象将变得更加困难。我们可能需要创建一个通用的Principal来简化这个问题:

public interface User {
    ...
}

public interface Principal {
    ...
}

public class UserPrincipal implements User, Principal {
    ...
}

有了以上代码,我们可以直到运行时才决定一个Principal的类型。一个人对应的Principa丨和一个系统对应的Principal在实现上是不同的。一个系统不需要拥有像人一样的联系信息。另外,我们还可以通过委派的方式来实现以上两个接口,此时我们需要在运行时检查存在哪种类型的Principal,再将逻辑委派给这个实际的Principal 对象:

public interface User {
    ...
}

public interface Principal {
    public Name principalName();
    ...
}

public class PersonPrincipal implements Principal {
    ...
}

public class SystemPrincipal implements Principal {
    ...
}

public class UserPrincipal implements User, Principal {
    private Principal personPrincipal;
    private Principal systemPrincipal;
    ...
    public Name principalName() {
        if (personPrincipal != null) {
            return personPrincipal.principalName();
        } else if (systemPrincipal != null) {
            return systemPrincipal.principalName();
        } else {
            throw new IllegalStateException(
                    "The principal is unknown.");
        }
    }
    ...
}

以上代码设计存在多个问题,其中之一便是对象分裂症(Object Schizophrenia)2。对象的行为通过技术上的转向和分发来进行委派。无论是personPrincipal,还是systemPrincipal,它们都不具有UserPrincipal实体的身份标识,而UserPrincipal才是行为的最初执行对象。对象分裂症描述的是:委派对象根本不知道原来被委派对象的身份标识,因此我们无法知道委派对象的真正身份。虽然并不是所有的委派对象都需要知道被委派对象的身份标识,但是在有些情况下的确是必要的。我们可以向principalName()传入一个UserPrincipal对象的引用,但这使设计变得更加复杂,并且需要改变Principal接口,因此显然是不好的。就像[Gamma et al]中提到的一样,“委派只有在使问题简化而不是复杂化时,才是好的。”

2. 即表示一个具有多重身份的对象。

这里,我们并不打算解决这个建模难题,它只是向我们展示在建模对象角色时可能遇到的问题,并且提醒大家在这个时候应该额外小心。有一些好的工具可以帮助我们改进,比如Qi4j[Öberg]。

正如Udi Dahan[Dahan,Roles]所倡导的,它可以帮助我们设计更好的角色接口。以下两项需求有助于我们设计出好的接口:

  • 向一个客户添加订单。
  • 使客户成为优先(Preferred)客户。

Customer类实现了两个细粒度的角色接口: IAddOrdersToCustomer和IMakeCustomerPreferred。每一个接口都只定义了单个操作,如图5.9所示。我们甚至还可以使Customer实现另外的接口,比如IValidator。

图5.9在C#.NET命名规范中,Customer实体实现了2个对象角色,即IAddOrdersToCustomer和IMakeCustomerPreferred

聚合(10)中我们提到,我们并不希望创建一个拥有大量对象的集合,比如向Customei■中添加大量的订单。但是,这并不是我们当前的重点,这里的重点是演示对象角色的使用。

接口名字中的前缀“I”是.NET编程中的一种常见风格。这里的“I”除了表示“接口”之外,还表示“我”的意思,从而有助于提高代码的可读性,比如:“我将订单添加给客户”和“我将客户变成优先客户。”在没有前缀“I”的情况下,接口的可读性可能没有那么好:AddOrdersToCustomer和MakeCustomerPreferred。我们也有可能更倾向于使用名词和形容词来命名接口,这种方式显然也是适用的。

想想这种风格能给我们带来了哪些好处?实体的角色可以在不同的用例之间发生转变。将一个新的Order实例添加到Customer,或者使Customer变成优先客户,在这两种情况下一个Customer所扮演的角色是不同的。同时,这种风格还有技术上的好处,不同的用例所使用的Customer获取策略可能是不同的:

IMakeCustomerPreferred customer = session.Get<IMakeCustomerPreferred>(customerId);
customer.MakePreferred();

...

IAddOrdersToCustomer customer = session.Get<IAddOrdersToCustomer>(customerId);
customer.AddOrder(order);

通过使用泛型,持久化机制从基础设施中查找不同的获取策略。如果某个接口没有特殊的获取策略,那么将使用默认的获取策略。在使用特定的获取策略时,所获取的Customer能够满足特定的用例。

当然,还存在其他的特定于某个用例的行为可以与角色联系起来,比如验证功能,在实体被持久化时,它可以充当验证器的角色对自身进行数据验证。

好的接口设计也有助于实现类,比如Customer,将功能实现在其自身上,而没有必要将实现委派给其他类,对象分裂症也由此得到避免。

很自然地,你可能会问到,将Customer的行为通过角色进行划分是否能给领域建模带来好处呢?我们可以将前面的Customer和图5.10中的Customer做个对比,哪个更好呢?当需要调用MakePreferred()方法时,图5.10中的Customer是否更容易引导客户端错误地调用成AddOrder()方法?恐怕不见得,但是这并不是唯一的评判标准。

图5.10这里,先前实现了不同接口的Customer变成了实现单个接口的实体

角色接口最实用之处可能也是其最简单之处。通过接口,我们可以将实现细节隐藏起来,从而不至于将实现细节泄漏到客户端中。我们所设计的接口应该刚好能够满足客户端的需求,不多也不少。实现类可以比接口复杂得多,它可以拥有大量的支撑性属性,外加这些属性的getter和setter方法。但是,客户端是看不到这些实现细节的。比如,有些工具或框架可能强制性地要求在类上创建公有方法,而我们并不希望客户端调用这些公有方法。即便如此,领域模型接口也不会被技术上的实现细节所影响。显然,这是一个领域建模方面的好处。

不管采用哪种设计方式,我们都应该确保领域语言优先于技术实现。在DDD 中,业务领域的模型才是最重要的。

创建实体

当我们新建一个实体时,我们希望通过构造函数来初始化足够多的实体状态,这一方面有助于表明该实体的身份,另一方面可以帮助客户端更容易地查找该实体。在使用及早生成唯一标识的策略时,构造函数至少需要接受一个唯一标识作为参数。如果我们还有可能通过其他方式对实体进行查找,比如名字或描述信息,那么我们应该将这些参数也一并传给构造函数。

有时一个实体维护了一个或多个不变条件(Invariant)。不变条件即是在整个实体生命周期中都必须保持事务一致性的一种状态。不变条件主要是聚合所关注的,但是由于聚合根通常也是实体,故这里我们也稍作提及。如果实体的不变条件要求该实体所包含的对象都不能为null状态,或者由其他状态计算所得,那么这些状态需要作为参数传递给构造函数。

每一个User对象都必须包含tenantId、username、password和person属性。换句话说,在User对象得到正确实例化之后,这些属性绝对不能为null。User对象的构造函数和实例变量对应的setter方法保证了这一点:

public class User extends Entity {
    ...
    protected User(TenantId aTenantId, String aUsername,
            String aPassword, Person aPerson) {
        this();
        this.setPassword(aPassword);
        this.setPerson(aPerson);
        this.setTenantId(aTenantId);
        this.setUsername(aUsername);
        this.initialize();
    }
    ...
    protected void setPassword(String aPassword) {
        if (aPassword == null) {
            throw new IllegalArgumentException(
                   "The password may not be set to null.");
        }
        this.password = aPassword;
    }

    protected void setPerson(Person aPerson) {
        if (aPerson == null) {
            throw new IllegalArgumentException(
                    "The person may not be set to null.");
        }
        this.person = aPerson;
    }

    protected void setTenantId(TenantId aTenantId) {
        if (aTenantId == null) {
            throw new IllegalArgumentException(
                    "The tenantId may not be set to null.");
        }
        this.tenantId = aTenantId;
    }

    protected void setUsername(String aUsername) {
            if (this.username != null) {
            throw new IllegalStateException(
                    "The username may not be changed.");
        }
        if (aUsername == null) {
            throw new IllegalArgumentException(
                    "The username may not be set to null.");
        }
        this.username = aUsername;
    }
    ...
}

User对象展示了一种自封装性。在构造函数对实例变量赋值时,它把操作委派给了实例变量所对应的setter方法,这样便保证了实例变量的自封装性。实例变量的自封装性使用setter方法来决定何时给实例变量赋值。每一个setter方法都“代表着实体”对所传进的参数做非null检查,这里的断言称为守卫(Guard)(请参考“验证”一节)。在“标识稳定性”一节中我们讲到,setter方法的自封装性技术可能会变得非常复杂。

对于那些非常复杂的创建实体的情况,我们可以使用工H请参考工厂 (Factories, 11)。在上面的例子中,你是否注意到User对象的构造函数被声明为了protected? Tenant实体即为User实体的工厂也是同一个模块中唯一能够访问User构造函数的类。这样一来,只有Tenant能够创建User实例:

public class Tenant extends Entity  {
    ...
    public User registerUser(String aUsername, String aPassword Person aPerson) {

        aPerson.setTenantId(this.tenantId());

        User user = new User(this.tenantId(), aUsername, aPassword, aPerson);

        return user;
    }
    ...
}

这里的registerUser()便是工H该工厂简化了对User的创建,同时保证了 Tenantld在User和Person对象中的正确性。此外,该工厂是能够反映通用语言的。

验证

验证的主要目的在于检查模型的正确性,检查的对象可以是某个属性,也可以是整个对象,甚至是多个对象的组合。我们将对模型进行三个级别的验证。虽然有很多种验证方式,包括专门用于验证的框架和类库等,但这里我们并不会讲到这些。我们要讨论的主要是一些通用的验证方法。

验证可以达到不同的目的。即便领域对象的各个属性都是合法的,这也并不表示该对象作为一个整体是合法的。两个合法属性组合起来有可能使整个对象不合法。同样的道理,单个对象的合法性并不能保证对象组合的合法性。两个合法实体对象的组合有可能是不合法的。因此,我们需要采用不同级别的验证来处理这些情况。

验证属性

我们如何确保属性处于合法状态呢?正如我在本书其他地方所讲,我强烈建议使用自封装(Self-Encapsulation)来验证属性。

Martin Fowler曾说:“自封装性要求无论以哪种方式访问数据,艮P使从对象内部访问数据,都必须通过getter和setter方法” [Fowler, Self Encap]。这种方式有诸多优点。首先它为对象的实例变量和类变量提供了一层抽象。其次,我们可以方便地在对象中访问其所引用对象的属性。重要的是,自封装性使验证变得非常简单。

事实上,我并不愿意将自封装性称为验证。在一些开发者看来,验证是一个单独的关注点,因此应该将该职责放在验证类上,而不是领域对象上。我是同意这一点的。此外,我还想谈谈断言(Assertion),这是一种契约式设计(Design-by-Contract)方式。

从定义来看,在契约式设计中,我们可以指定前置条件、后置条件和组件中的不变条件。这种设计方法首先由Bertrand Meyer所提出,并在他开发的Eiffel语言中得到了充分的体现。在Java和C#语言中,我么也可以进行契约式设计,请参考《Design Patterns and Contracts》[Jezequel et al.]。这里我们只讨论前置条件,它为对象提供了一个保护层,因此也可以看作是一种验证形式:

public final class EmailAddress {

    private String address;

    public EmailAddress(String anAddress) {
        super();
        this.setAddress(anAddress);
    }
    ...
    private void setAddress(String anAddress) {
        if (anAddress == null) {
            throw new IllegalArgumentException(
                    "The address may not be set to null.");
        }
        if (anAddress.length() == 0) {
            throw new IllegalArgumentException(
                    "The email address is required.");
        }
        if (anAddress.length() > 100) {
            throw new IllegalArgumentException(
                    "Email address must be 100 characters or less.");
        }
        if (!java.util.regex.Pattern.matches(
            "\\w+([-+.']\\w+)*@\\w+([-.]\\w+)*\\.\\w+([-.]\\w+)*",
                anAddress)) {
            throw new IllegalArgumentException(
                    "Email address and/or its format is invalid.");
        }
        this.address = anAddress;
    }
    ...
}

在上面的代码中,setAddressO方法中存在4个前置条件。所有的前置条件都对 an Address参数进行断言:

  • anAddress 不能为 null。
  • anAddress不能为空字符串。
  • anAddress的长度不能大于100。
  • anAddress需要满足电子邮件格式。

只有所有这些前置条件都通过了,an Address才能被赋给address属性。否则,程序将拋出 IllegalArgumentException 异常。

EmailAddress类并不是一个实体,而是值对象。这里我们使用该值对象是有原因的。首先它向我们展示了一个很好的前置条件验证的例子,从null检查到格式检查。其次,EmailAddress是Person实体的属性--Person实体持有Contactlnformation 值对象,Contactlnformation 进而持有Email Address,因此 Email Address和其他直接声明在Person中的简单属性一样,都是Person实体的属性。在为其他简单属性创建setter方法时,我们可以釆用完全相同的方式对它们进行验证。在将一个整体值对象赋给实体时,只有当值对象中的所有较小属性得到验证时,我们才能保证对整体值对象的验证。

有些开发者认为这种前置条件检查是一种防御式编程(Defensive Programming)。其中一些人并不同意这种多级检查的验证方式,另有些认为对null和空字符串的检查是可以接受的,但是没有必要验证字符串长度、数值范围和格式等信息。另外,还有人认为将对字符串长度和数值范围的验证放在数据库中是最好的办法,因为这些功能并不属于领域对象。

有时我们没有必要对字符串的长度做出检查,此时可以定义一个拥有足够宽度的数据库列,比如在Microsoft SQL Server数据库中,我们可以使用max关键字来定义一个文本列:

CREATE TABLE PERSON (
    ...
    CONTACT_INFORMATION_EMAIL_ADDRESS_ADDRESS
            NVARCHAR(max) NOT NULL,
    ...
) ON PRIMARY
GO

这里并不是说我们希望一个E-mail地址有1,073,741,822个字符这么长,而是定义一个足够大的范围,使得任何实际的E-mail地址都不会超出该范围。

然而,对于有些数据库来说,这种方法便不见得可行了。在MySQL中,最大的行宽为65,535字节,请注意,这里是行宽,而不是列宽。如果我们将其中一个列定义为最大宽度为65,535的VARCHAR类型,那么其他的列便没有存放空间了。根据数据库中所定义VARCHAR列的数量,我们需要对每一列的宽度进行限制。在这种情况下,我们可能需要将某些列定义为TEXT类型,因为TEXT列和BLOB列存储在不同的块中。因此,对于不同的数据库,我们需要找到适当的限制列宽的方法,以避免在领域模型中对字符串长度进行验证。

如果对象的属性有可能超过列宽,那么此时在模型中进行长度验证便是有必要的了。思考一下,如果将以下错误转换成一个领域中的错误,这将有多大的实际意义?

ORA-01401: inserted value too large for column

我们甚至都不知道到底是哪个列超出了范围,此时最好的方式便是将长度验证放在前置条件中。另外,长度检查并不见得只是对数据库的列宽做出约束。最终,我们还是要根据各种领域需求来限制字符串长度,比如当需要集成的遗留系统对字符串长度有约束时。

有时我们还需要考虑诸如区间范围检查之类的验证。即便是非常简单的格式检查,比如E-mail地址格式,对于保护实体的合法性来说也是有意义的。在单个实体验证通过的情况下,要再对由不同实体组成的整体对象或组合对象进行验证,也将变得更加简单。

验证整体对象

虽然有时实体中的所有单个属性都是合法的,但是这并不意味着整个实体就是合法的。要验证整个实体,我们需要访问整个对象的状态一一所有对象属性。此时我们可能还需要使用规范(Specification) [Evans & Fowler, Spec]或者策略(Strategy) [Gamma et al.]来进行验证。

Ward Cunningham在他的Checks模式语言中[Cunningham,Checks]讨论了多种验证方法,其中验证整体对象的一种方法为延迟验证(Deferred Validation)。Ward解释道:“这是一种到最后一刻才进行验证的方法。”之所以需要延迟,是因为我们需要进行非常详细的验证,比如对复杂对象的验证,甚至对对象组合的验证。我们将在稍后的“验证对象组合”一节中讲到延迟验证。在本节中,我们主要讲解Ward所谓的“简单活动验证(the checks of simpler activities) ”。

由于验证逻辑需要访问实体的所有状态,有人可能会直接将验证逻辑嵌入到实体对象中。这里我们需要注意了,更多的时候验证逻辑比领域对象本身变化还快,而将验证逻辑嵌入在领域对象中也使领域对象承担了太多的职责。

此时我们可以创建一个单独的组件来完成模型验证。在Java中设计单独的验证类时,我们可以将该类放在和实体相同的模块(包)中,将属性的gettei■方法声明在包级别(S卩用protected修饰),这样验证类便能访问到这些属性了。当然,将属性声明为public也是可以的。但是,声明为private便不行了,因为此时验证类无法访问到领域对象的属性状态。如果验证类和领域对象不在相同的包中,那么所有属性的getter方法都应该声明为public,而这并不是我们希望看到的情形。

验证类可以实现规范模式或策略模式。当发现非法状态时,验证类将通知客户方或者记录下验证结果以便后用(比如,在批处理完成之后)。验证过程应该收集到所有的验证结果,而不是在一开始遇到非法状态时就抛出异常。考虑以下的例子:

public abstract class Validator {
    private ValidationNotificationHandler notificationHandler;
    ...
    public Validator(ValidationNotificationHandler aHandler) {
        super();
        this.setNotificationHandler(aHandler);
    }

    public abstract void validate();

    protected ValidationNotificationHandler notificationHandler() {
        return this.notificationHandler;
    }

    private void setNotificationHandler(
            ValidationNotificationHandler aHandler) {
        this.notificationHandler = aHandler;
    }
}
public class WarbleValidator extends Validator {

    private Warble warble;

    public Validator(
            Warble aWarble,
            ValidationNotificationHandler aHandler) {
        super(aHandler);
        this.setWarble(aWarble);
    }
    ...
    public void validate() {
        if (this.hasWarpedWarbleCondition(this.warble())) {
            this.notificationHandler().handleError(
                    "The warble is warped.");
        }
        if (this.hasWackyWarbleState(this.warble())) {
            this.notificationHandler().handleError(
                    "The warble has a wacky state.");
        }
        ...
    }
}

在上例中,WarbleValidator在初始化日寸传入了一个ValidationNotificationHandler。任何时候,当发现非法状态时,WarbleValidator^会调用 ValidationNotificationHandler来处理。ValidationNotificationHandler是一个通用实现,它拥有—

发现实体及其本质特征

handleErrorO方法,该方法接受一个String类型的验证通知消息。我们也可以为 ValidationNotificationHandler创建不同的方法来处理不同的非法状态:

class WarbleValidator extends Validator {
    ...
    public void validate() {
        if (this.hasWarpedWarbleCondition(this.warble())) {
            this.notificationHandler().handleWarpedWarble();
        }
        if (this.hasWackyWarbleState(this.warble())) {
            this.notificationHandler().handleWackyWarbleState();
        }
    }
    ...
}

这样一来,我们便将错误消息、消息键值或者消息通知与验证过程进行了解耦。还有更好的方法,将验证通知封装在方法中:

class WarbleValidator extends Validator {
    ...
    public Validator(
            Warble aWarble,
            ValidationNotificationHandler aHandler) {
        super(aHandler);
        this.setWarble(aWarble);
    }
    ...
    public void validate() {
        this.checkForWarpedWarbleCondition();
        this.checkForWackyWarbleState();
        ...
    }
    ...
    protected checkForWarpedWarbleCondition() {
        if (this.warble()...) {
            this.warbleNotificationHandler().handleWarpedWarble();
        }
    }
    ...
    protected WarbleValidationNotificationHandler
            warbleNotificationHandler() {
        return (WarbleValidationNotificationHandler)
                this.notificationHandler();
    }
}

在这个例子中,我们使用了一个特定的ValidationNotificationHandler。在传人WarbleValidator时,它是一个标准类型,然后在使用时我们将其强制转换成一个特定的WarbleVal id at ion Notification Handler类型。对于使用什么样的ValidationNotificationHandler类型,验证类和客户端应该达成一致。

客户端如何保证对实体的验证确实发生了呢?验证过程又从何处开始呢?要将validateO方法应用在所有需要验证的实体上,我们可以使用层超类型:

public abstract class Entity extends IdentifiedDomainObject  {

    public Entity() {
        super();
    }

    public void validate(
            ValidationNotificationHandler aHandler) {

    }
}

任何继承自Entity的类都可以安全地调用validateO方法。如果具体的实体类拥有自身的验证逻辑,该验证逻辑将被执行,否则validateO方法不做任何事情。同时,我们应该只在需要进行验证的实体中才定义validate()方法。

然而,实体应该进行自我验证吗?拥有validateO方法并不表示需要实体自行执行验证过程。此时实体可以将验证过程交给单独的验证类:

public class Warble extends Entity {
    ...
    @Override
    public void validate(ValidationNotificationHandler aHandler) {
        (new  WarbleValidator(this, aHandler)).validate();
    }
    ...
}

每一个专有的Validator都会执行特定的验证过程。实体类不用知道验证过程是如何发生的。单独的Validator也将验证逻辑的变化与实体对象本身的变化分离开来,并且有助于对复杂验证过程的测试。

验证对象组合

正如Ward Cunningham所说,在需要对复杂对象进行验证时,我们可以使用延迟验证。这里我们关注的并不只是某个单独的实体是否合法,而是多个实体的组合是否全部合法,包括一个或多个聚合实例。要达到这样的目的,我们需要创建继承自Validator的不同验证类实例。但是,最好的方式是把这样的验证过程创建成一个领域服务。该领域服务可以通过资源库读取那些需要验证的聚合实例,然后对每个实例进行验证,可以是单独验证,也可以和其他聚合实例组合起来验证。

在任何时候,我们都需要决定是否可以展开验证。有时某个聚合或一组聚合可能处于临时的、中间的状态。此时我们可以在聚合上创建一个状态标识来避免对这些状态的验证。当验证条件成熟时,模型通过发送领域事件的方式通知客户方:

public class SomeApplicationService ... {
    ...
    public void doWarbleUseCaseTask(...) {
        Warble warble = this.warbleRepository.warbleOfId(aWarbleId);

        DomainEventPublisher.instance().subscribe(new DomainEventSubscriber<WarbleTransitioned>(){
                public void handleEvent(DomainEvent aDomainEvent) {
                     ValidationNotificationHandler handler = ...;
                     warble.validate(handler);
                     ...
                }
                public Class<WarbleTransitioned> subscribedToEventType() {
                    return WarbleTransitioned.class;
                }
        });

        warble.performSomeMajorTransitioningBehavior();
    }
}

当客户方接收到事件时,其中的WarbleTransitioned表示可以进行验证了。而在这之前,客户方是不会进行验证的。

跟踪变化

根据实体的定义,我们没有必要在整个生命周期中对状态的变化进行跟踪,而是只需要跟踪那些持续改变的状态。然而,有时领域专家可能会关心发生在模型中的一些重要事件,此时我们便应该对实体的一些特殊变化进行跟踪了。

跟踪变化最实用的方法是领域事件和事件存储。我们为领域专家所关心的所有状态改变都创建单独的事件类型,事件的名字和属性表明发生了什么样的事件。当命令操作执行完后,系统发出这些领域事件。事件的订阅方可以接收发生在模型上的所有事件。在接收到事件后,订阅方将事件保存在事件存储中。

领域专家并不会关心发生在模型中的所有变化,但这却是技术团队所应该关心的。这主要是出于技术上的原因,请参考事件源(Event Sourcing, 4)模式。

results matching ""

    No results matching ""