四、建模执行者

我们现在已经准备好首先投入到开发中,并且我们已经有了一个坚实的结构来帮助我们处理无论如何都会出现的变化。 现在是时候更多地考虑我们系统的不同组件是什么以及它们是如何相互作用的了。

系统中的交互发生在多个层次上。 操作系统与语言运行时交互,运行时与我们的代码交互,然后在我们的代码中,我们创建回调对象并调用其他进程,等等。 我们已经看到了域对象如何与底层框架交互,我们可以想象代码如何调用不同的库。 在组织交互时,了解现有的接缝并在必要时创建新的接缝是很重要的。 当调用其他代码时,我们的代码在哪里结束,库代码在哪里开始是非常清楚的。 当我们创建代码时,很容易混淆职责,但是我们能够更好地分离它们,我们就能够更好地在未来发展我们的代码。

几乎计算机科学的所有方面都以某种方式处理不同组件之间的交互,因此存在多种技术来确保这些交互工作良好。 在本章中,我们将重点关注系统的参与者及其相互作用,并将深入了解以下细节:

  • 使用面向对象编程技术为领域参与者建模
  • 隔离测试域对象
  • 标识和命名域中的角色

巨人的肩膀

交互如何建模和工作的最著名模型之一是描述网络堆栈中各层交互的OSI/ISO模型。 它由七层组成,每一层都有一个定义良好的接口,上面的层可以与之通信,下面的层也可以与之通信。 此外,每一层定义了一个协议,允许它与同一层的通信。 有了这一点,就有了一个非常清晰的 API 来与该层通信,并且也清楚该层如何回调到系统,因此很容易替换系统的部分。 下图展示了 OSI/ISO 模型是如何描述它的。 每一层都由一个协议定义,该协议允许每一方的实例在其层上通信,当我们向上移动堆栈协议被给定的实例包装和解除包装:

The shoulders of giants

这个模型当然并没有被广泛采用,因为 TCP/IP 只关注 5 层,甚至有人说过多的分层会被认为是有害的。 但是,即使是那些不赞成 OSI/ISO 模型的人也认为这个基本思想是有价值的,可以说,隔离通信是使互联网工作的基本要素之一。 每一层都是可替换的,无论是完全替换还是只是针对特定情况,这在任何系统中都是非常强大的。

将这种方法引入到建模应用中意味着我们的对象应该在业务领域的层上进行通信。 就领域驱动设计而言,一个聚合可以与其他聚合交互以实现其目的,但如果一个服务进入一个实体而不考虑这个聚合,那就不行。 访问应用的不同部分而不考虑适当的 api 会导致将两个模型耦合在一起。 在我们的地下城中,让外国地下城的地下城主与囚犯直接交流也是一个糟糕的主意,这很有可能将囚犯标记为间谍并立即杀死他。 这不仅会由于紧密耦合而导致问题,而且还会导致应用出现安全问题。 例如,存在许多 SQL 注入攻击的实例,因为访问数据库的模型直接访问 HTTP 请求中传递的数据,而没有一个层来减少访问。

像这样的交流,即一个对象与对象图的另一部分进行交流,忽略了控制接口,这是一个很容易理解的问题,并被固化为Demeter 定律,即:

|   | 每个单元只应该对其他单元有有限的了解:只有与当前单元“密切”相关的单元。 |   | |   | ——得墨忒耳定律 |

在面向对象语言中,这通常被解释为一个方法应该只有一个点。 例如,在 orc master 上使用如下方法就违背了这一点。 下面的代码显示了通过深入地牢及其后代控制的对象,实现地牢中可用武器的访问器:

function weapons() {
  result = []
  dungeon.orcs.forEach(function (orc) {
    result.push(orc.weapon.type)
  })
  return result
}

在这种情况下,兽人大师通过地下城到达每个兽人,并直接询问他们的武器类型。 这不仅将兽人主人与地下城的内部执行绑定在一起,还将兽人甚至武器本身绑定在一起; 如果这些元素中的任何一个发生变化,方法也必须发生变化。 这不仅使对象本身更难以更改,而且系统现在整体上更加僵硬,在重构中不再具有可伸缩性。

前面的代码在对数据结构的操作中是必需的,而面向对象的代码则侧重于更声明式的样式,以减少耦合的数量。 声明式意味着代码告诉对象需要做什么,并让它们处理实现目标所需的操作:

|   | 过程代码获取信息,然后做出决定。 面向对象的代码告诉对象去做事情。 |   | |   | ——亚历克·夏普 |

沟通不应随意跨越边界,而应以定义良好且合理的方式保持软件的延展性。 这也意味着在开发软件时,我们需要意识到组件和接口,像我们已经做过的那样识别它们,并意识到我们正在编写的代码会产生新的组件和接口。 同样的道理也适用于表示在域中发送的消息的命令和事件。

即使是在开发之前非常深入地考虑正在开发的软件并像我们所做的那样绘制图表时,您几乎不可避免地会错过在开发开始时变得清晰的某些抽象概念。 我们编写的代码和测试应该使接口清晰,为了利用这一事实,一种常见的方法是尽可能早地运行开发中的代码,并让它“告诉”您它的依赖项。

不同的发展方式

现在我们正在编写代码来解决问题在我们的领域,我们可以以不同的方式解决问题:一种方法是在最高的级别开始我们迄今发现本指南,让我们降至低水平对象和抽象,或与我们确认的组件,我们可以开始冲洗出来并建立系统。 这两种方法都是有效的,通常被称为“由外而内”或“由内而外”开发。 由内而外的优点是我们总是有一个运行的工作系统,因为我们首先构建依赖项,然后构建系统。 缺点是,它更容易忽视更大的图景,并迷失在细节中。

这些方法的共同点是,它们遵循基于测试驱动开发的风格。 我们正在构建测试,让我们指导设计,并在完成时向我们展示。 我们首先使用我们的代码,以了解它以后的行为,并实现我们认为行为应该是什么。 这可以通过首先关注小的、更容易掌握的组件来实现,就像在由内到外方法中所做的那样。 另一种方法是在一开始就提出大问题,随着我们前进深入到更多的细节,就像由外而内方法一样。

对于这个项目,我们觉得从外部开始更合适,因为我们探索并了解了涉众想要什么,但并不清楚确切的组件和他们的行为; 毕竟,我们生活在一个我们并不完全熟悉的世界里。 特别是在一个不熟悉的世界里,我们很容易开始构建我们从来都不需要的东西。 例如,现在我们还不太了解地下城之间的信息系统。 我们可以开始尝试建立一个抽象,允许我们控制尽可能多的,但是,另一方面,我们可能每周只发送一个消息,有这个屏幕上弹出《地下城主让他做手工是完全合理的。 在这些类型的评估中,我们必须记住我们的首要目标应该总是交付价值和节省资金,这可能意味着而不是建造东西。 那么,在没有底层结构的情况下,我们如何创建软件呢?

介绍模仿

当试图从外部向内建模一个系统时,需要使用对象来代替最终将成为底层实现的东西。 这在每一层都会发生,建模 API 的概念首先会渗透到较低的层。 我们之前开始建立囚犯转移服务,依赖于囚犯和地牢; 这些也会有依赖关系,当冲洗对象时,将需要以类似的方式设计。

启用该功能的对象称为模拟; 它们是提供某个概念的静态实现的对象,并可以断言它们被正确调用。 mock 实现特定对象应该遵循的协议。 在动态语言(如 JavaScript)中,这既容易又困难。 不同的 JavaScript 测试框架采用不同的方法; 有些使用描述的模拟对象,而有些提供间谍,通过调用实际对象来监视这些调用的正确性。 这两种方法都很有效,都有各自的优点。

提示

更多关于间谍的信息可以在http://derickbailey.com/2014/04/23/mock-objects-in-nodejs-tests-with-jasmine-spies/上找到。

创建一个 mock 可以很简单:

var myMock = {
  called: false,
  aFunction: function () { myMock.called = true }
}

虽然这不是一个非常高级的模拟,但它包含了我们需要的东西。 这个对象现在可以代替任何需要特定 API 提供一个名为aFunction的函数的对象。 在运行测试之后,还可以通过检查被调用的变量来检查函数是否被调用。 这些检查可以通过运行时直接提供的assert库来完成,而不需要额外的测试框架。 在下面的代码中,我们使用上面创建的非常简单的 mock 来断言一个函数在给定的时间确实被调用了:

var assert = require("assert")

function test_my_mock() {
  mock = Object.create(myMock) // called on the mock is false
  mock.aFunction()
  assert(mock.called) // called on the mock is true
}

test_my_mock()

