构建支持领域建模的架构 #
大多数程序员从未见过领域模型, 只见过数据模型
许多程序员在谈论架构的时候都有一种感觉:认为事情可以变得更好。他们经常拯救一个不知为何出了问题的系统,并试图把一些结构恢复成一个粪球。他们知道他们的业务逻辑不应该散布在各处,但他们不知道如何修复他。
而且,我发现,许多程序员在被要求设计一个新系统时,会立即开始构建一个数据库模式,而对象模型则是事后才想到(甚至可能压根就不想)。这就是一切开始出错的地方。相反,行为应该放在第一位,并驱动我们对其进行存储。
毕竟我们的客户并不关心数据模型。他们只关心系统的工作,否则他们只会使用Excle。
接下来的文章我们将讨论如何通过TDD方式构建富对象模型,然后我们将展示如何使用该模型与技术问题解耦。我们只讨论如何构建与持久化无关的代码,以及如何在我们的领域周围创建稳定的API,以便我们能够积极地进行重构。
为此我们提出以下四个相关的设计模式
-
仓库模式(Repository pattern)
对持久化存储的抽象
-
服务层模式 (Service Layer)
定义我们业务逻辑开始和结束的位置
-
工作单元模式 (Unit of Work pattern)
提供原子操作的抽象
-
聚合模式 (Aggregate pattern)
保证数据完整性
以下就是我们完成时的架构图示,不用担心,我们在接下来会讲解每一个内容
领域建模 #
本章将开始探索在代码上面对业务流程进行建模,并使用TDD的模式开发。我们将讨论为什么领域建模很重要,并且我们将研究领域建模的一些关键模式:实体(Entity)、值对象(Value Object)、领域服务(Domain Service)
接下来的文章我们将一步步探讨下面的这些小形状都代表的是什么。
什么是领域模型(Domain Model)? #
Domain是你试图解决某些问题的一种说法。假如你为一家在线家具城工作。那么我们讨论这个系统时,Domain可能是采购,供货,产品设计,物流或者支付。大多数程序员每天都在尝试改进自动化业务流程;Domain是这些流程的一组活动。
Model是一个过程或现象的映射,它捕获了一些属性。人类特别擅长在脑子里创造事务的模型。比如说,当有人向你扔一个球时,你几乎无意识地就能预测他的运动,因为你有一个物体在空间中移动地模型。虽然你的模型并不完美。当球在超过光速和真空中时,你的模型可能就失效了。因为我们的模型从来未被设计用来涵盖这种情况。这并不意味着这个模型是错的,但它确实意味着一些预测超出了它地范围。
这意味着,如果我们的模型是正确的,就会使我们的软件就会提供价值,反之如果我们的模型是错误的,它就会成为我们工作的阻碍。在以后的文章里,我们将展示构建领域模型的基础知识,并围绕它构建一个体系结构,使模型尽可能不受外部约束,让它更容易发展壮大和容易改变。
领域模型是企业及其所有者对自己业务的一张地图,所有从事业务的人员都有这些地图,它们是人类对于复杂问题的一种思考方式。这张地图的形成是由在复杂系统上合作的人之间自然形成的。
比方说我们在开一艘飞船,也许你刚开始胡乱按着飞船上的按钮,很快你就知道那些按钮功能和作用。马上你就能总结出一些操作来,比如提升“氧气含量”,“打开小推进器”。慢慢的你会对一些复杂的操作总结出一些术语,比如“开启着陆程序”,“准备进行空间折跃”。总结出这些术语是非常自然的发生并不需要我们做什么特别正式的工作。
业务方面也是如此。业务涉及到的术语表代表了对领域模型的精炼理解,其中包含复杂的思想和过程可以总结位一些词和短句。
当我们听到业务利益相关者使用不熟悉的术语时,我们应该去理解它更深层次的含义,并将他们的经验写入软件中。
在本系列文章中,我们将使用一个现实世界的领域模型,特别是来自我们工作当中的模型。比如一家在线家具商城。我们从世界各地的制造商那里采购家具,然后出售给全国。
比如当你买了一个沙发或者电脑桌,我们必须找到最好的办法把你的商品从浙江或者江苏带到你的家里。
从高层次上讲,我们有独立的系统,负责购买库存,销售库存给客户,并发快递给客户。中间系统需要将库存分配给客户的订单来协调流程。
假如我们公司决定决定实施一种新的货物分配方式。因为到目前为止,业务一直根据现实中仓库数量来显示库存和交货时间,如果仓库的货物用完了,前端就会显示缺货,直到供应商下一批货物到达。
那我们这里搞些创新:如果我们有一个系统,能够跟踪我们所有的在途货物,当他们还在船上或者在路上的时候,就把这些货物作为我们仓库库存的一部分。只要时间提前一点,我们就会出现缺货的情况就会越来越少,我们会卖出更多的商品,公司也会通过在减少在国内仓库的库存来降低成本。
但随之而来的事,分配订单不再是在仓库中减少单个数量的这么简单的问题。我们需要一个更复杂的分配机制。这时候就需要进行一些领域建模了。
深入领域语言 #
理解领域模型需要时间和耐心。我们与业务方进行了初步的对话,并就领域模型的第一个最小版本的术语以及规则达成了一致。如果可以,我们就需要要求对每一条规则给出一个具体的例子来加以说明。
ok, 现在我们对业务方的的需求尽可能的做些注释
| 说明 | 例子 |
|---|---|
| 产品由SKU进行标识,是skew的缩写 | |
| 顾客下订单。订单由订单ID引用标识,并由多个订单行组成,其中每行有一个SKU和一个数量 | - 10 个红色咖啡桌 |
| 采购部门采购一批产品,每一批有一个唯一的ID, SKU 和 数量,称之为采购批次,采购批次可分为已经在仓库中,和正在运输中的 | |
| 每个订单不能分配两次给采购批次 | |
| 采购批次有一个叫ETA的时间,他表示到达仓库的时间 | |
| 我们要优先消耗库存而不是采购批次 | |
| 我们要按照ETA最早到达顺序来分配订单 |
对领域模型进行单元测试 #
这里使用了TDD的模式进行开发,当然,我这里并不想说明如何使用TDD开发,但是我想说的是,如何从需求出发来构建一个领域模型
Example 1. 第一个分配订单的测试
def test_allocating_to_batch_reduces_the_available_quantity():
batch = Batch("batch-001", "桌子", qty=20, eta=date.today())
line = OrderLine("order-ref", "桌子", 2)
batch.allocate(line)
assert batch.available_quantity == 18
单元测试的名称描述了我们希望从系统中看到的行为,我们使用类和变量的名称取自业务术语。我们可以向非技术同事展示这段代码。让他们认可这正确表达了系统的行为。
下面我们写一个符合我们需求的领域模型
Example 2. 第一个采购批次的模型
@dataclass(frozen=True) #(1)(2)
class OrderLine:
orderid: str
sku: str
qty: int
class Batch:
def __init__(
self, ref: str, sku: str, qty: int, eta: Optional[date] #(2)
):
self.reference = ref
self.sku = sku
self.eta = eta
self.available_quantity = qty
def allocate(self, line: OrderLine):
self.available_quantity -= line.qty #(3)
接下来我做一些解释
-
OrderLine是一个不可变数据,它并没有行为 -
我们没有用导入来区别模型,为了更方便阅读
-
虽然Typehint饱受争议,但是他给IDE提供大量提示,而且对于模型而言,他阐述了模型本身对入参的期望,这将在后面的开发中获得非常多的好处。
在这个模型中我们实现的非常简单:一个Batch只包装一个整数可用量,我们在分配时递减该值。虽然我们写了相当多的代码,只是为了从一个数字减去另一个数字。但是我认为对我们领域进行精确建模,这点代价来说是值得的。
OK,让我们编写一些失败的测试
Example 3. 测试我们订单是否可以分配采购批次的逻辑
def make_batch_and_line(sku, batch_qty, line_qty):
return (
Batch("batch-001", sku, batch_qty, eta=date.today()),
OrderLine("order-123", sku, line_qty)
)
def test_can_allocate_if_available_greater_than_required():
large_batch, small_line = make_batch_and_line("ELEGANT-LAMP", 20, 2)
assert large_batch.can_allocate(small_line)
def test_cannot_allocate_if_available_smaller_than_required():
small_batch, large_line = make_batch_and_line("ELEGANT-LAMP", 2, 20)
assert small_batch.can_allocate(large_line) is False
def test_can_allocate_if_available_equal_to_required():
batch, line = make_batch_and_line("ELEGANT-LAMP", 2, 2)
assert batch.can_allocate(line)
def test_cannot_allocate_if_skus_do_not_match():
batch = Batch("batch-001", "UNCOMFORTABLE-CHAIR", 100, eta=None)
different_sku_line = OrderLine("order-123", "EXPENSIVE-TOASTER", 10)
assert batch.can_allocate(different_sku_line) is False
我们为一个新方法分配了四个简单的测试来测试采购批次能否分配订单,ok,现在我们为Batch模型添加新的方法
def can_allocate(self, line: OrderLine) -> bool:
return self.sku == line.sku and self.available_quantity >= line.qty
现在,我们可以通过递增或递减Batch.available_quantity来解除分配的功能,让我们实现deallocate(),首先我们先写测试
def test_can_only_deallocate_allocated_lines():
batch, unallocated_line = make_batch_and_line("DECORATIVE-TRINKET", 20, 2)
batch.deallocate(unallocated_line)
assert batch.available_quantity == 20
在这个测试中,我们断言从一个采购批次解绑一个订单没有效果,除非该采购批次先前分配了该订单。为了实现这一点,我们需要了解已经分配了那些订单。让我们看看实现
class Batch:
def __init__(
self, ref: str, sku: str, qty: int, eta: Optional[date]
):
self.reference = ref
self.sku = sku
self.eta = eta
self._purchased_quantity = qty
self._allocations = set() # type: Set[OrderLine]
def allocate(self, line: OrderLine):
if self.can_allocate(line):
self._allocations.add(line)
def deallocate(self, line: OrderLine):
if line in self._allocations:
self._allocations.remove(line)
@property
def allocated_quantity(self) -> int:
return sum(line.qty for line in self._allocations)
@property
def available_quantity(self) -> int:
return self._purchased_quantity - self.allocated_quantity
def can_allocate(self, line: OrderLine) -> bool:
return self.sku == line.sku and self.available_quantity >= line.qty
一下是我们UML模型图
OK,采购批次现在可以追踪一组已经分配的OrderLine对象。当我们分配时,如果我们有足够的可用数量,我们只是添加到集合中。我们现在的可用数量是一个property的计算属性:购买数量 - 分配数量
emmmm, 我们还有很多事情要做。比如 allocate()和deallocate()有可能悄无声息的失败,但我们至少有了一个基础模型
顺便说一句,_allocations我们使用了一个set,他让我们我们容易处理最后一个测试用例,因为集合中的项是唯一的
def test_allocation_is_idempotent():
batch, line = make_batch_and_line("ANGULAR-DESK", 20, 2)
batch.allocate(line)
batch.allocate(line)
assert batch.available_quantity == 18
OK,到目前为止,有很多人觉得领域模型过于琐碎,不值得去费心DDD(甚至有人任务连面向对象都不去做)。当然这也不是没有根据,因为在现实开发中,任何数量的业务规则和边缘案例都会出突然出现,毕竟产品经理都是狗。比如:客户要求在特定的未来日期拿到货,这意味着我们可能不希望将他们分配到嘴早的采购批次中。一些产品呢,又不是从采购订单那里分配的,而是直接从供应商那里订购的,所以他们有不同的逻辑。根据客户的位置,我们分配到他们所在地区的一部分仓库和货物,还有一些比如我们本地区缺货的话。我们乐意从不同地区的仓库运送货物,等等,产品经理总会比我们想象中更快的速度去堆积复杂性。
但随着我们文章的进行,我们将继续拓展我们这个简单的模型,并将他插入到API,数据库,甚至Excle中等真实的业务场景中。我们将看到严格遵循封装和仔细分层的原则将如何帮助我们避免出现一团屎山。
Dataclasses可以让对值对象(Value Objects)更具有表达力 #
在前面的示例代码中,我们大量使用了OrderLine对象,那么这个line究竟是什么呢?在我们这个业务中,一项订单可能含有多个货物在里面,每个line由一个SKU和数量来表达,那我们这样的一个订单用YAML来表示,可能是这样的
Order_reference: 12345
Lines:
- sku: RED-CHAIR
qty: 25
- sku: BLU-CHAIR
qty: 25
- sku: GRN-CHAIR
qty: 25
这里你可能会发现,虽然每个订单都有一个唯一标识它的引用,但是OrderLine确没有。所以即使我们将订单的引用添加到OrderLine类中,他也不是标识OrderLine本身。
每当我们有一个有数据但是没有身份的业务概念时,我们通常会用Value Object去表达它。Value Object是持有领域对象唯一标识数据的对象。我们通常使她不能改变。可变数据是让系统变得复杂的罪魁祸首。
dataclasses给我们一个好处是,具有相同的order_id/sku/qty的两个数据他们是相等的。比如
from dataclasses import dataclass
from typing import NamedTuple
from collections import namedtuple
@dataclass(frozen=True)
class Name:
first_name: str
surname: str
class Money(NamedTuple):
currency: str
value: int
Line = namedtuple('Line', ['sku', 'qty'])
def test_equality():
assert Money('gbp', 10) == Money('gbp', 10)
assert Name('Harry', 'Percival') != Name('Bob', 'Gregory')
assert Line('RED-CHAIR', 5) == Line('RED-CHAIR', 5)
这些Value Object符合我们对现实世界中的直觉。比如,我们讨论究竟是哪张100元人民币钞票对我们来说并不重要,因为他们都有相同的价值。同样如果两个OrderLine具有相同的order_id 、qty、sku。那么他们两个也是相等的。除了这些,我们仍然可以进行更多的更复杂的操作。例如一些数学操作符
fiver = Money('gbp', 5)
tenner = Money('gbp', 10)
def can_add_money_values_for_the_same_currency():
assert fiver + fiver == tenner
def can_subtract_money_values():
assert tenner - fiver == fiver
def adding_different_currencies_fails():
with pytest.raises(ValueError):
Money('usd', 10) + Money('gbp', 10)
def can_multiply_money_by_a_number():
assert fiver * 5 == Money('gbp', 25)
def multiplying_two_money_values_is_an_error():
with pytest.raises(TypeError):
tenner * fiver
Value Objects 和 Entities(实体) #
首先我们看OrderLine由order_id、sku、qty组成,如果我们更改其中任意一个值,那么现在其实应该是有一个新的OrderLine。这就是Value Object的定义:任何由其数据标识的对象,并且没有长期存在的标志。那么Entities呢?
是否还是有点感到头晕?那么我们看个简单的例子,上文中我们定义了个叫Name的Value Object。如果我们取名叫张三,那么如果我们换一个名字,叫“李四”那么“张三”还是“张三”吗?不,你应该是获得了叫李四的Name对象
def test_name_equality():
assert Name("张三") != Name("李四")
但是作为一个人呢?张三呢?人确实可以改变他们的名字,婚姻状况,甚至可能性别也可以改变。但是张三去泰国做了手术,那张三还是张三吗?对,张三还是张三。这是因为人与名字不同。人有一个持久的身份。
class Person:
def __init__(self, name: Name):
self.name = name
def test_barry_is_harry():
harry = Person(Name("张三"))
barry = harry
barry.name = Name("李四")
assert harry is barry and barry is harr
实体(Entities)不是值, 具有相同的身份。我们可以改变他们的值。但是他们仍然是一样的。在我们的示例中。Batch就是实体。我们可以将一个OrderLine分配给一个Batch。
或者我们更改他的ETA ,但是他们仍任相同。
当然我们可以通过修改Batch的魔术方法来实现用数学操作符来更好表达这件事情
class Batch:
...
def __eq__(self, other):
if not isinstance(other, Batch):
return False
return other.reference == self.reference
def __hash__(self):
return hash(self.reference)
不是所有东西都必须是一个对象 #
我们刚刚已经建立了一个采购批次的模型,但实际上我还还需要做的是针对我们已经在库存的采购批次来分配订单。这时候我们可能不是需要一个对象,而是需要一个领域服务(domin function),那么什么叫领域服务呢?Evans在DDD讨论过这个,即:领域服务在实体或值对象中没有自然的归属。现在来看我们的需求,给一组采购批次分配订单。这个事情听起来其实是个函数。那我们就把他变成一个函数。首先我们先看看如何测试这个函数
def test_prefers_current_stock_batches_to_shipments():
in_stock_batch = Batch("in-stock-batch", "RETRO-CLOCK", 100, eta=None)
shipment_batch = Batch("shipment-batch", "RETRO-CLOCK", 100, eta=tomorrow)
line = OrderLine("oref", "RETRO-CLOCK", 10)
allocate(line, [in_stock_batch, shipment_batch])
assert in_stock_batch.available_quantity == 90
assert shipment_batch.available_quantity == 100
def test_prefers_earlier_batches():
earliest = Batch("speedy-batch", "MINIMALIST-SPOON", 100, eta=today)
medium = Batch("normal-batch", "MINIMALIST-SPOON", 100, eta=tomorrow)
latest = Batch("slow-batch", "MINIMALIST-SPOON", 100, eta=later)
line = OrderLine("order1", "MINIMALIST-SPOON", 10)
allocate(line, [medium, earliest, latest])
assert earliest.available_quantity == 90
assert medium.available_quantity == 100
assert latest.available_quantity == 100
def test_returns_allocated_batch_ref():
in_stock_batch = Batch("in-stock-batch-ref", "HIGHBROW-POSTER", 100, eta=None)
shipment_batch = Batch("shipment-batch-ref", "HIGHBROW-POSTER", 100, eta=tomorrow)
line = OrderLine("oref", "HIGHBROW-POSTER", 10)
allocation = allocate(line, [in_stock_batch, shipment_batch])
assert allocation == in_stock_batch.reference
我们的服务函数应该是这样的:
def allocate(line: OrderLine, batches: List[Batch]) -> str:
batch = next(
b for b in sorted(batches) if b.can_allocate(line)
)
batch.allocate(line)
return batch.reference
我们为了能够使sorted工作,我们需要重写我们Batch模型的_gt_魔法方法
class Batch:
...
def __gt__(self, other):
if self.eta is None:
return False
if other.eta is None:
return True
return self.eta > other.eta
这样看起来就优雅多了
异常也能用来领域概念 #
现在我们还剩下最后一个概念要将:异常也可以用来表达领域概念。我们在与产品经理的沟通中了解到,由于缺货而无法分配订单的可能性,我们可以通过领域异常来捕获这种可能性。我们先写测试
def test_raises_out_of_stock_exception_if_cannot_allocate():
batch = Batch('batch1', 'SMALL-FORK', 10, eta=today)
allocate(OrderLine('order1', 'SMALL-FORK', 10), [batch])
with pytest.raises(OutOfStock, match='SMALL-FORK'):
allocate(OrderLine('order2', 'SMALL-FORK', 1), [batch])
这里我们必须要提个醒,我们在用语言命名我们异常时要非常谨慎,就像我们命名我们实体值对象和我们领域服务一样。
class OutOfStock(Exception):
pass
def allocate(line: OrderLine, batches: List[Batch]) -> str:
try:
batch = next(
...
except StopIteration:
raise OutOfStock(f'Out of stock for sku {line.sku}')
结语 #
OK,现在为止,我们就可以能够对我们前文当中的模型图例进行一个完整的概念表示了
现在大概可以了我们其实就差一个数据库了。
我们对本章做一个总结
-
领域建模
这是我们代码中最接近业务,最有可能更改的部分。也是我们交付中最有价值的部分。所以我们要让它易于理解和修改
-
区分实体和值对象
值对象由其属性定义的。他最好作为不可变类型。如果你更改了值对象中的一个属性,那它表示的可能是一个不同的对象。相比之下,一个实体的属性可以随着时间而变化。但仍然是同一个实体。定义唯一标识实体的内容非常重要
-
不是所有东西都必须是一个对象
有些时候,他可能就不是个事情
-
现在是你用到各种OO设计模式最好的时候
重新审视以下面向对象的六大基本原则,比如组合大于继承等。
-
你还需要考虑写边界条件等,这个我们后面再讲。