JavaScript-领域驱动设计-全-

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,还是埃里克·埃文斯讨论的关于共同工作过程的模式。

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

总结

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

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

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

第六章:上下文地图-整体情况

地牢管理应用程序目前只包含管理囚犯运输的功能,但随着我们应用程序的增长,组织代码的需求也在增加。能够同时在一款软件上工作的开发人员数量有限。亚马逊创始人兼首席执行官杰夫·贝索斯曾经说过,一个团队的规模不应该超过两个披萨所能满足的人数。这个想法是,任何比这更大的团队在沟通方面都会遇到麻烦,因为团队内部的联系数量会迅速增长。随着我们增加更多的人,保持每个人都了解最新情况所需的沟通量也会增加,团队迟早会因为不断需要开会而变慢。

这个事实造成了一种困境,因为正如我们之前所描述的,完美的应用程序应该是每个人都了解开发过程以及围绕变化做出决策的应用程序。这给我们留下了很少的选择:我们可以决定不扩大团队,构建应用程序,但选择一个更慢的开发周期,这个团队可以独立处理(以及整个应用程序上的较小功能集),或者我们可以尝试让多个团队共同开发同一个应用程序。这两种策略在商业上都取得了成功。保持小规模并自然增长,虽然可能不会导致爆发式增长,但可以导致一个运行良好且成功的公司,就像 Basecamp Inc.和其他独立软件开发者所证明的那样。另一方面,这对于固有复杂性并且目标范围更广的应用程序来说是行不通的,因此亚马逊和 Netflix 等公司开始围绕创建由较小部分组成的更大应用程序的理念来扩大他们的团队。

假设我们选择领域驱动设计的理念,我们更有可能拥有一个属于固有复杂领域的应用程序,因此接下来的章节将介绍处理这种情况的一些常见方法。在设计这样的应用程序时不容忽视的一个重要点是,我们应该始终努力尽可能减少复杂性。你将学到:

  • 如何在技术上组织不断增长的应用程序

  • 如何测试系统中应用程序的集成

  • 如何组织应用程序中不断扩展的上下文

不要害怕单体应用

近来,人们开始更加倾向于将应用程序拆分并设计成一组通过消息进行通信的服务。这对于大规模应用程序来说是一个成熟的概念;问题在于找到正确的时间来拆分应用程序,以及决定是否拆分是正确的做法。当我们将一个应用程序拆分成多个服务时,这一点会增加复杂性,因为现在我们必须处理跨多个服务的通信问题。我们必须考虑服务的弹性以及每个服务提供其功能所需的依赖关系。

另一方面,当我们在后期拆分应用程序时,从应用程序中提取逻辑会出现问题。没有理由一个单体应用程序不能被很好地因素化,并且在很长一段时间内保持易于维护。拆分应用程序总会带来问题,而长期保持一个良好因素化的应用程序是可行的。问题在于,一个代码库庞大且有很多开发人员在上面工作的情况更容易恶化。

我们如何避免这样的问题?最好的方法是以尽可能简单的方式设计应用程序,但尽可能长时间地避免子系统之间的通信问题。这正是强大的领域模型擅长的;领域将使我们能够与底层框架强烈分离,但也清楚地指出了在必须分解应用程序时应该如何分解。

在领域模型中,我们已经确定了可以稍后分离的区域,因为我们将它们设计为单独的部分。一个很好的例子是囚犯运输,它被隐藏在一个接口后面,以后可以被提取出来。可以有一个团队专门负责囚犯运输功能,只要公共接口没有改变,他们的工作就可以进行,而不必担心其他改变。

更进一步,从纯逻辑角度来看,实际逻辑执行的位置并不重要。囚犯转移可能只是一个调用单独后端的幌子,或者可能在一个进程中运行。这就是一个良好分解的应用程序的全部意义——它提供了一个接口子域功能,并以足够抽象的方式暴露出来,使底层系统易于更改。

只有在必要的情况下,如果分离出一个服务,并且这样做有明显的好处,减少部署或开发依赖的复杂性,使流程的开发能够尽可能地扩展,最好是有一个团队来负责服务的进一步发展。

面向服务的架构和微服务

在极端情况下,面向服务的架构SOA)最终会变成微服务;每个服务只负责非常有限的功能集,因此很少有改变的理由,易于维护。在领域驱动设计方面,这意味着为应用程序中的每个有界上下文建立一个服务。上下文最终可以被分解为每个聚合由单独的服务管理。管理聚合的服务可以确保内部一致性,服务作为接口意味着访问点非常清晰地定义。大部分问题都转移到了通信层,必须处理弹性。这可能是应用程序中通信层的一个重大挑战,也是服务本身的挑战,因为它们现在必须处理更多的故障模式,由于依赖方之间的通信失败。微服务在某些场景中取得了巨大成功,但整体概念仍然年轻,需要在更广泛的用例中证明自己。

微服务架构或多或少是演员模型的延伸,只有当将演员变成自给自足的服务时,才是对此的延伸。这增加了更好隔离的通信开销;在领域驱动设计中,这可能意味着围绕实体构建服务,因为它们是应用程序部分生命周期的管理者。

总的来说,无论最终选择哪种架构,都有必要考虑如何准备应用程序以便以后可以分解。精心打造灵活的领域模型并利用有界上下文是以这种方式发展应用程序设计的关键。即使应用程序从未真正分解成部分,有清晰的分离也使每个部分更容易处理,并且由于更易理解,因此更容易修改,组件组合应用程序更少出错。

关键点在于要很好地确定核心领域,并最好将其与系统的其他部分隔离开来进行演化。并不是软件的每一部分都会被很好地设计,但是将核心领域及其子域隔离和定义好,使得整个应用程序都准备好进行演化,因为它们是应用程序的核心部分。

将所有这些记在脑中

每次我们打开我们选择的编辑器来编写代码时,都需要一些开销来知道从哪里开始以及我们实际需要修改哪个部分。了解从哪里开始修改以朝着特定目标前进,通常是一个应用程序能否愉快地工作的关键区别,而不是一个没有人愿意碰的应用程序。

当我们开始处理一段代码时,我们在任何给定时间内能够在脑海中保留的上下文量是有限的。尽管不可能给出确切的限制,但当代码的某个部分超出了这个限制时很容易注意到。这通常是重构变得更加困难的时候,测试开始变得脆弱,单元测试似乎失去了其价值,因为它们的通过不再确保系统功能。在开源世界中,这通常是项目的一个破坏点,并且由于其开放性质,这一点非常明显。在这一点上,一个库或应用程序如果能够让人们投入时间真正理解内部工作并继续朝着更模块化的设计取得进展,那么它就变得非常有价值,或者开发就会停止。企业应用程序也遭受同样的命运,只是人们更加不愿意放弃提供收入来源或其他重要业务方面的项目。

当项目变得复杂时,人们经常害怕进行任何修改,而且没有人真正理解发生了什么。当痛苦和不确定性开始增长时,重要的是要认识到这一点,并开始分离应用程序的上下文,以保持其规模可管理。

识别上下文

当我们绘制应用程序时,我们已经认识到应用程序的某些部分以及它们之间的通信方式。我们现在可以利用这些知识来确保我们对应用程序的上下文有一个概念:

识别上下文

在第一章中,典型的 JavaScript 项目,我们处理了领域中大约有六个上下文。通过最近章节所获得的理解,这有些变化,但基本原理仍在。这些上下文被确定为它们之间的通信是通过交换消息而不是修改内部状态来进行的。在构建 API 的情况下,我们不能依赖于我们处于可以修改内部状态的情况,也不应该有一种方法可以进入上下文并修改其内部,因为这是上下文之间非常紧密的耦合,这将使上下文的有用性变得不明显。

消息是易于建模 API 的关键基础;如果我们以消息为思考方式,很容易想象拆分应用程序和消息不再在本地发送,而是通过网络发送。当然,拆分应用程序仍然不容易,因为突然之间需要处理更多的复杂性,但有能力处理消息传递是摆脱复杂性的一大部分。

提示

函数式编程语言** Erlang**将这个概念发挥到了极致。Erlang 使得将应用程序拆分成所谓的进程变得容易,这些进程只能通过发送消息进行通信。这使得 Erlang 能够将进程重新定位到不同的机器上,并抽象出多处理器机器或多机系统的一系列问题。

拥有明确定义的 API 使我们能够在不破坏外部应用程序的情况下对上下文内部进行重构更改。上下文成为我们系统中可以视为黑匣子的角色,并且我们可以使用它们封装的知识来建模其他部分。在上下文内部,应用程序是一个连贯的整体,并且以一种抽象的方式向外部表示其数据。当我们将域和子域公开为接口时,我们生成了一个可塑性系统的构建块。当它们需要共享数据时,有一种明确的方法可以做到这一点,目标应该始终是共享底层数据并在这些数据上公开不同的视图。

在上下文中测试

当我们确定可以视为黑匣子的上下文时,我们也应该在测试中使用这些知识。我们已经看到模拟允许我们根据不同的角色进行分离,而上下文在这种方式上是进行单元测试时的一个完美候选。当我们将应用程序分解为上下文时,当然也可以在不同的上下文中开始使用不同的测试风格,使开发过程随着我们的理解和应用程序的变化而发展。在这样做时,我们需要记住整个应用程序需要继续运行,因此还需要测试应用程序的集成。

跨边界的集成

在上下文的边界处,从上下文开发者的角度,有多个需要测试的事情:

  1. 我们这一方的上下文需要遵守其合同,也就是 API。

  2. 两个上下文的集成需要正常工作,因此需要进行跨边界测试。

对于第一点,我们可以将我们的测试视为 API 的使用者。例如,当我们考虑我们的消息 API 时,我们希望有一个测试来确认我们的 API 是否实现了它承诺的功能。这最好通过一个外部测试来覆盖上下文一侧的合同。假设有一个虚构的Notifier,它的工作方式如下,就像我们之前使用通知器通过message函数发送消息一样:

function Notifier(backend) {
  this.backend = backend
}

function createMessageFromSubject(subject) {
  return {} // Not relevant for the example here.
}

Notifier.prototype.message = function (target, subject, cb) {
  var message = createMessageFromSubject(subject)
  backend.connectTo(target, function (err, connection) {
    connection.send(message)
    connection.close()
    cb()
  })
}

当调用通知器时,我们需要测试后端是否以正确的方式被调用:

var sinon = require("sinon")

var connection = {
  send: function (message) {
    // NOOP
  },
  close: function () {
    // NOOP
  }

}

var backend = {
  connectTo: function (target, cb) {
    cb(null, connection)
  }
}

describe("Notifier", function () {
  it("calls the backend and sends a message", function () {
    var backendMock = sinon.mock(backend)
    mock.expects("connectTo").once()

    var notifier = new Notifier(backendMock)
    var dungeon = {}
    var transport = {}
    notifier.message(dungeon, transport, function (err) {
      mock.verify()
    })
  })
})

这不会是一个详尽的测试,但基本的断言是我们使用的后端是否被调用了。为了使其更有价值,我们还需要断言正确的调用方式,以及进一步调用依赖项。

第二点需要建立一个集成测试,以覆盖两个或更多上下文之间的交互,而不涉及模拟或存根。当然,这意味着测试很可能会比允许使用模拟和存根来严格控制环境的测试更复杂,因此通常限于相当简单的测试,以确保基本交互正常工作。在这种情况下,集成测试不应该过于详细,因为这可能比预期的更加僵化 API。以下代码测试了系统中囚犯转移系统的集成,使用了地牢等不同的子系统作为集成点:

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

describe("Prisoner transfer to other dungeons", function () {

  it("prisoner is moved to remote dungeon", function (done) {
    var prisoner = new inmates.Prisoner()
    var remoteDongeon = new dungeon.remoteDungeon()
    var localDungeon = new dungeon.localDungeon()
    localDungeon.imprison(prisoner)
    var channel = new messages.Channel(localDungeon, remoteDungeon)

    assert(localDungeon.hasPrioner(prisoner))
    prisonerTransfer(prisoner, localDungeon, remoteDungeon, channel, function (err) {
      assert.ifError(err)
      assert(remoteDungeon.hasPrioner(prisoner))
      assert(!localDungeon.hasPrioner(prisoner))
      done()
    })
  })
})

前面的代码显示了确保囚犯转移的端到端测试可以涉及多么复杂。由于这种复杂性,只有测试简单交互才有意义,否则端到端测试很快就会随着小的变化而变得难以维护,并且它们应该只覆盖更高级别的交互。

端到端或系统边界的集成测试的目标是确保基本交互正常工作。单元测试的目标是模块本身的行为符合我们的期望。这留下了一个开放的层次,在生产环境中运行服务时会变得明显。

