JavaScript-领域驱动设计-一-

74 阅读55分钟

JavaScript 领域驱动设计(一)

原文:zh.annas-archive.org/md5/CC079113B860BF21A8B35D4B14B4E853

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

欢迎阅读《JavaScript 领域驱动设计》。多年来,JavaScript 一直局限于使网站更具交互性,但没有人会想到在 JavaScript 中实现整个应用程序。在过去几年里,这种情况发生了巨大变化,JavaScript 已经发展成为一种无所不在的强大语言,几乎存在于每个开发领域。

这种惊人的增长在开发过程中引入了许多问题,在 JavaScript 世界中以前是未知的。项目变得非常庞大,许多开发人员同时在这些大型代码库上工作,最终,JavaScript 往往是整体应用程序的重要组成部分。好消息是,大多数这些问题以前都已经解决了,作为 JavaScript 开发人员,我们可以借鉴多年来在其他领域获得的丰富经验,并使它们适应 JavaScript 的工作方式,同时利用 JavaScript 独特的灵活性。

本书涵盖的内容

第一章,“典型的 JavaScript 项目”,介绍了一个典型的业务应用程序以及它的开发过程。它展示了领域驱动设计如何帮助避免开发过程中的常见问题,从而创建一个更符合问题需求的应用程序。

第二章,“找到核心问题”,展示了我们如何有效地探索应用程序的问题领域,并确定最重要的工作方面。

第三章,“为领域驱动设计设置项目”,着重于为项目建立一个准备成长的结构。它不仅展示了我们如何布置文件和文件夹,还创建了正确的测试和构建环境。

第四章,“建模行为者”,展示了如何使用面向对象技术和领域驱动设计使项目得以发展,真正隔离领域。我们还解决了计算机科学中最难的问题之一,即命名。

第五章,“分类和实现”,讲述了领域驱动设计中我们使用的语言,以使项目更易理解和可读。我们研究了域和子域之间的关系,然后进一步深入到领域对象的核心。

第六章,“上下文地图-整体图景”,不仅涉及从技术角度发展应用程序,还涉及从组织角度发展。我们讨论了组织构成应用程序整体的不同部分,无论是作为独立部分还是相互关联的部分。

第七章,“并非全部领域驱动设计”,讨论了如何将领域驱动设计融入开发技术空间,讨论了哪些问题适合哪里。我们还谈到了诸如面向对象、领域特定语言等影响因素。

第八章,“一切开始串联”,讲述了我们的项目如何融入 JavaScript 项目空间,并回顾了开头部分。我们还探讨了框架和开发风格的替代选择。

阅读本书所需的内容

本书始终使用 JavaScript 作为首选语言。为了提供一致的运行时环境,本书始终使用 JavaScript Node.js 作为运行时。还会使用 Node.js 生态系统中的其他工具,主要是 npm 作为包管理器。要使用本书中的代码,您需要一个 Node.js 版本,可从 Node.js 网站nodejs.org/上获得,它已经打包了 npm。对于编辑代码,我建议使用您喜欢的文本编辑器或 IDE。如果您没有,也许可以尝试 Sublime Text 或 Vim,它们也适用于 Windows、Macintosh OS 和 Linux。

本书适合的读者

本书假定读者对 JavaScript 语言有一定的了解。它面向 JavaScript 开发人员,他们面临着应用程序不断增长的问题,以及由此带来的问题。本书提供了一种实用的领域驱动设计方法,并侧重于日常开发中最有用的部分。

约定

在本书中,您将找到一些文本样式,用以区分不同类型的信息。以下是这些样式的一些示例及其含义的解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 用户名显示如下:“在撰写本文时,当前活跃版本为node.js 0.10.33。”

代码块设置如下:

var Dungeon = function(cells) {
  this.cells = cells
  this.bookedCells = 0
}

当我们希望引起您对代码块的特定部分的注意时,相关行或项会以粗体显示:

var dungeons = {}
**Dungeon.find = function(id, callback) {**
  if(!dungeons[id]) {
    dungeons[id] = new Dungeon(100)
  }

任何命令行输入或输出都以以下方式编写:

**$ npm install –g express**

新术语重要单词以粗体显示。您在屏幕上看到的单词,例如菜单或对话框中的单词,会以这样的方式出现在文本中:“单击下一步按钮会将您移至下一个屏幕。”

注意

重要链接或重要说明会以这样的框出现。

提示

提示和技巧会以这种方式出现。

第一章:典型的 JavaScript 项目

欢迎来到 JavaScript 中的领域驱动设计。在本书中,我们将探索一种开发具有高级业务逻辑的软件的实用方法。有许多策略可以保持开发流畅和代码和思想有组织,有建立在约定上的框架,有不同的软件范式,如面向对象和函数式编程,或者测试驱动开发等方法。所有这些部分都解决了问题,并且就像工具箱中的工具一样,帮助管理软件中不断增长的复杂性,但这也意味着今天在开始新项目时,甚至在我们开始之前就有很多决定要做。我们想要开发单页应用程序吗,我们想要紧密遵循框架的标准吗,还是我们想要自己设置?这些决定很重要,但它们也在很大程度上取决于应用程序的上下文,在大多数情况下,对这些问题的最佳答案是:“这取决于情况”。

那么,我们真的该如何开始呢?我们真的知道我们的问题是什么吗?如果我们理解了,这种理解是否与其他人的理解相匹配?开发人员很少是某个特定主题的领域专家。因此,当涉及指定系统应具有的行为时,开发过程需要来自业务领域专家的外部输入。当然,这不仅适用于从头开始开发的全新项目,也可以应用于在开发过程中添加到应用程序或产品中的任何新功能。因此,即使您的项目已经进展顺利,也会有一个时机,新功能似乎会拖慢整个项目,此时,您可能想考虑以替代方式来处理这个新功能。

领域驱动设计为我们提供了另一个有用的工具,特别是为了解决与其他开发人员、业务专家和产品所有者互动的需求。在现代,JavaScript 成为构建项目的更加有说服力的选择,特别是在基于浏览器的 Web 应用程序等许多情况下,它实际上是唯一可行的选择。如今,使用 JavaScript 设计软件的需求比以往任何时候都更加迫切。过去,更复杂的软件设计问题集中在后端或客户端应用程序开发上,随着 JavaScript 作为一种开发完整系统的语言的崛起,情况已经发生了变化。在浏览器中开发 JavaScript 客户端是开发整个应用程序的复杂部分,随着 Node.js 的崛起,开发服务器端 JavaScript 应用程序也是如此。在现代开发中,JavaScript 发挥着重要作用,因此需要像过去其他语言和框架一样在开发实践和流程中得到同等重视。基于浏览器的客户端应用程序通常包含与后端相同甚至更多的逻辑。随着这种变化,许多新问题和解决方案已经出现,首先是朝着更好地封装和模块化 JavaScript 项目的方向发展。新的框架已经出现并确立了自己作为许多项目的基础。最后但同样重要的是,JavaScript 已经从浏览器中的语言跃升到更多地移动到服务器端,通过 Node.js 或作为某些 NoSQL 数据库中的首选查询语言。让我带你走一遍开发软件的过程,带你通过使用领域驱动设计引入的概念以及它们如何被解释和应用来创建一个应用程序的各个阶段。

在本章中,您将学习:

  • 领域驱动设计的核心理念

  • 我们的业务场景——管理兽人地牢

  • 跟踪业务逻辑

  • 理解核心问题并选择正确的解决方案

  • 学习什么是领域驱动设计

领域驱动设计的核心思想

有许多软件开发方法论,各有优缺点,但都有一个核心思想,就是要应用和理解以正确地使用该方法论。对于领域驱动设计,核心在于意识到,由于我们不是软件所处领域的专家,我们需要从其他专家那里收集意见。这意识意味着我们需要优化我们的开发过程来收集和整合这些意见。

那么,这对 JavaScript 意味着什么?当考虑在浏览器应用程序中向消费者公开某种功能时,我们需要考虑许多事情,例如:

  • 用户期望应用程序在浏览器中的行为是什么?

  • 业务工作流程是如何工作的?

  • 用户对工作流程了解多少?

这三个问题已经涉及到三种不同类型的专家:擅长用户体验的人可以帮助解决第一个问题,业务领域专家可以解决第二个问题,第三个人可以研究目标受众并提供最后一个问题的意见。将所有这些整合在一起是我们试图实现的目标。

虽然不同类型的人很重要,但核心思想是让他们参与的过程总是相同的。我们提供了一种共同的方式来谈论这个过程,并为他们建立了一个快速的反馈循环进行审查。在 JavaScript 中,这可能比大多数其他语言更容易,因为它是在浏览器中运行的,可以随时进行修改和原型设计;这是 Java 企业应用程序所梦寐以求的优势。我们可以与用户体验设计师密切合作,调整预期的界面,同时动态地改变工作流程以适应我们的业务需求,首先在浏览器的前端,然后将知识从原型移至后端,如果有必要的话。

管理兽人地牢

谈到领域驱动设计时,通常是在处理复杂的业务逻辑的情境下。事实上,大多数软件开发实践在处理非常小的、简化的问题时并不真正有用。就像使用任何工具一样,你需要清楚什么时候是使用它的正确时机。那么,什么才真正属于复杂的业务逻辑领域?这意味着软件必须描述一个现实世界的场景,通常涉及人类的思考和互动。

编写处理决策的软件,90%的时间按某种方式进行,10%的时间按其他方式进行,这在向不熟悉软件的人解释时尤其困难。这些决策是许多业务问题的核心,但尽管这是一个有趣的问题要解决,但跟踪下一个会计软件的开发并不是一个有趣的阅读。考虑到这一点,我想向你介绍我们正在尝试解决的问题,即管理地牢。

管理兽人地牢

一位兽人

地牢内部

从外部看,管理兽人地牢似乎很简单,但实际上管理起来并不容易。因此,我们受到一位兽人大师的联系,他苦于保持地牢的顺利运行。当我们到达地牢时,他向我们解释了实际运作方式和涉及的因素。

提示

即使是全新的项目通常也有一些现状是有效的。这一点很重要,因为这意味着我们不必提出功能集,而是匹配当前现实的功能集。

许多外部因素起着作用,地牢并不像它希望的那样独立。毕竟,它是兽人王国的一部分,国王要求他的地牢给他赚钱。然而,金钱只是交易的一部分。它实际上如何赚钱呢?囚犯需要采矿金子,为此需要在地牢中保留一定数量的囚犯。兽人王国的运行方式也导致不断有新囚犯到来,来自战争的新俘虏,那些无法支付税款的人等等。总是需要为新囚犯腾出空间。好消息是每个地牢都是相互连接的,为了实现其目标,它可以依靠其他地牢,通过请求囚犯转移来填满空牢房或摆脱自己牢房中的囚犯。这些选择使地牢主能够密切关注囚犯的保留和牢房空间的数量。根据需要将囚犯送往其他地牢,并向其他地牢请求新的囚犯,以防有太多的空牢房空间,可以使采矿劳工保持在最佳水平,以最大化利润,同时准备好接收直接被送到地牢的高价值囚犯。到目前为止,解释是合理的,但让我们深入一点,看看发生了什么。

管理进来的囚犯

囚犯可能因为一些原因到达,比如如果一个地牢已经满了,决定将一些囚犯转移到有空牢房的地牢,除非他们在途中逃跑,他们最终会在我们的地牢里到达。另一个囚犯来源是不断扩张的兽人王国本身。兽人将不断奴役新的人民,对我们的国王说“抱歉,我们没有空间”并不是一个有效的选择,这实际上可能导致我们成为新的囚犯之一。看到这一点,我们的地牢最终会填满,但我们需要确保这不会发生。

处理这个问题的方法是提前转移囚犯以腾出空间。这显然是最复杂的事情;我们需要权衡几个因素来决定何时以及转移多少囚犯。我们不能简单地通过阈值来解决这个问题的原因是,从地牢结构来看,这不是我们可以失去囚犯的唯一方式。毕竟,人们并不总是愿意成为采金矿的奴隶,他们可能会决定在逃跑时死亡的风险和在监狱中死亡的风险一样高,因此他们决定逃跑。

囚犯在不同地牢之间移动时也是如此,而且并不罕见。因此,即使我们在物理牢房上有一个硬性限制,我们仍需要处理进出囚犯的软性数量。这是商业软件中的一个经典问题。将这些数字相互匹配并优化特定结果基本上就是计算机数据分析的全部内容。

现状

考虑到所有这些,很明显兽人大师目前通过一张糟糕的餐巾纸上的笔记来跟踪的系统并不完美。事实上,这几乎已经让他多次险些丧命。举个例子,他讲述了一次国王抓住了四个氏族领袖并想让他们成为矿工,只是为了羞辱他们。然而,当到达地牢时,他意识到没有空间,不得不前往下一个地牢把他们放下,而他们则嘲笑他,因为显然他不知道如何管理王国。这是因为我们的兽人大师忘记了前一天到达的八名转移者。还有一次,兽人大师在国王的治安官到来时无法交付任何黄金,因为他不知道他只有三分之一所需囚犯才能挖掘任何东西。这次是因为有多人统计囚犯,而不是逐个单元格记录,他们实际上试图在脑海中做。虽然是兽人,但这是失败的设置。所有这些都归结为糟糕的组织,而将管理地牢囚犯的系统画在餐巾纸的背面当然也符合这样的标准。

数字地牢管理

在最近的失败案例的指导下,兽人大师终于意识到是时候进入现代化了,他希望通过数字化一切来革新管理地牢的方式。他努力要有一个系统,可以通过自动计算当前填充的单元格数量来简化管理繁琐的工作。他希望只需坐下来,放松,让计算机为他做所有的工作。

提示

与业务专家讨论软件时的一个常见模式是,他们不知道可以做什么。永远记住,我们作为开发者是软件专家,因此是唯一能够管理这些期望的人。

现在是时候考虑我们需要了解的细节以及如何处理不同的情况了。兽人大师并不真正熟悉软件开发的概念,所以我们需要确保用他能理解的语言交谈,同时确保我们得到所有需要的答案。我们被聘用是因为我们在软件开发方面的专业知识,所以我们需要确保管理期望以及功能集和开发流程。开发本身当然会是一个迭代的过程,因为我们不能指望一次性就得到所有需要的清单。这也意味着我们需要考虑可能的变化。这是构建复杂商业软件的重要部分。

开发包含更复杂业务逻辑的软件很容易迅速变化,因为业务正在调整自己,用户正在利用软件提供的功能。因此,保持业务理解者和软件开发者之间的共同语言是至关重要的。

提示

尽可能地融入业务术语,这将有助于业务领域专家和开发者之间的沟通,从而在早期防止误解。

规格

要了解软件需要做什么,至少要有用的最好方式是了解在你的软件存在之前未来用户在做什么。因此,我们与兽人大师坐下来,看他是如何管理进出囚犯的,并让他向我们介绍他日常工作。

地牢由 100 个单元格组成,目前每个单元格要么被囚犯占据,要么为空。在管理这些单元格时,我们可以通过观察兽人的工作来确定不同的任务。根据我们所见,我们可以大致将其勾画如下:

规格

有一些重要的组织事件和状态需要跟踪,它们是:

  1. 当前可用或空闲单元

  2. 外传输状态

  3. 传入传输状态

每次传输都可能处于多种状态,主控必须了解这些状态,以便进一步决定下一步该做什么。保持这样的世界观并不容易,尤其要考虑到同时发生的并发更新的数量。跟踪一切的状态会导致我们的主控有更多的任务要做:

  1. 更新跟踪

  2. 当太多的单元被占用时开始进行外传输

  3. 通过开始跟踪来响应传入传输

  4. 如果占用单元太少,要求传入传输

那么,每个任务都包括什么呢?

跟踪可用单元

地牢的当前状态由其单元的状态反映,因此第一个任务是获得这种知识。在其基本形式中,这很容易实现,只需计算每个占用和每个空闲单元,写下这些值。现在,我们的兽人主控在早上巡视地牢,记录每个空闲单元,假设另一个单元必定被占用。为了确保他不陷入麻烦,他不再相信他的下属能够做到!问题在于只有一个中央表来跟踪一切,所以如果有多人计算和记录单元,他的看守人可能会意外地覆盖彼此的信息。此外,这是一个很好的开始,目前已经足够了,尽管它缺少一些有趣的信息,例如逃离地牢的囚犯数量以及根据这一速率预期的空闲单元数量。对我们来说,这意味着我们需要能够在应用程序内跟踪这些信息,因为最终我们希望根据地牢的状态来预测预期的空闲单元数量,以便我们可以有效地根据地牢的状态创建建议或警告。

开始外传输

第二部分是实际处理在地牢填满囚犯的情况下如何处理。在这种具体情况下,这意味着如果空闲单元的数量低于 10,是时候移出囚犯了,因为随时可能会有新的囚犯到来。这种策略非常可靠,因为根据经验,几乎没有更大的运输,所以建议一开始就坚持这种策略。然而,我们已经看到一些目前过于复杂的优化。

提示

从业务经验中汲取经验是重要的,因为可以对这种知识进行编码并减少错误,但要注意,因为编码详细的经验可能是最复杂的事情之一。

在未来,我们希望根据逃离地牢的囚犯数量、因被捕获而到达的新囚犯以及来自传输的新到达的预期来优化这一点。目前这是不可能的,因为它只会压垮当前的跟踪系统,但实际上这归结为尽可能多地捕获数据并进行分析,这是现代计算机系统擅长的事情。毕竟,这可能会挽救兽人主控的脑袋!

跟踪传入传输的状态

有些日子,一只乌鸦会带来消息,说有些囚犯已经被送去转移到我们的地牢。我们实际上无能为力,但协议是在囚犯实际到达之前的五天发送乌鸦,给地牢一个准备的机会。如果囚犯在途中逃跑,将会发送另一只乌鸦通知地牢这尴尬的情况。这些消息每天都要筛选一遍,以确保实际上有足够的空间来容纳到达的囚犯。这是预测填充单元数量的一个重要部分,也是最不稳定的部分,我们被告知。重要的是要注意,每条消息只能处理一次,但它可以在任何时候到达。目前,它们都由一个兽人处理,他在记录内容结果后立即将它们扔掉。当前系统的一个问题是,由于其他地牢的管理方式与我们目前的方式相同,当它们陷入麻烦时,它们会迅速进行大规模的转移,这使得情况变得相当不可预测。

启动传入转移

除了让囚犯呆在他们应该呆的地方,挖掘黄金是地牢的第二个主要目标。为了做到这一点,需要有一定数量的囚犯来操作机器,否则生产基本上会停止。这意味着每当太多的单元被放弃时,就是填充它们的时候,因此兽人头目会派一只乌鸦请求新的囚犯。这再次需要五天时间,除非他们在途中逃跑,否则是可靠的。过去,由于长时间的延迟,这仍然是地牢的一个主要问题。如果填充的单元数量低于 50,地牢将不再生产任何黄金,而不赚钱是替换当前地牢主的原因。如果兽人头目所做的只是对情况做出反应,这意味着可能会有大约五天时间没有黄金被挖掘。这是当前系统的一个主要痛点,因为预测五天后填充单元数量似乎是不可能的,所以目前所有兽人能做的就是做出反应。

总的来说,这给了我们一个大致的想法,地牢主在寻找什么,以及需要完成哪些任务来替换当前系统。当然,这不必一次完成,而可以逐渐进行,以便每个人都能适应。目前,是时候确定从哪里开始了。

从零开始到应用程序

我们是 JavaScript 开发者,所以对我们来说构建一个 Web 应用程序来实现这一点似乎是显而易见的。根据问题的描述,很明显,从简单开始,随着我们进一步分析情况,逐渐扩展应用程序显然是正确的方式。目前,我们并不真正了解一些部分应该如何处理,因为业务流程尚未发展到这个水平。此外,随着我们的软件开始被使用,可能会出现新的功能或处理方式开始有所不同。所描述的步骤留有根据收集到的数据进行优化的空间,因此我们首先需要数据来看预测如何工作。这意味着我们需要从追踪尽可能多的事件开始。按照清单,第一步始终是了解我们所处的状态,这意味着追踪可用单元并为此提供一个接口。起初,可以通过计数器来完成,但这不能是我们的最终解决方案。因此,我们需要朝着追踪事件并对其进行汇总以便能够对未来进行预测。

第一条路线和模型

当然,有许多其他开始的方式,但在大多数情况下,最重要的是现在是选择构建的基础的时候了。我的意思是决定构建在哪个框架或一组库上。这与决定使用哪个数据库来支持我们的应用程序以及许多其他小决定同时进行,这些小决定受到框架和库的影响。对前端应该如何构建有清晰的理解也很重要,因为构建单页应用程序,在前端实现大量逻辑,并由与在服务器端实现大部分逻辑有很大不同的 API 层支持的应用程序,与构建大量逻辑的应用程序有很大不同。

提示

如果您对 express 或以下使用的任何其他技术不熟悉,不要担心。您不需要理解每一个细节,但您会了解如何使用框架开发应用程序的想法。

由于我们还没有明确了解应用程序最终会采取的方式,我们试图尽可能推迟尽可能多的决定,但决定我们立即需要的东西。由于我们在 JavaScript 中开发,应用程序将在 Node.js 中开发,express 将是我们选择的框架。为了使我们的生活更轻松,我们首先决定我们将使用纯 HTML 来实现前端,使用 EJS 嵌入式 JavaScript 模板,因为这将使逻辑集中在一个地方。这似乎是合理的,因为将复杂应用程序的逻辑分散在多个层中将进一步复杂化事情。此外,在传输过程中摆脱最终的错误将使我们更容易朝着一个坚实的应用程序迈进。我们可以推迟关于数据库的决定,并使用存储在 RAM 中的简单对象来处理我们的第一个原型;当然,这不是长期的解决方案,但至少我们可以在需要决定另一个重要软件之前验证一些结构,这也带来了很多期望。考虑到所有这些,我们设置了应用程序。

在接下来的章节和整本书中,我们将使用 Node.js 构建一个小型后端。在撰写本文时,当前活跃的版本是 Node.js 0.10.33。Node.js 可以从nodejs.org/获取,并且适用于 Windows、Mac OS X 和 Linux。我们的 Web 应用程序的基础由 express 提供,目前版本为 3.0.3,可通过Node Package Manager (NPM)获取:

**$ npm install –g express**
**$ express --ejs inmatr**

提示

为了简洁起见,以下的粘合代码被省略了,但像书中呈现的所有其他代码一样,该代码可以在 GitHub 存储库github.com/sideshowcoder/ddd-js-sample-code上找到。

创建模型

现在应用程序的最基本部分已经设置好了。我们可以继续创建我们的地牢模型在models/dungeon.js中,并添加以下代码以保持模型及其加载和保存逻辑:

var Dungeon = function(cells) {
  this.cells = cells
  this.bookedCells = 0
}

提示

下载示例代码

您可以从您在www.packtpub.com账户购买的所有 Packt 图书中下载示例代码文件。如果您在其他地方购买了这本书,您可以访问www.packtpub.com/support并注册,以便将文件直接通过电子邮件发送给您。

请记住,这将最终存储在数据库中,我们还需要以某种方式找到一个地牢,因此find方法似乎是合理的。这个方法应该已经遵循了 Node.js 的回调风格,以便在切换到真正的数据库时更容易。尽管我们推迟了这个决定,但假设是明确的,因为即使我们决定不使用数据库,地牢引用也将在将来存储并从进程外部请求。以下是一个使用find方法的示例:

var dungeons = {}
**Dungeon.find = function(id, callback) {**
  if(!dungeons[id]) {
    dungeons[id] = new Dungeon(100)
  }
  callback(null, dungeons[id])
}

第一个路由和加载地牢

现在我们已经做好了这些,我们可以继续实际响应请求。在 express 中定义所需的路由来做到这一点。由于我们需要确保我们当前的地牢可用,当请求到达时,我们还使用中间件来加载它。

使用我们刚刚创建的方法,我们可以向 express 堆栈添加一个中间件,以便在请求到达时加载地牢。

中间件是一段代码,每当请求到达其堆栈级别时就会执行,例如,用于将请求分派到定义的函数的路由器被实现为中间件,日志记录也是如此。这也是许多其他类型的交互的常见模式,比如用户登录。我们的地牢加载中间件看起来像这样,假设现在我们只管理一个地牢,我们可以通过在middleware/load_context.js中添加以下代码来创建它:

function(req, res, next) {
  req.context = req.context || {}
  Dungeon.find('main', function(err, dungeon) {
    req.context.dungeon = dungeon
    next()
  })
}

显示页面

有了这个,我们现在能够简单地显示有关地牢的信息,并在请求内跟踪对其所做的任何更改。创建一个视图来呈现状态,以及一个用于修改状态的表单,是我们 GUI 的基本部分。由于我们决定在服务器端实现逻辑,它们相当简单。在views/index.ejs下创建一个视图可以让我们稍后通过 express 将所有内容呈现到浏览器。以下示例是前端的 HTML 代码:

<h1>Inmatr</h1>
<p>You currently have <%= dungeon.free %> of
<%= dungeon.cells %> cells available.</p>

<form action="/cells/book" method="post">
  <select name="cells">
    <% for(var i = 1; i < 11; i++) { %>
    <option value="<%= i %>"><%= i %></option>
  <% } %>
  </select>
  <button type="submit" name="book" value="book">
  Book cells</button>
  <button type="submit" name="free" value="free">
  Free cells</button>
</form>

通过 express 将应用程序粘合在一起

现在我们几乎完成了,我们有一个显示状态的页面,一个用于跟踪变化的模型,以及一个根据需要加载此模型的中间件。现在,为了将所有这些粘合在一起,我们将使用 express 注册我们的路由并调用必要的函数。我们主要需要两个路由:一个用于显示页面,一个用于接受和处理表单输入。当用户访问首页时,显示页面已经完成,所以我们需要绑定到根路径。接受表单输入已经在表单本身中声明为/cells/book。我们只需为其创建一个路由。在 express 中,我们根据主应用程序对象定义路由,并根据 HTTP 动词定义如下:

app.get('/', routes.index)
app.post('/cells/book', routes.cells.book)

将此添加到主app.js文件中允许 express 连接各种东西,路由本身实现如下在 routes/index.js文件中:

var routes = {
  index: function(req, res){
    res.render('index', req.context)
  },

cells: {
  book: function(req, res){
    var dungeon = req.context.dungeon
    var cells = parseInt(req.body.cells)
    if (req.body.book) {
    dungeon.book(cells)
  } else {
    dungeon.unbook(cells)
  }

      res.redirect('/')
    }
  }
}

完成了这些,我们有一个可以跟踪空闲和已使用单元的工作应用程序。

以下显示了跟踪系统的前端输出:

通过 express 将应用程序粘合在一起

推动应用程序向前发展

这只是朝着希望自动化目前手工完成的应用程序的第一步。有了第一步,现在是时候确保我们可以推动应用程序了。我们必须考虑这个应用程序应该做什么,并确定下一步。在向业务展示当前状态后,下一个请求很可能是要集成某种登录,因为如果没有授权,将无法修改地牢的状态。由于这是一个 Web 应用程序,大多数人都熟悉它们有登录功能。这将使我们进入一个复杂的空间,我们需要开始指定应用程序中的角色以及它们的访问模式;因此目前还不清楚这是否是正确的方法。

另一种方法是开始将应用程序转向跟踪事件,而不是纯粹的空闲单元格数量。从开发者的角度来看,这可能是最有趣的路线,但立即的业务价值可能很难证明,因为没有登录似乎是不可用的。我们需要创建一个记录事件的端点,比如逃跑的囚犯,然后根据这些跟踪的事件修改地牢的状态。这是基于这样一个假设,即应用程序的最高价值将在于对囚犯移动的预测。当我们想以这种方式跟踪空闲单元格时,我们将需要修改我们的应用程序的第一个版本的工作方式。关于需要创建哪些事件的逻辑将不得不移动到某个地方,最合理的是前端,地牢将不再是地牢状态的唯一真相来源。相反,它将成为状态的聚合器,通过事件的生成进行修改。

以这种方式思考应用程序使一些事情变得清晰。我们并不完全确定应用程序的最终价值主张是什么。这将使我们走上一条危险的道路,因为我们现在做出的设计决策将影响我们如何在应用程序内构建新功能。如果我们关于主要价值主张的假设最终是错误的,这也是一个问题。在这种情况下,我们可能已经构建了一个相当复杂的事件跟踪系统,它并没有真正解决问题,而是使事情变得复杂。每个状态修改都需要转换为一系列事件,而对对象的简单状态更新可能已经足够了。这种设计不仅不能解决真正的问题,而且向兽人大师解释起来也很困难。某些抽象缺失,沟通也没有遵循作为业务语言建立的模式。我们需要一种替代方法来让业务更加参与。此外,我们需要保持开发简单,使用业务逻辑上的抽象,而不是技术上的抽象,这些技术由所使用的框架提供。

再次审视问题

到目前为止,我们一直从网页开发者的角度看待应用程序。这是一个经典的例子,当你手中只有一把锤子,一切看起来都像钉子。我们真的已经解决了核心问题吗?我们还没有问过哪些问题?这些是我们需要问自己的重要问题。此外,我们需要弄清楚我们可以向业务专家提出什么问题,以更好地了解如何前进。我们之前做了什么假设,以及为什么?

提示

使用合适的工具来解决问题也延伸到我们所做的抽象。当你已经知道解决方案是一个网页应用程序时,解决问题并不总是有帮助的。

另一个思考 MVC 网页应用程序的角度

到目前为止,我们一直在以模型-视图-控制器MVC)的方式思考问题,这是一个网页应用程序。这带来了一定的假设,可能在我们的业务领域并不成立。创建一个用于管理输入和输出的网页界面确实通常处理应用程序的呈现,但这并不意味着这部分也包含主要的逻辑。在我们的地牢管理器的情况下,这可能只是访问和输入数据的一种方式。以这种方式构建的信息系统具有包含逻辑和数据的模型。这些模型由数据库支持,负责持久性,并且还用于通过对数据的约束来实现一些逻辑。这意味着我们的领域被压缩成了,很可能是关系型的,数据库模型。

