1.Domain Modeling
В этой главе рассматривается, как можно моделировать бизнес-процессы с помощью кода таким образом, чтобы он был полностью совместим с TDD (Test Driven Development). Обсудим, почему моделирование домена имеет важное значение, и рассмотрим несколько ключевых шаблонов для моделирования доменов: Entity, Value Object, и Domain Service.
[maps_chapter_01_notext] это простое визуальное представление для нашего шаблона Domain Model. В этой главе мы расскажем о некоторых деталях, а когда перейдем к другим главам, то построим все вокруг Domain Model, но вы всегда должны быть в состоянии найти эти маленькие формы в центре.

Что такое Domain Model?
В introduction мы использовали термин business logic layer для описания центрального слоя трехслойной архитектуры. Для остальной части книги будем использовать термин domain model. Это термин из методологии Domain Driven Design (DDD), который лучше улавливает наш предполагаемый смысл (подробнее о DDD читайте дальше).
domain — причудливый способ обозначить проблему, которую вы пытаетесь решить. В настоящее время ваши авторы работают в мебельном интернет-магазине. В зависимости от того, о какой системе вы говорите, предметной областью может быть совершение разовых покупок и закупка по долгосрочным договорам, дизайн продукта или логистика и доставка. Большинство программистов проводят свои дни в попытках улучшить или автоматизировать бизнес-процессы; домен является набором действий, которые поддерживают эти процессы.
model — карта процесса или явления, которая фиксирует полезное свойство. Люди исключительно хороши в производстве моделей вещей в их головах. Например, когда кто-то бросает в вас мяч, вы можете предсказать его движение почти бессознательно, потому что у вас есть модель движения объектов в пространстве. Ваша модель ни в коем случае не идеальна. У людей есть ужасные интуитивные представления о том, как объекты ведут себя на околосветовых скоростях или в вакууме, потому что наша модель никогда не предназначалась для этих случаев. Это не означает, что модель неверна, но это означает, что некоторые прогнозы выходят за рамки её области.
Модель предметной области — это ментальная карта, которую владельцы бизнеса вешают у себя на стене в кабинете и которая отображает их мысли о структуре их бизнеса. У всех деловых людей есть эти ментальные карты — это изображене мыслей этих людей о сложных процессах.
Вы можете сразу определить, когда они ориентируются по этим картам, потому что они используют деловой язык. Жаргон возникает естественным образом среди людей, которые сотрудничают в сложных системах.
Представьте себе, что вы, наш несчастный читатель, внезапно перенеслись за много световых лет от Земли на борту инопланетного космического корабля со своими друзьями и семьей и должны выяснить, исходя из первых принципов, как вернуться домой.
В первые несколько дней вы наверное будете просто нажимать кнопки случайным образом, но вскоре разберётесь, какие кнопки что делают и сможете давать друг другу инструкции. "Нажми красную кнопку возле мигающей штуковины, а затем пребрось этот большой рычаг рядом с радарной хреновиной", — скажете вы.
Через пару недель вы станете более точными, определив слова для описания функций корабля: "Увеличить уровень кислорода в третьем грузовом отсеке" или "включите дополнительные двигатели." Через несколько месяцев вы бы придумали язык для целых сложных процессов: "Начать программу посадки " или "приготовиться к перегрузке." Этот процесс происходил бы совершенно естественно, без каких-либо формальных усилий по созданию общего глоссария.
Так и в обычном мире бизнеса. Терминология, используемая заинтересованными сторонами бизнеса, представляет собой дистиллированное понимание модели предметной области, где сложные идеи и процессы сводятся к одному слову или фразе.
Когда мы слышим, как наши деловые партнеры используют незнакомые слова или используют термины определенным образом, мы должны слушать, чтобы понять более глубокий смысл и закодировать их с трудом завоеванный опыт в наше программное обеспечение.
В этой книге мы будем использовать модель предметной области реального мира, в частности модель из нашей текущей работы. MADE.com является успешным мебельным ритейлером. Мы поставляем нашу мебель от производителей по всему миру и продаем её по всей Европе.
Когда вы покупаете диван или журнальный столик, мы должны решить, как лучше всего доставить ваш товар из Польши, Китая или Вьетнама в вашу гостиную.
На высоком уровне у нас есть отдельные системы, которые отвечают за покупку акций, продажу акций клиентам и доставку товаров клиентам. Система в середине должна координировать процесс, распределяя запасы по заказам клиента; см. [allocation_context_diagram].