TDD 和生产测试

测试驱动开发使我们能够设计一个易于更改和发展的系统;相反,它并不能确保完美的功能。我们首先编写一个“有问题”的测试,一个测试,其中基本功能仍然缺失,然后编写代码来满足它。我们不会编写测试以完全避免生产错误,因为我们永远无法预料到可能出现的所有可能的复杂情况。我们编写测试是为了使我们的系统灵活,并且使其准备好投入生产,以便我们可以审视其行为,并且上下文相对独立,以处理故障。

将代码移至生产环境时,我们以一种新的方式来运行系统,为此我们需要准备好进行监视和审视。这种审视和监视也允许由于注入日志模块和其他模块而进行简单的集成测试断言。

我们已经看到了上下文系统如何帮助我们创建一个更稳定、更易于维护的系统。在接下来的部分中,我们将重点关注如何在应用程序中实际维护上下文,以抵制抽象泄漏和上下文泄漏,并且这与组织应用程序的不同方式有关。

管理上下文的不同方式

到目前为止,我们应用程序中上下文的主要目的是通过抽象 API 来分离不同的模块,并使整个应用程序的复杂性更易管理。分离上下文的另一个重要好处是,我们可以开始探索在这些解耦部分中管理应用程序开发的不同方式。

应用程序的开发方式随着软件周围的行业快速发展而发展。几年前还是最先进的开发原则现在受到了指责,开发人员希望转向新的方式,使其更具生产力,同时承诺无错误,更易管理的应用程序。当然,更换开发原则并不是免费的,而且往往新的方式并不一定与完整组织的工作方式相匹配。通过分离应用程序的上下文,我们可以开始探索这些新的方式,同时保持团队与他们维护的应用程序一起发展和进步。

管理上下文的第一步是绘制它们之间的关系地图,并开始清晰地分离,使用我们建立的语言。有了这张地图,我们可以开始考虑如何划分应用程序,并将其分解为不同的方式,以便在团队内实现最大的生产力。

绘制上下文地图

到目前为止,我们一直在跟踪的囚犯运输应用程序涉及多个上下文。每个上下文都可以通过清晰的 API 进行抽象,并聚合多个合作者,以使囚犯运输作为一个整体运行。我们可以在之前看到的集成测试中跟踪这些合作者,并在项目中为每个人绘制出它们的关系地图。以下图表显示了囚犯运输中涉及的不同上下文的概述,包括它们的角色:

绘制上下文地图

目前,地图涉及四个主要上下文,正如我们在前面的集成测试中看到的:

  • 囚犯管理

    • 地牢
  • 消息系统

    • 运输

每个上下文负责提供所需的合作者,以使地牢之间的实际传输发生,并且只要 API 保持一致,就可以用不同的实现替换它。

对上下文的调查显示了随着应用程序的发展而增加的差异,这意味着需要不同的策略来管理上下文。地牢作为应用程序的主要入口点,管理大部分原始资源。地牢将成为地牢管理太阳系中的太阳。它提供对资源的访问,然后可以用来完成不同的任务。因此,地牢是应用程序的共享核心。

另一方面,有不同的子域使用地牢提供的资源聚集在一起。例如,消息系统以一种大部分解耦的方式为不同的系统提供基础设施,以增强它们完成的任务。我们所看到的囚犯转移就是将这些其他子域联系在一起的一个子域。我们使用地牢提供的资源来构建囚犯转移,并使用解耦的消息功能来增强转移任务。

这三个系统展示了我们如何让不同的上下文共同工作,并提供资源来完成系统要构建的任务。在构建它们时,我们需要考虑这些子域应该如何相关。根据正在构建的不同类型的子系统,不同形式的上下文关系是有用的,并且最好支持开发。需要记住的一件事是,只要应用程序足够简单,大多数情况下,这些都会给开发增加更多的开销,而不是增加灵活性,因为应用程序的共享方面将会变得比以前更复杂。

单体架构

在开始开发时,开发应用程序的团队很可能很小,应用程序的上下文本身还不是很大。在这个阶段,将应用程序域的上下文分离出来可能没有意义,因为它们仍然是灵活的,还没有发展到需要一个单独的团队来管理它们的程度。此外,在这个阶段,API 还不够稳定,无论事先进行了多少规划,都无法实现一个坚实的抽象。

提示

Martin Fowler 也在谈论这个问题,并建议首先构建一个单体,然后根据需要将其拆分。您可以在他的博客上找到更多信息martinfowler.com/bliki/MonolithFirst.html

在这个阶段,应用程序开发将最好使用提供对模型的共享访问的单体架构。当然,这并不意味着一切都应该是一大堆代码,但特别是在单体中,很容易将对象拆分出来,因为每个人都可以访问它们。这将使以后拆分应用程序变得更容易,因为边界在开发过程中往往会发展。

这也是我们迄今为止开发应用程序的方式;即使我们认识到存在上下文,这些上下文不一定意味着分离成不同的应用程序或领域,但目前它们是开发者头脑中的地图,用于指导代码的位置和交互的流程。看看囚犯转移,它可能看起来像这样:

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 {} // as a placeholder for now
}

现在,代码直接访问应用程序的每个部分。即使通信被包装成控制流的对象,囚犯转移中发生了大量的交互,如果应用程序被拆分,这些交互将需要通过网络访问。这种组织形式对于单体应用程序是典型的,当它被分解成不同的部分时会发生变化,但整体上下文将保持不变。

共享内核

我们已经看到,地牢就像我们的兽人地牢管理宇宙中的太阳,因此将其功能跨应用程序共享是有意义的。

这种开发方式是一种共享内核。地牢本身提供的功能需要在许多不同的地方进行复制,除非以某种方式进行共享,而且由于功能是如此关键的一部分,它与供应链的慢接口并不相容,例如。

地牢为使用它的不同部分提供了许多有用的接口,因此功能需要与使用者一起开发。回到囚犯运输,代码将如下所示:

var PrisonerTransfer = function (prisoner, ourDungeon) {
  this.prisoner = prisoner
  this.ourDungeon = ourDungeon
  this.assignDungeonRessources()
}

PrisonerTransfer.prototype.assignDungeonRessources = function () {
  var resources = this.ourDungeon.getTransferResources()
  this.carriage = resources.getCarriage()
  this.keeper = resources.getKeeper()
}

PrisonerTransfer.prototype.prepare = function () {
  // Make the transfer preparations
  return true;
}

PrisonerTransfer.init = function (prisoner, otherDungeon, ourDungeon, notifier, callback) {
  var transfer = new PrisonerTransfer(prisoner, ourDungeon)
  if (transfer.prepare()) {
    notifier.message(otherDungeon, transfer)
    callback()
  } else {
    callback(new Error("Transfer initiation failed."))
  }
}

在前面的代码中,我们使用了一个常见的模式,它使用init方法来封装一些初始化地牢所需的逻辑。这通常对于使外部创建变得容易很有用,而不是在复杂的构造函数中处理它,我们将其移到一个单独的工厂方法中。优点是,简单方法的返回值比使用复杂构造函数更容易处理,因为失败的构造函数可能会导致一个半初始化的对象。

这里的重要一点是,地牢现在支持一个特定的端点,以提供转移所需的资源。这很可能会锁定给定的资源并为其初始化一个事务,以便它们在物理世界中不会被重复使用。

由于我们的共享内核特性,这种变化可以同时发生在囚犯转移和应用程序的地牢部分。共享内核当然并非没有问题,因为它在部分之间创建了强耦合。牢记这一点并仔细考虑,是否真的需要在共享内核中使用这些部分,或者它们是否属于应用程序的另一部分,这总是有用的。共享数据并不意味着有理由共享代码。对于应用程序中囚犯转移的视图可能会有所不同:虽然转移本身可能更关心细节,但消息服务共享转移数据以创建要发送的消息只关心目标和来源,以及参与转移的囚犯。因此,在两个上下文之间共享代码会使每个领域混淆不必要和无关的知识。

这样的共享上下文架构意味着在共享上下文内工作的团队必须紧密合作,这部分应用程序必须进行大力重构和审查,以免失控。可以说,这是单体架构的直接演变,但它使应用程序更进一步地分割成多个部分。

对于许多应用程序来说,将一些基本元素分离出来并进行大量变更就足够了,应用程序可以通过共享内核更快地演进,开发团队进行协调。当然,这迫使团队在一般情况下信任彼此的决定,并且工程师之间的沟通开销可能会随着共享内核的演变而增加,此时应用程序已经稳定到一个阶段,团队可以接管应用程序部分的责任,并将其整合到自己的部分中。

API

构建不同的应用程序需要一组可靠的 API。有了这样的 API,可以从主域和应用程序中提取某些子域,这些子域可以开始完全独立于主应用程序演进,只要它们继续遵守之前的相同 API。

首先要识别一个子域,以便为其提供一个清晰的 API 层来构建。查看上下文映射将显示子域的交互,而这些交互是 API 模型应该基于的。首先以更单片式的方式构建,然后在其子域中巩固时分解出部分,将自然地朝着这个方向发展。

提示

与以前相同的 API 一致通常只被视为接受相同的输入并产生相同的输出,但实际上还有更多内容,以便提供一个可替换的组件。新应用程序需要提供类似的保证,以确保响应时间和其他服务水平,例如数据持久性。在大多数情况下,实现一个可替换的组件并不像表面上那么容易,但将应用程序发展到更好的服务水平通常在孤立环境中更容易。

随着我们开发应用程序,我们现在可以自由地分支出去,同时保持对应用程序使命的忠诚。我们为其他需要遵循我们做事方式的应用程序提供服务,但仅限于应用程序的调用。

顾客和供应商

提供服务的应用程序是某种服务的供应商。我们可以将消息系统视为这样的服务。它为其他应用程序提供了一个发送消息到特定端点的入口点。如果它们想要接收消息,这些端点需要提供必要的调用,而消息系统则负责传递消息。使用消息系统的应用程序需要以某种方式调用系统。

这种互动方式非常抽象,而且像这样的一个好应用程序并不提供很多端点,而是提供了非常高级的入口点,以便尽可能地使使用变得容易。

开发客户端

像这样使用内部应用程序可以有多种方式。接口可以非常简单,例如,像这样通过 HTTP 进行非常基本的调用:

**$ curl –X POST --date ' {"receiver":1,"sender":2,"message":"new transfer of one prisoner"'  http://api.messaging.orc**

像这样的调用对大多数语言来说并不需要单独的客户端,因为它非常容易进行交互,并且将被捆绑到客户应用程序中,以任何被认为最佳的方式。

当然,并非每个应用程序都能提供如此简单的接口,因此在这个阶段需要提供一个客户端,最好是在应用程序的不同客户之间共享,以避免重复工作。这可以由开发应用程序提供,例如在复杂客户端的情况下,也可以由其中一个客户应用程序发起,然后以与共享内核相同的方式共享。在大多数更大的系统中,客户端往往是由应用程序开发团队提供的,但这并不一定是最好的方式,因为他们并不总是了解使用他们应用程序的复杂性,因此邀请每个消费者的封装客户端与内部客户端一起发展可能更好。

墨守成规

将应用程序分割为 API 供应商和消费者是一个非常明显的分割,即使有提供的客户端,这意味着应用程序现在由多个部分组成,不再作为一个单元进行开发。这种分割通常被认为可以增加开发速度,因为团队可以更小,不再需要如此强烈的沟通。然而,当两个独立的应用程序需要共同提供新功能时,这是需要付出代价的。

当我们需要跨界通信时,这是昂贵的,不仅在网络和方法调用速度方面,而且在整体团队沟通方面也是如此。提供应用程序不同部分的团队并不是为了相互合作而设立的,建立这种结构所需的时间是我们每次开发合作功能时都要付出的额外开销。

设计系统的组织...受限于产生与这些组织沟通结构相同的设计...
--M. 康威

这种发展是康威定律的一种反向效应,因为组织将会产生受其结构限制的系统,强制使用不同的结构将无意中减慢团队的速度,因为它不适合开发这样的应用程序。

当面对一个不断增长的应用程序时,我们需要做出选择:我们可以决定拆分应用程序,或者处理增长痛苦的结果。处理遗留应用程序的痛苦,并且只是顺应其发展路线,根据整体系统的预期走向,这可能是一个不错的选择。例如,如果应用程序在一段时间内处于维护模式,并且不太可能很快增加功能,决定继续这条路线,即使模型不完美,代码库看起来遗留,可能是最好的选择。

成为顺从者是不受欢迎的选择,但它遵循“永远不要重写”的规则,毕竟,开发一个实际有用的应用程序比开发一个可能工程精良但没有价值并因此迟早被忽视的应用程序更有意义。

反腐层

