Моделирование предметной области бизнеса работодателя в Python

Перевод с английского главы из книги Architecture Patterns with Python: Enabling Test-Driven Development, Domain-Driven Design, and Event-Driven Microservices (Архитектурные шаблоны в Python: Реализуя процесс разработки на основе тестирования и микросервисы на основе событий). Данная глава посвящена моделированию предметной области и принципам разработки на основе потребностей работодателя/заказчика.

Источник: 1. Domain Modelling, Harry Percival, Bob Gregory

Глава 7 Совокупности и контуры согласованности

Содержание

  • Что такое модель предметной области?
  • Изучаем язык предметной области
  • Поблочное тестирование (Unit Testing) моделей предметной области
  • Дата-классы (Dataclasses) отлично подходят для объектов-значений (value objects)
  • Объекты-значения и Объекты-сущности (Entities)
  • Не обязательно превращать все в объекты: Функция службы для предметной области
  • Исключения (Exceptions) тоже могут выражать концепции предметной области
  • Моделирование предметной области. Краткое повторение
  • Сноски

В данной главе мы рассмотрим, каким образом можно моделировать бизнес-процессы в коде, используя способ, хорошо совместимый с разработкой на основе тестирования (test-driven development). Мы обсудим, почему так важно моделировать предметную область и обратим внимание на несколько основных шаблонов проектирования: Объект-сущность (Entity), Объект-значение (Value Object) и Служба для предметной области (Domain Service).

Иллюстрация-основа для нашей модели предметной области представляет собой простую визуальную основу для нашего шаблона Модели предметной области (Domain Model pattern). В данной главе мы введем ряд деталей и по мере перехода к следующим главам будем делать надстройки вокруг модели предметной области, но в самой основе вы всегда найдете эти небольшие формы.

модель предметной области шаблон архитектуры в Python

Рисунок 1. Иллюстрация-шаблон для нашей модели предметной области

Что такое модель предметной области?

Во введении мы воспользовались термином "слой бизнес логики" (business logic layer) при описании центрального слоя трехслойной архитектуры. В дальнейшем по всей книге мы будем использовать вместо него термин "модель предметной области" (domain model). Данный термин перенят у сообщества предметно-ориентированного проектирования (domain-driven design, DDD) и лучше отражает предполагаемое нами значение (см. более подробную информацию о предметно-ориентированном проектировании).

Термин "предметная область" — изящный вариант фразы "проблема, которую вы пытаетесь решить". Ваши авторы в настоящее время работают в розничном онлайн-магазине мебели. В зависимости от того, о какой системе мы говорим, предметной областью будет покупка и снабжение либо проектирование продукта либо логистика и доставка. Большинство программистов проводят жизнь за попытками улучшить или автоматизировать бизнес-процессы, а предметная область — это ряд мероприятий, которые поддерживаются этими процессами.

Модель представляет собой карту процесса или феномена, в котором заключено полезное свойство. Человек исключительно хорошо умеет создавать модели вещей в своей голове. Например, когда кто-нибудь бросает мяч в вашем направлении, вы почти неосознанно можете прогнозировать его полет, потому что у вас есть модель того, каким образом объекты перемещаются в пространстве. Ваша модель ни в коем случае не будет точной. Интуиция человека очень плохо справляется с поведением объектов на скоростях, близких к скорости света, или в вакууме, потому что наша модель по своему назначению никогда не предназначалась для охвата таких случаев. Это не означает, что модель неправильная, но это все же означает, что некоторые прогнозы выходят за пределы ее предметной области.

Модель предметной области — это умственная карта, которую владельцы бизнеса составляют для своего бизнеса. Все участники бизнеса создают данные умственные карты, ведь именно так человек думает о сложных процессах.

Вы поймете, когда они пользуются этими картами, потому что они начинают говорить на деловом жаргоне. Он возникает естественным образом среди людей, сотрудничающих в сфере сложных систем.

Давайте представим себе, что вас, нашего невезучего читателя, переместили на расстояния в световые годы от Земли на борту инопланетного космического корабля вместе с друзьями и семьей. Вам нужно разобраться, начиная с самых базовых принципов, каким образом долететь домой.