在本例中,我们使用Object.create方法来创建myMock对象的一个新实例,并对其进行练习,验证其是否正确工作。

如何创建模拟对象非常特定于需要它们并且多个库实现它们的创建的情况。 一个非常常用的库是Sinon.JS,,它提供了许多不同的方法来验证功能,实现存根,mock 和间谍。 结合 Mocha 作为我们的测试框架,我们可以创建一个模拟测试,通过创建我们想要模拟的对象,并让 Sinon.JS 模拟,因为它为我们解除了繁重的验证。 我们现在可以用可读性很强的术语来描述 API 的行为,使用 Mocha 的组合特性来提供行为描述,而 Sinon.JS 则提供高级的模拟和验证。 这里有一个例子:

var sinon = require("sinon")

var anApi = {
  foo: function () {
         return "123"
       }
}

describe("Implementing an API", function () {
  it("is a mock", function () {
    var mock = sinon.mock(anApi)
    mock.expects("foo").once()

    anApi.foo()
    mock.verify()
  })
})

从表面上看,mock 的概念相当简单,但它的使用可能很困难,因为很难发现 mock 实际的正确位置。

提示

更多关于模仿的信息,请访问http://www.mockobjects.com/2009/09/brief-history-of-mock-objects.html

为什么嘲笑,为什么不嘲笑

|   | 我们最初的描述过多地关注于实现,关键的想法是该技术强调对象相互扮演的角色。 |   | |   | ——模拟对象简史——Steve Freeman |

模拟对象在测试期间代替系统中的其他对象,有时甚至在开发期间。 这样做有多种原因,例如,底层结构还没有实现,或者调用在开发期间的时间成本,甚至调用按调用数量收费的 API 的费用都非常昂贵。 对于开发人员来说,能够离线运行测试也是非常方便的,而且还有更多的原因可以解释为什么有些人不希望调用真实的系统,而是在它的位置上做一些事情。

这种做法通常被称为剔除外部依赖项。 当结合使用关于这个依赖项的断言时,这个存根就变成了一个 mock,这在开发过程中通常有助于确保某些代码在正确的时间,当然,在测试时被正确调用。

很容易陷入创建非常特定的模拟对象、模拟其他对象的内部依赖关系等等的陷阱。 要记住的重要一点是,mock 应该始终代表系统中的一个角色。 现实世界中的各种其他对象都可以扮演这个角色,但是它们可以在一个 mock 中表示。 在经典的面向对象术语中,这意味着我们在模拟接口而不是类。 在 JavaScript 中,没有接口,所以我们需要选择正确的对象来模拟。 对象,或者我们的模拟对象的一部分,只需要表示对测试来说最基本的东西,而不是更多。 当我们通过测试驱动我们的设计时,这是很自然的,但是随着软件的发展和变化,我们需要关注这一点,因为变化可能会导致我们的测试通过模拟过度指定对象。

谁参与了囚犯转移?

在前几节中,我们对该领域进行了大量的探索,以便了解要使系统中的操作发生,必须做些什么。 有了这些知识,我们现在可以清楚地知道囚犯转移应该如何发生。 我们之前创建的测试指定了我们在该领域中知道的一些行为和协作者。 我们将它们表示为包含满足测试所需属性的基本 JavaScript 对象; 例如,我们知道地下城需要一个邮件收件箱来通知,但我们还不知道囚犯的任何属性。 下面的代码提供了一些简单的函数让我们描述我们正在使用的类型的对象,随着代码的增长,我们的知识是什么让一个囚犯或一个地牢固化我们可以填补那些继续各自的对象的替身在我们测试:

/* get a prisoner to transfer */
function getPrisonerForTransfer() { return {} }

/* get a dungeon to transfer to */
function getDungenonToTransfer() { return { inbox: [] } }

到目前为止,囚徒和地牢都是特定的 JavaScript 对象,只是为了表示我们现在需要的东西。 进一步看细节,其他演员也参与了,即在路上守卫囚犯的兽人,以及转移马车。 当然,这些也有依赖关系:马车由车夫组成,木制马车作为囚犯的移动牢房,以及拉马车的马。 所有这些都是我们需要获得的潜在稀缺资源。 这就是领域建模再次发挥作用的地方; 在我们的应用的上下文中,我们可以避免把它们看作是单独的东西,因为如果其中任何一个都没有了,整个对象将无法运行。 我们可以关注不同对象扮演的角色,并将它们作为集合来获取,使其符合我们的模型。