所有这些将我们锁定在一定的技术集合中:用于托管我们的应用程序的 Web 服务器,用于持久性的数据库,以及用于访问和输入的 Web 层。所有这些元素都成为我们应用程序的组成部分,并使变更变得困难。此外,模型层除了由一堆模型组成之外,并没有真正的抽象。当我们想要表示更复杂的交互时,这可能不够。要明确的是,只要开发的应用程序主要由系统之间的交互组成,这并没有真正的问题,但是当价值主张主要是要在系统的各个部分之间表示业务逻辑时,这种设计就不再足够了。

理解核心问题

在业务应用的情况下,许多问题及其解决方案通常并不明确。这对许多领域都是如此,大多数开发人员可能熟悉的一个例子是设置 Web 服务器。当询问开发人员或管理员要实现这一目标时,描述的步骤通常只有几步,例如:设置操作系统,安装Apache,配置站点,然后启动。对于另一个开发人员或系统管理员来说,这可能足够了解要做什么,但对于外部人员,甚至更糟糕的是对于计算机来说,这几乎无法复制。

明确所有步骤对于了解核心业务领域的真正构成至关重要。在我们的情况下,我们需要确保跟随兽人大师目前所做的事情来保持他的地牢运行。这可以通过跟随他周围,或者让他向我们展示他的正常业务流程来完成。然而,我们不能依赖业务专家向我们详细解释流程。此外,我们也不能依赖我们对其理解与实际需要做的事情是否匹配。

因此,这项练习的主要目标是建立对正在发生的事情的理解基线,并提供一个共同的语言来讨论将不可避免地出现的问题。我们开始处于不确定的情况中。这不应该吓倒我们,而是我们需要将其视为增加自己理解的机会,有时甚至是增加当前执行人员的理解。通常,当质疑达到目标的所有步骤时,业务专家会意识到他们领域的新细节,他们甚至可能会识别可能的问题。

提示

找出对业务流程理解存在差距的地方是正确实施的关键。

在实施业务流程的情况下,我们可以假设现状是我们需要复制以替换业务目前正在使用的工具的最低要求。因此,首先,我们需要重建或整合业务目前正在使用的所有工具。当我们对问题有了牢固的把握后,我们可以找到优化有意义且可能的地方。我们还应该逐步替换一个接一个的流程,而不是一次性进行大规模切换,因为这样可以最大程度地减少业务风险。

沟通是关键

计算机科学中只有两件难事:缓存失效和命名事物。
--菲尔·卡尔顿

在应用程序中工作时,往往很难在开发人员、产品所有者以及业务人员之间创建共享语言。通常有人说,命名事物是计算机科学中最困难的问题之一,有一个描述性的名称确实会让很多事情变得更容易。通常情况下,一个明确定义的对象更容易扩展,因为它的范围已经由它的名称定义。因此,在面向对象设计中,通常不鼓励使用一般性词语来命名事物,比如ManagerCreatorProcessor。在考虑这个问题时,我们发现在我们的业务领域中,我们可以并且应该尽可能多地重用已建立的业务语言。这一切都归结为沟通。我们作为开发人员是这个领域的新手,所以介绍我们的业务专家将已经有一个建立的语言来描述我们所缺少的领域中的问题。

当我们跟随业务专家的步伐时,我们应该花时间熟悉正在使用的特定语言。当我们开始编写代码时,这变得更加重要。我们将不断需要与领域专家核对,以考虑他们的理解,因此当我们使用业务语言来编码领域时,我们将更容易与周围的每个人交流,以更好地理解领域。这相当抽象,所以让我举个例子。考虑一下地牢的命名:

function Dungeon(cells) {
  this.freeCells = cells
}

现在考虑我们想要记录囚犯数量的变化,并编写以下代码:

var dungeon = new Dungeon(100)
dungeon.freeCells -= 5
dungeon.freeCells += 3

尽管这对开发人员来说是自然的,但它并没有使用任何特定于业务的语言。我们需要向非开发人员解释像+=这样的东西的含义,以使他们理解含义。另一方面,考虑使用以下方法对相同的逻辑进行编码:

Dungeon.prototype.inPrison = function (number) {
  this.freeCells -= number
}

Dungeon.prototype.free = function (number) {
  this.freeCells += number
}

使用这些方法来表达相同的事物,看起来比以前更加领域特定。现在我们可以在领域的上下文中描述问题,代码如下:

var dungeon = new Dungeon(100)
dungeon.inPrison(5)
dungeon.free(3)

现在即使对非开发人员来说,发生了什么也变得非常清晰,因此我们可以专注于讨论行为是否正确,而不是代码的细节。

领域驱动设计的概念。

在开发软件时,很容易陷入实施细节,而从未深入问题的本质。作为软件开发人员,我们的主要目标始终是为业务增加价值,为了实现这一目标,我们首先需要明确我们试图解决的问题是什么。这在计算机科学的历史上已经有过多种尝试。结构化编程为开发人员提供了一种将问题分解为片段的方法,面向对象则将这些片段附加到命名的事物上,从而进一步构建结构并更好地将含义与程序的各个部分关联起来。

领域驱动设计专注于在解决问题的过程中建立结构,并提供了正确的起点,以便开始每个利益相关者都可以参与的对话。语言在其中是一个重要部分,因为沟通是许多项目挣扎的领域,因为工程术语往往更具体,而业务语言则留下了解释的空间,让人和他或她的上下文来解决所谈论的问题。这两种形式的语言都有它们的位置,因为它们已经被证明是在特定场景中有效的沟通形式,但在这两者之间进行翻译往往是引入问题或错误的地方。为了帮助解决这些问题,领域驱动设计允许开发人员以多种形式对通信中的某些类型的对象进行分类,所有这些都将在本书中详细介绍:

  • 值对象

  • 实体

  • 聚合

  • 有界上下文

这些是具有一定意义并允许对业务流程中的对象进行分类的概念。有了这些,我们可以附加意义和模式。

这一切都是关于分心。

考虑创建程序的不同方式,结构化编程对我们今天的编程方式所做的主要改进是,程序员在项目上工作时,不必总是将整个项目记在脑中,以确保不重复功能或干扰程序的正常流程。这是通过将功能封装在可在其他部分重复使用的块中来实现的。接着,面向对象编程增加了进一步将功能封装在对象中的能力,将数据和函数作为一个逻辑单元放在一起。对于函数式编程也可以说类似的事情,它允许程序员将程序看作是由输入定义的函数流,因此可以组合成更大的单元。领域驱动设计现在在此基础上增加了一层,它增加了抽象来表达业务逻辑,并可以将其从外部交互中封装起来。在这种情况下,通过明确定义的 API 与外部世界进行交互的业务层就是这样做的。

在这些不同的实践中,有一件事在所有层面都闪耀出来,那就是消除分心的想法。在处理大型代码库或复杂问题时,你需要一次记住的东西越多,就越容易分心。这是领域驱动设计的一个重要观点,我们将在下一章中看到这是如何发挥作用的,当我们考虑如何从之前看到的规范转向我们可以继续使用的问题描述时。

专注于手头的问题

在很多情况下,陈述问题实际上并不明显。这就是为什么在业务专家和开发人员之间努力达成共识是如此重要的原因,双方都需要就他们对功能或软件的期望达成一致。允许开发人员清楚地告诉业务功能解决了什么问题,使开发人员能够更直接地专注于手头的问题并获得更直接的输入。类似于测试驱动或行为驱动开发的原则,清晰地陈述某个功能的意图对开发有很大帮助。在这个阶段,创建从AB的路径,以及客观地陈述何时达到目标,是我们所追求的。这绝不意味着我们不需要不断确认目标是否仍然是需要不断与业务方确认的,但它使我们能够使这种沟通更加清晰。有了确定的语言,现在就不必再进行多个持续数小时且没有明确结果的会议了。

有了这一切,现在是时候深入领域驱动设计的本质了。在本书中,我们将把我们的兽人地牢带入 21 世纪,使其能够灵活地适应其业务需求。作为第一步,我们将坐下来看看经营这个地牢到底是什么,以及我们的新软件如何利用领域驱动设计的概念和思维方式增加价值。

进一步阅读

领域驱动设计,正如本章所述,主要由 Eric J. Evans 的书《领域驱动设计》描述。我建议每个读者都跟进他的描述,以更深入地了解领域驱动设计的思想,不仅限于本章所描述的更具体的主题。

总结

在本章中,我们经历了开始应用程序的步骤,因为大多数项目今天都是这样开始的,并将其与领域驱动设计开发方法进行了对比。我们了解了领域驱动设计的重点,即开发人员与项目中涉及的其他各方之间的沟通。

需要牢记的关键点是,在关注技术选择和其他开发相关问题之前,要着重关注应用程序的核心功能集,否则会从探索中减少资源。我们学到的另一个重要方面是如何收集使用规范。关键点在于获取关于当前工作如何完成以及应用程序如何帮助的知识,而不仅仅是询问潜在用户的规范。

下一章更深入地关注了收集关于应用程序使用、预期可用性的知识的过程,以及开始建立一种语言来帮助开发应用程序的团队、建立领域专家和开发人员之间的沟通过程。

第二章:找到核心问题

每一段软件都是为了解决一个问题而编写的,并且反过来也是这个确切问题的一个完全有效的解决方案。遗憾的是,一段软件完美解决的问题并不总是软件最初创建时的问题,甚至不是程序员在编写软件时所考虑的问题。编程的历史充满了开发人员尝试各种方法来完美陈述问题并实施解决方案的例子。基于瀑布模型开发软件是一个很好的例子,它未能兑现承诺。当你询问参与方失败的原因时,最有可能的原因是问题偏离了规范,或者规范被误解了——根据一方的说法,这是非常明显的。那么,为什么会这样呢?

在开始软件项目时,特别是一个由业务需求驱动的项目,我们着手建模现实世界的一部分,并对其应用一组约束和算法,以便简化业务中一个或多个参与方的工作。问题在于,遇到问题的一方很可能不是开发人员。这意味着开发人员首先必须了解请求的真正内容,才能知道应该开发什么。

我们如何在没有客户多年经验的情况下,对业务的某个部分有足够深入的理解?解决这个问题,也是最可能的问题,就是沟通。我们需要找到一种方式,足够深入地探索问题,并结合我们如何在软件中建模世界的知识,以便能够提出正确的问题。我们需要以一种不失去非技术人员的方式来做到这一点,这样我们就可以从他们的理解中汲取经验。这又回到了开发人员和业务人员之间的语言不匹配,这可能是要克服的最大障碍。在领域驱动设计中,这被称为项目的普遍语言,这是所有参与项目的各方共享的语言。建立这种语言允许我们在团队边界之间清晰地进行沟通,正如前面提到的,这是领域驱动设计中的核心思想之一。