В первые дни вы, возможно, будете просто нажимать на кнопки случайным образом, но вскоре вы узнаете назначение каждой из них, поэтому сможете давать друг другу указания. Вы можете сказать: "Нажми на красную кнопку возле мерцающего предмета, а затем передвинь тот большой рычаг рядом с радарной штукой".

Не пройдет и двух недель, как вы начнете более точно использовать принятые между вами слова для описания функций корабля: "Повысить уровни кислорода в грузовом отсеке три" или "включить малые двигатели". Через несколько месяцев вы договоритесь между собой о словах, обозначающих целые комплексные процессы: "Запустить очередность на посадку" или "приготовиться к прыжку". Данный процесс будет проходить вполне естественно без каких-то формальных усилий по разработке общего глоссария.

Это не книга про DDD. Вам лучше прочитать книгу про DDD.

Предметно-ориентированное проектирование (Domain-driven design, DDD) популяризовало концепцию моделирования предметной области[1], и это движение очень успешно поменяло используемые людьми методы проектирования программного обеспечения путем наведения фокуса на базовую предметную область бизнеса. Многие из архитектурных шаблонов, которые охватываются нашей книгой, в том числе Объект-сущность (Entity), Совокупность (Aggregate), Объект-значение (Value Object) (см. Главу 7) и Репозиторий (Repository) (в следующей главе), пришли из традиций предметно-ориентированного проектирования.

Если коротко, предметно-ориентированное проектирование говорит о том, что самым важным аспектом программного обеспечения является то, что оно предлагает полезную модель проблемы. Если мы все сделаем правильно насчет этой модели, наше программное обеспечение создает добавленную стоимость и позволяет достигать новых свершений.

Если мы создадим неправильную модель, она станет препятствием, которое будет трудно преодолеть. В данной книге мы можем показать основы построения модели для предметной области и построения вокруг нее архитектуры, благодаря которой модель остается в максимально возможной степени свободной от внешних ограничений, чтобы ей было легко эволюционировать и меняться.

Но сфера предметно-ориентированного проектирования, а также процессов, инструментов и методик для разработки модели предметной области, гораздо шире. Но мы надеемся, что с нашей помощью вы в ней разберетесь, и от всей души рекомендуем вам прочитать полноценную книгу про предметно-ориентированное проектирование:

Первоначальная "синяя книга" "Domain-Driven Design" Эрика Эванса (издательство "Addison-Wesley Professional")

"Красная книга" Вона Вернона о реализации предметно-ориентированного проектирования (издательство "Addison-Wesley Professional")

Так что, все происходит в обыденном мире бизнеса. Терминология, используемая долевыми участниками бизнеса, представляет собой дистиллированное понимание модели предметной области, в которой комплексные идеи и процессы сводятся к одному слову или к одной фразе.

Когда мы слышим, как наши долевые участники бизнеса пользуются незнакомыми словами или применяют термины определенным образом, мы должны слушать, чтобы понимать более глубокое значение и преобразовать их тяжко наработанный опыт в свое программное обеспечение.

На протяжении всей книги мы будем пользоваться моделью предметной области из реального мира, в частности моделью с нашего нынешнего места работы. MADE.com — успешный розничный продавец мебели. Мы получаем мебель от производителей по всему миру и продаем ее по всей Европе.

Когда вы покупаете диван или кофейный столик, нам нужно определить лучший способ транспортировки ваших товаров из Польши, Китая или Вьетнама до вашей гостиной.

На высоком уровне у нас отдельные системы, отвечающие за покупку товара, продажу товара покупателям и доставку товара до покупателей. Центральная система должна координировать процессы, распределяя товары по заказам покупателей. Служба распределения показана на контекстной схеме.

модель предметной области шаблон архитектуры в Python

Рисунок 2. Контекстная схема для службы распределения

Для целей данной книги мы представим себе, что бизнес принял решение реализовать новый увлекательный способ распределения товаров. До этого момента бизнес показывал товары и сроки выполнения заказов по физическому наличию на складе. Если и когда склад опустеет, продукт отражается в перечне со статусом "нет в наличии" до тех пор, пока от производителя не прибудет следующая поставка.