在应用程序的生命周期中,有一个特定的阶段,只是添加更多功能并顺应已有设计不再具有生产力。在这个阶段,从主应用程序中分离出来并开始摆脱软件中不断增加的复杂性很有意义。在这个阶段,重新构建领域语言也是一个好主意,并且看看遗留代码库如何适应模型,因为这可以让您创建坚实的抽象,并在其上设计一个良好的 API。这种开发提供了对代码的外观,我们指的是提供一个层来保护应用程序免受可能泄漏进来的旧术语和问题的影响。

提示

反腐层是在改进已经投入生产的应用程序时非常重要的模式。隔离新功能不仅更容易测试,还可以增加可靠性,并且可以更容易地引入新模式。

隔离方法论

当我们构建这样的层时,我们完全隔离自己不受底层技术的影响;当然,这意味着我们也应该隔离自己不受下面的软件构建方式的影响,并且可以开始使用自原应用程序开始以来开发的所有新方式。

这有一个非常不好的副作用,即旧应用程序立即成为不多人愿意再去工作的遗留应用程序,而且可能会受到很多责备。确保出于这个原因有必要进行如此强烈的分割。

反腐层在集成外部应用程序进入系统的情况下也可能是有意义的,例如,外部银行系统的信用卡处理。最好将外部依赖项与核心应用程序隔离开来,即使只是因为外部 API 可能会发生变化,调整所有调用者很可能比调整内部抽象更复杂。这正是反腐层擅长的,因此很快你的内部依赖项最好像外部依赖项一样处理。

分道扬镳

类似于反腐层,以更分离的方式,试图解决应用程序在域中分离的问题。当我们在系统中开发一个共同的语言并将应用程序拆分时,语言将变得更加精细,某些模型的复杂性将增加在某些应用程序中,但不一定在其他应用程序中。这可能会导致问题,因为共享的核心被使用,因为这个核心需要合并每个子域所需的最大复杂性,因此在我们宁愿保持它小的同时继续增长。

问题在于何时决定某个应用程序需要在域模型层面进行拆分,因为对于一个部分的增加复杂性不再增强另一个部分的可用性。在我们的应用程序中,可能的候选者是共享在其他应用程序中的地牢模型。我们希望尽可能地保持它小,但应用程序的部分将对它有不同的需求。消息子系统将需要专注于将消息传递给地牢并增加这部分的复杂性,而处理囚犯运输前提条件的系统将关心其他资源管理部分。

无关的应用程序

由于不同的应用程序对核心域有如此不同的要求,因此不共享模型而为需要它的应用程序构建一个特定的模型是有意义的,只共享数据存储或其他一些共享状态的手段。目标是减少依赖关系,这可能意味着只共享实际需要共享的内容,即使名称可能暗示相反。在共享数据存储时,重要的是要记住,只有拥有数据的子域应该能够修改它,而其他所有子域应该使用 API 来访问数据,或者只有只读访问权限。这取决于 API 的开销是否可持续,或者是否需要直接集成以提高性能。

当应用程序开始以不同的方式使用模型,而它们共享模型的唯一原因是模型的名称相同时,我们可以开始寻找更适合目的的更具体的名称,甚至在某个时候完全摆脱主要模型。在我们的地牢示例中,情况可能是,随着时间的推移,地牢本身只能被减少到只作为应用程序的入口点,充当管理子域应用程序的路由器。

将更多功能移出应用程序最初共享的上下文,意味着我们减少了共享子域的表面,并且最初误解了这个域的角色。这并不是坏事,因为每个应用程序都应该被建立为进化,随着上下文变得更加清晰,这反过来可以澄清先前不清晰的子域边界。

提示

不要过于执着于你对域和子域边界的理解。从业务专家那里获得经验可以改善你对子域的理解,因此有界上下文的精炼反过来会影响域。

一个开放的协议

使应用程序真正独立的最后一步是将它们发布为开放协议。关键是使应用程序的核心功能从外部公开访问,作为一个公开的标准。这很少是情况,因为它需要大量的维护和初始设置。开放协议的最佳候选者是用于与应用程序通信的特殊通信层,以允许外部客户端。

当应用程序邀请外部用户,甚至可能是外部客户端时,应用程序的 API 可以被视为一个开放协议。在我们的地牢应用程序中,我们可能希望将消息子系统在某个时候变成一个开放协议,以允许其他地牢通过它们自己的本地应用程序插入,并因此在 Dungeon Management™中建立标准。

在这个阶段,当考虑到开放协议时,我们需要关注的是如何有效地分享协议的知识。

分享知识

我们将应用程序拆分为多个子域的子应用程序,我们这样做是为了增加团队的规模,并促进它们之间更好的合作。这也意味着团队需要找到一种方式来与新开发人员分享关于应用程序及其使用的信息,以及与进入子域以实现特定目标的开发人员分享信息。

领域语言是我们设计的重要部分,我们已经花了一些时间来构建它。我们可以利用这一点,使这种语言对其他开发人员可用。正如我们所看到的,每个模块的语言都略有调整,是一个需要保持更新的工作文档,这意味着我们需要找到一种方式来保持其发布。

发布语言

我们一直在开发的语言是一个不断发展的文档,因此我们必须考虑如何分享其中蕴含的知识。让我们首先定义在一个完美的世界里我们会做什么,然后看看我们如何可以接近这种情况。

在一个完美的世界里,开始开发应用程序的团队会一直保持在一起,直到应用程序的整个生命周期,并继续成长,但核心开发人员会一直在那里。这样的团队会有一个重大优势,即项目的术语和假设被团队共享,因为他们一直在应用程序的整个生命周期中跟踪,并且新的开发人员会加入并通过渗透学习核心团队。他们会慢慢适应团队并遵循规则,必要时打破规则,如果团队一致同意的话。

然而,我们并不生活在一个完美的世界,团队可能会有一些变动,核心开发人员因某种原因离开,并被新面孔取代。当这种情况发生时,应用程序的核心原则可能会丢失,围绕项目的语言可能不再遵循最初的规则,以及许多其他不好的事情。幸运的是,与古代相比,我们不必依赖口口相传,而是可以为他人记录我们的发现。

创建文档

文档通常不是软件开发中最受欢迎的部分,但这是因为许多项目中的文档并不实用。当我们创建文档时,重要的是不要陈述显而易见的事实,而是实际上记录在开发过程中出现的问题和想法。

通常,项目中找到的文档是方法的概要,它们接受什么参数,以及它们返回什么。这是一个很好的开始,但并不是所有必要的文档的结束。当不同的人使用项目时,他们需要理解其背后的意图以正确使用 API。当我们创建一个应用程序并决定我们想要什么样的功能以及它们如何工作时,这是重要的文档。到目前为止,在这本书中,我们一直在专注于如何思考应用程序开发,以及如何确保它以一种可理解的形式供他人遵循。所有这些都是需要保留的文档。我们希望确保下一个人能够理解开发过程中的思维,知道术语的含义以及它们之间的关系。

一个很好的开始是保持一个中心文档,其中包含这种信息,靠近应用程序并且对所有感兴趣的人都可访问。使文档尽可能简短,并且有一种方式可以随着项目的发展而看到它的演变是关键的,因此具有某种版本控制是一个非常有用的功能。回溯源代码中的时间是非常常见的,以找出某段代码是如何改变的,能够将正确的文档与之相关联是非常有帮助的。

提示

将一个简单的文本文件作为项目的 README 是一个很好的开始。这个 README 可以存在于应用程序存储库中,使文档和应用程序之间的关系非常紧密。

接下来我们通过一个罐头假 API 服务器的示例来看这一点,可在github.com/sideshowcoder/canned找到:

创建文档

文档的重要点是:

  • 项目目标的简短陈述

  • 贯穿整个项目的设计理念,以指导新开发人员。

  • 功能的示例用法

  • 非常重要的代码实现说明

  • 主要变更的更改历史

  • 设置说明

更新文档

将文档保持在应用程序附近具有一些重要的优势;在某个需要特殊权限访问的 wiki 中忽视一些文档太容易了,而在项目上工作时每天查看某些东西更可能保持最新。

文档是整个项目的一个活生生的部分,因此它需要成为项目本身的一部分。特别是在现代、开源激发的开发世界中,每个人都应该能够快速地为项目做出贡献的想法已经根植于开发者文化中,这是一件好事。代码比千言万语的架构规范更有说服力,因此将文档限制在核心设计理念,同时让代码解释具体实现,使文档在长期内更有用,并保持开发人员参与更新过程。

测试不是唯一的文档

关于测试的一个侧面说明:通常 TDD 被认为具有提供测试作为文档的好处,因为它们毕竟是如何使用代码的示例。这经常成为不费力地在外部编写任何示例和不记录整体设计的借口,因为阅读测试可以说明设计。

这种方法的问题在于对于测试来说,所有方法都同等重要。很难传达一个辅助决策,因为它似乎在这一刻没有影响,从项目的核心设计理念来看,这使得重构变得困难,并且容易使项目偏离轨道并维护从未打算在一开始就有的功能。对于加入项目的开发人员,文档应该指定核心功能是什么,如果他或她发现核心之外的某个晦涩函数有用,这很好,也是阅读测试的好地方,但有一种方法可以区分主应用程序支持的功能与辅助功能。

尝试使这更加互动的一种方法是 README 驱动开发,我们首先编写 README 并使示例可执行,试图使我们的代码通过我们指定的示例作为第一层测试。

提示

您可以在 Tom Preston-Werner 的博客上阅读更多关于 README 驱动开发的内容,tom.preston-werner.com/2010/08/23/readme-driven-development.html

总结

在本章中,我们重点关注了不同子项目形成子域并通过不同方式相互合作的互动。这种合作可以采取许多形式,取决于应用程序整体的上下文和状态,有些形式可能比其他形式更有价值。

当然,合作的正确选择总是值得讨论的,并且随着项目的发展很可能会改变模式。我想要传达的一个重要观点是,这些合作理念并不是一成不变的;它是一个可变的尺度,每个团队都应该决定什么对他们最有效,以及如何保持应用程序和团队工作的实际复杂性低。

在上一部分中,本章重点关注了在为项目创建文档时的重要事项,以及我们如何使其在不深入创建精心制定的规范的情况下变得有用,因为一旦离开最初创建它们的架构师的手,很少有人会接触或理解它们。

在下一章中,我们将探讨其他开发方法如何适应领域驱动设计,良好的面向对象结构如何支持总体设计,以及领域驱动设计如何受到许多其他技术的影响。

第七章:并不全是领域驱动设计

如果我看得更远,那是因为我站在巨人的肩膀上。
--牛顿

与大多数开发中的事物一样,并不仅仅是在开发软件时,大多数概念在之前已经被发现,大多数事情在之前已经完成,但有些微小的变化,或者思想的重新组合,使旧概念更有用,或者实现新的创新用途。软件开发的实践自开始以来一直在不断增长和发展。一段时间以前,结构化编程的概念,使用函数、子程序,以及 while 和 for 循环,被认为是一个新概念。后来,面向对象编程和函数式编程吸收了这些想法,并在此基础上添加了新的想法,以进一步简化可维护性,并允许程序员更好地表达他们在编写程序时的意图。

与这些想法一样,领域驱动设计是从面向对象编程的许多想法中发展而来的,书中已经提到了很多这些想法。还有更多影响这些想法的概念,其中一些与面向对象编程密切相关,例如面向方面的思想,以及使用普通对象来模拟系统中的核心服务层。但也有一些来自其他领域的想法,比如构建领域特定语言。领域特定语言已经存在很长时间了,在 LISP 语言家族中经常见到。

注意

LISP 家族知道不同形式的 DSL,大多数 LISP 程序本身可以被看作是非常轻量级的 DSL。访问en.wikipedia.org/wiki/Lisp_%28programming_language%29了解更多细节。

函数式编程也为领域驱动设计增添了一些想法,尤其是不可变性是一个值得追求的东西,有助于调试,并且从总体上考虑领域。

在接下来的章节中,您将详细了解那些影响领域驱动设计以及一般编程的额外概念。本章将涵盖以下主题:

  • 理解领域驱动设计的先决条件

  • 了解影响,如面向对象和面向方面的编程,使用普通对象进行编程,以及命令查询分离

  • 领域特定语言

  • 其他编程实践,如函数式编程和基于事件的系统

将领域与问题匹配

大部分应用程序的工作意味着考虑如何以机器能理解和处理的方式表达给定的问题。领域驱动设计将这一切重新回到了起点,并确保在领域上工作的人理解问题的机器表示,因此能够对其进行推理和贡献。

在整本书中,我们一直在讨论同时为人类和机器构建一种语言。这意味着采用 JavaScript 给我们的构造,并使其对开发人员和领域专家都具有表现力。