回到我们的例子,兽人在地牢中奔跑,我们不知道这是如何做到的;我们甚至完全不了解文化方面涉及或应用的约束条件。兽人的世界是一个我们只能观察、提问并根据我们的理解来建模的外部世界。我们自然必须信任当地的专家。即使在现实世界的问题中,我们也应该尽可能地从外部视角来看待问题,因为在一个经过多年发展的业务中,我们自己的假设可能是错误的。

接下来,我们将探讨问题并介绍一套工具,这将有助于解决问题。我们将涵盖几个方面,但最重要的是以下几点:

  • 使用纸和笔进行编程

  • 代码尖峰和一次性代码

  • 绘制我们的角色——为我们的领域创建一个依赖图

探索问题

在软件开发中,很少有问题可以很容易地完全规定。即使有些问题看起来可以,也会留下一些解释的空间。最近在实施数据库适配器项目时,我遇到了这个问题。有一个需要实施的规范,以及一组单元测试,确保实施符合规范。然而,在实施过程中,我发现自己一路上提出了一些问题。主要问题与如果没有规范,我会问的问题非常相似:人们将如何使用这段代码?在许多情况下,有多种实现某个特定功能的方法,但选择一种方法通常意味着权衡不同的折衷方案,比如速度、可扩展性和可读性。

在我们的兽人地牢中,我们必须问同样的基本问题:客户将如何使用我们的软件?遗憾的是,单凭这个问题本身不会得到我们心目中的结果。问题在于我们的用户不了解我们的软件。基本上,我们的未来用户和我们一样有同样的问题:他们不知道软件完成后会是什么样子,只能猜测它的用途。这真的是软件开发的进退两难;因此,为了成功,我们需要找到一个解决办法。作为开发人员,我们需要找到一种方法,使未来用户能够理解开发过程,而我们的未来用户需要适应高度描述性语言的概念,以尽可能清晰地陈述意图。

软件实际上是一个抽象的概念,大多数人不习惯谈论抽象的东西。因此,更好理解的第一步是使其对用户更加可接近。我们需要使概念变得可触摸;这可以通过各种方式实现,但越触觉越好。

提示

使用纸张。作为开发人员,我们经常更喜欢无纸化,但将事物写在纸上可以使大多数人更容易理解概念,因此写下来可以极大地帮助。

概述问题

就说明和组织信息的技术而言,大纲在许多情况下都很有用。但是,我们如何为软件制定大纲呢?这个想法是将与业务专家交谈时出现的所有信息以易于搜索的格式保存下来。在许多地方,这是一个维基,但也可以只是一组共享的文本文件,可以随时添加或检索信息。这里的大纲意味着按主题嵌套存储信息,并根据需要进行深入。

知识跟踪

在开始收集信息时,最重要的部分是尽可能收集尽可能多的信息,为此需要使其无缝。保持信息有序添加和根据需要重组也很重要。与我们的软件一样,我们不知道大纲的结构从何开始,所以每当我们识别出一个新的实体、角色或系统的任何重要部分时,我们就添加一个新的部分。因此,不要花太多时间使当前结构完美,而是使其足够好。

提示

养成收集所遇到的任何信息的习惯,并随时保持应用程序大纲。在许多公司,走廊交流通常是一个非常宝贵的信息来源,所以一定要好好利用它。

大纲如此有用的原因在于你可以轻松地重新构建它,这也是你在决定保留这些大纲笔记的工具时应该追求的目标。重新排序笔记需要快速和直观。目前的目标是尽可能降低变更成本,这样我们就可以轻松地探索不同的路径。

到目前为止,我们收集的地牢信息可以这样表示:

# Dungeon
receives prisoners
transfers from other dungeons
new captures
loses prisoners
transfers to other dungeons
fleeing
prisoners might flee during transfer
prisoners might flee from the dungeon itself

重要的是,这种结构非常容易修改和随着新信息的到来保持最新,我们已经可以看到大纲中出现了一个新的实体——囚犯。有了这些新信息,我们现在将其添加到大纲中,以便有一个地方来保存更多关于囚犯的信息,因为他们显然是我们地牢应用程序中的一个重要概念。

# Prisoner
can flee a dungeon or transport
needs a cell
is transferred between dungeon

这本质上就是大纲的内容,记录信息并得出快速结论。

媒介

根据情况,可能或者更好的是使用不同的媒介来保存信息。这可以从一张纸到一个完整的维基系统。我喜欢用于大纲的格式是 Markdown,它的优势在于以纯文本形式存储,并且在未经处理的情况下非常易读。此外,为了生成一些要打印的文档,将其先处理成 HTML 是很有用的。这绝不是最终选择,你应该选择任何感觉最自然的东西,只要它简单易编辑,并且在尽可能多的地方都可以读取。重要的是选择一个不会将你限制在其做事方式或难以导出或更改的数据格式中的系统。

纸上编程

在我们努力让非程序员参与软件创建的过程中,重要的是让概念易于理解。我们需要说明系统的交互以及参与者,并使它们准备好被移动。通常,当谈论一个主题时,让人们实际拿在手上并在桌子上移动的东西是有帮助的。实现这一点的最佳方法是创建系统元素的纸质表示。创建一个基于纸张、手动操作的版本,可以立即触摸和交互。这通常是 UI 设计中所知的,纸质原型是一件常见的事情,但它也很适合创建应用程序非 UI 部分的版本。

这个想法是将系统的任何部分绘制在卡片上,以便组合、分离和添加。当这样做时,它通常最终会非常接近我们将来在系统中拥有的实体表示。开始使用这种技术时,重要的是要注意最终结果总是处于某种状态。当事物在桌子上移动,元素被修改时,我们需要跟踪生成的信息。确保记下在讨论过程中某些行动是如何发展的,因为最终结果的单一图片只是反映了一个状态。

那么这样的纸质程序是如何工作的?

开始时,我们列出我们目前拥有的所有信息,为所有元素绘制出方框,并为它们命名。在我们的情况下,我们将绘制地牢、囚犯、牢房和一个运输工具。目前,这些是我们要交互的实体。在这一点上,我们考虑一个具体的交互,并尝试用我们目前拥有的实体和其他对象来表示它。让我们将一个囚犯从我们的地牢转移到另一个地牢;为了做到这一点,我们需要考虑我们必须做什么:

  • 地牢管理员通知其他地牢

  • 囚犯从牢房转移到运输工具上

  • 一个兽人被指派守卫运输

  • 运输到达其他地牢

当在一张纸上绘制出来时,结果可能看起来有点像这样,其中数字是步骤出现的顺序:

那么这样的纸质程序是如何工作的?

在这一点上,我们已经注意到缺少多个部分,主要是地牢管理员和通知其他地牢的方式。那么,如何添加这些呢?地牢管理员显然是管理地牢的实体,因此应该添加一个单独的卡片。此外,通知是通过消息完成的,因此我们添加一个消息系统。这是一个新的子系统,但我们现在可以将其视为一个黑匣子,我们可以将消息放入其中,让它们到达另一侧。

现在系统已经就位,我们可以为系统的参与者添加所需的方法:地牢管理员需要一种发送消息的方式来请求转移;单元需要放弃囚犯的所有权;运输需要接管;等等。随着我们进行这种交互,我们可以清楚地看到这可以被建模的一种可能方式,这对非开发人员来说也更容易接近,因为他们可以看到实际的盒子在桌子上移动。由于这个模型不断变化,请确保在沿途保留大纲中的注释,以免丢失任何新获得的信息。

不那么可怕的 UML

我们的论文原型为我们提供了交互的良好图像,我们的大纲捕捉了关于程序在各种情况下应该如何行为的大量信息。它还从业务角度捕捉了命名方面的细节。总的来说,这给了我们很多有益的见解,但仍然有一部分缺失。这使得我们的纸质原型的信息足够持久,因此我们可以更容易地在移动过程中参考它。我们之前绘制的原型缺少了一些对实施重要的信息。我们需要捕捉更多应用程序结构的信息。

这就是统一建模语言UML)发挥作用的地方,是的,这个充满瀑布注入实践的可怕东西,大多数人从未想过它有用。谈论 UML 时,通常会提到将所有建模信息编码在图表中的想法;因此,最终可以由基本上具有一定编码技能的每个人生成代码并填写。当然,这是行不通的,但 UML 仍具有一些有趣的属性,使其有用。我们要做的是利用 UML 的一个属性,即捕捉交互的能力。UML 定义了多个类别的图表:

  • 结构图

  • 行为图

  • 交互图

结构图主要关注系统中的参与者及其关系。在我们的情况下,它将表达管理员与地牢和其他兽人之间的关系。当涉及许多参与者时,这可能会有所帮助,但不一定是开始时最重要的信息。

不那么可怕的 UML

用例图提供了系统中参与者的略微更详细的图像,以及他们之间的互动。用例图是行为图系列的一部分,因此侧重于参与者的行为。这不仅是对我们的系统有用的信息,而且在目前来说也太粗粒度,无法表达信息和行动的流程。

不那么可怕的 UML

由于我们的功能涉及系统中定义的参与者之间的交互,一个有用的探索对象是事件在系统中发生的顺序。为此,我们可以使用序列图,这是 UML 中的一种交互图。这种图表侧重于实现特定目标所需的事件顺序。其中一些可能是异步的,有些需要等待响应;所有这些都在一个图表中捕捉到:

不那么可怕的 UML

通过这种插图,很容易区分同步和异步消息,因此我们可以确保相应地对方法进行建模。此外,命名事物被认为是计算机科学中最困难的问题之一,因此一定要向领域专家展示这一点,以便从他们的语言中命名现在暴露出来的消息和方法。

到目前为止,每个部分的想法都是拥有工具来从不同的视角探索问题,但不要过分相信!我们不试图创建整个系统的完整描述,而是深入探索一个部分,以便我们能够了解其核心功能以及如何实现它。然后,通过提出正确的问题来消除不确定性,因为我们对领域了解足够,以便与专家一起探索业务领域。

涉及专家

当我们从各个角度探索领域时,与尽可能多了解它的人交谈是很重要的。领域驱动设计的核心思想之一是创建一个可以被每个参与方使用的领域语言。在谈论工具时,我们设定了以这样一种方式来创建它们,使得开发人员和领域专家都能够平等参与,这样每个人都可以从对方的知识中解决问题。

口语本身就是一个问题,因此对于开发人员来说,它需要尽可能明确,因为需要表达非常具体和具体的想法。不应该有误解的余地。另一方面,对于业务人员来说,它需要对非技术观众来说是可理解的。现在来到了重要的部分,我们实际上将看到我们迄今为止是否已经实现了这个目标,以及我们如何能够来回沟通领域的想法。

涉及领域专家时,我们应该首先清楚地知道我们试图实现什么,比如获取关于我们目前正在开发的系统的知识。开发人员往往会让他们的系统展现出最好的一面,但我们的目标是暴露我们设计和理解中的误解和不确定性。实际上,我们希望被“出其不意”,可以这么说。对于项目当前阶段来说,这应该被视为一个成就。现在,改变的成本是最低的,所以如果我们暴露了我们知识中的某个空白,我们以后的生活会更轻松。现在暴露一个误解也意味着我们能够提出所有正确的问题,以便成功地沟通这个软件系统的抽象概念;因此,业务方面能够深入了解我们的系统并纠正缺陷。如果我们达到了这一点,非开发人员实际上参与了开发,我们可以继续开发一个非常合适的系统。那么,我们如何才能做到这一点呢?

找到空白

我们现在要做的第一件事是开始对话。与大多数问题一样,最好是在一个多元化的团队中思考,这样我们可以得到最多的观点。为了达到这个目标,我们希望创造一个环境,让业务领域的专家向我们解释发生了什么。我们现在可以使用各种不同的技术来以易于理解的方式谈论我们的软件。纸上编程的想法在这个阶段非常有用。

所以首先我们需要准备,确保所有已经确定的单位都做好准备。为每个人准备好卡片,让他们四处移动并在上面写下行动,同时识别出知识中的空白。将当前状态拍照并附上注释以保存状态以供以后参考,因为想法在演变。对话可以从开发者解释他们认为系统如何工作开始,鼓励业务专家在有不清楚或错误的地方插话。这可能真的成为一种游戏。我们如何用现有的部分表达我们想要表达的行动?当然,这不是一个拼图游戏,所以你可以随意创建新的部分并根据需要更改它们。以这种方式引导通过过程很可能会暴露出系统中的几个有价值的属性。

提示

准确是最重要的;务必尽可能多地提出诸如“这是 100%的时间都是这样做的吗?”这样的问题。

因此,让我们通过一个软件功能的示例来走一遍:将囚犯转移到另一个地牢。

谈论业务

囚犯转移的过程已经被描述为三个步骤:

  1. 地牢管理员通知另一个地牢。

  2. 囚犯从牢房转移到运输工具上。

  3. 运输到达另一个地牢。

所以,我们准备了一些卡片:

  • 由信封识别的通知服务

  • 地牢牢房

  • 囚犯

  • 运输工具

有了可用的卡片,我们可以让兽人大师准确描述囚犯转移时需要发生的事情。

兽人大师确定了问题,并发送了一只乌鸦通知地牢转移请求。然后他去牢房将囚犯移出并送上运输工具,指派一名兽人守卫运输工具并将其送往另一个地牢。

在这个简短的描述中,我们看到了与我们的模型有多处不同之处需要解决。

  1. 1 和 2 的顺序实际上并不重要,只要地牢中至少有一个囚犯,我们就可以在通知时进行检查。

  2. 还会涉及到另一个稀缺资源,那就是押送囚犯的卫兵;他们需要可用,并且他们的进出需要被跟踪。

有了新的见解,我们现在可以相当准确地将这个事件建模为我们系统中的演员。重要的是要注意,当然,我们的系统不需要直接在代码中表示流程,但从高层次来看,有一个一致的流程是有意义的,因为它可能已经通过(可能)多年的实际使用而被确立。因此,至少从某种程度上来说,这是一个很好的起点。

谈论演员

当讨论如何实现某个功能时,涉及到多种形式的对象,它们在系统中扮演着不同的角色。许多系统中存在这些角色,尽管它们可能有不同的名称。在领域驱动设计中,对这些角色的分类有很大的影响。原因在于,如果我们对某物进行分类,就可以立即应用一定的模式,因为它已经被证明是有用的。这与命名企业应用程序中出现的模式的想法非常相似,现在几乎已经成为大多数应用程序开发人员的基本知识。

在领域驱动设计中,我们有多个可供选择的构建模块:

  • 实体

  • 价值对象

  • 聚合

  • 领域事件

  • 服务

  • 存储库

  • 工厂

这个列表中的大部分元素可能对开发者来说已经很清楚了,但如果不清楚,我们稍后会更明确地定义每一个。现在,让我们专注于我们需要的并且已经在系统中使用的部分:聚合、值对象和领域事件。

一个重要的区别是实体和值对象之间的区别。实体由其身份定义,而值对象由其属性定义。回到我们的囚犯和牢房,我们可以看到可以使用任一分类,但它会改变焦点。如果囚犯是一个实体,每个囚犯都清楚地定义,两个囚犯将始终不同。这样对他们进行分类使得囚犯在整个系统中可追踪,因为他们从一个地牢到另一个地牢,从一个牢房到另一个牢房。这可能非常有用,但也可能过度。这就是当前阶段的全部内容——从领域角度找到项目的焦点。所以让我们一步一步地走完整个过程。

从外到内开始,我们首先要考虑我们的领域事件。顾名思义,这是触发领域特定反应的事件;在我们的情况下,这是囚犯的转移。为了处理这些事件,我们必须向下移动一级,考虑处理我们资源交易的系统部分,即聚合。它们可以说是系统中的行为者,因为它们聚合了所有需要的实体、值对象和其他一切,以向外界呈现一致的视图。聚合还负责根据领域的需要改变系统中的世界状态。就聚合而言,有多个聚合负责这个动作:管理牢房、囚犯和看守的地牢管理员,以及作为移动牢房的交通工具,囚犯和看守。通知其他地牢的服务有点超出系统范围,因此将其分类为服务似乎是自然的事情。好吧,这并不太难,思考不同对象的分类是相当自然的。

使用提供的领域术语让我们清楚地说明部分的关注和级别。其他开发人员,即使他们对系统不熟悉,现在也能够假定每个命名实体的特定功能集。对我们来说,命名是一种文档形式,可以让我们在开始混合概念时迅速注意到。

确定难题

在过去的几节中,我们开始对系统中的交互有了很清晰的理解。现在是时候利用这种理解,继续实施我们的软件解决方案。那么,在开发软件时我们应该从哪里开始呢?

通常在启动项目时,我们喜欢从简单的部分开始,也许从模板创建一个项目——例如,在一个新文件夹中运行一个框架代码生成器,比如 Node.js Express,为我们的项目设置脚手架结构。起初,这似乎是一个非常好的选择,因为它创建了大量我们必须编写的样板代码,以便创建一个 Express 项目。但是,这是否让我们更接近解决业务问题?现在我们有一个代码库可以探索,但是,由于它是自动生成的,显然没有任何特定于领域的代码。另一方面,我们已经将自己锁定在一个固定的结构中。对于一些项目来说,这是一件好事;这意味着要考虑的事情更少。然而,如果我们试图解决一个较低级别的问题,将自己锁定在某种思维方式中可能是不好的。

我们需要确定问题,并确定如何尽快为业务提供价值。这将推动用户采用和进一步开发软件。到目前为止,我们已经探索了领域的一部分,这对我们的业务来说似乎很重要,我们探索实施它作为我们的第一个特性。现在,是时候深入研究它,看看核心问题所在,看看将涉及的对象及其与我们的软件的交互。

映射依赖关系

通过之前的工作,我们对涉及的对象有了相当清晰的理解,至少在高层次上:

  • 通知服务

  • 单元

  • 囚犯

  • 看守

  • 兽人大师

  • 运输

有了这些想法,我们现在的任务是找到一个开始的地方。当布置这些对象时,很明显它们都与其他部分有一些依赖,我们可以利用这一点。我们绘制每个对象,使用箭头来展示它依赖的对象。这就是所谓的依赖图

映射依赖关系

该图向我们展示了我们确定的每个角色的依赖关系。例如,看守对于运输和兽人大师来说是必要的依赖。另一方面,兽人大师不仅依赖于看守,还依赖于运输、囚犯和牢房。通过查看图表,我们可以看出元素需要以哪种顺序实现。我们之前确定为聚合物的元素当然会有最多的依赖关系。正如它们的名字所暗示的那样,它们将多个对象聚合成一个单元,以便进行共同访问和修改。

解决问题的一种方法是按以下顺序开始:

  1. 看守。

  2. 牢房。

  3. 囚犯。

  4. 运输。

  5. 通知服务。

  6. 兽人大师。

好处是,一路上,我们可以在其中一个聚合物处于工作状态时立即呈现中间状态。我们可以谈论我们对运输的想法,并将其与预期的功能对齐。工作状态在这里是一个重要的点,因为如果某个部分在多个方面不满足要求,人们很难判断某个特定部分。当然,“工作状态”并不意味着我们实际上看到了什么,但随着软件变得更加复杂,我们能够使用这些聚合物来发挥它们设计用途中的作用。例如,我们可以创建一个快速原型,重播业务团队指定的一些交互。当然,这与测试和功能验收测试或行为驱动开发是相辅相成的。

提示

向领域专家展示中间状态需要涉及对功能的指导,以及沿途提出问题。把部分实现的软件“扔过围墙”几乎没有任何用处。

用代码绘制-尖峰

现在我们已经有了一个开始开发的想法,我们最终可以探索如何实际做到这一点。当我们思考问题时,我们可能对它的运作方式有一些想法,但也会有一些地方,尽管我们知道高层是如何运作的,但我们对低层仍然不清楚。当开发人员不知道某件事在现实中如何运作时,找出该怎么做的最佳方法就是实际尝试并探索在其中途中被认为有用的库和工具。

这种方法被称为尖峰。我们创建一段一次性的代码,只是为了探索某个困难的部分,没有打算让这段代码进入生产。这使我们摆脱了通常涉及创建生产就绪代码的复杂性。代码只是为了解决特定情况并帮助我们获得有关如何以后解决同样问题的知识。大多数开发人员都知道,第一种方法几乎从来都不是问题的完美解决方案,所以让我们通过创建一个打算丢弃的第一个版本来处理这个事实。尖峰是关于知识的获取,而不是关于代码,所以准备好写一些快速而肮脏的东西来让它工作。顺便说一句,这实际上可以是一个非常有趣的练习!