Вот инновация: если наша система в состоянии отслеживать все наши поставки и сроки их прибытия, мы можем считать товары в процессе транспортировки реальным товаром и частью наших запасов, просто сроки доставки будут немного выше. Меньше товаров будет показываться со статусом "нет в наличии", у нас будет больше продаж, а бизнес сможет экономить деньги благодаря пониженным запасам на своем складе.

Но теперь распределение заказов — это уже не тривиальная задача по вычитанию одной цифры из складской системы. Нам нужен более комплексный механизм распределения. Пора уделить немного времени моделированию предметной области.

Изучаем язык предметной области

Чтобы разобраться в модели предметной области, придется тратить время, проявлять терпение и расклеивать стикеры с заметками. Мы проводим стартовый разговор с экспертами нашего бизнеса и согласовываем глоссарий, а также несколько правил для первой минимальной версии модели предметной области. Мы просим по возможности рассказывать о конкретных примерах в целях иллюстрации каждого правила.

Мы постарались выразить данные правила в деловом жаргоне (едином языке (ubiquitous language) по терминологии предметно-ориентированного проектирования). Мы выбираем запоминающиеся идентификаторы для наших объектов, чтобы было проще обсуждать примеры.

На следующей врезке есть несколько заметок, которые мы могли бы написать во время разговора с экспертами нашей предметной области по поводу распределения.

Несколько заметок по поводу распределения

Продукт идентифицируется по учетному складскому номеру (SKU, произносится как "скью", сокращение от термина "stock-keeping unit"). Клиенты размещают заказы. Заказ идентифицируется по номеру заказа (order reference) и содержит несколько строк заказа (order lines), при этом на каждой строке показан свой SKU и количество.

Например:

RED-CHAIR (КРАСНЫЙ-СТУЛ), 10 единиц

TASTELESS-LAMP (БЕЗВКУСНАЯ-ЛАМПА), 1 единица

Департамент закупок заказывает товар небольшими партиями. Каждой партии товара назначается уникальный идентификатор под названием "номер" (reference), SKU и количество.

Нам нужно распределить строки заказа по партиям. Когда мы свяжем строку заказа с определенной партией, то направим товар из этой конкретной партии по адресу доставки покупателя. Когда мы свяжем Х единиц товара с партией, объем в наличии уменьшается на Х. Например:

  • У нас партия из 20 SMALL-TABLE (МАЛЕНЬКИХ-СТОЛИКОВ) и мы распределяем строку заказа на 2 SMALL-TABLE.
  • В партии должно остаться 18 SMALL-TABLE.

Мы не можем связать с партией, если доступное количество меньше количества по строке заказа. Например:

  • У нас партия из 1 BLUE-CUSHION (ГОЛУБОЙ-ПОДУШКИ), а по строке заказа 2 BLUE-CUSHION.
  • Должно быть так, чтобы мы не могли связать строку с партией.

Мы не можем распределять одну и ту же строку два раза. Например:

  • У нас партия на 10 BLUE-VASE (ГОЛУБЫХ-ВАЗ), и мы распределяем строку заказа на 2 BLUE-VASE.
  • Если мы связываем строку заказа с той же партией еще раз, доступное количество по партии должно быть равно 8.

Если партия в настоящее время в пути, то ей должно быть присвоено расчетное время прибытия, либо она может числиться на складе. Мы связываем со складским запасом, отдавая предпочтение партиям в пути. Мы распределяем на партии в пути, начиная с самого раннего расчетного времени прибытия.

Поблочное тестирование (Unit Testing) моделей предметной области

В этой книге мы не будем показывать вам, каким образом работает разработка на основе тестирования (test-driven development, TDD), но мы хотим показать вам, каким образом мы будем конструировать модель на основе этого разговора о бизнесе.

Упражнение для читателей

Почему бы не попробовать решить эту проблему самостоятельно? Напишите несколько блочных тестов (unit tests), чтобы проверить, можете ли вы ухватить суть этих бизнес-правил в изящном и чистом коде.

На гитхабе вы найдете несколько моделей поблочных тестов, но можете начать с самого нуля или совместить/переписать их по желанию.

Вот так может выглядеть один из наших первых тестов:

Первый тест для распределения (test_batches.py)

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

Название нашего блочного теста описывает поведение, которое мы хотим увидеть в системе, а названия используемых нами классов и переменных, берутся из бизнес-жаргона. Мы можем показать этот код коллегам, которые не занимаются техническими задачами, и они согласятся с тем, что код корректно описывает поведение системы.

Далее приводится модель предметной области, которая отвечает нашим требованиям:

Первый рабочий вариант модели предметной области для партий (model.py)

@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)

1. OrderLine — не изменяемый дата-класс (immutable dataclass), не выполняющий никаких действий.[2]

2. В большинстве блоков кода мы не будем показывать импорты, потому что стараемся поддерживать в них чистоту. Надеемся, вы поймете, что это сделано с помощью from dataclasses import dataclass; аналогичным образом и typing.Optional с datetime.date. Если вы хотите все перепроверить, рабочий код по каждой главе в полном объеме приведен на ее ветке (например, chapter_01_domain_model).

3. Подсказки о типах (type hints) все еще остаются предметом споров в мире Python. Что касается моделей предметной области, они иногда могут помочь с улучшением понятности документа или зафиксировать предполагаемые аргументы, а пользователи интегрированных сред разработки (IDE) часто рады их видеть. Возможно, вы решите, что это слишком сильно усложняет чтение кода и не стоит того.

В данном случае наша реализация тривиальна: класс Batch просто обертывает число доступного количества available_quantity, и мы уменьшаем данное значение при распределении. Мы написали довольно много кода только для того, чтобы вычесть одно число из другого, но мы полагаем, что моделирование нашей предметной области окупится[3].]

Давайте напишем несколько новых тестов, которые должны закачиваться неудачей:

Логика тестирования для вещей, которые мы можем распределять (test_batches.py)

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

Здесь тоже нет ничего неожиданного. Мы провели рефакторинг нашего тестового комплекса (test suite), чтобы нам не приходилось и дальше повторять одни и те же строки кода для создания партий и строки для одного и того же SKU; и мы написали четыре простых теста для нового метода can_allocate (можно_распределить). Опять-таки, обратите внимание на то, что используемые нами названия отражают язык экспертов нашей предметной области, и согласованные между нами примеры прямым текстом вписаны в код.

Здесь нам тоже доступна прямолинейная реализация, если мы напишем метод can_allocate для Batch:

Новый метод в модели (model.py)

    def can_allocate(self, line: OrderLine) -> bool:
        return self.sku == line.sku and self.available_quantity >= line.qty

На данный момент мы можем управлять реализацией, просто увеличивая и уменьшая Batch.available_quantity, но, когда мы перейдем к тестам для deallocate(), то будем вынуждены пользоваться более интеллигентным решением:

Для данного теста потребуется более умная модель (test_batches.py)

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

В данном тесте мы утверждаем, что отвязывание строки от партии не имеет никакого эффекта, если строка не распределялась на партию перед этим. Чтобы это работало, нашему классу Batch нужно понимать, какие строки были распределены. Давайте посмотрим на реализацию:

Теперь модель предметной области отслеживает распределения (model.py)

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

Наша модель на едином языке моделирования (Unified Modeling Language, UML) показывает, как выглядит модель в выражениях единого языка моделирования.

модель предметной области шаблон архитектуры в Python

Рисунок 3. Наша модель на едином языке моделирования

Вот теперь у нас кое-что есть! Теперь партия отслеживает набор (set) распределенных объектов класса OrderLine. Когда мы осуществляем распределение, если у нас в наличии достаточное количество, мы просто добавляем к набору. Теперь доступное количество стало рассчитанным количеством: купленное количество минус распределенное количество.

Да, мы еще много чего можем сделать. Слегка смущает то, что allocate() и deallocate() оба могут по-тихому не сработать, но зато у нас есть основа.

Кстати, реализация ._allocations через набор (set) упрощает для нас работу с последним тестом, потому что все элементы набора уникальны:

Последний тест для партии! (test_batches.py)

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