有许多表达问题的方式,其中一些比其他方式更容易理解。例如,在一个非常简单的情况下,可以这样写一个数组中数字的总和:

var ns = [1,2,3,4]
for(var i = ns.length-1, si = ns[i], s = 0; si = ns[i--];) s += si
console.log("sum for " + ns + " is " + s)

这个简单的程序通过在for循环检查中做了很多工作来工作,分配数组的当前元素,求和的初始起始值,并因此使用它们来实现求和。为了使循环更加混乱,它使用了获取数组边界之外的索引的属性,导致未定义,这是为了在检查中跳出循环。

尽管这样做是有效的,但很难理解发生了什么。这是由于命名以及使用复杂的构造来表达求和的概念。

成长为一个领域

考虑前面例子中的领域,我们可以看到 JavaScript 已经为我们提供了更清晰地表达这个领域的术语,假设对数学术语有一定的了解,例如:

var numbers = [1,2,3,4]
sum = numbers.reduce(function (a, b) { return a + b }, 0)
console.log("sum for " + numbers + " is " + sum)

通过使用我们可用的工具,我们可以逐渐成长为领域概念的一般性,扩展已有的内容,并构建需要添加的内容。我们现在使用内置的 reduce 函数来执行与以前的for循环相同的操作。reduce函数将一个函数作为参数,该函数传递到目前为止的结果和当前元素,并且我们还给它一个起始点 0 来启动该过程。对于熟悉该语言的人来说,这更易读,几乎可以立即理解,因为它使用了如何在数组上表达操作的常见概念。

使用相同的基本技术,我们也可以利用内置函数来完成我们领域中的任务。当我们想要计算运输所需的时间时,我们可能只想考虑工作日,因此我们需要过滤周末,使用内置函数,这可以清晰地表达:

var SATURDAY = 6
var SUNDAY = 7

var days = [1,2,3,4,5,6,7,8,9,10,11,12,13,14,15,16]

var transportTime = 11

var arrivalDay = days.filter(function (e) {
  if (e % SATURDAY === 0) return false
  if (e % SUNDAY === 0) return false
  return true
})[transportTime]

我们使用 filter 方法来过滤掉现在的日期中的周末,假设今天是星期一,然后我们可以选择到达日作为数组中的位置。随着我们在开发中的进展,我们可以使这更加清晰,但是使用内置方法来操作已经使代码具有自然的可读性。

不同的领域具有不同的表现优点,通常情况下,对某个问题的理解越深刻,就越有利于围绕这个想法构建领域设计,以便在一般问题的复杂性实际上导致领域驱动设计的情况下。

领域驱动设计的良好领域

到目前为止,我们一直在使用地牢管理系统,该系统管理着进出地牢的单元格和囚犯,如果囚犯在场,地牢就会赚钱。这个领域非常复杂,正如我们已经看到的那样,因为我们到目前为止只是管理了囚犯从地牢中的运输,使地牢在适当的时间有足够的囚犯。当然,这不是一个真实的例子,显然,因为我们一直在谈论兽人。这个例子是基于一个非常真实的应用程序,最初是基于管理酒店预订的想法,包括超额预订和不足预订。

在检查领域时,我们可以看到使其成为领域驱动设计有价值的特性。固有问题非常复杂,涉及许多不同的部分进行协作和建模,以构建一个完整的、可工作的系统。随着系统进一步优化为最佳系统,为使用公司提供最大利润,每个部分也经常会发生变化。

在这种情况下,进一步增加领域设计的价值的是,与系统交互的用户差异很大。不同的用户需要暴露给不同的接口,这些接口被连接在一起。这种共享功能很难做到,拥有一个中心核心模型来模拟共享部分是保持不同子应用程序不会分离的好方法,这在将一组特定于领域的逻辑分割到多个应用程序的项目中是一个常见的问题。

面向对象的力量

到目前为止,我们在构建应用程序的过程中利用的概念绝不是领域驱动设计概念的特定发明。许多熟悉软件工程原则的人会注意到许多来自其他领域的想法。许多想法是多年来许多人培养出来的对象导向的一部分。

到目前为止的面向对象原则

面向对象是关于封装状态和功能的。这个概念是基本的,我们在整本书中一直在使用它来构建系统,并将不同的部分组合成对象。当涉及到面向对象时,JavaScript 对象是特殊的,因为 JavaScript 是少数几种基于原型继承而不是传统继承的语言之一,大多数其他面向对象的语言都是传统继承。这意味着不仅仅是处理继承的一种特殊方式;它还意味着 JavaScript 在处理对象时有一种非常简单的方式:

var thing = {
  state: 1,
  update: function() {
    this.state++
  }
}

thing.update()
thing.update()
thing.state         // => 3

这是创建对象的最简单方式,也是 JavaScript 中最常用的方式。

我们已经使用对象来表示值对象以及实体,特别是值对象。面向对象编程的一个关键点是对象提供的隔离;在构建系统时,我们通过让对象相互发送消息来构建系统。当我们能够将命令消息与查询消息分开时,这种方法特别有效。将命令与查询分开使得测试更容易,对代码的推理也更好,因为它将修改状态的事物(命令)与幂等操作(可以在不引起任何副作用的情况下执行的查询)分开。另一个更重要的优势是,将查询与命令分开允许我们更清楚地表达命令在领域中的意义。当我们向领域对象发出命令时,它在领域中具有重要意义,因此应该独立存在,并且应该在项目中建立的通用语言中。当发出命令时,我们总是希望表达“为什么”,将查询与命令捆绑在一起不允许名称同时表达两者。

提示

一个常见的例子是更新对象属性的命令,比如updateAddress,这个命名并没有告诉我们“为什么”更新它。changeDeliveryTarget更清楚地说明了为什么更新了这个属性。在查询方法中混合这些类型的更改是不自然的。

原型继承为我们提供了另一种很好的建模数据的方式,与传统继承相比,原型继承中的链通常相当浅。原型的重要特征是它们允许我们动态地从任何对象继承。以下代码显示了使用Object.create来继承和扩展对象的用法:

var otherThing = Object.create(thing, {
  more: { value: "data" }
})

otherThing.update()
thing.update()

thing.state           // => 2
otherThing.state       // => 2
otherThing.more        // => data
thing.more             // => undefined

使用Object.create方法允许我们轻松地从其他对象构建。它并不总是存在于 JavaScript 中,在此之前,我们需要做更多的工作才能达到相同的效果,但是使用Object.create方法,构建对象非常自然,并且它符合原型继承的概念。

对象非常适合模拟通过系统的数据流,因为它们非常轻量级且可扩展。正如在前面的部分中讨论的那样,我们需要注意一些注意事项。特别是,简单的扩展允许使用浅继承层次结构,同时仍然使用多态来解决控制流。使用多态来控制控制流是面向对象中常见的方法,它允许对象封装知识。当我们向对象发送命令时,我们希望它根据内部的知识和状态来执行,除非我们想发送特定的命令,否则我们不关心它的具体实现。这使我们能够拥有智能对象,它们对针对它们的命令做出不同的响应,例如:

var counter = {
  state: 1,
  update: function() {
    this.state++
  }
}

var jumpingCounter = Object.create(counter, {
  update: { value: function() { this.state += 10 } }
})

jumpingCounter.update()
jumpingCounter.state // => 11

我们再次使用基本的 JavaScript 对象作为基础来构建新功能。这一次,我们通过实现一个新函数来扩展我们简单的计数器的新功能,而不修改基础计数器对象。这展示了易于扩展性的力量——我们可以只使用已经存在的对象中封装的功能,并在其基础上构建,而无需太多仪式。这种可能性是 JavaScript 的许多力量的来源,这是一个很好的力量,但也很容易被滥用。

这导致了一个非常简单的依赖于彼此的领域模型的模型,可以直接使用,也可以在途中进行扩展。

业务领域的面向对象建模

封装业务领域的面向对象的想法通常是非常有益的,因为它导致了一个耦合度较低、更容易理解和修改的系统。当我们把对象看作是我们传递消息并接收答案的东西时,我们自然地对代码的内部结构耦合度较低,因为 API 变成了一个问题和答案,以及一个命令游戏。

在一个非常简单的例子中,回到我们的地牢和其中的兽人,我们可能想要实现一个与入侵者战斗的方法。因此,我们首先通过使用一个非常轻量级的对象来实现一个带武器的兽人,例如:

var Orc = {
  init: function (name, weapon) {
    this.name = name
    this.weapon = weapon
    return this
  },

  get isArmed () { return !!this.weapon },

  attack: function (opponent) {
    console.log(this.name + " strikes "
        + opponent.name + " with " + this.weapon + ".")
  }
}

这里有一个特性并不常用,但非常强大:我们可以通过特殊的getset语法在 JavaScript 中为对象定义 getter 和 setter,这允许我们首先限制对我们的属性的修改范围,同时也允许我们通过其他属性构建更复杂的属性。在这种情况下,我们抽象出了一个缺少武器意味着兽人没有武装的知识。

我们认为战斗是自己的领域对象,因此我们也对其进行建模:

var Fight = {
  init: function (orc, attacker) {
    this.orc = orc
    this.attacker = attacker
    return this
  },

  round: function () {
     if(this.orc.isArmed) {
       this.orc.attack(this.attacker)
     } else {
       this.attacker.attack(this.orc)
     }
   }
}

战斗封装了只有武装的兽人才能在战斗中实际攻击对手的逻辑。当然,这是非常简单的逻辑,但它可能会变得更加复杂。我们将使用一个对象模型来抽象出系统中如何处理战斗的事实。

提示

始终要记住,创建对象,特别是在 JavaScript 中,是非常廉价的。将太多的知识封装到一个对象中并不是一个好主意,往往最好的做法是早期将一个对象分解为不同的责任。一个很好的指标是一个对象有很多私有方法,或者方法的名称与其紧密相关。

现在我们可以用对象来对战斗进行建模:

var agronak = Object.create(Orc).init("Agronak", "sword")
var traugh = Object.create(Orc).init("Traugh")

var fight = Object.create(Fight).init(agronak, traugh)
fight.round() // => Agronak strikes Traugh with sword.

这将战斗的逻辑封装在自己的对象中,并使用兽人来封装与兽人相关的逻辑。

纯对象导向的场景不足

面向对象的基础在很大程度上非常适合对领域进行建模。特别是在 JavaScript 中,由于其非常轻量级的对象创建和建模,它非常适合对我们所见过的领域进行建模。

面向对象的不足之处在于事务管理的层面,我们有一些跨多个对象的交互需要从更高的层面进行管理。另一方面,我们不希望事务的细节泄漏给所有涉及的对象。这就是领域驱动设计的作用,通过价值对象、实体和聚合的分离来管理工作流。在这种情况下,聚合允许通过成为其他协作者的生命周期管理者来管理工作流。当我们将领域建模为由子领域组成时,即使一个实体可能在不同的协作子领域之间共享,每个子领域也有自己的实体视图。在每个子领域中,聚合可以控制完成任务所需的事务,并确保数据处于一致的状态。

当然,在整本书中我们已经看到了多个其他的添加,但是对对象的低级细节进行更高级管理的添加是一个重要的特性,将面向对象的应用程序结构扩展到面向领域的形式对象导向。

保持紧密联系的影响

面向对象不是本书中我们所见过的应用程序开发的唯一影响。许多不同的技术可以用于建模领域概念,并影响应用程序的开发方式。JavaScript 本身是一种非常灵活的语言,可以用于做很多有趣的事情,有时也会被滥用。

根据情况,建模某些方面或解决某些问题时,可以保留不同的想法,这些想法可以很好地应用。

面向方面的编程

在软件开发的大部分思想核心,都围绕着如何封装逻辑和状态,使其易于访问,并具有一个可理解和可扩展的公共接口。可扩展性是一个非常重要的方面,特别是在商业软件中,因为需求需要根据现实世界进行调整,软件需要能够快速包含新的需求。

面向方面的编程将软件开发的方面的想法置于程序设计的中心,并特别关注如何在不重复和可维护的方式中实现横切关注点。在面向方面的编程的情况下,方面是可能在不同对象之间共享的各种关注点。

面向方面的编程的典型例子是向系统添加审计日志。审计日志是需要在所有不同的域对象中实现的东西,同时又不是对象的核心关注点。面向方面的编程提取了方面,即审计日志,在这种情况下,并将其应用于应该以这种方式处理的每个对象。通过这种方式,它使方面成为系统的核心部分,与业务对象解耦。

由于 JavaScript 具有非常动态的特性,可以非常简单和动态地实现这一点;一个解决方案是使用特性。

注意

所使用的特性基于javascriptweblog.wordpress.com/2011/05/31/a-fresh-look-at-javascript-mixins/