В рамках этой книги, пофантазируем, что фирма решает внедрить новый способ распределения запасов. До сих пор компания представляла товар и время выполнения заказа на основе того, что физически доступно на складе. Если и вдруг склад пустеет, продукт считается "отсутствующим на складе" до тех пор, пока не поступит следующая партия от производителя.
Новая идея такова: если у нас есть система, которая может отслеживать все наши поставки и когда они должны прибыть, мы можем рассматривать товары на этих кораблях как реальные запасы и часть нашего инвентаря, просто с немного более длительным временем выполнения заказа. Не смотря на небольшие складские запасы, продавать будем больше, а бизнес сможет сэкономить деньги, уменьшив запасы на внутреннем складе.
Но распределение заказов больше не является тривиальным вопросом уменьшения одного количества в складской системе. Нам нужен более сложный механизм распределения. Время для некоторого моделирования предметной области.
Изучение языка предметной области
Понимание модели предметной области требует времени, терпения и заметок. Мы предварительно беседуем с нашими бизнес-экспертами и договариваемся о глоссарии и некоторых правилах для первой минимальной версии модели предметной области. Там, где это возможно, мы просим привести конкретные примеры, иллюстрирующие каждое правило.
Мы уверены, чтобы выразить эти правила на бизнес-жаргоне (на ubiquitous language в DDD терминологии) надо выбрать запоминающиеся идентификаторы для наших объектов, чтобы было легче говорить на примерах.
следующий сайдбар показывает некоторые заметки, которые мы могли бы сделать во время разговора с нашими экспертами по предметной области Распределения.
Модульное тестирование доменных моделей
Мы не собираемся показывать вам, как работает TDD в этой книге, но мы хотим показать вам, как мы могли бы построить модель из этого делового разговора.
Вот как может выглядеть один из наших первых тестов:
def test_allocating_to_a_batch_reduces_the_available_quantity():
batch = Batch("batch-001", "SMALL-TABLE", qty=20, eta=date.today())
line = OrderLine('order-ref', "SMALL-TABLE", 2)
batch.allocate(line)
assert batch.available_quantity == 18
Название нашего модульного теста описывает поведение, которое мы хотим получить от системы, а имена классов и переменных, которые мы используем, взяты из делового жаргона. Мы могли бы показать этот код нашим нетехническим коллегам, и они согласились бы, что это правильно описывает поведение системы.
А вот и доменная модель, отвечающая нашим требованиям:
@dataclass(frozen=True) #
class OrderLine:
orderid: str
sku: str
qty: int
class Batch:
def __init__(
self, ref: str, sku: str, qty: int, eta: Optional[date] #
):
self.reference = ref
self.sku = sku
self.eta = eta
self.available_quantity = qty
def allocate(self, line: OrderLine):
self.available_quantity -= line.qty #
OrderLine это неизменяемый класс данных без какого-либо поведения.[3] | |
Мы не показываем импорт в большинстве листингов кода, чтобы сохранить их в чистоте. Мы надеемся, что вы догадались, что это появилось здесь благодаря from dataclasses import dataclass; аналогично, typing.Optional и datetime.date. Если вы хотите что-то перепроверить, вы можете увидеть полный рабочий код для каждой главы в её ветке (например, chapter_01_domain_model). | |
| Аннотации типов по-прежнему вызывают споры в мире Python. Для моделей предметной области они иногда могут помочь прояснить или задокументировать ожидаемые аргументы, и люди с IDE часто благодарны за них. Вы можете решить, что цена, заплаченная с точки зрения удобочитаемости, слишком высока. |
Наша реализация здесь тривиальна: Batch просто декоратор! Берёт целое число available_quantity, и уменьшает это значение при резервровании товара в заказе. Мы написали кучу кода только для того, чтобы вычесть одно число из другого, но мы надеемся, что моделирование нашего домена точно окупится off.[4]
Давайте напишем несколько новых failing tests:
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
Здесь нет ничего неожиданного. Мы переработали наш набор тестов, чтобы не повторять одни и те же строки кода для создания партии товара (Batch) и позиции заказа (OrderLine) для одного и того же SKU; и мы написали четыре простых теста для нового метода can_allocate. Again, notice that the names we use mirror the language of our domain experts, and the examples we agreed upon are directly written into code.
Мы также можем реализовать это напрямую, написав can_allocate метод 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
В этом тесте мы assert-им, что deallocating (освобождение) строки из пакета не имеет никакого эффекта, если только пакет ранее не allocated (резервировал) эту позицию . Чтобы это сработало, наша Batch должна понять, какие позиции или строки были зарезервированы. Давайте посмотрим на реализацию:
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
[model_diagram] показывает модель в UML.

