作者:Mathias Verraes
译者:ThoughtWorks咨询师 徐培、黄雨青、覃宇
校审:ThoughtWorks咨询师 伍斌
一个特定的概念应该属于哪个限界上下文? 找到答案的一种方法,是不断改进模型,直到一切概念都找到顺应自然的位置。然而,所有模型都是错误的,特别是早期的模型。 让我们看一些简单的需求,并探索模型如何随着时间的推移而不断演化。当更充分地理解了正在解决的问题后,我们就可以将这种清晰的理解带入到新的模型迭代中。
1. 问题
假设我们正在开发一个业务应用程序。这个应用涉及销售、会计、报表等功能。现有软件存在一些严重问题,例如,金额用小数表示。在许多地方,金额数值先是用高精度计算,然后四舍五入到小数点后两位,最后又用高精度来计算。这些舍入错误遍布代码。虽然单个数字和舍入错误产生的误差很小,但最终这些误差累加起来会给企业造成数百万美元的损失。金额可以用不同的货币表示,但财务报告总是以欧元表示。目前还不清楚代码是不是总能在需要的时候将金额正确地转换为欧元,或者是否会不小心将不同货币单位的金额相加。
要解决这些问题,首先要做的是与销售和会计领域的专家沟通。 这样,我们可以就某些需求达成共识。
2. 需求
- 业务需要支持大约10种货币,未来可能还要支持更多。当企业想要支持新货币时,比如日元,那么开发人员应该能在代码中加入支持,而不需要通过 UI 界面来添加新货币。
- 所有价格计算都需要精确到小数点后8位。这是一个商业决策。
- 向用户展示金额或通过API传递金额时,软件将始终使用货币官方保留小数的位数。例如,对于欧元和美元来说,保留小数点后两位。而对比特币来说,精度则是1聪,即保留小数点后8位(10-8 BTC)。
- 在某些市场中,软件需要进行特定的合规报告。
- 无论原始货币是什么,所有内部报告都必须以欧元表示。
- 存在一些遗留系统和第三方系统,也会将收入发布到内部报告工具。它们中的大多数只支持货币的官方单位,无法处理更高的精度。
有些编程语言不能很好地处理高精度计算。本文不会讨论这些问题的解决方法。 我们假设这里的“数字”使用了恰当的数据类型,例如C#中的float或Decimal,或者Java中的BigDecimal。
3. 第一步
这里是否可以用一些现成的设计模式?《领域驱动设计》(http://amzn.to/1CdXXP9)中的值对象(Value Object)就是一种不错的模式,它可以用来处理金额。事实上,在Eric Evans的书出版之前的几年,Martin Fowler已经在《模式企业应用程序架构》(http://amzn.to/1TN7Tq4)中描述了如何实现Money。该模式描述了一个对象,该对象由两个属性组成,一个属性是金额的数值,另一个属性则是相关货币(也可以是Value Object)。Money值对象是不可变的,因此所有操作都将返回一个新的实例。
图1
Currency类型支持业务涉及的10种不同货币,可以使用枚举。但如果语言不支持,则可以使用一些简单的断言。例如添加下面这些约束:
- 该类型不能使用业务需要支持的10种货币之外的值进行初始化。
- 该类型只能使用3个字母的ISO符号,其他任何值都被认为是错误。这满足了上一条需求。
Money的构造函数会把数字四舍五入到小数点后8位。这样的精度足以满足任何货币的处理要求。Money类中加入了一些操作,比如Money.add(Money other) : Money和Money.multiply(Number operand) : Money。这些操作会自动舍入到8位小数。 另外,该类还有一个方法是Money.round() : Money,它返回一个新的Money对象,舍入为2位小数。
Money {
// ...
round() : Money {
return new Money(round(this.amount, 2), this.currency)
}
}
现在我们已经根据需求对类型进行了建模,下一步是重构遗留代码中所有涉及金额的地方,在这些地方使用新的Money对象。
4. 组合
有意思的是,所有对象最终都是由值对象组成的,这能让隐式的概念更加明确。 例如,我们可直接使用Money类型来表示价格。但如果价格没有那么简单呢?在欧洲,价格里还包括增值税(Value Added Tax,VAT)。你现在可以自然地用Money对象和VAT对象组成一个新的Price值对象,其中VAT对象用一个百分比表示。
图2
产品在不同的市场中可能有不同的价格,我们可以在另一个值对象中明确表达这个概念。
图3
类似的组合还可以照这样继续下去。这样就可以将传统上放在服务中的许多业务逻辑,都放到这些值对象中。
使用值对象可以轻松构建一些抽象,来处理大量复杂性,并且降低开发人员在使用时的认知成本。找到抽象虽然会很难,但是从长远来看,花在此处的精力,往往会提升代码质量和可维护性,这通常是值得的。
5. 寻找弱点
即使当前模型直观漂亮,也总是有改进的余地。Eric经常建议,要寻找模型使用时感觉尴尬或痛苦的方面。这些都是坏味道,会指引我们做进一步的改善。
仔细观察模型,有两个潜在的缺点:
- 虽然代码中的scalar类型已经被Money类型所取代,但软件仍然存在下述风险,即货币值被舍入,然后在更高精度的计算中又被重复使用。舍入错误这个问题仍然没有得到充分解决。
- 软件仅支持小数点后8位的精度,而且模型假定这样的精度也够用了。然而,当调用Money.round()时,模型并没有真正处理好下述情况,即一些货币默认情况下不是精确到小数点后2位,而是要精确到小数点后3位(如巴林和科威特所使用的第纳尔)或更多(比如比特币);而有些货币则不需要小数(如日元)。
我们闻到了坏味道。此时应该停下来,重新思考模型。
仔细看看:Money.round():Money
a = new Money(1.987654321, eur)
// The constructor rounds it down to 1.98765432
b = a.round()
// round() rounds it up to 1.99 and instantiates a new Money
// Money’s constructor rounds it to 1.99000000
从技术上讲,大多数编程语言并不区分1.99和1.99000000,但从逻辑上讲,这里有一个重要的细微差别。代码中的变量b并不仅仅是普通的Money,而是一种完全不同的货币。目前的设计并没有做出这样的区分,而只是将两种金钱混淆了起来,无论是否做舍入。
6. 使隐式的内容显式出来
建模时的一个好的启发式方法,是考虑我们是否可以在命名中更明确,并梳理出微妙的不同概念。 我们可以将Money重命名为PreciseMoney,并添加一个名为RoundedMoney的新类型。 后者总是会舍入到货币的默认保留小数的位数。
Money.round()方法现在变得非常简单:
PreciseMoney {
round() : RoundedMoney {
return new RoundedMoney(this.amount, this.currency)
}
}
图4
这样做的主要好处,是在编程时能获得有力的保障。因为现在就可以在大多数域模型中,对PreciseMoney给出类型提示,并在明确需要的情况下,对RoundedMoney给出类型提示。
类型定义到了这种粒度,其价值无论怎样高估都不过分。
- 这种防御性的编程风格,能防范一系列软件缺陷。此时,方法及其调用方就金额的类型,建立了一个明确的契约,关于他们正在谈论什么样的钱。好的设计就能传达这样清晰的意图。
- 契约胜过测试。显然,正确的代码不需要测试。如果RoundedMoney经过了充分测试,并且某些客户端代码能对该类型给出类型提示,那么就不需要编写测试来验证这笔钱确实进行了四舍五入。这就是为什么强静态类型系统的支持者们,如此喜欢谈论类型驱动开发可以替代测试驱动开发。因为只要通过类型声明了意图,那么就能让类型检查器来完成接下来的工作。
- 这种对不同类型进行特别区分的设计,能提升不同开发人员编写这段代码的沟通效率。因为不熟悉此问题空间的开发人员,可能会寻找Money类型。但IDE告诉他们,不存在这样的类型,并建议使用RoundedMoney和PreciseMoney。这能促使开发人员考虑使用哪种类型,并去了解领域是如何处理精度的。
- 这样的设计,能将领域中的概念引入到统一语言和模型中。精度和舍入虽然是该领域的基础,但在原始模型中却被忽略了。与领域专家共同雕琢统一语言、模型和实现,是域驱动设计的核心。从长远来看,这些模型的改善,能带来指数级的收益。
- 这样的设计,也有助于体现接口隔离原则。即客户端代码不必依赖大量的API,而仅依赖于与该类型相关的API。
7. 处理不同的精度
我们可以在PreciseMoney中添加round()方法,但不会在RoundedMoney中添加toPrecise()方法。就如同在物理学中,无法从无到有地创造更大的精确度一样。换句话说,可将PreciseMoney转换为RoundedMoney,但不能将RoundedMoney转换为PreciseMoney。这是一个单向操作。
这种约束会让设计更优雅。 一旦进行舍入,精度永远消失。缺少RoundedMoney.toPrecise()方法,会符合我们对领域的理解。
8.通用接口
读者可能已经注意到,在当前设计中,对象图顶部没有Money接口。PreciseMoney和RoundedMoneynan难道不是某种Money或AbstractMoney,具备很多相同的方法吗?
如果设计的目标,是尝试构建一个反映现实世界的模型,那么设计Money接口是有意义的。 但是,不要根据是否符合层次分类来判断一个模型的优劣。领域模型并不是分类学,不要被一些在面向对象编程的书中所教的“Cat extends Animal”所迷惑。要根据实用性来评判模型。顶级的Money接口不仅根本没有增加任何价值,实际上还有损价值。
图5
这可能有点违反直觉。PreciseMoney和RoundedMoney虽然相关,但在根本上却是不同的类型。模型设计的目标应该是清晰性,以保证舍入和精确值不会被混淆。如果允许客户端代码当遇到通用的Money时给出类型提示,那么就会有损清晰性。当得到一个Money类型时,是无法知道其具体类型的。传递正确类型的所有责任,都落到了调用者的手中。调用者当然可以进行money instanceof PreciseMoney的转换,但这是一个严重的代码腐臭。
9.处理转换
不同货币之间的转换取决于当天的汇率。汇率可能来自某些第三方API或数据库。为了避免将这些技术细节泄漏到模型中,可以使用CurrencyService接口的convert方法。该方法需要一个源PreciseMoney和一个目标Currency,并进行转换。
interface CurrencyService {
convert(PreciseMoney source, Currency target) : PreciseMoney
}
CurrencyService的实现可能会处理诸如缓存当天费率之类的问题,以避免每次调用所带来的不必要的流量。
如果一个服务既做获取,又做缓存,还做转换,那么这个服务的职责真是太多了。像CurrencyService这样的服务类,基本上就是包裹在对象中的过程式代码。虽然CurrencyService接口的设计,能减少实现细节的泄漏,但其他所有相关代码,仍然需要依赖这个接口。这使得测试变得更加困难,因为所有这些测试都需要模拟CurrencyService。最终,CurrencyService的所有实现都需要重复实际的转换操作,或以某种方式共享这个转换逻辑。一种有帮助的启发式方法,是弄清楚是否能以某种方式,将实际的领域逻辑代码,与仅仅试图为该领域逻辑准备数据的代码,区分开来。例如,将领域逻辑(转换)与基础设施代码(获取和缓存)区分开来。
这里缺少一个概念。与其让CurrencyService处理转换,不如让它返回ConversionRate。这是一个值对象(Value Object),能够封装源Currency,目标Currency和利率因子(浮点数)。值对象会与行为“形影不离”,在这种情况下,ConversionRate这个值对象,正是进行实际转换计算的天然位置。
interface CurrencyService {
getRate(Currency source, Currency target) :: ConversionRate
}
ConversionRate {
- sourceCurrency : Currency
- targetCurrency : Currency
- factor : Float
convert(PreciseMoney source) : PreciseMoney
}
如果我们想要将美元兑换成欧元,那么convert方法能确保我们不会意外地使用英镑兑换日元的汇率进行转换。如果参数currency出现了错误,那么可以让该方法抛出异常。
这种设计另一个很酷的方面,是没必要传递CurrencyService接口了。相反,我们会传递更小,更简单的ConversionRate对象。它们表现出更强的可组合性。其重用的可能性也很明显:例如,事务日志可以存储用于转换的ConversionRate实例的副本,这样有助于实现问责。
10. 更简单的元素
现在,CurrencyService变成了ConversionRate对象的某种集合,并提供对该集合的访问(和过滤)机制。这听起来很熟悉对吧?这实际上就是Repository(仓库)模式!仓库不仅用于实体(Entity)或聚合(Aggregate),还可用于所有领域对象(Domain Object),包括值对象(Value Object)。
CurrencyService现在可以重命名为ConversionRateRepository,其优势是意图更明确,定义更精准。但在类名中使用模式名称Repository有点烦人。由于DDD中的Repository确实代表域对象的集合,因此将其称为ConversionRates是一个很好的主意。但应该借此机会寻找领域通用语言中的术语。而生活中人们称当日汇率为foreign exchange,因此我们可以将ConversionRateRepository重命名为ForeignExchange(或Forex)。我们可以将ForeignExchange标柱为@Repository,或者让它实现一个名为Repository的接口,以起到标记作用,而不是将Repository这个词放在类名中。
原来过程式的CurrencyService现在被分成两个更简单的模式:Repository和Value Object。注意这期间并没有降低任何问题的复杂度,但每个元素和每个对象本身都非常简单。如需了解这段代码所使用的有关模式的知识,请参见《领域驱动设计》第5章和第6章。这些是初级开发人员需要了解的。
11. 货币类型安全
现在的模型还是存在一些问题。要知道,某些货币要保留小数点后2位,某些要保留3位,还有一些要保留8位。RoundedMoney需要支持这一点。并且在这个例子中,要支持10种不同的货币。起初的构造函数看起来有点难看:
switch(currency)
case EUR: this.amount = round(amount, 2)
case USD: this.amount = round(amount, 2)
case BTC: this.amount = round(amount, 8)
etc
此外,每次业务需要支持一种新货币时,都需要在构造函数中添加新代码,并且有可能在系统中的其他位置添加新代码。 虽然这不是一个严重的问题,但绝不是理想的设计。switch语句(或键/值对的实现)散发着类型缺失的代码腐臭。
解决这一问题的一种方法,是将PreciseMoney和RoundedMoney类转变为抽象类或接口,并将保留小数点位数的差异,交由每种货币的子类型处理。
图6
PreciseEUR、PreciseBTC、RoundedEUR和RoundedBTC等每个类,都自己知道该保留几位小数,就如同上面switch语句所表现的那样。
RoundedEUR {
RoundedEUR (amount) {
this.amount = round(amount, 2)
}
}
上面有关类型的设计又一次发挥了作用。还记得这个例子要求报告以欧元表示吗?我们现在可以为此而实现类型提示,使得任何其他种类的货币都无法进入到报告中。同样,适用于其他市场的报告,也可以相应地限定所要求的货币类型。
那么,何时不应该设计这些类型,而仅使用货币值?
当不是处理5或10种货币值,而是需要处理无限种类或种类非常多的货币,或者每种实现都经常需要更改时,就可以。在这种情况下,仅仅支持10种货币在某种程度上反而是特例了。但是,不太可能需要增加对所有180种货币的支持。可能只要支持10或15个最相关的货币种类就够了。
12. 极简式接口
从上面的例子能够看出,需求中的概念与改善后的Money模型,是如何相互作用的。经过分解后的模型,就能移动和复制到不同的有界上下文中。Money这个名字愚弄了我们:因为在不同的上下文中,Money意味着不同的东西。
拥有大量小类的好处,是我们可以少写大量的代码。也许Sales这个限界上下文处理了10个PreciseXYZ类型,但Reporting限界上下文只需要RoundedEUR这一个类型。这意味着没有必要支持RoundedUSD等类型,因为并不需要。这也意味着除了EUR之外,任何PreciseXYZ类都不需要round()方法。更少的代码意味着更少的缛节代码、更少的软件缺陷、更少的测试和更少的维护。
不支持从RoundedEUR转换回PreciseEUR,是另一个极简式接口的例子。不要构建不需要或想避免的行为。
13. 单一责任
这些小且用途超级单一的类所带来的另一个好处,是很少需要进行更改。而只有当领域中的某些内容发生变化时,这些类才会发生变化。货币是非常稳定的概念,而我们的模型也具备这种缓慢的变化速度。一个好的设计有助于轻松添加或删除元素,或更改元素的组成,但很少需要实际更改现有代码。这样能减少错误,减轻工作量,让代码更易于维护。
14. 舍入误差账本
使用富有表达力且具有良好适应性的领域模型,来表达代码的一个优点能为其他功能带来机会,同时还可以使这些功能的实现变得非常容易。
上面这个建模练习的目标,是解决精度问题。然而每当对精确值进行四舍五入时,业务的盈亏是否会发生变化?这一点对于业务领域是否重要?
如果上面两个问题的答案都是肯定的,那么就可以使用一个单独的舍入误差账本。每当舍入对货币金额产生误差时,就将其记录在账本中。当这种误差累计起来超过1美分时,就可以将其添加到下一笔付款中。
// rounding now separates the rounded part from the leftover fraction
PreciseMoney.round() : (RoundedMoney, PreciseMoney)
Ledger.add(PreciseMoney) : void
可能不必在大多数领域中处理此类问题,但是当拥有大量的小型事务时,就可能会产生显著差异。可以考虑将这种模式纳入你的工作箱中。
这里的要点,是模型很容易扩展。在原始代码中,由于四舍五入和精度都没有明确定义,所以追溯舍入所带来的损失简直就是一场噩梦。如果能将需求的变化反映在类型中,那么就可以依靠类型检查器,在代码中找到所有需要调整的位置。
15. 改善限界上下文
系统的不同功能具有不同的需求。而限界上下文此时就有了用武之地,因为它能允许我们将系统视作一些相互协作的模型进行思考,而不是一个统一的模型。也许Product Catalog限界上下文需要一个非常简单的货币模型,因为除了显示价格之外,其中的货币模型并没有真正做任何其他事情。Sales限界上下文可能需要精确的价格计算。公司内部Reporting限界上下文可能允许包含舍入的金额,甚至不需要小数。公司财务限界上下文中的数字,会以千计或百万计。Compliance Reporting限界上下文也有不同的需求。
图7
清理图7的领域统一语言,就得到了图8:
图8
如果Sales限界上下文始终处理高精度的金额,那么叫PreciseMoney这个名字是否还有意义?为什么不直接叫Money?很明显,在Sales限界上下文中的领域通用语言中,只能有一个Money,且不能四舍五入。而在Reporting限界上下文中,资金总是四舍五入,并以欧元计算。同样,其中的类型不一定是RoundedMoney或RoundedEUR,而可以是Money。
每个限界上下文现在都有自己的Money领域模型。有些很简单,有些则复杂一些。有些功能更多,有些功能更少。我们将所编写的代码,分散到有需要的限界上下文中。在每个限界上下文中,都有一个小而独特的Money模型,能高度适应其中的特定需求。在限界上下文中工作的新团队成员,就能快速学习这个模型,因为模型不受其他限界上下文需求的影响。现在我们可以放心,这些新成员不会犯舍入错误,也不会把欧元和美元搞混。不同的团队不必协调Money的功能,也不需要担心发生破坏性的更改。
在某些情况下,给Money分配一个通用子域就足够好了。而在其他情况下,我们需要高度专业化的模型。决定是哪种情况,是领域建模者的职责。
我们并没有进行前期分析,来决定哪些概念应该进入哪个限界上下文。如果在项目的早期真的这样做了,那么我们就会天真地构建一个共享的Money库,并假设Money的概念会被所有限界上下文所共用。相反,我们持续根据需求和新的洞见,来改善模型,并看到这些明确的概念,是如何自然地找到应处的位置。要培养对领域通用语言恰到好处的痴迷,并一次一小步地持续重构,从而形成更深入的洞见。所有好的设计,都是重新设计过的。