一个常见的试验领域是与外部服务的交互,比如我们的通知服务,其中界面在高层次上是已知的,但开发人员实际上从未使用过。由于我们不知道如何与 Raven 进行接口,我现在打算暂时搁置这个问题。当情况出现时,我们需要重新审视这个问题,但正如我们从 UML 图中学到的,这个过程本身是异步的。因此,我们不指望在我们的第一个原型中,响应可以隐藏在Mock后面。

另一个有趣的问题将是创建我们的代码和用户之间的接口。我们无法确定用户希望如何与软件交互,因为他们没有使用类似软件的经验。接口往往能更好地帮助我们了解用户对软件的需求。用户希望如何使用软件会告诉我们很多关于他们关注的重点和期望的功能,因此进行试验是了解系统的好方法。这种试验可以通过多种方式进行,但实际上,有真实的界面元素可以构建,并且以后可以填充更多的交互,这是非常有用的。其中一种方法是使用 HTML 创建界面,它提供了基本的结构,但没有交互性,随着我们的进展,可以用 JavaScript 填补空白。为了简洁起见,代码被省略了。如果您感兴趣,请访问本书的代码存储库并查看。

请记住,这实际上并不是我们打算保留的界面,但现在我们可以向用户展示一些东西,并解释他们将如何进行交互。

开始吧,现在是时候了

通过之前的工作,我们现在可以开始着手应用程序的第一个功能了。我们已经探索了我们的想法,现在可以与我们的领域专家讨论细节了。我在这里稍微简化了到达这一点的步骤,实际上,这个过程很可能需要多次迭代,你对问题的理解也会不断发展。有时不仅是你的理解发生变化,而且业务方在这个过程中也会不断完善他们自己的理解。

在不创建代码的情况下创造价值

作为程序员,我们经常觉得我们创造的价值与我们创建的代码有关,但事实并非如此,我甚至会说我们的价值在于我们没有创建的代码。我们能够简化问题,项目推进就会更容易,简单是基于与业务团队合作的坚实理解。

提示

没有比创造复杂性更容易的事情了,所以要小心!以最简单的方式解决问题是每个软件都应该努力做到的。

当我们像之前那样走过流程,让人们解释他们每天做什么,很容易发现如何简化和改进某些事情。试图改进流程本身是软件开发过程的一部分。当我们探索一个功能的想法,并让业务方讨论他们自己的行动时,他们通常会注意到不必要的开销,甚至是不需要存在的过程继承复杂性。这就是为什么我们试图以文本格式进行之前所做的探索。不要嫌探索需要花费的时间,但要记住,现在你已经为业务创造了价值,这个阶段的改进是一个巨大的成功。

决定第一个功能

尽管我们已经在推动业务发展,但现在是时候真正做开发人员最擅长的事情了——编写代码。我们现在所做的探索指引我们开始以下功能集。

我们想要自动化将囚犯从地牢中移出并同时记录移动的囚犯。这似乎非常有价值,因为地牢溢出是兽人大师的主要问题。这也是保持地牢内囚犯记录的更大问题的一部分,我们将其视为我们大纲的一部分。最终,这就是我们要做的事情。完成了这个第一个功能后,囚犯的移动将几乎完全自动化,因此可以节省时间,我们可以投入到地牢运营的其他元素中。

我们设计了一个基本的界面来处理这个问题,看起来很容易使用。所以,让我们开始编码,并使用领域驱动设计的技术来设置项目,推动项目的进展。

总结

在本章中,我们学习了如何在编写代码之前开始项目。我们专注于与业务专家的互动,通过阐明我们的思路向他们提供反馈。我们讨论了收集知识的重要性,以及如何组织这些知识,以便在项目后期利用它,以了解我们正在构建的应用程序的目标。

随着我们的前进,我们研究了如何确定核心功能集,并选择一个良好的起点,不仅可以为业务提供早期价值,还可以帮助我们进一步了解业务领域。这个过程类似于敏捷方法的目标,试图尽早解决核心问题,并为业务提供快速价值和反馈。

在下一章中,我们将开始设置项目,并涵盖重要细节,以便在整个开发过程中对管理过程有一个良好的把握。

第三章:为面向领域驱动设计设置项目

到目前为止,我们一直专注于准备项目的先决条件。我们致力于为自己创建一个心智模型,并确认我们对领域的理解与我们的领域专家的理解相匹配。通过这样做,我们开始在所有参与者之间创建一个共享的语言,以便所有各方可以就项目进行沟通,同时避免大部分误解。有了这一切,我们能够确定项目的起点,现在我们知道从哪里开始,以及如何根据领域命名我们的对象,我们可以设置项目以适应这一点。罗伯特·C·马丁在他的演讲《架构的失落年代》中说:架构是关于意图的,架构不是为了自身而创建的,而是为了说明项目的内容,并清楚地表明下一个人需要覆盖的每个层次。在设置应用程序时,我们希望在每个层次上表达应用程序的内容,这包括文件和文件夹的组织层次,以及创建类和模块。

我们的主要目标与软件架构的目标一致,一般来说,我们的目标是不要过早做决定,并确保我们做出的决定尽可能自我解释。我们还没有决定任何框架或实际上任何技术,但随着我们推动应用程序的进展,现在是时候解决一些推迟的决定了,尽管我们希望尽可能保持开放以进行更改。

本章将讨论在创建灵活的项目设置时出现的挑战,这样可以使您的项目适应并实际上拥抱结构的变化。在整个设计过程中牢记这一点非常重要。我们不希望模块结构妨碍我们的重构,或者因为压倒性的类和文件层次结构而使我们的项目更加僵化。

在进行这项工作时,我们将在多个层次上处理结构:

  • 文件和目录结构

  • 项目结构

  • 对象或类结构

  • 应用程序结构与领域外部的交互

对象和类结构以及项目结构与我们决定如何设计应用程序密切相关。作为其中的一部分,测试被引入,因为它对我们如何设计我们的类和对象有最直接的影响。它还对我们的团队如何共同开展项目工作以及他们如何能够向业务专家展示结果产生影响,让他们探索当前项目的方式。

提示

随着 JavaScript 离开增强网站的领域,转向成为用于大型应用程序的语言,无论是在浏览器上还是在服务器上,对更复杂的架构的需求增加了,人们试图将目前用于 Java 或 C++后端应用程序的许多概念移植过来。通常,这实际上会引起更多问题,因为 JavaScript 是一种非常灵活的语言,有自己的组织方式和概念,尽管可能仍然缺少一些部分;模块是其中一个核心概念。构建 JavaScript 应用程序时,始终牢记自己使用的语言,并使用其特性和概念来处理项目;不要在每一步都与之对抗。

本章涵盖了项目的设置以及如何使其成为一种愉快的工作方式。您将了解以下内容:

  • 项目文件结构以及在布局时要考虑的因素

  • 不同形式的测试及其重要性

  • 构建应用程序

  • 六边形架构简介

按我们的看法构建项目

当一个新的开发人员投入一个项目时,他们总是会看到项目中文件和文件夹的布局。这也是我们在不断编辑项目时不断处理的组织元素,因此值得投入思考。仅仅看看文件和文件夹应该已经告诉你一些关于项目的信息;这是最高级别的组织,因此应该代表我们领域的一些最高级别的概念。

因此,首先,我们需要确保我们知道我们试图用这个结构解决什么问题。在这个层面上,有多个要点需要我们解决,并且它们将贯穿项目组织的每个部分;它们是:

  • 易接近性

  • 编辑的局部性

  • 适应变化的能力

因此,让我们看看这些要点是关于什么,以及我们如何为每个要点进行优化。

易接近性

当一个新的开发人员加入一个项目,甚至当一个回到他们最近没有工作的项目时,都需要花时间了解事物的位置,也许更重要的是,了解事物未来应该放在哪里。这总是一个问题,因为它会减慢开发速度,或者当谈论一个开源项目时,它实际上可能会减慢采用和贡献。因此,我们显然希望尽可能使代码库易接近,但这意味着什么呢?对于不熟悉的工具和风格,存在主观的学习曲线,这很难提前估计每个开发人员的情况,但也存在一个更客观的学习曲线,与常见的做法、命名和已经建立的概念相关。那么我们如何从文件和文件夹级别使代码库易接近呢?

当我们开始时,我们需要看看里面有什么,因此导航的便利性是我们必须处理的第一件事。具有大量的子文件夹,只有视图文件,或者有时甚至没有文件,都是使项目难以导航的例子。有些人可能会说,你正在使用的编辑器应该解决这个问题,但这也是我们为自己创造的问题,因此我们应该避免这样做。

有更多的方法可以使项目更易接近,例如,文件名应反映内容,目录名也应如此,而且可能最重要的是,项目应遵循社区已经建立的惯例。这意味着除非你有很好的理由,否则你应该避免创建自己的惯例。特别是一些小事情,比如根据社区标准命名文件,可以帮助很多。一个例子是在文件末尾添加像 model 或 controller 这样的名称。在一些编程社区中,这是非常常见的,而在 Node.js 社区中,这是不被赞同的。遵循这些小事情可以使开发人员更容易,而不遵循它们可能几乎会引起对项目的愤怒。

请记住,文件很可能只会被开发人员触及,因此它们可以被优化以支持开发人员的任务,因此,常见的开发人员实践比领域专家的易接近性更重要。当然,这在项目和任务之间的范围上有所不同。它在组织性质和框架的常见习语方面基本成立,但不适用于在整个项目中开发的语言固有部分的命名。我们希望项目的结构对于已经熟悉类似代码库的开发人员来说是易接近的,但我们不希望在开发人员和领域专家之间引入翻译层。

让我们以一个例子更仔细地看看我们如何为我们的地牢管理器制定基本规则。当然,一开始,这只会包含转移囚犯功能,但尽管如此,它将暗示整体结构:

易接近性

关于这种结构的重要事项是,它一直使用节点模块的基础,同时已经暗示了可能包括多个功能在囚犯转移之外的结构。index.js文件通常命名为指示特定模块的入口点。跳入项目的开发人员将知道在尝试了解有关模块的更多信息时首先查看这些文件。我们以后可以利用这一事实来包括有关功能的常见文档,以及使该文件加载完成模块任务所需的所有其他文件。

在测试文件夹中创建测试也是定位测试的已建立方式。由于测试在设计上具有某些固有的类别,因此按照测试目录的结构进行组织是有意义的。测试文件夹的结构应该让我们一眼就能看出有哪些测试,以及它们如何适用于我们的整个项目。随着项目的增长,拥有一组覆盖功能的测试不仅在回归方面非常有价值,而且还可以快速了解某个功能的使用方式,因此快速定位测试可以意味着某个模块被重复使用或调整,而不是浪费精力重复已有的工作。

提示

这里提出的结构并非一成不变,有些人更喜欢将 app 改为 lib,将 spec 改为 test,或者进行其他类似的小改动。结构的目标应始终是让开发人员感到宾至如归。在这个领域可以根据特定的开发人员做出权衡。

最后,添加package.json文件是处理项目依赖关系并定义结构和其他部分的常见方式,因此我们也添加了这个文件,准备以后充分利用。

编辑的局部性

当开发人员在项目上工作时,他们很可能正在处理一个功能,或者正在修复错误和重构代码。由于这些活动至少在我们所追求的情况下与一个功能相关,我们希望确保开发人员不必跳转到许多不同的地方进行编辑。因此,与问题相关的文件应该在一个地方,减少打开与给定任务或功能相关的所有内容的开销,以及保持相关部分在头脑中以确保编辑发生在正确的地方的心理开销。

这就是我们之前在lib文件夹中创建包或模块的原因之一。当开发人员在处理囚犯转移时,他们可以仅通过查看目录结构就知道要编辑什么。他们可以快速在编辑器中打开文件,并将其视为一个工作单元,因为他们正在更改代码以完成给定的任务。

使用这样的结构不仅使开发人员在编辑时更容易查看,而且版本控制系统也更容易使用。由于代码是这样组织的,我们可以逐个功能地查看它,而且在处理不同功能时也不太可能触及相同的文件。这不仅减少了冲突的可能性,还使给定模块的历史更有用。

如果您看过我们迄今为止一直在使用的前述结构,您可能已经注意到编辑的局部性在测试中会被打破。当我们在lib中开发囚犯转移功能时,我们也必须编辑测试中的功能测试,这在文件系统上可能是相隔很远的。与软件开发中的一切一样,这是一个权衡,我们在这种情况下选择了可接近性而不是局部性。原因是更重视人员的入职,并且假定非局部性的成本似乎足够低以支持这一点。如果我们有不同看法,我们可能会将每个功能的测试定位在功能内部,因此更容易在将来将整个功能移动到不同的项目中。当然,这个决定并不是非此即彼的,我们可能会创建一个类似于测试目录下主目录结构的结构,以保持测试的局部性,例如将测试目录作为测试目录的一部分。

健身

根据达尔文的说法,健身意味着生存和繁殖的能力。
- 达尔文健身-劳埃德·德米特里乌斯,马丁·齐赫克

随着我们的软件的增长和发展,它将需要适应不同的使用场景,最好的软件是超出其预期用例的软件。一个常见的例子是 Unix 及其相关的哲学。其理念是创建许多小的部分,当重新组合时,可以实现各种各样的用途。Unix 以各种形式存活了几十年,似乎没有尽头,但仅仅以某种方式创建只是故事的一半。随着变化的出现和新的用例形成,它并没有变得僵化,而是其思想和概念是可塑的,但对于我们的软件意味着什么。我们如何实现类似的多功能性?

我们已经看到,即使在文件系统级别上,软件也是由模块组成的。随着功能的实现,不同元素之间存在明显的区别。从健身的角度来看,这意味着我们能够快速定位某个特定功能,以及增强、删除或重用它。功能还应提示其依赖关系,可以通过子文件夹明确表示,或者只需查看位于功能目录根目录的索引文件中的导入依赖关系即可。

举个例子,随着地牢管理员的发展,囚犯转移可能会开始融入更多的消息传递,因为其他地牢已经采用了我们的系统,现在我们可以完全自动地在它们之间进行转移。在这一点上,整个王国都依赖于转移服务的可用性,这意味着需要非常严格的测试来确保其可靠性,因为停机意味着王国无法以最大效率进行袭击。我们对这个系统的成功非常满意,但它总体上减缓了地牢管理员的发展,因为囚犯转移是其一部分,我们需要遵守其严格的集成规则。但毕竟我们处于一个良好的位置;如果我们看一下我们的应用程序布局,我们可以看到我们可以相当容易地将囚犯转移提取到一个独立的应用程序中,并且可以单独维护它。

提取后,我们可以再次快速前进,并将转移集成为地牢管理员与之通信的另一个服务。拆分共同功能以及必须遵守不同约束的功能是保持可塑和可扩展软件不断前进的关键。

实际上,这显然是最好的情况,但仅仅将应用程序构建为一组独立的小部分,每个部分都在功能级别上进行了单独测试,使我们考虑 API 的方式,这在软件增长时将非常有用,当然,反过来也是一样。我们能够快速剔除不需要的功能,从而减少维护开销并提高我们的速度。这本质上就是本节开头提到的所有小型 Unix 程序的合作概念。

当然,这并不是软件设计的全部和终结,任何从 shell 开始使用 Unix 的人都会知道最初的学习曲线非常陡峭,最初做任何事情都不会感觉很快或者表达得很好。正如我们之前所看到的,为了达到一个目标,就意味着牺牲另一个,比如在这个例子中——项目的可接近性。毕竟,没有完美的解决方案,但至少在项目开始时,增强可接近性并在问题出现时考虑其他问题通常是有帮助的。对我们来说,这意味着牢记模块的高层结构可能是一件好事,但过度做准备并使每个部分都准备好提取,甚至是自己的应用程序,可能不会帮助项目前进。

提示

不要过于复杂化以获得完美的架构,因为完美的架构并不存在。更重要的是迅速将软件交到用户手中,以便获得关于其是否有用的反馈。由于延迟反馈,决定完美架构的减速很可能会在以后造成更大的成本,而次优的架构可能不会。

处理共享功能

就我们目前构建的应用程序而言,我们已经准备好拆分可能成为独立功能的功能,但反过来呢?领域通常有一定的一组关键概念,这些概念一次又一次地出现。这很好,因为它允许我们在需要时共享它,而不必一遍又一遍地写。这也表明我们足够了解领域,以提取核心概念并共享它们,因此这实际上是值得努力的事情。

当我们的功能与共享功能密切匹配时,我们提供一个公共接口,每个依赖接口都可以根据它进行开发。但如果我们实际上提取了一部分功能,例如我们的囚犯转移服务不再局限于应用程序,而实际上是通过 HTTP 可达的服务,那会发生什么?在这种情况下,我们不仅需要处理共享功能,而且我们实际上必须在每个依赖方中实现相同的代码,以便通过 API 调用来执行我们以前在本地执行的工作。想想每个其他购物系统都创建的支付网关抽象——这种功能可以开发一次并在多个地方使用,允许共享测试和共享开发资源。

当然,这并不是唯一一个共享功能实际上意味着有代码被共享的地方,似乎我们不得不在各个地方重复某些片段。其他例子可能是数据库访问或配置管理。所有这些共同点都是实际上与应用程序领域没有密切关系的较低级别代码。我们正在处理我们喜欢交流的方式的产物,我们应用的模式并不很好地支持这种交流。我们也可以这样思考,领域层面的内聚性较低,因为我们正在以一种方式泄露抽象,例如当我们想要处理囚犯时,我们会关心数据库访问代码。

提示

引入共享代码时要记住的一件事是,共享是耦合,耦合不是一件好事。共享代码应该有非常好的理由。

此时可能有多种解决方案,根据项目和代码的不同,可能适用不同的解决方案,所以让我向您介绍最常见的解决方案。

共享工具箱

当出现第一个不真正属于任何地方的共享功能时,大多数项目开始创建一个实用库,一个在整个项目中使用的工具箱。尽管许多架构纯粹主义者对此不屑一顾,但这可能是开始的最佳方式。最好将共享的工具箱分开,而不是在之后处理代码重复。许多流行的库都是这样开始的;想想 underscore 在 JavaScript 的each构造上提供了其实现,并处理了浏览器实现可能需要关心的所有不同版本,以在全世界运行。以下是从underscore.js文件中提取的一个示例,重新格式化以便更容易阅读:

var each = _.each = _.forEach = function(obj, iterator, context) {
  if (obj == null) return;
  if (nativeForEach && obj.forEach === nativeForEach) {
    obj.forEach(iterator, context);
  } else if (obj.length === +obj.length) {
    for (var i = 0, length = obj.length; i < length; i++) {
      if (iterator.call(context, obj[i], i, obj) === breaker)
      return;
    }
  } else {
    var keys = _.keys(obj);
    for (var i = 0, length = keys.length; i < length; i++) {
      if (iterator.call(context, obj[keys[i]], keys[i], obj) === breaker)
      return;
    }
  }
};

虽然 underscore 这样的库是这种方法有用性的完美例子,但也存在问题。特别是当命名不当时,这个文件夹或文件很快就会成为各种东西的倾倒地。不去考虑某样东西真正属于哪里,而是将更多东西倾倒到实用程序文件夹中,这更快。至少现在它在一个地方,可以从中移动和重构,所以保持积极;情况可能会更糟。从长远来看,目标应该是转向一种使用面向对象的方法,并让我们的测试从一开始就指导领域设计。当我们查看应用程序并看到类似上述的库函数是应用程序代码的一部分时,我们知道缺少一个抽象。再次强调,这一切都是权衡,抽象的问题是您在编写时必须考虑它们。

提示

实用程序或库是一个危险的地方,所以一定要确保将它们放在您的定期审查和重构的视线中。始终保持代码比您找到的代码整洁一点,并密切监视其变化。

提升依赖关系

随着项目的推进和发展,处理依赖关系的最佳方式可能是利用已有的内容。您的库已经成长,许多内部项目依赖于它们,为什么不利用已经内置到环境中的依赖管理呢?

JavaScript 曾经以处理依赖关系而臭名昭著,但是下载 jQuery 的版本并将其放入项目的时代幸运地结束了。JavaScript 为每种用例提供了惊人数量的依赖管理器。在浏览器中,我们可以利用bower (bower.io/)、browserify (browserify.org/)和npm (www.npmjs.com/),可能还有许多其他的依赖管理器,在 Node.js 中,npm 是处理任何我们可能需要的包的标准方式。