现在我们可以在先前的示例基础上构建,并向我们的Fight对象添加audit日志。我们可以直接将调用添加到fight类中:

var util = require("util")

var Fight = {
  init: function (orc, attacker, audit) {
    this.audit = audit
    if (this.audit) {
      console.log("Called init on " + util.inspect(this) + " with " + util.inspect(arguments))
    }
    this.orc = orc
    this.attacker = attacker
    return this
  },

  round: function () {
    if (this.audit) {
      console.log("Called round on " + util.inspect(this) + " with " + util.inspect(arguments))
    }
     if(this.orc.isArmed) {
       this.orc.attack(this.attacker)
     } else {
       this.attacker.attack(this.orc)
     }
   }
}

为了确保我们可以审计战斗,我们将添加一个标志,然后检查和记录适当的调用。这会给对象添加相当多的管道,因为我们现在还需要依赖一种检查的方式,并因此向util库添加一个依赖。

提示

在大多数情况下,我认为标志参数是一个警示信号,因为它们表明多个关注点混合在一个地方,需要进行切换。通常,这可能是使用面向方面的方法更好地解决横切关注点的指示器。

向兽人战斗添加日志的更好方法是向战斗添加一个可记录的特性。该特性将是以下内容:

var util = require("util")

var asLoggable = function () {
  Object.keys(this).forEach(function (key) {
    if (this.hasOwnProperty(key) && typeof this[key] === ' function' ) {
      var that = this
      var fn = this[key]
      this[key] = function () {
        console.log("Called " + key + " on " + util.inspect(that) + " with " + util.inspect(arguments))
        return fn.apply(that, arguments)
      }
    }
  }, this)
  return this
}

该代码将每个函数包装在一个函数中,首先记录其参数,然后将其转发给函数。由于 JavaScript 允许我们通过内省能力枚举要扩展的对象的所有属性,因此可以以一种抽象的方式实现这一点,而无需触及对象本身。

当应用于对象时,asLoggable特性会将对象的每个方法包装在一个记录方法中,写出调用了什么函数,以及使用了什么类型的参数,并且为了输出更有意义的信息,它使用了inspect库。

让我们将这应用于先前构建的代码,这意味着用LoggableFight对象替换Fight对象:

var LoggableFight = asLoggable.call(Fight)
var fight = Object.create(LoggableFight).init(agronak, traugh)
fight.round()

现在调用将被记录,输出将如下,但为了可打印性而缩短:

Called init on { init:…, round:…} with { … }
Called round on {…, orc: {…}, attacker: {…} } with {}
Agronak strikes Traugh with sword.

这个添加并不改变整体行为,而是对系统的纯扩展。

以这种方式扩展对象是一种非常强大的技术,但同时也可能非常危险。尽管代码创建起来相当简单,但要理解代码的某些属性来自何处并不容易,很大程度上取决于正确的命名。例如,如果我们完全替换了Fight对象,摆脱了LoggableFight对象名称,那么就不会有任何迹象表明为什么方法突然应用了日志记录,而在一个大型项目中,跟踪代码中的错误将会让开发人员感到困难。

命令查询分离

虽然面向方面是关于在对象级别上分离关注点,命令查询分离是关于在方法级别上分离关注点。我们之前已经看到处理状态是困难的,因此值对象比实体更简单。对于方法也是如此:向对象发送查询意味着只要对象保持相同的状态,查询就会以相同的方式回答,而且查询不会修改状态。这使得为查询编写测试非常容易,因为简单设置对象,并断言方法的输出就可以了。

命令可能更复杂,因为它们修改了被发送到的对象的状态。一般来说,命令没有返回值,但应该只导致对象的状态发生变化。这使得我们更容易测试命令的结果,因为我们可以设置一个对象,发送一个命令,并断言适当的变化已经被应用,而不必同时断言正确的返回值已经在途中返回。在编写命令时,我们需要牢记的是管理它们的失败状态,根据应用程序的不同,有多种处理方式。最简单的方式可能是引发异常,或者在使用async命令时,将错误返回给回调函数。这允许管理聚合,以便对问题做出反应,要么回滚,要么适当地处理问题。无论哪种方式,我们都不希望返回更复杂的结果,因为这很快就会导致依赖从命令返回的数据。

命令查询分离是编写可维护的、可测试和可扩展的代码时要牢记的核心原则之一。

普通旧对象

随着分离的出现,人们倾向于尽可能简化事物,尤其是在 JavaScript 中,大多数应用程序的最佳选择是使用 JavaScript 提供的简单、普通对象。我们在 JavaScript 中构建对象的方式有多种,本书中我们一直在使用经典的和更类似类的模式:

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

Orc.prototype.introduce = function () {
  console.log("I AM " + this.name + "!")
}

在本章中,我们还使用了更类似 JavaScript 的模式,使用Object.create和示例对象。

在所有这些中需要注意的重要事情是,代码远离使用复杂的容器来管理对象、生命周期等。使用普通对象,无论使用什么模式来构建它们,意味着它们可以在隔离的情况下进行测试,并且在应用程序中简单地跟踪,同时根据需要广泛地使用核心语言的模式。

领域特定语言

使用特定关键词来描述领域的部分是我们在使用领域驱动设计构建系统时设定的主要目标之一。特别是 LISP 社区对 JavaScript 产生了影响,有一种强烈的倾向将语言与问题融合在一起。这自然地导致进一步尝试使语言适应领域,最终目标是拥有一种完美解决特定问题的语言。

这种开发被称为使用特定领域语言,简称DSL。在日常工作中,我们经常遇到许多 DSL,无论是用于描述 HTML 文档样式的 CSS,还是用于与数据库交互的 SQL。语言是 DSL,还是通用语言的界限通常有些模糊。例如,SQL 通常被认为是一种“真正”的编程语言,即使它具有修改和查询关系数据库的非常具体的目的。

DSL 通常是在主机语言和库上定义和实现的,首先提供功能,然后通过添加特殊语法来进一步完善。最近的一个例子可以在 Ruby 世界中看到,服务器管理工具 Chef 最初是一组函数库,用于控制服务器配置,但随着系统的发展,它变得更像 DSL,到现在为止,描述配置的语言仍然托管在 Ruby 上,但有自己的词汇来描述服务器管理的具体内容。当然,这种模式的优势在于底层语言仍然是 Ruby,一种通用语言,因此当 DSL 达到极限时,总是可以使用主机语言进行扩展。

创建 DSL

在我看来,这种模式是我们想要在系统中遵循的。在构建新应用程序时,从实际角度来看,开始设计 DSL 来解决领域的核心问题——在这一点上可能仍然未知——是不切实际的。但我们希望开始构建一个词汇库,用来描述我们的问题,将这种词汇库越来越紧密地粘合在一起,同时填补空白。这就是大多数(好的)DSL 的演变方式。它们起初是一个库,然后不断发展,直到达到一个实际上可以将语言本身提取为核心领域部分的程度。

JavaScript 本身充满了 DSL,因为语言设计非常适合构建将其功能公开为 DSL 的库。再次强调,界限并不总是清晰,但当我们看到以下代码时,我们可以看到某种 DSL 的特性。以下片段是来自jquery.com/的一个例子:

var $hiddenBox = $( "#banner-message" );
$( "#button-container button" ).on( "click", function( event ) {
  $hiddenBox.show();
});

代码使用jQuery选择引擎来定义元素上的点击处理程序,并在其中触发操作。

jQuery 已经成为几乎无处不在的库,并且被一些 Web 开发人员认为是必不可少的。jQuery 首先介绍了通过其选择器选择特定页面元素的方法,无论是通过#选择元素 ID,还是通过.选择类别元素。这种重用 CSS 中的选择器定义来选择页面元素的方式,因此能够使用一个函数——$,来创建一种语言来操作各种页面元素,这就是 jQuery 的真正力量。

领域驱动设计中的 DSL

当我们看其他领域特定语言时,我们应该意识到我们自己的开发方法并没有离真正领域特定语言的力量太远。当然还有很长的路要走,但即使是本章开头的简单示例也显示了我们在正确命名事物方面的发展,以便能够发展一种我们可以与领域专家交流的语言。这是领域特定语言的另一个优势,因为目标是使语言尽可能易于理解,以便于不被视为系统核心开发人员的人使用。

就像 jQuery 使得网页设计师可以开始使用 JavaScript 操纵他们的网页一样,我们项目中的语言应该使业务所有者能够检查系统应该反映的规则是否真正如他们所期望的那样。以下代码显示了我们如何使用我们的构建函数,清楚地展示了代码如何执行囚犯转移:

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(otherDungeon, transfer)
    callback()
  } else {
    callback(new Error("Transfer initiation failed."))
  }
}

即使业务专家可能不会直接理解前面的代码,但它使我们能够进行解释。我们可以向专家解释输入,说:“囚犯转移涉及到囚犯被送往的地牢,囚犯来自的地牢,我们还需要通知地牢”。通过代码,我们可以解释沿途的步骤:

  1. 一个囚犯应该被转移到另一个地牢。

  2. 我们需要一个看守和一辆马车来执行转移。

  3. 如果转移成功,将向地牢发送一条消息。

我们的目标是尽可能接近普通英语的简单易懂的规则。即使我们可能不会在日常代码审查中涉及业务专家,但在需要时能够尽可能接近代码来交叉检查规则是有用的,也能减少我们自己的心智负担。

获取知识

面向对象和它的特定形式当然不是我们唯一的影响,也不是我们应该拥有的唯一影响。在软件开发领域,已经发现了许多不同的开发软件的方法都是有用的,并且具有价值。根据我们想要构建的系统类型,甚至不总是最好的模拟为对象。

有一些非常常见的方法非常适合特定的问题,无论是在面对并发问题时采用更加函数式的方法,还是在尝试构建规则引擎时采用更加逻辑的方法。所有这些方法都会影响我们思考系统的方式,而我们工具箱中有更多不同的方法,我们就能更好地选择适合的方法。

提示

当然,某些方法对个人来说感觉更好;例如,我面对纯函数式、静态类型的方法时,例如 Haskell 用于开发软件时,我很难表达我对问题的想法。不过,不要因为这种挣扎而感到沮丧,因为即使你的日常工作似乎不适合这种方法,你可能会遇到一个完全适合的问题。

因此,我认为除了了解面向对象解决问题的方法之外,与领域驱动设计的密切关系并不是全部,介绍其他思考方式可能非常有帮助,可以从中获取知识。

函数式编程

函数式编程是一种将计算建模为表达式求值的编程风格。
--wiki.haskell.org/Functional_programming

函数式编程在过去几年中获得了很大的影响力,它不仅在利基社区中获得了关注,而且一些公司也是基于函数式编程的理念创立的。

尽管它已经存在很长时间,但对函数式编程思想的兴趣最近出现了激增,但在开发需要同时为大量用户提供服务并尽可能无错的大规模系统时会出现问题。函数式编程的前提是,开发的大部分工作可以以纯函数式的方式完成,避免状态的变异以及传递函数到其他函数上执行,或者将值对象转换为最终结果。

随着我们的系统变得更加并行,并且需要处理更多并发请求,我们的代码越函数式,与不可变数据结构的交互越多,管理这种日益复杂的情况就越容易。我们可以避免对更复杂的锁定的需求,以及难以调试的竞争条件。

函数式编程和 JavaScript

JavaScript 受到了许多影响,无论是面向语言本身的原型继承机制,还是函数作为第一类公民的方式,就像Scheme,一个 LISP 方言中一样。

尽管这可能不是许多人使用该语言的主要关注点,但来自 Scheme 的这种影响使 JavaScript 在某种程度上具有函数式特性:

var numbers = [1,2,3,4,5,6]

var result = numbers.map(function(number) {
  return (number * 2)
}).filter(function(e) {
  return (e % 2) === 0
}).reduce(function(acc, val) {
  return acc + val
})

result // => a number, like 40

在本章的开头,我们已经在数组上使用了reduce函数,现在我们可以继续使用filtermap来创建更复杂的操作链。这些方法都非常相似,并且抽象了迭代应该如何处理的知识,但它们让你表达要执行的动作。在映射的情况下,将每个元素转换为其平方,而在过滤的情况下,筛选出不符合特定标准的元素。

JavaScript 有基本的方法以函数式的方式操作元素。使用 map、reduce 和 filter 等方法,我们可以快速修改集合,例如,这种编程方式经常用于以类似的方式修改一组 HTML 元素。

当然,这样的程序也可以写成for循环,但在这种情况下,意图会在循环的设置以及循环变量的管理中丢失。专门用于修改集合的函数式方法是将问题简化为核心步骤并将其描述为要应用的函数的非常有效的方法,而无需担心如何映射每个元素,从原始集合中选择元素,以及最重要的是存储结果的位置。