На данный момент, наверное, будет правильно покритиковать, сказав, что модель предметной области слишком простая для того, чтобы озадачиваться предметно-ориентированным проектированием (или даже местонахождением объектов!). В реальной жизни неожиданно появляется какое угодно количество бизнес-правил и пограничных случаев: покупатели могут попросить доставку на определенные даты в будущем, и это означает, что мы, возможно, не захотим распределить на них партию с самым ранним сроком. Некоторых SKU нет в партиях, но они заказываются по требованию непосредственно у поставщика, поэтому для них предусмотрена другая логика. В зависимости от адреса покупателя мы можем распределить лишь на какой-нибудь поднабор складов и доставок в пределах того же региона, за исключением тех нескольких SKU, которые мы рады доставить со склада в другом регионе, если запасы в пределах нужного региона на нуле. И так далее. Реальный бизнес в реальном мире знает, как нужно повышать сложность со скоростью, которая превышает наши возможности отображения на странице!

Но, используя эту простую модель предметной области в качестве шаблона под что-нибудь более сложное, мы будем расширять свою простую модель предметной области на всем протяжении книги и вставим ее в реальный мир интерфейсов API, баз данных и электронных таблиц. Мы увидим, каким образом строгое соблюдение наших принципов инкапсуляции (encapsulation) и аккуратное наложение слоев поможет нам избежать комка грязи.

Больше типов, чтобы было больше подсказок о типах (Type Hints)

Если вы по-настоящему хотите продвинуться в подсказках для типов, вы можете дойти аж до оборачивания примитивных типов с помощью typing.NewType:

Боб, это уже слишком

from dataclasses import dataclass
from typing import NewType
Quantity = NewType("Quantity", int)
Sku = NewType("Sku", str)
Reference = NewType("Reference", str)
...
class Batch:
    def __init__(self, ref: Reference, sku: Sku, qty: Quantity):
        self.sku = sku
        self.reference = ref
        self._purchased_quantity = qty

Это позволит нашей программе для проверки типов (type checker) убедиться в том, что мы не передаем SKU в ситуации, когда ожидается, например номер Reference.

Как бы вы ни относились к этому, восхищенно или с ужасом, этот вопрос остается предметом споров.[4]

Дата-классы (Dataclasses) отлично подходят для объектов-значений (value objects)

В предыдущих блоках кода мы использовали строки в буквальном смысле. Но, что такое строка? На языке нашего бизнеса в заказе несколько элементов-строк, при этом у каждой строки есть SKU и количество. Можно представить себе, что простой файл YAML с информацией о заказе может выглядеть вот так:

Информация о заказе в формате YAML

Order_reference: 12345
Lines:
  - sku: RED-CHAIR
    qty: 25
  - sku: BLU-CHAIR
    qty: 25
  - sku: GRN-CHAIR
    qty: 25

Обратите внимание, что в заказе есть номер, который создает для него уникальный идентификатор, а у строки его нет. (Даже если мы добавим номер заказа в класс OrderLine, это не поможет создать уникальный идентификатор для самой строки.)

Каждый раз, когда у нас появляется бизнес-концепция, содержащая данные и не имеющая идентификатора, мы часто предпочитаем отображать ее с помощью шаблона Объект-значение (Value Object). Объектом-значением будет любой объект в пределах предметной области, который уникально идентифицируется содержащимися в нем данными. Обычно мы делаем их не изменяемыми (immutable):

Класс OrderLine — это объект-значение

@dataclass(frozen=True)
class OrderLine:
    orderid: OrderReference
    sku: ProductReference
    qty: Quantity

Среди прочих изящных вещей, которые мы получаем с помощью дата-классов (или поименованных кортежей namedtuples), будет равенство значений. Это изящная версия фразы "Две строки с одним и тем же 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 фунтов стерлингов идет речь, потому что значение у них всех одинаковое. Аналогичным образом два имени (names) будут одинаковыми, если первое совпадает с последним. Две строки будут равны, если они содержат один и тот же заказ от покупателя, код продукта и то же самое количество. Опять-таки, наши объекты-значения могут выполнять сложные действия. На самом деле, часто есть поддержка операций со значениями. Например, математические операторы (mathematical operators):

Математика с объектами-значениями

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

Объекты-значения и Объекты-сущности (Entities)