根据作为过程一部分开发的库的类型,可能是一个很好的时机依赖于项目之外的版本,甚至可能建立一个私有版本的包注册表。这在开始时可能有些过度,但随着需求的出现,这是需要记住的事情。此外,不要忘记,现在可能是您回馈社区并将其作为开源发布的时候。

测试

小心上述代码中的错误;我只证明了它的正确性,而没有尝试它。
--Donald Ervin Knuth

每个即将投入生产的系统都需要根据实际情况进行评估。现实可能是一件严酷的事情,经常发生的情况是,我们期望完美运行的东西在实际使用时却不起作用。因此,在计算机编程的历史上,开发人员一直在思考如何确保软件能够正常工作,并且最好能够按预期工作。

1994 年,Kent Beck 为 Smalltalk 编写了 SUnit 测试框架,开启了现代单元测试的时代。这个想法非常简单:自动化代码评估,并确保它满足一定的规范。即使今天有许多新的框架来实现这一点,基本思想仍然是一样的:编写代码并检查它是否产生了预期的结果。实际上,无论有没有测试框架或固定流程,开发人员总是在做这个事情——没有人会在没有尝试过的情况下将代码推送到生产环境中。我们可以手动执行,也可以自动化执行。

有多个要点需要解决,以使测试变得有用,因为我们编写的测试有不同的目标。我们需要促进简单的单元测试、表达性的功能测试和性能测试。当然,这并不意味着所有场景都需要由一个框架处理,但摩擦越小,核心原则的采纳就会越好。确保测试被执行是至关重要的,而实现这一点的最佳方式是通过自动化,确保没有代码可以在最终产品中出现,而不满足其要求并且不破坏其他代码。

建立测试环境

正如我们现在所知,测试环境必须满足许多不同的目标,但也有大量的测试框架以及 JavaScript 本身在测试方面带来了一些挑战。过去许多项目中使用的一个框架是 Mocha 测试框架。它在 Web 开发人员中也得到了广泛的采用,因此接下来的部分将解释 Mocha。没有秘密可言,Mocha 可以与您团队最擅长的框架相互替换。唯一需要确保的是您实际使用了您拥有的工具,并且了解您想从测试中获得什么。因此,首先,我们需要确保在选择技术实现目标之前,了解我们不同测试的目标是什么。

不同类型的测试和目标

当我们开始测试代码时,有多个原因需要这样做。对于一个由其领域实现驱动的项目来说,一个主要方面始终是测试实现的功能,因为我们希望向客户提供快速反馈,并以一种解释性的方式展示我们的实现是有效的。但作为开发人员,我们还需要深入挖掘并在单元级别上工作,探索我们编写代码时的具体情况,或者在设计算法时。最后,一个项目不仅应关心其功能是否实际执行了它应该执行的任务,还应该从用户的角度来看,提供响应迅速的答复,并在整体上表现得足够好,以免成为障碍。所有这些方面都可以通过测试来实现。

功能规范

多年来,使测试不仅对开发人员有用,而且对客户也有用,一直是测试驱动和实施的最终目标。有一些工具,比如 Ruby 的 Cucumber,它有一个 JavaScript 实现,可以确保规范与代码有些解耦,使其尽可能易于领域专家阅读和理解。最终结果是规范看起来大部分像普通英语,但有一些限制。下面的代码使用黄瓜语法来描述囚犯转移作为功能规范,包括一个验收场景:

Feature: Prisoner transfer to other dungeon
  As a dungeon master
  I want to make prisoner transfer an automated process
  So no important steps get left out

  Scenario: Notifying other dungeons of the transfer
    Given I have a prisoner ready to transfer to another dungeon
    When I initiate the transfer
    Then the other dungeon should be notified

这种规范现在可以很容易地转化为一个运行的规范,使用GivenWhenThen块作为我们测试的指令。

将规范与真正的测试解耦有些程度上将程序员与之分离。因此,根据产品所有者的技术专业知识,即使他们也可以编写规范,当然需要一些开发人员的支持。在大多数项目中,情况并非如此,开发人员最终会为 Cucumber 创建规范代码,以及其作为测试代码的实现。在这种情况下,坚持使用更基本的工具是有用的,因为这更适合开发人员已经习惯的方式编写测试。这并不意味着黄瓜的想法不值得考虑。测试应该在非常高的层面上阅读,并且应该能够反映产品所有者最初在描述给开发人员时的意图,这样我们作为团队可以一起检测不匹配。但是,由于代码很可能在有开发人员在场的情况下阅读,几乎没有必要几乎有两种测试实现的开销。

受到 Cucumber 的启发并使用 Mocha 来编写我们的测试并没有错。例如,测试可以看起来像这样:

var prisonerTransfer = require("../../lib/prisoner_transfer")
var assert = require("assert")

describe("Prisoner transfer to other dungeons", function () {
  /*
   * Prisoner transfers need to be an automated process. After
   * initiation the transfer should take the necessary steps to
   * complete, and prompt for any additional information if needed
   */

  it("notifies other dungeons of the transfer", function (done) {
    var prionser = getPrisonerForTransfer()
    var dungeon = getDungenonToTransfer()
    prisonerTransfer(prionser, dungeon, function (err) {
      assert.ifError(err)
      assert.equal(dungeon.inbox.length, 1)
      done()
    })
  })

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

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

即使这种风格现在是实际可运行的代码,使用辅助方法来抽象细节并使命名清晰保持可读性。这里的目标不是让非技术人员轻松阅读,而是让开发人员能够与业务专家坐下来讨论隐含的规则。

提示

测试是代码的一个组成部分,因此它们需要采用相同严格的编码标准,由于测试没有测试,可读性至关重要。

单元测试

与业务专家讨论功能集后,创建功能规范中的当前状态规范,作为开发人员,我们需要尝试我们的代码。这就是单元测试的闪光之处!这个想法是在开发代码的同时测试我们的代码,并允许它在隔离环境中立即执行,这样我们就可以对其进行推理。单元测试通常随着某个部分的开发而迅速变化,并在之后作为回归保护。

提示

不要害怕放弃单元测试;它们是为了帮助开发而不是阻碍开发。

由于我们已经在我们的功能中使用 Mocha,自然而然地也要用它来测试我们的更小的单元,但测试看起来会有所不同。在单元测试的级别上,我们希望尽可能地隔离自己,如果我们做不到这一点,那么在其他开发领域中,我们真的会遇到一些痛苦。这种痛苦实际上是关于高耦合的;当我们将一个模块与系统的其他部分耦合得太紧时,测试会告诉我们。在这种设置中,创建一个孤立的单元测试将需要大量的设置,以确保我们只击中模块本身,而不触及依赖关系。

模块的单元测试的最终结果应始终测试公共接口,因为在这一点上它们起到了回归保护的作用。模块的单元测试测试的外部部分越多,它的私有接口就越多暴露,发生故障的可能性就越大,但即使这是我们的最终目标,也不要犯认为这应该是所有单元测试所做的错误。在编写更大应用程序的模块时,探索其逻辑更深入可能非常有用,特别是当公共接口可能仍在变化时。因此,在开发时编写所有能够深入了解模块较难部分的测试,但确保在声明模块可以使用之前删除这些“小助手”。

性能测试

每当一个应用程序向前发展并实现功能时,我们都需要考虑这个应用程序的性能。甚至在我们涉及性能需求之前,了解系统中哪些部分最有可能在将来引起麻烦是很重要的。

性能测试的重要之处在于它们将在早期阶段确定代码中的指标重点。考虑如何测量系统各部分的性能将确保我们考虑到仪器,这在我们后来实际上更接近重度使用或者在生产中探索故障时可能是一个至关重要的特性。

当然,测试应用程序的性能不是一次性的事情。单独来看,性能的测量是毫无意义的;只有在随着时间的推移监控时才会变得有用。实现这一点的一种策略是在每次推送到主分支时对外部 API 进行测试,并记录更改。这将让您了解项目在监控方面以及在项目开发期间的性能方面的情况。

尽管这可能并不明显,但监控性能的变化是实施域的一个重要点。作为采用领域驱动设计实践的开发人员,我们必须考虑我们应用程序的可用性。通常,不同的利益相关者对性能有不同的需求,而一个无法满足其需求的应用程序可能对某些人毫无用处。因此,很多时候,即使应用程序在其他方面提供了完整的功能集,由于性能特征不佳,应用程序也停止被使用。总的来说,只要知道了缺陷,就已经成功了一半。当我们至少了解时间花在哪里时,这是一个我们可以随时介入并根据需要进行优化的时机。这种需求很可能迟早会出现,因此为此做准备是非常值得的。

考虑到这些不同的目标,我们现在必须解决的问题是尽可能经常地运行所有这些不同的测试,而不必仅仅依赖于严格的遵从,特别是在创建一个随着时间推移而变化的视图时。随着项目的变化,依赖于人们每次运行所需的一切不仅对团队是一个重大负担,而且也是不必要的。

持续集成

最终,所有可能需要的测试只有在运行时才有用,这就是持续集成发挥作用的地方。当然,我们都是优秀的开发人员,总是测试他们的代码,但即使我们可能并不总是测试应用程序中整个集成链。我们的性能测试只有在可比较的平台上运行时才有用。

持续集成已经存在一段时间了,它最突出的系统可能是 Jenkins,但也有其他系统存在。其想法是在系统上自动运行我们从开发到生产需要的测试和其他步骤,并确保我们始终拥有稳定的构建。我们甚至可以使用这个系统来自动化部署,并且当然为开发人员提供一个仪表板,以检查应用程序当前的运行情况。

这样的系统可以成为项目的重要组成部分,因为它允许您快速从开发转移到系统,业务专家可以检查工作的影响。有许多关于如何设置项目持续集成的教程,最近高度精练的系统如Travis-CI使得设置变得非常容易,所以我在这里不会详细介绍;只需记住,这样的系统在项目达到一定规模和复杂性时,其价值远远超过成本,没有理由不使用。

提示

持续集成系统实际上是在整个开发过程中强制执行最佳实践,即使开发人员有一天状态不佳。它还提供了一个更易接近的方式,让外部人员发现和评估整个应用程序的状态。

管理构建

为兽人地牢编写软件有一个重要的优势,因为兽人对软件了解不多,所以我们可以引入任何我们喜欢的工具,他们不会对此有意见。你可能会想,当这一节的标题应该谈论构建软件时,我为什么要提到这个?市面上有很多构建工具,它们都有些许不同的功能,每个人似乎都更喜欢其中的一个。特别是在 JavaScript 中,社区尚未统一一种工具,因此有GruntJakeBroccoli等,当然,您的项目也可能利用其他语言的工具,比如 Ruby 的 Rake 或老式的 make。

尽管有这么多构建工具,但它们唯一重要的是实际使用一个。是的,它们都有差异,但它们几乎都可以做到相同的事情,只是语法和性能有所不同。但为什么构建工具如此重要?为什么我们应该使用一个?

为什么每个应用程序都需要一个构建系统

在实际创建一个功能完整的系统来管理业务流程的规模上创建软件总是一项困难的任务。这样的系统涉及许多部分,就像我们管理囚犯转移的例子中,通知其他地牢,跟踪地牢的统计数据等等。当我们设置它时,我们需要加载多个文件,也许编译一些部分,管理依赖关系,并且在前端 JavaScript 代码的情况下,我们还希望对其进行压缩和最小化,以优化页面加载速度。手动执行所有这些步骤涉及多个步骤,并且很可能会因为我们忘记了其中一个步骤而在早晚失败,这就是构建系统的作用。在某种程度上,所有软件都有一个构建系统,这取决于系统的自动化程度。

提示

构建系统优化了“无聊性”;构建越无聊,越好。

目标是不犯错误,并且每次都能创建一个可重现的环境。我们希望运行一个命令并获得预期的结果,所以在我们的情况下,构建系统有一些责任:

  • 运行测试

  • 打包应用程序

  • 部署应用程序

所有这些步骤都很重要,所以让我们逐步进行。

运行测试

我们现在正在编写很好的测试,这些测试确保我们的系统按照我们与领域专家确定的功能集的预期行为进行。因此,这些测试应该运行,如果它们失败,那么我们的系统有问题需要修复。由于我们已经有了一个测试框架,运行测试非常简单:

**$ mocha --recursive test**

这将运行在测试目录中指定的所有测试,根据我们之前创建的文件布局,这将是所有测试。由于我们不想记住这个命令,我们可以通过将其添加到我们已经设置的package.json文件中,将其连接到 npm 中:

{
  "name": "dungeon_manager",
  ...
  "scripts": {
    "test": "./node_modules/.bin/mocha --recursive test"
  }
  ...
}

有了这个设置,运行所有测试变得很简单:

**$ npm test**

这将使我们的生活变得更加轻松,现在我们可以依靠一个命令来运行我们的测试,失败肯定是开发失败,而不是命令输错,例如,忘记了--recursive然后跳过大部分测试。根据涉及的开发人员的偏好,我们甚至可以进一步观察文件的更改并重新运行由这些更改触发的测试,这里描述的系统应该被视为最低要求。

打包应用程序

将应用程序移至生产环境很可能不是一个一步完成的过程。Web 应用程序可能涉及将资产编译在一起,下载依赖项,甚至配置某些部分以适应生产环境而不是开发环境。手动运行这些步骤容易出错,之前使用过这种流程的每个开发人员都有一个失败的故事。但是,如果我们希望保持软件的可塑性,并能够对领域的变化做出反应,并且能够迅速将其交给我们的领域专家,我们需要尽早并经常部署,而这的第一步是将应用程序打包成一个步骤。

目标是让每个开发人员能够设置应用程序的基本环境,就像我们的情况下安装 Node.js 一样,然后用一个命令设置应用程序。目前继续使用 npm 来管理我们的任务,我们将以下内容添加到我们的package.json文件中:

{
  "name": "dungeon_manager",
  ...
  "scripts": {
    "test": "./node_modules/.bin/mocha --recursive test",
    "package": "npm install && npm test"
  }
  ...
}

由于这是一个自定义命令,在 npm 运行中没有特殊支持,这意味着运行:

**$ npm run package**

对于外部人来说,这有点不直观,但是在 readme 文件中列出这样的命令目前可以解决这个问题,如果我们愿意,我们也可以决定使用一个系统来包装所有这些调用,使它们保持一致。

现在我们有了一个地方来放置打包应用程序所涉及的任何步骤,我们准备确保我们也可以用一个命令部署它。

部署

正如我们之前所说,我们希望我们的部署过程是一个无聊的过程;它应该是一个步骤,永远不会导致难以恢复的失败。这实际上意味着我们需要能够根据需要回滚部署,否则错误部署的恐惧将对任何进展产生僵化作用。

实际部署可能非常简单,根据您的需求,几个 shell 脚本就可以轻松完成。一个涵盖基本知识的系统,易于使用并适应不断变化的需求的系统是deploy.sh,可以在github.com/visionmedia/deploy上找到。使用 deploy 时,需要创建一个deploy.conf配置文件:

[appserver]
user deploy
host appserver-1.dungeon-1.orc
repo ssh://deploy@githost.dungeon-1.orc/dungeon_manager
path /home/deploy/dungeon_manager
ref origin/master
post-deploy npm run package && npm start

该文件可以扩展为任何应用程序服务器,并且应该非常容易阅读。需要运行的任何步骤都可以实现为预部署或后部署挂钩,这使得该系统非常灵活,特别是当与管理应用程序部分的强大构建系统结合使用时。

选择正确的系统

到目前为止,我们一直在使用默认安装的工具,而没有真正安装大型工具;deploy.sh本身只是一个包含不到 400 行代码的 shell 脚本,npm 默认包含在 Node.js 中。实际上有很多有效的理由来使用环境之外的系统,例如,当您预期项目将来会由多种语言组成时,选择一个中立的包装器可以极大地增加项目之间的一致性,并简化入门。

现在我们知道我们想从系统中得到什么,所以选择一个意味着查看需求并选择大多数开发人员喜欢的系统。要记住的一件事是,这是项目希望长期坚持的东西,所以一个有一些使用经验的系统是个好主意。

提示

我喜欢在大多数项目中使用简单的Makefile,因为它是最常用和理解的系统,但你的情况可能有所不同。

这将我们带到了设置结束的地方,我们考虑文件和运行命令,但缺少一个重要的部分,那就是如何使领域部分真正成为世界的一部分,但又足够分离,以便对其进行推理。

隔离领域

 创建应用程序,使其可以在没有 UI 或数据库的情况下运行自动回归测试,当数据库不可用时工作,并且可以在没有用户参与的情况下链接应用程序。 
 --Alistair Cockburn

当我们创建一个遵循领域驱动设计原则的应用程序时,我们努力将业务逻辑与与“真实世界”交互的软件部分分开。最常引用的情况是,我们不希望以某种方式构建我们的 UI 层,使其也包含部分或全部业务逻辑。我们希望有一个清晰的面向领域的 API,被应用程序的其他部分消耗,以提供它们与领域的交互。

这个概念类似于一些提供的 UI,特定于 UI 的语言或 API,比如 HTML,或者例如 QT。这两者都源于提供开发人员构建 UI 所需的所有部分,但保持自然分离的概念。这是没有意义的,HTML、CSS 和 JavaScript 的 DOM 抽象的组合是 DSL,领域特定语言,用于构建浏览器界面。它们提供了一个抽象,浏览器实现者可以在其下更改他们的实现而不影响每个网站的编写。因此,它们隔离了浏览器供应商的业务领域,显示结构化内容,与创建内容的工作,很可能是你的工作。拥有这样的 API 比直接暴露内部数据结构具有许多优势,正如历史所显示的。

现代应用程序的架构

隔离业务领域的想法已经在软件行业中存在了很长时间,特别是随着核心领域和许多消费者的增长。近年来,首先将服务作为 API 变得越来越可行,这是由于移动设备和网络的重要性日益增加。今天,许多应用程序具有多个接口,例如,在酒店预订中,员工可以访问酒店的状态,将客户移动到不同的房间,通过电话进行预订等。与此同时,客户在线上,通过各种网络门户查看可用选项并进行预订。

在到达之前的几天,用户可能希望通过手机上的移动应用程序访问数据,以确保无论他们身在何处都可以使用。这些只是预订系统的许多访问选项之一,即使现在已经有很多选项:

  • 内部桌面应用程序

  • 内部 Web 应用程序

  • Web 应用程序

  • 其他供应商的 Web 应用程序

  • 移动应用程序

  • 其他供应商的移动应用程序

这已经是一个很长的列表,我们可以预期随着新设备的出现以及不同的使用模式,它将在未来不断增长。

六边形架构

那么,我们如何确保应用程序准备好发展?随着 Web 应用程序的出现和主导地位,开发人员意识到应用程序构建的处理过程与其使用的界面和技术之间存在分歧。这种分歧并不是一件坏事,因为它可以用来在这些点上建立 API,并封装核心概念领域驱动设计所关注的业务领域。实现这一点的一种可能的技术被称为六边形架构

六边形架构

整个应用程序被视为一个六边形,业务领域位于其中。虽然业务领域只关心自己的语言和概念,但它使用端口与所需的任何内容进行通信。端口是与外部世界的接口,并为所需内容和提供方式建立了清晰的 API。另一方面,还有适配器,即提供 API 的元素。这提供了很大的灵活性,不仅允许您交换适配器,例如在测试期间,还可以更快地尝试不同的技术,以找到最合适的技术,而不是猜测,而是实际尝试应用。

应用模式

热心的读者会意识到,我们的地牢管理应用程序与刚刚描述的预订应用程序并没有太大的不同。此外,我们还希望将其与多个 UI 和其他应用程序集成。此外,我们的业务概念足够复杂,以至于我们需要领域驱动设计,因此六边形架构非常适合我们。但我们如何才能实现这一点呢?

首先要意识到的是,到目前为止,我们已经在为此进行设计。我们的核心功能在数据库或 Web 框架的上下文之外被理解。六边形架构和领域驱动设计的理念毕竟非常契合。我们现在继续前进,清晰地分离业务领域包含的内容和外部提供的内容。这也被称为持久性无知,因为我们希望我们的领域忽略处理保存和加载数据的层。作为这种模式的一部分,我们创建单独的对象或模块,封装我们领域的操作,并在将来需要时将其集成到 Web 框架中以及公开为 API。

抽象并不是免费的;根据应用程序的不同,过度抽象数据层可能会引入性能开销,这可能是无法应付的。另一方面,如果您的领域与数据层的交互频率如此之高,可能领域本身存在问题,您可能需要重新思考领域层中的聚合。我们必须像滑块一样思考这种模式,而不是布尔值;我们可以根据领域以及应用程序性能的需求增加和减少抽象。

插入一个框架

那么,我们如何让这对我们的应用程序起作用呢?我们将要构建的第一个版本旨在具有 Web UI,因此我们需要插入一个 Web 框架,这样我们就不必重新发明轮子。Node.js 提供了许多选项,最流行的是express.js,我们已经使用过了,所以我们想要的是让 express 做它最擅长的事情,即处理请求,而我们的核心领域处理这些请求的逻辑。

让我们看一个例子:

app.post("/prisoner_transfer", function(req, res) {
  var dungeon = Dungeon.findById(req.params.dungeonId)
  var prisoner = Prisoner.findById(req.params.prisonerId)

  prisonerTransfer(prisoner, dungeon, function(err) {
    var message
    if(err) {
      res.statusCode = 400
      message = { error: err.message }
    } else {
      res.statusCode = 201
      message = { success: true }
    }
    res.end(JSON.stringify(message))
  })
})

管理囚犯转移的代码被很好地封装在自己的模块中,并且只与领域对象进行交互。另一个问题是代码应该放在哪里。在这个早期阶段,这样的代码可能仍然可以放在一个index.js文件中,提供接口,但随着项目的进展,我们可能会朝着一个包含将领域与 express 框架连接的粘合代码的更模块化的架构发展。在这个阶段,我们甚至可以创建一个中间件层,以自动注入我们需要的依赖关系。

总结

在本章中,我们已经开始了项目,并且进展顺利。我们已经做好了一切准备,使项目能够进展,并为随之而来的变化做好准备。再次强调,主要思想始终是关于隔离,并确保我们思考和解决领域的问题,而不会在这一过程中迷失在语言和框架的复杂性中。

大多数程序员都会同意,集成系统和建模数据是两项确实需要专注的任务,而通过这种设置,我们正在迈出实现这种集成的重要一步。与此同时,这种架构使我们能够继续对数据进行建模,就像我们之前开始的那样。

在下一章中,我们将更详细地讨论领域对象本身以及在领域驱动设计术语中对它们进行建模的含义。我们将介绍术语来对这些模型进行分类,并使用领域驱动设计与面向对象的方法来推动它们。

第四章:建模参与者

我们现在已经准备好全力投入开发工作,我们已经建立了一个坚实的结构,以帮助我们处理即将出现的变化。是时候更多地考虑我们系统的不同组件以及它们是如何相互作用的了。

系统中的交互发生在多个层面。操作系统与语言运行时进行交互,运行时与我们的代码进行交互,然后在我们的代码内部,我们创建对象进行回调和调用其他进程等。我们已经看到我们的领域对象如何与底层框架进行交互,我们可以想象代码如何调用不同的库。在构建交互时,了解存在的接缝并在必要时创建新的接缝是很重要的。在调用其他代码时,很明显我们的代码在哪里结束,库代码在哪里开始。在创建代码时,很容易混淆责任,但我们能够更好地分离它们,我们就能更好地发展我们的代码。

几乎计算机科学的所有方面都以某种方式处理不同组件之间的交互,因此存在多种技术来确保这些交互良好地进行。在本章中,我们将重点关注系统的参与者及其交互,并将详细介绍:

  • 使用面向对象编程技术来对领域参与者进行建模

  • 在隔离中测试领域对象

  • 在领域中识别和命名角色

巨人的肩膀

关于如何建模和处理交互的最著名的模型之一是描述网络堆栈中各层交互的 OSI/ISO 模型。它包括七层,每一层都有一个明确定义的接口,可以由上一层进行通信,并与下一层进行通信。此外,每一层都定义了一个协议,允许它与同一级别的层进行通信。有了这个,就有了一个非常清晰的 API 来与层进行通信,也清楚了层如何回调到系统,因此很容易替换系统的部分。下图显示了 OSI/ISO 模型中的描述。每一层都由一个协议定义,允许每一侧的实例在其层进行通信,随着堆栈的上升,协议由给定的实例进行包装和解包:

巨人的肩膀

当然,这种模型并没有被全面采用,TCP/IP 专注于五层,甚至有人说过分的分层可能是有害的。但即使那些不赞成 OSI/ISO 模型的人也认为基本思想是有价值的,隔离通信是使互联网运行的基础之一。每一层都是可替换的,无论是完全替换还是只针对特定情况,这对任何系统来说都是一件强大的事情。

将这一点引入到建模应用程序的世界意味着我们的对象应该在业务领域的层面进行通信。在领域驱动设计方面,允许一个聚合与其他聚合进行交互以实现其目的是可以的,但是一个服务在不考虑聚合的情况下直接访问实体是不可以的。在不考虑适当的 API 的情况下访问应用程序的不同部分会导致两个模型之间的耦合。在我们的地牢中,让外来地牢的地牢主直接与囚犯进行交流也是一个坏主意,这会让囚犯被标记为间谍并立即被杀害,这不仅会因为紧密耦合而导致问题,还会让应用程序暴露于安全问题。例如,由于模型访问数据库直接获取 HTTP 请求中传递的数据而没有层来减轻访问,曾经发生过许多 SQL 注入攻击的实例。

这样的通信,其中一个对象与对象图的另一部分进行通信,忽略了门控接口,这是一个众所周知的问题,并被确定为迪米特法则,该法则规定:

每个单元应该只对其他单元有限的了解:只有与当前单元“密切”相关的单元。
--迪米特法则

通常这在面向对象的语言中被改述为一个方法只应该有一个点。例如,像兽人大师上的以下方法违反了这一点。以下代码显示了通过深入到地牢及其后代控制的对象中实现了一个获取地牢中可用武器的访问器:

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

在这种情况下,兽人大师通过其地牢直接接触每个兽人,并直接询问他的武器类型。这不仅将兽人大师绑定到地牢的内部实现,还绑定到兽人甚至武器本身;如果这些元素中的任何一个发生变化,方法也必须发生变化。这不仅使对象本身更难以改变,而且整个系统现在更加僵化,在重构下不太灵活。

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

过程式代码获取信息然后做决定。面向对象的代码告诉对象要做事情。
--Alec Sharp

通信不应该随意跨越边界,而应该以明确定义和合理的方式保持软件的可塑性。这也意味着在开发软件时,我们需要意识到组件和接口,像我们已经做过的那样识别它们,并意识到新的组件和接口从我们正在编写的代码中出现。对于在我们的领域中发送的命令和事件代表的消息也是如此。

即使在非常集中地思考正在开发的软件并绘制像我们已经做过的那样的图表时,几乎不可避免地会错过某些在开发开始时变得清晰的抽象。我们编写的代码和测试应该使接口清晰,并利用这一事实的一种常见方式是尽早执行正在开发的代码,并让它“告诉”你关于它的依赖项。

开发的不同方法

现在我们正在编写代码来解决领域中的问题,我们可以以不同的方式来解决问题:一种方式是从我们迄今为止发现的最高级别开始,让这指导我们下到我们的较低级别对象和抽象,或者我们可以从我们识别的组件开始,完善它们并建立系统。这两种方法都是有效的,通常被称为“自外向内”或“自内向外”开发。自内向外的优势在于我们始终有一个运行的工作系统,因为我们首先构建依赖项并建立系统。缺点是很容易失去对整体情况的视野,并在细节中迷失方向。

这些方法的共同之处在于它们遵循基于测试驱动开发的风格。我们正在构建测试来指导设计,并在完成时向我们展示。我们首先使用我们的代码来感受它以后的行为,并实现我们认为行为应该是什么。这可以通过首先专注于小而容易理解的组件来获得对它们的信心,就像自内向外方法中所做的那样。另一种方法是在开始时提出重要问题,随着我们的深入,逐渐进入更多细节,就像自外向内方法中所做的那样。

对于这个项目,从外部开始可能更合适,因为我们探索并了解了利益相关者的需求,但对于确切的组件及其行为并不太清楚;毕竟我们处在一个我们并不完全熟悉的世界中。特别是在一个陌生的世界中,我们很容易开始构建我们从未需要过的部分。现在我们对例如地牢之间的消息系统了解不多。我们可以开始尝试在这里构建一个抽象,让我们尽可能地控制,但另一方面,结果可能是我们每周只发送一条消息,并且让地牢主人手动完成这个任务是完全合理的。在这种评估中,我们必须牢记我们的总体目标应始终是提供价值和节省金钱,这可能意味着构建东西。那么我们如何在没有基础结构的情况下创建软件呢?

介绍模拟对象

在从外部建模系统时,需要让对象代表最终将成为较低级别实现的对象。这在每个级别上都会发生,而首先对 API 进行建模的概念会向下渗透到较低层。我们之前开始构建了囚犯转移服务,依赖于囚犯和地牢;这些又将有依赖项,当完善对象时,需要以类似的方式设计。

实现这一点的对象称为模拟;它们是提供某个概念的静态实现并断言它们是否被正确调用的对象。模拟实现了某个对象应该遵循的协议。在 JavaScript 这样的动态语言中,这既容易又困难。不同的 JavaScript 测试框架以不同的方式处理这个问题;有些使用如上所述的模拟对象,而有些提供间谍,它们调用真实对象但监视这些调用的正确性。这两种方法都很有效,各有优势。

提示

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

创建模拟可以很简单:

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

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

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,它提供了许多不同的方法来验证功能,实现存根、模拟和间谍。结合我们的测试框架 Mocha,我们可以通过创建我们想要模拟的对象,并让 Sinon.JS 来进行验证的繁重工作,来创建一个模拟测试。现在我们可以用 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()
  })
})