为了增加可读性,我们可以根据需要命名被应用的函数,以减少读者理解函数体的心智负担。结合更高的抽象级别,这些集合方法,如之前介绍的filterreduce等方法,意味着我们可以快速创建非常表达性的 API。

值对象

我们不想担心存储结果的位置,只是简单地操作输入,让语言来处理中间结果以及如何管理元素的传递,这是函数式编程的核心优势。尽管这对于 JavaScript 来说并非如此,但很容易看出编译器如何优化前面的代码,以批处理方式传递项目,甚至在单独的工作线程上操作项目,而不是让主进程做所有工作。

当我们不必直接处理并发问题时,这些优化很容易实现。并发的主要问题是程序不同部分之间的共享状态。因此,从功能方法中可能学到的主要内容是我们之前所说的“值对象”,即仅通过其属性而不是其身份来识别的对象,是一件好事。我们可以轻松地传递它们并使用函数修改它们的集合,并与任何人分享,因为它们不会改变。

值对象使依赖关系变得浅显,因为它们终止了我们必须考虑的链条。一旦到达值对象,我们可以确信,如果想要测试某些东西,只需要构造一个。不需要模拟、存根或任何复杂的东西。

值对象不仅是功能方法的核心,也许与函数是第一类事物的想法一样重要,而且它们还用于表示要通过系统传递的数据。正如我们之前所看到的,这些数据可以流动,而不必停下来作为一个整体进行评估。这种思维自然地导致了我们工具箱中的另一个工具,即使用事件来模拟系统的状态。

事件

现实世界的功能是通过对行动和事件的反应系统来实现的。如果有人想让我打开公寓的门,他们会按门铃,如果我在家,我会对此做出反应并去开门。这是一个明确定义的事件流程:首先有人决定触发我打开门,所以他们需要发现发送事件的服务,在这种情况下是门铃,然后按门铃触发事件。当我听到铃声时,我首先需要检查事件是否真的是给我的,对于门铃的情况取决于我是否独自在家。在决定事件确实是给我的之后,我可以决定如何做出反应,选择适当的处理程序,我会起身去开门。

在执行的每个点上,我可以决定如何处理下一步。例如,如果我在淋浴,我可能决定忽略事件,继续淋浴。完成后,我可能稍后检查门,排队处理事件。同样,在门铃的情况下,事件被多个消费者广播;例如,如果我妻子在家,她也可以去开门。从事件发送方面来看,我们也有多种选择:如果我在别人家外面,我可以决定按门铃,但如果没有反应,我可以检查是否有其他方法触发信号;例如,我可以敲门。以下图表显示了描述的事件流程:

Events

这个小例子展示了通过小组件之间通过事件通信来建模系统的强大力量。每个组件可以根据其当前负载或同时触发的其他事件来决定如何响应事件。可以通过在发送方或消费方重新排序事件来实现优先级,以确保系统对于约定的服务水平协议具有最佳的响应能力。

JavaScript 在其核心提供了这种事件处理,NodeJS 的EventEmitter是对核心思想的一个很好的抽象,导致非常清晰的代码,例如:

var EventEmitter = require("events").EventEmitter
var util = require("util")

function House() {
  EventEmitter.call(this)

  var that = this

  this.pleaseOpen = function() {
    // use the EventEmitter to send the ring event
    that.emit("ring")
  }
}

util.inherits(House, EventEmitter)

var me = {
  onRing: function(ev) {
            console.log("opening door")
          }
}

var myHouse = new House()
// subscribe to the ring event, and dispatch it
myHouse.on("ring", me.onRing)

myHouse.pleaseOpen()

EventEmitter函数允许我们向任何需要的对象添加常见的与 JavaScript 文档对象模型交互的功能。在前面的代码中,我们使用inherits助手使我们的House成为EventEmitter。有了这个,我们可以对事件进行操作并分派它们。我们可以定义我们希望其他对象能够对其做出反应的事件,就像我们对点击或悬停事件做出反应一样。

事件存储与实体关系映射

根据我们的系统应该实现的目标,保存事件可能很重要。在我们的门铃示例中,当我在淋浴时,可能会出现我听不到事件的问题,以及我决定不予回应的问题。根据触发事件的人的原因,这可能是可以接受的,也可能不可以接受。

如果是邮递员试图投递包裹,而他们不想等待,他们可以设置一个短暂的超时等待回应,如果他们没有得到回应,他们可以在他们那一端再次排队投递包裹,重新上车,然后明天再试一次。在其他情况下,当我们希望系统传递事件来处理这种情况时,这也很常见,例如,当我错过一个电话时,我会收到一条包含通话详情的短信,或者一条保存事件详情的语音邮件,当我准备处理时,我可以这样做。

在许多软件系统中,我们希望事件传递系统尽可能地抽象化。甚至可以将系统构建为纯粹通过存储事件而从不实际修改任何数据,只是生成新事件再次存储。在这一点上,系统只需要知道消费者在事件流中的时间点,然后可以重播所需的内容,从而避免了将实体映射到数据库中存储可修改数据的需要。在这种情况下,唯一的实体是每个消费者在事件日志中的指针。由于这种系统只是最终一致性的,因此这并不容易实现,因为它会引发问题。毕竟,在系统之间发送事件需要时间,但对于一个相当复杂的系统来说,解决这个问题可能是值得的。

提示

这样的系统的一个很好的例子是Kafka,它是一个用于建模、消费、事件创建和存储的整个生态系统,但也有其他类似的例子。Martin Kleppman 在各种场合都写过关于这个的文章,并做过演讲,例如在 2014 年的 Spanconf:www.youtube.com/watch?v=b_H4FFE3wP0

创建这样的系统可能不是开发业务应用程序时最简单或首选的选择,因为支持它的基础设施的要求相当高。应用程序需要处理高可用性的越多,系统出于任何原因开始分布,这样的系统就变得越来越合理。JavaScript 作为一种语言非常适合处理事件,因为它是该语言构建的核心领域——在浏览器中对用户事件做出反应。

进一步阅读

在本章中,介绍了许多不是主要焦点但仍然增进了对领域驱动设计演变的理解的内容。受到解决方法的启发,可以真正改进一般的软件开发实践,因此我推荐进一步阅读。为了进一步理解面向对象,特别是寻找可用的设计模式,我推荐一本名为《四人帮》的书,以及《设计模式:可复用面向对象软件的元素》,作者是Erich Gamma, Richard Helm, Ralph Johnson, John Vlissides, Pearson Publishing. 尽管这本书很老,但它仍然代表了面向对象的经典作品,并确立了许多术语。另外,《Smalltalk 最佳实践模式》,作者是Kent Becks, Prentice Hall,真正拥抱了面向对象的设计,尽管这两本书自然不是专注于 JavaScript,但它们仍然可以在提高您的设计技能方面提供很大帮助。

在本章的另一端,我们详细介绍了如何开始建模事件流,这是目前非常热门的话题。Martin Kleppmann 在这个领域做了很多工作,因此密切关注他的工作将为您带来一些关于如何建模不断增长的应用程序的深刻见解(martin.kleppmann.com/)。

显然还有很多要跟进,但开始进行上述工作自然会导致更多的工作,可能超出短时间内的消化范围,因此我建议您继续跟进并深入研究。

总结

在本章中,我们探讨了领域驱动设计受到影响并可以通过相关软件开发模式进行增强的各种方式。有些模式比其他模式更接近,比如 DSL,有些则更正交,比如将系统建模为一系列事件。

重要的是要确保我们不要陷入尝试仅应用特定模式中看到的技术,而是要看看周围的情况,以确保我们使用合适的工具来完成工作。在其核心,领域驱动设计是关于建模商业软件的,虽然大多数商业软件遵循类似的模式和规则,但一些内部细节可能非常适合作为整个软件集成的功能核心,甚至是开发允许非技术业务专家清晰表达其规则的 DSL。

在下一章中,我们将总结我们遇到的所有细节,并思考如何处理像大多数商业软件一样不断变化的产品。

第八章:看到一切如何结合在一起

所有软件项目都是特殊的,永远不可能有一种“一刀切”的方法,但正如我们所看到的,对开发的各种不同方法都进行了深思熟虑。一个项目经历了许多开发阶段,通常开始探索基本的想法,有时甚至在那个阶段我们都不能确定项目的领域是什么。然后我们开始分解应用程序的某个核心能力,核心领域开始逐渐发展。在这个阶段,业务专家的参与至关重要,以确保领域与业务需求保持一致,并且项目不会因误解而岔道,同时通用语言也在不断发展。项目往往会从一个或两个开发人员发展成一个更大的团队,团队组织变得更加重要,因为我们需要开始考虑开发中涉及的沟通开销,因为不再成立的假设是每个人都对代码库中的几乎所有内容都很熟悉。在这一点上,团队可以决定采用面向领域的方法,并开始更详细地对现在已经建立的领域进行建模。尽管在后期阶段可能不需要业务专家的每日参与,但持续的参与对确保项目不偏离核心领域需求至关重要。

这种理想化的项目增长形式取决于多种环境因素,团队不仅需要做出所描述的选择,应用程序也需要准备好采用这种方法。我们之前看到,并不是所有项目都适合面向领域的方法,还有许多不同类型的 JavaScript 项目,可以在开发的不同阶段适用这种方法。

在本章中,我们将看看不同的项目、一些领域以及这两个因素,以及面向领域驱动设计如何适应整个画面。我们将探讨:

  • 涉及 JavaScript 的不同类型的项目

  • 客户端和服务器端开发如何影响项目

  • 不同问题及其适用于面向领域驱动设计的程度

  • 面向领域驱动设计的示例领域

不同类型的 JavaScript 项目

JavaScript 作为一种非常多才多艺的语言,已经在不同的开发阶段取得了成功。最初被构想为在浏览器中实现更动态的行为,它不仅征服了使用浏览器作为平台和运行时的开发复杂的客户端应用程序领域,而且现在也在使用 Node.js 的服务器端应用程序中得到了广泛应用。

从通过加入效果使文档看起来更具交互性,到在客户端渲染整个应用程序,这是一个广泛的复杂性和应用程序的范围。一些可能需要更多关注应用程序设计的方法,一些可能最好通过保持逻辑简单和本地化的较小脚本式方法来提供最佳维护。

增强用户体验

许多商业应用程序完全适合由一些页面组成的应用程序。在服务器端渲染所有页面在最长时间内一直是最简单的方法,而且很可能仍然是最简单的方法,因为它将技术堆栈保持在最低水平。一旦页面开始变得复杂,增加一些动态元素可以极大地改善用户体验。这些元素可以用于指出功能或引导用户。例如,对输入进行一些客户端验证可能非常有用,这样用户就不会发送明显无效的请求,并且不必等待服务器的缓慢响应:

<form>
  <label>
    Check Me: <input type="checkbox" id="check-me"></input>
  </label>
  <button id="disable-me">
    I can be clicked if you checked the box
  </button>
</form>

这样的表单经常会出现,我们希望在复选框被选中之前阻止用户点击按钮,并且在请求有效之前可能还需要达成一些协议。在服务器端进行验证很重要,但在点击按钮之前向用户提供一些反馈将是一个很大的增强。一个小的 JavaScript 函数,比如下面的例子,可以很容易地实现这一点:

window.onload = function () {
  var checkMeBox = document.getElementById("check-me")
  var disableMeBtn = document.getElementById("disable-me")

  function checkBoxHandler() {
    if(checkMeBox.checked) {
        disableMeBtn.removeAttribute("disabled")
      } else {
        disableMeBtn.setAttribute("disabled", "true")
      }
    }

  checkBoxHandler()
  checkMeBox.onclick = checkBoxHandler
}

我们检查复选框的值,并根据需要停用或激活按钮。

这是一个业务规则,我们希望在代码中看到它的体现;另一方面,该规则也在服务器端执行,因此不需要使其在任何情况下都能正常工作。这类问题经常在应用程序中出现,我们不希望立即使用过于强大的工具。例如,如果我们开始将表单对象设计为业务对象,并封装表单是否“可发送”的规则,我们可能会得到一个更清晰的设计,但代码的可读性会受到影响。这是在大部分是 UX 增强的项目中不断权衡的问题。通常来说,将视图代码与业务规则混合在一起是不好的;另一方面,过度设计非常小的增强功能,比如前面的代码,很容易在创建更复杂的基础设施以获得更清晰的模型时失去意义。

像这样的 UX 增强并不适合领域驱动的方法,因为业务逻辑的知识将不得不被复制,需要一个单独的适配器来处理 HTML 表示和服务器模型表示。这样的适配器会带来一些额外的开销,并且根据封装的功能量,它们未必是有意义的。随着客户端代码的增长并向应用程序发展,这种做法开始变得更有意义。