不同的对象及其角色

马车是那些描述的角色之一; 此时,我们并不关心马车是由什么组成的,而是把它当作实现我们系统中某些目的的一个东西。 整个车厢是一个集合,我们现在只希望从外部进行检查,而不太关心它的内部。 在这里,马车公共 API 显示了一个 seam,我们在建模时需要考虑这个 seam。 稍后,我们可能会把马作为一个单独的东西来关心,例如,在建模一个信使时,我们想要为马车和信使分配马。

聚合不是一种限制资源共享能力的方法,而是一种使处理组合对象不那么复杂的概念。 这并没有改变没有马的马车是无用的,其他东西也可能需要获得马作为资源的事实。 马车是我们系统中的一个角色。 它提供了一个公共 API,并负责处理自己的内部数据和依赖项。 它本身就是一个规模较小的集合体。

在使用模拟和存根构建系统时,发现这种接缝是一个基本的想法。 通过模拟系统中的角色,我们可以在角色真正存在之前与它进行交互,并在不受内部实现阻碍的情况下探索其功能。

根据域命名对象

|   | 在计算机科学中只有两件困难的事情:缓存失效和命名事物。 |   | |   | ——菲尔·卡尔顿 |

在研究领域中的角色时,最复杂的事情通常是,我们需要为试图在系统中建立的角色命名。 当我们能够命名一个事物时,我们可以自然地将它与它在系统中所扮演的角色联系起来。 当我们构建一个软件系统,并能够通过给他们具体的名字来指出角色时,我们使每个在系统上工作的开发人员很容易知道在哪里放置与他们需要工作的部分相关的功能。

在前面,我们介绍了马车的概念,包括马车本身、拖它的马和车夫。 这是根据领域命名概念的一个示例。 在兽人地下城的世界中,马车的概念非常清楚,运行它需要的东西也非常清楚。 通过在系统中使用涉众的语言,我们增加了团队的语言,并使所有涉众都能参与。 我们之前在识别区域的时候见过这个; 现在,我们确保在创建抽象的同时继续增加语言。 这允许我们将某些细节隐藏在一个共同的角色后面。

常见名称的陷阱,如*经理

我们引入的概念,在领域中众所周知,是一个很好的抽象; 然而,作为软件开发人员,我们倾向于重复使用以前在其他应用中看到的元素。 在命名角色时,很容易陷入一种命名模式。 我们经常看到Manager对象的存在只是因为它们所扮演的角色没有更好的名称:

var transportManager = new TransportManager(driver, horse, cart)
transportManager.initializeTransport(prisoner)

即使这个对象履行了与我们之前命名为carriage的对象相同的职责,但从名称中发现它的作用不再是显而易见的。 即使对团队中的开发人员来说这个对象的目的是什么很清楚,其他涉众也会感到困惑。 这将导致团队内部的分离,并且不会促进非开发人员参与开发过程。

将对象命名为管理器通常意味着根据它当前所做的事情来命名它,而不是根据它在系统中通常所扮演的角色来命名它。 以这种方式命名一个对象,很难抽象出其中的细节。 了解一个Manager对象总是意味着了解它在管理什么,以及它的内部细节如何工作来理解它。 抽象会泄露到系统的其他部分,每个使用管理器的人都会查看它所管理的部分和细节。

在编写测试的环境中,管理器对象的痛苦常常变得非常明显。 当我们想要测试一个管理器时,我们没有看到一个清晰的抽象,我们需要关心内部依赖关系,因此需要在测试中保留它们。 这使得测试看起来很复杂,并且设置开始超过实际的断言部分。 通过使用以通用角色命名的对象,我们得到了服务于非常通用角色的对象,从而远离了特定于域的对象。 这可能会造成痛苦,因为这些泛型对象只能通过其内部实现实现特定,因此不能很好地代表它们应该扮演的角色。

提示

当您在为对象命名时遇到困难时,可以尝试先给它命名一些显然很愚蠢的名称,然后让对该领域的探索引导您找到一个更具体、更有意义的名称。

方法名称的可读性

面向对象编程(OOP)中,对象保存数据,并负责与它所保存的数据最密切相关的操作。 对数据进行操作的函数,例如根据对象的内部状态计算新数据,称为查询。 这类函数的示例是计算复合数据的函数,比如根据 orc 集合的名和姓来计算其全名:

function Orc(firstName, lastName) {
  this.firstName = firstName
  this.lastName = lastName
}

Orc.prototype.fullName = function () {
  return this.firstName + " " + this.lastName
}

另一方面,如果一个对象不是不可变的,就需要有函数来修改它的内部状态。 改变对象内部状态的函数称为命令; 它们允许外部对象向对象发送命令以改变其行为。 下面是一个例子:

function Orc(age) {
  this.age = age
  this.attacking = false
}

Orc.prototype.goToBattle = function () {
  if (age < 18) throw new Error("To young for battle")
  this.attacking = true
}

作为命令改变他们的内部状态,它需要很清楚正在发生的事情和对象应该有尽可能多的控制在实际做什么命令的情况下,所以命令告诉对象做什么,不要求它的状态修改它。 这意味着我们想要指示对象完成任务,而不检查它的属性。 与此相反的是检查对象属性,并基于这些属性来代替负责属性的对象。 Tell, Don't Ask原则是 OOP 的一个重要原则。 前面的例子遵循这个概念,通过不创建一个 setter 来攻击属性,我们可以确保Orc对象控制其内部状态。 让特定于域的命令像它们所做的那样读取,而不是创建大量的 setter/getter 方法,这有助于可读性,并确保状态得到良好管理。 在面向对象中,我们希望对象负责它的状态和操作该状态的方法。

对象不仅是允许我们对域建模的一致命名方案的一部分。 当我们对功能建模时,我们想要它读得清楚,我们也需要使方法名可读。 在前面的例子中,TransportManager唯一的方法是initializeTransport,它或多或少重复了对象的名称。 当对象是ManagersExecutors或类似的对象时,这种模式非常常见,但它无助于提高可读性。 这与创建 setter(在设置值来初始化对象的上下文中调用)相同。 方法需要告诉命令做什么。

以系统中的角色命名的对象使方法具有更好的可读性。 域名Carriage使方法名称transport更容易理解,因为它很自然地带有域内马车的概念。

有了这些,现在到了我们需要考虑如何对对象建模以简化测试和开发的时候了。

对象

当创建地下城管理器时,我们开始创建一个可维护和可发展的软件。 OOP 的核心原则是帮助我们处理对象,但 JavaScript 在面向对象方面是特殊的。

许多 JavaScript 程序员肯定都听说过,JavaScript 使用了原型继承,更重要的是,它并没有真正得到类的概念,只有实例。

提示

即使下一个版本的 JavaScript,ECMAScript 6,引入了keyword类,核心语言设计没有改变。 类实际上只是目前 JavaScript 中原型继承的语法糖。 如果你想了解更多关于 ES6 的信息,请关注 Alex Rauschmayer 的博客http://www.2ality.com/,他详细描述并跟踪了 JavaScript 语言的发展。

当然,这并不会使 JavaScript 成为执行我们试图实现的任务的最差语言,因为这种缺乏并没有以任何方式限制该语言的能力,而是真正使它成为经典面向对象语言的超集。

首先,让我们快速回顾一下面向对象在 JavaScript 中的工作原理,以及我们如何使用该语言的强大功能来帮助我们建模我们迄今为止起草的系统。

JavaScript 对象的基础知识

在像 Ruby 甚至 Java 这样的面向对象语言中,对象是基于类的。 尽管可以创建普通对象,但这不是规范。 以 Ruby 为例,用我们的 carriage 方法创建一个对象,你可以这样写:

class Carriage
  def transport prisoner
    # some work happens
  end
end

carriage = Carriage.new
carriage.transport(a_prisoner)

在 JavaScript 中,对于非常简单的对象,以及非常重要的是,对于测试,我们不需要先有类才能有这样的对象:

var carriage = {
  transport: function(prisoner) {
    // do some work
  }
}

carriage.transport(aPrisoner)

前面的代码将做同样的事情,而不需要首先创建一个类和对象。 这可能非常强大,特别是在建模一个新的 API 时,因为它允许在开发阶段非常轻量级的使用和生成。

除了可以使用{}构造的普通对象外,JavaScript 还允许函数作为对象使用。 使用函数作为对象构造函数意味着与经典面向对象的类具有非常相同的灵活性。 JavaScript 中的函数是一种对象,它封装了函数的内部状态以及在创建函数时从外部世界引用的任何变量的状态。 由于这些属性,JavaScript 中的函数是用于创建对象的基本构建块,通过关键字 new提供的特殊支持是语言的一部分:

function Carriage() {}
Carriage.prototype.transport = function (prisoner) {
  // do some work
}

var carriage = new Carriage()
carriage.transport(aPrisoner)

这看起来很像 Ruby 代码,其行为也非常类似。 构造函数在 JavaScript 中是一种特殊的东西,关于它们的使用或不使用已经写了很多。 在很多情况下,通过通用功能关联一类对象的想法是一种很好的习惯用法,现代 JavaScript 引擎就是这样构建的。 所以不要害怕构造函数,但要注意它们对关键字new的特殊使用,以及它们可能会给新开发人员带来的困惑。

提示

关于 JavaScript 中的new问题已经写了很多。 更多关于 JavaScript 作为一种语言的内部信息,请阅读JavaScript: the Good PartsDouglas CrockfordO'Reilly

继承和为什么你不需要它

当然,仅仅是类的构造和它们的使用只是 OO(面向对象)语言的一部分。 特别是在 Java 中,构建非常复杂的继承层次结构是非常常见的,这种继承层次结构允许跨对象共享公共功能。

继承的基本概念是父类的所有方法在子类上也可用。

超越继承的建模模式

|   | 喜欢“对象组合”而不是“类继承”。 |   | |   | ——Gang of Four(四人帮) |

即使继承在 JavaScript 中是可能的,但当一个应用像四人组中所说的那样时,它不一定是设计的最佳路径。 继承在父类和其子类之间创建了一个非常牢固的纽带; 这本身就意味着在系统的某些部分出现了不应该出现的知识泄露。 继承是两个对象之间最强大的耦合形式,耦合本身应该是一个深思熟虑的选择。 深度继承树很快就会使一个软件非常抗拒更改,因为更改往往会波及整个系统。 这是一个更大的问题,因为 JavaScript 不做接口和关系的编译时检查,与更多的静态语言相比,这些部分更容易不同步,并在系统中造成错误。

由于这些原因,也由于像 JavaScript 这样的动态语言很少需要经典继承,因此几乎从不使用继承。 还有一些其他的模式已经被暗示来对抗继承的需要。

物体构成

当我们不想通过继承来共享功能时,我们该怎么做? 最简单的方法是传递已经实现了我们需要的功能的对象,并直接使用它,例如:

function Notifications(store) {
  if (typeof(store) === 'undefined') {
    this.store = []
  } else {
    this.store = store
  }
}

Notifications.prototype.add = function (notification) {
  store.push(notifictation)
}

通知是一个非常简单的对象,用于管理系统的一部分的通知; 它本身并不太关心如何保存通知以供以后处理,而是简单地将其委托给默认情况下实现为数组的存储对象。

委托给本机类型通常会做很多工作,但这对于程序员创建的所有其他对象来说都是如此。 这样的组合有一个很大的优点,它简化了测试,特别是当依赖项被传递进来时,就像刚才给出的例子一样,我们可以简单地替换测试中的存储对象,确保调用正确。

多态性无遗传

|   | 当我看到一只鸟像鸭子一样走路,像鸭子一样游泳,像鸭子一样嘎嘎叫,我就称那只鸟为鸭子。 |   | |   | ——迈克尔·海姆 |

在 Java 等语言中使用继承的另一个原因是对多态性的需要。 其思想是方法应该在不同的对象中以不同的方式实现。 在经典继承和类型检查相结合的情况下,这意味着调用方法的对象需要有一个共同的祖先或接口,因为类型检查器会抱怨:

interface Orc {
    abstract public String kill(String attacker);
}

class SwordMaster implements Orc {
    public String kill(String name) {
        return "Slash " + name;
    }
}

class AxeMaster implements Orc {
    public String kill(String name) {
       return "Split " + name;
    }
}

现在我们可以将SwordMasterAxeMaster的职业传递给需要的人,以便兽人守卫他们:

class Master {
  Orc[] guards;
  public Master(Orc[] guards) {
    this.guards = guards;
  }

  public void figthOfAttack(String[] attackers) {
    for(int i = 0; i < attackers.length; i++) {
      System.out.println(guards[i].kill(attackers[i]));
    }
  }
}

支持 duck typing 的语言不需要这种开销。 在 JavaScript 中,我们可以在不需要接口的情况下写这个,两个半兽人都可以只是普通的 JavaScript 对象,如下所示:

var axeMaster = {
  kill: function(name) { return "Hack " + name; }
}

var swordMaster = {
  kill: function(name) { return "Slash " + name; }
}

被守卫的Master对象现在可以在每个守卫上调用所需的方法,而不需要匹配类型:

var Master = function (guards) { this.guards = guards }
Master.prototype.fightOfAttackers = function (attackers) {
  var self = this
  attackers.forEach(function (attacker, idx) {
    console.log(self.guards[idx].kill(attacker))
  })
}

鸭子类型意味着对象是由它能做什么来定义的,而不是由它是什么来定义的。 在构建我们自己的非常简单的模拟时,我们已经看到了这种行为。 只要方法是在对象上定义的,我们调用它时它的类型是什么并不重要,因此实际上不需要有一个共同的祖先。

由于 JavaScript 的动态特性和 duck 类型的可用性,对继承的需求大大减少了。

将对象设计应用到领域

通过对概念对象设计的理解,我们需要将所有的概念应用到我们的领域。 我们继续模拟我们开始的囚犯转移。 到目前为止,我们有一个应用模块的入口点,它最终将处理这个问题。 从测试中我们知道囚犯转移需要一个囚犯和一个地牢物体。

基于简单对象构建系统

所以让我们来看看囚犯转移需要做什么,它的合作者是什么。 在此之前,我们明确了囚犯转移将需要一个囚犯,以及一个目标地牢,并且囚犯转移将管理所有其他内容。 从用户的角度考虑限制 API 界面的最小输入是什么是很重要的。

当然,囚犯转移作为 DDD 的一种服务,需要更多的合作者才能真正实现其目的。 首先是在当地地下城中获取资源,例如兽人作为守护者,移动囚犯的马车等等。 管理转移的目标也是通知其他地下城,所以我们也需要通知他们的方法。

正如我们在前几章中所发现的,通知的概念还没有被很好地理解,所以现在我们可以假设有一个服务允许我们出于特定的原因向目标发送消息。 我们可以针对消息传递服务的抽象进行编程,从而进一步指定我们需要从系统中得到什么。 把所有这些放在一起,并把它冲洗出来,我们可以得出以下结论:

prisonerTransfer = function (prisoner,
                             otherDungeon,
                             ourDungeon,
                             notifier,
                             callback) {
  var keeper = ourDungeon.getOrc()
  var carriage = ourDungeon.getCarriage()
  var transfer = prepareTransfer(carriage, keeper, prisoner)
  if (transfer) {
    notifier.message(dungeon, transfer)
    callback()
  } else {
    callback(new Error("Transfer initiation failed."))
  }
}
function prepareTransfer(carriage, keeper, prisoner) {
  return {}
}

在测试期间,所有调用都只是对对象的简单调用,可以有一个简单的 JavaScript 对象的代替:

it("notifies other dungeons of the transfer", function (done) {
  prisonerTransfer("prisoner",
                   getOtherDungeon(),
                   getLocalDungeon(),
                   getNotifier(),
                   function (err) {
      assert.ifError(err)
      assert.equal(dungeon.inbox.length, 1)
      done()
    })
})

返回带有所需功能的普通对象,我们最终将基于现在正在模拟的设计制作它们自己的模块,这是创建合作者角色的全部内容:

function getOtherDungeon() {
  return { inbox: [] }
}

function getLocalDungeon() {
  return {
    getOrc: function () { return {} },
      getCarriage: function () { return {} }
     }
   }

function getNotifier() {
  return {
    message: function (target, reason) { target.inbox.push({}) }
  }
}

这种顶层设计确实让我们得以创建底层功能。 我们已经可以非常清楚地看到我们需要从通知系统中得到什么,而清除转移本身来履行其职责也将告诉我们更多关于其他合作者的信息。

小结

在阅读了本章之后,您对如何在系统中模拟囚犯转移有了坚实的基础。 我们使用了一种非常简单的设计,尽可能减少工具开销。 我们的系统利用 JavaScript 的动态特性为我们还没有创建的对象创建简单的存根,并且我们能够验证我们在之前的研究中讨论的第一个理解。

在下一章中,我们将进一步探讨系统中的其他角色。 我们将重点放在以领域驱动的设计术语对它们进行分类,这样我们就可以重用该领域中其他人探索的模式。 我们还将更多地关注语言,以促进进一步的交流,以及它如何与这些模式一起工作,以在该领域实现非常清晰的交流。