模拟的概念表面上很简单,但它的使用可能很困难,因为很难发现模拟的正确位置在哪里。

提示

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

模拟的原因和不模拟的原因

我们最初的描述过于关注实现,关键的想法是该技术强调了对象在彼此之间扮演的角色。
--模拟对象的简史-Steve Freeman

模拟对象在测试期间代替系统中的其他对象,有时甚至在开发期间也是如此。有多种原因可以这样做,例如底层结构尚未实现,或者调用在开发过程中的时间成本或者甚至是通过调用按调用次数收费的 API 而非常昂贵。对于开发人员来说,能够离线运行测试非常方便,还有更多的原因可以解释为什么有人不想调用真实系统而是调用其替代物。

这种做法通常被称为stubbing out外部依赖关系。当与对这种依赖关系进行断言相结合时,这个存根就成为了模拟对象,在开发过程中通常有助于确保某些代码在正确的时间被正确调用,当然也有助于测试。

很容易陷入创建非常具体的模拟对象的陷阱,模拟其他对象的内部依赖关系等。要牢记的重要事情是,模拟应始终代表系统中的一个角色。现实世界中的各种其他对象可以扮演这个角色,但它们可以在一个模拟中表示。在经典的面向对象术语中,这意味着我们模拟接口而不是类。在 JavaScript 中,没有接口,所以我们需要选择合适的对象进行模拟。我们的模拟对象或对象的一部分只需要表示测试所需的内容,而不需要其他。当我们通过测试驱动我们的设计时,这是很自然的,但随着软件的发展和变化,我们需要注意这一点,因为变化可能导致我们的测试通过模拟过度规定一个对象。

谁参与了囚犯的转移?

在前面的部分中,我们在领域中进行了大量探索,以了解使系统中的操作发生所必须做的事情。有了这些知识,我们现在可以清楚地概念化囚犯转移应该如何进行。我们之前创建的测试指定了一些行为和我们在领域中知道的协作者。我们将它们表示为基本的 JavaScript 对象,其中包含满足测试所需的属性;例如,我们知道地牢需要一个消息收件箱来通知,但我们还不知道囚犯的任何属性。以下代码提供了一些简单的函数,让我们描述我们正在使用的对象类型,随着代码的增长和我们对囚犯或地牢的了解得到巩固,我们可以填写这些内容,以继续在测试期间代表相应的对象。

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

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

到目前为止,囚犯和地牢都是特定的 JavaScript 对象,只是为了代表我们此刻需要的东西。进一步了解细节,还涉及其他参与者,即在途中看守囚犯的兽人,以及转移马车。当然,这些又有依赖关系:马车由驾驶员、作为囚犯移动牢房的木制马车以及拉动它的马组成。所有这些部分都是我们需要获取的潜在稀缺资源。这就是域建模再次发挥作用的地方;在我们的应用程序的上下文中,我们可以不把它们看作独立的东西,因为如果其中任何一个缺失,整个对象将无法正常运行。我们可以专注于不同对象扮演的角色,并将它们作为聚合物获取,以符合我们的模型。

不同的对象及其角色

马车是其中一个描述的角色;我们现在不关心马车由什么组成,而是将其视为在我们的系统中实现某种目的的一个东西。马车作为一个整体是一个聚合体,我们现在只想从外部检查它,不太关心它的内部。在这里,马车的公共 API 显示了一个我们在建模时需要考虑的接缝。例如,我们可能会在以后关心马匹作为一个单独的东西,以建模一个信使,我们希望为马车和信使都分配马匹。

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

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

根据领域命名对象

在计算机科学中只有两件难事:缓存失效和命名事物。
--Phil Karlton

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

之前,我们介绍了马车的概念,包括马车本身、拉车的马匹和驾驶员。这是一个根据领域命名概念的例子。在兽人地牢的世界中,马车的概念非常清晰,运行所需的东西也很清晰。通过使用系统利益相关者的语言,我们增加了团队的语言能力,使所有利益相关者都能参与其中。在确定领域的部分时,我们确保继续增加语言,同时创建抽象。这使我们能够将某些细节隐藏在一个常见的角色后面。

常见名称陷阱,比如*Manager

我们介绍的马车概念是一个很好的抽象;然而,作为软件开发人员,我们很容易重复使用我们在其他应用程序中看到的元素。在命名角色时,很容易陷入一种模式。我们经常看到只是因为缺乏更好的名称而存在的Manager对象:

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

即使这个对象承担的责任与我们之前命名为“马车”的对象相同,但通过名称找出它的功能已不再明显。即使团队中的开发人员清楚这个对象的用途,其他利益相关者也会感到困惑。这会导致团队内部的分裂,并不会促进非开发人员参与开发过程。

将对象命名为经理通常意味着根据它目前的功能而不是它在系统中的角色来命名它。以这种方式命名对象使得很难将其中的细节抽象出来。了解Manager对象的功能总是意味着知道它在管理什么以及它的内部细节如何运作才能理解它。抽象泄漏到系统的其他部分,每个使用经理的人都会查看它所管理的部分和细节。

管理对象的痛苦在编写测试的情境中往往变得非常明显。当我们想要测试一个管理器,而我们看不到一个清晰的抽象时,我们需要关心内部依赖关系,因此需要在我们的测试中保持它们。这使得测试看起来复杂,设置开始超过实际的断言部分。通过以通用角色命名的对象,我们得到了为非常通用的角色提供服务的对象,因此远离了特定于领域的对象。这可能会带来痛苦,因为这些通用对象只能通过其内部实现来具体化,因此它们是应该扮演的角色的不良代表。

提示

当你在为一个对象想不出名字时,试着先给它取一个明显愚蠢的名字,然后让对领域的探索引导你找到一个更具体和有意义的名字。

方法名的可读性

面向对象编程OOP)中,对象保存数据,并负责与其保存的数据最密切相关的操作。操作数据的函数,如从对象的内部状态计算新数据的函数,称为查询。这样的函数的例子是计算复合数据的函数,例如根据其设置的名和姓计算兽人的全名:

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
}

随着命令改变其内部状态,需要非常清楚地知道发生了什么,并且对象应尽可能多地控制在命令的情况下实际要做什么,因此命令告诉对象要做什么,而不是询问它的状态以修改它。实现这一点意味着我们希望指示对象完成任务,而不是检查其属性。相反的是检查对象属性,并基于这些属性做出决定,代替负责属性的对象。告诉,不要问原则是面向对象编程的重要原则。前面的例子遵循了这个概念,通过不创建一个 setter 来attack属性,我们确保Orc对象控制其内部状态。使特定于域的命令读起来像它们所做的那样,而不是创建大量的 setter/getter 方法,有助于可读性,同时确保状态得到良好管理。在面向对象的方法中,我们希望对象负责其状态和操作该状态的方法。

对象不仅是一致命名方案的一部分,允许我们对领域进行建模。当我们在建模功能时,希望它读起来清晰,我们还需要使方法名可读。在前面的例子中,TransportManager的唯一方法是initializeTransport,它或多或少地重复了对象的名称。当对象是ManagersExecutors等时,这种模式非常常见,但它并不有助于可读性。这与创建在设置对象的上下文之外调用的 setter 方法一样。方法需要告诉命令做什么。

一个以系统中的角色命名的对象可以更好地提高方法的可读性。域名Carriage使方法名transport更容易理解,因为它自然地与领域中的马车概念联系在一起。

有了这一切,现在到了我们需要考虑如何对对象进行建模以便于测试和开发的时候了。

首先是对象

在构建地牢管理器时,我们致力于创建一个易于维护和可扩展的软件。面向对象编程的核心原则在处理对象时有助于我们,但是当涉及到面向对象编程时,JavaScript 是特殊的。

正如许多 JavaScript 程序员肯定已经听说的那样,JavaScript 使用原型继承,更重要的是,它实际上没有类的概念,只有实例。

提示

尽管 JavaScript 的下一个版本ECMAScript 6引入了class关键字,但核心语言设计并没有改变。类实际上只是 JavaScript 当前原型继承的一种语法糖。如果想了解更多关于 ES6 的信息,请关注 Alex Rauschmayer 的博客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 中的函数是封装其内部状态以及它们在创建时引用的任何变量的对象。由于这些属性,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 Parts》,作者 Douglas Crockford,O'Reilly 出版社。

继承以及为什么你不需要它

当然,仅仅构建类和它们的使用只是成为面向对象语言的一部分。特别是在 Java 中,构建相当复杂的继承层次结构是非常常见的,允许共同功能在对象之间共享。

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

建模模式超越继承

 更倾向于'对象组合'而不是'类继承'。 
 --《设计模式》1995:20

尽管在 JavaScript 中可以实现继承,但这并不一定是设计应用程序时最好的方法,就像《四人帮》中所说的那样。继承在父类和子类之间创建了非常强的联系;这本身意味着系统的某些部分泄漏了不应该知道的知识。继承是两个对象之间最强的耦合形式,耦合本身应该是一个非常慎重的选择。深层次的继承树很快会使软件变得非常难以改变,因为改变往往会在整个系统中产生连锁反应。还有一个更大的问题——由于 JavaScript 不会对接口和关系进行编译时检查,所以这些部分很容易不同步,导致系统中出现错误,而在更静态的语言中则不会出现这种情况。

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

对象组合

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

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

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

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

委托给原生类型通常会经常发生,但这对于程序员创建的所有其他对象来说都是如此。这种组合有一个很大的优势,特别是在依赖项被传入时,它可以很容易地进行测试,就像刚才给出的例子中,我们可以在测试中用确保正确调用的东西替换存储对象。

没有继承的多态性

 当我看到一只走起来像鸭子、游泳像鸭子、嘎嘎叫的鸟时,我就称那只鸟为鸭子。 
 --Michael Heim

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

现在我们可以将SwordMaster类或AxeMaster类传递给需要的人,以便兽人保护他们:

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

在支持鸭子类型的语言中不需要这种开销。在 JavaScript 中,我们可以直接写这个,而不需要接口,两个兽人可以直接作为普通的 JavaScript 对象,就像下面的例子中所示:

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

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

被守护的“大师”对象现在可以直接调用每个守卫所需的方法,而无需匹配类型:

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 的非常动态的特性和鸭子类型的可用性,继承的需求很大程度上被消除了。

将对象设计应用到领域中

在理解了概念性对象设计之后,我们需要将所有概念应用到我们的领域中。我们继续对我们开始的囚犯转移进行建模。到目前为止,我们有一个应用模块的入口点,最终将处理这个问题。从测试中,我们知道囚犯转移依赖于囚犯和地牢对象。

在简单对象上构建系统

因此,让我们来看看囚犯转移需要做什么,以及它的合作者是谁。之前,我们确定囚犯转移显然需要囚犯和目标地牢进行转移,而囚犯转移应该管理其他一切。重要的是要考虑从用户角度来看最小的输入是什么,以限制 API 的表面。

当然,囚犯转移,在领域驱动设计中是一个服务,需要更多的合作者来真正实现其目的。首先是参考本地地牢以获取资源,例如兽人充当看守,马车运送囚犯,可能还有其他资源。管理转移的目标还包括通知其他地牢,因此我们还需要通知它们的手段。