单页应用程序

近年来,厚客户端应用程序的概念再次变得更加普遍。在 Web 的早期,网站是静态的,后来使用 JavaScript 进行增强以便于导航或基本用户交互。近年来,浏览器中的客户端应用程序开始增长到一个程度,其中很多业务逻辑都存在于前端,并且前端本身成为了一个真正的应用程序。

提示

很久以前,当世界仍然围绕着大型机转动时,计算环境中的客户端通常是接受用户输入并显示输出的哑终端。随着硬件变得更加强大,越来越多的业务逻辑被移至客户端进行处理,直到我们达到了真正的客户端应用程序,比如运行 Microsoft Office。现在我们可以在浏览器中看到同样的情况,随着应用程序变得更加复杂,浏览器的功能也变得更加强大。

单页应用程序通常在 JavaScript 中实现大量的业务逻辑,作为向服务器查询的厚客户端。这样的应用程序有很多例子,从更传统的面向文档的风格到在浏览器中使用 HTML、CSS 和 JavaScript 作为运行环境的应用程序,更多或更少地完全接管了浏览器。

在开发基于浏览器的应用程序时,底层代码的结构比增强网页功能时更加重要。问题空间被分成几个部分。首先,代码需要以一种能够在应用程序增长和变化的情况下保持可维护性的方式进行组织。随着前端应用程序代码现在实现业务逻辑的更大部分,维护负担增加,重写更大部分的风险也随之增加。应用程序对系统的投资很大。其次,尽管客户端的技术堆栈似乎相当固定,包括 HTML、CSS 和 JavaScript,但最佳实践和浏览器对功能的支持正在快速发展。同时,向后兼容性至关重要,因为开发人员对用户的升级过程没有太多控制。第三,客户端应用程序的性能方面很重要。尽管 JavaScript 运行时引擎的速度提升已经很大,但用户对应用程序的期望越来越高,更重要的是,他们也在同时运行越来越多的应用程序。我们不能指望我们的单页应用程序拥有机器的大部分,但我们必须谨慎使用资源。

性能需求增加与灵活性需求之间的对比是驱动框架和技术的发展,以支持客户端应用程序的开发。我们希望框架在保持灵活性的同时避免过度抽象,这可能会在性能方面对我们的应用程序造成成本。另一方面,我们的用户期望有更多的互动,这需要越来越复杂的应用程序代码来管理,因为客户端应用程序的规模不断增长。

不同框架及其影响

JavaScript 框架的世界非常广阔,不断发布和放弃具有不同承诺的新框架。所有框架都有它们的用例,并且,虽然提倡不同的架构,但都考虑提供组织 JavaScript 应用程序的方式是必不可少的。

一方面,有一些小型框架或微框架,几乎像库一样,只提供最基本的组织和抽象。其中最知名且可能是最广泛使用的是 Backbone。目标是提供一种在客户端路由用户的方式——处理 URL,并在浏览器中重写和更新应用程序状态。另一方面,状态封装到模型中,提供和抽象对内部客户端状态的数据访问,以及远程服务器端状态,因此可以管理这两者的同步。

在光谱的另一端,我们发现更大的应用程序框架,其中一个流行的是 Ember,在浏览器中提供更集成的开发体验。处理数据同步,在应用程序页面中处理太多不同的控制器,以及通过模板将不同的对象呈现到浏览器的高级视图层,包括界面和后端模型表示之间的数据绑定。这在很大程度上符合 Smalltalk 的老派方法,比如模型视图控制器模式。

使用 Ember 为我们的兽人命名的简单应用程序可能是这样的:

window.App = Ember.Application.create()

App.Orc = Ember.Object.extend({
  name: "Garrazk"
})

App.Router.map(function () {
  this.route(' index' , { path: ' /'  })
})

var orc

App.IndexRoute = Ember.Route.extend({
  templateName: ' orc' ,
  controllerName: ' orc' ,
  model: function() {
    if(!orc) orc = App.Orc.create();
    return orc
  }
});

var names = [ "Yahg", "Hibub", "Crothu", "Rakgu", "Tarod", "Xoknath", "Gorgu", "Olmthu", "Olur", "Mug" ]

App.OrcController = Ember.Controller.extend({
  actions: {
    rename: function () {
      var newName = names[Math.floor(Math.random()*names.length)];
      this.set("model.name", newName)
    }
  }
})

顶级应用程序管理上下文,然后我们定义路由和控制器,就像大多数 MVC 应用程序中所做的那样。这种模型相当适用,并允许非常不同的应用程序。优点是我们可以很大程度上依赖预构建的基础设施。例如,在前面的代码中,路由和控制器之间的连接可以相当容易地设置,通过声明性地分配templateNamecontrollerName来使用。此外,与视图的连接几乎已经完成,允许我们定义主应用程序模板如下:

<html>
  <head>
    <script src="http://code.jquery.com/jquery-1.11.3.min.js"></script>
    <script src="http://builds.emberjs.com/release/ember-template-compiler.js"></script>
    <script src="http://builds.emberjs.com/release/ember.min.js"></script>
    <script src="/app.js"></script>
  </head>
  <script type="text/x-handlebars" data-template-name="orc">
    <p> ORC! {{ name }} </p>
    <button {{action "rename"}}>Give me a Name!</button>
  </script>
</html>

使用Handlebars.js进行模板化,并使用preassign模型进行交互,Ember 被设计为能够扩展非常大的前端应用程序,接管浏览器交互并提供完整的应用程序框架。

在这方面,我们几乎可以找到中间的一切。在领域驱动开发的世界中,我们现在必须选择最适合我们的应用程序和我们的开发风格。似乎较小的框架更适合领域驱动设计,因为它允许开发人员有更多的影响力,但这未必是真的。对我们来说重要的是我们如何与框架进行连接。就像我们在服务器端与之交互一样,我们希望将我们的代码抽象为简单的 JavaScript 对象,将框架视为一个层,用于获取用户显示的内容和用户输入返回到我们的领域层。我们希望我们的领域层尽可能地与框架分离。随着当今开发中模型-视图-控制器等组织形式的普及,只要我们不陷入围绕框架开发的陷阱,而是坚持讨论的组织形式,作为框架所需功能的实现方式,框架允许更好地进行组织上的分离。

在客户端渲染

根据我们正在开发的应用程序,完全采用客户端应用程序可能不是最佳选择。大多数业务应用程序最终都是非常任务导向的,通过表单操作数据,并根据此输入触发一些逻辑。操作的结果然后以类似文档的方式反映出来。这代表了大多数业务的做法,它涉及一个过程来完成一个任务,并以报告结束。考虑一下我们在整本书中一直在开发的应用程序,我们会发现一个类似的模式。我们一直在开发的应用程序的一部分包括几个步骤,涉及地牢主持人通过填写有关即将发生的运输的细节来触发某个特定操作。然后后端决定是否满足条件,是否可以满足请求,并触发适当的操作。大部分逻辑都存在于应用程序的服务器端,并且由于一致性问题,也需要在那里存在。另一方面,客户端非常注重表单,任务涉及一个或多个表单步骤,需要根据给定任务的流程来完成。流程和任务的逻辑在服务器端,因此完全采用客户端应用程序将需要复制大量服务器知识,以给予客户端应用程序的感觉,但然后我们仍然需要与后端进行确认。这在很大程度上消除了将逻辑移动到客户端的好处。

在这种情况下,采用一种折中的方法是很有道理的,可以确保利用服务器端的高级调试功能和监控,同时仍然使应用程序具有更流畅的感觉。这个想法是渲染要放在页面上的 HTML 片段,但是通过 JavaScript 将它们放在页面上,从而避免完全替换整个页面。最常用的库用于实现这一点是pjax,用于请求 HTML 片段,它又使用 jQuery 将片段放在页面上:

var express = require("express")
var app = express()

app.get("/rambo-5", function (req, res) {
  res.send("<p>Rambo 5 is the 5\. best movie of the Rambo series</p>")
})

app.use(express.static(' public' ));

var server = app.listen(3000, function () {
  console.log("App started...")
})

在这个例子中,pjax 需要服务器发送一个 HTML 片段,作为请求的结果放在页面上。这只是一个包含有关 Rambo 电影的一些信息的段落标签:

<!DOCTYPE html>
<html>
  <head>
    <script src="/jquery.min.js"></script>
    <script src="/jquery.pjax.js"></script>
    <script>
      $(document).ready(function () {
        $(document).pjax(' a' , ' #container' )
        var date = new Date()
        $("#clock").html(date.getHours() + ":" + date.getMinutes() + ":" + date.getSeconds())
      })
    </script>

  </head>
  <body>
    <h1>About Rambo</h1>
    <div id="container">
      Go to <a href="/rambo-5">Rambo 5</a>.
    </div>
    <div>This page rendered at: <span id="clock"></span></div>
  </body>
</html>

在客户端,我们只需要让 pjax 劫持容器内的所有链接,使其发送一个 pjax 请求并插入适当的内容。最终结果是一个表现得像一个普通 HTML 页面的页面,但是在点击时不会完全刷新。它只会重新加载适当的部分并更新窗口位置。

当构建服务器密集型应用程序并且仍然能够维护类似应用程序的流畅界面而不需要大量的完全客户端渲染开销时,这种方法可能非常有用。再次,我们可以看到这里有很大的区别,使得前端更像是一个轻客户端,因此这可能不是面向域驱动的首选方法,但是与使用这种方法构建的后端密切合作,因为它现在是关于应用程序逻辑的单一真相来源。

在服务器端使用 JavaScript

JavaScript 作为一种语言,尽管它是为浏览器开发的,但并不仅限于在浏览器上下文中执行。浏览器自然包含了一个用于在页面上下文中执行 JavaScript 的环境。当我们想要在浏览器之外运行 JavaScript 时,总是有直接通过 JavaScript 引擎执行的选项。有多种不同的引擎可用,例如 Mozilla 的 Spidermonkey 或 Google 的 V8。显然,仅仅拥有 JavaScript 是不够的,因此我们需要访问文件、套接字和其他许多其他东西,以便能够有效地处理服务器端代码。

Node.js 接管了这一部分,将 Google V8 引擎与标准的 POSIX 函数捆绑在一起,用于访问系统级部分。这绝不是第一个,还有来自 Mozilla 的 Rhino,将 Java 生态系统与 Java 捆绑在一起,以允许访问 JavaScript 标准库之外的所有部分:

Rhino 1.7 release 5 2015 01 29
js> var file = new java.io.File("./test.txt");
js> importPackage(java.nio.file)
js> Files.readAllLines(file.toPath())
[this is a test text file]

在 Node.js 中,同样的事情看起来有些不同,更像我们从 JavaScript 期望的:

> var fs = require("fs")
> fs.readFile("./test.txt", "utf8", function (err, data) {
... if(err) {
..... return console.log(err)
....}
... console.log(data)
..})
> this is a test text file

有了交互的基础,我们可以构建复杂的服务器端应用程序,并利用服务器端开发的特性,这在书中一直都有。

提示

在即将推出的 ECMAScript 6 标准中,将引入一种新的模块语法,以增加客户端和服务器端 JavaScript 应用程序的模块化。ECMAScript 6 几乎已经完成,但在撰写本文时还没有到处都可用。关于即将到来的变化,特别是关于 ECMAScript 6 模块的一个很好的信息来源是www.2ality.com/2014/09/es6-modules-final.html

受控环境的优势

本书的大部分依赖 Node.js 作为执行环境的原因是它提供了一组我们可以依赖的固定功能。另一方面,浏览器一直非常灵活和可变。这在开发业务应用程序时是一个很大的优势。作为开发人员,我们当然总是希望利用最新和最好的技术,而在合适的地方依赖这些技术是很有意义的,但我们也需要意识到稳定的平台在很大程度上是一个巨大的优势。

如果我们想要建模应用程序的业务逻辑,我们几乎不依赖于任何新技术。我们需要一个稳定的环境,可以执行我们已有的内容和将来会保留的内容。当然,JavaScript 的优势在于我们可以在客户端和服务器端执行,这意味着如果我们以后决定将某些逻辑转移到客户端,我们可以这样做,并且仍然可以回退到服务器端执行规则以进行验证,如果需要的话。

高级模块化

过去,JavaScript 一直被称为浏览器语言,而且在很长一段时间内,加载脚本是超出语言本身范围的,而是由环境的 HTML 部分通过脚本标签处理的。