Строка заказа имеет уникальный идентификатор в виде своих order ID, SKU и количества. Если мы изменим одно из этих значений, то у нас будет новая строка. Это и есть суть объекта-значения: каждый объект, который идентифицируется только своими данными и не имеет постоянной идентичности. А что же насчет партии? Она идентифицируется номером.

Мы пользуемся термином entity, чтобы описать объект предметной области с постоянной идентичностью. На предыдущей странице мы ввели класс Name как объект-значение. Если мы возьмем имя Гарри Персиваль (Harry Percival) и поменяем одну букву, то у нас будет новый объект Name под именем Barry Percival.

Наверное, очевидно, что Harry Percival и Barry Percival — это не одно и то же:

Само имя меняться не может

def test_name_equality():
    assert Name("Harry", "Percival") != Name("Barry", "Percival")

Но, как насчет Гарри как человека? Люди ведь и правда меняют имена, семейное положение и даже пол. Но мы продолжаем считать их той же самой личностью. Потому что у человека, в отличие от имени, есть постоянная идентичность:

Но человек может!

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

Объектам-сущностям, в отличие от объектов-значений, доступно постоянство идентичности. Мы можем менять их значения, и они по-прежнему узнаваемы как тот же самый предмет. В нашем примере партии являются объектами-сущностями. Мы можем распределять строки по партиям или менять ожидаемую дату прибытия, и они будут оставаться теми же самыми объектами-сущностями.

Обычно в нашем коде это проявляется явным образом, потому что мы используем операторы равенства (equality operators) с объектами-сущностями:

Реализация операторов равенства (model.py)

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__ определяет поведение класса с оператором ==.[5]

Как для объектов-сущностей, так и для объектов-значений следует продумать, каким образом будет работать __hash__. Этот волшебный метод используется в Python, чтобы контролировать поведение объектов в ситуации, когда мы добавляем их в наборы или используем их в качестве словарных ключей (dict keys). Более подробную информацию можно найти в документации Python.

Что касается объектов-значений, хэш должен создаваться на основе всех атрибутов объекта, и мы должны гарантировать невозможность изменения объектов. Это мы сделаем легко, добавив в дата-класс @frozen=True.

Что касается объектов-сущностей, самый простой вариант — это сказать, что хэш будет иметь значение None. То есть, объект будет не хэшируемым, поэтому его нельзя использовать, например, в наборе. Если мы почему-то решим, что нам и правда очень нужно реализовать операции для набора или словаря при работе с объектами-сущностями, хэш можно создавать на основе атрибута(ов), например .reference, который определяет определяет уникальную идентичность объекта-сущности в долгосрочной перспективе. Еще мы можем попробовать как-нибудь сделать этот атрибут доступным только для чтения.

Внимание

Это неоднозначная тема. Не стоит модифицировать __hash__ без параллельной модификации __eq__. Если вы не уверены в том, что делаете, предлагаем обратиться к дополнительным источником. Хорошей отправной точкой будет пост "Python Hashes and Equality" нашего технического рецензента Ханика Шлавака (Hynek Schlawack).

Не обязательно превращать все в объекты: Функция службы для предметной области

Мы создали модель, чтобы отразить партии, но на самом деле нам нужно распределять строки заказа по определенному набору партий, отражающему весь наш складской запас.

Иногда это просто не подходит.

Эрик Эванс

Предметно-ориентированное проектирование

Эванс рассматривает идею об операциях Службы для предметной области, у которых нет естественного дома в объекте-сущности или в объекте-значении.[6] То, что осуществляет распределение на строку заказа с учетом набора партий, больше похоже на функцию, и мы можем воспользоваться тем, что Python представляет собой язык с несколькими парадигмами, и просто сделаем функцию.

Давайте посмотрим, как можно провести тест-драйв такой функции:

Тестируем нашу службу для предметной области (test_allocate.py)

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

А вот так может выглядеть наша служба:

Самостоятельная функция в нашей службе для предметной области (model.py)

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__ в нашей модели предметной области:

Волшебные методы могут выражать семантику предметной области (model.py)

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

Очень красиво.

Исключения (Exceptions) тоже могут выражать концепции предметной области