Теперь мы кое-чего добились! Партия товара(Batch) теперь отслеживает набор выделенных(allocated) объектов OrderLine. Когда мы распределяем (allocate), если у нас достаточно свободного количества(available quantity), мы просто добавляем к набору. Наше available_quantity теперь является вычисляемым свойством: купленное количество минус выделенное количество.
Да, мы могли бы сделать еще много. Немного обескураживает то, что и allocate(), и deallocate() могут потерпеть неудачу без предупреждения, но основа у нас теперь есть.
Кстати, использование набора для ._allocations упрощает нам обработку последнего теста, потому что элементы в наборе уникальны:
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
На данный момент, вероятно, будет обоснованной критикой сказать, что модель предметной области слишком тривиальна, чтобы беспокоиться о DDD (или даже об объектной ориентации!). В реальной жизни возникает множество бизнес-правил и крайних случаев: клиенты могут запросить доставку в определенные будущие даты, а это означает, что мы можем не захотеть распределять их на самую раннюю партию. Некоторые SKU (артикулы) не выпускаются партиями, а заказываются по требованию непосредственно у поставщиков, поэтому у них другая логика. В зависимости от местоположения клиента мы можем выделить только подмножество складов и отгрузок, которые находятся в его регионе, за исключением некоторых SKU, которые мы с удовольствием доставляем со склада в другом регионе, если у нас нет запасов в домашнем регионе. And so on. Настоящий бизнес в реальном мире знает, как нагромождать сложности быстрее, чем мы можем показать на странице!
Но взяв эту простую модель предметной области в качестве заменителя чего-то более сложного, мы расширим нашу простую модель предметной области в остальной части книги и подключим её к реальному миру API, баз данных и электронных таблиц. Мы увидим, как строгое следование нашим принципам инкапсуляции и тщательного проанализированного наслоения поможет нам избежать :) "говнокодинга"
Dataclasses отлично подходят для Value Objects
Мы широко использовали line в предыдущих листингах кода, но что такое строка? На нашем деловом языке order состоит из нескольких line товаров, где каждая строка имеет SKU и количество. Представм, что простой файл YAML, содержащий информацию о заказе, может выглядеть так:
Order_reference: 12345
Lines:
- sku: RED-CHAIR
qty: 25
- sku: BLU-CHAIR
qty: 25
- sku: GRN-CHAIR
qty: 25
Обратите внимание, что в то время как заказ имеет reference, который однозначно идентифицирует его, line нет. (Даже если мы добавим ссылку на порядок в класс OrderLine, это не то, что однозначно идентифицирует саму строку.)
Всякий раз, когда у нас есть бизнес-концепция, имеющая данные, но не имеющая идентичности, мы часто предпочитаем представлять её с помощью шаблона Value Object. value object-это любой объект предметной области, который однозначно идентифицируется содержащимися в нем данными; обычно мы делаем их неизменяемыми:
@dataclass(frozen=True)
class OrderLine:
orderid: OrderReference
sku: ProductReference
qty: Quantity
Одна из приятных вещей, которые дают нам dataclasses (или namedtuples), — это value equality, что является причудливым способом сказать: "Две строки с одинаковыми orderid, 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)
Эти ценностные объекты соответствуют нашему реальнму передставлению о том, как работают их ценности. Не имеет значения, о какой банкноте в 10 фунтов мы говорим, потому что все они имеют одинаковую ценность. Аналогично, два имени равны, если совпадают имя и фамилия; и две строки эквивалентны, если они имеют один и тот же заказ клиента, код продукта и количество. Однако мы все еще можем иметь сложное поведение на ценностном объекте. На самом деле, обычно поддерживают операции со значениями; например, математические операторы:
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
Строка заказа однозначно идентифицируется по идентификатору заказа (ID), артикулу (SKU) и количеству (quantity); если мы изменим одно из этих значений, теперь у нас будет новая строка. Это определение value object: любой объект, который идентифицируется только своими данными и не имеет долгоживущей идентичности. А как насчет партии товара? Это is идентифицировано ссылкой.
Мы используем термин entity для описания объекта домена, который имеет долгосрочную идентичность. На предыдущей странице мы представили класс Name как объект значения. Если мы возьмем имя Гарри Персиваль и изменим одну букву, у нас будет новый объект Name, Барри Персиваль.
Должно быть ясно, что Гарри Персиваль не равен Барри Персивалю:
def test_name_equality():
assert Name("Harry", "Percival") != Name("Barry", "Percival")
Но как насчет Гарри как личности? Люди меняют свои имена, семейное положение и даже пол, но мы продолжаем признавать их как одного человека. Это потому, что люди, в отличие от имен, имеют постоянное identity:
class Person:
def __init__(self, name: Name):
self.name = name
def test_barry_is_harry():
harry = Person(Name("Harry", "Percival"))
barry = harry
barry.name = Name("Barry", "Percival")
assert harry is barry and barry is harry
Сущности, в отличие от значений, обладают identity equality (равенством идентичности). Мы можем изменить их ценности, и они по-прежнему узнаваемы. Batches (партии), в нашем примере, являются сущностями. Мы можем выделить строки в заказе для партии товара или изменить дату, когда мы ожидаем, что она прибудет, и это будет все та же сущность.
Обычно мы делаем это явно в коде, реализуя операторы равенства для сущностей:
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)
Магический метод Python __eq__ определяет поведение класса для == operator.[6]
И для объектов сущностей, и для объектов значений также стоит подумать о том, как __hash__ будет работать. Это волшебный метод, который Python использует для управления поведением объектов, когда вы добавляете их в наборы или используете их как ключи dict; вы можете найти дополнительную информацию в документации Python.
Для value objects хэш должен основываться на всех атрибутах value, и мы должны гарантировать, что объекты неизменяемы. Мы получаем это бесплатно, указав @frozen=True в классе данных.
Для сущностей самый простой вариант-сказать, что хэш-это None, что означает, что объект не является хэшируемым и не может, например, использоваться в множестве (имеется ввиду set). Если по какой-то причине вы решите, что действительно хотите использовать операции set или dict с сущностями, хэш должен основываться на атрибуте(атрибутах), таком как .reference, который определяет уникальную идентичность сущности с течением времени. Вы должны также попытаться как-то сделать этот атрибут read-only.
Это сложная территория; вы не должны изменять __hash__ без изменения __eq__. Если вы не уверены в том, что делаете, рекомендуется продолжить разбор почитав "Python Hashes and Equality" от нашего технического обозревателя Хайнека Шлавака - хорошее место для начала. |
Не Все Должно быть Объектом: A Domain Service Function
Мы создали модель для представления партий, но на самом деле нам нужно распределить строки заказа по определенному набору партий, представляющих все наши запасы.
Иногда это просто не так.
— Eric Evans
Эванс обсуждает идею Domain Service operations, которые не имеют естественного дома в entity или value object.[7] То, что выделяет строку заказа для данного набора партий, очень похоже на функцию, и мы можем воспользоваться тем фактом, что Python - это многопарадигмальный язык, и просто сделать его функцией.
Давайте посмотрим, как мы можем протестировать такую функцию:
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
Магические методы Python позволяют нам использовать наши модели с идиоматическим Python
Вам может понравиться или не понравиться использование next() в предыдущем коде, но мы почти уверены, что вы согласитесь с тем, что возможность использовать sorted() в нашем списке партий — это хороший идиоматический Python.
Чтобы заставить его работать, мы реализуем __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
Это прекрасно.
Исключения тоже могут выражать концепции предметной области
Имеется еще одна, наверное, последняя концепция, которую нужно охватить: исключения также могут использоваться для выражения концепций предметной области. В наших беседах с экспертами в предметной области мы узнали о возможности того, что заказ не может быть размещен, потому что у нас out of stock (нет запасов), и мы можем зафиксировать это, используя domain exception:
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])
Мы не будем слишком утомлять вас реализацией, но главное, что следует отметить, - это то, что мы тщательно называем наши исключения на ubiquitous language, так же как и наши сущности, объекты ценности и службы:
class OutOfStock(Exception):
pass
def allocate(line: OrderLine, batches: List[Batch]) -> str:
try:
batch = next(
...
except StopIteration:
raise OutOfStock(f'Нет в наличии для артикула {line.sku}')
[maps_chapter_01_withtext] это визуальное представление того, где мы оказались.

Пожалуй, на сегодня хватит! У нас есть доменная служба, которую мы можем использовать для нашего первого варианта использования. Но сначала нам понадобится база данных…
OrderLine совпадает с Batch.sku? Мы сохранили некоторые мысли о валидации для [appendix_validation].__eq__ произносится как "dunder-EQ". По крайней мере, некоторыми.
Комментарии
Отправить комментарий