JavaScript 领域驱动设计(二)
原文:
zh.annas-archive.org/md5/CC079113B860BF21A8B35D4B14B4E853译者:飞龙
第六章:上下文地图-整体情况
地牢管理应用程序目前只包含管理囚犯运输的功能,但随着我们应用程序的增长,组织代码的需求也在增加。能够同时在一款软件上工作的开发人员数量有限。亚马逊创始人兼首席执行官杰夫·贝索斯曾经说过,一个团队的规模不应该超过两个披萨所能满足的人数。这个想法是,任何比这更大的团队在沟通方面都会遇到麻烦,因为团队内部的联系数量会迅速增长。随着我们增加更多的人,保持每个人都了解最新情况所需的沟通量也会增加,团队迟早会因为不断需要开会而变慢。
这个事实造成了一种困境,因为正如我们之前所描述的,完美的应用程序应该是每个人都了解开发过程以及围绕变化做出决策的应用程序。这给我们留下了很少的选择:我们可以决定不扩大团队,构建应用程序,但选择一个更慢的开发周期,这个团队可以独立处理(以及整个应用程序上的较小功能集),或者我们可以尝试让多个团队共同开发同一个应用程序。这两种策略在商业上都取得了成功。保持小规模并自然增长,虽然可能不会导致爆发式增长,但可以导致一个运行良好且成功的公司,就像 Basecamp Inc.和其他独立软件开发者所证明的那样。另一方面,这对于固有复杂性并且目标范围更广的应用程序来说是行不通的,因此亚马逊和 Netflix 等公司开始围绕创建由较小部分组成的更大应用程序的理念来扩大他们的团队。
假设我们选择领域驱动设计的理念,我们更有可能拥有一个属于固有复杂领域的应用程序,因此接下来的章节将介绍处理这种情况的一些常见方法。在设计这样的应用程序时不容忽视的一个重要点是,我们应该始终努力尽可能减少复杂性。你将学到:
-
如何在技术上组织不断增长的应用程序
-
如何测试系统中应用程序的集成
-
如何组织应用程序中不断扩展的上下文
不要害怕单体应用
近来,人们开始更加倾向于将应用程序拆分并设计成一组通过消息进行通信的服务。这对于大规模应用程序来说是一个成熟的概念;问题在于找到正确的时间来拆分应用程序,以及决定是否拆分是正确的做法。当我们将一个应用程序拆分成多个服务时,这一点会增加复杂性,因为现在我们必须处理跨多个服务的通信问题。我们必须考虑服务的弹性以及每个服务提供其功能所需的依赖关系。
另一方面,当我们在后期拆分应用程序时,从应用程序中提取逻辑会出现问题。没有理由一个单体应用程序不能被很好地因素化,并且在很长一段时间内保持易于维护。拆分应用程序总会带来问题,而长期保持一个良好因素化的应用程序是可行的。问题在于,一个代码库庞大且有很多开发人员在上面工作的情况更容易恶化。
我们如何避免这样的问题?最好的方法是以尽可能简单的方式设计应用程序,但尽可能长时间地避免子系统之间的通信问题。这正是强大的领域模型擅长的;领域将使我们能够与底层框架强烈分离,但也清楚地指出了在必须分解应用程序时应该如何分解。
在领域模型中,我们已经确定了可以稍后分离的区域,因为我们将它们设计为单独的部分。一个很好的例子是囚犯运输,它被隐藏在一个接口后面,以后可以被提取出来。可以有一个团队专门负责囚犯运输功能,只要公共接口没有改变,他们的工作就可以进行,而不必担心其他改变。
更进一步,从纯逻辑角度来看,实际逻辑执行的位置并不重要。囚犯转移可能只是一个调用单独后端的幌子,或者可能在一个进程中运行。这就是一个良好分解的应用程序的全部意义——它提供了一个接口子域功能,并以足够抽象的方式暴露出来,使底层系统易于更改。
只有在必要的情况下,如果分离出一个服务,并且这样做有明显的好处,减少部署或开发依赖的复杂性,使流程的开发能够尽可能地扩展,最好是有一个团队来负责服务的进一步发展。
面向服务的架构和微服务
在极端情况下,面向服务的架构(SOA)最终会变成微服务;每个服务只负责非常有限的功能集,因此很少有改变的理由,易于维护。在领域驱动设计方面,这意味着为应用程序中的每个有界上下文建立一个服务。上下文最终可以被分解为每个聚合由单独的服务管理。管理聚合的服务可以确保内部一致性,服务作为接口意味着访问点非常清晰地定义。大部分问题都转移到了通信层,必须处理弹性。这可能是应用程序中通信层的一个重大挑战,也是服务本身的挑战,因为它们现在必须处理更多的故障模式,由于依赖方之间的通信失败。微服务在某些场景中取得了巨大成功,但整体概念仍然年轻,需要在更广泛的用例中证明自己。
微服务架构或多或少是演员模型的延伸,只有当将演员变成自给自足的服务时,才是对此的延伸。这增加了更好隔离的通信开销;在领域驱动设计中,这可能意味着围绕实体构建服务,因为它们是应用程序部分生命周期的管理者。
总的来说,无论最终选择哪种架构,都有必要考虑如何准备应用程序以便以后可以分解。精心打造灵活的领域模型并利用有界上下文是以这种方式发展应用程序设计的关键。即使应用程序从未真正分解成部分,有清晰的分离也使每个部分更容易处理,并且由于更易理解,因此更容易修改,组件组合应用程序更少出错。
关键点在于要很好地确定核心领域,并最好将其与系统的其他部分隔离开来进行演化。并不是软件的每一部分都会被很好地设计,但是将核心领域及其子域隔离和定义好,使得整个应用程序都准备好进行演化,因为它们是应用程序的核心部分。
将所有这些记在脑中
每次我们打开我们选择的编辑器来编写代码时,都需要一些开销来知道从哪里开始以及我们实际需要修改哪个部分。了解从哪里开始修改以朝着特定目标前进,通常是一个应用程序能否愉快地工作的关键区别,而不是一个没有人愿意碰的应用程序。
当我们开始处理一段代码时,我们在任何给定时间内能够在脑海中保留的上下文量是有限的。尽管不可能给出确切的限制,但当代码的某个部分超出了这个限制时很容易注意到。这通常是重构变得更加困难的时候,测试开始变得脆弱,单元测试似乎失去了其价值,因为它们的通过不再确保系统功能。在开源世界中,这通常是项目的一个破坏点,并且由于其开放性质,这一点非常明显。在这一点上,一个库或应用程序如果能够让人们投入时间真正理解内部工作并继续朝着更模块化的设计取得进展,那么它就变得非常有价值,或者开发就会停止。企业应用程序也遭受同样的命运,只是人们更加不愿意放弃提供收入来源或其他重要业务方面的项目。
当项目变得复杂时,人们经常害怕进行任何修改,而且没有人真正理解发生了什么。当痛苦和不确定性开始增长时,重要的是要认识到这一点,并开始分离应用程序的上下文,以保持其规模可管理。
识别上下文
当我们绘制应用程序时,我们已经认识到应用程序的某些部分以及它们之间的通信方式。我们现在可以利用这些知识来确保我们对应用程序的上下文有一个概念:
在第一章中,典型的 JavaScript 项目,我们处理了领域中大约有六个上下文。通过最近章节所获得的理解,这有些变化,但基本原理仍在。这些上下文被确定为它们之间的通信是通过交换消息而不是修改内部状态来进行的。在构建 API 的情况下,我们不能依赖于我们处于可以修改内部状态的情况,也不应该有一种方法可以进入上下文并修改其内部,因为这是上下文之间非常紧密的耦合,这将使上下文的有用性变得不明显。
消息是易于建模 API 的关键基础;如果我们以消息为思考方式,很容易想象拆分应用程序和消息不再在本地发送,而是通过网络发送。当然,拆分应用程序仍然不容易,因为突然之间需要处理更多的复杂性,但有能力处理消息传递是摆脱复杂性的一大部分。
提示
函数式编程语言** Erlang**将这个概念发挥到了极致。Erlang 使得将应用程序拆分成所谓的进程变得容易,这些进程只能通过发送消息进行通信。这使得 Erlang 能够将进程重新定位到不同的机器上,并抽象出多处理器机器或多机系统的一系列问题。
拥有明确定义的 API 使我们能够在不破坏外部应用程序的情况下对上下文内部进行重构更改。上下文成为我们系统中可以视为黑匣子的角色,并且我们可以使用它们封装的知识来建模其他部分。在上下文内部,应用程序是一个连贯的整体,并且以一种抽象的方式向外部表示其数据。当我们将域和子域公开为接口时,我们生成了一个可塑性系统的构建块。当它们需要共享数据时,有一种明确的方法可以做到这一点,目标应该始终是共享底层数据并在这些数据上公开不同的视图。
在上下文中测试
当我们确定可以视为黑匣子的上下文时,我们也应该在测试中使用这些知识。我们已经看到模拟允许我们根据不同的角色进行分离,而上下文在这种方式上是进行单元测试时的一个完美候选。当我们将应用程序分解为上下文时,当然也可以在不同的上下文中开始使用不同的测试风格,使开发过程随着我们的理解和应用程序的变化而发展。在这样做时,我们需要记住整个应用程序需要继续运行,因此还需要测试应用程序的集成。
跨边界的集成
在上下文的边界处,从上下文开发者的角度,有多个需要测试的事情:
-
我们这一方的上下文需要遵守其合同,也就是 API。
-
两个上下文的集成需要正常工作,因此需要进行跨边界测试。
对于第一点,我们可以将我们的测试视为 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 + ".")
}
}
这里有一个特性并不常用,但非常强大:我们可以通过特殊的get或set语法在 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."))
}
}
即使业务专家可能不会直接理解前面的代码,但它使我们能够进行解释。我们可以向专家解释输入,说:“囚犯转移涉及到囚犯被送往的地牢,囚犯来自的地牢,我们还需要通知地牢”。通过代码,我们可以解释沿途的步骤:
-
一个囚犯应该被转移到另一个地牢。
-
我们需要一个看守和一辆马车来执行转移。
-
如果转移成功,将向地牢发送一条消息。
我们的目标是尽可能接近普通英语的简单易懂的规则。即使我们可能不会在日常代码审查中涉及业务专家,但在需要时能够尽可能接近代码来交叉检查规则是有用的,也能减少我们自己的心智负担。
获取知识
面向对象和它的特定形式当然不是我们唯一的影响,也不是我们应该拥有的唯一影响。在软件开发领域,已经发现了许多不同的开发软件的方法都是有用的,并且具有价值。根据我们想要构建的系统类型,甚至不总是最好的模拟为对象。
有一些非常常见的方法非常适合特定的问题,无论是在面对并发问题时采用更加函数式的方法,还是在尝试构建规则引擎时采用更加逻辑的方法。所有这些方法都会影响我们思考系统的方式,而我们工具箱中有更多不同的方法,我们就能更好地选择适合的方法。
提示
当然,某些方法对个人来说感觉更好;例如,我面对纯函数式、静态类型的方法时,例如 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函数,现在我们可以继续使用filter和map来创建更复杂的操作链。这些方法都非常相似,并且抽象了迭代应该如何处理的知识,但它们让你表达要执行的动作。在映射的情况下,将每个元素转换为其平方,而在过滤的情况下,筛选出不符合特定标准的元素。
JavaScript 有基本的方法以函数式的方式操作元素。使用 map、reduce 和 filter 等方法,我们可以快速修改集合,例如,这种编程方式经常用于以类似的方式修改一组 HTML 元素。
当然,这样的程序也可以写成for循环,但在这种情况下,意图会在循环的设置以及循环变量的管理中丢失。专门用于修改集合的函数式方法是将问题简化为核心步骤并将其描述为要应用的函数的非常有效的方法,而无需担心如何映射每个元素,从原始集合中选择元素,以及最重要的是存储结果的位置。
为了增加可读性,我们可以根据需要命名被应用的函数,以减少读者理解函数体的心智负担。结合更高的抽象级别,这些集合方法,如之前介绍的filter和reduce等方法,意味着我们可以快速创建非常表达性的 API。
值对象
我们不想担心存储结果的位置,只是简单地操作输入,让语言来处理中间结果以及如何管理元素的传递,这是函数式编程的核心优势。尽管这对于 JavaScript 来说并非如此,但很容易看出编译器如何优化前面的代码,以批处理方式传递项目,甚至在单独的工作线程上操作项目,而不是让主进程做所有工作。
当我们不必直接处理并发问题时,这些优化很容易实现。并发的主要问题是程序不同部分之间的共享状态。因此,从功能方法中可能学到的主要内容是我们之前所说的“值对象”,即仅通过其属性而不是其身份来识别的对象,是一件好事。我们可以轻松地传递它们并使用函数修改它们的集合,并与任何人分享,因为它们不会改变。
值对象使依赖关系变得浅显,因为它们终止了我们必须考虑的链条。一旦到达值对象,我们可以确信,如果想要测试某些东西,只需要构造一个。不需要模拟、存根或任何复杂的东西。
值对象不仅是功能方法的核心,也许与函数是第一类事物的想法一样重要,而且它们还用于表示要通过系统传递的数据。正如我们之前所看到的,这些数据可以流动,而不必停下来作为一个整体进行评估。这种思维自然地导致了我们工具箱中的另一个工具,即使用事件来模拟系统的状态。
事件
现实世界的功能是通过对行动和事件的反应系统来实现的。如果有人想让我打开公寓的门,他们会按门铃,如果我在家,我会对此做出反应并去开门。这是一个明确定义的事件流程:首先有人决定触发我打开门,所以他们需要发现发送事件的服务,在这种情况下是门铃,然后按门铃触发事件。当我听到铃声时,我首先需要检查事件是否真的是给我的,对于门铃的情况取决于我是否独自在家。在决定事件确实是给我的之后,我可以决定如何做出反应,选择适当的处理程序,我会起身去开门。
在执行的每个点上,我可以决定如何处理下一步。例如,如果我在淋浴,我可能决定忽略事件,继续淋浴。完成后,我可能稍后检查门,排队处理事件。同样,在门铃的情况下,事件被多个消费者广播;例如,如果我妻子在家,她也可以去开门。从事件发送方面来看,我们也有多种选择:如果我在别人家外面,我可以决定按门铃,但如果没有反应,我可以检查是否有其他方法触发信号;例如,我可以敲门。以下图表显示了描述的事件流程:
这个小例子展示了通过小组件之间通过事件通信来建模系统的强大力量。每个组件可以根据其当前负载或同时触发的其他事件来决定如何响应事件。可以通过在发送方或消费方重新排序事件来实现优先级,以确保系统对于约定的服务水平协议具有最佳的响应能力。
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 应用程序中所做的那样。这种模型相当适用,并允许非常不同的应用程序。优点是我们可以很大程度上依赖预构建的基础设施。例如,在前面的代码中,路由和控制器之间的连接可以相当容易地设置,通过声明性地分配templateName和controllerName来使用。此外,与视图的连接几乎已经完成,允许我们定义主应用程序模板如下:
<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 Fowler,Addison-Wesley,它遵循了大多数面向对象开发中每天使用的模式,并更详细地介绍了这些模式。该书更加侧重于 Java 方面,但正如我们在本书中所看到的,以面向对象的方式使用 JavaScript 是非常可能的,并且在许多建模场景中会被推荐。
摘要
随着用 JavaScript 编写的应用程序变得越来越复杂,对更强大的应用程序设计的需求也在增加。浏览器应用程序正在增长,企业对它们的依赖也在增加。因此,曾经是后端开发领域的东西开始变得在前端开发中变得重要起来。长期以来,人们一直在不断演变后端应用程序的建模方式,以便能够灵活发展业务,现在浏览器应用程序也需要做同样的事情。多年来已经开发出了很多方法,有很多值得学习的地方,尽管有些方法并不直接适用于 JavaScript,甚至可能并不需要,但其中的一般思想非常适用。我希望我能在整本书中呈现一些这样的想法。
另一方面,随着 Node.js 作为应用程序开发平台的崛起和采用,JavaScript 也进入了后端,现在需要解决与 Java 或 Ruby on Rails 后端系统相同的挑战,这些挑战现在需要为 JavaScript/Node.js 解决。重要的是要忠于 JavaScript 的本质,因为使用 Node.js,目标通常是使系统更简单,更容易以更小的块进行管理。这反过来意味着 Node.js 后端采用了比传统的企业 Java 系统更轻的建模方法。这对开发人员来说是有力的,因为整体的大规模架构讨论朝着更实用的自下而上的方法发展。这并不意味着架构不重要,但是随着前端和后端系统之间复杂性的分离,采用更轻的方法可以更好地管理复杂性。