客户端更高级应用程序的崛起和服务器端 JavaScript 的崛起改变了语言。这种语言正在发展,下一个版本将包括一个模块标准。目前,有多种加载其他资源的方式,使用其中一种是个好主意,具体是什么并不重要。重要的是,加载外部模块使我们能够更好地将代码分离成逻辑单元,摆脱了许多客户端 JavaScript 程序看起来像 1000 多行文件的情况。在服务器端,这个问题已经解决,而客户端还没有远远赶上。

考虑到这些不同类型的 JavaScript 程序和挑战,我们可以思考在设计业务应用程序时我们的目标是什么,以及我们如何看待领域驱动设计在开发过程中的作用。

不同类型的复杂性

每个业务应用程序在开发过程中都面临着不同类型的问题。领域驱动设计的目标是通过提供一种语言以及一组对象在领域中的交互规则,来隔离应用程序的复杂性,使其易于更改和维护。

正如我们在整本书中所看到的,领域驱动设计的核心是建模业务逻辑,以便领域专家可以评判和评估。这是应用程序的重要部分,如果做得好,可以在整个开发周期中节省很多麻烦。在通过领域驱动设计驱动应用程序时,我们需要确定核心领域及其子领域。根据我们的领域是什么,纯粹的业务复杂性是需要建模的,但不是唯一的复杂性。

并非每个应用程序都对业务规则复杂,也并非每个应用程序都适合以我们之前所见的面向对象的方式进行建模。有一些复杂性是不同性质的,更接近于计算机科学所考虑的核心问题领域,就像每个领域一样,它有自己特定的交流和建模方式,当我们遇到时,我们也应该使用这些方式。

提示

将计算机科学作为另一个业务领域是一种抽象化处理我们在处理计算机科学问题时遇到的复杂性的方式。往往,试图将这些问题暴露给业务领域本身是没有用的,只会导致更多的混乱。我们可以将与计算机科学相关的主题看作是我们与之交互以解决非常具体问题的核心,如果我们想要隔离它,就应该以这种方式发展它。

算法复杂性

在数学和计算机科学中,算法是一组自包含的逐步操作。
--维基百科

从本质上讲,我们所做的一切都可以描述为算法。它们可能非常简短和独特,但它们仍然是一系列步骤。例如,我们在业务应用程序中遇到的算法是必须执行的一系列步骤,比如启动囚犯运输。我们遇到的算法是业务规则,并且最好作为领域本身的一部分进行建模,因为它们直接涉及领域对象。然而,还有其他算法,我们可以从数学或计算机科学中重用,它们更抽象,因此不太适合业务领域。

当我们谈论算法复杂性时,我们最常指的是众所周知的算法,如树搜索或算法数据结构,比如列表或跳表。这些抽象的想法不太适合适应我们正在建模的领域,而是在某种程度上是外部的。当我们在领域中遇到问题,并且已知算法能够很好地解决问题时,我们应该利用这一事实,而不是用这些知识混淆领域,而是保持分开。

有一些应用程序,它们的算法复杂度很高,这些应用程序很可能不是领域驱动设计的最佳候选。一个例子是搜索,其中很多知识都存在于数据结构中,使得搜索变得高效,因此可以在更大范围内使用。重要的想法是,在这样的领域中,业务专家就是开发人员,我们应该以开发人员最擅长的方式处理领域。最基本的想法仍然是一样的——我们可能希望通过共同术语促进沟通,但在这种情况下,共同术语是开发人员特定的,最好的表达方式是通过代码,因此方法是编写代码并尝试。

逻辑复杂度

与算法问题密切相关的另一个领域是逻辑问题。根据领域的不同,这些问题可能经常出现,并且具有不同程度的复杂性。这类问题的一个很好的例子是任何类型的配置器,例如,一个允许您订购汽车的应用程序涉及到额外选项可能会发生冲突的问题。根据不同的额外选项和冲突的数量,问题可能会迅速失控。

在逻辑编程中,我们陈述事实,让引擎为我们推导可能的解决方案:

var car = new Car()
var configurator = new Configurator(car)

configurator.bodyType(BodyTypes.CONVERTIBLE)
configurator.engine(Engines.V6)
configurator.addExtra(Extras.RADIO)
configurator.addExtra(Extras.GPS)

configurator.errors() // => {conflicts: [{ "convertible": "v6" }]}

在前面的例子中,配置器由规则引擎支持,这使得它能够确定配置中的潜在冲突并将其报告给用户。为了使其工作,我们创建了一个事实或约束的列表:

configurator.engineConstraints = [
new Contstraint(BodyTypes.CONVERTIBLE, Engines.V8, Engines.V6_6L)
]

有了这一点,规则引擎可以在我们想要订购汽车时检查约束是否满足。

在应用程序中解决逻辑问题类似于解决算法问题,最适合于为此目的构建一个独立的系统,以领域特定的逻辑语言清晰地表达问题,然后将其包装在领域中。

业务规则

在开发业务软件时,我们最常面对的复杂性通常是客户定义的业务规则,我们为其开发软件。这些规则的复杂性往往不是因为规则本身很复杂,而是规则并不是一成不变的,行为可能会改变。更重要的是,它需要快速改变,以使软件对业务保持相关。

实施业务规则意味着跟踪业务需要做什么,这往往是基于业务领域专家头脑中的事实。对于建模领域的重要部分是提取这些知识,并与整个业务验证其有效性。这是一个坚实的领域模型努力产生差异的领域。

当我们能够与最了解领域的人谈论领域时,我们可以快速验证,如果我们与这个人分享共同的语言,他或她可以快速向我们解释新的规则。通常,复杂的数据结构和算法并不是构建应用程序的核心部分,这些部分可以通过外部提供系统进行优化,对领域的理解和灵活的建模是领域模型的力量。

适合领域驱动设计的领域

在本书中,我们专注于构建一个业务应用程序,从本质上来说,这确保我们不会过度或不足地预订我们的地牢,更具体地管理因地牢的限制而需要转移的囚犯。作为开发人员,我们必须严重依赖领域专家指导我们开发,因为我们还没有业务领域的必要知识。在这种情况下,建立一种语言非常方便,因为它允许我们以精确的方式讨论问题所在以及我们如何处理新规则。

以前,我们已经看到并非所有领域都适合这种策略,即使适合的领域中也可能包含最好由辅助系统处理的部分。特别是在启动新项目时,我们无法确定是否值得投资于繁重的领域层。

银行业应用

一个规范良好,有固定规则集,并且主要涉及数字的领域应该是由成熟软件服务的主要候选者,那么为什么会计软件并不多见,为什么银行要如此大力投资于他们的开发团队呢?

许多人从领域驱动的角度探索会计问题,问题主要出现在类似的领域。首先是一系列规则,尽管这些规则从外部看似乎定义得很好,但实际上包含许多需要覆盖并且正确覆盖的边缘情况,因为大量资金正在系统中流动。这些规则大部分由一组专家所知,他们的工作是在市场变化时调整这些规则。这带来了第二个问题,许多非常微妙的变化需要在整个系统中表达并保持一致。

因此,尽管表面上看来,关系数据库覆盖了银行应用中的许多情况,但对于变化所需的灵活性以及与银行专家进行大量沟通的内在需求使得银行成为领域驱动设计应用的一个很好的候选者,如果他们确实想要开始新的开发。

提示

银行业是那些最好由专家来处理的领域之一。如果没有必要构建自己的会计系统,最好购买现成的系统,因为领域的复杂性和错误的可能性非常高。

旅行应用

在整本书中,我们一直在关注与另一个领域驱动设计的主要候选者相关的领域,即旅行和相关预订管理。从软件角度来看,将地牢与酒店进行比较可能有点奇怪,但管理方式是相似的。我们试图在同时优化收入的同时管理过度预订和不足预订。

预订酒店是一个表面上看起来简单而明确定义的领域,但在深入挖掘时容易出现许多调整和复杂规则。例如,当适当查看数据库条目时,完全避免过度预订会相当容易,但这又违反了我们酒店最大化收入的目标。为了补偿最终客人的退出,需要一定数量的过度预订。

这并不是管理预订的唯一复杂部分,业务的一个重要部分是适应季节和当前的市场情况。在城市举办贸易展时预订酒店可能会比普通日子显著更昂贵,特别是如果没有预订整个贸易展的时间,因为这意味着房间可能会空着,即使在整个时间段内可以轻松预订。另一方面,合作伙伴折扣可能使这些展会期间的预订对某些人再次变得更便宜,我们希望确保在预订其他客人时有一定数量的房间可供这些人使用。所有预订还有多个需要管理的时间表,例如折扣窗口,退款窗口等。

近年来,使旅行更加有趣的领域驱动设计的原因是,其表示也在很大程度上发展。以前,系统被优化为通过电话或少量预订代理人操作,现在开始通过网络向公众开放。这种暴露导致请求量显著增加,也增加了所需的支持。甚至最近,这些系统不再直接操作,而是需要通过 API 访问以集成到搜索引擎中。

所有这些都使旅行变得复杂,远不止是从数据库中存储和加载操作;特别是,由于许多系统的集成与一般公众的访问结合在一起,对开发系统的能力来说不仅在性能方面,更重要的是在复杂性方面都带来了巨大的负担。

域先决条件

我们一直在研究的领域都涉及业务领域中不同形式的复杂性,这些领域都适合使用域驱动设计方法。在前面的章节中,我们已经看到了一些适合这种方法的领域。它们有什么共同之处?

正如之前所见,这一切都关乎我们需要解决的不同形式的复杂性。业务规则集快速变化的领域需要更多关注其建模,因为规则需要随着演变而调整。更重要的是,不断演变的规则意味着开发人员并不完全了解规则,因此业务专家需要大量参与。这意味着我们在域驱动设计中构建的语言很快就会得到回报。因此,域驱动设计的一个重要部分是它关乎开发人员的访问和理解领域的能力。我们希望能够迅速将业务专家整合到流程中,以避免误解。最终,业务专家是推动领域发展的人。我们作为开发人员,提供的是使业务更成功的软件。作为我们域驱动设计方法的一部分,我们确定了对业务现在真正重要的事情,以及如何使其更高效和更少出错。

现在从另一方面来看问题,仍然考虑访问,意味着其他系统对系统的访问需要简单。目前,这对许多领域来说是真实的,因为新设备不断流行,业务普遍朝着更高级别的业务集成发展。域驱动设计如何适应其中?关键再次是访问。我们希望能够提供多个可从外部访问的接口,具有相同的基本逻辑。在域驱动设计中,我们致力于构建强大的服务层,然后可以通过不同的接口将该层暴露给不同的系统,而无需复制逻辑,这将固有地增加部分和逻辑的分歧风险。

进一步阅读

正如本书的书名所暗示的那样,它已经受到埃里克·埃文斯在他的书《领域驱动设计:软件核心复杂性的挑战》Addison-Wesley中提出的想法的重大影响,我建议这本书作为后续阅读。他通过提供不同的例子,从经典的 Java 后端方法的角度更详细地介绍了一般方法。

另一本关于软件设计的后续阅读中不应该缺少的书是当然《企业应用架构模式》Martin FowlerAddison-Wesley,它遵循了大多数面向对象开发中每天使用的模式,并更详细地介绍了这些模式。该书更加侧重于 Java 方面,但正如我们在本书中所看到的,以面向对象的方式使用 JavaScript 是非常可能的,并且在许多建模场景中会被推荐。

摘要

随着用 JavaScript 编写的应用程序变得越来越复杂,对更强大的应用程序设计的需求也在增加。浏览器应用程序正在增长,企业对它们的依赖也在增加。因此,曾经是后端开发领域的东西开始变得在前端开发中变得重要起来。长期以来,人们一直在不断演变后端应用程序的建模方式,以便能够灵活发展业务,现在浏览器应用程序也需要做同样的事情。多年来已经开发出了很多方法,有很多值得学习的地方,尽管有些方法并不直接适用于 JavaScript,甚至可能并不需要,但其中的一般思想非常适用。我希望我能在整本书中呈现一些这样的想法。

另一方面,随着 Node.js 作为应用程序开发平台的崛起和采用,JavaScript 也进入了后端,现在需要解决与 Java 或 Ruby on Rails 后端系统相同的挑战,这些挑战现在需要为 JavaScript/Node.js 解决。重要的是要忠于 JavaScript 的本质,因为使用 Node.js,目标通常是使系统更简单,更容易以更小的块进行管理。这反过来意味着 Node.js 后端采用了比传统的企业 Java 系统更轻的建模方法。这对开发人员来说是有力的,因为整体的大规模架构讨论朝着更实用的自下而上的方法发展。这并不意味着架构不重要,但是随着前端和后端系统之间复杂性的分离,采用更轻的方法可以更好地管理复杂性。

posted @ 2024-05-22 12:07  绝不原创的飞龙  阅读(6)  评论(0编辑  收藏  举报