正如我们在前几章中发现的那样,通知的概念尚未被很好地理解,因此我们现在可以假设将有一个服务,允许我们向目标发送消息,出于特定的原因。我们可以针对消息服务的抽象进行编程,从而进一步指定我们将从系统中需要什么。将所有这些结合起来并加以完善,将我们带到了以下结论:

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 的动态特性,为我们尚未创建的对象创建了简单的存根,并且我们能够验证我们在先前研究中讨论的第一个理解。

在下一章中,我们将进一步探讨系统中的其他角色。我们将专注于用领域驱动设计术语对它们进行分类,以便我们可以重用空间中其他人探索的模式。我们还将更加关注语言,以促进进一步的沟通,以及如何与这些模式一起工作,以在领域中实现非常清晰的沟通。

第五章:分类和实施

根据 IBM 的一项研究(www-935.ibm.com/services/us/gbs/bus/pdf/gbe03100-usen-03-making-change-work.pdf),只有 41%的项目能够达到其进度、预算和质量目标。项目的成功或失败在很大程度上并不取决于技术,而是取决于参与其中的人。

想象一个软件项目,每个开发人员始终了解项目各个部分决策制定过程的复杂性。在这个理想的世界中,开发人员可以始终做出明智的决定,只要没有开发人员想要积极损害项目,决策就会是合理的。如果做出了错误的决定,在整体规划中不会造成巨大问题,因为接下来接触该项目部分的开发人员将知道如何修复它,并且也会了解所有涉及的依赖关系。这样的项目从项目角度来看极不可能失败。然而,悲哀的事实是,世界上几乎没有这样的项目,这很可能是因为这样的系统会带来整个团队需要审查每一次变更的额外开销。

这可能适用于非常小的项目,很可能是一个只有少数工程师的初创公司,但随着项目的增长,它根本无法扩展。当功能和复杂性增长时,我们需要分解项目,正如我们已经看到的,小项目比大项目更容易处理。分解并不容易,所以我们需要找到项目中的接缝,我们还需要意识到并决定项目整体的治理模式,以及子项目或子域。

在开源世界中,Linux 内核项目是一个很好的例子,它最初只有少数开发人员,但从那时起不断增长。自从超出了一个人在任何时间点都能掌握的规模后,内核就分裂成了子项目或子域,无论是网络处理、文件系统还是其他方面。每个子项目都建立了自己的领域,项目的增长建立在每个子项目都会做正确的事情的信任基础上。这意味着项目会分裂,因此一个开放的邮件列表可以让人们讨论有关整体架构和项目目标的话题。为了促进这一点,该邮件列表中使用的语言非常专注于社区的需求。否则,每次都详细解释会导致完全偏离重点的大讨论。

在本章中,我们将详细介绍如何在不断增长的项目中利用领域驱动设计,特别是:

  • 使用和扩展项目的语言

  • 管理领域及其子领域的上下文

  • 领域驱动项目的构建块,聚合、实体、值对象和服务

建立一个共同的语言

我们无法让每个开发人员始终了解整个项目,但我们可以通过建立项目内部共享的通用语言,使决策非常清晰,结构非常直观。熟悉项目中使用的语言的开发人员应该能够弄清楚陌生代码的作用以及它在整个系统上下文中的位置。即使在一个领域中项目增长并且子领域的语言变得更加突出并开始更专注于子领域的特定部分,保持整体结构也是很重要的。作为一个子领域的开发人员,当我看到另一个子领域时,我不应该感到迷失,因为总体领域的语言为我提供了一个全局上下文。

到目前为止,我们一直在通过从业务领域中获取单词并在应用程序中使用它们来建立一个共同的语言。业务专家能够大致了解每个组件的内容以及组件之间的交互方式。随着我们的发展,构建这种语言也很重要,开发人员可以为业务领域贡献新的单词,以消除元素的歧义。

这些贡献不仅对开发人员有价值,因为他们现在能够清楚地传达某个元素是什么,而且对业务专家也有益,因为他们现在也能更清楚地传达信息。如果一个术语很合适,它将被领域所采纳;如果不合适,那么在大多数情况下最好放弃它。为了做到这一点,我们必须首先意识到我们可以使用哪些模式,并使用已经提供给我们的术语来影响我们在整个过程中使用的语言。

对象分类的重要性

开发人员喜欢对事物进行分类,就像我们之前讨论为什么命名类似SomethingManager是有害的时候所看到的那样。我们喜欢对事物进行分类,因为这样可以让我们对我们正在处理的对象做出一些假设。描述某个元素的目的不仅在业务领域中存在问题且容易出错,而且在编程领域中也是如此。我们希望能够快速地将代码的某些部分与特定问题关联起来。虽然通用语言解决了业务领域中的这一部分,但我们可以借鉴模式来更好地描述我们的编程问题。让我们来看一个例子:

开发者 1:嗨,让我们谈谈代码,以便在我们的领域对象和持久性之间进行转换。

开发者 2:是的,我认为这里有很大的优化空间。我们有没有看过外部公司提供的东西?

开发者 1:是的,我们有,但我们有非常特殊的需求,而常见的可用替代方案似乎对我们来说性能不够好。

开发者 2:哦,好的。我印象中我们自己开发的版本在处理线程方面有问题,整体性能也不太好。

开发者 1:我认为我们不需要在这里讨论线程,这应该在更低的层次处理。

开发者 2:等等,我们现在不是在讨论数据库连接吗?你想要降到多低的层次?

开发者 1:不,不!我在谈论将领域对象转换为数据库对象,因为我们需要将字段转换为正确的类型和列名等等。

开发者 2:哦,在这种情况下,你找错人了。对这部分我不太熟悉,抱歉。

当糟糕的命名潜入项目时,这种对话很可能会发生。开发者 1 谈论的通常被称为数据映射器模式,而开发者 2 谈论的是数据库 API。拥有通常被接受的名称不仅使对话变得更容易,而且还让某些开发人员更容易地表达他们对代码的哪一部分更熟悉或不太熟悉。

模式最常用于命名编程技术,例如,数据映射器模式描述了处理对象与它们对数据库的持久性之间的交互的一种方式。

数据映射器执行持久数据存储和领域对象或类似它的内存数据表示之间的双向数据传输。它在企业应用架构模式Martin FowlerPearson中被命名。

在领域驱动设计中,我们也有处理某些类型的对象及其关系的特定方式。一方面,有组织开发本身的模式,另一方面有为实现特定目的的对象命名。这种分类就是本章的主题。我们通过将领域元素转化为特定领域驱动设计概念的具体实现来建立对某些领域元素的理解。

看到更大的图景

在处理大型项目时,最常见的问题是弄清楚设计背后的指导思想是什么。当软件变得庞大时,项目很可能由多个项目组成,实际上被分割成子项目,每个子项目负责自己的 API 和设计。在领域驱动设计方面,有领域和子领域。每个子领域都有自己的上下文;在领域驱动设计中,这就是有界上下文。独立的上下文以及主领域和其子领域之间的关系将知识整合在一起,形成一个完整的整体。

提示

在服务器端,有一个朝着面向服务的架构的趋势,这将项目的某些元素分割得非常彻底,将它们分离成不同的组件,分别运行。

在 Java 中,一直有定义自己可见性的包的概念。JavaScript 在这方面有些欠缺,因为所有的代码传统上都在一个线程下在浏览器中运行。当然,这并不意味着一切都完了,因为我们可以通过约定分隔命名空间,而像npmbrowserify这样的工具现在也使我们能够在前端使用类似后端的分离。

支持查找代码的某些部分的过程,以及找出可以在领域的不同部分之间共享的内容,是一个问题,不同的语言已经以多种方式解决了这个问题。由于 JavaScript 非常动态,这意味着语言本身从来没有一种严格的方式来强制执行某些部分的隐私,例如private这样的关键字。然而,如果我们选择这样做,隐藏某些细节是可能的。以下代码使用了 JavaScript 模式来定义对象中的私有属性和函数:

function ExaggeratingOrc(name) {
  var that = this
  // public property
  that.name = name

  // private property
  var realKills = 0
  // private method
  function killCount() {
    return realKills + 10
  }

  // public method using private method
  that.greet = function() {
    console.log("I am " + that.name + " and I killed " + killCount())
  }

  // public method using private property
  that.kill = function() { // public
    realKills = realKills + 1
  }
}

var orc = new ExaggeratingOrc("Axeman Axenson")
orc.killCount() // => TypeError: Object #< ExaggeratingOrc> has no method 'killCount'
orc.greet() // => I am Axeman Axenson and I killed 10

这种编码风格是可能的,但并不是很惯用。在 JavaScript 中,程序员倾向于相信他们的同行会做正确的事情,并假设如果有人想要访问某个属性,他或她一定有充分的理由。

提示

经常提到的面向对象的一个很好的特性是它可以隐藏实现的细节。根据你工作的环境不同,隐藏细节的原因也常常不同。虽然大多数 Java 开发人员都竭尽全力防止他人触及“他们”的实现。大多数 JavaScript 开发人员倾向于将其解释为其他开发人员不需要知道事物是如何工作的,但如果他们想要重用内部部分,他们是自由的,并且必须应对后果。很难说在实践中哪种方式更好。

在更高层次的抽象中也是如此;可能会隐藏很多细节,但一般来说,包往往是相当开放的,暴露内部结构,如果程序员想要访问的话。JavaScript 本身及其文化并不适合有效地隐藏细节。我们可以尽最大努力实现这种效果,但这将违背人们对软件的期望原则。

尽管完全隐藏许多细节很困难,但我们仍然需要在我们的应用程序中保持一致性。这就是我们使用聚合的目的,它封装了一组功能,通过一个连贯的接口来公开。对于我们的领域驱动设计,我们需要意识到这一点;通过使用正确的语言和模式,我们需要引导其他程序员通过我们的代码。我们希望通过一致的命名和通过测试解释某个功能所在的级别来在正确的情况下提供正确的上下文。当我们将软件的某些部分分类为聚合时,我们向下一个开发人员表明,访问功能的安全方式是通过这个聚合。牢记这一点,尽管仍然有可能深入内部并检查内部细节,但只有在有很好的理由的情况下才应该这样做。

值对象

在处理各种语言中的对象时,包括 JavaScript,在几乎所有情况下,对象都是通过引用传递和比较的,这意味着传递给方法的对象不会被复制,而是传递了它的指针,当比较两个对象时,比较它们的指针。这不是我们对对象的思考方式,尤其是值对象的思考方式,因为我们认为如果它们的属性相同,那么它们就是相同的。更重要的是,当我们考虑诸如相等性之类的事情时,我们不想考虑内部实现细节。这对使用对象的函数有一些影响;一个重要的影响是修改对象实际上会改变系统中的每个人,例如:

function iChangeThings(obj) {
  obj.thing = "changed"
}

obj = {}
obj.thing // => undefined
iChangeThings(obj)
obj.thing // => "changed"

与此相关的是,比较并不总是产生预期的结果,就像在这种情况下:

function Coin(value) {
  this.value = value
}

var fiftyCoin = new Coin(50)
var otherFiftyCoin = new Coin(50)

fiftyCoin == otherFiftyCoin // => false

尽管这对我们作为程序员来说可能是显而易见的,但它实际上并没有捕捉到领域中对象的意图。在现实世界中,拥有两个价值为 50 美分的硬币并认为它们不同并不方便,例如,在支付领域。对于商店来说,接受某个特定的 50 美分硬币而拒绝另一个硬币是没有意义的。我们希望通过它们所代表的价值而不是物理形式来比较我们的硬币。另一方面,收藏家对这个问题的看法会大不相同,对他们来说,某个 50 美分硬币可能价值连城,而普通的硬币则不是。对象的比较和它们的标识总是必须考虑到领域的上下文。

如果我们决定通过其属性值而不是其固有标识来比较和识别软件系统中的对象,我们就有了值对象的一个实例。

值对象的优势

传递并可以修改的对象可能会导致意外行为,并且根据领域的不同,通过标识比较对象可能会产生误导。在这种情况下,声明某个对象为值对象可以在未来节省大量麻烦。确保对象不被修改反过来使得更容易推理与之交互的任何代码。这是因为我们不必查看下一行的依赖关系,因为我们可以直接使用对象。

JavaScript 内置了对这些类型的对象的支持;使用Object.freeze方法将确保对象在被冻结后不会发生任何更改。将这添加到对象的构造中将使我们确信对象始终会按我们期望的方式行事。以下代码使用freeze构造了一个不可变的值对象:

"use strict"

function Coin(value) {
  this.value = value
  Object.freeze(this)
}

function changeValue(coin) {
  coin.value = 100
}

var coin = new Coin(50)
changeValue(coin) // => TypeError: Cannot assign to read only property 'value' of #<Coin>

提示

JavaScript 的一个值得注意的补充是use strict指令。如果我们不使用这个指令,对值属性的赋值将会悄悄失败。即使我们仍然可以确保不会发生任何更改,这也会导致代码中出现一些茫然的表情。因此,即使在本书中大部分时间都没有提到,为了使代码示例简短,强烈建议使用use strict。例如,您可以使用JSLint来强制执行此操作(www.jslint.com/)。

在处理值对象时,还可以提供一个函数来比较它们,无论在当前领域中意味着什么。在硬币的例子中,我们希望通过硬币的价值来比较它们,因此我们提供了一个equals函数来实现这一点:

Coin.prototype.equals = function(other) {
  if(!(other instanceof Coin)) {
    return false
  }

  return this.value === other.value
}
}

var notACoin = { value: 50 }
var aCoin = new Coin(50)
var coin = new Coin(50)

coin.equals(aCoin) // => true
coin.equals(notACoin) // => false

equals函数确保我们正在处理硬币,并且如果是的话,检查它们是否具有相同的价值。这在支付领域是有意义的,但在其他地方可能并不一定成立。重要的是要注意,某些领域中的某些东西是值对象并不意味着这在普遍意义上也是如此。当处理组织内部项目的关系时,这一点变得特别重要。可能需要对类似的事物进行不同的定义,因为它们在不同的应用程序中以不同的方式被看待。

提示

前面的代码使用了对象的__proto__属性,这是一个内部管理的属性,指向对象的原型,是 JavaScript 的一个最近的补充。尽管这非常方便,但如果必要的话,我们总是可以通过Object.prototype(对象)来获取原型,如果__proto__不可用的话。

当然,仅仅有一个比较的方法并不意味着每个人都会在所有情况下使用它,而 JavaScript 也没有提供强制执行的方法。这是一个领域语言能够帮助我们的地方。传播关于领域的知识将使其他开发人员清楚地知道什么应该被视为值对象,以及比较它的方法。在使用类并需要向下一个人提供一些细节时,这可能是一个不错的主意。

引用透明度

我们一直在使用的Coin对象还有另一个有趣的属性,这对我们的系统可能很有用,那就是它们是引用透明的。这是一个非常花哨的说法,意思是无论我们对硬币做什么,它在应用程序的每个部分都会被视为相同。因此,我们可以自由地将其传递给其他函数,并保持它,而不必担心它的变化。我们也不需要跟踪硬币作为依赖项,检查在传递它时可能发生了什么,或者它可能被其他函数改变了。下面的代码说明了构造为值对象的硬币对象的简单用法。即使代码依赖于它,我们也不需要特别小心地与对象交互,因为它被定义为不可变的值对象。

Orc.prototype.receivePayment = function (coin) {
  if (this.checkIfValid(coin)) {
    return this.wallet.add(coin)
  } else {
    return false
  }
}

在前面的例子中,只有一个依赖项 - 钱包的保存操作,如果Coin是一个值对象,那么情况就会复杂得多。Coin对象是一个实体的话,checkIfValid函数可能会改变属性,因此我们必须调查内部发生了什么。

值对象不仅使代码流程更容易跟踪,引用透明性在处理应用程序生命周期内的缓存对象时也是一个非常重要的因素。尽管 JavaScript 是单线程的,所以我们不必担心对象被其他线程修改,但我们已经看到对象仍然可以被其他函数修改,它们也可能因其他原因而改变。有了值对象,我们就不必担心这一点,因此我们可以自由地将其保存起来,以后需要时引用它。在函数之间,可能会发生事件导致我们当前正在处理的对象被修改,这可能会使跟踪错误变得非常困难。在下面的代码中,我们看到了EventEmitter变量的简单用法,以及如何使用它来监听"change"事件:

var events = require("events")
var myEmitter = new events.EventEmitter()

var thing = { count: 0 }

myEmitter.on("change", function () {
  thing.count++
})
function doStuff(thing) {
  thing.count = 10
  process.nextTick(function() {
    doMoreStuff(thing)
  })
}

function doMoreStuff(thing) {
  console.log(thing.count)
}

doStuff(thing)
myEmitter.emit("change")
// => prints 11

仅查看doStuffdoMoreStuff函数,我们期望在控制台上看到 10 被打印出来,但实际上打印出了 11,因为事件change是交错的。在前面的例子中这是非常明显的,但是这样的依赖关系可能深藏在代码内部,跨越更多的函数。值对象使相同的错误变得不可能,因为对象的更改将被禁止。当然,这并不是异步编程中所有错误的终点,需要更多的模式来确保这样的预期工作;对于大多数用例,我建议查看async(github.com/caolan/async),这是一个帮助处理各种异步编程任务的库。

作为实体定义的对象

正如我们所见,通过其属性主要定义对象可以非常有用,并帮助我们处理系统设计中的许多场景。因此,我们经常看到某些对象具有与它们相关的不同生命周期。在这种情况下,对象由其 ID 定义,在领域驱动设计术语中,它被视为实体。这与值对象相反,值对象由其属性定义,当它们的属性匹配时被视为相等。只有当实体具有相同的 ID 时,它才是相等的,即使所有属性匹配;只要 ID 不同,实体就不相同。

实体对象管理应用程序内部的生命周期;这可以是整个应用程序中的生命周期,但也可能是系统中发生的事务。在地牢中,我们处理了许多情况,我们实际上并不关心对象本身的生命周期,而是关心它的属性。以囚犯运输为例,我们知道它包括许多不同的对象,但其中大多数可以实现为值对象。我们并不真的关心随行的兽人守卫的生命周期,只要我们知道有一个守卫,并且他有武器保护我们就可以了。

这可能看起来有点违直觉,因为我们知道我们需要注意分配兽人,因为我们没有无限数量的兽人,但实际上里面隐藏着两个不同的概念,一个是Orc作为值对象,另一个是它被分配来守卫运输。下面的代码定义了一个OrcRepository函数,它可以用于在受控情况下获取兽人并使用它们。这种模式可以用于控制对共享资源的访问,通常与最有可能封装在其中的数据库访问一起使用:

function OrcRepository(orcs, swords) {
  this.orcs = orcs
  this.swords = swords
}

OrcRepository.prototype.getArmed = function () {
  if (this.orcs > 0 && this.swords > 0) {
    this.orcs -= 1
    this.swords -= 1
    return Orc.withSword();
  }
  return false
}

OrcRepository.prototype.add = function (orc) {
  this.orcs += 1
  if (orc.weapon == "sword") this.swords += 1
}

function Orc(name, weapon) {
  this.name = name
  this.weapon = weapon
}

Orc.withSword = function () {
  return new Orc(randomName(), "sword")
}

repo = new OrcRepository (1, 1)
orc = repo.getArmed() // => { name: "Zuul", weapon: "sword" }
repo.getArmed() // => false
repo.add(orc)
repo.getArmed() // => { name: "Zuul", weapon: "sword"}

虽然Orc对象本身可能是一个值对象,但分配需要具有生命周期,定义开始、结束和可用性。我们需要从Orc对象的存储库中获取一个兽人,满足能够在运输过程中保护并在运输完成后立即归还的需求。在前面的情况下,Orcs存储库是一个实体,因此我们需要确保它被正确管理,否则我们可能会得到不正确的兽人数量或未记录的武器,这两者对业务都不利。在这种情况下,兽人可以自由传递,我们与其管理隔离开来。

更多关于实体的内容

在构建应用程序时,实体经常出现,很容易陷入使系统中的大多数对象成为实体而不是值对象的陷阱。要牢记的重要事情是值对象可以执行大量工作,并且对值对象的依赖是“便宜”的。

