五、分类和实现
根据 IBM 的一项研究(http://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:不,不! 我所说的是从域对象到数据库对象的转换,正如我们将字段转换为正确的类型和列名等等。
:哦,在这种情况下,你找错人了。 这部分我不熟悉,对不起。
这种对话很可能会在项目中出现不好的命名时发生。 开发人员 1 谈论的是通常被称为数据映射模式,而开发人员 2 谈论的是数据库 API。 使用普遍接受的名称不仅可以简化对话,还可以让某些开发人员更容易地表达他们对代码的哪些部分比较熟悉。
模式最常用于命名编程技术,例如,数据映射模式描述了一种处理对象之间的交互及其对数据库的持久性的方法。
注释
数据映射器在持久数据存储和域对象或类似数据结构的内存中数据表示之间执行数据的双向传输。 它被命名为Patterns of Enterprise Application Architecture、Martin Fowler、Pearson。
在领域驱动设计中,我们也有某些方法来处理某些类型的对象及其关系。 一方面,有用于组织开发本身的模式,另一方面,有用于实现特定目的的对象的名称。 这一章就是关于这种分类的。 我们通过将特定领域元素转化为特定领域驱动设计概念的具体实现来构建对特定领域元素的理解。
放眼全局
在处理一个大型项目时,最常见的问题是弄清楚设计背后的指导思想是什么。 当一个软件变大时,这个项目很可能由多个项目组成,但实际上它被分割成多个子项目,每个子项目负责自己的 API 和设计。 在领域驱动设计方面,有领域和子领域。 每个子域都有自己的上下文; 在领域驱动的设计中,这就是有边界的上下文。 独立的上下文以及主域及其子域的关系将知识放在一个结论性的整体中。
提示
在服务器端,正朝着面向服务的体系结构发展,通过将项目的某些元素分离为独立运行的不同组件,这在项目的某些元素之间引入了相当严重的分裂。
在 Java 中,总是存在定义其自身可见性的包的概念。 JavaScript 在这方面有些欠缺,因为所有代码传统上都在浏览器的一个线程下运行。 当然这并不意味着,所有丢失,我们可以单独的名称空间按照惯例和工具如npm和browserify现在使我们能够使用像分离在前端后端。
支持查找代码的某些部分的过程,以及确定哪些内容可以在域的不同部分之间共享,这是一个已经由不同语言以多种方式解决的问题。 由于 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
指令。 如果我们不使用这个指令,value 属性的赋值将无声地失败。 尽管我们仍然可以确定不会发生任何更改,但这将导致代码出现一些空白。 因此,即使为了保持代码示例的简短,本书大部分都省略了use strict
,仍然强烈推荐使用use strict
。 您可以使用JSLint来强制执行,例如(http://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
对象有另一个有趣的特性,这在我们的系统中可能很有用,这就是它们是参照透明的。 这是一种非常奇特的说法,即无论何时我们有一枚硬币,我们如何处理它都无关紧要,因为它在应用的每个部分都被认为是一样的。 因此,我们可以自由地将它传递给其他函数,并保留它,而不必担心它会发生变化。 我们也不需要跟踪硬币作为一个依赖项,检查它之前可能发生了什么,或者它可能如何被其他函数更改,以防我们传递它。 下面的代码演示了构造为值对象的 coin 对象的简单用法。 即使代码依赖于它,我们也不需要特别注意与对象的交互,因为它被定义为一个不可变值对象:
Orc.prototype.receivePayment = function (coin) {
if (this.checkIfValid(coin)) {
return this.wallet.add(coin)
} else {
return false
}
}
虽然前面的例子是一个只有一个依赖项的保存操作——钱包,但是如果Coin
是一个值对象,那么如果Coin
对象是一个实体,就会复杂得多。 checkIfValid
函数可能会改变属性,因此我们必须研究其中发生了什么。
值对象不仅使代码流更容易理解,而且在整个应用生命周期内处理缓存对象时,引用透明性也是一个非常重要的因素。 尽管 JavaScript 是单线程的,所以我们不必担心对象被其他线程修改,但我们已经看到,对象仍然可以被其他函数修改,而且它们也可能因为其他原因而更改。 对于值对象,我们永远不必担心这个问题,因此我们可以自由地保存它,以便以后在需要时引用它。 在函数之间,可能会发生一个事件,导致我们当前正在处理的对象被修改,这可能会使跟踪 bug 变得非常困难。 在下面的代码中,我们看到了变量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
仅看函数doStuff
和doMoreStuff
,我们希望看到打印到控制台的是 10,但当事件变化交错时,它实际上打印了 11。 这在前面的例子中是非常明显的,但是像这样的依赖关系可以隐藏在代码的深处,跨越更多的函数。 值对象使同样的错误不可能发生,因为对对象的更改将被禁止。 当然,这并不是异步编程中所有错误的结束,需要更多的模式来确保它按照预期工作; 对于大多数用例,我建议查看async(https://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
}
在这种情况下,我们需要确保 buy 操作不会被中断,因为根据其他实现可能会发生奇怪的行为。 如果库存有更多与之相关的行为,比如大小检查,那么情况就会变得更糟,然后我们需要协调这两个检查,同时确保我们可以在不被中断的情况下回滚。 我们之前已经看到事件如何在这里给我们带来很多问题,而这很快就变得难以处理。 尽管在某种程度上处理这个问题是不可避免的,但意识到问题是重要的。
实体需要以确保不存在不一致状态的方式来控制它们的生命周期。 这使得处理实体更加复杂,并且由于锁定和事务控制,也会对性能产生影响。
管理应用的生命周期
实体和聚合都是关于在应用的每个级别上管理这个周期。 我们可以将应用本身看作是包裹其所有组件的聚合,以管理附加的值对象和包含的实体。 在囚犯转移的级别上,我们将转移本身视为一个包裹所有本地依赖项的事务,并管理成功或失败转移的最终结果。
将生命周期管理推到对象链的更上或更下总是可能的,而找到合适的级别可能很困难。 在前面的示例中,赋值也可以是由链上的聚合管理的值对象,以确保其约束得到满足。 在这个阶段,正确的抽象级别是系统开发人员必须做出的决定。 将事务控制推得太高,然后使事务跨越更多对象的代价可能会很高,因为锁更粗,因此并发操作会受到阻碍; 把它推得太低会导致聚合体之间复杂的相互作用。
提示
决定正确的抽象级别来管理生命周期对应用的影响比最初可见的要深。 由于实体是通过其 ID 进行管理的,并且是可变的,这意味着它们是在处理并发性时需要同步的对象,因此它会影响整个系统的并发性。
聚合
面向对象在很大程度上依赖于结合多个协作者的功能来实现某些功能。 在构建系统时,经常会出现这样的问题,某些对象吸引了越来越多的功能,在这一点上,它成为了一种上帝对象,几乎涉及到系统中的每一个交互。 解决这一问题的方法是让多个对象协作来实现相同的功能,但作为小部件的总和,而不是一个大对象。
构建这些相互关联的子系统有一个不同的问题,因为它倾向于暴露大型和复杂的接口,因为用户需要了解更多的内部信息,以便在构建对象结构时使用系统。 让客户端处理子系统的内部并不是建模这样一个系统的好方法,这就是聚合的概念发挥作用的地方。
聚合允许使用向我们的客户端公开一个一致的接口,让他们只处理需要提供的部分,以使系统作为一个整体运行,并让外部入口点处理不同的内部部分。 在前一章,第四章,角色建模中,我们以一辆马车为例讨论了集合,马车由所有必要的元素组成,使其作为一个整体运行。 同样的概念也适用于其他级别,我们构建的每个子系统都是其各部分的集合,由实体和值对象组成。
分组和接口
作为开发人员,我们需要问自己的问题是,在开发过程中,我们如何分组部分,管理那些聚合的接口在哪里,它们应该是什么样子的? 当然,虽然没有严格的公式,但下面描述的部分可以作为指导。
接口应该只要求客户端提供它真正关心的具有灵活性的部件,这通常意味着一个子系统有多个入口点,客户端通过不同的入口接触系统可能会在此过程中踩到其他人的脚趾。 在这一点上,我们可以借用一些经典的技术,并提供所谓的factory
方法,为我们提供所需的对象图的入口点。 这允许使用创建易于阅读的语法,而不是试图利用所有的动态方法来灵活地创建对象,并接受非常不同的参数来提供相同的功能。 下面的代码展示了在创建半兽人的环境下的这种工厂。 我们希望对象构造函数尽可能灵活,同时为常见情况提供工厂方法:
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 的动词负责所有的工作,但由于它们被所有人所轻视,任何动词都不被允许自由走动。 如果一个动词出现在公共场合,它必须一直有一个名词陪伴。当然,“伴游”本身作为动词,是不允许裸奔的; 必须找个保镖护送。 但是“促使”和“便利”呢? 碰巧,facilitator 和 Procurers 都是相当重要的名词,它们的工作是伴随较低的动词“facilitate”和“Procurement”,分别通过 Facilitation 和 Procurement。 | | | | ——- Steve Yegge(2006 年 3 月 30 日星期四) |
服务是一个非常有用的概念,但也经常被滥用,它们通常归结为命名。 做某事的行为既可以用“事情”来表达,也可以用命名“行动者”来表达。 例如,我们可以有一个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)
}
在一个简单的示例中,很明显,第二种方法看起来很复杂,并且没有以任何有意义的方式添加到发送信件的领域语言中。 在更复杂的代码中,这通常会被忽略,因为特定操作的复杂性需要存在于某个地方。 选择哪种方法取决于服务中可以封装的功能的数量。 当一个服务的存在只是为了将一段代码分割成现在独立的但有点无家可归的部分时,服务可能是一个坏主意。 如果我们能够在服务中封装领域知识,那么我们就有了创建一个领域知识的正当理由。
对于任何面向对象的程序员来说,如果一个对象被命名为,并且其中只有一个方法(实际上是操作),这应该会引起一个危险信号。 好的服务添加到领域中,并表达在领域本身具有坚实基础的概念。 这意味着有名字来表达这个概念。 服务可以封装那些没有“事物”直接支持的概念,它们应该根据域来命名。
协会
在前一节中,我们了解到信件的投递依赖于邮递员。 信件和送信人之间有一定的关系,但根据不同的领域,这种关系可能不是非常强或相关的。 这可能与地下城主知道谁投递了哪封信有关,例如,以防每个快递员立即被监禁,并被要求对他或她投递的邮件内容负责。
兽人的行事方式可能不像商业规则那样容易理解。 在本例中,我们希望确保在每封信和投递信的邮递员上贴上标签,将这封信与某个特定的人联系起来。 反之就无关紧要了。 当我们在我们的领域中建模时,我们希望将这一重要的知识传播开来,并有一种方法将信息在传递过程中与适当的交付人联系起来。 在代码中,这可以更容易地完成; 例如,我们可以为信件创建一个历史记录,其中与交付相关联的每个协作者都是链接的。
领域模型之间的关联的概念是领域设计中不可分割的一部分,因为无论何种形式的大多数对象都不能完全独立工作。 我们希望将尽可能多的知识编入协会。 当我们考虑对象之间的关联时,关联本身可以包含我们想要合并到模型中的领域知识。
实施过程中的洞察
模式的概念在面向对象语言和其他类型中都得到了很好的确立。 关于它已经写了很多书,也进行了很多讨论,讨论如何将许多开发人员的知识编码成企业中使用的模式,以及其他类型的软件。 最后,它归结为在开发期间的正确点使用正确的模式,这不仅适用于域模式,也适用于其他软件模式。
在他的书中的企业应用架构模式,马丁不仅讨论了可用的选项来处理通信数据库通过DataMapper
插件加域层、事务脚本,活动记录,等等,还讨论了何时使用它们。 和大多数事情一样,最后的结论是,所有的选择都有好的一面和坏的一面。
*当我们在开发软件时,随着我们的前进,我们会获得多种见解。 一个非常有价值的见解是引入了一个以前不清楚的新概念。 要达到这一点没有明显的方法,我们可以做的是开始对我们目前在软件中拥有的模式进行分类,并使它们尽可能清晰,以便更有可能发现新概念。 有了一组分离良好的碎片,发现丢失的碎片的可能性更大。 当我们考虑域模式时,特别是我们可以对应用的某些元素进行分类的各种方法,分类的方法并不总是像我们希望的那样清晰。
识别域模式
正如您在处理发送信件的示例中所看到的,我们注意到,尽管所建议的选项使用服务来处理协作,但还有其他方法可以实现这一点。 像我们在这本书中提到的小例子的问题是,在一般情况下,我们很难传达什么时候特定的选择有好处,什么时候特定的设计是过度的; 对于领域驱动设计这样的复杂体系结构来说尤其如此,毕竟,如果应用是在几百行代码中完成的,那么领域驱动设计解决的许多问题就不存在了。
当我们为某个特性编写代码时,我们总是需要意识到组件的设计并不是一成不变的。 应用可能一开始就有很多实体存在,内联处理大多数交互,因为系统还没有发展到足够清楚地认识到哪些交互是复杂的、重要的,不能将它们作为一个领域概念。 此外,我们使用软件通常意味着我们认识某些概念,而作为开发人员使用,我指的是接触界面并将软件作为一个整体进行扩展。
不是所有的东西都是一个实体
通常,在领域驱动设计中,很容易为所有事物创建实体。 实体的概念在开发人员的头脑中是非常普遍的。 当我们把对象看作内存中的东西时,它们总是有一个固定的标识,大多数语言默认是这样比较的,例如:
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 的应用上投入巨资的企业,所以我们该如何处理这个问题呢?
回到半兽人地下城,我们面对的是一个与我们非常陌生的世界,这个世界并没有一种非常成熟的语言来处理它的过程,因为到目前为止我们几乎从未需要过这种语言。 我们在书中已经讨论过这个问题,因为许多术语在上下文中被严重超载。 通知可以是通知兽人他被分配到某个囚犯运输车的信息,或者是通知另一个地牢的信息,通知他们有囚犯到来,或者是监狱需要新的囚犯。 我们如何处理这种情况?
让我们以兽人大师为例,他向我们解释如何通知另一个地下城:
开发者:当地牢里满是囚犯时,你需要什么?
兽人大师:没问题! 让 Xaguk 来处理?
开发者:据我所知,Xaguk 是北方地牢的首领,所以我猜你需要准备一辆运输工具了?
是的,我需要通知男爵,让他准备好运输和 Xaguk。
他写下两封信,叫来他的小妖精助手。
兽人大师:把这个给男爵,把这个给 Xaguk!
地精开始跑开,但就在他从南门离开房间之前,主人开始尖叫。
你在做什么? 你得先找只渡鸦把这个送给 Xaguk !
地精看起来很困惑,但现在开始朝另一个方向跑。
半兽人大师:这种情况经常发生——地精只是不记得谁是谁,他也不需要记住,但他需要把信件送到正确的办公室。
开发者:啊,所以如果你想给其他地下城发送信息,你会得到一只乌鸦吗? 当你给洞见地牢的人发信息时,会被带到当地吗?
兽人大师:没错!
开发者:好的,所以我们不会在系统中感到困惑,我只是将另一个地下城的消息称为“raven”某人,在本地我们将继续称之为“消息”。 这有意义吗?
兽人大师:是的! 希望地精不会再这样搞砸了,因为这已经引起了一些奇怪的对话。
这是对这类事情如何发展的一个主要简化,但重要的是,作为开发人员,我们应该吸收业务提供的语言片段,并将它们合并到领域中。 这不仅使我们的生活更容易,而且也改善了商业交流。
需要注意的一点是,我们需要确保我们不会把我们非常具体的语言强加到业务中,也就是说,如果某个概念没有被采纳,就要准备好抛弃它。 毕竟,唯一比未命名的概念更糟糕的是一个令人困惑的命名概念。 构建一种语言需要来自于领域,而不应该强制于领域。 一个不被人记住的名字要么是一个不重要的概念,要么是一个描述性不够的概念。
如果一个不重要的概念被命名,它往往会迫使人们对它进行不必要的关注,这可能会导致麻烦,因为当我们认为它太重要时,我们可能不愿意改变它或适应新的需求。 例如,考虑我们开发的囚犯单元自动分配的概念,它使用一个算法来确定我们拥有的囚犯数量的最佳单元。 这似乎非常重要,因为我们想要尽可能地优化地下城的使用。 有一天,一个新来的囚犯到来了,系统开始计算,为他确定最理想的牢房,看守说,“为什么要花这么长时间?” 每一次! 我已经知道我把他放在哪里了,我只是把他塞在一号牢房!” 这是有效的用户反馈,尽管我们可能已经找到了优化地下城的方法,但这可能并不重要,因为兽人以一种比我们更轻松的方式看待每个牢房的囚犯数量。 自动分配的概念从来没有真正流行过; 我们从来没有听过任何人谈论过它,所以我们最好把它全部删除,让系统对我们和用户更容易。
当然,系统不仅为用户服务; 它们也可能服务于沿途的其他系统。 所以记住谁是真正的用户可以对决策产生重大影响。
构建语境
我们已经讨论了很多关于我们使用的语言,以及系统如何交互以及它们是由什么组成的,但我们还需要触及一个更高的层次:系统如何与其他系统交互?
在服务器领域,微服务及其构建和交互是当前的重点。 重要的结论是,拥有一个小团队拥有的系统比一个大团队构建的系统更容易维护; 这只是故事的一半,所以服务毕竟需要交互。 微服务是领域驱动设计限定上下文的更技术性的方法。
下图显示了微服务世界中的交互是如何发生的。 我们有很多小服务互相呼叫来完成一个更大的任务:
交互不仅发生在 API 级别,而且也发生在开发人员级别。
知识分离与共享
在应用的不同部分工作的团队需要知道,当发生变化时,他们如何能够共享知识并一起工作。 Eric Evans 在领域驱动设计中花了很大的篇幅介绍我们在实践中看到的模式。 我们在软件开发中经常看到模式,无论是DataMapper
、ActiveRecord
这样的软件模式,还是 Eric Evans 讨论的关于共同工作过程的模式。
在当前的微服务世界中,我们似乎已经从深度集成转向了一种更灵活的方式,只非常轻松地接触系统的其他部分。 在整个团队中共享一个领域仍然很重要,一个关于哪些内容涉及哪些内容的地图变得比以往任何时候都更加重要。
小结
在这一章中,我们详细讨论了如何分离系统,如何处理整个应用中的概念(主要是在较小规模的项目中),以及它如何与更大规模的项目交互。
在构建项目时,我们可以从其他地方借鉴许多已经被使用的思想,无论是面向对象的设计还是软件架构模式; 重要的是要记住没有什么是一成不变的。 关于领域驱动设计的一个非常重要的事情是它在不断地变化,而这种变化是一件好事。 一旦一个项目变得过于稳固,它就很难改变,这意味着使用它的业务不能再与它的软件一起发展,这最终意味着转换到另一个系统和不同的软件,或者重写现有的软件。
下一章将更多地涉及项目作为一个整体的相互交织的各个部分的更高层次的观点,深入了解项目的每个部分所处的环境的细节。*
版权属于:月萌API www.moonapi.com,转载请注明出处