Мы должны охватить еще одну последнюю концепцию: исключения тоже можно использовать для выражения концепций предметной области. Во время общения с экспертами предметной области мы мы узнали о возможности того, что заказ нельзя распределять в связи с отсутствием запаса, и мы можем ухватить это с помощью исключения, реализованного в предметной области:

тест для исключения, говорящего "нет в наличии" (test_allocate.py)

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])

Моделирование предметной области. Краткое повторение

Моделирование предметной области — это та часть вашего кода, которая ближе всего к бизнесу, для которой наиболее вероятны изменения, в которой вы приносите больше всего пользы бизнесу. Сделайте ее простой для понимания и изменения.

Разделение между объектами-сущностями и объектами-значениями. Объект-значение определяется своими атрибутами. Обычно его лучше всего реализует не изменяемый тип. Если вы изменяете атрибут объекта-значения, он представляет собой другой объект. Сущность, напротив, имеет атрибуты, которые могут меняться с течением времени, но она все еще будет той же самой сущностью. Важно определить, что в действительности является уникальным идентификатором сущности (обычно это какое-нибудь поле с именем или номером).

Не все должно быть объектом. Python — это язык с несколькими парадигмами, поэтому пусть функциями в вашем коде будут "глаголы". На каждый класс FooManager, BarBuilder или BazFactory приходится более выразительная и читаемая функция manage_foo(), build_bar() или get_baz(), которая напрашивается на реализацию.

Теперь как раз уместно реализовать свои лучшие объектно-ориентированные принципы. Предлагаем вспомнить принципы SOLID и все остальные хорошие практические навыки, например "использование извне против присутствия внутри" ("has a versus is-a"), "композиция предпочтительнее наследования" ("prefer composition over inheritance") и т.д.

Также вы захотите подумать про области последовательности (consistency boundaries) и совокупности (aggregates), но это тема главы 7.

Мы не будем слишком вас утомлять реализацией, но следует отметить главное: мы проявляем осторожность, присваивая имена своим исключениям в рамках единого языка, точно так же как мы это делаем для сущностей, объектов-значений и служб:

Вызываем исключение в предметной области (model.py)

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}')

В конце главы наша модель предметной области представляет собой визуальное представление того, где мы оказались.

модель предметной области шаблон архитектуры в Python

Рисунок 4. Модель нашей предметной области по состоянию на конец главы

Пока, наверное, все! У нас есть служба для предметной области, которую мы можем использовать в своем первом сценарии. Но сначала нам понадобится база данных...

Сноски

1 Моделирование предметной области зародилось не в предметно-ориентированном проектировании. Эрик Эванс ссылается на книгу 2002 года под названием "Object Design", авторы Ребекка Вирфс-Брок (Rebecca Wirfs-Brock) и Алан МакКин (Alan McKean) (издательство "Addison-Wesley Professional"), в которой предложено проектирование на основе обязанностей (responsibility-driven design). Предметно-ориентированное проектирование как раз является частным случаем последнего, занимаясь предметной областью. Но даже она появилась слишком поздно, и энтузиасты объектно-ориентированного программирования скажут вам идти еще дальше в прошлое к Ивару Якобсону (Ivar Jacobson) и Греди Буху (Grady Booch). Этот термин известен с середины 1980-х.

2 В предыдущих версиях Python мы могли использовать namedtuple. Еще можете посмотреть прекрасную библиотеку attrs Ханика Шлавака.

3 Или вы, может быть, думаете, что не хватает кода? Как насчет какой-нибудь проверки того, что SKU в OrderLine соотносится с Batch.sku? Некоторые мысли по поводу валидации мы оставили на Приложение Е.

4 Это ужасно. Очень-очень прошу вас не делать этого. —Гарри

5 Метод __eq__ произносится как "dunder-EQ". По крайней мере, некоторыми.

6 Службы для предметной области — это не то же самое, что службы на служебном уровне (service layer), хотя они нередко связаны между собой. Служба для предметной области представляет собой бизнес-концепцию или процесс, а служба на служебном уровне представляет собой сценарий использования вашего приложения. Нередко бывает так, что служебный уровень вызывает службу для предметной области.