那么,为什么对值对象的依赖比对实体的依赖“更便宜”呢?当处理实体时,我们必须处理状态,因此对实体进行的任何修改都可能对使用该实体的其他子系统产生影响。造成这种情况的原因是每个实体都是一个可以改变的独特事物,而值对象归结为一组属性。当我们传递实体时,我们需要同步实体的状态,可能还需要同步所有依赖实体的状态。这可能会很快失控。以下代码显示了处理多个实体交互时的复杂性。在添加和删除物品时,我们需要控制钱包、库存和兽人本身的多个方面,以保持一致的状态:

function Wallet(coins) {
  this.money = coins
}

Wallet.prototype.pay = function (coin) {
  for(var i = 0; i < this.money.length; i++) {
    if (this.money[i].equals(coin) {
      this.money.splice(i, 1)
      return true
    }
  }
  return false
}

function Orc(wallet) {
  this.wallet = wallet
  this.inventory = []
}

Orc.prototype.buy = function (thing, price) {
  var priceToPay = new Coin(price)
  if (this.wallet.pay(priceToPay)) {
    this.inventory.unshift(thing)
    return true
  }
  return false
}

在这种情况下,我们需要确保购买行为不会被中断,因为根据其他实现可能会出现奇怪的行为。如果库存有更多与之相关的行为,比如大小检查,那么我们需要在确保可以无中断地回滚的同时协调这两个检查。我们之前已经看到事件如何在这里给我们带来了很多问题,这会很快变得难以控制。尽管在某个级别上不可避免地需要处理这个问题,但意识到这些问题是很重要的。

实体需要以确保不存在不一致状态的方式来控制它们的生命周期。这使得处理实体更加复杂,也可能会影响性能,因为需要进行锁定和事务控制。

管理应用程序的生命周期

实体和聚合都是关于在应用程序的每个级别管理这个周期。我们可以将应用程序本身视为包装在其所有组件周围以管理附加值对象和包含实体的聚合。在我们的囚犯转移级别上,我们将转移本身视为包装所有本地从属者的事务,并管理最终结果,无论是成功的转移还是失败的转移。

始终可以将生命周期管理进一步推到对象链的上方或下方,并找到合适的级别可能很困难。在前面的例子中,分配也可以是一个值对象,由对象链上的聚合管理,以确保满足其约束。在这个阶段,正确的抽象级别是系统开发人员必须做出的决定。将事务控制推得太高,然后使事务跨越更多对象可能会很昂贵,因为锁更粗糙,因此并发操作受到阻碍;将其推得太低可能会导致聚合之间的复杂交互。

提示

决定管理生命周期的正确抽象级别对应用程序的影响比一开始看到的要深远得多。由于实体是通过它们的 ID 进行管理的,同时又是可变的,这意味着它们是需要在处理并发时进行同步的对象,因此它影响了整个系统的并发性。

聚合

面向对象的编程在很大程度上依赖于将多个协作者的功能组合起来实现某些功能。在构建系统时,经常出现这样的问题,即某些对象吸引了越来越多的功能,从而成为几乎参与系统中每次交互的上帝对象。摆脱这种情况的方法是让多个对象合作实现相同的功能,但是作为小部分的总和,而不是一个大对象。

构建这些相互关联的子系统存在一个不同的问题,即随着对象结构的构建,它往往会暴露大而复杂的接口,因为用户需要更多地了解内部来使用系统。让客户端处理子系统的内部不是对建模这样的系统的好方法,这就是聚合的概念发挥作用的地方。

聚合允许我们向客户端公开一个一致的接口,并让他们只处理他们需要提供的部分,以使系统作为一个整体运行,并让外部入口点处理不同的内部部分。在前一章第四章中,建模演员,我们讨论了聚合的例子,即马车由使其作为一个整体运行所需的所有元素组成。相同的概念也适用于其他级别,并且我们构建的每个子系统都是其部分的一种聚合,包括实体和值对象。

分组和接口

作为开发人员,我们需要问自己的问题是,在开发过程中,如何分组部分,最好在哪里构建管理这些聚合的接口,以及它们应该是什么样子?尽管当然没有严格的公式,但以下描述的部分可以作为指导。

接口应该只要求客户端提供它实际关心的部分,这通常意味着子系统有多个入口点,通过不同的点触及系统的客户端可能会在途中相互干扰。在这一点上,我们可以借鉴一些经典的技术,提供所谓的“工厂”方法,以便为我们提供所需的对象图的入口点。这使我们能够创建一个易于阅读的语法,而不是试图利用所有动态的方式使对象创建灵活,并接受非常不同的参数来提供相同的功能。以下代码展示了在创建兽人的上下文中使用这种工厂的情况。我们希望对象构造函数尽可能灵活,同时为常见情况提供工厂方法:

var AVAILABLE_WEAPONS = [ "axe", "axe", "sword" ]
var NAMES = [ "Ghazat", "Waruk", "Zaraugug", "Smaghed", "Snugug",
              "Quugug", "Torug", "Zulgha", "Guthug", "Xnath" ]

function Orc(weapon, rank, name) {
  this.weapon = weapon
  this.rank = rank
  this.name = name
}

Orc.anonymusArmedGrunt = function () {
  var randomName = NAMES[Math.floor(Math.random() * NAMES.length)]
  var weapon = AVAILABLE_WEAPONS.pop()
  return new Orc(weapon, "grunt", randomName)
}

在这个例子中,我们可以检测到缺少的属性,并重新安排输入参数,以确保生成兽人可以适用于各种组合,但这很快变得难以控制。一旦协作者不再只是简单的字符串,我们需要与更多的对象进行交互并控制更多的交互。通过提供一个工厂函数,我们可以准确表达我们打算提供的内容,而不需要采取非常复杂的处理。

总的来说,将协作者分组成聚合并为访问提供不同的接口的目标是控制上下文,并更深入地将领域语言融入项目中。聚合的作用是提供对其聚合的模型数据的简化视图,以防止不一致的使用。

服务

到目前为止,我们一直在表达关于“事物”的概念,但有些概念最好是围绕着做某事的行为来表达,这就是服务的用武之地。服务是领域驱动设计的一流元素,它们的目标是封装领域中涉及许多合作者协调的行为。

 “在 Javaland 中,动词负责所有工作,但由于它们受到所有人的鄙视,因此任何动词都不得自由行动。如果要在公共场合看到动词,必须始终由名词陪同。当然,“陪同”本身也是一个动词,几乎不允许裸奔;必须找到一个动词陪同者来促进陪同。但“促进”和“促进”呢?事实上,促进者和采购者都是相当重要的名词,它们的工作是监护低贱的动词“促进”和“采购”,分别通过促进和采购。” 
 --- Steve Yegge - Thursday, March 30, 2006 - Execution in the Kingdom of Nouns

服务是一个非常有用但也经常被滥用的概念,它们归根结底是关于命名的。做某事的行为可以用“事物”来表达,也可以用“做事者”的名称来表达。例如,我们可以有一个Letter并在其上调用send方法,让它决定该做什么,并传递所需的合作者,例如:

function Letter(title, text, to) {
  this.title = title
  this.text = text
  this.to = to
}

Letter.prototype.send = function(postman) {
  postman.deliver(this)
}

另一种选择是创建一个处理发送信件的服务,并以无状态的方式调用它,在构造时将所有合作者传递给服务:

function LetterSender(postman, letter) {
  this.postman = postman
  this.letter = letter
}

LetterSender.prototype.send = function() {
  var address = this.letter.to
  postman.deliver(letter, address)
}

在一个简单的例子中,很明显第二种方法似乎复杂,并且并没有以任何有意义的方式增加发送信件的领域语言。在更复杂的代码中,这经常被忽视,因为某个动作的复杂性需要存在于某个地方。选择哪种方法取决于服务中可以封装的功能量。如果一个服务存在只是为了将一段代码拆分成一个现在独立但有点无家可归的部分,那么服务可能是一个坏主意。如果我们能够在服务中封装领域知识,那么我们将有一个创建服务的有效理由。

将一个对象命名为其功能,并且只有一个实际执行操作的方法,这对于任何面向对象的程序员来说都应该引起警惕。良好的服务可以增加领域的表达,并表达在领域本身具有坚实基础的概念。这意味着有名称来表达这个概念。服务可以封装那些不直接由“事物”支持的概念,并且它们应该根据领域进行命名。

关联

在前一节中,我们看到信件的递送取决于邮递员。信件和递送它的人之间存在一定的关系,但根据领域的不同,这种关系可能并不是非常强大或相关的。例如,对于我们的地牢主来说,知道谁递送了哪封信可能是相关的,因为每个邮递员都会立即被监禁并对他或她递送的邮件内容负责。

兽人的方式可能不像商业规则那样容易理解。在这种情况下,我们希望确保我们给每封信和递送它的邮递员贴上标签,将信件与特定的人联系起来。反之则无关紧要。在我们的领域中对此进行建模时,我们希望传递这一重要知识,并有一种方法将递送过程中的消息与适当的递送人员关联起来。在代码中,这可以更容易地完成;例如,我们可以为该封信创建一个历史记录,其中每个与递送相关的合作者都被关联起来。

领域模型之间的关联概念是领域设计的一个组成部分,因为大多数对象无论以何种形式都不会完全独立。我们希望在关联中尽可能地编码知识。当我们考虑对象之间的关联时,关联本身可能包含我们希望在我们的模型中加入的领域知识。

实现过程中的见解

模式的概念在面向对象语言以及其他类型的语言中都已经得到了很好的建立。已经有很多书籍对此进行了讨论,也有很多讨论涉及将许多开发人员的知识编码成模式,以用于企业以及其他类型的软件。最终,关键在于在开发过程中在正确的时间使用正确的模式,这不仅适用于领域模式,也适用于其他软件模式。

在他的书《企业应用架构模式》中,Martin Fowler 不仅讨论了通过DataMapper插件加领域层、事务脚本、Active Record 等方式处理与数据库通信的可用选项,还讨论了何时使用它们。和大多数事情一样,最终的结论是所有选择都有好坏两面。

在软件开发中,随着我们的前进,我们可以获得多种见解。一个非常有价值的见解是引入了以前不清楚的新概念。要达到这一点并没有明显的方法,我们可以做的是开始对我们当前在软件中拥有的模式进行分类,并尽可能地使它们清晰,以便更有可能发现新概念。有了一组良好分离的部分,发现缺失的部分更有可能发生。当我们考虑领域模式时,特别是我们可以对应用程序的某些元素进行分类的各种方式时,分类的方式并不总是像我们希望的那样清晰。

识别领域模式

正如你在处理发送信件的示例中看到的,我们注意到即使所提出的选项使用服务来处理协作,也有其他方法可以处理。这本书中的小例子的问题在于很难传达某个选项何时有益,或者某个设计在一般情况下是否过度复杂;这对于像领域驱动设计这样复杂的架构来说尤其如此,毕竟如果应用程序只有几百行代码,领域驱动设计解决的许多问题就不存在了。

当我们为某个功能编写代码时,我们总是需要意识到组件的设计并不是一成不变的。一个应用程序可能一开始会有很多实体存在,大部分交互都是内联处理的,因为系统还没有发展到足够清晰地看到哪些交互是复杂且重要到足以将它们作为领域概念的阶段。此外,通常我们使用软件意味着我们认识到某些概念,并且作为开发人员使用软件,我的意思是触及接口并扩展整个软件。

并非一切都是实体

在领域驱动设计中,往往很容易为一切创建实体。实体的概念在开发人员的思维中非常普遍。当我们将对象视为内存中的事物时,对象总是具有固定的标识,大多数语言默认以这种方式进行比较,例如:

Function Thing(name) {
  this.name = name
}

aThing = new Thing("foo")
bThing = new Thing("foo")

aThing === bThing // => false

这使得我们很容易期望一切都是一个实体,其 ID 是 JavaScript 认为的任何东西。

当我们考虑领域时,这当然并不总是有意义的,我们很快就会意识到某些事物并不是以这种方式被识别的,这往往会使应用程序的某些部分转向使用值对象。

尽可能简单地开始是件好事,但随着时间的推移,使项目成为一个更好的工作场所的关键是尽可能多地抓住机会来改进事物。即使最终选择的路线可能并非如此,尝试它本身也会使代码变得更好。

提示

原始偏执反模式是在不及早和经常进行重构时经常陷入的陷阱。问题在于很少引入新对象,许多概念由原语表示,比如将电子邮件表示为字符串,或者将货币值表示为纯整数。问题在于原语并未封装所有知识,而只是纯属性,这导致知识在各处重复,而命名概念,如电子邮件或货币对象,本可以共享。

始终朝着可塑代码进行重构

当我们开始朝着让代码以不同的方式引导我们的设计的方向努力时,我们会注意到那些不断变化的地方,以及那些给我们带来最多新功能或甚至重构的地方。这些就是我们需要解决的痛点。

在面向对象编程中,单一责任原则指出每个类应对软件提供的功能的一个部分负责,并且该责任应完全由类封装。它的所有服务应与该责任紧密对齐
--– 根据维基百科,单一责任原则最初由 Robert C. Martin 定义

我们希望我们的更改是局部的,并且探索某个功能的不同实现路径应该尽可能少地触及代码。这就是由 Robert C. Martin 定义的单一责任原则的目的,它将责任定义为变更的原因。与开闭原则一起,使代码易于使用,因为已知的接缝和构建块。

领域驱动设计的目标是将面向对象编程的概念提升到更高的水平,因此大多数面向对象的概念也适用于领域驱动设计。在面向对象中,我们希望封装对象,在领域驱动设计中,我们封装领域知识。我们希望我们的每个子系统和领域的每个元素尽可能独立,如果我们实现了这一点,代码将很容易在途中进行更改。

实施语言指导

领域驱动设计的核心是封装领域知识,而包含和分发知识的引导力量是语言。我们之前已经谈到,领域驱动设计的目标之一是在项目中创建一个共享的通用语言,该语言在开发人员和项目所有者或利益相关者之间共享,以指导实施。之前已经暗示过,这当然不是单向的。

当发现领域概念时,通常有必要作为团队建立和命名新概念,以使它们成为通信的已建立方式。有时,这些新名称和含义可能会回到业务中,它们将开始用于描述现在命名的模式,并且在很长时间内可能会回到跨业务领域中使用的通用语言中,如果它们被认为是有用的。

在 Eric Evans 的领域驱动设计原著中,他讨论了财务软件的开发以及建立的术语如何回溯到销售和营销部门,用于描述软件的新功能。即使您的业务语言的新添加可能不会如此,如果一个添加是有帮助的,至少业务的核心部分会采纳它们。

与业务语言一起工作

根据领域的不同,构建领域语言是非常不同的。很少有领域已经有与之相关的非常具体的语言。例如,如果我们研究会计,就会发现有关一切名称和相互作用方式的书籍。类似的情况也可能存在于成熟的企业,即使可能没有相关的书籍可供参考,但跟随日常进行业务的人可以很快揭示一些概念。

提示

跟踪你被要求实施的过程一天可以提供一些非常重要的见解。它还可以暗示业务在非常特定的方式中表现出来的领域,那些我们作为程序员在事后才会遇到的小事情。我们认为不合逻辑的事情很难融入我们的模型。

很少有业务领域是如此幸运的,特别是在年轻企业开发新理念的世界中,缺乏成熟的语言是固有的。那些企业往往是那些大量投资于基于 JavaScript 的应用程序的企业,那么我们该如何处理这种情况呢?

回到兽人地牢,我们正在处理一个对我们非常陌生的世界,这个世界没有一个非常成熟的语言来处理它的过程,因为迄今为止几乎从来没有这样的需要。我们在书中已经处理了这个问题,因为许多术语在上下文中都被严重重载。通知可以是一条消息,通知兽人他被分配到某个囚犯运输任务,或者通知另一个地牢囚犯即将到达,或者监狱要求新的囚犯。我们该如何处理这种情况呢?

让我们以兽人大师向我们解释他如何通知另一个地牢的情况为例:

开发者: 当地牢里囚犯满了的时候,你需要什么?

兽人大师: 没问题!让萨古克处理?

开发者: 据我所知,萨古克是北方地牢的领袖,所以我猜你需要准备一个运输?

兽人大师: 是的,我需要通知巴隆克准备运输,还有通知萨古克准备好。

他写下两封信,然后叫来他的哥布林助手。

兽人大师: 把这个送给巴隆克,这个送给萨古克!

哥布林开始跑开,但就在他通过南门离开房间之前,主人开始尖叫。

兽人大师: 你在干什么?你需要找一只乌鸦,先把这个送到萨古克那里!

哥布林看起来很困惑,但现在开始朝另一个方向跑去。

兽人大师: 这种事经常发生——哥布林就是记不住谁是谁,他也不需要记住,但他需要把信送到正确的办公室。

开发者: 啊,所以如果你想给另一个地牢发消息,你找一只乌鸦?当你在地牢内部给某人发消息时,它会在本地传递?

兽人大师: 没错!

开发者: 好的,为了不混淆系统,我只会称另一个地牢的消息为“乌鸦”,而在本地我们继续称其为“消息”。这样做有道理吗?

兽人大师:是的!希望哥布林不再搞砸了,因为这已经引起了一些奇怪的对话。

这是对这种情况可能发展的一个重要简化,但我们作为开发者应该吸收业务提供的语言片段并将其纳入领域。这不仅使我们的生活更轻松,也提高了业务沟通的效率。

需要注意的一点是,我们需要确保不把我们非常特定的语言强加给业务,也就是说,如果某个概念没有被采纳,就准备放弃它。毕竟,未命名的概念比令人困惑的命名概念更糟糕。构建语言需要来自领域,而不应该被强加给领域。一个名字不被接受的概念,要么是命名了一个不重要的概念,要么是不够描述性而无法被接受。

如果给一个不重要的概念命名,通常会不必要地引起对它的关注,这可能会在以后造成麻烦,因为我们可能会不愿意改变它或者适应新的需求,认为它太重要。例如,我们开发了自动囚犯分配的概念,它使用算法来确定我们有多少囚犯时最佳的牢房。这似乎非常重要,因为我们希望地牢尽可能地得到最佳利用。有一天,一个新的囚犯到来,系统开始计算确定他的最佳牢房,而狱卒却说:“为什么这么久?每次都这样!我早就知道把他放在哪里了,我就把他塞进 1 号牢房!”这是有效的用户反馈——尽管我们可能已经找到了最佳利用地牢的方法,但这可能并不重要,因为兽人对每个牢房的囚犯数量看待得比我们轻松得多。自动分配的概念从未真正流行起来;我们从未听说过有人谈论它,所以我们可能会把整个概念都移除掉,这样对我们和用户来说系统会更容易一些。

当然,系统不仅为用户服务;它们也可能在途中为其他系统服务。因此,牢记谁是真正的用户可能会对决策产生重大影响。

构建上下文

我们一直在谈论我们使用的语言,系统如何相互作用以及它们由什么组成,但我们还需要触及一个更高的层次:系统如何与其他系统相互作用?

在服务器世界中,目前强调的是微服务及其构建和交互。重要的一点是,一个小团队拥有的系统比由一个较大团队构建的系统更容易维护;这只是故事的一半,所以服务毕竟需要相互作用。微服务是领域驱动设计界限上下文的更技术化的方法。

下图显示了微服务世界中的交互是如何进行的。我们有很多小服务相互调用来完成更大的任务:

构建上下文

交互不仅发生在 API 级别,也发生在开发者级别。

分离和共享知识

团队需要意识到在变化发生时如何分享知识并共同合作。埃里克·埃文斯在领域驱动设计中专门讨论了实践中所见的模式。在软件开发中,我们经常看到各种模式,无论是软件模式,比如DataMapperActiveRecord,还是埃里克·埃文斯讨论的关于共同工作过程的模式。

在当前的微服务世界中,我们似乎已经从深度集成转向了更灵活地轻触系统的其他部分的方式。跨团队共享领域,以及了解什么接触了什么的地图变得比以往更加重要。

总结

在本章中,我们详细讨论了如何分离系统以及如何处理应用程序中的概念,主要是在项目的较小规模上,以及它如何与较大规模互动。

在构建项目时,我们可以从其他地方使用过的许多想法中汲取经验,无论是面向对象的设计还是软件架构模式;要牢记的重要一点是,没有什么是一成不变的。关于领域驱动设计的一个非常重要的事情是,它不断变化,而这种变化是一件好事。一旦项目变得太固定,就很难改变,这意味着使用它的企业无法随着软件的发展而发展,最终意味着转换到另一个系统和不同的软件,或者重写现有的软件。

下一章将更多地涉及项目整体交织部分的高层视图,详细介绍项目的每个部分所处的上下文。