Предисловие

Возможно, вам интересно, кто мы и почему написали эту книгу.

В последнем издании книги Test-Driven Development with Python (O’Reilly), в конце, Гарри поймал себя на мысли, что задает уйму вопросов по архитектуре. Типа, как лучше всего структурировать приложение, чтобы его было легко тестировать? Это вообще, про что? Может это про покрытие модульными тестами вашей основной бизнес-логики и что бы количество необходимых вам интеграционных и сквозных тестов было минимизировано? Он дал расплывчатые ссылки на «Гексагональную архитектуру», «Порты и адаптеры» и "Functional Core, Imperative Shell", но если бы он был честен, ему пришлось бы признать, что это не то, что он действительно до конца понял, принял или применял на практике.

И тут случилось чудо! Ему посчастливилось столкнуться с Бобом, у которого есть ответы на все эти вопросы.

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

Управление Сложностью, Решение Бизнес-Задач

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

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

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

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

Почему Python?

Если вы читаете эту книгу, то вероятно, не нужно убеждать вас в том, что Python великолепен, поэтому реальный вопрос заключается в следующем: "Зачем сообществу Python нужна такая книга?" Ответ заключается в популярности и зрелости Python: хотя Python, вероятно, является самым быстрорастущим языком программирования в мире и приближается к вершине абсолютной таблицы популярности, он только начинает решать те проблемы, над которыми мир C# и Java работал в течение многих лет. Стартапы становятся реальным бизнесом; веб-приложения и скриптовые автоматизации становятся (говорю шепотом) enterprise software.

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

Ни один из методов и шаблонов, которые мы обсуждаем в этой книге, не является новым, но по большей части они являются новыми для мира Python. И эта книга не является заменой классики в этой области, такой как Domain-Driven Design Эрика Эванса или Patterns of Enterprise Application Architecture Мартина Фаулера (оба опубликованы Addison-Wesley Professional), на которые мы часто ссылаемся и призываем вас пойти и прочитать.

Но все классические примеры кода в литературе, как правило, написаны на Java или C++/#, и если вы человек из рядов Python и не использовали ни один из этих языков в течение длительного времени (или вообще никогда), то эти листинги кода могут быть довольно…​трудными. Видимо это и есть причина, по которой последнее издание другого классического текста, Fowler’s Refactoring (Addison-Wesley Professional), написано в JavaScript.

TDD, DDD, и Event-Driven Architecture

В порядке популярности назовём три инструмента для управления сложностью:

  1. Test-driven development (TDD) Разработка на основе тестов. Помогает нам создавать правильный код и проводить рефакторинг или добавлять новые функции, не опасаясь регресса. Но бывает сложно извлечь максимальную пользу из наших тестов: как сделать так, чтобы они выполнялись как можно быстрее? Чтобы мы получили как можно более качественное покрытие и обратную связь от быстрых модульных тестов без зависимостей (dependency-free unit tests) и имели минимальное количество более медленных, нестабильных сквозных (end-to-end) тестов?

  2. Domain-driven design (DDD) Разработка на основе поведения. Просит нас сосредоточить наши усилия на построении хорошей модели бизнес-сферы, но как мы можем убедиться, чтобы наши модели не были обременены инфраструктурными проблемами и их не было трудно изменить?

  3. Loose coupling Слабосвязанные (микросервисы), интегрированные через сообщения (иногда называемые reactive microservices), являются хорошо зарекомендовавшим себя решением проблемы управления сложностью в нескольких приложениях или бизнес-доменах. Но не всегда очевидно, как сделать так, чтобы они соответствовали широко распространённым инструментам мира Python-Flask, Django, Celery и так далее.

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

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

Кому следует прочитать эту книгу

Вот несколько мыслей, которые мы предполагаем справедливо сказать о тебе, дорогой читатель:

  • Ты был близок к некоторым достаточно сложным приложениям Python.

  • Ты видел, а может сам испытал ту боль, которая приходит вместе с попыткой справиться с этой сложностью.

  • Ты не обязательно знаешь что-либо о DDD или любом из классических шаблонов архитектуры приложений.

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

Мы используем некоторые специфические фреймворки и технологии Python, включая Flask, SQLAlchemy и pytest, а также Docker и Redis. Если вы уже знакомы с ними, то это не повредит, но вряд ли это необходимо. Одной из наших главных целей в этой книге является построение архитектуры, для которой конкретные технологические решения становятся второстепенными деталями реализации.

Краткий обзор того, что будет дальше

Книга разделена на две части; Вот некоторые темы, которые мы рассмотрим, и главы, в которых они живут.

#Часть1

Domain modeling и DDD (Chapters 1, 2 и 7)

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

Repository, Service Layer, и Unit of Work patterns (Chapters 2, 4, и 5)

В этих трех главах мы представляем три тесно связанных и взаимно усиливающих друг друга паттерна, которые поддерживают наше стремление сохранить модель свободной от посторонних зависимостей. Мы выстроим слой абстракции вокруг постоянного хранилища, и уровень сервиса, чтобы определить точки входа в нашу систему и захватить основные варианты использования. Мы покажем, как этот слой позволяет легко создавать тонкие точки входа в нашу систему, будь то API Flask или CLI (Command Line Interface - Интерфейс командной строки).

Некоторые соображения о тестировании и абстракциях (Chapter 3 и 5)

После представления первой абстракции (паттерна Repository) воспользуемся возможностью для общего обсуждения того, как выбирать абстракции и какова их роль в выборе того, как наше программное обеспечение связано друг с другом. После знакомства с шаблоном Service Layer, немного поговорим о построении test pyramid и написании модульных тестов на максимально возможном уровне абстракции.

#Часть2

Архитектура, управляемая событиями (Chapters 8-11)

Мы вводим еще три взаимно усиливающих шаблона: Domain Events, Message Bus, и Handler patterns. События домена (Domain Events)-это средство передачи идеи о том, что некоторые взаимодействия с системой являются триггерами для других. Мы используем шину сообщений Message Bus, чтобы позволить действиям вызывать события и вызывать соответствующие handlers (обработчики). Мы переходим к обсуждению того, как события могут быть использованы в качестве шаблона для интеграции между службами в архитектуре микросервисов. Наконец, мы различаем команды и события. Наше приложение теперь по сути является системой обработки сообщений.

Разделение ответственности по командам и запросам (Command-Query Responsibility Segregation (CQRS)[1])

Мы приводим пример разделения ответственности команд-запросов с событиями и без событий.

Инъекция зависимостей (Dependency Injection (и Bootstrapping))

Мы приводим в порядок наши явные и неявные зависимости и реализуем простую структуру внедрения зависимостей.

Дополнительный контент

Как мне попасть туда отсюда?[3] (Эпилог):: Реализация архитектурных шаблонов всегда выглядит легко, когда вы показываете простой пример, начиная с нуля, но многие из вас, вероятно, зададутся вопросом, как применить эти принципы к существующему программному обеспечению. Мы дадим несколько указаний в эпилоге и некоторые ссылки для дальнейшего чтения.

Примеры кода и совместное кодирование

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

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

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

Вот три способа кодирования вместе с книгой:

  • Начните свой собственное репозиторий и попробуйте создать приложение, как это делаем мы, следуя примерам из листингов в книге и время от времени заглядывая в наше репо за подсказками. Однако предупреждаю: если вы читали предыдущую книгу Гарри и кодировали вместе с ней, вы обнаружите, что эта книга требует от вас проявить больше самостоятельности; вам, возможно, придется сильно полагаться на рабочие версии на GitHub.

  • Попробуйте применить каждый шаблон, главу за главой, к вашему собственному (желательно маленькому/игрушечному) проекту и посмотрите, сможете ли вы заставить его работать для вашего варианта использования. Высокий риск/высокая награда (и, кроме того, достаточные усилия!). Возможно придётся изрядно попотеть, чтобы заставить какие то вещи работать в соответствии со спецификой вашего проекта, но, с другой стороны, вероятно вы, узнаете много полезного.

  • В каждой главе мы описываем "Упражнение для читателя" и даём ссылки на GitHub, где вы можете скачать частично готовый код для главы с несколькими недостающими частями, чтобы написать его самостоятельно.

Особенно если вы намереваетесь применить некоторые из этих паттернов в своих собственных проектах, работа с простым примером-отличный способ безопасно практиковаться.

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

Лицензия

Код (и онлайн-версия книги) находится под лицензией Creative Commons CC BY-NC-ND, что означает, что вы можете свободно копировать и делиться им с кем угодно в некоммерческих целях при условии указания авторства. Если вы хотите повторно использовать какой-либо контент из этой книги и у вас есть какие-либо опасения по поводу лицензии, свяжитесь с O’Reilly .

Печатное издание лицензируется по-другому; см. страницу об авторских правах.

Условные обозначения, используемые в этой книге

В этой книге используются следующие типографские условные обозначения:

Курсив

Указывает новые термины, URL-адреса, адреса электронной почты, имена файлов и расширения файлов.

Постоянная ширина

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

Постоянная ширина жирный шрифт

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

Курсив постоянной ширины

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

Этот элемент означает подсказку или предложение.

Этот элемент обозначает общее примечание.

Этот элемент указывает на предупреждение или предостережение.

Онлайн-обучение O’Reilly

Более 40 лет O’Reilly Media предоставляет технологии и бизнес-тренинги, знания и идеи, чтобы помочь компаниям добиться успеха.

Наша уникальная сеть экспертов и новаторов делится своими знаниями и опытом с помощью книг, статей, конференций и нашей онлайн-платформы обучения. Платформа онлайн-обучения O’Reilly предоставляет вам доступ по требованию к живым учебным курсам, углубленным учебным путям, интерактивным средам кодирования и обширной коллекции текстов и видео от O’Reilly и более чем 200 других издателей. Для получения дополнительной информации, пожалуйста, посетите сайт http://oreilly.com.

Как связаться с O’Reilly

Пожалуйста, направляйте комментарии и вопросы, касающиеся этой книги, издателю:

  • O’Reilly Media, Inc.
  • 1005 Gravenstein Highway North
  • Sebastopol, CA 95472
  • 800-998-9938 (in the United States or Canada)
  • 707-829-0515 (international or local)
  • 707-829-0104 (fax)

У нас есть веб-страница для этой книги, где мы перечисляем ошибки, примеры и любую дополнительную информацию. Вы можете получить доступ к этой странице по адресу https://oreil.ly/architecture-patterns-python.

Email для комментариев и технических вопросов по этой книге.

Для получения дополнительной информации о наших книгах, курсах, конференциях и новостях посетите наш веб-сайт по адресу http://www.oreilly.com.

Найдите нас на Facebook: http://facebook.com/oreilly

Следите за нами в Twitter: http://twitter.com/oreillymedia

Смотрите нас на YouTube: http://www.youtube.com/oreillymedia

Благодарности

Нашим техническим обозревателям Дэвиду Седдону, Эду Юнгу и Хайнеку Шлаваку: мы абсолютно не заслуживаем вас. Вы все невероятно преданные, добросовестные и строгие. Каждый из вас безмерно умен, и ваши разные точки зрения были полезны и дополняли друг друга. Спасибо вам от всего сердца.

Огромное спасибо всем нашим читателям за их комментарии и предложения: Йен Купер, Абдулла Арифф, Джонатан Мейер, Гил Гонсалвес, Матье Чоплин, Бен Джадсон, Джеймс Грегори, Лукаш Лехович, Клинтон Рой, Виторино Араужо, Сьюзан Гудбоди, Джош Харвуд, Дэниел Батлер, Лю Хайбин, Джимми Вергиа Игнасиа Игнас Канестрани, Ренне Роча, Педроаби, Ашиа Завадук, Йостейн Лейра, Брэндон Роудс, Язепс Баско, Симкимсия, Адриен Брюнет и многие другие; приносим свои извинения, если мы пропустили Вас в этом списке.

Супер-мега-спасибо нашему редактору Корбину Коллинзу за его нежное щебетание и за то, что он неутомимый защитник читателя. В такой же степени выражаем благодарность производственному персоналу Кэтрин Тозер, Шэрон Уилки, Эллен Траутман-Заиг и Ребекке Демарест за вашу преданность делу, профессионализм и внимание к деталям. Эта книга неизмеримо улучшена благодаря вам.

Любые ошибки, оставшиеся в книге, естественно, являются нашими собственными.

Введение

Почему Наш Дизайн Такой НЕУДАЧНЫЙ?

Что приходит на ум, когда вы слышите слово хаос? Возможно, вы думаете о шумной фондовой бирже или о своей кухне по утрам — все запутано и перемешано. Когда вы думаете о слове порядок, возможно, вы думаете о пустой комнате, безмятежной и спокойной. Однако для ученых хаос характеризуется однородностью (sameness), а порядок — сложностью (difference).

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

Программные системы тоже склонны к хаосу. Когда мы впервые начинаем строить новую систему, у нас есть грандиозные идеи, что наш код будет чистым и хорошо упорядоченным, но со временем мы обнаруживаем, что он включает ненужные и оборванные элементы кода и заканчивается запутанной трясиной менеджеров классов и утилитных модулей. Мы обнаруживаем, что наша разумная многослойная архитектура рухнула сама по себе, как какая то безделушка. Хаотические программные системы характеризуются одинаковостью функций: обработчики API, которые знают предметную область и отправляют электронную почту и выполняют регистрацию; классы «бизнес-логики», которые не выполняют вычислений, но выполняют ввод-вывод; и все вместе со всем остальным, так что изменение любой части системы чревато опасностью. Это настолько распространено, что у разработчиков программного обеспечения есть собственный термин для обозначения хаоса: антипаттерн "the Big Ball of Mud" (Большой шар грязи или нелитературно по русски говнокод :) ) (Схема зависимостей из реальной жизни (source: "Enterprise Dependency: Big Ball of Yarn" by Alex Papadimoulis)).

apwp 0001
Figure 1. Схема зависимостей из реальной жизни (source: "Enterprise Dependency: Big Ball of Yarn" by Alex Papadimoulis)
Говнокод — естественное состояние программного обеспечения, так же как болезнь — естественное состояние вашего сада. Чтобы предотвратить коллапс, нужны энергия и направление.

К счастью, методы, позволяющие избежать этого, не сложны.

Инкапсуляции и абстракции

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

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

Взгляните на следующие два фрагмента кода Python:

Example 1. Выполним поиск с помощью urllib
import json
from urllib.request import urlopen
from urllib.parse import urlencode

params = dict(q='Sausages', format='json')
handle = urlopen('http://api.duckduckgo.com' + '?' + urlencode(params))
raw_text = handle.read().decode('utf8')
parsed = json.loads(raw_text)

results = parsed['RelatedTopics']
for r in results:
    if 'Text' in r:
        print(r['FirstURL'] + ' - ' + r['Text'])
Example 2. Сделаем поиск с запросами
import requests

params = dict(q='Sausages', format='json')
parsed = requests.get('http://api.duckduckgo.com/', params=params).json()

results = parsed['RelatedTopics']
for r in results:
    if 'Text' in r:
        print(r['FirstURL'] + ' - ' + r['Text'])

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

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

Example 3. Поиск с помощью клиентской библиотеки duckduckgo
import duckduckpy
for r in duckduckpy.query('Sausages').related_topics:
    print(r.first_url, ' - ', r.text)

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

В литературе, посвященной объектно-ориентированному (ОО) миру, одна из классических характеристик этого подхода называется responsibility-driven design; в нем используются слова roles (роли) и responsibilities (обязанности), а не tasks (задачи). Главное — думать о коде с точки зрения поведения, а не с точки зрения данных или алгоритмов.[4]
Abstractions и ABCs

В традиционном объектно-ориентированном языке, таком как Java или C\#, вы можете использовать абстрактный базовый класс (ABC) или интерфейс для определения абстракции. В Python вы можете (и мы иногда используем) использовать ABC, но вы также можете положиться на Duck typing (если это похоже на утку и крякает как утка, то это утка).

Абстракция может означать просто «общедоступный API того, что вы используете» — например, имя функции плюс некоторые аргументы.

Большинство шаблонов в этой книге связаны с выбором абстракции, поэтому вы увидите множество примеров в каждой главе. Кроме того, Краткая интерлюдия: О Связях и Абстракции конкретно обсуждает некоторые общие эвристики для выбора абстракций.

Многоуровневое представление

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

В большом комке грязи зависимости выходят из-под контроля (как вы видели в Схема зависимостей из реальной жизни (source: "Enterprise Dependency: Big Ball of Yarn" by Alex Papadimoulis)). Изменение одного узла графа становится затруднительным, поскольку оно может повлиять на многие другие части системы. Слоистые архитектуры являются одним из способов решения этой проблемы. В многоуровневой архитектуре мы разделяем наш код на отдельные категории или роли и вводим правила касающеся того, какие категории кода могут вызывать друг друга.

Одним из наиболее распространенных примеров является трехслойная архитектура, показанная на рис. Многоуровневая архитектура.

apwp 0002
Figure 2. Многоуровневая архитектура
[ditaa, apwp_0002]
+----------------------------------------------------+
|                Уровень представления               |
+----------------------------------------------------+
                          |
                          V
+----------------------------------------------------+
|                 Бизнес-логика                      |
+----------------------------------------------------+
                          |
                          V
+----------------------------------------------------+
|                  Уровень базы данных               |
+----------------------------------------------------+

Многоуровневая архитектура является, пожалуй, наиболее распространенным шаблоном для построения business software — коммерческого ПО. В этой модели у нас есть компоненты пользовательского интерфейса, которые могут быть веб-страницей, API или командной строкой; эти компоненты пользовательского интерфейса взаимодействуют со слоем бизнес-логики, который содержит наши бизнес-правила и наши рабочие процессы; и, наконец, у нас есть уровень базы данных, который отвечает за хранение и извлечение данных.

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

The Dependency Inversion Principle (Принцип инверсии зависимостей)

Возможно, вы уже знакомы с принципом инверсии зависимостей (DIP), потому что это D в SOLID. [5]

К сожалению, мы не можем проиллюстрировать DIP, используя три небольших листинга кода, как мы это делали для инкапсуляции. Однако вся [Часть1] по сути представляет собой отработанный пример реализации DIP во всем приложении, так что вы получите множество конкретных примеров.

А пока можно поговорить о формальном определении DIP:

  1. Модули высокого уровня не должны зависеть от модулей низкого уровня. И то и другое должно зависеть от абстракций.

  2. Абстракции не должны зависеть от деталей. Вместо этого детали должны зависеть от абстракций.

Но что это значит? Давайте разберемся по крупицам.

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

Напротив, низкоуровневые модули — это код, который вашей организации не важен. Маловероятно, что ваш отдел кадров будет в восторге от файловых систем или сетевых сокетов. Нечасто вы обсуждаете SMTP, HTTP или AMQP со своим финансовым отделом. Для наших нетехнических заинтересованных сторон эти низкоуровневые концепции не интересны и не актуальны. Все, что их волнует, — это правильность работы высокоуровневых концепций. Если расчет заработной платы выполняется вовремя, вашему бизнесу вряд ли будет важно, выполняется ли это задание cron или временная функция, выполняемая в Kubernetes.

Depends on (зависит от) не обязательно означает imports или calls, а скорее несёт более общую идею о том, что один модуль knows about (знает о) или needs (нуждается в) другом модуле.

И мы уже упоминали abstractions: это упрощенные интерфейсы, которые инкапсулируют поведение, подобно тому, как наш модуль duckduckgo инкапсулирует API поисковой системы.

Все проблемы в информатике можно решить, добавив еще один косвеный уровень.

— David Wheeler

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

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

Вторая часть еще более загадочна. «Абстракции не должны зависеть от деталей» кажется достаточно ясным, но «Детали должны зависеть от абстракций» трудно себе представить. Как мы можем получить абстракцию, которая не зависит от деталей, которые она абстрагирует? К тому времени, когда мы дойдем до Наш первый Use Case или пример использования: Flask API и Service Layer, у нас будет конкретный пример, который должен прояснить все это.

Место для Всей Нашей Бизнес-логики: Модель Предметной Области (The Domain Model)

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

Domain Modeling показывает, как построить бизнес-уровень с помощью шаблона Domain Model. Остальные шаблоны в Построение архитектуры на основе поддержки модели предметной области показывают, как мы можем сохранить модель предметной области легко изменяемой и свободной от низкоуровневых проблем, выбирая правильные абстракции и постоянно применяя DIP.

1. Построение архитектуры на основе поддержки модели предметной области

Большинство разработчиков никогда не видели модель предметной области (domain model), только модель данных(data model).

— Cyrille Martraire
DDD EU 2017

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

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

Первая часть книги посвящена тому, как построить богатую объектную модель с помощью TDD (в Domain Modeling), а затем рассмотрим, как уберечь эту модель от технических проблем. Покажем, как создавать код, игнорирующий персистентность, и как создавать стабильные API-интерфейсы вокруг нашего домена, чтобы мы могли проводить агрессивный рефакторинг.

Для этого мы представляем четыре ключевых шаблона проектирования:

  • Repository pattern, абстракция над идеей постоянного хранения

  • Шаблон Service Layer четко определяет, где начинаются и заканчиваются наши варианты использования

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

apwp p101
Figure 3. Диаграмма компонентов для нашего приложения в конце Построение архитектуры на основе поддержки модели предметной области

Мы также уделим немного времени, чтобы поговорить о coupling and abstractions, проиллюстрировав это на простом примере, который показывает, как и почему мы выбираем наши абстракции.

Три приложения являются дальнейшими целями исследованиями содержания Части I:

  • Шаблонная структура проекта это описание инфраструктуры для нашего примера кода: как мы строим и запускаем образы Docker, где мы управляем информацией о конфигурации и как мы запускаем различные типы тестов.

  • Замена инфраструктуры: Делайте все с CSV это своего рода контент типа "proof is in the pudding", показывающий, как легко поменять всю нашу инфраструктуру—API Flask, ORM и Postgres-на совершенно другую модель ввода-вывода, включающую CLI и CSV.

  • Наконец, [appendix_django] может представлять интерес, если вам интересно, как эти паттерны могут выглядеть при использовании Django вместо Flask и SQLAlchemy.

2. Domain Modeling

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

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

apwp 0101
Figure 4. Иллюстрация прототипа нашей модели предметной области (Domain Model)

2.1. Что такое Domain Model?

В introduction мы использовали термин business logic layer для описания центрального слоя трехслойной архитектуры. Для остальной части книги будем использовать термин domain model. Это термин из методологии Domain Driven Design (DDD), который лучше улавливает наш предполагаемый смысл (подробнее о DDD читайте дальше).

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

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

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

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

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

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

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

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

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

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

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

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

  • Оригинальная «синяя книга», Domain-Driven Design Эрика Эванса (Addison-Wesley Professional)

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

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

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

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

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

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

apwp 0102ru
Figure 5. Контекстная диаграмма для службы распределения
[plantuml, apwp_0102]
@startuml Allocation Context Diagram
!include https://raw.githubusercontent.com/plantuml-stdlib/C4-PlantUML/master/C4_Container.puml

scale 2

System(systema, "Allocation", "Распределяет запасы по заказам клиентов")

Person(customer, "Customer", "Хочет купить мебель")
Person(buyer, "Buying Team", "Формирует заявку на закупку мебели у поставщиков")

System(procurement, "Совершает закупку")
, "Управляет рабочим процессом покупки запасов у поставщиков")
System(ecom, "Ecommerce", "Продает товары онлайн")
System(warehouse, "Warehouse", "Управляет рабочим процессом доставки товаров покупателям")

Rel(buyer, procurement, "Использует")
Rel(procurement, systema, "Notifies about shipments (Уведомляет о доставке)")
Rel(customer, ecom, "Buys from (Покупает у)")
Rel(ecom, systema, "Asks for stock levels (Запрашивает уровень запасов)")
Rel(ecom, systema, "Notifies about orders (Уведомляет о заказах)")
Rel_R(systema, warehouse, "Sends instructions to (Отправляет инструкции)")
Rel_U(warehouse, customer, "Dispatches goods to (Отправляет товар в)")

@enduml

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

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

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

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

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

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

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

Некоторые примечания по распределению

product идентифицируется с помощью SKU, произносится как "skew", что является сокращением от stock-keeping unit (единицы складского учета ). Customers место orders. Заказ идентифицируется ссылкой order reference и содержит несколько order lines, где каждая строка имеет SKU и quantity. Например:

  • 10 единиц RED-CHAIR

  • 1 единица TASTELESS-LAMP

Отдел закупок заказывает небольшие партии товара. У batch (партий) заказов есть уникальный идентификатор, называемый reference (ссылка), SKU и quantity (количество).

Нам нужно allocate (распределить) order lines (позиции заказа) по batches (партиям отгрузки). Когда мы выделили позицию или строку заказа для партии, мы отправим запас из этой конкретной партии поставки на адрес доставки клиента. Когда мы распределяем x единиц запаса на партию, available quantity (доступное количество) уменьшается на x. Например:

  • У нас есть партия поставки 20 SMALL-TABLE, и мы выделяем строку заказа для 2 SMALL-TABLE.

  • В партии поставки должно остаться 18 SMALL-TABLE.

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

  • У нас есть партия 1 СИНЯЯ ПОДУШКА а строка заказа на 2 СИНЕЙ ПОДУШКИ.

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

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

  • У нас есть партия поставки из 10 СИНИХ ВАЗ, и мы выделяем строку заказа для 2 СИНИХ ВАЗ.

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

Партии имеют определённый ETA, если они в настоящее время уже отгружаются, или могут находится на warehouse (складе). Мы распределяем складские запасы в соответствии с партиями отгрузки. Сортируем партии отгрузки начиная с самого раннего ETA [8].

2.3. Модульное тестирование доменных моделей

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

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

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

Вы найдете некоторые placeholder unit tests on GitHub, но вы можете просто начать с нуля или объединить/переписать их так, как вам нравится.

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

Example 4. Первый тест на распределение (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

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

А вот и доменная модель, отвечающая нашим требованиям:

Example 5. Первый заход доменной модели для партий (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 это неизменяемый класс данных без какого-либо поведения.[9]
2 Мы не показываем импорт в большинстве листингов кода, чтобы сохранить их в чистоте. Мы надеемся, что вы догадались, что это появилось здесь благодаря from dataclasses import dataclass; аналогично, typing.Optional и datetime.date. Если вы хотите что-то перепроверить, вы можете увидеть полный рабочий код для каждой главы в её ветке (например, chapter_01_domain_model).
3 Аннотации типов по-прежнему вызывают споры в мире Python. Для моделей предметной области они иногда могут помочь прояснить или задокументировать ожидаемые аргументы, и люди с IDE часто благодарны за них. Вы можете решить, что цена, заплаченная с точки зрения удобочитаемости, слишком высока.

Наша реализация здесь тривиальна: Batch просто декоратор! Берёт целое число available_quantity, и уменьшает это значение при резервровании товара в заказе. Мы написали кучу кода только для того, чтобы вычесть одно число из другого, но мы надеемся, что моделирование нашего домена точно окупится off.[10]

Давайте напишем несколько новых failing tests:

Example 6. Логика тестирования того, что мы можем выделить (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

Здесь нет ничего неожиданного. Мы переработали наш набор тестов, чтобы не повторять одни и те же строки кода для создания партии товара (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:

Example 7. Новый метод в модели (model.py)
    def can_allocate(self, line: OrderLine) -> bool:
        return self.sku == line.sku and self.available_quantity >= line.qty

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

Example 8. Этот тест потребует более умной модели (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

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

Example 9. Модель предметной области теперь отслеживает распределения (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

Our model in UML показывает модель в UML.

apwp 0103
Figure 6. Our model in UML
[plantuml, apwp_0103, config=plantuml.cfg]
@startuml
scale 4

left to right direction
hide empty members

class Batch {
    reference
    sku
    eta
    _purchased_quantity
    _allocations
}

class OrderLine {
    orderid
    sku
    qty
}

Batch::_allocations o-- OrderLine

Теперь мы кое-чего добились! Партия товара(Batch) теперь отслеживает набор выделенных(allocated) объектов OrderLine. Когда мы распределяем (allocate), если у нас достаточно свободного количества(available quantity), мы просто добавляем к набору. Наше available_quantity теперь является вычисляемым свойством: купленное количество минус выделенное количество.

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

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

Example 10. Last batch test! (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

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

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

Больше типов для большего числа аннотаций

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

Example 11. Просто зашел слишком далеко, Боб

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

Считаете ли вы это замечательным или ужасным-вопрос спорный.[11]

2.3.1. Dataclasses отлично подходят для Value Objects

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

Example 12. Информация о заказе как YAML

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

Всякий раз, когда у нас есть бизнес-концепция, имеющая данные, но не имеющая идентичности, мы часто предпочитаем представлять её с помощью шаблона Value Object. value object-это любой объект предметной области, который однозначно идентифицируется содержащимися в нем данными; обычно мы делаем их неизменяемыми:

Example 13. OrderLine как value object

Одна из приятных вещей, которые дают нам dataclasses (или namedtuples), — это value equality, что является причудливым способом сказать: "Две строки с одинаковыми orderid, sku и qty равны."

Example 14. Еще примеры value objects

Эти ценностные объекты соответствуют нашему реальнму передставлению о том, как работают их ценности. Не имеет значения, о какой банкноте в 10 фунтов мы говорим, потому что все они имеют одинаковую ценность. Аналогично, два имени равны, если совпадают имя и фамилия; и две строки эквивалентны, если они имеют один и тот же заказ клиента, код продукта и количество. Однако мы все еще можем иметь сложное поведение на ценностном объекте. На самом деле, обычно поддерживают операции со значениями; например, математические операторы:

Example 15. Математика с value objects

2.3.2. Value Objects и Entities

Строка заказа однозначно идентифицируется по идентификатору заказа (ID), артикулу (SKU) и количеству (quantity); если мы изменим одно из этих значений, теперь у нас будет новая строка. Это определение value object: любой объект, который идентифицируется только своими данными и не имеет долгоживущей идентичности. А как насчет партии товара? Это is идентифицировано ссылкой.

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

Должно быть ясно, что Гарри Персиваль не равен Барри Персивалю:

Example 16. Само имя не может измениться …​

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

Example 17. Но человек может!

Сущности, в отличие от значений, обладают identity equality (равенством идентичности). Мы можем изменить их ценности, и они по-прежнему узнаваемы. Batches (партии), в нашем примере, являются сущностями. Мы можем выделить строки в заказе для партии товара или изменить дату, когда мы ожидаем, что она прибудет, и это будет все та же сущность.

Обычно мы делаем это явно в коде, реализуя операторы равенства для сущностей:

Example 18. Реализация операторов равенства (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__ определяет поведение класса для == operator.[12]

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

Для value objects хэш должен основываться на всех атрибутах value, и мы должны гарантировать, что объекты неизменяемы. Мы получаем это бесплатно, указав @frozen=True в классе данных.

Для сущностей самый простой вариант-сказать, что хэш-это None, что означает, что объект не является хэшируемым и не может, например, использоваться в множестве (имеется ввиду set). Если по какой-то причине вы решите, что действительно хотите использовать операции set или dict с сущностями, хэш должен основываться на атрибуте(атрибутах), таком как .reference, который определяет уникальную идентичность сущности с течением времени. Вы должны также попытаться как-то сделать этот атрибут read-only.

Это сложная территория; вы не должны изменять __hash__ без изменения __eq__. Если вы не уверены в том, что делаете, рекомендуется продолжить разбор почитав "Python Hashes and Equality" от нашего технического обозревателя Хайнека Шлавака - хорошее место для начала.

2.4. Не Все Должно быть Объектом: A Domain Service Function

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

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

— Eric Evans
Domain-Driven Design

Эванс обсуждает идею Domain Service operations, которые не имеют естественного дома в entity или value object.[13] То, что выделяет строку заказа для данного набора партий, очень похоже на функцию, и мы можем воспользоваться тем фактом, что Python - это многопарадигмальный язык, и просто сделать его функцией.

Давайте посмотрим, как мы можем протестировать такую ​​функцию:

Example 19. Тестирование нашей доменной службы (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

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

Example 20. Автономная функция для нашего доменного сервиса (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

2.4.1. Магические методы Python позволяют нам использовать наши модели с идиоматическим Python

Вам может понравиться или не понравиться использование next() в предыдущем коде, но мы почти уверены, что вы согласитесь с тем, что возможность использовать sorted() в нашем списке партий — это хороший идиоматический Python.

Чтобы заставить его работать, мы реализуем __gt__ на нашей доменной модели:

Example 21. Магические методы могут выражать семантику предметной области (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

Это прекрасно.

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

Имеется еще одна, наверное, последняя концепция, которую нужно охватить: исключения также могут использоваться для выражения концепций предметной области. В наших беседах с экспертами в предметной области мы узнали о возможности того, что заказ не может быть размещен, потому что у нас out of stock (нет запасов), и мы можем зафиксировать это, используя domain exception:

Example 22. Проверка исключения отсутствия на складе (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])
Краткое описание Моделирования предметной области
Domain modeling

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

Отличия entities от value objects

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

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

Сейчас самое время применить свои лучшие принципы проектирования ОО

Вернитесь к принципам SOLID и всем остальным хорошим эвристикам, таким как «has вместо is-a», «предпочитать композицию наследованию» и так далее.

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

Но это тема для Агрегаты и границы консистентности.

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

Example 23. Вызов исключения домена (model.py)
class OutOfStock(Exception):
    pass


def allocate(line: OrderLine, batches: List[Batch]) -> str:
    try:
        batch = next(
        ...
    except StopIteration:
        raise OutOfStock(f'Нет в наличии для артикула {line.sku}')

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

apwp 0104
Figure 7. Наша модель предметной области в конце главы

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

3. Repository Pattern

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

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

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

apwp 0201
Figure 8. До и после шаблона репозитория

Код для этой главы находится в chapter_02_repository branch on GitHub.

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_02_repository
# или чтобы писать код вместе с нами, ознакомьтесь с предыдущей главой:
git checkout chapter_01_domain_model

3.1. Persisting Our Domain Model

В Domain Modeling мы построили простую модель домена, которая может распределять заказы по партиям запасов. Мы относительно легко написали тесты для такого кода, потому что нет никаких зависимостей или инфраструктуры для настройки. Если бы нужно было запустить базу данных или API и создать тестовые данные, тесты было бы сложнее писать и поддерживать.

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

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

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

3.2. Псевдокод: Что делать то будем?

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

Example 24. Как будет выглядеть наш первый endpoint API
Мы использовали Flask, потому что он достаточно простой, но вам не нужно быть с Flask на "ты", чтобы понять эту книгу. На самом деле, наша задача объяснить, как сделать выбор фреймворка незначительной деталью.

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

Какого…​? Ух-х-х, «gubbins» - это британское слово, означающее «фигня». Вы можете просто забить на это. Это’ж псевдокод, понятно?

3.3. Применение DIP для доступа к данным

Как уже упоминалось в введение, многоуровневая архитектура — это общий подход к структурированию системы, которая имеет пользовательский интерфейс, некоторую логику и базу данных (см. Многослойная архитектура).

apwp 0202
Figure 9. Многослойная архитектура

Структура Django Model-View-Template тесно связана, как и Model-View-Controller (MVC). В любом случае цель состоит в том, чтобы слои были разделены (что хорошо), и чтобы каждый слой зависел только от того, который находится под ним.

Надо, чтобы в нашей модели предметной области не было никаких зависимостей .[14] Не надо, чтобы проблемы с инфраструктурой проникли в нашу модель предметной области и замедлили наши модульные тесты или нашу способность вносить изменения.

Вместо этого, как обсуждалось во введении, мы будем думать, что наша модель находится "inside (внутри)", и зависимости текут внутрь неё; это то, что умные люди иногда называют onion (луковая) architecture (см. Onion architecture).

apwp 0203
Figure 10. Onion architecture
[ditaa, apwp_0203]
+------------------------+
|   Presentation Layer   |
+------------------------+
           |
           V
+--------------------------------------------------+
|                  Domain Model                    |
+--------------------------------------------------+
                                        ^
                                        |
                             +---------------------+
                             |    Database Layer   |
                             +---------------------+
Это Порты и Адаптеры?

Если вы читали об архитектурных паттернах, вы можете задавать себе такие вопросы:

Это порты и адаптеры? Или это гексогональная архитектура? Это то же самое, что и луковая архитектура? А как насчет чистой архитектуры? Что такое порт и что такое адаптер? Почему у вас, "яйцеголовых", так много слов для одного и того же?

Хотя некоторые умники любят придираться к различиям, все это в значительной степени названия одного и того же, и все они сводятся к принципу инверсии зависимостей: модули высокого уровня (домен) не должны зависеть от модулей низкого уровня (инфраструктура).[15]

Мы рассмотрим некоторые мелочи, касающиеся «зависимости от абстракций», и того, существует ли Pythonic-эквивалент интерфейсов, later in the book. Смотрите также Что такое порт и что такое адаптер в Python?.

3.4. Напоминание: Наша модель

Давайте вспомним нашу модель предметной области (см. Наша модель): Распределение - это концепция связывания OrderLine с Batch. Мы сохраняем выделенные позиици как коллекцию в нашем объекте Batch.

apwp 0103
Figure 11. Наша модель

Давайте посмотрим, как мы можем перенести это в реляционную базу данных.

3.4.1. "Нормальный" способ это ORM: Модель зависит от ORM

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

Эти структуры называются объектно-реляционными картографами object-relational mappers (ОРМ), поскольку они существуют для преодоления концептуального разрыва между миром объектов и моделирования предметной области и миром баз данных и реляционной алгебры.

Самая важная вещь, которую дает нам ORM, - это игнорирование сохраняемости persistence ignorance: идея в том, что наша доменная модель не должна ничего знать о том, как данные загружаются или сохраняются. Это помогает сохранить наш домен чистым от прямых зависимостей конкретных технологий баз данных.[16]

Но если вы будете следовать типичному учебнику SQLAlchemy, то в итоге получите что-то вроде этого:

Example 25. SQLAlchemy "декларативный" синтаксис, модель зависит от ORM (orm.py)

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

ORM Django, по сути, тот же, но более строгий

Если вы больше привыкли к Django, предыдущий «декларативный» фрагмент SQLAlchemy можно перевести примерно так:

Example 26. Django ORM пример

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

Django не предоставляет эквивалента классическому мапперу SQLAlchemy, но примеры применения инверсии зависимостей и шаблона репозитория к Django см. в разделе [appendix_django].

3.4.2. Инвертирование зависимости: ORM зависит от модели

К счастью, это не единственный способ использовать SQLAlchemy. Альтернативой является определение вашей схемы отдельно и определение явного mapper-а для преобразования между схемой и нашей моделью предметной области, что SQLAlchemy называет classical mapping:

Example 27. Явное сопоставление ORM с объектами таблицы SQLAlchemy (orm.py)
from sqlalchemy.orm import mapper, relationship

import model  (1)


metadata = MetaData()

order_lines = Table(  (2)
    'order_lines', metadata,
    Column('id', Integer, primary_key=True, autoincrement=True),
    Column('sku', String(255)),
    Column('qty', Integer, nullable=False),
    Column('orderid', String(255)),
)

...

def start_mappers():
    lines_mapper = mapper(model.OrderLine, order_lines)  (3)
1 ORM импортирует (или "зависит от" или "знает о") модель предметной области, а не наоборот.
2 Мы определяем таблицы и столбцы нашей базы данных с помощью абстракций SQLAlchemy.[17]
3 Когда мы вызываем функцию mapper, SQLAlchemy творит чудеса, связывая классы нашей модели предметной области с различными таблицами, которые мы определили.

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

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

Когда вы впервые пытаетесь создать свою конфигурацию ORM, может быть полезно написать для неё тесты, как в следующем примере:

Example 28. Тестирование ОРМ напрямую (одноразовые тесты) (test_orm.py)
def test_orderline_mapper_can_load_lines(session):  (1)
    session.execute(
        'INSERT INTO order_lines (orderid, sku, qty) VALUES '
        '("order1", "RED-CHAIR", 12),'
        '("order1", "RED-TABLE", 13),'
        '("order2", "BLUE-LIPSTICK", 14)'
    )
    expected = [
        model.OrderLine("order1", "RED-CHAIR", 12),
        model.OrderLine("order1", "RED-TABLE", 13),
        model.OrderLine("order2", "BLUE-LIPSTICK", 14),
    ]
    assert session.query(model.OrderLine).all() == expected


def test_orderline_mapper_can_save_lines(session):
    new_line = model.OrderLine("order1", "DECORATIVE-WIDGET", 12)
    session.add(new_line)
    session.commit()

    rows = list(session.execute('SELECT orderid, sku, qty FROM "order_lines"'))
    assert rows == [("order1", "DECORATIVE-WIDGET", 12)]
1 Если вы не использовали pytest, то аргумент session для этого теста нуждается в объяснении. Смысл такой: Вам не нужно беспокоиться о деталях pytest или его фикстурах в целях этой книги, но главная мысль состоит в том, что вы можете определить общие зависимости для ваших тестов в виде "fixtures", и pytest передаст их в тесты, которые нуждаются в них, приняв их в качестве аргументов функций. В данном случае это сеанс session базы данных SQLAlchemy.

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

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

В зависимости от того, что вы делаете в своей модели предметной области, и особенно если вы отходите далеко от парадигмы объектно-ориентированного программирования, вам может оказаться все труднее заставить ORM обеспечить точное поведение, которое вам нужно, и вам может потребоваться изменить модель предметной области. [18] Как это часто бывает с архитектурными решениями, вам нужно будет найти компромисс. Как говорит дзэн Python: «Практичность лучше чистоты!»

На данный момент, однако, наш endpoint API может выглядеть примерно так, и мы могли бы заставить её работать просто отлично:

Example 29. Использование SQLAlchemy непосредственно в нашем endpoint API

3.5. Знакомство с шаблоном репозитория

Шаблон Repository — это абстракция над постоянным хранилищем. Он скрывает скучные детали доступа к данным, делая вид, что все наши данные находятся в памяти.

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

Example 30. Вы должны откуда-то брать данные

Несмотря на то, что наши объекты находятся в памяти, нам нужно поместить их где-нибудь, чтобы снова найти их. Наши данные в памяти позволят нам добавлять новые объекты, как список или множество. Поскольку объекты находятся в памяти, нам никогда не нужно вызывать метод .save (); мы просто получаем объект, который нам нужен, и модифицируем его в памяти.

3.5.1. The Repository in the Abstract

В простейшем репозитории всего два метода: add () для добавления нового элемента в репозиторий и get() для возврата ранее добавленного элемента.[19]

Мы твердо придерживаемся использования этих методов для доступа к данным в нашем домене и на уровне сервиса. Эта добровольная простота не позволяет нам связать нашу модель предметной области с базой данных.

Вот как будет выглядеть абстрактный базовый класс (ABC) для нашего репозитория:

Example 31. Самый простой из возможных репозиториев (repository.py)
class AbstractRepository(abc.ABC):

    @abc.abstractmethod  (1)
    def add(self, batch: model.Batch):
        raise NotImplementedError  (2)

    @abc.abstractmethod
    def get(self, reference) -> model.Batch:
        raise NotImplementedError
1 Python tip: @abc.abstractmethod — это одна из немногих вещей, которая заставляет ABCs действительно "работать" в Python. Python не позволит вам создать экземпляр класса, который не реализует все "абстрактные методы", определенные в его родительском классе.[20]
2 raise NotImplementedError — это хорошо, но это не обязательно и не достаточно. На самом деле, ваши абстрактные методы могут иметь реальное поведение, которое подклассы могут вызвать, если вы действительно хотите.
Абстрактные базовые классы, утиная типизация и протоколы

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

В реальной жизни мы иногда обнаруживаем, что удаляем ABC из нашего продакшен кода, потому что Python слишком упрощает их игнорирование, и они в конечном итоге не обслуживаются и, в худшем случае, вводят в заблуждение. На практике мы часто просто полагаемся на утиную типизацию Python для включения абстракций. Для Pythonista репозиторий — это любой объект, имеющий add(thing) and get(id) methods.

Альтернативой для изучения является PEP 544 protocols. Это дает вам возможность писать классы без возможного использования наследования, что особенно понравится фанатам "предпочитать композицию наследованию".

3.5.2. Что такое компромисс?

Знаете, говорят, что экономисты знают всё о цене и ничего о ценности? Программисты же, знают всё о преимуществе и ничего о компромисе.

— Рич Хикки

Всякий раз, когда мы представляем архитектурный паттерн в этой книге, мы всегда задаёмся вопроосом: «Что нам ЭТО даст? И во что нам ЭТО обойдётся?»

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

Шаблон репозитория, вероятно, является одним из самых простых вариантов в книге, если вы уже идёте по пути DDD и инверсии зависимостей. Что касается нашего кода, на самом деле мы просто меняем абстракцию SQLAlchemy (session.query (Batch)) на другую (batches_repo.get), которую мы разработали.

Нам придется добавлять несколько строк кода в нашем классе репозитория каждый раз, когда мы добавляем новый объект домена, который мы хотим получить, но взамен мы получаем простую абстракцию над нашим уровнем хранения, который мы контролируем. Шаблон репозитория позволит легко вносить фундаментальные изменения в то, как мы храним объекты (см. Замена инфраструктуры: Делайте все с CSV), и, как мы увидим, его легко подменить для модульных тестов.

Кроме того, шаблон репозитория настолько распространен в мире DDD, что, если вы сотрудничаете с программистами, пришедшими в Python из мира Java и C#, они, скорее всего, узнают его. Repository pattern иллюстрирует этот паттерн.

apwp 0205
Figure 12. Repository pattern
[ditaa, apwp_0205]
  +-----------------------------+
  |      Application Layer      |
  +-----------------------------+
                 |^
                 ||          /------------------\
                 ||----------|   Domain Model   |
                 ||          |      Objects     |
                 ||          \------------------/
                 V|
  +------------------------------+
  |          Repository          |
  +------------------------------+
                 |
                 V
  +------------------------------+
  |        Database Layer        |
  +------------------------------+

Как всегда, мы начинаем с теста. Это, вероятно, было бы классифицировано как интеграционный тест, поскольку мы проверяем, что наш код (репозиторий) правильно интегрирован с базой данных; следовательно, тесты, как правило, смешивают необработанный SQL с вызовами и ассертами в нашем собственном коде.

В отличие от предыдущих тестов ORM, эти тесты являются хорошими кандидатами на то, чтобы оставаться частью вашей кодовой базы в долгосрочной перспективе, особенно если какие-либо части вашей модели предметной области означают, что объектно-реляционная карта нетривиальна.
Example 32. Тест репозитория для сохранения объекта (test_repository.py)
def test_repository_can_save_a_batch(session):
    batch = model.Batch("batch1", "RUSTY-SOAPDISH", 100, eta=None)

    repo = repository.SqlAlchemyRepository(session)
    repo.add(batch)  (1)
    session.commit()  (2)

    rows = list(session.execute(
        'SELECT reference, sku, _purchased_quantity, eta FROM "batches"'  (3)
    ))
    assert rows == [("batch1", "RUSTY-SOAPDISH", 100, None)]
1 repo.add() это тестируемый здесь метод.
2 Мы храним .commit() вне репозитория и возлагаем ответственность на вызывающего. В этом есть свои плюсы и минусы; некоторые из причин станут яснее, когда мы доберемся до Паттерн Unit of Work(Единица работы).
3 Используем необработанный SQL, чтобы убедиться, что были сохраненыправильные данные .

Следующий тест включает в себя извлечение пакетов и распределений, поэтому он более сложный:

Example 33. Тест репозитория для извлечения сложного объекта (test_repository.py)
def insert_order_line(session):
    session.execute(  (1)
        'INSERT INTO order_lines (orderid, sku, qty)'
        ' VALUES ("order1", "GENERIC-SOFA", 12)'
    )
    [[orderline_id]] = session.execute(
        'SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku',
        dict(orderid="order1", sku="GENERIC-SOFA")
    )
    return orderline_id

def insert_batch(session, batch_id):  (2)
    ...

def test_repository_can_retrieve_a_batch_with_allocations(session):
    orderline_id = insert_order_line(session)
    batch1_id = insert_batch(session, "batch1")
    insert_batch(session, "batch2")
    insert_allocation(session, orderline_id, batch1_id)  (2)

    repo = repository.SqlAlchemyRepository(session)
    retrieved = repo.get("batch1")

    expected = model.Batch("batch1", "GENERIC-SOFA", 100, eta=None)
    assert retrieved == expected  # Batch.__eq__ only compares reference  (3)
    assert retrieved.sku == expected.sku  (4)
    assert retrieved._purchased_quantity == expected._purchased_quantity
    assert retrieved._allocations == {  (4)
        model.OrderLine("order1", "GENERIC-SOFA", 12),
    }
1 Проверяет сторону чтения, поэтому необработанный SQL готовит данные для чтения repo.get().
2 Избавляем вас от деталей insert_batch и insert_allocation; Зыдача в том, чтобы создать пару партий, а для интересующей нас партии выделить одну существующую строку заказа.
3 Вот что мы здесь проверяем. Первый assert == проверяет соответствие типов и совпадение ссылок (потому что, как вы помните, Batch — это сущность, и для нее у нас есть собственный __ eq __ ).
4 Поэтому мы также явно проверяем его основные атрибуты, в том числе ._allocations, который представляет собой набор Python-объектов значений OrderLine.

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

Вы получите что-то вроде этого:

Example 34. Типичный репозиторий (repository.py)
class SqlAlchemyRepository(AbstractRepository):

    def __init__(self, session):
        self.session = session

    def add(self, batch):
        self.session.add(batch)

    def get(self, reference):
        return self.session.query(model.Batch).filter_by(reference=reference).one()

    def list(self):
        return self.session.query(model.Batch).all()

И теперь наша конечная точка Flask может выглядеть примерно так:

Example 35. Использование нашего репозитория непосредственно в нашей конечной точке API
Упражнение для читателя

На днях мы столкнулись с другом на конференции DDD, который сказал: "Я не использовал ORM в течение 10 лет." Шаблон репозитория и ORM действуют как абстракции перед необработанным SQL, поэтому использование одного за другим на самом деле не является необходимым. Почему бы не попробовать реализовать наш репозиторий без использования ORM? Вы найдете код на GitHub.

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

3.6. Создание поддельного репозитория для тестов теперь тривиально!

Вот одно из самых больших преимуществ шаблона репозиторий:

Example 36. Простой фейковый репозиторий с использованием набора (repository.py)

Поскольку это простая оболочка для set, все методы являются однострочными.

Использовать фальшивое репо в тестах действительно просто, и у нас есть простая абстракция, которую легко использовать и рассуждать:

Example 37. Пример использования поддельного репозитория (test_api.py)

Вы увидите эту подделку в действии в следующей главе.

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

3.7. Что такое порт и что такое адаптер в Python?

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

Порты и адаптеры вышли из мира OO, и определение, которое мы придерживаемся, состоит в том, что port — это interface между нашим приложением и тем, что мы хотим абстрагировать, а adapter — это implementation (реализация) за этим интерфейсом или абстракцией.

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

Конкретно, в этой главе, AbstractRepository это порт, a SqlAlchemyRepository и FakeRepository - это адаптеры.

3.8. Заключение

Помня цитату Рича Хики, в каждой главе мы суммируем затраты и преимущества каждого представленного архитектурного шаблона. Мы хотим, чтобы было ясно, что мы не говорим, что каждое отдельное приложение должно быть построено именно таким образом; только иногда сложность приложения и домена заставляет тратить время и усилия на добавление этих дополнительных слоев косвенности.

Имея это в виду, Шаблон репозитория и persistence ignorance: компромиссы показывает некоторые плюсы и минусы шаблона репозитория и нашей модели с игнорированием персистентности.

Table 1. Шаблон репозитория и persistence ignorance: компромиссы
Плюсы Минусы
  • У нас есть простой интерфейс между persistent (постоянным) хранилищем и нашей доменной моделью.

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

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

  • Наша схема базы данных очень проста, потому что у нас есть полный контроль над тем, как мы сопоставляем наши объекты с таблицами.

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

  • Ведение сопоставлений ORM вручную требует дополнительной работы и дополнительного кода.

  • Любой дополнительный уровень косвенности всегда увеличивает затраты на обслуживание и добавляет "фактор WTF" для программистов Python, которые никогда раньше не видели шаблон репозитория.

Компромиссы модели предметной области в виде диаграммы демонстрирует основной тезис: да, для простых случаев развязанная модель предметной области является более сложной работой, чем простой шаблон ORM/ActiveRecord.[21]

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

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

apwp 0206
Figure 13. Компромиссы модели предметной области в виде диаграммы

Наш пример кода не настолько сложен, чтобы дать больше, чем намек на то, как выглядит правая часть графика, но намеки есть. Представьте себе, например, что однажды мы решим, что хотим изменить распределение, чтобы жить на "OrderLine", а не на "Batch" объекте: если бы мы использовали, скажем, Django, нам пришлось бы определить и продумать миграцию базы данных, прежде чем мы могли бы запустить какие-либо тесты. Как бы то ни было, поскольку наша модель-это просто старые объекты Python, мы можем изменить set() на новый атрибут, не думая о базе данных до более подходящего момента.

Резюме шаблона репозитория
Применение инверсии зависимости в ORM

Наша модель предметной области должна быть свободна от проблем с инфраструктурой, поэтому ваш ORM должен импортировать вашу модель, а не наоборот.

Шаблон репозитория — это простая абстракция вокруг постоянного хранилища

Репозиторий дает вам иллюзию коллекции объектов в памяти. Это позволяет легко создать "FakeRepository" для тестирования и поменять местами основные детали вашей инфраструктуры, не нарушая работу вашего основного приложения. Смотрите Замена инфраструктуры: Делайте все с CSV для примера.

Вам будет интересно, как мы создаем экземпляры этих хранилищ, поддельные или настоящие? Как на самом деле будет выглядеть наше приложение Flask? Вы узнаете об этом в следующей захватывающей части, the Service Layer pattern.

Но сначала небольшое отступление.

4. Краткая интерлюдия: О Связях и Абстракции

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

Код для этой главы находится в ветке chapter_03_abstractions on GitHub:

git clone https://github.com/cosmicpython/code.git
git checkout chapter_03_abstractions

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

Когда мы не можем изменить компонент A из опасения сломать компонент B, мы говорим, что компоненты стали связанными или сцепленными (coupled). Локальное сцепление — это хорошо: это признак того, что наш код работает дружно "всем коллективом", каждый компонент поддерживает другие, все они подходят друг к другу, как колёсики в часах. Говоря на жаргоне будет сказано как то так: это работает, когда существуют жесткие связи между связанными элементами.

Вот в глобальном масштабе жёстка связанность (сцепление) — это неприятность: Увеличивается риск и стоимость внесения изменений нашего кода, иногда до такой степени, что мы чувствуем себя неспособными внести какие-либо изменения вообще. Это проблема на рисунке Шара грязи может быть описана так: по мере роста приложения, в случае если мы не можем предотвратить жесткую связанность между элементами, которые не связаны друг с другом, эта взаимосвязь будет прогрессировать сверхлинейно, до тех пор пока мы больше не сможем эффективно вносить изменения в наши системы.

Мы можем уменьшить степень сцепления внутри системы (Много жёстких связей) абстрагируясь от деталей (Меньше жёстких связей).

apwp 0301
Figure 14. Много жёстких связей
[ditaa, apwp_0301]
+--------+      +--------+
| System | ---> | System |
|   A    | ---> |   B    |
|        | ---> |        |
|        | ---> |        |
|        | ---> |        |
+--------+      +--------+
apwp 0302
Figure 15. Меньше жёстких связей
[ditaa, apwp_0302]
+--------+                           +--------+
| System |      /-------------\      | System |
|   A    | ---> |             | ---> |   B    |
|        | ---> | Abstraction | ---> |        |
|        |      |             | ---> |        |
|        |      \-------------/      |        |
+--------+                           +--------+

На обеих диаграммах у нас есть пара подсистем, одна из которых зависит от другой.В Много жёстких связей между ними существует высокая степень связаности; количество стрелок указывает на множество видов зависимостей между ними. Если нам нужно изменить систему B, есть большая вероятность, что это изменение отразится на системе A.

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

4.1. Абстрагирование от Состояния Улучшает Тестируемость

Давайте рассмотрим пример. Представьте, что мы хотим написать код для синхронизации двух файловых каталогов, которые назовем source и destination:

  • Если файл существует в источнике, но не в месте назначения, скопируйте его.

  • Если файл существует в источнике, но имеет другое имя, отличное от имеющегося в папке назначения, переименуйте его в соответствующее.

  • Если файл существует в папке назначения, но отсутствует в источнике, удалите его.

Первое и третье требования достаточно просты: мы можем просто сравнить два списка путей. Но, вот, со вторым сложнее. Чтобы выявить необходимость переименования, нам придется проверить содержимое файлов. Для этого мы можем использовать функцию хеширования, такую ​​как MD5 или SHA-1. Код для генерации хэша SHA-1 из файла достаточно прост:

Example 38. Хеширование файла (sync.py)
BLOCKSIZE = 65536

def hash_file(path):
    hasher = hashlib.sha1()
    with path.open("rb") as file:
        buf = file.read(BLOCKSIZE)
        while buf:
            hasher.update(buf)
            buf = file.read(BLOCKSIZE)
    return hasher.hexdigest()

Теперь нам нужно чуть дописать, часть принятия решения "что делать" — Бизнес-логику, если хотите.

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

Наш первый подход выглядит примерно так:

Example 39. Базовый алгоритм синхронизации (sync.py)
import hashlib
import os
import shutil
from pathlib import Path

def sync(source, dest):
    # Пройдите по исходной папке и создайте список имен файлов и их хэшей
    source_hashes = {}
    for folder, _, files in os.walk(source):
        for fn in files:
            source_hashes[hash_file(Path(folder) / fn)] = fn

    seen = set()  # Следите за файлами, которые мы нашли в целевой папке

    # Пройдитесь по целевой папке и получите имена файлов и их хэши
    for folder, _, files in os.walk(dest):
        for fn in files:
            dest_path = Path(folder) / fn
            dest_hash = hash_file(dest_path)
            seen.add(dest_hash)

            # если в целевой папке есть файл, которого нет в исходной,
			#  удалите его
			if dest_hash not in source_hashes:
                dest_path.remove()

            # если в target есть файл, который имеет другой путь в source,
            # переместите его на правильный путь
            elif dest_hash in source_hashes and fn != source_hashes[dest_hash]:
                shutil.move(dest_path, Path(folder) / source_hashes[dest_hash])

    # для каждого файла, который появляется в исходной папке,
	# но не в целевой, скопируйте его в целевую
    for src_hash, fn in source_hashes.items():
        if src_hash not in seen:
            shutil.copy(Path(source) / fn, Path(dest) / fn)

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

Example 40. Парочка сквозных тестов (test_sync.py)
def test_when_a_file_exists_in_the_source_but_not_the_destination():
    try:
        source = tempfile.mkdtemp()
        dest = tempfile.mkdtemp()

        content = "Я очень полезный файл"
        (Path(source) / 'my-file').write_text(content)

        sync(source, dest)

        expected_path = Path(dest) /  'my-file'
        assert expected_path.exists()
        assert expected_path.read_text() == content

    finally:
        shutil.rmtree(source)
        shutil.rmtree(dest)


def test_when_a_file_has_been_renamed_in_the_source():
    try:
        source = tempfile.mkdtemp()
        dest = tempfile.mkdtemp()

        content = "Я файл, который был переименован"
        source_path = Path(source) / 'source-filename'
        old_dest_path = Path(dest) / 'dest-filename'
        expected_dest_path = Path(dest) / 'source-filename'
        source_path.write_text(content)
        old_dest_path.write_text(content)

        sync(source, dest)

        assert old_dest_path.exists() is False
        assert expected_dest_path.read_text() == content


    finally:
        shutil.rmtree(source)
        shutil.rmtree(dest)

Строго говоря, тут многовато установок для двух простых случаев! Проблема в том, что логика нашей предметной области «выяснение разницы между двумя каталогами» тесно связана с I/O кодом. Мы не можем запустить наш алгоритм поиска различий без вызова модулей pathlib, shutil и hashlib.

Только вот беда в том, что даже с нашими текущими требованиями мы не написали достаточно тестов: текущая реализация имеет несколько ошибок (например, shutil.move() неверен). Чтобы получить достойное покрытие и выявить эти ошибки, нужно написать больше тестов, но если все они будут такими же громоздкими, как предыдущие, это быстро станет очень геморно.

Вдобавок наш код не очень расширяемый. Представьте, что вы пытаетесь реализовать флаг --dry-run, который заставляет наш код просто распечатать то, что он собирается делать, а не выполнять это на самом деле. А что, если мы хотим синхронизироваться с удаленным сервером или с облачным хранилищем?

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

4.2. Выбор правильной Абстракции(-й)

Что мы можем сделать, чтобы переписать наш код и сделать его более тестируемым?

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

  1. Мы опрашиваем файловую систему с помощью os.walk и определяем хэши для ряда путей. Это похоже как для исходного, так и конечного случая.

  2. Мы решаем, является ли файл новым, переименованным или лишним.

  3. Мы копируем, перемещаем или удаляем файлы в соответствии с источником.

Помните, что мы хотим найти упрощающие абстракции для каждой из этих обязанностей. Это позволит нам скрыть беспорядочные детали, чтобы мы могли сосредоточиться на интересующей нас логике.[23]

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

Для шагов 1 и 2 мы уже интуитивно начали использовать абстракцию, словарь хэшей для путей. Возможно, вы уже думали: «Почему бы не создать словарь для целевой папки, а также для источника, а затем мы просто сравним два словаря?» Это похоже на хороший способ абстрагироваться от текущего состояния файловой системы:

source_files = {'hash1': 'path1', 'hash2': 'path2'}
dest_files = {'hash1': 'path1', 'hash2': 'pathX'}

А как насчет перехода от пункта 2 к пункту 3? Как мы можем абстрагироваться от фактического взаимодействия файловой системы перемещения/копирования/удаления?

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

("COPY", "sourcepath", "destpath"),
("MOVE", "old", "new"),

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

Вместо того чтобы сказать: "Учитывая фактическую файловую систему при запуске своей функции, проверить, какие действия произошли", мы говорим: "Учитывая абстрацию файловой системы, какое абстрактное действие файловой системы произойдет?"

Example 41. Упрощенные входы и выходы в наших тестах (test_sync.py)

4.3. Реализация Выбранных Нами Абстракций

Это все очень хорошо, но как нам на самом деле написать эти новые тесты и как изменить нашу реализацию, чтобы все это работало?

Наша цель состоит в том, чтобы изолировать умную часть нашей системы и иметь возможность тщательно протестировать её без необходимости создавать реальную файловую систему. Мы создадим "ядро" кода, которое не имеет зависимостей от внешнего состояния, а затем посмотрим, как оно реагирует, когда мы даем ему входные данные из внешнего мира (такой подход был охарактеризован Гэри Бернхардтом как Functional Core, Imperative Shell, или FCIS).

Давайте начнем с разделения кода, чтобы отделить части с состоянием от логики.

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

Example 42. Разделим наш код на три (sync.py)
def sync(source, dest):
    # imperative shell Шаг 1, собрать входные данные
    source_hashes = read_paths_and_hashes(source)  (1)
    dest_hashes = read_paths_and_hashes(dest)  (1)

    # Шаг 2: вызов функционального ядра
    actions = determine_actions(source_hashes, dest_hashes, source, dest)  (2)

    # imperative shell Шаг 3, применить результаты
    for action, *paths in actions:
        if action == 'copy':
            shutil.copyfile(*paths)
        if action == 'move':
            shutil.move(*paths)
        if action == 'delete':
            os.remove(paths[0])
1 Первая функция, которую мы учитываем, read_paths_and_hashes(), которая изолирует часть ввода-вывода нашего приложения.
2 Именно здесь мы вырежем функциональное ядро, бизнес-логику.

Код для создания словаря путей и хешей теперь написать тривиально просто:

Example 43. Функция, которая просто выполняет ввод/вывод (sync.py)
def read_paths_and_hashes(root):
    hashes = {}
    for folder, _, files in os.walk(root):
        for fn in files:
            hashes[hash_file(Path(folder) / fn)] = fn
    return hashes

Функция define_actions() будет ядром нашей бизнес-логики, которая выясняет: «Учитывая эти два набора хэшей и имен файлов, что мы должны копировать/перемещать/удалять?». Она принимает простые структуры данных и возвращает простые структуры данных:

Example 44. Функция, которая просто выполняет бизнес-логику (sync.py)
def determine_actions(src_hashes, dst_hashes, src_folder, dst_folder):
    for sha, filename in src_hashes.items():
        if sha not in dst_hashes:
            sourcepath = Path(src_folder) / filename
            destpath = Path(dst_folder) / filename
            yield 'copy', sourcepath, destpath

        elif dst_hashes[sha] != filename:
            olddestpath = Path(dst_folder) / dst_hashes[sha]
            newdestpath = Path(dst_folder) / filename
            yield 'move', olddestpath, newdestpath

    for sha, filename in dst_hashes.items():
        if sha not in src_hashes:
            yield 'delete', dst_folder / filename

Теперь наши тесты действуют непосредственно на функцию determine_actions():

Example 45. Более приятные на вид тесты (test_sync.py)
def test_when_a_file_exists_in_the_source_but_not_the_destination():
    src_hashes = {'hash1': 'fn1'}
    dst_hashes = {}
    actions = determine_actions(src_hashes, dst_hashes, Path('/src'), Path('/dst'))
    assert list(actions) == [('copy', Path('/src/fn1'), Path('/dst/fn1'))]

def test_when_a_file_has_been_renamed_in_the_source():
    src_hashes = {'hash1': 'fn1'}
    dst_hashes = {'hash1': 'fn2'}
    actions = determine_actions(src_hashes, dst_hashes, Path('/src'), Path('/dst'))
    assert list(actions) == [('move', Path('/dst/fn2'), Path('/dst/fn1'))]

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

При таком подходе мы перешли от тестирования нашей основной функции точки входа sync() к тестированию функции более низкого уровня determine_actions(). Вы можете решить, что это нормально, потому что sync() теперь выполняется так просто. Или вы можете решить провести несколько интеграционных/приемочных тестов, чтобы проверить эту sync(). Но есть еще один вариант, который заключается в изменении функции sync(), чтобы её можно было тестировать модульно и тестировать от начала до конца; это подход, который Боб называет edge-to-edge testing.

4.3.1. Тестирование Edge to Edge с Fakes и Dependency Injection

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

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

Example 46. Явные зависимости (sync.py)
1 Наша функция верхнего уровня теперь предоставляет две новые зависимости: reader и filesystem.
2 Мы вызываем reader для создания наших файлов dict.
3 Мы вызываем filesystem, чтобы применить обнаруженные нами изменения.
Хотя мы используем инъекцию зависимостей, нет необходимости определять абстрактный базовый класс или какой-либо явный интерфейс. В этой книге мы часто показываем ABC, потому что надеемся, что этот модуль поможет вам понять, что такое абстракция, но в этом нет необходимости. Динамический характер Python означает, что мы всегда можем положиться на утиную типизацию[24].
Example 47. Тесты с использованием DI
1 Боб обожает использовать списки для создания простых тестовых двойников, даже если это бесит его коллег. Это означает, что мы можем писать тесты вроде assert 'foo' not in database.
2 Каждый метод в нашей FakeFileSystem просто добавляет что-то в список, чтобы мы могли проверить это позже. Это пример spy object.

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

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

4.3.2. Почему бы просто не запатчить это?

В этот момент вы можете почесать затылок и подумать: "Почему бы просто не использовать 'mock.patch' и не сэкономить свои усилия?"

Мы избегаем использования моков в этой книге и в нашем production коде. Мы не собираемся устраивать ХолиВар по этому поводу, но инстинкт подсказывает, что mocking frameworks, особенно monkeypatching, - это дурнопахнущий код.

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

Вы можете увидеть пример в События и шина сообщений, где мы mock.patch()-ем выводим модуль отправки электронной почты, но в конечном итоге заменяем его явным небольшим кодом внедрения зависимостей в Dependency Injection (и Bootstrapping).

У нас есть три тесно связанных причины нашего предпочтения:

  • Исправление зависимости, которую вы используете, позволяет модульно протестировать код, но это никак не улучшает дизайн. Использование mock.patch не позволит вашему коду работать с флагом --dry-run и не поможет вам работать с FTP-сервером. Для этого вам нужно будет ввести абстракции.

  • Тесты, которые используют mocks стремятся быть более связанными с деталями реализации кодовой базы. Это потому, что имитационные тесты проверяют взаимодействие между объектами: вызывали ли мы shutil.copy с правильными аргументами? По нашему опыту, эта связь между кодом и тестом стремится сделать тесты более хрупкими.

  • Чрезмерное использование моков приводит к созданию сложных наборов тестов, которые не могут объяснить код.

Проектирование для тестируемости на самом деле означает проектирование для расширяемости. Мы обмениваем немного большую сложность на более чистый дизайн, который допускает новые варианты использования.
Моки против фейков; Классический стиль в сравнении с TDD Лондонской школы[25]

Вот краткое и несколько упрощенное определение разницы между моком и фейком:

  • Моки используются для проверки как что-то используется; у них есть такие методы, как assert_called_once_with(). Они связаны с TDD лондонской школы.

  • Фейки-это рабочие реализации того, что они заменяют, но они предназначены для использования только в тестах. Они не будут работать "в реальной жизни"; наш репозиторий in-memory — хороший пример. Но вы можете использовать их, чтобы выполнить assert о конечном состоянии системы, а не о поведении на пути к этому состоянию, поэтому они связаны с классическим стилем TDD.

Здесь мы слегка смешиваем насмешки (mocks) со шпионами(spies) и фальшивки(fakes) с заглушками(stubs), однако вы можете прочитать длинный, правильный опус в классическом эссе Мартина Фаулера на эту тему под названием "Mocks Aren’t Stubs".

Также, вероятно, не помогает то, что объекты MagicMock, предоставляемые unittest.mock, строго говоря, не являются mocks; они шпионы(spies), если уж на то пошло. Но их также часто используют как заглушки(stubs) или пустышки(dummies). Ну вот, мы обещаем, что теперь покончим с придирками двойной терминологии тестирования.

А как насчет лондонской школы по сравнению с TDD в классическом стиле? Вы можете прочитать больше об этих двух подходах в статье Мартина Фаулера, которую мы только что процитировали, а также на Software Engineering Stack Exchange site, но в этой книге мы довольно твердо придерживаемся классицизма. Нам нравится строить наши тесты вокруг состояния как в сетапах, так и в ассертах, и нам нравится работать на самом высоком уровне абстракции, а не проверять поведение промежуточных участников.[26]

Подробнее об этом читайте в О принятии решения О том, Какие Тесты писать.

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

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

В своем выступлении Стив Фриман приводит отличный пример чрезмерно замкнутых тестов. "Test-Driven Development". Вам также следует ознакомиться с этим выступлением PyCon, "Mocking and Patching Pitfalls", от нашего уважаемого технического обозревателя Эда Юнга, который также рассматривает mocking и их альтернативы. И в то время как мы рекомендуем доклады, не пропустите Брэндона Родса, говорящего о "Hoisting Your I/O", который действительно хорошо охватывает проблемы, о которых мы говорим, используя еще один простой пример.

В этой главе мы потратили много времени, заменяя сквозные тесты модульными. Это не значит, что мы считаем, что вы никогда не должны использовать тесты E2E! В этой книге мы показываем методы, которые помогут вам составить достойную пирамиду тестов с максимально возможным количеством модульных тестов и с минимальным количеством тестов E2E, необходимых для уверенности. Прочтите Резюме: Эмпирические правила для различных типов тестов для получения более подробной информации.
Так Что Же Мы Используем В Этой Книге? Функциональную или Объектно-ориентированную композицию?

Оба. Наша доменная модель полностью свободна от зависимостей и побочных эффектов, так что это наше функциональное ядро. Уровень сервиса, который мы строим вокруг него (в Наш первый Use Case или пример использования: Flask API и Service Layer) позволяет нам управлять системой на перефирии, и мы используя инъекцию зависимостей, можем предоставить этим службам компоненты с отслеживанием состояния, так что мы все еще можем их модульно тестировать.

См. Dependency Injection (и Bootstrapping) для более подробного изучения того, как сделать нашу инъекцию зависимостей более явной и централизованной.

4.4. Подведение итогов

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

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

  • Где я могу провести границу между моими системами, где я смогу использовать шов чтобы вставить эту абстракцию?

  • Что такое разумный способ разделения объектов на компоненты с различными обязанностями? Какие неявные понятия я могу сделать явными?

  • Что же такое зависимость, и каковы основные бизнес-логики?

Практика делает его менее несовершенным! А теперь вернемся к нашим баранам нашему обычному программированию…​

5. Наш первый Use Case или пример использования: Flask API и Service Layer

Вернемся к нашему исходному проекту! Схема Управляем приложением, общаясь с репозиторием и моделью домена показывает точку, которую мы достигли в конце Repository Pattern, которая включает в себя шаблон репозитория.

apwp 0401
Figure 16. Управляем приложением, общаясь с репозиторием и моделью домена

В этой главе мы обсудим различие между Orchestration logic, business logic и interfacing code, а также введем модель Service Layer для координации наших бизнес - процессов и определения вариантов использования системы.

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

Схема Сервис будет основным способом доступа к нашим приложениям показывает, то к чему мы стремимся: Собираемся добавить API Flask, который будет общаться с уровнем сервиса, который будет служить точкой входа в нашу доменную модель. Поскольку наш уровень обслуживания зависит от AbstractRepository, мы можем модульно протестировать его с помощью FakeRepository, но запустить наш production код с помощью SqlAlchemyRepository.

apwp 0402
Figure 17. Сервис будет основным способом доступа к нашим приложениям

В наших диаграммах мы используем соглашение о том, что новые компоненты выделяются жирным шрифтом/линиями (и желтым/оранжевым цветом, если вы читаете цифровую версию).

Код этой главы находится в ветке chapter_04_service_layer on GitHub:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_04_service_layer
# or to code along, checkout Chapter 2:
git checkout chapter_02_repository

5.1. Подключение нашего приложения к реальному миру

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

Давайте как можно скорее соединим все мобильные компоненты и перестроим их в более чистую архитектуру. Наш план таков:

  1. Используем Flask, чтобы поместить endpoint API перед сервисом allocate. Подключаем сеанс базы данных и наш репозиторий. Тестируем его с помощью сквозного теста и некоторого quick-and-dirty SQL для подготовки тестовых данных. Проводим сеанс работы с базой данных и нашим репозиторием. Протестируем его с помощью сквозного теста и небольшого количества quick-and-dirty SQL запросов для подготовки тестовых данных.

  2. Отрефакторим сервисный уровень, который будет служить абстракцией для захвата сценария использования и который будет находиться между Flask и нашей моделью домена. Построим несколько тестов сервисного уровня и покажем, как они могут использовать FakeRepository.

  3. Поэкспериментируем с различными типами параметров для наших функций сервисного уровня; продемонстрируем, что использование примитивных типов данных позволяет отделить клиентов сервисного уровня (наши тесты и наш API Flask) от уровня модели.

5.2. Первый сквозной тест

Никто не заинтересован в долгих терминологических дебатах о том, что считается сквозным тестом (E2E) по сравнению с функциональным тестом по сравнению с приемочным тестом по сравнению с интеграционным тестом по сравнению с модульным тестом. Различные проекты нуждаются в различных комбинациях тестов, и мы видели, как совершенно успешные проекты просто делят вещи на "быстрые тесты" и "медленные тесты"."

На данный момент мы хотим написать один или, может быть, два теста, которые будут использовать "реальную" конечную точку API (используя HTTP) и общаться с реальной базой данных. Давайте назовем их сквозные тесты, потому что это одно из самых понятных названий.

Ниже показан первый разрез:

Example 48. Первый тест API (test_api.py)
@pytest.mark.usefixtures('restart_api')
def test_api_returns_allocation(add_stock):
    sku, othersku = random_sku(), random_sku('other')  (1)
    earlybatch = random_batchref(1)
    laterbatch = random_batchref(2)
    otherbatch = random_batchref(3)
    add_stock([  (2)
        (laterbatch, sku, 100, '2011-01-02'),
        (earlybatch, sku, 100, '2011-01-01'),
        (otherbatch, othersku, 100, None),
    ])
    data = {'orderid': random_orderid(), 'sku': sku, 'qty': 3}
    url = config.get_api_url()  (3)
    r = requests.post(f'{url}/allocate', json=data)
    assert r.status_code == 201
    assert r.json()['batchref'] == earlybatch
1 random_sku(), random_batchref () и так далее — это небольшие вспомогательные функции, которые генерируют случайные символы с помощью модуля uuid. Поскольку сейчас мы работаем с реальной базой данных, это один из способов предотвратить взаимное влияние различных тестов друг на друга.
2 add_stock — это вспомогательная фикстура, инструмент, который просто скрывает детали ручной вставки строк в базу данных с помощью SQL. В конце этой главы мы покажем способ получше.
3 config.py это модуль, в котором мы храним информацию о конфигурации.

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

5.3. Простая Реализация

Реализуя вещи самым очевидным образом, вы можете получить что-то вроде этого:

Example 49. First cut of Flask app (flask_app.py)
from flask import Flask, jsonify, request
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker

import config
import model
import orm
import repository


orm.start_mappers()
get_session = sessionmaker(bind=create_engine(config.get_postgres_uri()))
app = Flask(__name__)

@app.route("/allocate", methods=['POST'])
def allocate_endpoint():
    session = get_session()
    batches = repository.SqlAlchemyRepository(session).list()
    line = model.OrderLine(
        request.json['orderid'],
        request.json['sku'],
        request.json['qty'],
    )

    batchref = model.allocate(line, batches)

    return jsonify({'batchref': batchref}), 201

Пока всё слишком хорошо. Боб и Гарри, вы наверное думаете, что вам больше не нужно говорить про "архитектурных астронавтов".

Но подождите минутку — никаких обязательств. На самом деле мы не сохраняем наше распределение в базе данных. Теперь нам нужен второй тест, либо тот, который проверит состояние базы данных после (не очень black-boxy чёрного ящика), или, может быть, тот, который проверяет, что мы не можем выделить вторую строку, если первая уже должна была исчерпать пакет:

Example 50. Тест распределения с сохранением (test_api.py)
@pytest.mark.usefixtures('restart_api')
def test_allocations_are_persisted(add_stock):
    sku = random_sku()
    batch1, batch2 = random_batchref(1), random_batchref(2)
    order1, order2 = random_orderid(1), random_orderid(2)
    add_stock([
        (batch1, sku, 10, '2011-01-01'),
        (batch2, sku, 10, '2011-01-02'),
    ])
    line1 = {'orderid': order1, 'sku': sku, 'qty': 10}
    line2 = {'orderid': order2, 'sku': sku, 'qty': 10}
    url = config.get_api_url()

    # первый заказ использует все запасы в партии 1
    r = requests.post(f'{url}/allocate', json=line1)
    assert r.status_code == 201
    assert r.json()['batchref'] == batch1

    # второй заказ должен перейти в партию 2
    r = requests.post(f'{url}/allocate', json=line2)
    assert r.status_code == 201
    assert r.json()['batchref'] == batch2

Не совсем так красиво, но это заставит нас добавить коммит.

5.4. Ошибочные условия требуют проверки базы данных

Если мы будем продолжать в том же духе, все станет ещё хуже и хуже.

Предположим, что мы добавим несколько обработок ошибок. Что делать, если домен вызывает ошибку для SKU, которого нет в наличии? Или как насчет SKU, которого даже не существует? Об этом домен даже не знает, да и не должен знать. Это скорее проверка на вменяемость, которую мы должны применить на уровне базы данных, прежде чем мы даже вызовем службу домена.

Теперь мы рассмотрим еще пару сквозных теста:

Example 51. Еще больше тестов на уровне E2E (test_api.py)
@pytest.mark.usefixtures('restart_api')
def test_400_message_for_out_of_stock(add_stock):  (1)
    sku, smalL_batch, large_order = random_sku(), random_batchref(), random_orderid()
    add_stock([
        (smalL_batch, sku, 10, '2011-01-01'),
    ])
    data = {'orderid': large_order, 'sku': sku, 'qty': 20}
    url = config.get_api_url()
    r = requests.post(f'{url}/allocate', json=data)
    assert r.status_code == 400
    assert r.json()['message'] == f'Out of stock for sku {sku}'


@pytest.mark.usefixtures('restart_api')
def test_400_message_for_invalid_sku():  (2)
    unknown_sku, orderid = random_sku(), random_orderid()
    data = {'orderid': orderid, 'sku': unknown_sku, 'qty': 20}
    url = config.get_api_url()
    r = requests.post(f'{url}/allocate', json=data)
    assert r.status_code == 400
    assert r.json()['message'] == f'Invalid sku {unknown_sku}'
1 В первом тесте мы пытаемся выделить больше единиц, чем есть на складе.
2 Во втором случае SKU просто не существует (потому что мы никогда не вызывали add_stock), поэтому он недействителен для нашего приложения.

И конечно, мы могли бы реализовать его и в приложении Flask:

Example 52. Приложение Flask начинает становиться крутым (flask_app.py)
def is_valid_sku(sku, batches):
    return sku in {b.sku for b in batches}

@app.route("/allocate", methods=['POST'])
def allocate_endpoint():
    session = get_session()
    batches = repository.SqlAlchemyRepository(session).list()
    line = model.OrderLine(
        request.json['orderid'],
        request.json['sku'],
        request.json['qty'],
    )

    if not is_valid_sku(line.sku, batches):
        return jsonify({'message': f'Invalid sku {line.sku}'}), 400

    try:
        batchref = model.allocate(line, batches)
    except model.OutOfStock as e:
        return jsonify({'message': str(e)}), 400

    session.commit()
    return jsonify({'batchref': batchref}), 201

Но наше приложение Flask начинает выглядеть слегка громоздким. И наше количество тестов E2E начинает выходить из-под контроля, и вскоре мы получим перевернутую тестовую пирамиду (или "модель рожка мороженого", как любит называть ее Боб).

5.5. Представляем сервисный слой и используем FakeRepository для его модульного тестирования

Если мы посмотрим на то, что делает наше приложение Flask, то увидим довольно много того, что мы могли бы назвать orchestration —- извлечение материала из нашего репозитория, проверка наших входных данных на соответствие состоянию базы данных, обработка ошибок и фиксация в happy path. Большинство из этих вещей не имеют ничего общего с наличием web API endpoint (они понадобились бы вам, если бы вы создавали, например CLI; см. Замена инфраструктуры: Делайте все с CSV), и на самом деле это не те вещи, которые нужно тестировать сквозными тестами.

Часто имеет смысл разделить service layer, иногда называемый orchestration layer слоем оркестровки или use-case слоем прецедентов .

Вы помните "FakeRepository", который мы подготовили в Краткая интерлюдия: О Связях и Абстракции?

Example 53. Our fake repository, an in-memory collection of batches (test_services.py)
class FakeRepository(repository.AbstractRepository):

    def __init__(self, batches):
        self._batches = set(batches)

    def add(self, batch):
        self._batches.add(batch)

    def get(self, reference):
        return next(b for b in self._batches if b.reference == reference)

    def list(self):
        return list(self._batches)

Вот где он будет полезен; он позволяет нам тестировать наш уровень обслуживания с помощью хороших, быстрых модульных тестов:

Example 54. Модульное тестирование с фейками на уровне сервиса (test_services.py)
def test_returns_allocation():
    line = model.OrderLine("o1", "COMPLICATED-LAMP", 10)
    batch = model.Batch("b1", "COMPLICATED-LAMP", 100, eta=None)
    repo = FakeRepository([batch])  (1)

    result = services.allocate(line, repo, FakeSession())  (2) (3)
    assert result == "b1"


def test_error_for_invalid_sku():
    line = model.OrderLine("o1", "NONEXISTENTSKU", 10)
    batch = model.Batch("b1", "AREALSKU", 100, eta=None)
    repo = FakeRepository([batch])  (1)

    with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"):
        services.allocate(line, repo, FakeSession())  (2) (3)
1 FakeRepository содержит объекты Batch, которые будут использоваться в нашем тесте.
2 Наш сервисный модуль (services.py) определит функцию сервисного уровня allocate(). Он будет находиться между нашей функцией allocate_endpoint() на уровне API и функцией доменной службы allocate() из нашей модели домена.[27]
3 Нам также нужен "FakeSession", чтобы подделать сеанс базы данных, как показано в следующем фрагменте кода.
Example 55. A fake database session (test_services.py)
class FakeSession():
    committed = False

    def commit(self):
        self.committed = True

Эта фальшивая сессия - лишь временное решение. Мы скоро избавимся от него и сделаем все лучше. Паттерн Unit of Work(Единица работы). Но в то же время фейковый .commit() позволяет нам перенести третий тест со слоя E2E:

Example 56. Второй тест на сервисном уровне (test_services.py)
def test_commits():
    line = model.OrderLine('o1', 'OMINOUS-MIRROR', 10)
    batch = model.Batch('b1', 'OMINOUS-MIRROR', 100, eta=None)
    repo = FakeRepository([batch])
    session = FakeSession()

    services.allocate(line, repo, session)
    assert session.committed is True

5.5.1. Типичная Service Function

Мы напишем служебную функцию, которая выглядит примерно так:

Example 57. Базовая служба распределения (services.py)
class InvalidSku(Exception):
    pass


def is_valid_sku(sku, batches):
    return sku in {b.sku for b in batches}

def allocate(line: OrderLine, repo: AbstractRepository, session) -> str:
    batches = repo.list()  (1)
    if not is_valid_sku(line.sku, batches):  (2)
        raise InvalidSku(f'Invalid sku {line.sku}')
    batchref = model.allocate(line, batches)  (3)
    session.commit()  (4)
    return batchref

Типичные функции сервисного уровня имеют сходные этапы:

1 Извлекаем некоторые объекты из репозитория.
2 Мы делаем несколько подтверждений или опровергаем требования о текущем состоянии мира.
3 Мы вызываем доменную службу.
4 Если все хорошо, то мы сохраняем/обновляем любое состояние, которое мы изменили.

Этот последний шаг на данный момент несовсем удовлетворителен, поскольку наш сервисный уровень тесно связан с нашим уровнем базы данных. Мы улучшим это в Паттерн Unit of Work(Единица работы) с помощью шаблона Unit of Work.

Зависеть от абстракций

Обратите внимание на еще одну особенность нашей функции уровня сервиса:

Она зависит от репозитория. Мы решили сделать зависимость явной и использовали аннотацию типа, чтобы показать, что мы зависим от AbstractRepository. Это означает, что функция будет работать даже тогда, когда тесты предоставят ему FakeRepository, или когда приложение Flask предоставит ему SqlAlchemyRepository.

Если вы помните The Dependency Inversion Principle (Принцип инверсии зависимостей), это то, что мы имеем в виду, когда говорим, что должны «зависеть от абстракций». Наш high-level module, уровень обслуживания, зависит от абстракции репозитория. И детали реализации для нашего конкретного выбора постоянного хранилища также зависят от той же абстракции. См. Abstract dependencies of the service layer и Tests provide an implementation of the abstract dependency.

См. также в Замена инфраструктуры: Делайте все с CSV отработаемый пример замены деталей, которые постоянно используется системой хранения данных, оставляя абстракции нетронутыми.

Но самое необходимое для уровня сервиса уже есть, и наше приложение Flask теперь выглядит намного чище:

Example 58. Делегирование приложения Flask на уровень сервиса (flask_app.py)
@app.route("/allocate", methods=['POST'])
def allocate_endpoint():
    session = get_session()  (1)
    repo = repository.SqlAlchemyRepository(session)  (1)
    line = model.OrderLine(
        request.json['orderid'],  (2)
        request.json['sku'],  (2)
        request.json['qty'],  (2)
    )
    try:
        batchref = services.allocate(line, repo, session)  (2)
    except (model.OutOfStock, services.InvalidSku) as e:
        return jsonify({'message': str(e)}), 400  (3)

    return jsonify({'batchref': batchref}), 201  (3)
1 Инстанцируем сеанс работы с базой данных и некоторые объекты репозитория.
2 Извлекаем команды пользователя из веб-запроса и передаем их службе домена.
3 Возвращаем несколько ответов JSON с соответствующими кодами состояния.

Обязанности приложения Flask - это обычные веб-вещи: управление сеансами по каждому запросу, анализ информации из параметров POST, коды состояния ответа и JSON. Вся логика оркестрации находится на уровне использования case/service, а логика домена остается в домене.

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

Example 59. E2E тесты только для happy и unhappy paths (test_api.py)
@pytest.mark.usefixtures('restart_api')
def test_happy_path_returns_201_and_allocated_batch(add_stock):
    sku, othersku = random_sku(), random_sku('other')
    earlybatch = random_batchref(1)
    laterbatch = random_batchref(2)
    otherbatch = random_batchref(3)
    add_stock([
        (laterbatch, sku, 100, '2011-01-02'),
        (earlybatch, sku, 100, '2011-01-01'),
        (otherbatch, othersku, 100, None),
    ])
    data = {'orderid': random_orderid(), 'sku': sku, 'qty': 3}
    url = config.get_api_url()
    r = requests.post(f'{url}/allocate', json=data)
    assert r.status_code == 201
    assert r.json()['batchref'] == earlybatch


@pytest.mark.usefixtures('restart_api')
def test_unhappy_path_returns_400_and_error_message():
    unknown_sku, orderid = random_sku(), random_orderid()
    data = {'orderid': orderid, 'sku': unknown_sku, 'qty': 20}
    url = config.get_api_url()
    r = requests.post(f'{url}/allocate', json=data)
    assert r.status_code == 400
    assert r.json()['message'] == f'Invalid sku {unknown_sku}'

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

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

Теперь, когда у нас есть служба распределения, почему бы не создать службу для освобождения? Мы добавили тест E2E и несколько тестов stub для уровня сервиса для вас, чтобы начать работу на GitHub.

Если этого недостаточно, переходите к тестам E2E и flask_app.py и отрефакторите адаптер Flask, чтобы он был более RESTful. Обратите внимание, что это не требует каких-либо изменений в нашем сервисном или доменном слое!

Если вы решили, что хотите создать конечную точку read-only для получения информации о выделении, просто сделайте «простейшую вещь, которая может сработать», а именно repo.get() прямо в обработчике Flask. Мы поговорим больше о чтении и записи в Command-Query Responsibility Segregation (CQRS)[1].

5.6. Почему всё называется сервисом?

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

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

В этой главе мы используем две вещи, называемые service. Первый-это application service (наш service layer). Его работа заключается в обработке запросов из внешнего мира и в orchestrate операции. Мы имеем в виду, что уровень сервиса управляет приложением, следуя нескольким простым шагам:

  • Получить некоторые данные из базы данных

  • Обновить модели домена

  • Сохранить любые изменения

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

Второй тип сервиса-это domain service. Это имя для части логики, которая принадлежит модели предметной области, но не находится естественным образом внутри состояния сущности или value object. Например, если вы создаете приложение для корзины покупок, вы можете выбрать создание правил налогообложения в качестве доменной службы. Расчет налога-это отдельная работа от обновления корзины, и это важная часть модели, но не кажется правильным иметь постоянную сущность для этой работы. Вместо этого эту работу может выполнять класс TaxCalculator или функция calculate_tax.

5.7. Разложим всё по папкам, чтобы увидеть, чему всё это принадлежит

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

Мы можем организовать все так:

Example 60. Some subfolders
1 Давайте создадим папку для нашей доменной модели. В настоящее время это всего лишь один файл, но для более сложного приложения у вас может быть один файл на класс; у вас могут быть вспомогательные родительские классы для Entity, ValueObject, и Aggregate, и вы могли бы добавить exceptions.py для исключений доменного уровня и, как вы увидите в Событийно-Ориентированная архитектура, commands.py и events.py.
2 Мы будем различать уровень обслуживания. В настоящее время это всего лишь один файл с именем services.py для наших функций сервисного уровня. Здесь вы можете добавить исключения сервисного уровня, и, как вы увидите в TDD на Высокой и Низкой передаче, мы добавим unit_of_work.py.
3 Adapters - это дань терминологии портов и адаптеров. Это заполнит любые другие абстракции вокруг внешнего I/O (напр., a redis_client.py). Строго говоря, вы бы назвали эти адаптеры secondary или driven адаптерами, или иногда inward-facing адаптерами.
4 Точки входа entrypoints — это места, откуда мы управляем нашим приложением. В официальной терминологии портов и адаптеров они тоже являются адаптерами и называются адаптерами primary первичными, driving управляющими или outward-facing обращенными наружу.

А как насчет портов? Как вы помните, это абстрактные интерфейсы, которые реализуют адаптеры. Мы склонны хранить их в том же файле, что и адаптеры, которые их реализуют.

5.8. Резюме

Добавление service layer уровня сервиса даёт немало преимуществ.

  • Наши entrypoints Flask API становятся очень тонкими и легкими в написании: их единственная обязанность-делать "web stuff", такие как разбор JSON и создание правильных HTTP-кодов для удачных или неудачных случаев.

  • Мы определили четкий API для нашего домена, набор вариантов использования или точек входа, которые могут быть использованы любым адаптером без необходимости знать что-либо о наших классах моделей домена-будь то API, CLI (см. Замена инфраструктуры: Делайте все с CSV) или тесты! Они также являются адаптером для нашего домена.

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

  • И наша пирамида тестирования выглядит неплохо — большая часть наших тестов — это быстрые модульные тесты, с минимальным количеством E2E и интеграционных тестов.

5.8.1. DIP в действии

Abstract dependencies of the service layer показывает зависимости нашего уровня сервиса: модель предметной области и AbstractRepository (порт в терминологии портов и адаптеров).

Когда мы запускаем тесты, Tests provide an implementation of the abstract dependency показывает, как мы реализуем абстрактные зависимости с помощью FakeRepository (адаптера).

И когда мы на самом деле запускаем наше приложение, мы меняем "реальную" зависимость, показанную в Dependencies at runtime.

apwp 0403
Figure 18. Abstract dependencies of the service layer
[ditaa, apwp_0403]
        +-----------------------------+
        |         Service Layer       |
        +-----------------------------+
           |                   |
           |                   | depends on abstraction
           V                   V
+------------------+     +--------------------+
|   Domain Model   |     | AbstractRepository |
|                  |     |       (Port)       |
+------------------+     +--------------------+
apwp 0404
Figure 19. Tests provide an implementation of the abstract dependency
[ditaa, apwp_0404]
        +-----------------------------+
        |           Tests             |-------------\
        +-----------------------------+             |
                       |                            |
                       V                            |
        +-----------------------------+             |
        |         Service Layer       |    provides |
        +-----------------------------+             |
           |                     |                  |
           V                     V                  |
+------------------+     +--------------------+     |
|   Domain Model   |     | AbstractRepository |     |
+------------------+     +--------------------+     |
                                    ^               |
                         implements |               |
                                    |               |
                         +----------------------+   |
                         |    FakeRepository    |<--/
                         |     (in–memory)      |
                         +----------------------+
apwp 0405
Figure 20. Dependencies at runtime
[ditaa, apwp_0405]
       +--------------------------------+
       | Flask API (Presentation Layer) |-----------\
       +--------------------------------+           |
                       |                            |
                       V                            |
        +-----------------------------+             |
        |         Service Layer       |             |
        +-----------------------------+             |
           |                     |                  |
           V                     V                  |
+------------------+     +--------------------+     |
|   Domain Model   |     | AbstractRepository |     |
+------------------+     +--------------------+     |
              ^                     ^               |
              |                     |               |
       gets   |          +----------------------+   |
       model  |          | SqlAlchemyRepository |<--/
   definitions|          +----------------------+
       from   |                | uses
              |                V
           +-----------------------+
           |          ORM          |
           | (another abstraction) |
           +-----------------------+
                       |
                       | talks to
                       V
           +------------------------+
           |       Database         |
           +------------------------+

Чудесно.

Давайте сделаем паузу для Service layer: Компромиссы, в которой мы рассмотрим плюсы и минусы наличия service layer вообще.

Table 2. Service layer: Компромиссы
Плюсы Минусы
  • У нас есть единое место, где можно запечатлеть все случаи использования нашего приложения.

  • Мы поместили нашу умную доменную логику за API, что оставляет нам свободу для рефакторинга.

  • Мы четко отделили "stuff that talks HTTP" от "stuff that talks allocation."

  • В сочетании с шаблоном Repository и FakeRepository у нас есть хороший способ написания тестов на более высоком уровне, чем уровень домена; мы можем протестировать большую часть нашего рабочего процесса без необходимости использования интеграционных тестов (подробнее см. в TDD на Высокой и Низкой передаче).

  • Если ваше приложение является purely чистым веб-приложением, ваши контроллеры/функции просмотра могут быть единственным местом для захвата всех вариантов использования.

  • Это еще один слой абстракции.

  • Внесение слишком большого количества логики в уровень сервиса может привести к антипаттерну Anemic Domain. Этот уровень лучше вводить после того, как вы заметите, как логика оркестровки проникает в ваши контроллеры.

  • Вы можете получить много преимуществ, связанных с наличием богатых моделей предметной области, просто вытолкнув логику из ваших контроллеров на уровень модели, без необходимости добавлять дополнительный слой между ними (также известный как «толстые модели, тонкие контроллеры») .

Но есть еще несколько неловких моментов, которые нужно убрать:

  • Уровень сервиса по-прежнему тесно связан с доменом, поскольку его API выражается в терминах объектов OrderLine. В TDD на Высокой и Низкой передаче мы исправим это и поговорим о том, как уровень сервиса обеспечивает более производительный TDD.

  • Уровень сервиса тесно связан с объектом session. В Паттерн Unit of Work(Единица работы) мы введем еще один паттерн, который тесно работает с паттернами Уровня Репозитория и Сервиса, паттерн Unit of Work, и все будет абсолютно прекрасно. Вот увидите!

6. TDD на Высокой и Низкой передаче

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

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

Harry Says: Seeing a Test Pyramid in Action Was a Light-Bulb Moment

Вот несколько слов непосредственно от Гарри:

Я изначально скептически относился ко всем архитектурным паттернам Боба, но настоящая тестовая пирамида сделала меня новообращенным.

Как только вы реализуете моделирование предметной области и уровень сервиса, вы действительно можете перейти к этапу, когда модульные тесты на порядок превосходят интеграционные и сквозные тесты. Работая в местах, где тестовая сборка E2E заняла бы несколько часов (по сути, "подождите до завтра"), я не могу сказать вам, какая разница, если вы сможете запустить все свои тесты за считанные минуты или секунды.

Прочтите несколько рекомендаций о том, как решить, какие тесты писать и на каком уровне. Мышление с высокой передачей High Gear и низкой передачей Low Gear действительно изменило мою тестовую жизнь.

6.1. Как выглядит наша тестовая пирамида?

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

Example 61. Подсчет типов тестов

Неплохо! У нас есть 15 модульных тестов, 8 интеграционных тестов и всего 2 сквозных теста. Это уже здоровая на вид тестовая пирамида.

6.2. Должны ли тесты доменного уровня перейти на уровень сервиса?

Посмотрим, что произойдет, если мы сделаем еще один шаг. Поскольку мы можем тестировать наше программное обеспечение на уровне сервисов, нам больше не нужны тесты для модели предметной области. Вместо этого мы могли бы переписать все тесты уровня домена из Domain Modeling с точки зрения уровня обслуживания:

Example 62. Переписывание теста домена на уровне сервиса (tests/unit/test_services.py)

Зачем нам это нужно?

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

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

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

По мере того, как мы углубимся в книгу, вы увидите, как уровень сервиса формирует API для нашей системы, которым мы можем управлять разными способами. Тестирование этого API сокращает объем кода, который нам нужно изменить при рефакторинге нашей модели предметной области. Если мы ограничимся тестированием только на уровне сервиса, у нас не будет никаких тестов, которые напрямую взаимодействуют с «частными» методами или атрибутами объектов нашей модели, что дает нам больше свободы для их рефакторинга.

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

6.3. О принятии решения О том, Какие Тесты писать

Вы можете спросить себя: «А стоит ли мне тогда переписать все свои модульные тесты? Разве неправильно писать тесты для модели предметной области?» Чтобы ответить на эти вопросы, важно понимать компромисс между связью и обратной связью по проекту (см. Тестовый спектр).

apwp 0501
Figure 21. Тестовый спектр
[ditaa, apwp_0501]
| Низкая обратная связь                                    Высокая обратная связь |
| Низкий барьер для изменений                           Высокий барьер для перемен |
| Высокий охват системы                                  Сфокусированное покрытие |
|                                                                              |
| <---------                                                       ----------> |
|                                                                              |
| API Tests                  Service–Layer Tests                  Domain Tests |

Экстремальное программирование (XP) призывает нас «слушать код». Когда мы пишем тесты, мы можем обнаружить, что код трудно использовать, или заметим запах кода. Это повод для рефакторинга и пересмотра нашего дизайна.

Однако мы получаем эту обратную связь только тогда, когда тесно работаем с целевым кодом. Тест HTTP API ничего не говорит нам о детальном дизайне наших объектов, потому что он находится на гораздо более высоком уровне абстракции.

С другой стороны, мы можем переписать все наше приложение, и, пока мы не меняем URL-адреса или форматы запросов, наши HTTP-тесты будут продолжать проходить. Это дает нам уверенность в том, что крупномасштабные изменения, такие как изменение схемы базы данных, не нарушили наш код.

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

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

Мы часто «зарисовываем» новое поведение, написав тесты на этом уровне, чтобы увидеть, как может выглядеть код. Однако, когда мы хотим улучшить дизайн кода, нам нужно будет заменить или удалить эти тесты, потому что они тесно связаны с конкретной implementation реализацей.

6.4. Высокая и низкая передача

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

Например, при написании функции add_stock или функции cancel_order мы можем работать быстрее и с меньшей связью, написав тесты на уровне сервиса.

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

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

6.5. Полное отделение тестов уровня сервиса от домена

У нас все еще есть прямые зависимости от домена в наших тестах уровня обслуживания, потому что мы используем объекты домена для настройки наших тестовых данных и вызова наших функций уровня обслуживания.

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

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

Example 63. До: allocate принимает объект домена (service_layer/services.py)

Как бы это выглядело, если бы все его параметры были примитивными типами?

Example 64. После: allocate принимает строки и целые числа (service_layer/services.py)
def allocate(
        orderid: str, sku: str, qty: int, repo: AbstractRepository, session
) -> str:

Мы также переписываем тесты в этих терминах:

Example 65. Tests now use primitives in function call (tests/unit/test_services.py)
def test_returns_allocation():
    batch = model.Batch("batch1", "COMPLICATED-LAMP", 100, eta=None)
    repo = FakeRepository([batch])

    result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, FakeSession())
    assert result == "batch1"

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

6.5.1. Смягчение последствий: Храните Все доменные зависимости в функциях Fixture

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

Example 66. Фабричные функции для фикстур — это одна из возможностей (tests/unit/test_services.py)

По крайней мере, это переместило бы все зависимости наших тестов из домена в одно место.

6.5.2. Добавление отсутствующей службы

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

Example 67. Тест для нового сервиса add_batch (tests/unit/test_services.py)
def test_add_batch():
    repo, session = FakeRepository([]), FakeSession()
    services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, repo, session)
    assert repo.get("b1") is not None
    assert session.committed
В общем, если вам нужно делать что-то на уровне домена непосредственно в тестах уровня сервиса, это может быть признаком того, что ваш уровень сервиса не завершен.

А реализация — это всего две строчки:

Example 68. Новый сервис для add_batch (service_layer/services.py)
def add_batch(
        ref: str, sku: str, qty: int, eta: Optional[date],
        repo: AbstractRepository, session,
):
    repo.add(model.Batch(ref, sku, qty, eta))
    session.commit()


def allocate(
        orderid: str, sku: str, qty: int, repo: AbstractRepository, session
) -> str:
    ...
Стоит ли писать новую службу только потому, что она поможет устранить зависимости из ваших тестов? Возможно нет. Но в этом случае нам почти наверняка однажды понадобится сервис add_batch. так или иначе.

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

Example 69. Тесты сервисов теперь используют только сервисы (tests/unit/test_services.py)
def test_allocate_returns_allocation():
    repo, session = FakeRepository([]), FakeSession()
    services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, repo, session)
    result = services.allocate("o1", "COMPLICATED-LAMP", 10, repo, session)
    assert result == "batch1"


def test_allocate_errors_for_invalid_sku():
    repo, session = FakeRepository([]), FakeSession()
    services.add_batch("b1", "AREALSKU", 100, None, repo, session)

    with pytest.raises(services.InvalidSku, match="Invalid sku NONEXISTENTSKU"):
        services.allocate("o1", "NONEXISTENTSKU", 10, repo, FakeSession())

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

6.6. Внесение улучшений в тесты E2E

Точно так же, как добавление add_batch помогло отделить наши тесты сервисного уровня от модели, добавление конечной точки API для добавления пакета устранило бы необходимость в уродливом приспособлении add_stock, и наши тесты E2E могли бы быть свободны от этих жестко закодированных SQL-запросов и прямой зависимости от базы данных.

Благодаря нашей сервисной функции добавить endpoint очень просто, требуется всего лишь немного порботать с JSON и один раз вызвать функцию:

Example 70. API для добавления batch (entrypoints/flask_app.py)
@app.route("/add_batch", methods=['POST'])
def add_batch():
    session = get_session()
    repo = repository.SqlAlchemyRepository(session)
    eta = request.json['eta']
    if eta is not None:
        eta = datetime.fromisoformat(eta).date()
    services.add_batch(
        request.json['ref'], request.json['sku'], request.json['qty'], eta,
        repo, session
    )
    return 'OK', 201
Вы думаете про себя, POST to _ /add_batch_? Это не очень RESTful! Вы совершенно правы. Мы, к счастью, небрежны, но если вы хотите сделать все более RESTy, возможно, POST to /batches,тогда сам щёлкни себя по носу! Поскольку Flask - тонкий адаптер, это будет несложно. See the next sidebar.

И наши жестко закодированные SQL-запросы из conftest.py заменяются некоторыми вызовами API, что означает, что тесты API не имеют никаких зависимостей, кроме API, что тоже неплохо:

Example 71. Тесты API теперь могут добавлять свои собственные пакеты (tests/e2e/test_api.py)
def post_to_add_batch(ref, sku, qty, eta):
    url = config.get_api_url()
    r = requests.post(
        f'{url}/add_batch',
        json={'ref': ref, 'sku': sku, 'qty': qty, 'eta': eta}
    )
    assert r.status_code == 201


@pytest.mark.usefixtures('postgres_db')
@pytest.mark.usefixtures('restart_api')
def test_happy_path_returns_201_and_allocated_batch():
    sku, othersku = random_sku(), random_sku('other')
    earlybatch = random_batchref(1)
    laterbatch = random_batchref(2)
    otherbatch = random_batchref(3)
    post_to_add_batch(laterbatch, sku, 100, '2011-01-02')
    post_to_add_batch(earlybatch, sku, 100, '2011-01-01')
    post_to_add_batch(otherbatch, othersku, 100, None)
    data = {'orderid': random_orderid(), 'sku': sku, 'qty': 3}
    url = config.get_api_url()
    r = requests.post(f'{url}/allocate', json=data)
    assert r.status_code == 201
    assert r.json()['batchref'] == earlybatch

6.7. Заключение

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

Резюме: Эмпирические правила для различных типов тестов
Стремитесь к одному сквозному тесту для каждой функции.

Это может быть написано, например, для HTTP API. Цель состоит в том, чтобы продемонстрировать, что функция работает, и что все движущиеся части правильно склеены.

Пишите основную часть ваших тестов на уровне сервиса.

Эти сквозные тесты предлагают хороший компромисс между охватом, временем выполнения и эффективностью. Каждый тест обычно охватывает один путь кода функции и использует подделки для ввода-вывода. Это место, где можно полностью охватить все крайние случаи и тонкости вашей бизнес-логики.[28]

Поддерживайте небольшое ядро ​​тестов, написанных для вашей модели предметной области.

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

Обработку ошибок считайте функцией.

В идеале ваше приложение должно быть структурировано таким образом, чтобы все ошибки, возникающие в ваших точках входа (например, Flask), обрабатывались одинаково. Это означает, что вам нужно протестировать только удачный путь для каждой функции и зарезервировать один сквозной тест для всех неудачных путей (и, конечно, многих модульных тестов неудачных путей).

В этом вам помогут несколько вещей:

  • Выражайте уровень обслуживания в терминах примитивов, а не объектов предметной области.

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

Переходим к следующей главе!

7. Паттерн Unit of Work(Единица работы)

В этой главе мы познакомимся с заключительной частью головоломки, которая связывает воедино паттерны уровня Репозитория и Уровня Сервиса: паттерн Unit of Work.

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

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

Код для этой главы находится в ветке chapter_06_uow on GitHub:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_06_uow
# or to code along, checkout Chapter 4:
git checkout chapter_04_service_layer
apwp 0601
Figure 22. Без UoW: API напрямую общается с тремя уровнями

С помощью UoW: UoW теперь управляет состоянием базы данных показывает наше целевое состояние. API Flask теперь делает только две вещи: он инициализирует единицу работы и вызывает службу. Сервис сотрудничает с UoW (нам нравится думать о UoW как о части сервисного уровня), но ни сама сервисная функция, ни Flask теперь не нуждаются в непосредственном общении с базой данных.

И мы сделаем все это с помощью прекрасного синтаксиса Python - диспетчера контекста.

apwp 0602
Figure 23. С помощью UoW: UoW теперь управляет состоянием базы данных

7.1. Unit of Work взаимодействует с репозиторием

Давайте посмотрим, как работает единица работы (или UoW, что мы произносим как «you-wow»). Вот как будет выглядеть сервисный слой, когда мы закончим:

Example 72. Предварительный просмотр Unit of Work в действии (src/allocation/service_layer/services.py)
def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:  (1)
        batches = uow.batches.list()  (2)
        ...
        batchref = model.allocate(line, batches)
        uow.commit()  (3)
1 Мы запустим UoW в качестве менеджера контекста.
2 uow.batches это пакетный репо, поэтому UoW предоставляет нам доступ к нашему постоянному хранилищу.
3 Когда мы закончили, мы фиксируем или откатываем нашу работу, используя UoW.

UoW действует как единственная точка входа в наше постоянное хранилище и отслеживает, какие объекты были загружены, и последнее состояние.[29]

Это дает нам три полезных преимущества:

  • Стабильный моментальный снимок базы данных для работы, поэтому объекты, которые мы используем, не меняются на полпути операции.

  • Способ сохранить все наши изменения сразу, чтобы, если что-то пойдет не так, мы не оказались в непоследовательном состоянии.

  • Простой API для наших проблем персистентности и удобное место для получения репозитория

7.2. Тест-драйв UoW с интеграционными тестами

Here are our integration tests for the UOW:

Example 73. A basic "round-trip" test for a UoW (tests/integration/test_uow.py)
def test_uow_can_retrieve_a_batch_and_allocate_to_it(session_factory):
    session = session_factory()
    insert_batch(session, 'batch1', 'HIPSTER-WORKBENCH', 100, None)
    session.commit()

    uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)  (1)
    with uow:
        batch = uow.batches.get(reference='batch1')  (2)
        line = model.OrderLine('o1', 'HIPSTER-WORKBENCH', 10)
        batch.allocate(line)
        uow.commit()  (3)

    batchref = get_allocated_batch_ref(session, 'o1', 'HIPSTER-WORKBENCH')
    assert batchref == 'batch1'
1 Мы инициализируем UoW, используя нашу настраиваемую фабрику сеансов, и возвращаем объект uow для использования его в нашем блоке with.
2 UoW дает нам доступ к репозиторию batch через uow.batches.
3 Мы вызываем для него commit(), когда закончим.

Для любопытных хелперы insert_batch и get_allocated_batch_ref выглядят так:

Example 74. Помощники для работы с SQL (tests/integration/test_uow.py)
def insert_batch(session, ref, sku, qty, eta):
    session.execute(
        'INSERT INTO batches (reference, sku, _purchased_quantity, eta)'
        ' VALUES (:ref, :sku, :qty, :eta)',
        dict(ref=ref, sku=sku, qty=qty, eta=eta)
    )


def get_allocated_batch_ref(session, orderid, sku):
    [[orderlineid]] = session.execute(
        'SELECT id FROM order_lines WHERE orderid=:orderid AND sku=:sku',
        dict(orderid=orderid, sku=sku)
    )
    [[batchref]] = session.execute(
        'SELECT b.reference FROM allocations JOIN batches AS b ON batch_id = b.id'
        ' WHERE orderline_id=:orderlineid',
        dict(orderlineid=orderlineid)
    )
    return batchref

7.3. Unit of Work и её менеджер контекста

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

Example 75. Абстрактный менеджер контекста UoW (src/allocation/service_layer/unit_of_work.py)
1 UoW предоставляет атрибут под названием .batches, который дает нам доступ к репозиторию пакетов.
2 Если вы никогда не видели контекстного менеджера, __enter__ и __exit__ это два волшебных метода, которые выполняются, когда мы входим в блок with и когда выходим из него, соответственно. Это наши фазы setup и teardown.
3 Мы вызовем этот метод, чтобы явно зафиксировать нашу работу, когда будем готовы.
4 Если мы не фиксируем, или если мы выходим из диспетчера контекста, вызывая ошибку, мы выполняем «откат» rollback. (Откат не возымеет никакого эффекта, если была вызвана функция commit(). Читайте дальше для более подробного обсуждения этого вопроса.)

7.3.1. Реальная Unit of Work Использует Сеансы SQLAlchemy

Главное, что добавляет наша конкретная реализация, - это сеанс базы данных:

Example 76. The real SQLAlchemy UoW (src/allocation/service_layer/unit_of_work.py)
DEFAULT_SESSION_FACTORY = sessionmaker(bind=create_engine(  (1)
    config.get_postgres_uri(),
))

class SqlAlchemyUnitOfWork(AbstractUnitOfWork):

    def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
        self.session_factory = session_factory  (1)

    def __enter__(self):
        self.session = self.session_factory()  # type: Session  (2)
        self.batches = repository.SqlAlchemyRepository(self.session)  (2)
        return super().__enter__()

    def __exit__(self, *args):
        super().__exit__(*args)
        self.session.close()  (3)

    def commit(self):  (4)
        self.session.commit()

    def rollback(self):  (4)
        self.session.rollback()
1 Модуль определяет фабрику сеансов по умолчанию, которая будет подключаться к Postgres, но мы позволяем переопределить это в наших интеграционных тестах, чтобы вместо этого мы могли использовать SQLite.
2 Метод __enter__ отвечает за запуск сеанса базы данных и создание экземпляра реального репозитория, который может использовать этот сеанс.
3 Закрываем сессию при выходе.
4 Наконец, мы предоставляем конкретные методы commit() и rollback(), которые используют наш сеанс базы данных.

7.3.2. Иммитация Unit of Work для теста

Вот как мы используем фиктивный UoW в наших тестах уровня сервиса:

Example 77. Fake UoW (tests/unit/test_services.py)
class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork):

    def __init__(self):
        self.batches = FakeRepository([])  (1)
        self.committed = False  (2)

    def commit(self):
        self.committed = True  (2)

    def rollback(self):
        pass



def test_add_batch():
    uow = FakeUnitOfWork()  (3)
    services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow)  (3)
    assert uow.batches.get("b1") is not None
    assert uow.committed


def test_allocate_returns_allocation():
    uow = FakeUnitOfWork()  (3)
    services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow)  (3)
    result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow)  (3)
    assert result == "batch1"
...
1 FakeUnitOfWork и FakeRepository тесно связаны, так же как реальные классы UnitofWork и Repository. Это прекрасно, потому что мы признаем, что объекты являются соавторами.
2 Обратите внимание на сходство с фальшивой функцией commit() из FakeSession (от которой теперь мы можем избавиться). Но это существенное улучшение, потому что мы сейчас подделываем код, который мы написали, а не сторонний код. Как гласит народная мудрость, "Не твоё — не трогай".
3 В наших тестах мы можем создать экземпляр UoW и передать его на наш уровень обслуживания, а не передавать репозиторий и сеанс. Это значительно изящнее.
Не твоё — не мОкай

Почему мы чувствуем себя более комфортно, мокая UoW, а не сессию? Обе наши имитации преднназначены для одного и того же: дать нам возможность изменить уровень персистентности, чтобы мы могли запускать тесты в памяти вместо того, чтобы связываться с реальной базой данных. Разница заключается в полученном дизайне.

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

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

«Не смейтесь над тем, что вам не принадлежит» - это эмпирическое правило, которое заставляет нас строить эти простые абстракции над беспорядочными подсистемами. Это дает тот же выигрыш в производительности, что и имитация сеанса SQLAlchemy, но побуждает нас тщательно обдумать наши проекты.

7.4. Использование UoW в сервисном слое

Вот как выглядит наш новый уровень обслуживания:

Example 78. Уровень обслуживания с использованием UoW (src/allocation/service_layer/services.py)
def add_batch(
        ref: str, sku: str, qty: int, eta: Optional[date],
        uow: unit_of_work.AbstractUnitOfWork  (1)
):
    with uow:
        uow.batches.add(model.Batch(ref, sku, qty, eta))
        uow.commit()


def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork  (1)
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        batches = uow.batches.list()
        if not is_valid_sku(line.sku, batches):
            raise InvalidSku(f'Invalid sku {line.sku}')
        batchref = model.allocate(line, batches)
        uow.commit()
    return batchref
1 Наш уровень обслуживания теперь имеет только одну зависимость, опять же от abstract UoW.

7.5. Явные тесты для режима Commit/Rollback

Чтобы убедиться, что поведение commit/rollback фиксации/отката работает, мы написали несколько тестов:

Example 79. Интеграционные тесты на поведение отката (tests/integration/test_uow.py)
def test_rolls_back_uncommitted_work_by_default(session_factory):
    uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
    with uow:
        insert_batch(uow.session, 'batch1', 'MEDIUM-PLINTH', 100, None)

    new_session = session_factory()
    rows = list(new_session.execute('SELECT * FROM "batches"'))
    assert rows == []


def test_rolls_back_on_error(session_factory):
    class MyException(Exception):
        pass

    uow = unit_of_work.SqlAlchemyUnitOfWork(session_factory)
    with pytest.raises(MyException):
        with uow:
            insert_batch(uow.session, 'batch1', 'LARGE-FORK', 100, None)
            raise MyException()

    new_session = session_factory()
    rows = list(new_session.execute('SELECT * FROM "batches"'))
    assert rows == []
Мы не показывали его здесь, но, возможно, стоит протестировать некоторые из более "неясных" действий базы данных, таких как транзакции, против "реальной" базы данных—то есть того же самого движка. На данный момент нам сходит с рук использование SQLite вместо Postgres, но в Агрегаты и границы консистентности мы переключим некоторые тесты на использование реальной базы данных. Очень удобно, что наш класс UoW делает это легко!

7.6. Явные и неявные коммиты

Теперь мы вкратце остановимся на различных способах реализации паттерна UoW.

Мы могли бы представить себе несколько иную версию UoW, которая фиксируется по умолчанию и откатывается только в том случае, если замечает исключение:

Example 80. UoW с неявной фиксацией …​ (src/allocation/unit_of_work.py)
1 Должны ли мы иметь на счастливом пути неявную фиксацию?
2 И откатиться только при исключении?

Это позволило бы нам сохранить строку кода и удалить явную фиксацию из нашего клиентского кода:

Example 81. ...это сэкономило бы нам строку кода (src/allocation/service_layer/services.py)

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

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

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

7.7. Примеры: Использование UoW для группировки нескольких операций в атомарную единицу

Ниже приведены некоторые примеры используемых схем работы. Это может привести к более простому рассуждению о том, как блоки кода работают совместно.

7.7.1. Пример 1: Перераспределение

Предположим, что мы хотим отменить распределение, а затем передислоцировать заказ:

Example 82. Перераспределить сервисную функцию
1 Если deallocate() не работает, очевидно мы не хотим вызывать allocate().
2 Если allocate() терпит неудачу, вероятно мы, так же не хотим фиксить deallocate()

7.7.2. Пример 2: Изменить размер партии

Наша судоходная компания звонит нам, чтобы сообщить, что одна из дверей контейнера открылась, и половина наших диванов упала в Индийский океан. Ой!

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

7.8. Уборка интеграционных тестов

Теперь у нас есть три набора тестов, все из которых, по сути, направлены на базу данных: test_orm.py, test_repository.py, и test_uow.py. Может, выкинем что-нибудь?

└── tests
    ├── conftest.py
    ├── e2e
    │   └── test_api.py
    ├── integration
    │   ├── test_orm.py
    │   ├── test_repository.py
    │   └── test_uow.py
    ├── pytest.ini
    └── unit
        ├── test_allocate.py
        ├── test_batches.py
        └── test_services.py

Вы всегда можете отказаться от тестов, если считаете, что они не принесут пользы в долгосрочной перспективе. Мы бы сказали, что test_orm.py был в первую очередь инструментом для изучения SQLAlchemy, поэтому в дальнейшем он не понадобится, особенно если основные вещи, которые он делает, описаны в test_repository.py. Этот последний тест вы можете оставить, но мы, безусловно, видим аргумент в пользу того, чтобы просто держать все на максимально возможном уровне абстракции (так же, как мы делали это в юнит-тестах).

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

Для этой главы, пожалуй, лучшее, что можно попробовать, это реализовать UoW с нуля. Код, как всегда, здесь на GitHub. Вы можете либо достаточно внимательно следовать нашей модели, либо, возможно, поэкспериментировать с отделением UoW (в обязанности которого входит commit(), rollback() и предоставление репозитория .batches) от контекстного менеджера, чья работа заключается в инициализации объектов, а затем выполнить коммит или откат при выходе. Если вы чувствуете, что хотите работать полностью функционально, а не возиться со всеми этими классами, вы можете использовать @contextmanager из contextlib.

Мы удалили как фактический UoW, так и подделки, а также сократили абстрактный UoW. Почему бы не прислать нам ссылку на ваше репо, если вы придумали что-то, чем особенно гордитесь?

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

7.9. Заключение

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

Этот шаблон настолько полезен, что SQLAlchemy уже использует UoW в форме объекта Session. Объект Session в SQLAlchemy - это способ, которым ваше приложение загружает данные из базы данных.

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

Паттерн Единица Работы: компромиссы обсуждает некоторые компромиссы.

Table 3. Паттерн Единица Работы: компромиссы
Плюсы Минусы
  • У нас есть хорошая абстракция над концепцией атомарных операций, и контекстный менеджер позволяет легко увидеть, визуально, какие блоки кода сгруппированы вместе атомарно.

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

  • Это хорошее место для размещения всех репозиториев, доступных для клиентского кода.

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

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

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

Во-первых, Session API богат и поддерживает операции, которые нам не нужны или не нужны в нашем домене. Наш UnitOfWork упрощает сеанс до его основного ядра: его можно запустить, зафиксировать или выбросить.

Во-вторых, мы используем UnitOfWork для доступа к нашим объектам Repository. Это добавит удобства в использовании разработчиками, и это то, то мы не смогли бы сделать с помощью простого SQLAlchemy Session.

Краткий обзор шаблона Unit of Work

Шаблон Unit of Work - это абстракция вокруг целостности данных

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

Он тесно работает с шаблонами Уровня репозитория и сервиса

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

Это прекрасный случай для контекстного менеджера

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

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

Наконец, мы снова мотивированы принципом инверсии зависимостей: наш уровень сервиса зависит от тонкой абстракции, и мы прикрепляем конкретную реализацию к внешнему краю системы. Это хорошо согласуется с собственной рекомендацией SQLAlchemy:

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

— SQLALchemy "Session Basics" Documentation

8. Агрегаты и границы консистентности

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

Добавление Product aggregate показывает предварительный образ того, куда мы движемся: мы введем новый объект модели под названием Product для упаковки нескольких пакетов batches, и вместо этого сделаем старую доменную службу allocate() доступной в качестве метода в Product.

apwp 0701
Figure 24. Добавление Product aggregate

Почему? Давай выясним.

Код этой главы находится в ветке chapter_07_aggregate on GitHub:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_07_aggregate
# или, чтобы кодировать вместе, проверьте предыдущую главу:
git checkout chapter_06_uow

8.1. Почему бы просто не запустить все в электронной таблице?

В любом случае, в чем смысл доменной модели? Какую фундаментальную проблему мы пытаемся решить?

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

На самом деле, огромное количество бизнес-процессов работают путем ручной отправки электронных таблиц туда и обратно по электронной почте. Эта архитектура «CSV поверх SMTP» имеет низкую начальную сложность, но имеет тенденцию не очень хорошо масштабироваться, потому что трудно применять логику и поддерживать согласованность.

Кому разрешено просматривать это конкретное поле? Кому разрешено обновлять? Что происходит, когда мы пытаемся заказать -350 стульев, или 10 000 000 столов? Может ли работник иметь отрицательную зарплату?

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

8.2. Инварианты, Ограничения и Консистентность

Эти два слова в некоторой степени взаимозаменяемы, но constraint Ограничения — это правило, ограничивающее возможные состояния, в которые может попасть наша модель, в то время как invariant определяется немного более точно как условие, которое всегда истинно.

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

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

Давайте рассмотрим несколько конкретных примеров из наших бизнес-требований; мы начнем с этого:

Строка заказа может быть выделена только для одной партии одновременно.

— The business

Это бизнес - правило, которое накладывает инвариант. Инвариант заключается в том, что строка заказа распределяется либо на ноль, либо на одну партию, но никогда не более чем на одну. Нам нужно убедиться, что наш код никогда случайно не вызывает Batch.allocate() в двух разных пакетах для одной и той же строки, и в настоящее время ничто явно не мешает нам это сделать.

8.2.1. Инварианты, Параллельность, и Блокировки locks

Давайте рассмотрим еще одно из наших бизнес-правил:

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

— The business

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

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

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

Обычно мы решаем эту проблему, применяя блокировку locks к нашим таблицам базы данных. Это предотвращает одновременное выполнение двух операций в одной строке или одной таблице.

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

8.3. Что такое Агрегат?

Итак, если мы не можем блокировать всю базу данных каждый раз, когда хотим выделить строку заказа, что мы должны делать вместо этого? Мы хотим защитить инварианты нашей системы, но при этом обеспечить максимальную степень параллелизма. Сохранение наших инвариантов неизбежно означает предотвращение одновременной записи; если несколько пользователей могут выделить "DEADLY-SPOON" одновременно, мы рискуем перераспределить ее.

С другой стороны, нет причин, по которым мы не можем выделить DEADLY-SPOON одновременно с FLIMSY-DESK. Безопасно выделять два продукта одновременно, потому что нет инварианта, покрывающего оба. Нам не нужно, чтобы они были консистентны друг другу.

Шаблон Aggregate является шаблоном дизайна от сообщества DDD, который помогает нам решить эту проблему. aggregate - это просто объект домена, который содержит другие объекты домена и позволяет нам рассматривать всю коллекцию как единое целое.

Единственный способ модифицировать объекты внутри агрегата - это загрузить его целиком, а также вызвать методы внутри самомого агрегата.

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

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

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

АГРЕГАТ - это кластер связанных объектов, который мы рассматриваем как единое целое с целью изменения данных.

— Eric Evans
Domain-Driven Design blue book

Согласно Эвансу, наш агрегат имеет корневую сущность (корзину), которая инкапсулирует доступ к элементам. Каждый товар имеет свою индивидуальность, но другие части системы всегда будут относиться к Корзине только как к неделимому целому.

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

8.4. Выбор агрегата

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

Объект, которым мы манипулируем под капотом, - это Batch.. Что мы называем коллекцией партий? Как нам разделить все партии в системе на дискретные острова консистентности?

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

Но ни одна из этих концепций нас не удовлетворяет. Мы должны быть в состоянии выделить DEADLLY-SPOONs и FLIMSY-DESK одновременно, даже если они находятся на одном и том же складе или в одной и той же отгрузке. Эти понятия имеют неправильную гранулярность.

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

Однако, это громоздкое имя, поэтому после некоторого пролива велосипедов через SkuStock, Stock, ProductStock и так далее, мы решили просто назвать его Product — в конце концов, это была первая концепция, с которой мы столкнулись при изучении языка домена еще в Domain Modeling.

Итак, план таков: когда мы хотим выделить строку заказа вместо Раньше: распределение по всем пакетам, использующим доменную службу, где мы ищем все объекты Batch в мире и передаем их службе домена allocate().. .

apwp 0702
Figure 25. Раньше: распределение по всем пакетам, использующим доменную службу
[plantuml, apwp_0702, config=plantuml.cfg]
@startuml
scale 4

hide empty members

package "Service Layer" as services {
    class "allocate()" as allocate {
    }
    hide allocate circle
    hide allocate members
}



package "Domain Model" as domain_model {

  class Batch {
  }

  class "allocate()" as allocate_domain_service {
  }
    hide allocate_domain_service circle
    hide allocate_domain_service members
}


package Repositories {

  class BatchRepository {
    list()
  }

}

allocate -> BatchRepository: list all batches
allocate --> allocate_domain_service: allocate(orderline, batches)

@enduml

…​ мы переместимся в мир После: просим Product распределить продукт по его партиям, в котором есть новый объект Product для конкретного SKU нашей строки заказа который теперь будет отвечать за все партии для этого SKU, и вместо этого мы можем вызвать метод .allocate().

apwp 0703
Figure 26. После: просим Product распределить продукт по его партиям
[plantuml, apwp_0703, config=plantuml.cfg]
@startuml
scale 4

hide empty members

package "Service Layer" as services {
    class "allocate()" as allocate {
    }
}

hide allocate circle
hide allocate members


package "Domain Model" as domain_model {

  class Product {
    allocate()
  }

  class Batch {
  }
}


package Repositories {

  class ProductRepository {
    get()
  }

}

allocate -> ProductRepository: get me the product for this SKU
allocate --> Product: product.allocate(orderline)
Product o- Batch: has

@enduml

Посмотрим, как это выглядит в виде кода:

Example 84. Наш выбранный агрегат, Продукт (src/allocation/domain/model.py)
class Product:

    def __init__(self, sku: str, batches: List[Batch]):
        self.sku = sku  (1)
        self.batches = batches  (2)

    def allocate(self, line: OrderLine) -> str:  (3)
        try:
            batch = next(
                b for b in sorted(self.batches) if b.can_allocate(line)
            )
            batch.allocate(line)
            return batch.reference
        except StopIteration:
            raise OutOfStock(f'Out of stock for sku {line.sku}')
1 Основной идентификатор Product - это sku.
2 Наш класс Product содержит ссылку на коллекцию batches для этого SKU.
3 Наконец, мы можем переместить доменную службу allocate() в метод агрегата 'Product`.
Этот Product может выглядеть не так, как вы ожидаете от модели Product. Ни цены, ни описания, ни габаритов. Нашу службу размещения не волнует ни одна из этих вещей. В этом сила ограниченных контекстов; концепция продукта в одном приложении может сильно отличаться от другого. См. Дополнительную информацию на следующей боковой панели.
Агрегаты, Ограниченные контексты и микросервисы

Одним из наиболее важных вкладов Эванса и сообщества DDD является концепция ограниченные контексты.

По сути, это была реакция на попытки объединить весь бизнес в единую модель. Слово "клиент" означает разные вещи для людей в сфере продаж, обслуживания клиентов, логистики, поддержки и так далее. Атрибуты, необходимые в одном контексте, не имеют значения в другом; более пагубно то, что понятия с одним и тем же именем могут иметь совершенно разные значения в разных контекстах. Вместо того чтобы пытаться построить единую модель (или класс, или базу данных), чтобы охватить все варианты использования, лучше иметь несколько моделей, рисовать границы вокруг каждого контекста и явно обрабатывать перевод между различными контекстами.

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

В нашем примере сервис распределения имеет Product(sku, batches), в то время как электронная коммерция будет иметь Product(sku, description, price, image_url,dimensions, etc…​). Как правило, ваши доменные модели должны включать только те данные, которые необходимы для выполнения вычислений.

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

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

8.5. Один Агрегат = Один Репозиторий

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

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

В нашем случае мы переключимся с BatchRepository на ProductRepository:

Example 85. Наш новый UoW и репозиторий (unit_of_work.py и repository.py)

Уровень ORM потребует некоторых доработок, чтобы нужные партии автоматически загружались и ассоциировались с объектами Product. Хорошо то, что паттерн Repository позволяет нам пока не беспокоиться об этом. Мы можем просто использовать наш FakeRepository и затем передать новую модель в наш сервисный слой, чтобы посмотреть, как она выглядит с Product в качестве основной точки входа:

Example 86. Service layer (src/allocation/service_layer/services.py)
def add_batch(
        ref: str, sku: str, qty: int, eta: Optional[date],
        uow: unit_of_work.AbstractUnitOfWork
):
    with uow:
        product = uow.products.get(sku=sku)
        if product is None:
            product = model.Product(sku, batches=[])
            uow.products.add(product)
        product.batches.append(model.Batch(ref, sku, qty, eta))
        uow.commit()


def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=line.sku)
        if product is None:
            raise InvalidSku(f'Invalid sku {line.sku}')
        batchref = product.allocate(line)
        uow.commit()
    return batchref

8.6. Что насчет производительности?

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

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

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

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

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

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

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

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

Вы найдете код on GitHub. Мы добавили обманную(cheating) реализацию в делегатах к существующей функции allocate(), так что вы должны быть в состоянии развивать её в направлении реальной вещи.

Мы пометили пару тестов командой @pytest.skip(). После того, как вы прочитаете остальную часть этой главы, вернитесь к этим тестам, чтобы попробовать реализовать номера версий. Бонусные баллы, если вы сможете заставить SQLAlchemy сделать это за вас с помощью магии!

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

8.7. Оптимистический параллелизм с номерами версий

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

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

Мы не хотим держать блокировку на всей таблице batches, но как мы реализуем блокировку только строк для определенного SKU?

Один из ответов - иметь единственный атрибут в модели Product, который действует как маркер завершения всего изменения состояния, и использовать его как единственный ресурс, за который могут бороться параллельные воркеры. Если две транзакции одновременно читают состояние мира для batches и обе хотят обновить таблицы allocations, мы заставим обе транзакции также попытаться обновить version_number в таблице products, таким образом, чтобы только одна из них могла выиграть, а мир бы остался целостным.

Последовательная диаграмма: две транзакции пытаются выполнить одновременное обновление на Product иллюстрирует две параллельные транзакции, выполняющие операции чтения одновременно, так что они видят Product, например, с version=3. Они оба вызывают Product.allocate() для того, чтобы изменить состояние. Но мы установили правила целостности базы данных таким образом, что только одному из них разрешено commit-ить новый Product с version=4, а другое обновление будет отклонено.

Номера версий - это лишь один из способов реализации оптимистической блокировки. Вы можете добиться того же, установив уровень изоляции транзакций Postgres на SERIALIZABLE, но это часто оборачивается серьезными издержками производительности. Номера версий также делают неявные понятия явными.
apwp 0704
Figure 27. Последовательная диаграмма: две транзакции пытаются выполнить одновременное обновление на Product
[plantuml, apwp_0704, config=plantuml.cfg]
@startuml
scale 4

entity Model
collections Transaction1
collections Transaction2
database Database


Transaction1 -> Database: get product
Database -> Transaction1: Product(version=3)
Transaction2 -> Database: get product
Database -> Transaction2: Product(version=3)
Transaction1 -> Model: Product.allocate()
Model -> Transaction1: Product(version=4)
Transaction2 -> Model: Product.allocate()
Model -> Transaction2: Product(version=4)
Transaction1 -> Database: commit Product(version=4)
Database -[#green]> Transaction1: OK
Transaction2 -> Database: commit Product(version=4)
Database -[#red]>x Transaction2: Error! version is already 4

@enduml
Оптимистическое управление параллелизмом и повторные попытки

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

Пессимистический контроль параллелизма работает в предположении, что два пользователя будут вызывать конфликты, а мы хотим предотвратить конфликты во всех случаях, поэтому мы блокируем все на всякий случай. В нашем примере это означает блокировку всей таблицы batches или использование SELECT FOR UPDATE - мы делаем вид, что исключили эти варианты из соображений производительности, но в реальной жизни вы захотите провести некоторые оценки и измерения самостоятельно.

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

Обычный способ обработки сбоя - повторить неудачную операцию с самого начала. Представьте, что у нас есть два покупателя, Гарри и Боб, и каждый из них делает заказ на SHINY-TABLE. Оба потока загружают продукт в версии 1 и распределяют запасы. База данных предотвращает одновременное обновление, и заказ Боба не выполняется с ошибкой. Когда мы повторяем операцию, заказ Боба загружает продукт в версии 2 и снова пытается распределить. Если запасов достаточно, то все в порядке; в противном случае он получит OutOfStock. Большинство операций можно повторить таким образом в случае возникновения проблем с параллелизмом.

Подробнее о повторных попытках читайте в Синхронное восстановление после ошибок и Footguns.

8.7.1. Варианты реализации для номеров версий

Существует три варианта реализации номеров версий:

  1. version_number живет в домене; мы добавляем его в конструктор Product, а Product.allocate() отвечает за его увеличение.

  2. Это может сделать сервисный уровень! Номер версии не является строго вопросом домена, поэтому вместо этого наш сервисный уровень может считать, что текущий номер версии прикреплен к Product хранилищем, и сервисный уровень увеличит его перед выполнением commit().

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

Вариант 3 не идеален, потому что нет реального способа сделать это, не предполагая, что все продукты изменились, поэтому мы будем увеличивать номера версий, когда это не нужно.сноска:[Возможно, мы могли бы использовать какую-то магию ORM/SQLAlchemy, чтобы сообщить нам, когда объект грязный, но как это будет работать в общем случае - например, для CsvRepository?].

Вариант 2 подразумевает смешение ответственности за изменение состояния между сервисным уровнем и уровнем домена, поэтому он также слегка чумазый.

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

Example 87. Выбранный нами агрегат, Product (src/allocation/domain/model.py)
class Product:

    def __init__(self, sku: str, batches: List[Batch], version_number: int = 0):  (1)
        self.sku = sku
        self.batches = batches
        self.version_number = version_number  (1)

    def allocate(self, line: OrderLine) -> str:
        try:
            batch = next(
                b for b in sorted(self.batches) if b.can_allocate(line)
            )
            batch.allocate(line)
            self.version_number += 1  (1)
            return batch.reference
        except StopIteration:
            raise OutOfStock(f'Нет в наличии для sku {line.sku}')
1 Вот оно!
Если вы ломаете голову над вопросом о номере версии, возможно, стоит вспомнить, что номер не важен. Важно то, что строка базы данных Product изменяется всякий раз, когда мы вносим изменения в агрегат Product. Номер версии - это простой, понятный человеку способ моделирования всякой разной хрени, которая изменяется при каждой записи, но он также может быть случайным UUID каждый раз.

8.8. Тестирование на соответствие нашим правилам целостности данных

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

Во-первых, давайте смоделируем "медленную" транзакцию с использованием функции, которая выполняет распределение, а затем впадает в спячку:[30]

Example 88. time.sleep может воспроизводить поведение параллелизма (tests/integration/test_uow.py)
def try_to_allocate(orderid, sku, exceptions):
    line = model.OrderLine(orderid, sku, 10)
    try:
        with unit_of_work.SqlAlchemyUnitOfWork() as uow:
            product = uow.products.get(sku=sku)
            product.allocate(line)
            time.sleep(0.2)
            uow.commit()
    except Exception as e:
        print(traceback.format_exc())
        exceptions.append(e)

Затем мы заставляем наш тест вызывать это медленное распределение дважды, одновременно, используя потоки:

Example 89. Интеграционный тест на поведение параллелизма (tests/integration/test_uow.py)
def test_concurrent_updates_to_version_are_not_allowed(postgres_session_factory):
    sku, batch = random_sku(), random_batchref()
    session = postgres_session_factory()
    insert_batch(session, batch, sku, 100, eta=None, product_version=1)
    session.commit()

    order1, order2 = random_orderid(1), random_orderid(2)
    exceptions = []  # type: List[Exception]
    try_to_allocate_order1 = lambda: try_to_allocate(order1, sku, exceptions)
    try_to_allocate_order2 = lambda: try_to_allocate(order2, sku, exceptions)
    thread1 = threading.Thread(target=try_to_allocate_order1)  (1)
    thread2 = threading.Thread(target=try_to_allocate_order2)  (1)
    thread1.start()
    thread2.start()
    thread1.join()
    thread2.join()

    [[version]] = session.execute(
        "SELECT version_number FROM products WHERE sku=:sku",
        dict(sku=sku),
    )
    assert version == 2  (2)
    [exception] = exceptions
    assert 'could not serialize access due to concurrent update' in str(exception)  (3)

    orders = list(session.execute(
        "SELECT orderid FROM allocations"
        " JOIN batches ON allocations.batch_id = batches.id"
        " JOIN order_lines ON allocations.orderline_id = order_lines.id"
        " WHERE order_lines.sku=:sku",
        dict(sku=sku),
    ))
    assert len(orders) == 1  (4)
    with unit_of_work.SqlAlchemyUnitOfWork() as uow:
        uow.session.execute('select 1')
1 Мы запускаем два потока, которые надежно обеспечат нужное нам поведение параллелизма: read1, read2, write1, write2.
2 Мы утверждаем, что номер версии был увеличен только один раз.
3 При желании мы можем проверить конкретное исключение.
4 И мы дважды проверяем, что прошло только одно распределение.

8.8.1. Обеспечение правил параллелизма с помощью транзакций базы данных Isolation Levels

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

Example 90. Установить уровень изоляции для сеанса (src/allocation/service_layer/unit_of_work.py)
DEFAULT_SESSION_FACTORY = sessionmaker(bind=create_engine(
    config.get_postgres_uri(),
    isolation_level="REPEATABLE READ",
))
Уровни изоляции транзакций - сложная штука, поэтому стоит потратить время на понимание https://oreil.ly/5vxJA [документация Postgres].[31]

8.8.2. Пример пессимистического управления параллелизмом: SELECT FOR UPDATE

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

SELECT FOR UPDATE это способ выбора строки или строк для использования в качестве блокировки (хотя эти строки не обязательно должны быть теми, которые вы обновляете). Если две транзакции одновременно попытаются SELECT FOR UPDATE-ть строки, одна из них выиграет, а другая будет ждать, пока блокировка не будет освобождена. Таким образом, это пример пессимистического управления параллелизмом.

Вот как можно использовать SQLAlchemy DSL для указания FOR UPDATE во время запроса:

Example 91. SQLAlchemy with_for_update (src/allocation/adapters/repository.py)
    def get(self, sku):
        return self.session.query(model.Product) \
                           .filter_by(sku=sku) \
                           .with_for_update() \
                           .first()

Это приведет к изменению шаблона параллелизма с

to

Можно встретить термин, который определяет это режимом сбоя "read-modify-write". Прочитай "PostgreSQL Anti-Patterns: Read-Modify-Write Cycles" для хорошего, но быстрого ознакомления.

У нас действительно нет времени обсуждать все компромиссы между "ПОВТОРЯЕМЫМ ЧТЕНИЕМ" и "ВЫБОРОМ ДЛЯ ОБНОВЛЕНИЯ" или оптимистичной и пессимистичной блокировкой в целом. Но если у вас есть тест, подобный тому, который мы показали, вы можете указать желаемое поведение и посмотреть, как оно изменится. Вы также можете использовать тест в качестве основы для проведения некоторых экспериментов по производительности.

8.9. Подведение итогов

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

Выбор правильного агрегата является ключевым, и это решение вы можете пересмотреть со временем. Вы можете прочитать больше об этом в нескольких книгах DDD. Мы также рекомендуем эти три онлайн-статьи по эффективной конструкции агрегата автор: Vaughn Vernon (автор "красной книги" ).

Aggregates: компромиссы есть некоторые мысли о компромиссах реализации агрегированного шаблона.

Table 4. Aggregates: компромиссы
Плюсы Минусы
  • Python может не иметь "официальных" публичных и частных методов, но у нас есть соглашение о подчеркивании, потому что часто полезно попытаться указать, что предназначено для "внутреннего" использования, а что для "внешнего кода". Выбор агрегатов-это просто следующий уровень: он позволяет вам решить, какие из ваших классов модели домена являются общедоступными, а какие нет.

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

  • Возложение на агрегат ответственности за изменения состояния его дочерних моделей делает систему более простой для рассуждений и облегчает контроль инвариантов.

  • Yet another new concept for new developers to take on. Explaining entities versus value objects was already a mental load; now there’s a third type of domain model object? Еще одна новая концепция для начинающих разработчиков. Объяснение entities в сравнении с value objects уже было непосильной умственной нагрузкой; теперь есть третий тип объекта доменной модели?

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

  • Решение проблемы возможной согласованности между агрегатами может быть сложным.

Обобщение агрегатов и границ согласованности

Агрегаты - это ваши точки входа в доменную модель

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

Агрегаты отвечают за границу согласованности

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

Агрегаты и проблемы параллелизма идут рука об руку

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

8.10. Part I Recap

Помните Диаграмма компонентов для нашего приложения в конце части I, диаграмму, которую мы показывали в начале [части 1], чтобы просмотреть, куда мы направляемся?

apwp 0705
Figure 28. Диаграмма компонентов для нашего приложения в конце части I

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

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

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

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

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

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

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

Событийно-Ориентированная архитектура

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

Главная идея-это "messaging обмен сообщениями"…​.Ключ к созданию больших и растущих систем гораздо больше заключается в том, чтобы спроектировать, как его модули взаимодействуют, а не в том, какими должны быть их внутренние свойства и поведение.

— Alan Kay

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

Столкнувшись с этим требованием, многие команды обращаются к микросервисам, интегрированным через HTTP API. Но если они не будут осторожны, то в конечном итоге создадут самый хаотичный беспорядок из всех: распределенный БОЛЬШОЙ ШАР ГРЯЗИ.

В части II мы покажем, как методы из [части 1] могут быть распространены на распределенные системы. Мы уменьшим масштаб, чтобы посмотреть, как мы можем составить систему из множества небольших компонентов, которые взаимодействуют посредством асинхронной передачи сообщений.

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

apwp 0102ru
Figure 29. Но как именно все эти системы будут общаться друг с другом?

Мы рассмотрим следующие шаблоны и техники:

События домена

Триггерные рабочие процессы, которые пересекают границы консистенции.

Шина сообщений

Обеспечивает унифицированный способ вызова случаев использования из любой конечной точки.

CQRS

Разделяет операций чтения и записи позволяет избежать неудобных компромиссов в событийно-управляемой архитектуре и повысить производительность и масштабируемость.

Плюс, мы добавим каркас для инъекций зависимостей. Это не имеет ничего общего с архитектурой, основанной на событиях как таковой, но она убирает очень много свободных концовок.

9. События и шина сообщений

Итак, мы потратили кучу времени и энергии на простую проблему, которую мы могли бы легко решить с помощью Django. Возможно, вы задаётесь вопросом, действительно ли повышенная тестируемость и выразительность стоят всех усилий?!

Однако на практике мы обнаруживаем, что не очевидные функции создают беспорядок в наших кодовых базах: это нечто липкое и тупое скопившееся по краю. Это отчеты, разрешения и рабочие процессы, которые затрагивают миллион объектов.

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

В случае первой версии, наш владелец только должен отправить предупреждение по электронной почте.

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

Мы начнём с самого простого, самого быстрого решения и дальше поговорим о том, почему именно такое решение приводит нас к Большому Комку грязи.

Затем мы покажем, как использовать шаблон Domain Events для отделения побочных эффектов от наших вариантов использования, и как использовать простой шаблон Message Bus для запуска поведения на основе этих событий. Мы покажем несколько вариантов для создания этих событий и того, как передать их в шину сообщений, и, наконец, мы покажем, как можно изменить шаблон Unit of Work, чтобы элегантно соединить их вместе, как показано в <<message_bus_diagram> >.

apwp 0801
Figure 30. События, протекающие через систему

Код этой главы находится в ветке chapter_08_events_and_message_bus на GitHub:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_08_events_and_message_bus
# или, чтобы дальше кодировать вместе, проверьте предыдущую главу:
git checkout chapter_07_aggregate

9.1. Как избежать беспорядка

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

9.1.1. Во-первых, давайте не будем путать наши веб-контроллеры

В качестве одноразового взлома, это может быть допустимо:

Example 92. Просто сунь его в endpoint—что может пойти не так? (src/allocation/entrypoints/flask_app.py)

…​но легко понять, как мы можем быстро попасть в переделку, если так всё сделать. Отправка электронной почты не является задачей нашего HTTP-уровня, и мы хотели бы иметь возможность протестировать эту новую функцию.

9.1.2. И давайте не будем портить нашу модель

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

Example 93. Код отправки электронной почты в нашей модели тоже не очень хорош (src/allocation/domain/model.py)
    def allocate(self, line: OrderLine) -> str:
        try:
            batch = next(
                b for b in sorted(self.batches) if b.can_allocate(line)
            )
            #...
        except StopIteration:
            email.send_mail('stock@made.com', f'Out of stock for {line.sku}')
            raise OutOfStock(f'Out of stock for sku {line.sku}')

Но это еще хуже! Мы не хотим, чтобы наша модель имела какие-либо зависимости от инфраструктурных проблем, таких как email.send_mail.

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

9.1.3. Или уровень обслуживания!

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

Мы написали сервисный уровень для управления оркестровкой для нас, но даже здесь эта функция кажется неуместной:

Example 94. И на сервисном уровне, это неуместно (src/allocation/service_layer/services.py)
def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=line.sku)
        if product is None:
            raise InvalidSku(f'Invalid sku {line.sku}')
        try:
            batchref = product.allocate(line)
            uow.commit()
            return batchref
        except model.OutOfStock:
            email.send_mail('stock@made.com', f'Out of stock for {line.sku}')
            raise

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

9.2. Single Responsibility Principle

На самом деле, это нарушение принципа единственной ответственности (SRP) .footnote: [Этот принцип — S в SOLID.] Наш пример использования — распределение. Наша конечная точка, служебная функция и методы домена называются [.keep-together] allocate, а не allocate_and_send_mail_if_out_of_stock.

Эмпирическое правило: если вы не можете описать, что делает ваша функция, не используя такие слова, как "тогда" или "и", вы можете нарушить SRP.

Согласно одной из формулировок SRP, у каждого класса должна быть только одна причина для изменения. Когда мы переключаемся с электронной почты на SMS, нам не нужно обновлять нашу функцию allocate(), потому что это явно отдельная ответственность.

Чтобы решить эту проблему, мы разделим оркестровку на отдельные этапы, чтобы различные проблемы не перепутались.[32] Задача модели домена состоит в том, чтобы знать, что у нас нет запасов, но ответственность за отправку предупреждения лежит на другом месте. Мы должны иметь возможность включать или выключать эту функцию или переключаться на SMS-уведомления вместо этого, не меняя правила нашей доменной модели.

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

9.3. Все на борт автобуса Сообщений!

Шаблоны, которые мы собираемся здесь представить, - это Domain Events События домена и Message Bus Шина сообщений. Мы можем реализовать их несколькими способами, поэтому мы покажем пару, прежде чем остановимся на том, который нам больше всего нравится.

9.3.1. Модель Записывает События

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

9.3.2. События (events) - это простые классы данных

event-это своего рода value object. События не имеют никакого поведения, потому что они являются чистыми структурами данных. Мы всегда называем события на языке домена и думаем о них как о части нашей модели домена.

Мы могли бы хранить их в model.py, но мы также можем хранить их в отдельном файле. (возможно, сейчас самое подходящее время подумать о рефакторинге каталога с именем domain, чтобы у нас был domain/model.py и domain/events.py):

Example 95. Классы событий (src/allocation/domain/events.py)
from dataclasses import dataclass

class Event:  (1)
    pass

@dataclass
class OutOfStock(Event):  (2)
    sku: str
1 Как только у нас будет несколько событий, нам будет полезно иметь родительский класс, который может хранить общие атрибуты. Это также полезно для подсказок типа в нашей шине сообщений, как вы вскоре увидите.
2 dataclasses отлично подходят и для доменных событий.

9.3.3. Модель вызывает события

Когда наша модель домена фиксирует факт, который произошел, мы говорим, что это raises (поднимает) событие.

Вот как это будет выглядеть со стороны; если мы попросим "Product" распределить ( allocate ), но он не сможет, он должен raise (поднять) событие:

Example 96. Протестируйте наш агрегат, чтобы поднять события (tests/unit/test_product.py)
def test_records_out_of_stock_event_if_cannot_allocate():
    batch = Batch('batch1', 'SMALL-FORK', 10, eta=today)
    product = Product(sku="SMALL-FORK", batches=[batch])
    product.allocate(OrderLine('order1', 'SMALL-FORK', 10))

    allocation = product.allocate(OrderLine('order2', 'SMALL-FORK', 1))
    assert product.events[-1] == events.OutOfStock(sku="SMALL-FORK")  (1)
    assert allocation is None
1 Наш агрегат предоставит новый атрибут под названием .events, который будет содержать список фактов о том, что произошло, в форме объектов Event.

Вот как выглядит модель изнутри:

Example 97. Модель вызывает событие предметной области (src/allocation/domain/model.py)
class Product:

    def __init__(self, sku: str, batches: List[Batch], version_number: int = 0):
        self.sku = sku
        self.batches = batches
        self.version_number = version_number
        self.events = []  # type: List[events.Event]  (1)

    def allocate(self, line: OrderLine) -> str:
        try:
            #...
        except StopIteration:
            self.events.append(events.OutOfStock(line.sku))  (2)
            # raise OutOfStock(f'Out of stock for sku {line.sku}')  (3)
            return None
1 Вот наш новый атрибут .events.
2 Вместо того, чтобы напрямую вызывать какой-либо код отправки электронной почты, мы записываем эти события в том месте, где они происходят, используя только язык домена.
3 Мы также собираемся прекратить создавать исключение для случая отсутствия на складе. Событие выполнит ту работу, которую выполняло исключение.
На самом деле мы "принюхиваемся" к коду, который мы рассматривали до сих пор, а именно к тому, что обсуждается в использование исключений для потока управления. В общем случае, если вы реализуете доменные события, не создавайте исключений для описания одной и той же концепции домена. Как вы увидите позже, когда мы будем обрабатывать события в шаблоне Unit of Work, это сбивает с толку, когда приходится рассуждать о совместном использовании событий и исключений.

9.3.4. Шина сообщений сопоставляет События(Events) с Обработчиками(Handlers)

Шина сообщений в основном говорит: "Когда я вижу это событие, я должен вызвать следующую функцию обработчика". Другими словами, это простая система подписки на публикации. Обработчики подписаны (subscribed) на получение событий, которые мы размещаем в шине. Это звучит сложнее, чем есть на самом деле, и мы обычно реализуем это с помощью dict:

Example 98. Simple message bus (src/allocation/service_layer/messagebus.py)
def handle(event: events.Event):
    for handler in HANDLERS[type(event)]:
        handler(event)


def send_out_of_stock_notification(event: events.OutOfStock):
    email.send_mail(
        'stock@made.com',
        f'Out of stock for {event.sku}',
    )


HANDLERS = {
    events.OutOfStock: [send_out_of_stock_notification],

}  # type: Dict[Type[events.Event], List[Callable]]
Обратите внимание, что реализованная шина сообщений не дает нам параллелизма, потому что одновременно будет работать только один обработчик. Наша цель состоит не в том, чтобы поддерживать параллельные потоки, а в том, чтобы концептуально разделить задачи и сделать каждый UoW как можно меньше. Это помогает нам понять кодовую базу, потому что "рецепт" для запуска каждого варианта использования написан в одном месте. См. следующую боковую панель.
Это как Celery?

Celery — это популярный в мире Python инструмент для переноса автономных фрагментов работы в асинхронную очередь задач. Шина сообщений, которую мы представляем здесь, очень отличается, поэтому короткий ответ на вышеприведенный вопрос-нет; наша шина сообщений имеет больше общего с Express.js приложение, цикл событий пользовательского интерфейса или структура актера.

Если у вас есть необходимость перенести работу из основного потока, вы все еще можете использовать наши event-based metaphors (метафоры, основанные на событиях), но мы предлагаем вам использовать для этого external events. Это более подробно обсуждается в Интеграция микросервисов на основе событий: компромиссы, но по сути, если вы реализуете способ сохранения событий в централизованном хранилище, вы можете подписаться на другие контейнеры или другие микросервисы. Затем та же самая концепция использования событий для разделения обязанностей между единицами работы в рамках одного process/service может быть распространена на несколько процессов, которые могут быть различными контейнерами в рамках одной и той же службы или совершенно разными микросервисами.

Если вы следуете нашему подходу, ваш API для распределения задач-это ваше событие классы—или их JSON-представление. Это дает вам большую гибкость в том, кому вы распределяете задачи; они не обязательно должны быть службами Python. Celery’s API для распределения задач — это, по сути, "имя функции плюс аргументы", что является более ограничительным и только для Python.

9.4. Вариант 1. Уровень сервиса Принимает События из Модели и Помещает их в Шину сообщений

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

Самый простой способ сделать это — добавить код в наш сервисный слой:

Example 99. Уровень обслуживания с явной шиной сообщений (src/allocation/service_layer/services.py)
from . import messagebus
...

def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=line.sku)
        if product is None:
            raise InvalidSku(f'Invalid sku {line.sku}')
        try:  (1)
            batchref = product.allocate(line)
            uow.commit()
            return batchref
        finally:  (1)
            messagebus.handle(product.events)  (2)
1 Мы сохраняем try/finally из нашей уродливой более ранней реализации (мы еще не избавились от всех исключений, просто OutOfStock).
2 Но теперь, вместо того чтобы напрямую зависеть от инфраструктуры электронной почты, уровень сервиса отвечает только за передачу событий от модели до шины сообщений.

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

9.5. Вариант 2: Уровень Сервиса Создает Свои Собственные События

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

Example 100. Service layer calls messagebus.handle directly (src/allocation/service_layer/services.py)
1 Как и раньше, мы коммитим событие, даже если ничего не можем зарезервировать, потому что код таким образом проще и легче понимать: мы всегда фиксируем, если что-то не идет не так. Фиксация, когда мы ничего не изменили, безопасна и сохраняет код незагроможденным.

Опять же, у нас есть приложения в производстве (production), которые реализуют шаблон таким образом. То, что работает для вас, будет зависеть от конкретных компромиссов, с которыми вы столкнётесь, но мы хотели бы показать вам, что мы считаем наиболее элегантным решением, в котором мы помещаем единицу работы, отвечающую за сбор и обработку событий.

9.6. Вариант 3: UoW публикует события в шине сообщений

У UoW уже есть блок try/finally, и он знает обо всех агрегатах, находящихся в данный момент в игре, потому что он предоставляет доступ к репозиторию. Так что это хорошее место для обнаружения событий и передачи их в шину сообщений:

Example 101. UoW обеспечивает шину сообщений (src/allocation/service_layer/unit_of_work.py)
class AbstractUnitOfWork(abc.ABC):
    ...

    def commit(self):
        self._commit()  (1)
        self.publish_events()  (2)

    def publish_events(self):  (2)
        for product in self.products.seen:  (3)
            while product.events:
                event = product.events.pop(0)
                messagebus.handle(event)

    @abc.abstractmethod
    def _commit(self):
        raise NotImplementedError

...

class SqlAlchemyUnitOfWork(AbstractUnitOfWork):
    ...

    def _commit(self):  (1)
        self.session.commit()
1 Мы изменим наш метод фиксации, чтобы запросить частный метод ._commit() из подклассов.
2 После фиксации мы прогоняем все объекты, которые воспринял наш репозиторий, и передаем их события в шину сообщений.
3 Это зависит от репозитория, отслеживающего агрегаты, которые были загружены с использованием нового атрибута, .seen, как вы увидите в следующем листинге.
Вам интересно, что произойдет, если один из обработчиков выйдет из строя? Мы подробно обсудим обработку ошибок в Команды и Обработчики команд.
Example 102. Репозиторий отслеживает агрегаты, которые проходят через него (src/allocation/adapters/repository.py)
class AbstractRepository(abc.ABC):

    def __init__(self):
        self.seen = set()  # type: Set[model.Product]  (1)

    def add(self, product: model.Product):  (2)
        self._add(product)
        self.seen.add(product)

    def get(self, sku) -> model.Product:  (3)
        product = self._get(sku)
        if product:
            self.seen.add(product)
        return product

    @abc.abstractmethod
    def _add(self, product: model.Product):  (2)
        raise NotImplementedError

    @abc.abstractmethod  (3)
    def _get(self, sku) -> model.Product:
        raise NotImplementedError



class SqlAlchemyRepository(AbstractRepository):

    def __init__(self, session):
        super().__init__()
        self.session = session

    def _add(self, product):  (2)
        self.session.add(product)

    def _get(self, sku):  (3)
        return self.session.query(model.Product).filter_by(sku=sku).first()
1 Чтобы UoW мог публиковать новые события, он должен иметь возможность запрашивать репозиторий, для каких объектов Product использовались во время этого сеанса. Мы используем set под названием` .seen` для их хранения. Это означает, что наши реализации должны вызывать super().__ init __() .
2 Родительский метод add() добавляет элементы в .seen и теперь требует jn подклассов реализацию ._add().
3 Аналогично, .get() делегирует функцию ._get (), которая должна быть реализована подклассами, чтобы захватить видимые объекты.
Использование методов ._underscorey() и подклассов определенно не является единственным способом реализации этих шаблонов. Попробуйте воспользоваться "Упражнения для читателя" в этой главе и поэкспериментируйте с некоторыми альтернативами.

После того, как UoW и репозиторий будут сотрудничать таким образом, чтобы автоматически отслеживать живые объекты и обрабатывать их события, уровень сервиса может быть полностью свободен от проблем с обработкой событий:

Example 103. Сервисный слой снова чист (src/allocation/service_layer/services.py)
def allocate(
        orderid: str, sku: str, qty: int,
        uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(orderid, sku, qty)
    with uow:
        product = uow.products.get(sku=line.sku)
        if product is None:
            raise InvalidSku(f'Invalid sku {line.sku}')
        batchref = product.allocate(line)
        uow.commit()
        return batchref

Мы также должны помнить, что надо изменить фейки в сервисном слое и заставить их вызывать super() в нужных местах, а также реализовать методы c двойным подчёркиванием ("str","repr"), но изменения минимальны:

Example 104. Фейки сервисного уровня нуждаются в настройке (tests/unit/test_services.py)
class FakeRepository(repository.AbstractRepository):

    def __init__(self, products):
        super().__init__()
        self._products = set(products)

    def _add(self, product):
        self._products.add(product)

    def _get(self, sku):
        return next((p for p in self._products if p.sku == sku), None)

...

class FakeUnitOfWork(unit_of_work.AbstractUnitOfWork):
    ...

    def _commit(self):
        self.committed = True
Упражнения для читателя

Вы находите все эти методы ._add () и ._commit() "супер-навороченными", по словам нашего любимого технического обозревателя Хайнека? Это "возбудит у вас желание шмякнуть Гарри по голове плюшевой змеей"? Эй, наши куски кода предназначены только для примеров, а не для идеального решения! Почему бы не пойти и не глянуть, сможешь ли ты сделать лучше?

Одним из способов пеподнять композицию над наследованием было бы реализовать класс-декоратор:

Example 105. Обертка добавляет функциональность, а затем делегирует (src/adapters/repository.py)
1 Обернув репозиторий, мы можем вызывать фактические методы .add () и .get (), избегая волшебных методов с двойным подчёркиванием.

Посмотрите, можете ли вы применить аналогичный шаблон к нашему классу UoW, чтобы избавиться и от тех Java-подобных методов _commit(). Вы можете найти код наhttps://github.com/cosmicpython/code/tree/chapter_08_events_and_message_bus_exercise[GitHub].

Переключение всех ABC на typing.Protocol - хороший способ заставить себя избегайте использования наследования. Дайте нам знать, если у вас получится что-нибудь приятное!

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

9.7. Подведение итогов

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

Волшебные слова "When X, then Y" часто говорят нам о событии, которое мы можем сделать конкретным в нашей системе. Рассматривая события как first-class вещи в нашей модели, мы делаем наш код более тестируемым и наблюдаемым, а также изолируем проблемы.

И Domain events: компромиссы показывает компромиссы, как мы их видим.

Table 5. Domain events: компромиссы
Плюсы Минусы
  • Шина сообщений (message bus) дает нам хороший способ разделить обязанности, когда мы должны предпринять несколько действий в ответ на запрос.

  • Обработчики событий (Event handlers) хорошо отделены от "основной" логики приложения, что позволяет легко изменить их реализацию позже.

  • Доменные события (Domain events) — это отличный способ моделирования реального мира, и мы можем использовать их как часть нашего делового языка при моделировании с заинтересованными сторонами.

  • Шина сообщений — это еще одна вещь, которая кружит вам голову. Реализация, в которой единица работы вызывает для нас события, может это изящно и волшебно. Но, когда мы вызываем commit, не очевидно, что мы также собираемся отправить электронное письмо людям.

  • Более того, этот скрытый код обработки событий выполняется synchronously, что означает, что ваша функция уровня сервиса не завершится до тех пор, пока не будут завершены все обработчики для любых событий. Это может привести к неожиданным проблемам с производительностью в ваших web endpoints (adding asynchronous processing is possible but makes things even more confusing).

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

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

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

Если у нас есть две вещи, которые могут быть транзакционно изолированы (например, заказ и product), то мы можем сделать их eventually consistent (в конечном итоге согласованными) с помощью событий. Когда заказ отменяется, мы должны найти продукты, которые были ему назначены, и удалить allocations.

Обзор Событий домена и шины сообщений

События способствуют реализации принципа единой ответственности

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

Шина сообщений направляет сообщения обработчикам

Вы можете думать о шине сообщений как о словаре, который сопоставляет события (events) с их потребителями(consumers). Словарь ничего не "знает" о смысле событий; это просто кусок тупой инфраструктуры для передачи сообщений по всей системе.

Вариант 1: Уровень сервиса вызывает события и передает их в шину сообщений

Самый простой способ начать использовать события в вашей системе-это вызвать их из обработчиков, вызвав bus.handle(some_new_event) после того, как вы зафиксируете свою единицу работы.

Вариант 2: Доменная модель вызывает события, сервисный уровень передает их в шину сообщений

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

Вариант 3: UoW собирает события из агрегатов и передает их в шину сообщений

Добавление bus.handle (aggregate.events) к каждому обработчику раздражает, поэтому мы можем прибраться, сделав нашу единицу работы ответственной за создание событий, которые были вызваны загруженными объектами. Это наиболее сложный дизайн, и он может полагаться на магию ORM, но после настройки он понятен и прост в использовании.

В Едем в город на Мессагобусе мы рассмотрим эту идею более подробно при построении более сложного рабочего процесса с нашей новой шиной сообщений.

10. Едем в город на Мессагобусе

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

apwp 0901
Figure 31. Раньше: шина сообщений являлась необязательным дополнением

…​к ситуации в Шина сообщений теперь является основной точкой входа на уровень сервиса., где всё идет по шине сообщений, а наше приложение было принципиально преобразовано в процессор сообщений.

apwp 0902
Figure 32. Шина сообщений теперь является основной точкой входа на уровень сервиса.

Код для этой главы находится в ветке chapter_09_all_messagebus. на GitHub:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_09_all_messagebus
# или, чтобы кодировать вместе, проверьте предыдущую главу:
git checkout chapter_08_events_and_message_bus

10.1. Новое требование приводит нас к новой архитектуре

Рич Хикки говорит о situated software, то есть о программном обеспечении, которое работает в течение длительных периодов времени, управляя реальным процессом. Примеры включают системы управления складом, логистические планировщики и системы расчета заработной платы.

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

  • Во время инвентаризации мы обнаруживаем, что три <код>ПРУЖИННЫЙ МАТРАСбыли повреждены водой из-за протекающей крыши.

  • У груза RELIABLE-FORKs отсутствует необходимая документация и он застрял на таможне, где находится уже в течение нескольких недель. Три RELIABLE-FORKs впоследствии не проходят проверку безопасности и уничтожаются.

  • Глобальная нехватка блесток означает, что мы не сможем изготовить следующую партию SPARKLY-BOOKCASE.

В таких ситуациях мы узнаем о необходимости изменения количества партий, когда они уже находятся в системе. Может быть, кто-то ошибся номером в декларации, а может быть, какие-то диваны упали с грузовика. После разговора с представителями компании[33] мы моделируем ситуацию, как на Изменение количества партии означает освобождение и перераспределение (deallocate and reallocate).

apwp 0903
Figure 33. Изменение количества партии означает освобождение и перераспределение (deallocate and reallocate)
[ditaa, apwp_0903]
+----------+    /----\      +------------+       +--------------------+
| Batch    |--> |RULE| -->  | Deallocate | ----> | AllocationRequired |
| Quantity |    \----/      +------------+-+     +--------------------+-+
| Changed  |                  | Deallocate | ----> | AllocationRequired |
+----------+                  +------------+-+     +--------------------+-+
                                | Deallocate | ----> | AllocationRequired |
                                +------------+       +--------------------+

Событие, которое мы назовем BatchQuantityChanged, должно привести нас к изменению количества в партии, да, но также и к применению бизнес правила: если новое количество опускается до меньшего, чем уже распределённое общее количество, нам нужно dealocate освободить места в этих заказы из этой партии. Затем каждый из них потребует нового распределения, которое мы можем зафиксировать как событие под названием AllocationRequired Требуемое распределение.

Возможно, вы уже ожидаете, что наша внутренняя шина сообщений и события помогут реализовать это требование. Мы могли бы определить службу под названием change_batch_quantity, которая знает, как корректировать количество партий, а также как deallocate (освобождать) любые избыточные строки заказа, а затем каждое освобождение может генерировать событие AllocationRequired, которое может быть перенаправлено существующей службе allocate (распределения) в отдельные транзакции. И снова наша шина сообщений помогает нам обеспечивать соблюдение принципа единой ответственности и позволяет нам делать выбор в отношении транзакций и целостности данных.

10.1.1. Представим себе изменение архитектуры: всё будет event handler Обработчик событий

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

  • Вызовы API, которые обрабатываются функцией уровня сервиса

  • Внутренние события (которые могут быть вызваны как побочный эффект функции уровня сервиса) и их обработчики (которые, в свою очередь, вызывают функции уровня сервиса)

Разве не было бы проще, если бы все было обработчиком событий? Если мы переосмыслим наши вызовы API как захват событий, функции уровня сервиса также могут быть обработчиками событий, и нам больше не нужно проводить различие между внутренними и внешними обработчиками событий:

  • services.allocate() может быть обработчиком события AllocationRequired и может выдавать события Allocated в качестве его выходных данных.

  • services.add_batch() может быть обработчиком события BatchCreated.[34]

Наше новое требование будет соответствовать той же схеме:

  • Событие под названием BatchQuantityChanged может вызывать обработчик под названием change_batch_quantity().

  • И новые события AllocationRequired, которые он может вызвать, также могут быть переданы в `services.allocate() `, поэтому нет концептуальной разницы между совершенно новым распределением, исходящим от API, и перераспределением, которое запускается изнутри deallocate.

Все это звучит как-то чересчур? Давайте работать над всем этим постепенно. Мы будем следовать за рабочим процессом Подготовительный рефакторинг, он же гласит "Сделайте изменение легким; затем сделайте легкое изменение":

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

  2. Мы создаем сквозной тест,который помещает события BatchQuantityChanged в систему и принимает выходящие события Allocated.

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

По пути мы сделаем небольшую настройку шины сообщений и UoW, перенеся ответственность за размещение (put) новых событий на шине сообщений в саму шину сообщений.

10.2. Рефакторинг сервисных функций для обработчиков сообщений

Мы начинаем с определения двух событий, которые фиксируют наши текущие входные данные API - AllocationRequired и BatchCreated:

Example 106. События BatchCreated и AllocationRequired (src/allocation/domain/events.py)
@dataclass
class BatchCreated(Event):
    ref: str
    sku: str
    qty: int
    eta: Optional[date] = None

...

@dataclass
class AllocationRequired(Event):
    orderid: str
    sku: str
    qty: int

Затем мы переименовываем services.py в handlers.py; мы добавляем обработчик текущих сообщений для send_out_of_stock_notification; и самое главное, мы меняем все обработчики так, чтобы у них были одинаковые входные данные, событие и UoW:

Example 107. Обработчики и сервисы - это одно и то же (src/allocation/service_layer/handlers.py)
def add_batch(
        event: events.BatchCreated, uow: unit_of_work.AbstractUnitOfWork
):
    with uow:
        product = uow.products.get(sku=event.sku)
        ...


def allocate(
        event: events.AllocationRequired, uow: unit_of_work.AbstractUnitOfWork
) -> str:
    line = OrderLine(event.orderid, event.sku, event.qty)
    ...


def send_out_of_stock_notification(
        event: events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork,
):
    email.send(
        'stock@made.com',
        f'Out of stock for {event.sku}',
    )

Это изменение станет более ясным если помотреть на различие:

Example 108. Переход от сервисов к обработчикам (src/allocation/service_layer/handlers.py)
 def add_batch(
-        ref: str, sku: str, qty: int, eta: Optional[date],
-        uow: unit_of_work.AbstractUnitOfWork
+        event: events.BatchCreated, uow: unit_of_work.AbstractUnitOfWork
 ):
     with uow:
-        product = uow.products.get(sku=sku)
+        product = uow.products.get(sku=event.sku)
     ...


 def allocate(
-        orderid: str, sku: str, qty: int,
-        uow: unit_of_work.AbstractUnitOfWork
+        event: events.AllocationRequired, uow: unit_of_work.AbstractUnitOfWork
 ) -> str:
-    line = OrderLine(orderid, sku, qty)
+    line = OrderLine(event.orderid, event.sku, event.qty)
     ...

+
+def send_out_of_stock_notification(
+        event: events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork,
+):
+    email.send(
     ...

Попутно мы сделали API нашего сервисного уровня более структурированным и последовательным. Это было рассеяние примитивов, и теперь используются четко определенные объекты (см. Следующую главу).

От Domain Objects через Primitive Obsession к событиям в качестве интерфейса

Некоторые из вас, возможно, помнят Полное отделение тестов уровня сервиса от домена, в котором мы изменили наш API сервисного уровня с точки зрения доменных объектов на примитивы. А теперь мы возвращаемся назад, но к другим объектам? Что это дает?

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

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

Итак, мы отступили? Ну, наши основные объекты модели предметной области по-прежнему свободны варьироваться, но вместо этого мы связали внешний мир с нашими классами событий. Они тоже часть домена, но есть надежда, что они меняются реже, так что они разумный артефакт для пары.

И что мы приобрели? Теперь при вызове варианта использования в нашем приложении нам больше не нужно запоминать конкретную комбинацию примитивов, а только один класс событий, представляющий входные данные для нашего приложения. Это концептуально довольно мило. Кроме того, как вы увидите в Validation, эти классы событий могут быть хорошим местом для некоторой проверки входных данных.

10.2.1. The Message Bus Now Collects Events from the UoW

Наши обработчики событий теперь нуждаются в UoW. Кроме того, поскольку наша шина сообщений становится всё более центральной для нашего приложения, имеет смысл явно возложить на неё ответственность за сбор и обработку новых событий. До сих пор существовала некоторая циклическая зависимость между UoW и шиной сообщений, так что это сделает её односторонней. Вместо того, чтобы иметь события UoW push на шине сообщений, мы будем иметь события message bus pull из UoW.

Example 109. Handle принимает UoW и управляет очередью (src/allocation/service_layer/messagebus.py)
def handle(event: events.Event, uow: unit_of_work.AbstractUnitOfWork):  (1)
    queue = [event]  (2)
    while queue:
        event = queue.pop(0)  (3)
        for handler in HANDLERS[type(event)]:  (3)
            handler(event, uow=uow)  (4)
            queue.extend(uow.collect_new_events())  (5)
1 Шина сообщений теперь проходит UoW при каждом запуске.
2 Когда мы начинаем обрабатывать наше первое событие, мы запускаем очередь.
3 Мы извлекаем события из передней части очереди и вызываем их обработчики (HANDLERS dict не изменился; он по-прежнему сопоставляет типы событий с функциями обработчиков).
4 Шина сообщений передает UoW каждому обработчику.
5 После завершения каждого обработчика мы собираем все новые сгенерированные события и добавляем их в очередь.

В unit_of_work.py 'publish_events()` становится менее активным методом, collect_new_events():

Example 110. UoW больше не помещает события прямо в шину (src/allocation/service_layer/unit_of_work.py)
-from . import messagebus  (1)
-


 class AbstractUnitOfWork(abc.ABC):
@@ -23,13 +21,11 @@ class AbstractUnitOfWork(abc.ABC):

     def commit(self):
         self._commit()
-        self.publish_events()  (2)

-    def publish_events(self):
+    def collect_new_events(self):
         for product in self.products.seen:
             while product.events:
-                event = product.events.pop(0)
-                messagebus.handle(event)
+                yield product.events.pop(0)  (3)
1 Модуль unit_of_work теперь больше не зависит от messagebus.
2 Мы больше не выполняем publish_events автоматически при фиксации. Вместо этого шина сообщений отслеживает очередь событий.
3 И UoW больше не размещает активные события в шину сообщений; он просто делает их доступными.

10.2.2. Наши тесты тоже написаны в терминах событий

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

Example 111. Тесты обработчиков используют события (tests/unit/test_handlers.py)
class TestAddBatch:

     def test_for_new_product(self):
         uow = FakeUnitOfWork()
-        services.add_batch("b1", "CRUNCHY-ARMCHAIR", 100, None, uow)
+        messagebus.handle(
+            events.BatchCreated("b1", "CRUNCHY-ARMCHAIR", 100, None), uow
+        )
         assert uow.products.get("CRUNCHY-ARMCHAIR") is not None
         assert uow.committed

...

 class TestAllocate:

     def test_returns_allocation(self):
         uow = FakeUnitOfWork()
-        services.add_batch("batch1", "COMPLICATED-LAMP", 100, None, uow)
-        result = services.allocate("o1", "COMPLICATED-LAMP", 10, uow)
+        messagebus.handle(
+            events.BatchCreated("batch1", "COMPLICATED-LAMP", 100, None), uow
+        )
+        result = messagebus.handle(
+            events.AllocationRequired("o1", "COMPLICATED-LAMP", 10), uow
+        )
         assert result == "batch1"

10.2.3. Временный Наглый Взлом: шина сообщений должна возвращать результаты

Наш API и наш уровень сервиса в настоящее время хотят узнать выделенную ссылку на пакет, когда они вызывают наш обработчик allocate(). Это означает, что нам нужно временно взломать нашу шину сообщений, чтобы она возвращала события:

Example 112. Message bus returns results (src/allocation/service_layer/messagebus.py)
 def handle(event: events.Event, uow: unit_of_work.AbstractUnitOfWork):
+    results = []
     queue = [event]
     while queue:
         event = queue.pop(0)
         for handler in HANDLERS[type(event)]:
-            handler(event, uow=uow)
+            results.append(handler(event, uow=uow))
             queue.extend(uow.collect_new_events())
+    return results

Это потому, что мы смешиваем обязанности чтения и записи в нашей системе. Мы вернемся, чтобы исправить эту неприятность в Command-Query Responsibility Segregation (CQRS)[1].

10.2.4. Изменение нашего API для работы с событиями

Example 113. Diff при замене Flask на шину сообщений (src/allocation/entrypoints/flask_app.py)
 @app.route("/allocate", methods=['POST'])
 def allocate_endpoint():
     try:
-        batchref = services.allocate(
-            request.json['orderid'],  (1)
-            request.json['sku'],
-            request.json['qty'],
-            unit_of_work.SqlAlchemyUnitOfWork(),
+        event = events.AllocationRequired(  (2)
+            request.json['orderid'], request.json['sku'], request.json['qty'],
         )
+        results = messagebus.handle(event, unit_of_work.SqlAlchemyUnitOfWork())  (3)
+        batchref = results.pop(0)
     except InvalidSku as e:
1 Вместо вызова уровня сервиса с кучей примитивов, извлеченных из запроса JSON …​
2 Создаем событие.
3 Затем передаем его в шину сообщений.

И мы должны вернуться к полностью функциональному приложению, но теперь полностью управляемому событиями:

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

  • Это делает их такими же, как функции, которые мы вызываем для обработки внутренних событий, вызванных нашей моделью предметной области.

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

  • Теперь все приложение лучше всего описать как процессор сообщений или, если хотите, процессор событий. Мы поговорим об этом различии в следующей главеКоманды и Обработчики команд.

10.3. Реализация нашего нового требования (Requirement)

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

apwp 0904
Figure 34. Диаграмма последовательности для потока перераспределения
[plantuml, apwp_0904, config=plantuml.cfg]
@startuml
scale 4

API -> MessageBus : BatchQuantityChanged event

group BatchQuantityChanged Handler + Unit of Work 1
    MessageBus -> Domain_Model : change batch quantity
    Domain_Model -> MessageBus : emit AllocationRequired event(s)
end


group AllocationRequired Handler + Unit of Work 2 (or more)
    MessageBus -> Domain_Model : allocate
end

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

10.3.1. Наше новое событие

Событие, которое говорит нам, что количество партии изменилось, простое; ему просто нужна ссылка на партию и новое количество:

Example 114. Новое событие (src/allocation/domain/events.py)
@dataclass
class BatchQuantityChanged(Event):
    ref: str
    qty: int

10.4. Test-Driving нового Handler

Следуя урокам, извлеченным из Наш первый Use Case или пример использования: Flask API и Service Layer, мы можем работать на «высокой передаче» и писать наши модульные тесты на максимально возможном уровне абстракции с точки зрения событий. Вот как они могут выглядеть:

Example 115. Тесты обработчика для change_batch_quantity (tests/unit/test_handlers.py)
class TestChangeBatchQuantity:

    def test_changes_available_quantity(self):
        uow = FakeUnitOfWork()
        messagebus.handle(
            events.BatchCreated("batch1", "ADORABLE-SETTEE", 100, None), uow
        )
        [batch] = uow.products.get(sku="ADORABLE-SETTEE").batches
        assert batch.available_quantity == 100  (1)

        messagebus.handle(events.BatchQuantityChanged("batch1", 50), uow)

        assert batch.available_quantity == 50  (1)


    def test_reallocates_if_necessary(self):
        uow = FakeUnitOfWork()
        event_history = [
            events.BatchCreated("batch1", "INDIFFERENT-TABLE", 50, None),
            events.BatchCreated("batch2", "INDIFFERENT-TABLE", 50, date.today()),
            events.AllocationRequired("order1", "INDIFFERENT-TABLE", 20),
            events.AllocationRequired("order2", "INDIFFERENT-TABLE", 20),
        ]
        for e in event_history:
            messagebus.handle(e, uow)
        [batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches
        assert batch1.available_quantity == 10
        assert batch2.available_quantity == 50

        messagebus.handle(events.BatchQuantityChanged("batch1", 25), uow)

        # order1 or order2 will be deallocated, so we'll have 25 - 20
        assert batch1.available_quantity == 5  (2)
        # and 20 will be reallocated to the next batch
        assert batch2.available_quantity == 30  (2)
1 Простой случай будет тривиально легко реализовать; мы просто модифицируем количество.
2 Но если мы попытаемся изменить количество на меньшее, чем было выделено, нам нужно будет исключить по крайней мере один заказ, и перераспределить его на новую ожидаемую партию.

10.4.1. Реализация

Наш новый обработчик очень прост:

Example 116. Обработчик делегирует уровень модели (src/allocation/service_layer/handlers.py)
def change_batch_quantity(
        event: events.BatchQuantityChanged, uow: unit_of_work.AbstractUnitOfWork
):
    with uow:
        product = uow.products.get_by_batchref(batchref=event.ref)
        product.change_batch_quantity(ref=event.ref, qty=event.qty)
        uow.commit()

Мы понимаем, что нам понадобится новый тип запроса в нашем репозитории:

Example 117. Новый тип запроса в нашем репозитории (src/allocation/adapters/repository.py)
class AbstractRepository(abc.ABC):
    ...

    def get(self, sku) -> model.Product:
        ...

    def get_by_batchref(self, batchref) -> model.Product:
        product = self._get_by_batchref(batchref)
        if product:
            self.seen.add(product)
        return product

    @abc.abstractmethod
    def _add(self, product: model.Product):
        raise NotImplementedError

    @abc.abstractmethod
    def _get(self, sku) -> model.Product:
        raise NotImplementedError

    @abc.abstractmethod
    def _get_by_batchref(self, batchref) -> model.Product:
        raise NotImplementedError
    ...

class SqlAlchemyRepository(AbstractRepository):
    ...

    def _get(self, sku):
        return self.session.query(model.Product).filter_by(sku=sku).first()

    def _get_by_batchref(self, batchref):
        return self.session.query(model.Product).join(model.Batch).filter(
            orm.batches.c.reference == batchref,
        ).first()

И в нашем FakeRepository:

Example 118. Обновление фейкового репо тоже (tests/unit/test_handlers.py)
class FakeRepository(repository.AbstractRepository):
    ...

    def _get(self, sku):
        return next((p for p in self._products if p.sku == sku), None)

    def _get_by_batchref(self, batchref):
        return next((
            p for p in self._products for b in p.batches
            if b.reference == batchref
        ), None)
Мы добавляем запрос в наш репозиторий, чтобы упростить реализацию этого варианта использования. Пока наш запрос возвращает единственную совокупность, мы не нарушаем никаких правил. Если вы обнаружите, что пишете сложные запросы к своим репозиториям, возможно, вам захочется рассмотреть другой дизайн. Такие методы, как get_most_popular_products или find_products_by_order_id, в частности, определенно вызовут щекотку в области нашего шестого чувства. В Event-Driven Architecture: Использование событий для интеграции микросервисов и epilogue есть несколько советов по управлению сложными запросами.

10.4.2. Новый метод модели предметной области

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

Example 119. Наша модель развивается в соответствии с новыми требованиями (src/allocation/domain/model.py)
class Product:
    ...

    def change_batch_quantity(self, ref: str, qty: int):
        batch = next(b for b in self.batches if b.reference == ref)
        batch._purchased_quantity = qty
        while batch.available_quantity < 0:
            line = batch.deallocate_one()
            self.events.append(
                events.AllocationRequired(line.orderid, line.sku, line.qty)
            )
...

class Batch:
    ...

    def deallocate_one(self) -> OrderLine:
        return self._allocations.pop()

Подключаем наш новый обработчик:

Example 120. Шина сообщений растет (src/allocation/service_layer/messagebus.py)
HANDLERS = {
    events.BatchCreated: [handlers.add_batch],
    events.BatchQuantityChanged: [handlers.change_batch_quantity],
    events.AllocationRequired: [handlers.allocate],
    events.OutOfStock: [handlers.send_out_of_stock_notification],

}  # type: Dict[Type[events.Event], List[Callable]]

И наше новое требование полностью выполнено.

10.5. Опционально: Модульное тестирование Event Handlers изолированно с Fake Message Bus

Наш основной тест для рабочего процесса перераспределения-это edge-to-edge (см. Пример кода в Test-Driving нового Handler). Он использует реальную шину сообщений и тестирует весь поток, где обработчик событий BatchQuantityChanged запускает освобождение и выдает новые события AllocationRequired, которые, в свою очередь, обрабатываются их собственными обработчиками. Один тест охватывает цепочку из нескольких событий и обработчиков.

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

В нашем случае мы фактически вмешиваемся, изменяя метод publish_events() в FakeUnitOfWork и отделяя его от реальной шины сообщений, вместо этого заставляя его записывать события, которые он видит:

Example 121. Шина фальшивых сообщений реализована в UoW (tests/unit/test_handlers.py)
class FakeUnitOfWorkWithFakeMessageBus(FakeUnitOfWork):

    def __init__(self):
        super().__init__()
        self.events_published = []  # type: List[events.Event]

    def publish_events(self):
        for product in self.products.seen:
            while product.events:
                self.events_published.append(product.events.pop(0))

Теперь, когда мы вызываем messagebus.handle() используя FakeUnitOfWorkWithFakeMessageBus, он запускает только обработчик этого события. Таким образом, мы можем написать более изолированный модульный тест: вместо проверки всех побочных эффектов мы просто проверяем, что BatchQuantityChanged приводит к AllocationRequired, если количество падает ниже уже выделенного общего количества:

Example 122. Тестирование перераспределения в изоляции (tests/unit/test_handlers.py)
def test_reallocates_if_necessary_isolated():
    uow = FakeUnitOfWorkWithFakeMessageBus()

    # test setup as before
    event_history = [
        events.BatchCreated("batch1", "INDIFFERENT-TABLE", 50, None),
        events.BatchCreated("batch2", "INDIFFERENT-TABLE", 50, date.today()),
        events.AllocationRequired("order1", "INDIFFERENT-TABLE", 20),
        events.AllocationRequired("order2", "INDIFFERENT-TABLE", 20),
    ]
    for e in event_history:
        messagebus.handle(e, uow)
    [batch1, batch2] = uow.products.get(sku="INDIFFERENT-TABLE").batches
    assert batch1.available_quantity == 10
    assert batch2.available_quantity == 50

    messagebus.handle(events.BatchQuantityChanged("batch1", 25), uow)

    # assert on new events emitted rather than downstream side-effects
    [reallocation_event] = uow.events_published
    assert isinstance(reallocation_event, events.AllocationRequired)
    assert reallocation_event.orderid in {'order1', 'order2'}
    assert reallocation_event.sku == 'INDIFFERENT-TABLE'

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

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

Отличный способ заставить себя действительно понять какой-то код-это его рефакторинг. При обсуждении тестирования обработчиков в изоляции мы использовали нечто под названием FakeUnitOfWorkWithFakeMessageBus, что является излишне сложным и нарушает SRP.

Если мы изменим шину сообщений на класс,[35] тогда создание FakeMessageBus будет более простым:

Example 123. Абстрактная шина сообщений и ее реальные и поддельные версии

Так что сигайте в код на GitHub и посмотрите, сможете ли вы заставить работать версию на основе классов, а затем напишите версию test_reallocates_if_needed_isolated () из более ранней версии.

Мы используем шину сообщений на основе классов в Dependency Injection (и Bootstrapping), если вам нужно больше вдохновения.

10.6. Подведём итоги

Давайте оглянемся на то, чего мы достигли, и подумаем, "А нафига?".

10.6.1. Чего мы достигли?

События (Events) - это простые классы данных, которые определяют структуры данных для входных данных. и внутренние сообщения в нашей системе. Это довольно мощно с точки зрения DDD, поскольку события часто очень хорошо переводятся на деловой язык (look up event storming if you haven’t already).

Обработчики (Handlers) - это то, как мы реагируем на события. Они могут обратиться к нашей модели или обратиться к внешним службам. Мы можем определить несколько обработчиков для одного события, если захотим. Обработчики также могут вызывать другие события. Это позволяет нам быть очень детальными в отношении того, что делает обработчик, и действительно придерживаться SRP.

10.6.2. Почему мы достигли цели?

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

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

Table 6. Все приложение - это шина сообщений: компромиссы
Плюсы Минусы
  • Обработчики и сервисы - это одно и то же, так что все проще.

  • У нас есть великолепные структуры данных, чтобы ввести их в систему.

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

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

Теперь вам может быть интересно, откуда берутся эти события BatchQuantityChanged? Ответ станет понятен через пару глав. Но сначала давайте поговорим о событиях в сравнении с командами events versus commands.

11. Команды и Обработчики команд

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

Для этого мы преобразовали все наши use-case функции в обработчики событий. Когда API получает POST для создания нового пакета (batch), он выстраивает новое событие 'BatchCreated' и обрабатывает его так, как если бы это было внутреннее событие. Это может показаться нелогичным. В конце концов, пакет еще не создан; вот почему мы вызвали API. Мы собираемся исправить эту концептуальную бородавку, введя команды и показав, как они могут обрабатываться одной и той же шиной сообщений, но с легка другими правилами.

Код для этой главы находится в chapter_10_commands branch on GitHub:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_10_commands
# или, чтобы кодировать вместе, проверьте предыдущую главу:
git checkout chapter_09_all_messagebus

11.1. Commands и Events

Как и события, commands-это тип message—​instructions, посылаемые одной частью системы другой. Мы обычно представляем команды с тупыми структурами данных и можем обрабатывать их почти так же, как события.

Однако различия между командами(commands) и событиями(events) очень важны.

Команды посылаются одним актором другому конкретному актору в надежде, что в результате произойдет то или иное событие. Когда мы отправляем форму обработчику API, мы посылаем команду. Мы называем команды глаголами повелительного наклонения, такими как "выделить запас" (allocate stock) или "задержать отгрузку"(delay shipment).

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

События транслируются актором всем заинтересованным слушателям (listeners). Когда мы публикуем 'BatchQuantityChanged', мы не знаем, кто его возьмет. Мы называем события глагольными фразами прошедшего времени, такими как "заказ распределен на складе"(order allocated to stock) или "отгрузка задержана" (shipment delayed).

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

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

Table 7. События против команд
Событие Команда

Название

Прошедшее время

Повелительное наклонение

Обработка ошибок

Частная неудача

Шумный Сбой

Отправлено для

Всех слушателей

Единственного получателя

Какие команды есть у нас сейчас в нашей системе?

Example 124. Pulling out some commands (src/allocation/domain/commands.py)
class Command:
    pass

@dataclass
class Allocate(Command):  (1)
    orderid: str
    sku: str
    qty: int

@dataclass
class CreateBatch(Command):  (2)
    ref: str
    sku: str
    qty: int
    eta: Optional[date] = None

@dataclass
class ChangeBatchQuantity(Command):  (3)
    ref: str
    qty: int
1 commands.Allocate заменит events.AllocationRequired.
2 commands.CreateBatch заменит events.BatchCreated.
3 commands.ChangeBatchQuantity заменит events.BatchQuantityChanged.

11.2. Различия в обработке исключений

Просто изменение имен и форм глаголов-это очень хорошо, но это не изменит поведение нашей системы. Мы хотим относиться к событиям и командам одинаково, но не совсем одинаково. Давайте посмотрим, как меняется наша шина сообщений:

Example 125. Вилка для отправки событий и команд (src/allocation/service_layer/messagebus.py)
Message = Union[commands.Command, events.Event]


def handle(message: Message, uow: unit_of_work.AbstractUnitOfWork):  (1)
    results = []
    queue = [message]
    while queue:
        message = queue.pop(0)
        if isinstance(message, events.Event):
            handle_event(message, queue, uow)  (2)
        elif isinstance(message, commands.Command):
            cmd_result = handle_command(message, queue, uow)  (2)
            results.append(cmd_result)
        else:
            raise Exception(f'{message} was not an Event or Command')
    return results
1 У него все еще есть главная точка входа handle(), которая принимает message, который может быть командой или событием.
2 Мы отправляем события и команды двум различным вспомогательным функциям, показанным далее.

Вот как мы справляемся с событиями:

Example 126. События не могут прервать поток (src/allocation/service_layer/messagebus.py)
def handle_event(
    event: events.Event,
    queue: List[Message],
    uow: unit_of_work.AbstractUnitOfWork
):
    for handler in EVENT_HANDLERS[type(event)]:  (1)
        try:
            logger.debug('обработка события %s с помощью обработчика %s', event, handler)
            handler(event, uow=uow)
            queue.extend(uow.collect_new_events())
        except Exception:
            logger.exception('Exception handling event %s', event)
            continue  (2)
1 События передаются диспетчеру, который может делегировать их нескольким обработчикам на одно событие.
2 Он ловит и регистрирует ошибки, но не позволяет им прерывать обработку сообщений.

А вот команду мы делаем так:

Example 127. Commands reraise exceptions (src/allocation/service_layer/messagebus.py)
def handle_command(
    command: commands.Command,
    queue: List[Message],
    uow: unit_of_work.AbstractUnitOfWork
):
    logger.debug('handling command %s', command)
    try:
        handler = COMMAND_HANDLERS[type(command)]  (1)
        result = handler(command, uow=uow)
        queue.extend(uow.collect_new_events())
        return result  (3)
    except Exception:
        logger.exception('Exception handling command %s', command)
        raise  (2)
1 Диспетчер команд ожидает только одного обработчика для каждой команды.
2 Если возникают какие-либо ошибки, они быстро терпят неудачу и будут пузыриться.
3 возвращаемый результат является только временным; как уже упоминалось в Временный Наглый Взлом: шина сообщений должна возвращать результаты, это временный хак, позволяющий шине сообщений возвращать пакетную ссылку для использования API. Мы исправим это в Command-Query Responsibility Segregation (CQRS)[1].

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

Example 128. Словари новых обработчиков (src/allocation/service_layer/messagebus.py)
EVENT_HANDLERS = {
    events.OutOfStock: [handlers.send_out_of_stock_notification],
}  # type: Dict[Type[events.Event], List[Callable]]

COMMAND_HANDLERS = {
    commands.Allocate: handlers.allocate,
    commands.CreateBatch: handlers.add_batch,
    commands.ChangeBatchQuantity: handlers.change_batch_quantity,
}  # type: Dict[Type[commands.Command], Callable]

11.3. Обсуждение: Events, Commands, и Error Handling

Многие разработчики испытывают дискомфорт в этот момент и спрашивают: "Что произойдёт, когда событие не получиться обработать? Как мне понять, что система находится в консистентном состоянии?" Если нам удастся обработать половину событий во время messagebus.handle прежде чем ошибка нехватки памяти убьет наш процесс, как мы можем сгладить проблемы, вызванные потерянными сообщениями?

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

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

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

For example, when we allocate stock to an order, our consistency boundary is the Product aggregate. This means that we can’t accidentally overallocate: either a particular order line is allocated to the product, or it is not—​there’s no room for inconsistent states. Например, когда мы распределяем запасы по заказу, нашей границей согласованности является агрегат Product. Это означает, что мы не можем случайно распределить: либо конкретная строка заказа выделяется продукту, либо нет — другого не дано, ибо нет места для несогласованных состояний.

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

Разбирая этот пример, мы можем лучше понять причину разделения сообщений на команды и события. Когда пользователь хочет заставить систему что-то сделать, мы представляем его запрос как команду. Эта команда должна изменить один aggregate и либо преуспеть, либо потерпеть неудачу в целом. Любая другая бухгалтерия, очистка и уведомление, которые нам нужно сделать, могут происходить через event. Мы не требуем, чтобы обработчики событий были успешными, чтобы команда была успешной.

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

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

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

Example 129. VIP customer (example code for a different project)
1 Агрегат History фиксирует правила, указывающие, когда клиент становится VIP-персоной. Это особенно станет заметным, когда в будущем правила станут более сложными для внесения изменениий.
2 Наш первый хандлер создает заказ для клиента и вызывает доменное событие OrderCreated.
3 Второй обновляет объект History, чтобы записать, что заказ был created.
4 Наконец, мы отправляем электронное письмо клиенту, когда он становится VIP-персоной.

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

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

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

Что произойдет, если в реализации агрегата History есть ошибка? Неужели мы откажемся от ваших денег только потому, что не можем признать вас VIP-персоной?

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

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

11.4. Синхронное восстановление после ошибок

Hopefully we’ve convinced you that it’s OK for events to fail independently from the commands that raised them. What should we do, then, to make sure we can recover from errors when they inevitably occur? Надеюсь, мы были достаточно убедительны показывая, что события могут завершиться неудачей независимо от команд, которые их вызвали. Что же нам тогда делать, чтобы убедиться, что мы можем оправиться от ошибок, когда они неизбежно произойдут?

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

Давайте еще раз рассмотрим метод handle_event из нашей шины сообщений:

Example 130. Текущая функция обработчик (src/allocation/service_layer/messagebus.py)
def handle_event(
    event: events.Event,
    queue: List[Message],
    uow: unit_of_work.AbstractUnitOfWork
):
    for handler in EVENT_HANDLERS[type(event)]:
        try:
            logger.debug('handling event %s with handler %s', event, handler)
            handler(event, uow=uow)
            queue.extend(uow.collect_new_events())
        except Exception:
            logger.exception('Exception handling event %s', event)
            continue

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

Handling event CustomerBecameVIP(customer_id=12345)
with handler <function congratulate_vip_customer at 0x10ebc9a60>

Because we’ve chosen to use dataclasses for our message types, we get a neatly printed summary of the incoming data that we can copy and paste into a Python shell to re-create the object. Поскольку мы решили использовать классы данных (dataclasses) для наших типов сообщений (message types), мы получаем аккуратно напечатанную сводку входящих данных, которую мы можем скопировать и вставить в оболочку Python для повторного создания объекта.

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

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

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

Example 131. Обработка с повтором (src/allocation/service_layer/messagebus.py)
1 Tenacity-это библиотека Python, которая реализует общие шаблоны для повторных попыток.
2 Здесь мы настраиваем нашу шину сообщений для повторения операций до трех раз с экспоненциально увеличивающейся паузой между попытками.

Повторные вызовы операций, которые могут потерпеть неудачу, - это, вероятно, единственный лучший способ повысить устойчивость нашего программного обеспечения. Опять же, шаблоны Unit of Work и Command Handler означают, что каждая попытка начинается с согласованного состояния и не оставит выполнение заданий наполовину законченными.

В какой-то момент, независимо от tenacity, нам придется отказаться от попыток обработать сообщение. Строить надежные системы с распределенными сообщениями непросто, и нам приходится пропускать некоторые сложные моменты. Поэтому, будет полезно изучить дополнительные справочные материалы в epilogue.

11.5. Подведение итогов

В этой книге мы решили представить концепцию событий до концепции команд, но другие руководства часто делают это наоборот. Сделать явными запросы, на которые наша система может ответить, дав им имя и их собственную структуру данных, - это довольно фундаментальная вещь. Иногда вы заметите, как используется шаблон Command Handler для описания того, что мы делаем с Events, Commands, и Message Bus.

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

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

  • CreateBatch безусловно, менее запутанное имя, чем BatchCreated. Мы явно выражаем намерения наших пользователей, а явное лучше, чем неявное, верно?

  • Семантические различия между командами и событиями могут быть незначительными. Ожидаемы споры по поводу различий.

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

В Event-Driven Architecture: Использование событий для интеграции микросервисов мы поговорим об использовании событий в качестве шаблона интеграции.

12. Event-Driven Architecture: Использование событий для интеграции микросервисов

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

У нас есть микросервис с веб-API, но как насчет других способов общения с другими системами? Как мы узнаем, если, скажем, отгрузка задерживается или количество изменяется? Как мы сообщим складской системе, что заказ распределен и должен быть отправлен клиенту?

В этой главе мы хотели бы показать, как метафора events может быть расширена, чтобы охватить способ обработки входящих и исходящих сообщений из системы. Внутренне ядро нашего приложения теперь процессор сообщений. Давайте проследим за тем, чтобы он также стал обработчиком внешних сообщений. Как показано в Наше приложение является процессором сообщений, наше приложение будет получать события из внешних источников через внешнюю шину сообщений (в качестве примера мы будем использовать очереди Redis pub/sub) и публиковать свои выходные данные в виде событий там же.

apwp 1101
Figure 35. Наше приложение является процессором сообщений

Код для этой главы находится в ветви chapter_11_external_events on GitHub:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_11_external_events
# или, чтобы кодить вместе, проверьте предыдущую главу:
git checkout chapter_10_commands

12.1. Distributed Ball of Mud, и мыслим в существительных

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

Какие существительные мы уже ввели в нашу систему? Ну, у нас есть партии(batches) товара(stock), заказов(orders), продуктов(products) и клиентов(customers). Таким образом, наивная попытка разрушить систему могла бы выглядеть следующим образом Context diagram with noun-based services (обратите внимание, что мы назвали нашу систему в честь существительного Партии(Batches), а не Распределение(Allocation)).

apwp 1102
Figure 36. Context diagram with noun-based services
[plantuml, apwp_1102, config=plantuml.cfg]
@startuml Batches Context Diagram
!include images/C4_Context.puml

System(batches, "Batches", "Knows about available stock""Знает об имеющихся запасах")
Person(customer, "Customer", "Wants to buy furniture""Хочет купить мебель")
System(orders, "Orders", "Knows about customer orders""Знает о заказах клиентов")
System(warehouse, "Warehouse", "Knows about shipping instructions""Знает об инструкциях по доставке")

Rel_R(customer, orders, "Places order with""Размещает заказ с")
Rel_D(orders, batches, "Reserves stock with""Резервирует запасы с")
Rel_D(batches, warehouse, "Sends instructions to""Посылает инструкции")

@enduml

Каждая "Штуковина" в нашей системе имеет связанную службу, которая предоставляет HTTP API.

Давайте рассмотрим пример happy-path в Поток Команд 1.: наши пользователи посещают веб - сайт и могут выбирать из продуктов, которые есть на складе. Когда они добавят товар в свою корзину, мы зарезервируем для них некоторый запас. Когда заказ завершен, мы подтверждаем бронирование, что заставляет нас отправлять инструкции по отправке на склад. Допустим, к примеру, если это третий заказ клиента, то надо обновить запись клиента, чтобы отметить его как VIP-персону.

apwp 1103ru
Figure 37. Поток Команд 1.
[plantuml, apwp_1103, config=plantuml.cfg]
@startuml
scale 4

actor Customer
entity Orders
entity Batches
entity Warehouse
database CRM


== Reservation(Бронирование) ==

  Customer -> Orders: Add product to basket (Добавляет товар в корзину)
  Orders -> Batches: Reserve stock (Резервирует запас)

== Purchase(Закупка) ==

  Customer -> Orders: Place order(Размещает заказ)
  activate Orders
  Orders -> Batches: Confirm reservation(Подтверждает бронирование)
  Batches -> Warehouse: Dispatch goods(Отправляет товары)
  Orders -> CRM: Update customer record(Обновляет запись клиента)
  deactivate Orders


@enduml

Мы можем рассматривать каждый из этих шагов как команду в нашей системе: ReserveStock, ConfirmReservation, DispatchGoods, MakeCustomerVIP, и так далее.

Этот стиль архитектуры, в котором мы создаем микросервисы для каждой таблицы базы данных и рассматриваем наши HTTP-API как интерфейсы CRUD для анемичных моделей, является наиболее распространенным первоначальным способом для людей подойти к сервис-ориентированному дизайну.

Это прекрасно работает для очень простых систем, но может быстро превратиться в distributed ball of mud(расползающийся ком грязи).

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

Куда ведет эта логика?

Ну, система складского учёта знает, что запас был поврежден, поэтому, возможно, она должна владеть этим процессом, как показано на рисунке. Поток команд 2.

apwp 1104ru
Figure 38. Поток команд 2
[plantuml, apwp_1104, config=plantuml.cfg]
@startuml
scale 4

actor w as "Warehouse worker"
entity Warehouse
entity Batches
entity Orders
database CRM


  w -> Warehouse: Report stock damage
  activate Warehouse
  Warehouse -> Batches: Decrease available stock
  Batches -> Batches: Reallocate orders
  Batches -> Orders: Update order status
  Orders -> CRM: Update order history
  deactivate Warehouse

@enduml

Это тоже работает, но теперь наш график зависимостей в беспорядке. Для распределения запасов служба заказов(Orders service) управляет системой Партий(Batches system), которая управляет Складом(Warehouse); но для решения проблем на складе наша складская система(Warehouse system) управляет партиями(Batches), которые управляют Заказами(Orders).

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

12.2. Обработка ошибок в распределенных системах

"Вещи ломаются" - это универсальный закон разработки программного обеспечения. Что происходит в нашей системе, когда один из наших запросов терпит неудачу? Предположим, что сетевая ошибка происходит сразу после того, как мы принимаем заказ пользователя на три MISBEGOTTEN-RUG, как показано на рисунке Поток команд с ошибкой.

У нас есть два варианта: мы можем разместить заказ в любом случае и оставить его нераспределенным(unallocated), или мы можем отказаться принять заказ, потому что распределение не может быть гарантировано. Резко всплывший сбой в работе нашей службы обработки партий оказывает критическое влияние на надежность нашей службы заказов.

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

apwp 1105ru
Figure 39. Поток команд с ошибкой
[plantuml, apwp_1105, config=plantuml.cfg]
@startuml
scale 4

actor Customer
entity Orders
entity Batches

Customer -> Orders: Place order(Разместить заказ)
Orders -[#red]x Batches: Confirm reservation(Подтвердить бронирование)
hnote right: network error(ошибка сети)
Orders --> Customer: ???

@enduml
Connascence(Связанность)

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

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

В нашем первом примере расползающегося шара грязи мы видим Связанность по Выполнению(Connascence of Execution): несколько компонентов должны знать о правильном порядке работы, чтобы операция была успешной.

Когда мы думаем об условиях ошибок здесь, мы говорим о Связанность по Времени(Connascence of Timing): для того, чтобы операция сработала, должно произойти несколько событий, одно за другим.

Когда мы заменяем нашу систему в RPC-стиле событиями, мы заменяем оба этих типа связи более слабым типом. Это Связанность по Имени(Connascence of Name): для нескольких компонентов необходимо согласовать только название события и название поля, которое оно содержит.

Мы никогда не сможем полностью избежать связей, только если запретим нашему программному обеспечению общаться с каким - либо другим программным обеспечением. Чего мы хотим, так это избежать ненужной(inappropriate) связи. Connascence обеспечивает ментальную модель для понимания силы и типа связи, присущих различным архитектурным стилям. Читайте все об этом по адресу connascence.io.

12.3. Альтернатива: Временное Разъединение(Декаплинг) С Использованием Асинхронного Обмена Сообщениями

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

Поэтому вместо того, чтобы думать о системе для заказов и системе для партий, мы думаем о системе для ordering и системе для allocating и так далее.

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

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

Как и агрегаты, микросервисы должны быть консистентными границами. Между двумя службами мы можем принять возможную согласованность, и это означает, что нам не нужно полагаться на синхронные вызовы. Каждая служба принимает команды из внешнего мира и создает события для записи результата. Другие службы могут прослушивать(listen) эти события, чтобы инициировать следующие шаги в рабочем процессе.

Чтобы избежать неприятностей в виде Distributed Ball of Mud, вместо временно связанных вызовов HTTP API мы будем использовать асинхронный обмен сообщениями для интеграции наших систем. Нам надо, чтобы наши сообщения BatchQuantityChanged поступали как внешние сообщения из вышестоящих систем, и мы хотим, чтобы наша система публиковала события Allocated для прослушивания нижестоящими системами.

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

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

12.4. Использование канала Redis Pub/Sub для интеграции

Давайте посмотрим конкретно, как все это будет работать. Нам понадобится какой-то способ получения событий из одной системы в другую, подобно нашей шине сообщений, но для сервисов. Этот элемент инфраструктуры часто называют message broker. Роль брокера сообщений заключается в том, чтобы принимать сообщения от издателей и доставлять их подписчикам.

На сайте MADE.com мы используем Event Store; Kafka или RabbitMQ являются достойными альтернативами. Легкое решение на основе Redis pub/sub channels также может прекрасно работать, и поскольку Redis гораздо более привычен для большинства программистов, мы решили использовать его в этой книге.

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

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

apwp 1106ru
Figure 40. Диаграмма последовательности для потока перераспределения
[plantuml, apwp_1106, config=plantuml.cfg]
@startuml
scale 4

Redis -> MessageBus : BatchQuantityChanged event

group BatchQuantityChanged Handler + Unit of Work 1
    MessageBus -> Domain_Model : change batch quantity
    Domain_Model -> MessageBus : emit Allocate command(s)
end


group Allocate Handler + Unit of Work 2 (or more)
    MessageBus -> Domain_Model : allocate
    Domain_Model -> MessageBus : emit Allocated event(s)
end

MessageBus -> Redis : publish to line_allocated channel
@enduml

12.5. Тест-Драйв всего этого с использованием Сквозного теста

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

Example 132. Сквозной тест для нашей модели pub/sub (tests/e2e/test_external_events.py)
def test_change_batch_quantity_leading_to_reallocation():
    # начать с двух партий и заказа, выделенного для одной из них  (1)
    orderid, sku = random_orderid(), random_sku()
    earlier_batch, later_batch = random_batchref('old'), random_batchref('newer')
    api_client.post_to_add_batch(earlier_batch, sku, qty=10, eta='2011-01-01')  (2)
    api_client.post_to_add_batch(later_batch, sku, qty=10, eta='2011-01-02')
    response = api_client.post_to_allocate(orderid, sku, 10)  (2)
    assert response.json()['batchref'] == earlier_batch

    subscription = redis_client.subscribe_to('line_allocated')  (3)

    # изменить количество выделенной партии так, чтобы оно было меньше нашего заказа  (1)
    redis_client.publish_message('change_batch_quantity', {  (3)
        'batchref': earlier_batch, 'qty': 5
    })

    # подождать, пока не появится сообщение о том, что заказ был перераспределен  (1)
    messages = []
    for attempt in Retrying(stop=stop_after_delay(3), reraise=True):  (4)
        with attempt:
            message = subscription.get_message(timeout=1)
            if message:
                messages.append(message)
                print(messages)
            data = json.loads(messages[-1]['data'])
            assert data['orderid'] == orderid
            assert data['batchref'] == later_batch
1 То, что происходит в этом месте, понятно из комментариев: мы хотим отправить в систему событие, которое вызывает перераспределение строки заказа, и мы видим, что это перераспределение также появляется как событие в Redis.
2 api_client - это маленький помощник, который мы отрефакторили для совместного использования двумя типами тестов; он оборачивает наши вызовы к requests.post.
3 redis_client - это еще один маленький помощник в тестировании, детали которого не имеют особого значения; его задача заключается в том, чтобы иметь возможность отправлять и получать сообщения из различных каналов Redis. Мы будем использовать канал под названием change_batch_quantity для отправки запроса на изменение количества для партии, и мы будем слушать другой канал под названием line_allocated для поиска ожидаемого перераспределения.
4 Из-за асинхронной природы тестируемой системы нам нужно снова использовать библиотеку tenacity, чтобы добавить цикл повтора - во-первых, потому что может пройти некоторое время, пока наше новое сообщение line_allocated придет, а также потому, что это будет не единственное сообщение на этом канале.

12.5.1. Redis - еще один тонкий адаптер вокруг нашей шины сообщений

Наш слушатель(listener) Redis pub/sub (мы называем его потребитель событий) очень похож на Flask: он транслируется из внешнего мира в наши события:

Example 133. Простой слушатель(listener) сообщений Redis (src/allocation/entrypoints/redis_eventconsumer.py)
r = redis.Redis(**config.get_redis_host_and_port())


def main():
    orm.start_mappers()
    pubsub = r.pubsub(ignore_subscribe_messages=True)
    pubsub.subscribe('change_batch_quantity')  (1)

    for m in pubsub.listen():
        handle_change_batch_quantity(m)


def handle_change_batch_quantity(m):
    logging.debug('handling %s', m)
    data = json.loads(m['data'])  (2)
    cmd = commands.ChangeBatchQuantity(ref=data['batchref'], qty=data['qty'])  (2)
    messagebus.handle(cmd, uow=unit_of_work.SqlAlchemyUnitOfWork())
1 main() подписывает нас на канал change_batch_quantity при загрузке.
2 Наша основная задача как точки входа в систему - десериализовать JSON, преобразовать его в Command и передать его сервисному уровню - так же, как это делает адаптер Flask.

Мы также создаем новый адаптер для выполнения противоположной задачи - преобразования событий домена в публичные события:

Example 134. Простой издатель сообщений Redis (src/allocation/adapters/redis_eventpublisher.py)
r = redis.Redis(**config.get_redis_host_and_port())


def publish(channel, event: events.Event):  (1)
    logging.debug('publishing: channel=%s, event=%s', channel, event)
    r.publish(channel, json.dumps(asdict(event)))
1 Здесь мы используем жестко заданный канал, но вы также можете хранить связку между classes/names событий и соответствующим каналом, позволяя одному или нескольким типам сообщений отправляться на разные каналы.

12.5.2. Наше новое выездное мероприятие

Вот как будет выглядеть событие Allocated:

Example 135. Новое событие (src/allocation/domain/events.py)
@dataclass
class Allocated(Event):
    orderid: str
    sku: str
    qty: int
    batchref: str

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

Мы добавляем его в метод allocate() нашей модели (предварительно добавив тест, естественно):

Example 136. Product.allocate() выдает новое событие для записи того, что произошло (src/allocation/domain/model.py)
class Product:
    ...
    def allocate(self, line: OrderLine) -> str:
        ...

            batch.allocate(line)
            self.version_number += 1
            self.events.append(events.Allocated(
                orderid=line.orderid, sku=line.sku, qty=line.qty,
                batchref=batch.reference,
            ))
            return batch.reference

Обработчик для ChangeBatchQuantity уже существует, поэтому все, что нам нужно добавить, это обработчик, который публикует исходящее событие:

Example 137. Шина сообщений разрастается (src/allocation/service_layer/messagebus.py)
HANDLERS = {
    events.Allocated: [handlers.publish_allocated_event],
    events.OutOfStock: [handlers.send_out_of_stock_notification],
}  # type: Dict[Type[events.Event], List[Callable]]

Для публикации события используется наша вспомогательная функция из обертки Redis:

Example 138. Публикация в Redis (src/allocation/service_layer/handlers.py)
def publish_allocated_event(
        event: events.Allocated, uow: unit_of_work.AbstractUnitOfWork,
):
    redis_eventpublisher.publish('line_allocated', event)

12.6. Внутренние и внешние события

Хорошо, если различие между внутренними и внешними событиями будет четким. Некоторые события могут приходить извне, а некоторые события могут обновляться и публиковаться извне, но не все будут таковыми. Это особенно важно, если вы попадаете в event sourcing (хотя это очень подходящая тема для другой книги).

Исходящие события — это одно из мест, где важно применять валидацию. См. Validation для ознакомления с некоторой философией валидации и примеры.
Упражнение для читателя

Ниболее удачный простой вариант для этой главы: сделать так, чтобы основной сценарий использования allocate() мог быть вызван событием на канале Redis, а также (или вместо) через API.

Скорее всего, вы захотите добавить новый тест E2E и передать некоторые изменения в redis_eventconsumer.py.

12.7. Подведение итогов

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

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

Martin Fowler, "What do you mean by 'Event-Driven'"

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

Table 9. Интеграция микросервисов на основе событий: компромиссы
Плюсы Минусы
  • Избегает distributed big ball of mud.

  • Сервисы разделены: проще менять отдельные сервисы и добавлять новые.

  • Общие потоки информации сложнее увидеть.

  • Эвентуальная консистентность - это новая концепция, с которой нужно иметь дело.

  • Message reliability and choices around at-least-once versus at-most-once delivery need thinking through. Надежность сообщений и выбор между доставкой at-least-once versus"минимум один раз" и at-most-once"максимум один раз" требуют продумывания.

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

13. Command-Query Responsibility Segregation (CQRS)[1]

В этой главе мы начнем с довольно бесспорного понимания: чтение (queries) и запись (commands) различны, поэтому к ним следует относиться по-разному (или разделять их обязанности, если хотите). Затем мы собираемся продвинуть это понимание как можно дальше.

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

Отделение чтения от записи показывает, где мы можем оказаться.

Код для этой главы находится в ветке chapter_12_cqrs on GitHub.

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_12_cqrs
# или, чтобы кодить вместе, вспомните предыдущую главу:
git checkout chapter_11_external_events

Во-первых, хотя, зачем беспокоиться?

apwp 1201
Figure 41. Отделение чтения от записи

13.1. Domain Models предназначены для Writing

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

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

В начале книги мы записали эти правила в виде юнит-тестов:

Example 139. Наши основные тесты домена (tests/unit/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

...

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

Чтобы правильно применять эти правила, нам нужно было обеспечить последовательность операций, поэтому мы ввели такие шаблоны, как Unit of Work и Aggregate, которые помогают нам фиксировать небольшие фрагменты работы.

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

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

А как насчет чтения?

13.2. Большинство Пользователей не собираются покупать Вашу Мебель

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

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

Область domain та же самая — мы имеем дело с партиями запасов, датой их прибытия и количеством, которое еще доступно - но схема доступа совсем другая. Например, наши клиенты не заметят, если запрос устарел на несколько секунд, но если наша служба allocate работает непоследовательно, мы устроим бардак в их заказах. Мы можем воспользоваться этой разницей, сделав наши чтения эвентуально консистентными, чтобы они получше работали.

Действительно ли достижима Согласованность Чтения?

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

Представим, что наш запрос "Get Available Stock" устарел на 30 секунд, после того, как Боб посетил страницу ASYMMETRICAL-DRESSER. Тем временем Гарри уже купил последний товар. Когда мы попытаемся распределить заказ Боба, мы получим отказ, и нам придется либо отменить его заказ, либо купить больше запасов и отложить его доставку.

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

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

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

Хорошо, давайте предположим, что мы как-то решим эту проблему: мы волшебным образом создаем абсолютно согласованное веб-приложение, в котором никто никогда не видит несвежих данных. На этот раз Гарри попадает на страницу первым и покупает комод.

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

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

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

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

Для стороны записи наши причудливые архитектурные паттерны домена помогают нам развивать нашу систему с течением времени, но сложность, которую мы создали до сих пор, ничего не дает для чтения данных. service layer, unit of work и clever domain model - это просто раздутая структура.

Table 10. Чтение против записи
Сторона чтения Сторона записи

Behavior(Поведение)

Простое чтение

Сложная бизнес-логика

Cacheability(Кэшируемость)

Сильно кэшируемый

Не кэшируемый

Consistency(Консистентность)

Может быть устаревшим

Должен быть транзакционно последовательным

13.3. Post/Redirect/Get и CQS

Если вы занимаетесь веб-разработкой, вы наверняка знакомы с шаблоном Post/Redirect/Get. В этой технике конечная веб-точка принимает HTTP POST и отвечает перенаправлением для просмотра результата. Например, мы можем принять POST на /batches для создания новой партии и перенаправить пользователя на /batches/123 для просмотра только что созданной партии.

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

Обе эти проблемы возникают потому, что мы возвращаем данные в ответ на операцию записи. Post/Redirect/Get обходит эту проблему стороной, разделяя фазы чтения и записи нашей операции.

Эта техника является простым примером разделения команд и запросов (CQS).[36] Мы следуем одному простому правилу: Функции должны либо изменять состояние, либо отвечать на вопросы, но никогда и то, и другое. Это делает программное обеспечение более легким для рассуждений: мы всегда должны иметь возможность спросить: "Включен ли свет?". не щелкая выключателем.

При создании API мы можем применить ту же технику проектирования, возвращая файл 201 Created, или 202 Accepted, с заголовком Location, содержащим URI наших новых ресурсов. Здесь важен не код статуса, который мы используем. но логическое разделение работы на фазу записи и фазу запроса.

Как вы увидите, мы можем использовать принцип CQS, чтобы сделать наши системы более быстрыми и масштабируемыми, но сначала давайте исправим нарушение CQS в нашем существующем коде. Давным-давно мы ввели конечную точку allocate, которая принимает заказ и вызывает наш сервисный уровень для выделения некоторого запаса. В конце вызова мы возвращаем 200 OK и идентификатор партии. Это привело к некоторым уродливым недостаткам дизайна, чтобы мы могли получить нужные нам данные. Давайте изменим его, чтобы он возвращал простое сообщение OK, и вместо этого предоставим новую конечную точку, доступную только для чтения, для получения состояния распределения(allocation):

Example 140. API-тест выполняет GET после POST (tests/e2e/test_api.py)
@pytest.mark.usefixtures('postgres_db')
@pytest.mark.usefixtures('restart_api')
def test_happy_path_returns_202_and_batch_is_allocated():
    orderid = random_orderid()
    sku, othersku = random_sku(), random_sku('other')
    earlybatch = random_batchref(1)
    laterbatch = random_batchref(2)
    otherbatch = random_batchref(3)
    api_client.post_to_add_batch(laterbatch, sku, 100, '2011-01-02')
    api_client.post_to_add_batch(earlybatch, sku, 100, '2011-01-01')
    api_client.post_to_add_batch(otherbatch, othersku, 100, None)

    r = api_client.post_to_allocate(orderid, sku, qty=3)
    assert r.status_code == 202

    r = api_client.get_allocation(orderid)
    assert r.ok
    assert r.json() == [
        {'sku': sku, 'batchref': earlybatch},
    ]


@pytest.mark.usefixtures('postgres_db')
@pytest.mark.usefixtures('restart_api')
def test_unhappy_path_returns_400_and_error_message():
    unknown_sku, orderid = random_sku(), random_orderid()
    r = api_client.post_to_allocate(
        orderid, unknown_sku, qty=20, expect_success=False,
    )
    assert r.status_code == 400
    assert r.json()['message'] == f'Invalid sku {unknown_sku}'

    r = api_client.get_allocation(orderid)
    assert r.status_code == 404

OK, как может выглядеть приложение Flask?

Example 141. Конечная точка для просмотра распределений(allocations) (src/allocation/entrypoints/flask_app.py)
from allocation import views
...

@app.route("/allocations/<orderid>", methods=['GET'])
def allocations_view_endpoint(orderid):
    uow = unit_of_work.SqlAlchemyUnitOfWork()
    result = views.allocations(orderid, uow)  (1)
    if not result:
        return 'not found', 404
    return jsonify(result), 200
1 Отлично! views.py, вполне справедливо; мы можем держать там всякие разные штуки только для чтения, и это будет настоящий views.py, не такой, как у Django, что-то, что знает, как строить представления наших данных только для чтения…​

13.4. Держите свой обед, ребята

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

Example 142. Представления выполняют…​ необработанный SQL? (src/allocation/views.py)
from allocation.service_layer import unit_of_work

def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork):
    with uow:
        results = list(uow.session.execute(
            'SELECT ol.sku, b.reference'
            ' FROM allocations AS a'
            ' JOIN batches AS b ON a.batch_id = b.id'
            ' JOIN order_lines AS ol ON a.orderline_id = ol.id'
            ' WHERE ol.orderid = :orderid',
            dict(orderid=orderid)
        ))
    return [{'sku': sku, 'batchref': batchref} for sku, batchref in results]

Простите? Необработанный SQL?

Если вы похожи на Гарри, впервые столкнувшегося с этой моделью, вам будет интересно, что же такое курил Боб. Сейчас мы вручную обрабатываем наш собственный SQL, и преобразуем строки базы данных непосредственно в dicts? После всех усилий, которые мы приложили для создания хорошей модели домена? А как насчет паттерна Repository? Разве это не должно быть нашей абстракцией вокруг базы данных? Почему бы нам не использовать его повторно?

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

Мы по-прежнему будем хранить наше представление в отдельном модуле views.py; обеспечение четкого разграничения между чтением и записью в вашем приложении по-прежнему является хорошей идеей. Мы применяем разделение команд и запросов, и легко понять, какой код изменяет состояние (обработчики событий - event handlers), а какой просто получает состояние только для чтения (представления - views).

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

13.5. Тестирование представлений CQRS

Прежде чем перейти к изучению различных вариантов, давайте поговорим о тестировании. Какой бы подход вы ни выбрали, вам наверняка понадобится хотя бы один интеграционный тест. Что-то вроде этого:

Example 143. Интеграционный тест для представления (tests/integration/test_views.py)
def test_allocations_view(sqlite_session_factory):
    uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory)
    messagebus.handle(commands.CreateBatch('sku1batch', 'sku1', 50, None), uow)  (1)
    messagebus.handle(commands.CreateBatch('sku2batch', 'sku2', 50, today), uow)
    messagebus.handle(commands.Allocate('order1', 'sku1', 20), uow)
    messagebus.handle(commands.Allocate('order1', 'sku2', 20), uow)
    # добавляем ложную партию и заказываем, чтобы убедиться, что мы получаем правильные партии
    messagebus.handle(commands.CreateBatch('sku1batch-later', 'sku1', 50, today), uow)
    messagebus.handle(commands.Allocate('otherorder', 'sku1', 30), uow)
    messagebus.handle(commands.Allocate('otherorder', 'sku2', 10), uow)

    assert views.allocations('order1', uow) == [
        {'sku': 'sku1', 'batchref': 'sku1batch'},
        {'sku': 'sku2', 'batchref': 'sku2batch'},
    ]
1 Мы выполняем настройку интеграционного теста с помощью public entrypoint в наше приложение-message bus. Это позволяет отделить наши тесты от любых деталей реализации/инфраструктуры того, как вещи хранятся.

13.6. "Obvious" Alternative 1: Using the Existing Repository

Как насчет добавления вспомогательного метода в repository products?

Example 144. Простое представление, использующее repository (src/allocation/views.py)
1 Наше хранилище возвращает объекты Product, и нам нужно найти все продукты для SKU в данном заказе, поэтому мы создадим новый вспомогательный метод .for_order() для хранилища.
2 Теперь у нас есть продукты, но на самом деле нам нужны ссылки на партии, поэтому мы получаем все возможные партии с помощью восприятия списка.
3 Мы фильтруем еще раз, чтобы получить только партии для нашего конкретного заказа. Это, в свою очередь, зависит от того, смогут ли наши объекты Batch сообщить нам, какие идентификаторы заказов они выделили.

Последнее мы реализуем с помощью свойства .orderid:

Example 145. An arguably unnecessary property on our model (src/allocation/domain/model.py)

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

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

13.7. Ваша модель домена не оптимизирована для операций чтения

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

Это оправдание архитектора, поглаживающего окладистую бороду, касательно CQR. Как мы уже говорили, модель домена - это не модель данных, мы пытаемся отразить то, как работает бизнес: рабочий процесс, правила изменения состояния, обмен сообщениями; озабоченность тем, как система реагирует на внешние события и пользовательский ввод. Большая часть этих штуковин совершенно не важна для операций read-only.

Это обоснование для CQR связано с обоснованием шаблона модели предметной области. Если вы создаете простое CRUD-приложение, чтение и запись будут тесно связаны между собой, поэтому вам не нужна модель домена или CQRS. Но чем сложнее ваш домен, тем больше вероятность, что вам понадобится и то, и другое.

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

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

13.8. "Очевидная" Альтернатива 2: Использование ORM

Вас может посетить мысль: "Хорошо, если наш репозиторий такой косой, и работа с Products такая кривая, то я могу, по крайней мере, использовать мой ORM и работать с Batches. Вот для чего это нужно!"

Example 146. Простое представление, в котором используется ORM (src/allocation/views.py)

Но разве это действительно легче написать или понять, чем необработанную версию SQL из примера кода в Держите свой обед, ребята? Возможно, с виду все выглядит не так уж плохо, но скажем вам по секрету, что это заняло несколько попыток и много копаний в документации по SQLAlchemy. SQL - это просто SQL.

Но ORM также может привести к проблемам с производительностью.

13.9. SELECT N+1 и другие соображения по производительности

Так называемый SELECT N+1 problem is a common performance problem with ORMs: when retrieving a list of objects, your ORM will often perform an initial query to, say, get all the IDs of the objects it needs, and then issue individual queries for each object to retrieve their attributes. This is especially likely if there are any foreign-key relationships on your objects. проблема является общей проблемой производительности ORM: при получении списка объектов ваш ORM часто будет выполнять начальный запрос, чтобы, скажем, получить все идентификаторы нужных ему объектов, а затем выдавать отдельные запросы для каждого объекта, чтобы получить их атрибуты. Это особенно вероятно, если в ваших объектах есть какие-либо связи с внешними ключами(foreign-key).

Справедливости ради следует сказать, что SQLAlchemy неплохо справляется с проблемой SELECT N+1. В предыдущем примере он не отображается, и вы можете явно запросить принудительную загрузку, чтобы избежать этого при работе с объединенными объектами.

Помимо SELECT N+1, у вас могут быть и другие причины для того, чтобы разделить способ сохранения изменений состояния и способ получения текущего состояния. Набор полностью нормализованных реляционных таблиц - это хороший способ убедиться, что операции записи никогда не приведут к повреждению данных. Но получение данных с помощью множества объединений может быть медленным. В таких случаях обычно добавляют некоторые денормализованные представления, создают реплики для чтения или даже добавляют уровни кэширования.

13.10. Время полностью Jump the Shark[1]

На этой ноте: Убедили ли мы вас, что наша версия необработанного SQL не такая уж странная, как кажется на первый взгляд? Возможно, мы преувеличивали ради эффекта? Подожди.

Итак, разумно или нет, но этот жестко закодированный SQL-запрос довольно уродлив, верно? Что если мы сделаем его посимпатичнее…​

Example 147. A much nicer query (src/allocation/views.py)
def allocations(orderid: str, uow: unit_of_work.SqlAlchemyUnitOfWork):
    with uow:
        results = list(uow.session.execute(
            'SELECT sku, batchref FROM allocations_view WHERE orderid = :orderid',
            dict(orderid=orderid)
        ))
        ...

…​путем сохранения совершенно отдельного, денормализованного хранилища данных для нашей модели представления?

Example 148. Хи-хи-хи, никаких внешних ключей, только строки, YOLO[6] (src/allocation/adapters/orm.py)
allocations_view = Table(
    'allocations_view', metadata,
    Column('orderid', String(255)),
    Column('sku', String(255)),
    Column('batchref', String(255)),
)

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

Даже с хорошо настроенными индексами реляционная база данных использует много CPU для выполнения соединений. Самые быстрые запросы всегда будут проходить:[<code>SELECT * from <em>mytable</em> WHERE <em>key</em> = :<em>value</em></code>].

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

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

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

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

13.10.1. Обновление Read Model Table с помощью Event Handler

Мы добавляем второй обработчик к событию Allocated:

Example 149. Выделенное событие получает новый обработчик (src/allocation/service_layer/messagebus.py)
EVENT_HANDLERS = {
    events.Allocated: [
        handlers.publish_allocated_event,
        handlers.add_allocation_to_read_model
    ],

Вот как выглядит наш код update-view-model:

Example 150. Обновленная информация о распределении (src/allocation/service_layer/handlers.py)

def add_allocation_to_read_model(
        event: events.Allocated, uow: unit_of_work.SqlAlchemyUnitOfWork,
):
    with uow:
        uow.session.execute(
            'INSERT INTO allocations_view (orderid, sku, batchref)'
            ' VALUES (:orderid, :sku, :batchref)',
            dict(orderid=event.orderid, sku=event.sku, batchref=event.batchref)
        )
        uow.commit()

Хотите верьте, хотите нет, но это вполне сработает! И он будет работать с теми же интеграционными тестами, что и остальные наши варианты.

OK, вам также нужно будет обрабатывать Deallocated:

Example 151. Второй слушатель(listener) для чтения обновлений модели

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

apwp 1202
Figure 42. Диаграмма последовательностей для модели чтения
[plantuml, apwp_1202, config=plantuml.cfg]
@startuml
scale 4
!pragma teoz true

actor User order 1
boundary Flask order 2
participant MessageBus order 3
participant "Domain Model" as Domain order 4
participant View order 9
database DB order 10

User -> Flask: POST to allocate Endpoint
Flask -> MessageBus : Allocate Command

group UoW/transaction 1
    MessageBus -> Domain : allocate()
    MessageBus -> DB: commit write model
end

group UoW/transaction 2
    Domain -> MessageBus : raise Allocated event(s)
    MessageBus -> DB : update view model
end

Flask -> User: 202 OK

User -> Flask: GET allocations endpoint
Flask -> View: get allocations
View -> DB: SELECT on view model
DB -> View: some allocations
& View -> Flask: some allocations
& Flask -> User: some allocations

@enduml

В Диаграмма последовательностей для модели чтения можно увидеть две транзакции в операции POST/write, одна для обновления модели записи, другая для обновления модели чтения, которую может использовать операция GET/read.

Восстановление с нуля

"Что будет, если он сломается?" должен быть первым вопросом, который мы задаем как инженеры.

Как быть с view model, которая не была обновлена из-за ошибки или временного сбоя? Что ж, это всего лишь еще один случай, когда события и команды могут завершиться неудачей независимо друг от друга.

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

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

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

  • Вызывает обработчик add_allocation_to_read_model для каждого выделенного элемента

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

13.11. Изменить нашу реализацию Read Model очень просто

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

Просто смотрите:

Example 152. Handlers update a Redis read model (src/allocation/service_layer/handlers.py)
def add_allocation_to_read_model(event: events.Allocated, _):
    redis_eventpublisher.update_readmodel(event.orderid, event.sku, event.batchref)

def remove_allocation_from_read_model(event: events.Deallocated, _):
    redis_eventpublisher.update_readmodel(event.orderid, event.sku, None)

The helpers in our Redis module are one-liners:

Example 153. Redis read model read and update (src/allocation/adapters/redis_eventpublisher.py)
def update_readmodel(orderid, sku, batchref):
    r.hset(orderid, sku, batchref)


def get_readmodel(orderid):
    return r.hgetall(orderid)

(Возможно, название redis_eventpublisher.py сейчас звучит неправильно, но вы поняли идею.)

И сам вид меняется очень незначительно, чтобы адаптироваться к новому бэкенду:

Example 154. View адаптированный к Redis (src/allocation/views.py)
def allocations(orderid):
    batches = redis_eventpublisher.get_readmodel(orderid)
    return [
        {'batchref': b.decode(), 'sku': s.decode()}
        for s, b in batches.items()
    ]

И такие же интеграционные тесты, которые были у нас раньше, все еще проходят, потому что они написаны на уровне абстракции, отделенном от реализации: setup помещает сообщения на шину сообщений, и утверждения(assertions) противоречат нашему представлению (view).

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

Реализуйте еще одно представление, на этот раз для отображения распределения для одной строки заказа.

Здесь компромисс между использованием жестко закодированного SQL и обращением к хранилищу должен быть гораздо более размытым. Попробуйте несколько версий (возможно, включая переход на Redis) и посмотрите, какая вам больше нравится.

13.12. Подведение итогов

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

Так получилось, что служба распределения на MADE.com действительно использует "полномасштабный" сервис. CQRS, с моделью чтения, хранящейся в Redis, и даже вторым уровнем кэша, обеспечиваемым Varnish. Но его применение значительно отличается от того, что мы показали здесь. Для сервиса распределения, который мы создаем, кажется маловероятным, что вам понадобится использовать отдельную модель чтения и обработчики событий для ее обновления.

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

Table 11. Компромиссы различных вариантов модели представления
Вариант Плюсы Минусы

Просто используйте репозитории

Простой, последовательный подход.

Ожидайте проблем с производительностью при сложных шаблонах запросов.

Использование пользовательских запросов в ORM

Позволяет повторно использовать конфигурацию БД и определения моделей.

Добавляет еще один язык запросов со своими причудами и синтаксисом.

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

Предлагает тонкий контроль над производительностью с помощью стандартного синтаксиса запросов.

Изменения в схеме БД должны быть внесены в ваши ручные запросы и ваши Определения ORM. Высоко нормализованные схемы все еще могут иметь производительность ограничения.

Добавьте несколько дополнительных (денормализованных) таблиц в вашу БД в качестве модели чтения

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

Это немного замедлит процесс записи

Создание отдельных хранилищ для чтения с помощью событий

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

Сложная техника. Гарри навсегда останется недоверчивым к твоим вкусам и мотивы.

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

В нашем примере с книгой операции чтения действуют на совершенно другие концептуальные сущности, чем в нашей модели домена. Служба распределения думает в терминах партий для одного SKU, но пользователи заботятся о распределении для всего заказа, с несколькими SKU, поэтому использование ORM оказывается немного неудобным. У нас был бы большой соблазн использовать представление raw-SQL, которое мы показали в начале главы.

На этой ноте давайте приступим к нашей последней главе.

14. Dependency Injection (и Bootstrapping)

В мире Python к инъекции зависимостей (DI) относятся с подозрением. И до сих пор мы прекрасно обходились без этого в примерах кода для этой книги!

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

Мы также добавим в нашу архитектуру новый компонент под названием bootstrap.py; он будет отвечать за внедрение зависимостей, а также за некоторые другие вещи инициализации, которые нам часто нужны. Мы объясним, почему такого рода вещи называются композиционным корнем в языках OO, и почему _bootstrap script отлично подходит для наших целей.

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

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

apwp 1301
Figure 43. Без бутстрапа: точки входа делают многое

Код для этой главы находится в ветке chapter_13_dependency_injection on GitHub:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout chapter_13_dependency_injection
# или, чтобы кодить вместе, вспомните предыдущую главу:
git checkout chapter_12_cqrs

Bootstrap позаботится обо всем этом в одном месте показывает, как наш бутстраппер берет на себя эти обязанности.

apwp 1302
Figure 44. Bootstrap позаботится обо всем этом в одном месте

14.1. Неявные и явные зависимости

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

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

Example 155. Наши обработчики имеют явную зависимость от UoW (src/allocation/service_layer/handlers.py)
def allocate(
        cmd: commands.Allocate, uow: unit_of_work.AbstractUnitOfWork
):

И это позволяет легко подменить фейковый UoW в наших тестах сервисного уровня:

Example 156. Тесты сервисного уровня против фейкового UoW: (tests/unit/test_services.py)

Сам UoW декларирует явную зависимость от фабрики сессий:

Example 157. UoW зависит от фабрики сессий (src/allocation/service_layer/unit_of_work.py)
class SqlAlchemyUnitOfWork(AbstractUnitOfWork):

    def __init__(self, session_factory=DEFAULT_SESSION_FACTORY):
        self.session_factory = session_factory
        ...

Мы используем это преимущество в наших интеграционных тестах, чтобы иметь возможность иногда использовать SQLite вместо Postgres:

Example 158. Интеграционные тесты с другой БД (tests/integration/test_uow.py)
def test_rolls_back_uncommitted_work_by_default(sqlite_session_factory):
    uow = unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory)  (1)
1 Интеграционные тесты заменяют стандартный Postgres session_factory на SQLite.

14.2. Разве явные зависимости не являются совершенно странными и Java-выми?

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

Example 159. Отправка электронной почты как обычная зависимость на основе импорта (src/allocation/service_layer/handlers.py)
from allocation.adapters import email, redis_eventpublisher  (1)
...

def send_out_of_stock_notification(
        event: events.OutOfStock, uow: unit_of_work.AbstractUnitOfWork,
):
    email.send(  (2)
        'stock@made.com',
        f'Out of stock for {event.sku}',
    )
1 Захардкоженный импорт
2 Вызывает конкретного отправителя электронной почты напрямую

Зачем загрязнять код приложения ненужными аргументами только ради тестов? mock.patch позволяет легко и просто выполнять обезьянье сопряжение:

Example 160. заплатка с точечками, спасибо Майклу Фоорду (tests/unit/test_handlers.py)
    with mock.patch("allocation.adapters.email.send") as mock_send_mail:
        ...

Проблема в том, что мы упростили задачу, потому что наш игрушечный пример не отправляет реальную почту (email.send_mail просто выполняет print), но в реальной жизни вам пришлось бы вызывать mock.patch для каждого теста, который может вызвать уведомление о нехватке товара. Если вы работали над кодовыми базами с большим количеством моков, используемых для предотвращения нежелательных побочных эффектов, вы знаете, насколько раздражающим становится этот кодовый шаблон.

И вы знаете, что моделирование жестко связывает нас с реализацией. Выбрав monkeypatch email.send_mail, мы привязаны к выполнению import email, и если мы когда-нибудь захотим сделать from email import send_mail, что является тривиальным рефактором, нам придется изменить все наши mocks.

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

Кроме того, объявление явной зависимости является примером принципа инверсии зависимости - вместо того, чтобы иметь (неявную) зависимость от конкретной детали, мы имеем (явную) зависимость от абстракции:

Явное лучше неявного.

— Дзен питона
Example 161. Явная зависимость является более абстрактной (src/allocation/service_layer/handlers.py)
def send_out_of_stock_notification(
        event: events.OutOfStock, send_mail: Callable,
):
    send_mail(
        'stock@made.com',
        f'Out of stock for {event.sku}',
    )

Но если мы перейдем к явному объявлению всех этих зависимостей, кто и как будет их внедрять? До сих пор мы действительно имели дело только с передачей UoW: наши тесты используют FakeUnitOfWork, в то время как точки входа Flask и Redis eventconsumer используют настоящие UoW, а шина сообщений передает их нашим обработчикам команд. Если мы добавим классы настоящих и фальшивых электронных адресов, кто будет их создавать и передавать?

Это должно происходить как можно раньше в жизненном цикле процесса, поэтому самое очевидное место - это наши точки входа. Это будет означать дополнительную (дублирующуюся) нагрузку на Flask и Redis, а также на наши тесты. Кроме того, нам пришлось бы возложить ответственность за передачу зависимостей на шину сообщений, у которой и так есть чем заняться; это похоже на нарушение SRP.[37]

Вместо этого мы обратимся к шаблону под названием Composition Root (для нас с вами - загрузочный скрипт),сноска:[Поскольку Python не является "чистым" ОО-язык, разработчики Python не всегда привыкли к концепции необходимости составить набор объектов в работающее приложение. Мы просто выбираем точку входа и выполняем код сверху вниз], и мы сделаем немного "ручного DI". (внедрение зависимостей без фреймворка). См. Bootstrapper между точками входа и шиной сообщений.сноска:[Марк Симанн называет это Pure DI или иногда Vanilla DI].

apwp 1303
Figure 45. Bootstrapper между точками входа и шиной сообщений
[ditaa, apwp_1303]

+---------------+
|  Entrypoints  |
| (Flask/Redis) |
+---------------+
        |
        | call
        V
 /--------------\
 |              |  подготавливает обработчики с правильными зависимостями, инжектированными в
 | Bootstrapper |  (тестовый бутстраппер будет использовать фейки, а продакшн - настоящий)
 |              |
 \--------------/
        |
        | передача инжектированных обработчиков в
        V
/---------------\
|  Message Bus  |
+---------------+
        |
        | отправка события и команды в инжектированные обработчики
        |
        V

14.3. Подготовка обработчиков: Ручной DI с Closures(замыканиями) и Partials(Частично определенными функции)

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

Example 162. Примеры DI с использованием замыканий или partial-функциями
1 Разница между замыканиями (ламбдами или именованными функциями) и functools.partial заключается в том, что первые используют позднее связывание переменных, что может стать источником путаницы, если какая-либо из зависимостей является изменяемой.

Вот та же схема для обработчика send_out_of_stock_notification(), который имеет другие зависимости:

Example 163. Еще один пример замыкания и Частично определенные функции(partial)

14.4. Альтернатива с использованием классов

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

Example 164. DI using classes
1 Класс предназначен для создания вызываемой функции, поэтому у него есть __call__ method.
2 Но мы используем init для объявления необходимых зависимостей. Подобные вещи покажутся вам знакомыми, если вы когда-либо создавали дескрипторы, основанные на классах, или контекстный менеджер, основанный на классах и принимающий аргументы.

Используйте то, с чем вам и вашей команде удобнее работать.

14.5. Сценарий Bootstrap

Мы хотим, чтобы наш сценарий bootstrap выполнял следующие действия:

  1. Объявите зависимости по умолчанию, но позвольте нам их переопределить

  2. Сделайте "инициализацию", которая нам нужна для запуска нашего приложения

  3. Внедрите все зависимости в наши обработчики

  4. Верните нам основной объект для нашего приложения-шину сообщений

Вот первый разрез:

Example 165. Функция bootstrap (src/allocation/bootstrap.py)
def bootstrap(
    start_orm: bool = True,  (1)
    uow: unit_of_work.AbstractUnitOfWork = unit_of_work.SqlAlchemyUnitOfWork(),  (2)
    send_mail: Callable = email.send,
    publish: Callable = redis_eventpublisher.publish,
) -> messagebus.MessageBus:

    if start_orm:
        orm.start_mappers()  (1)

    dependencies = {'uow': uow, 'send_mail': send_mail, 'publish': publish}
    injected_event_handlers = {  (3)
        event_type: [
            inject_dependencies(handler, dependencies)
            for handler in event_handlers
        ]
        for event_type, event_handlers in handlers.EVENT_HANDLERS.items()
    }
    injected_command_handlers = {  (3)
        command_type: inject_dependencies(handler, dependencies)
        for command_type, handler in handlers.COMMAND_HANDLERS.items()
    }

    return messagebus.MessageBus(  (4)
        uow=uow,
        event_handlers=injected_event_handlers,
        command_handlers=injected_command_handlers,
    )
1 orm.start_mappers() - это наш пример инициализации, которую необходимо выполнить один раз в начале работы приложения. Другим распространенным примером является настройка модуля logging.
2 Мы можем использовать аргумент defaults, чтобы определить, какими будут normal/production значения по умолчанию. Хорошо иметь их в одном месте, но иногда зависимости имеют некоторые побочные эффекты во время конструирования, и в этом случае вы можете предпочесть вместо них значение по умолчанию None.
3 Мы создаем наши инжектированные версии связок обработчиков с помощью функции inject_dependencies(), которую мы покажем далее.
4 Мы возвращаем сконфигурированную шину сообщений, готовую к использованию.

Вот как мы вводим зависимости в функцию обработчика, проверяя ее:

Example 166. DI путем проверки сигнатур функций (src/allocation/bootstrap.py)
def inject_dependencies(handler, dependencies):
    params = inspect.signature(handler).parameters  (1)
    deps = {
        name: dependency
        for name, dependency in dependencies.items()  (2)
        if name in params
    }
    return lambda message: handler(message, **deps)  (3)
1 Мы проверяем аргументы нашего обработчика command/event.
2 Мы сопоставляем их по имени с нашими зависимостями.
3 Мы вводим их как kwargs для создания Частично определенной функции.
Еще более ручной DI с меньшим количеством Магии

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

Гарри написал код для inject_dependencies() как первый пример того, как делать "вручную" инъекции зависимостей, и когда он увидел это, Боб обвинил его в чрезмерной инженерии и написании собственного DI-фреймворка.

Честно говоря, Гарри даже не приходило в голову, что можно сделать это более просто, но можно, вот так:

Example 167. Ручное создание Частично определенной функции inline (src/allocation/bootstrap.py)
    injected_event_handlers = {
        events.Allocated: [
            lambda e: handlers.publish_allocated_event(e, publish),
            lambda e: handlers.add_allocation_to_read_model(e, uow),
        ],
        events.Deallocated: [
            lambda e: handlers.remove_allocation_from_read_model(e, uow),
            lambda e: handlers.reallocate(e, uow),
        ],
        events.OutOfStock: [
            lambda e: handlers.send_out_of_stock_notification(e, send_mail)
        ]
    }
    injected_command_handlers = {
        commands.Allocate: lambda c: handlers.allocate(c, uow),
        commands.CreateBatch: \
            lambda c: handlers.add_batch(c, uow),
        commands.ChangeBatchQuantity: \
            lambda c: handlers.change_batch_quantity(c, uow),
    }

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

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

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

В MADE мы использовали Inject в нескольких местах, и это хорошо (хотя это делает Pylint недовольным). Вы также можете ознакомиться с Punq, написанным самим Бобом, или с Dependencies команды DRY-Python.

14.6. Шина сообщений получает обработчики на этапе выполнения

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

Example 168. MessageBus как класс (src/allocation/service_layer/messagebus.py)
class MessageBus:  (1)

    def __init__(
        self,
        uow: unit_of_work.AbstractUnitOfWork,
        event_handlers: Dict[Type[events.Event], List[Callable]],  (2)
        command_handlers: Dict[Type[commands.Command], Callable],  (2)
    ):
        self.uow = uow
        self.event_handlers = event_handlers
        self.command_handlers = command_handlers

    def handle(self, message: Message):  (3)
        self.queue = [message]  (4)
        while self.queue:
            message = self.queue.pop(0)
            if isinstance(message, events.Event):
                self.handle_event(message)
            elif isinstance(message, commands.Command):
                self.handle_command(message)
            else:
                raise Exception(f'{message} was not an Event or Command')
1 Шина сообщений становится классом…​
2 …​который получает свои уже инжектированные в зависимости обработчики.
3 Основная функция handle() по сути та же самая, только несколько атрибутов и методов перенесены на self.
4 Использование self.queue таким образом не является потокобезопасным, что может быть проблемой, если вы используете потоки, потому что экземпляр шины является глобальным в контексте приложения Flask, как мы и написали. Просто нужно быть начеку.

Что еще меняется в …​бусе?

Example 169. Логика обработчиков событий и команд остается прежней (src/allocation/service_layer/messagebus.py)
    def handle_event(self, event: events.Event):
        for handler in self.event_handlers[type(event)]:  (1)
            try:
                logger.debug('handling event %s with handler %s', event, handler)
                handler(event)  (2)
                self.queue.extend(self.uow.collect_new_events())
            except Exception:
                logger.exception('Exception handling event %s', event)
                continue


    def handle_command(self, command: commands.Command):
        logger.debug('handling command %s', command)
        try:
            handler = self.command_handlers[type(command)]  (1)
            handler(command)  (2)
            self.queue.extend(self.uow.collect_new_events())
        except Exception:
            logger.exception('Exception handling command %s', command)
            raise
1 handle_event и handle_command по сути одно и то же, но вместо индексации в статической дикте EVENT_HANDLERS или COMMAND_HANDLERS они используют версии на self.
2 Вместо того чтобы передавать UoW в обработчик, мы ожидаем, что обработчики уже имеют все свои зависимости, поэтому им нужен только один аргумент - конкретное событие или команда.

14.7. Использование Bootstrap в наших точках входа

Теперь в точках входа нашего приложения мы просто вызываем bootstrap.bootstrap() и получаем готовую к работе шину сообщений, вместо того чтобы настраивать UoW и все остальное:

Example 170. Flask вызывает bootstrap (src/allocation/entrypoints/flask_app.py)
-from allocation import views
+from allocation import bootstrap, views

 app = Flask(__name__)
-orm.start_mappers()  (1)
+bus = bootstrap.bootstrap()


 @app.route("/add_batch", methods=['POST'])
@@ -19,8 +16,7 @@ def add_batch():
     cmd = commands.CreateBatch(
         request.json['ref'], request.json['sku'], request.json['qty'], eta,
     )
-    uow = unit_of_work.SqlAlchemyUnitOfWork()  (2)
-    messagebus.handle(cmd, uow)
+    bus.handle(cmd)  (3)
     return 'OK', 201
1 Нам больше не нужно вызывать start_orm(); этапы инициализации скрипта bootstrap сделают это.
2 Нам больше не нужно явно создавать конкретный тип UoW; скрипт bootstrap по умолчанию позаботится об этом.
3 И наша шина сообщений теперь является конкретным экземпляром, а не глобальным модулем.[38]

14.8. Инициализация DI в наших тестах

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

Example 171. Overriding bootstrap defaults (tests/integration/test_views.py)
@pytest.fixture
def sqlite_bus(sqlite_session_factory):
    bus = bootstrap.bootstrap(
        start_orm=True,  (1)
        uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory),  (2)
        send_mail=lambda *args: None,  (3)
        publish=lambda *args: None,  (3)
    )
    yield bus
    clear_mappers()

def test_allocations_view(sqlite_bus):
    sqlite_bus.handle(commands.CreateBatch('sku1batch', 'sku1', 50, None))
    sqlite_bus.handle(commands.CreateBatch('sku2batch', 'sku2', 50, today))
    ...
    assert views.allocations('order1', sqlite_bus.uow) == [
        {'sku': 'sku1', 'batchref': 'sku1batch'},
        {'sku': 'sku2', 'batchref': 'sku2batch'},
    ]
1 Мы все еще хотим стартовать ОРМ…​
2 …​потому что мы будем использовать настоящий UoW, хотя и с базой данных in-memory.
3 Но нам не нужно отправлять электронную почту или публиковать, поэтому мы делаем эти noops.[39]

В наших модульных тестах, напротив, мы можем повторно использовать наш FakeUnitOfWork:

Example 172. Bootstrap в модульном тесте (tests/unit/test_handlers.py)
def bootstrap_test_app():
    return bootstrap.bootstrap(
        start_orm=False,  (1)
        uow=FakeUnitOfWork(),  (2)
        send_mail=lambda *args: None,  (3)
        publish=lambda *args: None,  (3)
    )
1 Нет необходимости запускать ORM…​
2 …​потому что фейковый UoW не использует его.
3 Мы также хотим подделать наши адаптеры электронной почты и Redis.

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

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

Измените все обработчики на классы в соответствии с примером DI using classes и внесите соответствующие изменения в DI-код бутстраппера. Это позволит вам понять, предпочитаете ли вы функциональный подход или подход, основанный на классах, когда речь идет о ваших собственных проектах.

14.9. Построение адаптера "Properly": Пример из практики

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

На данный момент у нас есть два типа зависимостей:

Example 173. Два типа зависимостей (src/allocation/service_layer/messagebus.py)
1 UoW имеет абстрактный базовый класс. Это самый тяжелый вариант для объявления и управления внешней зависимостью. Мы бы использовали это для случая, когда зависимость относительно сложная.
2 Отправитель электронной почты и издатель pub/sub определяются как функции. Это отлично работает для простых зависимостей.

Вот некоторые из вещей, которые мы вводим в работу:

  • Клиент файловой системы S3

  • Клиент хранилища ключ/значение

  • Объект сессии requests

Большинство из них будут иметь более сложные API, которые вы не сможете охватить в виде одной функции: чтение и запись, GET и POST, и так далее.

Несмотря на простоту, давайте на примере send_mail рассмотрим, как можно определить более сложную зависимость.

14.9.1. Определите абстрактную и конкретную реализации

Мы представим себе более общий API уведомлений. Это может быть электронная почта, может быть SMS, могут быть сообщения в Slack в один прекрасный день.

Example 174. ABC и конкретная реализация (src/allocation/adapters/notifications.py)
class AbstractNotifications(abc.ABC):

    @abc.abstractmethod
    def send(self, destination, message):
        raise NotImplementedError

...

class EmailNotifications(AbstractNotifications):

    def __init__(self, smtp_host=DEFAULT_HOST, port=DEFAULT_PORT):
        self.server = smtplib.SMTP(smtp_host, port=port)
        self.server.noop()

    def send(self, destination, message):
        msg = f'Subject: allocation service notification\n{message}'
        self.server.sendmail(
            from_addr='allocations@example.com',
            to_addrs=[destination],
            msg=msg
        )

Мы изменим зависимость в сценарии bootstrap:

Example 175. Уведомления в шине сообщений (src/allocation/bootstrap.py)

14.9.2. Создайте фейковую версию для ваших тестов

Мы прорабатываем и определяем фейковую версию для модульного тестирования:

Example 176. Поддельные уведомления (tests/unit/test_handlers.py)
class FakeNotifications(notifications.AbstractNotifications):

    def __init__(self):
        self.sent = defaultdict(list)  # type: Dict[str, List[str]]

    def send(self, destination, message):
        self.sent[destination].append(message)
...

И мы используем его в наших тестах:

Example 177. Тесты немного изменяются (tests/unit/test_handlers.py)
    def test_sends_email_on_out_of_stock_error(self):
        fake_notifs = FakeNotifications()
        bus = bootstrap.bootstrap(
            start_orm=False,
            uow=FakeUnitOfWork(),
            notifications=fake_notifs,
            publish=lambda *args: None,
        )
        bus.handle(commands.CreateBatch("b1", "POPULAR-CURTAINS", 9, None))
        bus.handle(commands.Allocate("o1", "POPULAR-CURTAINS", 10))
        assert fake_notifs.sent['stock@made.com'] == [
            f"Out of stock for POPULAR-CURTAINS",
        ]

14.9.3. Разберитесь, как провести интеграционное тестирование реальной вещи

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

Example 178. Конфиг Docker-compose с реальным фейковым почтовым сервером (docker-compose.yml)
version: "3"

services:

  redis_pubsub:
    build:
      context: .
      dockerfile: Dockerfile
    image: allocation-image
    ...

  api:
    image: allocation-image
    ...

  postgres:
    image: postgres:9.6
    ...

  redis:
    image: redis:alpine
    ...

  mailhog:
    image: mailhog/mailhog
    ports:
      - "11025:1025"
      - "18025:8025"

В наших интеграционных тестах мы используем настоящий класс EmailNotifications, общающийся с сервером MailHog в кластере Docker:

Example 179. Интеграционный тест для электронной почты (tests/integration/test_email.py)
@pytest.fixture
def bus(sqlite_session_factory):
    bus = bootstrap.bootstrap(
        start_orm=True,
        uow=unit_of_work.SqlAlchemyUnitOfWork(sqlite_session_factory),
        notifications=notifications.EmailNotifications(),  (1)
        publish=lambda *args: None,
    )
    yield bus
    clear_mappers()


def get_email_from_mailhog(sku):  (2)
    host, port = map(config.get_email_host_and_port().get, ['host', 'http_port'])
    all_emails = requests.get(f'http://{host}:{port}/api/v2/messages').json()
    return next(m for m in all_emails['items'] if sku in str(m))


def test_out_of_stock_email(bus):
    sku = random_sku()
    bus.handle(commands.CreateBatch('batch1', sku, 9, None))  (3)
    bus.handle(commands.Allocate('order1', sku, 10))
    email = get_email_from_mailhog(sku)
    assert email['Raw']['From'] == 'allocations@example.com'  (4)
    assert email['Raw']['To'] == ['stock@made.com']
    assert f'Out of stock for {sku}' in email['Raw']['Data']
1 Мы используем наш bootstrapper для создания шины сообщений, которая взаимодействует с настоящим классом уведомлений.
2 Мы выяснили, как получать электронные письма из нашего "настоящего" сервера электронной почты.
3 Мы используем шину для проведения тестовой установки.
4 Вопреки всему, это действительно сработало, причем практически с первого раза!

И это все.

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

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

  1. Попробуйте заменить наши уведомления с электронной почты на SMS-уведомления с помощью Twilio, например, или уведомления Slack. Можете ли вы найти хороший аналог MailHog для интеграционного тестирования?

  2. По аналогии с тем, как мы перешли от класса send_mail к классу `Notifications, попробуйте рефакторить наш redis_eventpublisher, который в настоящее время является просто Callable, в какой-то более формальный adapter/base class/protocol.

14.10. Подведение итогов

  • Как только у вас появится более одного адаптера, вы начнете испытывать сильную боль от передачи зависимостей вручную, если только вы не сделаете что-то вроде инъекции зависимостей.

  • Настройка внедрения зависимостей - это лишь одно из многих типичных действий по setup/initialization, которые необходимо выполнить всего один раз при запуске приложения. Часто хорошей идеей является объединение всего этого в bootstrap-скрипт.

  • Сценарий bootstrap также хорош как место для предоставления разумной конфигурации по умолчанию для ваших адаптеров и как единственное место для переопределения этих адаптеров с подделками для ваших тестов.

  • Фреймворк для инъекции зависимостей может быть полезен, если вам нужно сделать DI на нескольких уровнях - например, если у вас есть цепочки зависимостей компонентов, которые все нуждаются в DI.

  • В этой главе также представлен пример изменения неявной/простой зависимости на "правильную". адаптер, вычисляя ABC, определяя его реальные и ложные реализации и продумывая интеграционное тестирование.

DI и Bootstrap Резюме

Вкратце:

  1. Определите свой API с помощью ABC.

  2. Внедряйте реальные вещи.

  3. Создайте подделку и используйте ее для тестов юнитов/сервисного уровня/хандлеров.

  4. Найдите менее фальшивую версию, которую можно поместить в среду Docker.

  5. Протестируйте менее поддельные "настоящие" вещи.

  6. Прибыли!

These were the last patterns we wanted to cover, which brings us to the end of Событийно-Ориентированная архитектура. In the epilogue, we’ll try to give you some pointers for applying these techniques in the Real WorldTM.

Это были последние паттерны, которые мы хотели охватить, что подводит нас к концу [части 2]. В the epilogue мы постараемся дать вам несколько советов по применению этих методов в Real WorldTM.

Appendix A: Эпилог

Что теперь?

Фух! В этой книге рассмотрено много тем, и для большей части аудитории все эти идеи являются новыми. Учитывая это, надежда сделать вас экспертами в этих техниках, конечно есть, но всё, что можно сделать, это предоставить общие идеи и достаточно кода, чтобы попытаться написать что-то с нуля.

Код, который представлен в этой книге, не является закаленным в боях Production code: это набор блоков Lego, с которыми можно играть, чтобы сделать свой первый дом, космический корабль и небоскреб.

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

Как попасть отсюда в светлое будущее?

Скорее всего, многие подумают примерно следующее:

"Хорошо, Боб и Гарри, это все хорошо, и если меня когда-нибудь наймут для работы над новым проектом, я знаю, что делать. Но пока что я здесь со своей кучей говнкода Django, и я не вижу никакого способа добраться до вашей хорошей, чистой, идеальной, незапятнанной, упрощенной модели. Точно не отсюда".

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

Прежде всего определить: какую проблему вы пытаетесь решить? Сложно ли изменить программное обеспечение? Является ли производительность неприемлемой? Есть ли у странные, необъяснимые ошибки?

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

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

Разделение запутанных обязанностей

В начале книги мы говорили, что основной характеристикой БОЛЬШОГО ШАРА ГРЯЗИ является однородность: каждая часть системы выглядит одинаково, потому что нами не были чётко определены обязанности каждого компонента. Чтобы исправить это, следовало бы начать разделять обязанности и вводить четкие границы. Однима из первых шагов, который было бы правильно сделать, это начать строить сервисный слой (Домен системы совместной работы).

apwp ep01ru
Figure 46. Домен системы совместной работы
[plantuml, apwp_ep01, config=plantuml.cfg]
@startuml
scale 4
hide empty members

Workspace *- Folder : contains
Account *- Workspace : owns
Account *-- Package : has
User *-- Account : manages
Workspace *-- User : has members
User *-- Document : owns
Folder *-- Document : contains
Document *- Version: has
User *-- Version: authors
@enduml

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

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

Начните с разработки use cases вашей системы. Если у вас есть пользовательский интерфейс, какие действия он выполняет? Если есть компонент обработки бэкенда, возможно, каждое задание cron или задание Celery является единственным вариантом использования. Каждый из вариантов использования должен иметь императивное имя: Например, применить начисления по счетам, очистить заброшенные счета или поднять заказ на поставку.

В нашем случае большинство вариантов использования были частью классов менеджеров и имели такие названия, как Create Workspace или Delete Document Version. Каждый пример использования вызывался с веб-фронтэнда.

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

  • Начать собственную транзакцию базы данных, если это необходимо

  • Получеать все необходимыe данныe

  • Проверять все предварительные условия (см. шаблон Ensure в Validation).

  • Обновлять модели домена

  • Сохранять любые изменения

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

Одна из самых больших проблем, с которой мы столкнулись, заключалась в том, что методы менеджера вызывали другие методы менеджера, и доступ к данным мог происходить из самих объектов модели. Было трудно понять, что делает каждая операция, не отправляясь на поиски сокровищ по всей кодовой базе. Сведение всей логики в один метод и использование UoW для управления нашими транзакциями упростило работу с системой.
Тематическое исследование: Наслаивание заросшей системы

Много лет назад Боб работал в компании по разработке программного обеспечения, которая передала на аутсорсинг первую версию своего приложения - онлайновой платформы для совместной работы и обмена файлами.

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

В своей основе система представляла собой приложение ASP.NET Web Forms, построенное с использованием NHibernate ORM. Пользователи загружали документы в рабочие пространства, где они могли приглашать других участников рабочего пространства просматривать, комментировать или изменять их работу.

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

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

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

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

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

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

Это нормально, если у вас есть дублирование в функциях прецедентов. Мы не пытаемся написать идеальный код; мы просто пытаемся извлечь некоторые значимые слои. Лучше дублировать некоторый код в нескольких местах, чем иметь функции прецедентов, вызывающие друг друга в длинной цепочке.

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

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

Читать Working Effectively with Legacy Code by Michael C. Feathers (Prentice Hall) для руководства по тестированию унаследованного кода и началу разделения ответственности.

Определение агрегатов и ограниченных контекстов

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

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

Каждый объект в системе был частью иерархии наследования, которая включала SecureObject и Version. Эта иерархия наследования была отражена непосредственно в схеме базы данных, так что каждый запрос должен был объединять 10 различных таблиц и просматривать столбец дискриминатора, чтобы определить, с какими объектами вы работаете.

Кодовая база позволила легко "расставить точки" прокладывая свой путь через эти объекты следующим образом:

user.account.workspaces[0].documents.versions[1].owner.account.settings[0];

Построить систему таким образом с помощью Django ORM или SQLAlchemy легко, но следует избегать. Хотя это удобно, это очень затрудняет рассуждения о производительности, потому что каждое свойство может вызвать поиск в базе данных.

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

Многие операции требуют, чтобы мы перебирали объекты таким образом - например:

# Блокировка рабочих пространств пользователя за неуплату

def lock_account(user):
    for workspace in user.account.workspaces:
        workspace.archive()

Или даже пересмотреть коллекции папок и документов:

def lock_documents_in_folder(folder):

    for doc in folder.documents:
         doc.archive()

     for child in folder.children:
         lock_documents_in_folder(child)

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

Мы говорили о печально известной проблеме SELECT N+1 в Command-Query Responsibility Segregation (CQRS)[1] и о том, как мы можем использовать различные методики при чтении данных для запросов по сравнению с чтением данных для команд.

В основном мы делали это, заменяя прямые ссылки идентификаторами.

До агрегатов было так:

apwp ep02
[plantuml, apwp_ep02, config=plantuml.cfg]
@startuml
scale 4
hide empty members

together {
    class Document {
      add_version()
      workspace: Workspace
      parent: Folder
      versions: List[DocumentVersion]

    }

    class DocumentVersion {
      title : str
      version_number: int
      document: Document

    }
    class Folder {
      parent: Workspace
      children: List[Folder]
      copy_to(target: Folder)
      add_document(document: Document)
    }
}

together {
    class User {
      account: Account
    }


    class Account {
      add_package()
      owner : User
      packages : List[BillingPackage]
      workspaces: List[Workspace]
    }
}


class BillingPackage {
}

class Workspace {
  add_member(member: User)
  account: Account
  owner: User
  members: List[User]
}



Account --> Workspace
Account -left-> BillingPackage
Account -right-> User
Workspace --> User
Workspace --> Folder
Workspace --> Account
Folder --> Folder
Folder --> Document
Folder --> Workspace
Folder --> User
Document -right-> DocumentVersion
Document --> Folder
Document --> User
DocumentVersion -right-> Document
DocumentVersion --> User
User -left-> Account

@enduml

После моделирования с помощью агрегатов:

apwp ep03
[plantuml, apwp_ep03, config=plantuml.cfg]
@startuml
scale 4
hide empty members

frame Document {

  class Document {

    add_version()

    workspace_id: int
    parent_folder: int

    versions: List[DocumentVersion]

  }

  class DocumentVersion {

    title : str
    version_number: int

  }
}

frame Account {

  class Account {
    add_package()

    owner : int
    packages : List[BillingPackage]
  }


  class BillingPackage {
  }

}

frame Workspace {
   class Workspace {

     add_member(member: int)

     account_id: int
     owner: int
     members: List[int]

   }
}

frame Folder {

  class Folder {
    workspace_id : int
    children: List[int]

    copy_to(target: int)
  }

}

Document o-- DocumentVersion
Account o-- BillingPackage

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

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

Этот экран был невероятно тяжелым для базы данных, потому что он полагался на вложенные циклы for, которые запускали лениво загруженную ORM.

Мы используем эту же технику в [главе_12_cqrs], где мы заменяем вложенный цикл над объектами ORM простым SQL-запросом. Это первый шаг в подходе к CQRS.

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

Когда нам нужно было записать данные, мы изменяли по одному агрегату за раз, а для обработки событий ввели шину сообщений. Например, в новой модели, когда мы блокируем учетную запись, мы можем сначала запросить все затронутые рабочие пространства с помощью SELECT id FROM workspace WHERE account_id = ?.

Затем мы могли бы поднять новую команду для каждого рабочего пространства:

for workspace_id in workspaces:
    bus.handle(LockWorkspace(workspace_id))

Событийно-управляемый подход к переходу к микросервисам с помощью паттерна Strangler

Модель Strangler Fig предполагает создание новой системы по краям старой системы, сохраняя ее работоспособность. Части старой функциональности постепенно перехватываются и заменяются, пока старая система вообще ничего не будет делать и её можно будет отключить.

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

  1. Поднять события, представляющие изменения, происходящие в системе, которую вы хотите заменить.

  2. Построить вторую систему, которая потребляет эти события и использует их для построения собственной модели домена.

  3. Заменить старую систему на новую.

Мы использовали перехват event для перехода от Было: сильное двунаправленное соединение на основе XML-RPC…​

apwp ep04
Figure 47. Было: сильное двунаправленное соединение на основе XML-RPC
[plantuml, apwp_ep04, config=plantuml.cfg]
@startuml Ecommerce Context
!include images/C4_Context.puml

LAYOUT_LEFT_RIGHT
scale 2

Person_Ext(customer, "Customer", "Wants to buy furniture")

System(fulfillment, "Fulfillment System", "Manages order fulfillment and logistics")
System(ecom, "Ecommerce website", "Allows customers to buy furniture")

Rel(customer, ecom, "Uses")
Rel(fulfillment, ecom, "Updates stock and orders", "xml-rpc")
Rel(ecom, fulfillment, "Sends orders", "xml-rpc")

@enduml
apwp ep05
Figure 48. Стало: свободное соединение с асинхронными событиями (вы можете найти версию этой диаграммы в высоком разрешении на cosmicpython.com)
[plantuml, apwp_ep05, config=plantuml.cfg]
@startuml Ecommerce Context
!include images/C4_Context.puml

LAYOUT_LEFT_RIGHT
scale 2

Person_Ext(customer, "Customer", "Wants to buy furniture")

System(av, "Availability Service", "Calculates stock availability")
System(fulfillment, "Fulfillment System", "Manages order fulfillment and logistics")
System(ecom, "Ecommerce website", "Allows customers to buy furniture")

Rel(customer, ecom, "Uses")
Rel(customer, av, "Uses")
Rel(fulfillment, av, "Publishes batch_created", "events")
Rel(av, ecom, "Publishes out_of_stock", "events")
Rel(ecom, fulfillment, "Sends orders", "xml-rpc")

@enduml

Практически, это был проект длительностью в несколько месяцев. Нашим первым шагом было написание модели домена, которая могла бы представлять партии, поставки и продукты. Мы использовали TDD для создания игрушечной системы, которая могла бы ответить на один вопрос: "Если мне нужно N единиц HAZARDOUS_RUG, сколько времени займет их доставка?".

При развертывании событийно-управляемой системы начните с "ходячего скелета". Развертывание системы, которая просто регистрирует свои входные данные, заставляет нас решить все инфраструктурные вопросы и начать работать в production.
Тематическое исследование: Carving Out a Microservice to Replace a Domain

MADE.com начинал с двух монолитов: один для внешнего приложения электронной коммерции и один для внутренней системы выполнения заказов.

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

Со временем этот процесс синхронизации становился все медленнее и медленнее, пока однажды на Рождество импорт заказов за один день не занял более 24 часов. Боба наняли, чтобы разбить систему на набор сервисов, управляемых событиями.

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

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

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

В результате мы можем ответить на вопрос "Сколько запасов имеется в наличии?" за 2-3 миллисекунды, а теперь API часто обрабатывает сотни запросов в секунду в течение длительного времени.

Если все это звучит немного знакомо, что ж, теперь вы знаете, откуда взялся наш пример приложения!

Как только у нас появилась рабочая модель домена, мы перешли к созданию некоторых инфраструктурных элементов. Наша первая производственная установка представляла собой крошечную систему, которая могла получать событие batch_created и регистрировать его JSON-представление. Это "Hello World" событийно-управляемой архитектуры. Это заставило нас развернуть шину сообщений, подключить производителя и потребителя, построить конвейер развертывания и написать простой обработчик сообщений.

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

Убедить заинтересованные стороны попробовать что-то новое

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

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

Тематическое Исследование: Модель Пользователя

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

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

Это было беспорядочно и ситуативно, но работало хорошо до того дня, когда владелец продукта попросил о новой функции:

Когда пользователь присоединяется к компании, мы хотим добавить его в некоторые рабочие пространства компании по умолчанию, например, в рабочее пространство HR или рабочее пространство "Объявления компании".

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

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

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

СОВЕТ: Посмотрите на www.eventmodeling.org и www.eventstorming.org несколько отличных руководств по визуальному моделированию систем с событиями.

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

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

Тематическое Исследование: Дэвид Седдон о том, как делать маленькие шаги

Привет, я Дэвид, один из технических рецензентов этой книги. Я работал над несколькими сложными монолитами Django, и поэтому мне знакома боль, которую Боб и Гарри обещали успокоить.

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

Я решил заняться проблемной областью кодовой базы, которая всегда меня беспокоила. Я начал с того, что реализовал его как вариант использования. Но я столкнулся с неожиданными вопросами. Были вещи, о которых я не подумал во время чтения, и которые теперь мешают понять, что делать. Является ли проблемой, если мой сценарий использования взаимодействует с двумя разными агрегатами? Может ли один вариант использования вызывать другой? И как она должна была существовать в системе, которая следовала другим архитектурным принципам, не приводя к ужасному беспорядку?

Что случилось с этим многообещающим планом? Действительно ли я понял идеи достаточно хорошо, чтобы применить их на практике? Подходит ли он вообще для моего применения? Даже если бы это было так, согласился бы кто-нибудь из моих коллег на такие серьезные изменения? Были ли это просто приятные идеи для меня, чтобы пофантазировать, пока я занимаюсь реальной жизнью?

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

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

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

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

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

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

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

Нужно ли мне делать все это сразу? Могу ли я делать понемногу за раз?

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

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

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

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

Нужно ли мне заниматься CQRS? Это звучит странно. Разве я не могу просто использовать репозитории?

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

В системе рабочего пространства/документов, на примере которой проводилось исследование, у нас было много объектов View Builder, которые использовали хранилища для получения данных, а затем выполняли некоторые преобразования, чтобы вернуть модели для немого чтения. Преимущество заключается в том, что при возникновении проблем с производительностью можно легко переписать конструктор представлений для использования пользовательских запросов или необработанного SQL.

Как должны взаимодействовать варианты использования в рамках более крупной системы? Разве это проблема для одного называть другого?

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

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

Является ли запахом кода для сценария использования использование нескольких repositories/aggregates, и если да, то почему?

Агрегат - это граница согласованности, поэтому если в вашем случае требуется обновить два агрегата атомарно (в рамках одной транзакции), то ваша граница согласованности, строго говоря, неверна. В идеале вам следует подумать о переезде в новый агрегат, который одновременно охватывает все вещи, которые вы хотите изменить.

Если вы действительно обновляете только один агрегат и используете другой (другие) для доступа только для чтения, то это вполне, хотя вы могли бы рассмотреть возможность создания read/view модели для получения этих данных вместо этого - это делает вещи чище, если каждый случай использования имеет только один агрегат.

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

Что если у меня система только для чтения, но с тяжелой бизнес-логикой?

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

Мы писали системы, в которых модели представлений нуждались в обширных модульных тестах. В этих системах мы разделяем построитель представлений и поисковик представлений, как в Построитель представлений и сборщик представлений (вы можете найти версию этой диаграммы в высоком разрешении на cosmicpython.com).

apwp ep06
Figure 49. Построитель представлений и сборщик представлений (вы можете найти версию этой диаграммы в высоком разрешении на cosmicpython.com)
[plantuml, apwp_ep06, config=plantuml.cfg]
@startuml View Fetcher Component Diagram
!include images/C4_Component.puml

ComponentDb(db, "Database", "RDBMS")
Component(fetch, "View Fetcher", "Reads data from db, returning list of tuples or dicts")
Component(build, "View Builder", "Filters and maps tuples")
Component(api, "API", "Handles HTTP and serialization concerns")

Rel(api, build, "Invokes")
Rel_R(build, fetch, "Invokes")
Rel_D(fetch, db, "Reads data from")

@enduml

+ Это позволяет легко протестировать конструктор представлений, предоставив ему поддельные данные (например, список dicts). "Модный CQRS" с обработчиками событий на самом деле является способом запуска сложной логики представления, когда мы пишем, чтобы избежать ее запуска при чтении.

Нужно ли мне создавать микросервисы для выполнения этих задач?

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

Я использую Django. Может нуегонах?

У нас есть целое приложение специально для вас: [appendix_django]!

Footguns

Итак, мы дали вам целую кучу новых игрушек для игр. Вот мелкий шрифт. Гарри и Боб не рекомендуют вам копировать и вставлять наш код в производственную систему и перестраивать свою автоматизированную торговую платформу на Redis pub/sub. В целях краткости и простоты мы обошли стороной многие щекотливые темы. Вот список того, что, по нашему мнению, вы должны знать, прежде чем попробовать это по-настоящему.

Надежные сообщения - это сложно

Redis pub/sub ненадежен и не должен использоваться в качестве инструмента обмена сообщениями общего назначения. Мы выбрали его, потому что он хорошо знаком и прост в управлении. В MADE мы используем Event Store в качестве инструмента обмена сообщениями, но у нас есть опыт работы с RabbitMQ и Amazon EventBridge.

На сайте Тайлера Трэта bravenewgeek.com есть несколько отличных статей; вам следует хотя бы прочитать "You Cannot Have Exactly-Once Delivery" и "What You Want Is What You Don’t: Понимание компромиссов в распределенном обмене сообщениями".

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

В главе События и шина сообщений мы обновляем наш процесс таким образом, чтобы распределение строки заказа и перераспределение строки происходили в двух отдельных единицах работы. Вам понадобится мониторинг, чтобы знать, когда эти транзакции терпят неудачу, и инструменты для воспроизведения событий. Некоторые из этих задач упрощаются при использовании журнала транзакций в качестве брокера сообщений (например, Kafka или EventStore). Вы также можете посмотреть на Outbox pattern.

Мы не обсуждаем идемпотентность

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

Есть много хорошего материала по идемпотентной обработке сообщений, попробуйте начать с "How to Ensure Idempotency in an Eventual Consistent DDD/CQRS Application" и "(Un)Reliability in Messaging".

Ваши события должны будут изменить свою схему со временем

Вам необходимо найти способ документировать события и делиться схемами с потребителями. Нам нравится использовать схему JSON и разметку, потому что это просто, но есть и другой уровень техники. Грег Янг написал целую книгу об управлении системами, управляемыми событиями, с течением времени: Versioning in an Event Sourced System (Leanpub).

Больше обязательного чтения

Еще несколько книг, которые мы хотели бы порекомендовать в помощь читателю:

  • Clean Architectures in Python Леонардо Джордани (Leanpub), вышедшая в 2019 году, является одной из немногих предыдущих книг по архитектуре приложений на Python.

  • Enterprise Integration Patterns Грегора Хохпе и Бобби Вульфа (Addison-Wesley Professional) - это довольно хорошее начало для изучения моделей обмена сообщениями.

  • Monolith to Microservices Сэма Ньюмана (O’Reilly), а также первая книга Ньюмана, Building Microservices (O’Reilly). Узор Strangler Fig упоминается как любимая, наряду со многими другими. С ними стоит ознакомиться, если вы думаете о переходе на микросервисы, также они хорошо описывают паттерны интеграции и соображения, связанные с асинхронным обменом сообщениями на основе интеграции.

Подведение итогов

Фух! Это множество предупреждений и предложений по чтению; надеемся, что мы не отпугнули вас окончательно. Наша цель в этой книге - дать вам достаточно знаний и интуиции, чтобы вы могли начать строить что-то из этого для себя. Мы будем рады узнать, как у вас идут дела и с какими проблемами вы сталкиваетесь при использовании техник в своих системах, так почему бы не связаться с нами по адресу www.cosmicpython.com?

Appendix B: Сводная диаграмма и таблица

Вот как выглядит наша архитектура к концу книги:

diagram showing all components: flask+eventconsumer, service layer, adapters, domain etc

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

Table 12. Компоненты нашей архитектуры и то, что все они делают
Уровень Компонент Описание

Домен

определяет бизнес-логику..

Entity

Объект домена, атрибуты которого могут меняться, но который имеет узнаваемую идентичность с течением времени.

Value object

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

Aggregate

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

Event

Событие Представляет собой нечто произошедшее.

Command

Представляет собой задание, которое должна выполнить система.

Сервисный уровень

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

Handler

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

Unit of work

Абстракция вокруг целостности данных. Каждая единица работы представляет собой атомарное обновление. Делает репозитории доступными. Отслеживает новые события по полученным агрегатам.

Message bus (internal)

Обрабатывает команды и события, направляя их в соответствующий обработчик.

Адаптеры (вторичные)

Конкретные реализации интерфейса, который идет от нашей системы к внешнему миру (вход/выход)..

Repository

Абстракция вокруг постоянного хранилища. Каждый агрегат имеет свой собственный репозиторий.

Event publisher

Выдаёт события на внешнюю шину сообщений.

Entrypoints (первичные адаптеры)

Переводить внешние входные данные в вызовы на сервисном уровне..

Web

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

Event consumer

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

N/A

Внешняя шина сообщений (брокер сообщений)

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

Appendix C: Шаблонная структура проекта

Примерно в [главе_04_service_layer] мы перешли от простого хранения всего в одной папке к более структурированному дереву, и подумали, что будет интересно описать переместившиеся части.

Код этого приложения находится в ветке appendix_project_structure на GitHub:

git clone https://github.com/cosmicpython/code.git
cd code
git checkout appendix_project_structure

Основная структура папок выглядит следующим образом:

Example 180. Project tree
.
├── Dockerfile  (1)
├── Makefile  (2)
├── README.md
├── docker-compose.yml  (1)
├── license.txt
├── mypy.ini
├── requirements.txt
├── src  (3)
│   ├── allocation
│   │   ├── __init__.py
│   │   ├── adapters
│   │   │   ├── __init__.py
│   │   │   ├── orm.py
│   │   │   └── repository.py
│   │   ├── config.py
│   │   ├── domain
│   │   │   ├── __init__.py
│   │   │   └── model.py
│   │   ├── entrypoints
│   │   │   ├── __init__.py
│   │   │   └── flask_app.py
│   │   └── service_layer
│   │       ├── __init__.py
│   │       └── services.py
│   └── setup.py  (3)
└── tests  (4)
    ├── conftest.py  (4)
    ├── e2e
    │   └── test_api.py
    ├── integration
    │   ├── test_orm.py
    │   └── test_repository.py
    ├── pytest.ini  (4)
    └── unit
        ├── test_allocate.py
        ├── test_batches.py
        └── test_services.py
1 docker-compose.yml и Dockerfile являются основными элементами конфигурации для контейнеров, которые запускают приложение, и они также могут запускать тесты (для CI). Более сложный проект может иметь несколько Docker-файлов, хотя замечено, что минимизация количества образов обычно является хорошей идеей.[40]
2 Makefile обеспечивает точку входа для всех типичных команд, которые разработчик (или сервер CI) может захотеть выполнить во время своего обычного рабочего процесса: make build, make test и так далее.[41] Это необязательно. Вы можете просто использовать docker-compose и pytest напрямую, но если ничего другого нет, то приятно иметь все "общие команды". в каком-нибудь списке, и в отличие от документации, Makefile - это код, поэтому он имеет меньше шансов устареть.
3 Весь исходный код приложения, включая модель домена, приложение Flask и код инфраструктуры, находится в пакете Python внутри src,[42] который мы устанавливаем с помощью pip install -e и файла setup.py. Это упрощает импорт. В настоящее время структура внутри этого модуля абсолютно плоская, но для более сложного проекта можно ожидать роста иерархии папок, включающей в себя domain_model/, infrastructure/, services/, and api/.
4 Тесты живут в своей собственной папке. Вложенные папки выделяют различные типы тестов и позволяют запускать их отдельно. Можно хранить общие фикстуры (conftest.py) в основной папке с тестами и при желании вложить более специфические. Это также место для хранения pytest.ini.
На сайте pytest docs очень хорошо описана компоновка тестов и возможность их импорта.

Давайте рассмотрим некоторые из этих файлов и концепций более подробно.

C.1. Env Vars, 12-Factor, и Config, внутренние и внешние контейнеры

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

  • Запуск кода или тестов непосредственно с вашей собственной dev-машины, возможно, обращаясь к сопоставленным портам из контейнеров Docker

  • Запуск на самих контейнерах, с "реальными" портами и именами хостов

  • Различные контейнерные среды (dev, staging, prod и т.д.)

Конфигурирование через переменные окружения, как это предлагается в 12-факторный манифест, решит эту проблему, но конкретно, как мы реализуем это в коде и контейнерах?

C.2. Config.py

Всякий раз, когда коду приложения требуется доступ к некоторой конфигурации, он будет получать её из файла под названием config.py. Вот несколько примеров:

Example 181. Примеры функций конфигурации (src/allocation/config.py)
import os

def get_postgres_uri():  (1)
    host = os.environ.get('DB_HOST', 'localhost')  (2)
    port = 54321 if host == 'localhost' else 5432
    password = os.environ.get('DB_PASSWORD', 'abc123')
    user, db_name = 'allocation', 'allocation'
    return f"postgresql://{user}:{password}@{host}:{port}/{db_name}"


def get_api_url():
    host = os.environ.get('API_HOST', 'localhost')
    port = 5005 if host == 'localhost' else 80
    return f"http://{host}:{port}"
1 Используем функции для получения текущей конфигурации, а не константы, доступные во время импорта, потому что это позволяет клиентскому коду изменять os.environ, если это необходимо.
2 В config.py также определены некоторые настройки по умолчанию, предназначенные для работы при запуске кода с локальной машины разработчика.[43]

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

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

C.3. Docker-Compose и Containers Config

Мы используем легкий инструмент оркестровки контейнеров Docker под названием docker-compose. Его основная конфигурация осуществляется через YAML-файл (вздох):[44]

Example 182. docker-compose config file (docker-compose.yml)
version: "3"
services:

  app:  (1)
    build:
      context: .
      dockerfile: Dockerfile
    depends_on:
      - postgres
    environment:  (3)
      - DB_HOST=postgres  (4)
      - DB_PASSWORD=abc123
      - API_HOST=app
      - PYTHONDONTWRITEBYTECODE=1  (5)
    volumes:  (6)
      - ./src:/src
      - ./tests:/tests
    ports:
      - "5005:80"  (7)


  postgres:
    image: postgres:9.6  (2)
    environment:
      - POSTGRES_USER=allocation
      - POSTGRES_PASSWORD=abc123
    ports:
      - "54321:5432"
1 В файле docker-compose определяем различные сервисы (контейнеры), которые нужны для нашего приложения. Обычно один главный образ содержит весь код, и можно использовать его для запуска API, тестов или любого другого сервиса, которому нужен доступ к доменной модели.
2 Вероятно, у вас будут и другие инфраструктурные службы, включая базу данных. В производстве вы можете не использовать для этого контейнеры; Вы можете использовать облачного провайдера, но docker-compose дает нам возможность создать подобный сервис для dev или CI.
3 Строфа environment позволяет вам установить переменные окружения для ваших контейнеров, имена хостов и портов, как это видно из кластера Docker. Если у вас достаточно контейнеров, чтобы информация начала дублироваться в этих разделах, вы можете использовать environment_file вместо этого. Мы обычно называем наш container.env.
4 Внутри кластера docker-compose устанавливает сетевое взаимодействие таким образом, чтобы контейнеры были доступны друг другу через имена хостов, названные по имени их сервиса.
5 Совет профессионала: Если вы монтируете тома для обмена папками с исходными текстами между локальной машиной разработчиков и контейнером, переменная окружения PYTHONDONTWRITEBYTECODE указывает Python не писать файлы .pyc, и это избавит вас от миллионов файлов, принадлежащих корню, разбросанных по всей локальной файловой системе, раздражающих удалением и вызывающих странные ошибки компилятора Python.
6 Монтирование исходного и тестового кода в виде томов означает, что нам не нужно перестраивать наши контейнеры каждый раз, когда мы вносим изменения в код.
7 Секция ports позволяет нам открывать порты из контейнеров во внешний мирСноска:[На CI-сервере вы не сможете надежно открывать произвольные порты, но это лишь удобство для локальных разработчиков. Вы можете найти способы сделать эти сопоставления портов необязательными (например, с помощью docker-compose.override.yml).]- они соответствуют портам по умолчанию, которые заданы в config.py.
Внутри Docker другие контейнеры доступны через имена хостов, названные по имени их службы. Вне Docker они доступны на localhost, на порту, определенном в разделе ports.

C.4. Установка вашего источника в виде пакета

Весь код всё, кроме тестов) находится в папке src:

Example 183. Папка src
1 Вложенные папки определяют имена модулей верхнего уровня. Вы можете иметь несколько, если хотите.
2 А setup.py - это файл, который нужен для того, чтобы сделать его pip-инсталлируемым, как показано далее.
Example 184. pip-устанавливаемые модули в трех линиях (src/setup.py)
from setuptools import setup

setup(
    name='allocation',
    version='0.1',
    packages=['allocation'],
)

Это все, что вам нужно. packages= указывает имена вложенных папок, которые вы хотите установить в качестве модулей верхнего уровня. Запись " имя " является просто косметической, но она обязательна. Для пакета, который на самом деле никогда не попадет в PyPI, это будет прекрасно.сноска:[Дополнительные советы setup.py см. в разделе эта статья об упаковке Хайнека.]

C.5. Dockerfile

Dockerfiles будет очень специфичным для конкретного проекта, но вот несколько основных этапов, которые ожидаемы:

Example 185. Наш Dockerfile (Dockerfile)
FROM python:3.8-alpine

(1)
RUN apk add --no-cache --virtual .build-deps gcc postgresql-dev musl-dev python3-dev
RUN apk add libpq

(2)
COPY requirements.txt /tmp/
RUN pip install -r /tmp/requirements.txt

RUN apk del --no-cache .build-deps

(3)
RUN mkdir -p /src
COPY src/ /src/
RUN pip install -e /src
COPY tests/ /tests/

(4)
WORKDIR /src
ENV FLASK_APP=allocation/entrypoints/flask_app.py FLASK_DEBUG=1 PYTHONUNBUFFERED=1
CMD flask run --host=0.0.0.0 --port=80
1 Установка зависимостей системного уровня
2 Установка зависимостей Python (возможно, вы захотите разделить свои зависимости dev и prod; для простоты мы этого не делаем).
3 Копирование и установка источника
4 Опциональная настройка команды запуска по умолчанию (вероятно, вы будете часто изменять её из командной строки)
Следует отметить, что мы устанавливаем сущности и значения в том порядке, как часто они могут меняться. Это позволяет нам максимально использовать кэш сборки Docker повторно. Я не могу передать, сколько боли и разочарования лежит в основе этого урока. Этот и многие другие советы по улучшению Python Dockerfile смотрите на сайте "Production-Ready Docker Packaging".

C.6. Тесты

Тесты хранятся вместе со всем остальным, как показано здесь:

Example 186. Дерево папок тестов
└── tests
    ├── conftest.py
    ├── e2e
    │   └── test_api.py
    ├── integration
    │   ├── test_orm.py
    │   └── test_repository.py
    ├── pytest.ini
    └── unit
        ├── test_allocate.py
        ├── test_batches.py
        └── test_services.py

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

В тестовых папках нет папки src или setup.py, потому что обычно нам не нужно делать тесты pip-инсталлируемыми, но если у вас есть трудности с путями импорта, это может вам помочь.

C.7. Подведение итогов

Это наши основные строительные блоки:

  • Исходный код в папке src, pip-инсталлируемый с помощью setup.py.

  • Некоторые конфигурации Docker для создания локального кластера, максимально повторяющего производственный.

  • Конфигурация через переменные окружения, централизованные в файле Python под названием config.py, с настройками по умолчанию, позволяющими запускать вещи вне контейнеров.

  • Makefile для полезных команд командной строки.

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

Appendix D: Замена инфраструктуры: Делайте все с CSV

Это приложение предназначено в качестве небольшой иллюстрации преимуществ паттернов Repository, Unit of Work и Service Layer. Она должна следовать из [главы_06_uow].

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

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

Вот тест E2E, показывающий, как CSV поступают и выводятся:

Example 187. Первый тест CSV (tests/e2e/test_csv.py)
def test_cli_app_reads_csvs_with_batches_and_orders_and_outputs_allocations(
        make_csv
):
    sku1, sku2 = random_ref('s1'), random_ref('s2')
    batch1, batch2, batch3 = random_ref('b1'), random_ref('b2'), random_ref('b3')
    order_ref = random_ref('o')
    make_csv('batches.csv', [
        ['ref', 'sku', 'qty', 'eta'],
        [batch1, sku1, 100, ''],
        [batch2, sku2, 100, '2011-01-01'],
        [batch3, sku2, 100, '2011-01-02'],
    ])
    orders_csv = make_csv('orders.csv', [
        ['orderid', 'sku', 'qty'],
        [order_ref, sku1, 3],
        [order_ref, sku2, 12],
    ])

    run_cli_script(orders_csv.parent)

    expected_output_csv = orders_csv.parent / 'allocations.csv'
    with open(expected_output_csv) as f:
        rows = list(csv.reader(f))
    assert rows == [
        ['orderid', 'sku', 'qty', 'batchref'],
        [order_ref, sku1, '3', batch1],
        [order_ref, sku2, '12', batch2],
    ]

Погружаясь в процесс и реализуя его, не думая о репозиториях и прочей чепухе, вы можете начать с чего-то вроде этого:

Example 188. Первый срез нашего CSV reader/writer (src/bin/allocate-from-csv)
#!/usr/bin/env python
import csv
import sys
from datetime import datetime
from pathlib import Path

from allocation import model

def load_batches(batches_path):
    batches = []
    with batches_path.open() as inf:
        reader = csv.DictReader(inf)
        for row in reader:
            if row['eta']:
                eta = datetime.strptime(row['eta'], '%Y-%m-%d').date()
            else:
                eta = None
            batches.append(model.Batch(
                ref=row['ref'],
                sku=row['sku'],
                qty=int(row['qty']),
                eta=eta
            ))
    return batches



def main(folder):
    batches_path = Path(folder) / 'batches.csv'
    orders_path = Path(folder) / 'orders.csv'
    allocations_path = Path(folder) / 'allocations.csv'

    batches = load_batches(batches_path)

    with orders_path.open() as inf, allocations_path.open('w') as outf:
        reader = csv.DictReader(inf)
        writer = csv.writer(outf)
        writer.writerow(['orderid', 'sku', 'batchref'])
        for row in reader:
            orderid, sku = row['orderid'], row['sku']
            qty = int(row['qty'])
            line = model.OrderLine(orderid, sku, qty)
            batchref = model.allocate(line, batches)
            writer.writerow([line.orderid, line.sku, batchref])



if __name__ == '__main__':
    main(sys.argv[1])

Выглядит не так уж плохо! И мы повторно используем объекты нашей доменной модели и нашу доменную службу.

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

Example 189. И еще один, с существующими распределениями (tests/e2e/test_csv.py)
def test_cli_app_also_reads_existing_allocations_and_can_append_to_them(
        make_csv
):
    sku = random_ref('s')
    batch1, batch2 = random_ref('b1'), random_ref('b2')
    old_order, new_order = random_ref('o1'), random_ref('o2')
    make_csv('batches.csv', [
        ['ref', 'sku', 'qty', 'eta'],
        [batch1, sku, 10, '2011-01-01'],
        [batch2, sku, 10, '2011-01-02'],
    ])
    make_csv('allocations.csv', [
        ['orderid', 'sku', 'qty', 'batchref'],
        [old_order, sku, 10, batch1],
    ])
    orders_csv = make_csv('orders.csv', [
        ['orderid', 'sku', 'qty'],
        [new_order, sku, 7],
    ])

    run_cli_script(orders_csv.parent)

    expected_output_csv = orders_csv.parent / 'allocations.csv'
    with open(expected_output_csv) as f:
        rows = list(csv.reader(f))
    assert rows == [
        ['orderid', 'sku', 'qty', 'batchref'],
        [old_order, sku, '10', batch1],
        [new_order, sku, '7', batch2],
    ]

И мы могли бы продолжать халтурить и добавлять дополнительные строки в эту функцию load_batches, и какой-то способ отслеживания и сохранения новых распределений - но у нас уже есть модель для этого! Это называется нашими шаблонами Repository и Unit of Work.

Все, что нам нужно сделать ("all we need to do") - это заново реализовать те же абстракции, но с CSV, лежащими в их основе, вместо базы данных. И, как вы увидите, это действительно относительно просто.

D.1. Реализация репозитория и единицы работы для CSV

Вот как может выглядеть хранилище на основе CSV. Он абстрагирует всю логику чтения CSV с диска, включая тот факт, что он должен читать два разных CSV (один для партий и один для распределений), и дает нам только знакомый API .list(), который обеспечивает иллюзию коллекции объектов домена в памяти:

Example 190. Хранилище, использующее CSV в качестве механизма хранения данных (src/allocation/service_layer/csv_uow.py)
class CsvRepository(repository.AbstractRepository):

    def __init__(self, folder):
        self._batches_path = Path(folder) / 'batches.csv'
        self._allocations_path = Path(folder) / 'allocations.csv'
        self._batches = {}  # type: Dict[str, model.Batch]
        self._load()

    def get(self, reference):
        return self._batches.get(reference)

    def add(self, batch):
        self._batches[batch.reference] = batch

    def _load(self):
        with self._batches_path.open() as f:
            reader = csv.DictReader(f)
            for row in reader:
                ref, sku = row['ref'], row['sku']
                qty = int(row['qty'])
                if row['eta']:
                    eta = datetime.strptime(row['eta'], '%Y-%m-%d').date()
                else:
                    eta = None
                self._batches[ref] = model.Batch(
                    ref=ref, sku=sku, qty=qty, eta=eta
                )
        if self._allocations_path.exists() is False:
            return
        with self._allocations_path.open() as f:
            reader = csv.DictReader(f)
            for row in reader:
                batchref, orderid, sku = row['batchref'], row['orderid'], row['sku']
                qty = int(row['qty'])
                line = model.OrderLine(orderid, sku, qty)
                batch = self._batches[batchref]
                batch._allocations.add(line)

    def list(self):
        return list(self._batches.values())

А вот как будет выглядеть UoW для CSV:

Example 191. UoW для CSV: commit = csv.writer (src/allocation/service_layer/csv_uow.py)
class CsvUnitOfWork(unit_of_work.AbstractUnitOfWork):

    def __init__(self, folder):
        self.batches = CsvRepository(folder)

    def commit(self):
        with self.batches._allocations_path.open('w') as f:
            writer = csv.writer(f)
            writer.writerow(['orderid', 'sku', 'qty', 'batchref'])
            for batch in self.batches.list():
                for line in batch._allocations:
                    writer.writerow(
                        [line.orderid, line.sku, line.qty, batch.reference]
                    )

    def rollback(self):
        pass

И как только мы получим это, наше приложение CLI для чтения и записи партий и распределений в CSV будет сведено к тому, чем оно должно быть - немного кода для чтения строк заказов, и немного кода, который вызывает наш existing service layer:

Example 192. Распределение с CSV в девять строк (src/bin/allocate-from-csv)
def main(folder):
    orders_path = Path(folder) / 'orders.csv'
    uow = csv_uow.CsvUnitOfWork(folder)
    with orders_path.open() as f:
        reader = csv.DictReader(f)
        for row in reader:
            orderid, sku = row['orderid'], row['sku']
            qty = int(row['qty'])
            services.allocate(orderid, sku, qty, uow)

Та-да! Ну что, вы впечатлены или как?

С большой любовью,

Боб и Гарри

Appendix E: Validation

Всякий раз, когда мы преподаем и говорим об этих техниках, один из вопросов, который возникает снова и снова: "Где я должен проводить валидацию? Относится ли это к моей бизнес-логике в доменной модели, или это инфраструктурная проблема?".

Как и на любой архитектурный вопрос, ответ таков: это зависит!

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

E.1. Что такое валидация?

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

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

E.2. Проверка синтаксиса

В лингвистике синтаксис языка - это набор правил, регулирующих структуру грамматических предложений. Например, в английском языке предложение "Allocate three units of TASTELESS-LAMP to order twenty-seven" является грамматически правильным, в то время как фраза "hat hat hat hat hat wibble" не является. Мы можем описать грамматически правильные предложения как хорошо сформированные.

Как это связано с нашим приложением? Вот несколько примеров синтаксических правил:

  • Команда Allocate должна содержать идентификатор заказа, SKU и количество.

  • Количество - это целое положительное число.

  • SKU - это строка.

Это правила относительно формы и структуры входящих данных. Команда Allocate без SKU или ID заказа не является действительным сообщением. Это эквивалент фразы "Выделите три для".

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

Один из вариантов - поместить логику проверки в сам тип сообщения:

Example 193. Проверка класса сообщений (src/allocation/commands.py)
from schema import And, Schema, Use


@dataclass
class Allocate(Command):

    _schema = Schema({  (1)
        'orderid': int,
         sku: str,
         qty: And(Use(int), lambda n: n > 0)
     }, ignore_extra_keys=True)

    orderid: str
    sku: str
    qty: int

    @classmethod
    def from_json(cls, data):  (2)
       data = json.loads(data)
       return cls(**_schema.validate(data))
1 schema library позволяет нам описать структуру и проверку наших сообщений в приятной декларативной форме.
2 Метод from_json считывает строку как JSON и превращает ее в наш тип сообщения.

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

Example 194. Фабрика команд со схемой (src/allocation/commands.py)
def command(name, **fields):  (1)
    schema = Schema(And(Use(json.loads), fields), ignore_extra_keys=True)
    cls = make_dataclass(name, fields.keys())  (2)
    cls.from_json = lambda s: cls(**schema.validate(s))  (3)
    return cls

def greater_than_zero(x):
    return x > 0

quantity = And(Use(int), greater_than_zero)  (4)

Allocate = command(  (5)
    orderid=int,
    sku=str,
    qty=quantity
)

AddStock = command(
    sku=str,
    qty=quantity
1 Функция command принимает имя сообщения, плюс kwargs для полей полезной нагрузки сообщения, где имя kwarg - имя поля, а значение - парсер.
2 Мы используем функцию make_dataclass из модуля dataclass для динамического создания нашего типа сообщения.
3 Мы добавляем метод from_json в наш динамический класс данных.
4 Мы можем создать многократно используемые парсеры для количества, SKU и так далее, чтобы сохранить все DRY.
5 Объявление типа сообщения становится однострочным.

Это происходит за счет потери типов в вашем классе данных, так что имейте в виду этот компромисс.

E.3. Закон Постеля и модель толерантного читателя

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

Например, наша система может проверять формат SKU. Мы использовали такие выдуманные SKU, как UNFORGIVING-CUSHION и MISBEGOTTEN-POUFFE. Они следуют простой схеме: два слова, разделенные тире, где второе слово - тип продукта, а первое слово - прилагательное.

Разработчики любят проверять такие вещи в своих сообщениях и отклоняют все, что выглядит как недопустимый SKU. Это приводит к ужасным проблемам в будущем, когда какой-нибудь анархист выпустит продукт под названием COMFY-CHAISE-LONGUE или когда в результате ошибки поставщика будет поставлена партия CHEAP-CARPET-2.

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

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

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

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

Поскольку эти поля не имеют значения для службы распределения, мы должны просто игнорировать их. Мы можем сделать это в библиотеке schema, передав ключевое слово arg ignore_extra_keys=True.

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

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

Упоминание о Постеле может вызвать у некоторых товарищей сильное раздражение. Они скажут вам, что Postel - это точная причина того, что все в интернете сломано, и мы не можем иметь хорошие вещи. Как-нибудь спросите Хайнека о SSLv3.

Нам нравится подход Tolerant Reader в конкретном контексте интеграции на основе событий между сервисами, которые мы контролируем, потому что он позволяет независимое развитие этих сервисов.

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

E.4. Проверка на границе

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

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

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

Example 195. Validation
class MessageBus:

    def handle_message(self, name: str, body: str):
        try:
            message_type = next(mt for mt in EVENT_HANDLERS if mt.__name__ == name)
            message = message_type.from_json(body)
            self.handle([message])
        except StopIteration:
            raise KeyError(f"Unknown message name {name}")
        except ValidationError as e:
            logging.error(
                f'invalid message of type {name}\n'
                f'{body}\n'
                f'{e}'
            )
            raise e

Вот как мы можем использовать этот метод из конечной точки Flask API:

Example 196. API выдает ошибки валидации (src/allocation/flask_app.py)
@app.route("/change_quantity", methods=['POST'])
def change_batch_quantity():
    try:
        bus.handle_message('ChangeBatchQuantity', request.body)
    except ValidationError as e:
        return bad_request(e)
    except exceptions.InvalidSku as e:
        return jsonify({'message': str(e)}), 400

def bad_request(e: ValidationError):
    return e.code, 400

И вот как мы могли бы подключить его к нашему асинхронному процессору сообщений:

Example 197. Ошибки проверки при обработке сообщений Redis (src/allocation/redis_pubsub.py)
def handle_change_batch_quantity(m, bus: messagebus.MessageBus):
    try:
        bus.handle_message('ChangeBatchQuantity', m)
    except ValidationError:
       print('Skipping invalid message')
    except exceptions.InvalidSku as e:
        print(f'Unable to change stock for missing sku {e}')

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

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

E.5. Проверка семантики

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

Мы можем прочитать этот JSON-блоб как команду Allocate, но не можем успешно выполнить ее, потому что это бессмыслица:

Example 198. Бессмысленное сообщение
{
  "orderid": "superman",
  "sku": "zygote",
  "qty": -1
}

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

Example 199. Предпосылки (src/allocation/ensure.py)
"""
Этот модуль содержит предварительные условия, которые мы применяем к нашим обработчикам.
"""

class MessageUnprocessable(Exception):  (1)

    def __init__(self, message):
        self.message = message

class ProductNotFound(MessageUnprocessable):  (2)
   """"
   Это исключение возникает, когда мы пытаемся выполнить действие над продуктом
   которого не существует в нашей базе данных.
   """"

    def __init__(self, message):
        super().__init__(message)
        self.sku = message.sku

def product_exists(event, uow):  (3)
    product = uow.products.get(event.sku)
    if product is None:
        raise ProductNotFound(event)
1 Мы используем общий базовый класс для ошибок, которые означают, что сообщение недопустимо.
2 Использование конкретного типа ошибки для этой проблемы облегчает составление отчета и обработку ошибки. Например, во Flask легко сопоставить ProductNotFound с 404.
3 product_exists является предварительным условием. Если условие False, мы выдаем ошибку.

Таким образом, основной поток нашей логики в сервисном уровне остается чистым и декларативным:

Example 200. Обеспечение вызовов в службах (src/allocation/services.py)
# services.py

from allocation import ensure

def allocate(event, uow):
    line = model.OrderLine(event.orderid, event.sku, event.qty)
    with uow:
        ensure.product_exists(event, uow)

        product = uow.products.get(line.sku)
        product.allocate(line)
        uow.commit()

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

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

Example 201. Вызвать исключение SkipMessage для игнорируемых событий (src/allocation/services.py)
class SkipMessage (Exception):
    """"
    Это исключение возникает, когда сообщение не может быть обработано,
	но нет никакого неправильного поведения. Например, мы можем получить
	одно и то же сообщение несколько раз, или мы можем получить сообщение,
	которое уже устарело.
    """"

    def __init__(self, reason):
        self.reason = reason

def batch_is_new(self, event, uow):
    batch = uow.batches.get(event.batchid)
    if batch is not None:
        raise SkipMessage(f"Batch with id {event.batchid} already exists")

Внедрение исключения SkipMessage позволяет нам обрабатывать эти случаи общим способом в нашей шине сообщений:

Example 202. Автобус теперь умеет пропускать (src/allocation/messagebus.py)
class MessageBus:

    def handle_message(self, message):
        try:
           ...
       except SkipMessage as e:
           logging.warn(f"Skipping message {message.id} because {e.reason}")

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

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

E.6. Проверка прагматики

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

Подведение итогов валидации
Валидация означает разные поняттия для разных товарищей

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

Проверяйте по границе, когда это возможно

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

Проверьте только то, что вам нужно

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

Потратьте время на написание хелперов для проверки

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

Найдите каждый из трех типов валидации в нужном месте

Проверка синтаксиса может происходить на классах сообщений, проверка семантики может происходить на сервисном уровне или на шине сообщений, а проверка прагматики относится к модели домена.

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

В терминах программного обеспечения прагматика операции обычно управляется моделью домена. Когда мы получаем сообщение типа "выделите три миллиона единиц SCARCE-CLOCK для заказа 76543," сообщение является синтаксически действительным и семантически действительным, но мы не можем его выполнить, потому что у нас нет в наличии запасов.


1. Прим переводчика: Jump the Shark—​метафора, используемая американскими телевизионными критиками и фэнами с 1990-х годов для обозначения момента, когда телевизионный сериал проходит пик успешности. По мере роста популярности этой идиомы она «обтесалась» до простого обозначения любого снижения качества.
1. Прим переводчика: CQRS—​Разделение ответственности по командам и запросам
2. python -c "import this"
3. Прим. переводчика: Судя по всему, имеется ввиду песня "How do I get there from here" (Disney) В исполнении: China Anne McClain: Я шагнула в новом направлении Сейчас я слушаю своё сердце, а не разум Но всё, что я чувствую, я подвергаю сомнениям Что если я неправа…​ Что же дальше? Существует ли иная дорога?
4. Если вы сталкивались с карточками class-responsibility-collaborator (CRC), то они основаны на одном и том же: размышления об responsibilities ответственности поможет вам решить, как разделить составляющие на части.
5. SOLID — это аббревиатура от пяти принципов объектно-ориентированного проектирования Роберта К. Мартина: единственная ответственность, открытость для расширения, но закрытость для модификации, подстановка Лискова, сегрегация интерфейсов и инверсия зависимостей. См "S.O.L.I.D: Первые 5 принципов Объектно-ориентированного проектирования" Самуэля Олорунтоба.
6. YOLO (You Only Live Once, Живёшь только раз) – аббревиатура фразы “живем один раз”
7. DDD не был инициатором моделирования предметной области. Эрик Эванс ссылается на книгу 2002 года Object Design за авторством Rebecca Wirfs-Brock и Alan McKean (Addison-Wesley Professional), который ввел дизайн, основанный на ответственности, из которых DDD является частным случаем, связанным с доменом. Но даже это слишком поздно, и энтузиасты ОО скажут вам, чтобы вы посмотрели дальше назад на Ивара Якобсона и Грейди Буча; этот термин существует с середины 1980-х годов.
8. ETA (англ. Estimated time of arrival) — ожидаемое время прибытия.
9. В предыдущих версиях Python мы могли использовать именованный кортеж (namedtuple). Вы также можете ознакомиться с отличными предложениями Hynek Schlawack. attrs.
10. Или вы думаете, что кода недостаточно? Как насчет какой-то проверки того, что SKU в OrderLine совпадает с Batch.sku? Мы сохранили некоторые мысли о валидации для Validation.
11. Это ужасно. Пожалуйста, пожалуйста, не делай этого. —Harry
12. __eq__ произносится как "dunder-EQ". По крайней мере, некоторыми.
13. Domain services это не то же самое, что услуги от service layer, хотя они часто тесно связаны. Доменная служба представляет собой бизнес-концепцию или процесс, в то время как служба уровня сервиса представляет собой вариант использования вашего приложения. Часто уровень сервиса вызывает доменную службу.
14. Полагаю, мы имеем в виду «отсутствие зависимостей с отслеживанием состояния». В зависимости от вспомогательной библиотеки это нормально; в зависимости от ORM или веб-фреймворка — нет.
15. Mark Seemann has an excellent blog post on the topic.
16. В этом смысле использование ORM уже является примером DIP. Вместо того чтобы полагаться на жестко запрограммированный SQL, мы зависим от абстракции, ORM. Но нам этого мало — не в этой книге!
17. Даже в проектах, где мы не используем ORM, мы часто используем SQLAlchemy вместе с Alembic для декларативного создания схем в Python и управления миграциями, соединениями и сеансами.
18. Привет чрезвычайно полезным специалистам по сопровождению SQLAlchemy и, в частности, Майку Байеру.
19. Вы можете подумать: «А как насчет list, delete или update?" Однако в идеальном мире мы модифицируем объекты нашей модели по одному, а удаление обычно обрабатывается как мягкое удаление, то есть batch.cancel (). Наконец, об обновлении позаботится шаблон Unit of Work, как вы увидите в Паттерн Unit of Work(Единица работы).
20. Чтобы действительно воспользоваться преимуществами ABC (какими бы они ни были), запустите помощники, такие как pylint и mypy.
21. Диаграмма вдохновлена ​​публикацией под названием «Глобальная сложность, локальная простота» Роба Венса.
22. code kata - это концепция, предлагающая оттачивать навыки программиста делая небольшие проблемы много раз, пытаясь улучшить код на каждой итерации. Название происходит от аналогии с Ката боевых искусств , где формы (aka kata) - это практика, выполняемая над и в результате улучшений. code kata - это небольшая, содержательная задача программирования, часто используемая для практики TDD. См. "Kata—The Only Way to Learn TDD" автор: Питер Провост.
23. Если вы привыкли мыслить терминами интерфейсов, то мы пытаемся дать определение именно этому. Прим переводчика: https://habr.com/ru/post/30444/
24. PEP 544 — Protocols: Structural subtyping (static duck typing) https://www.python.org/dev/peps/pep-0544/
25. "Лондонская школа", которая больше ориентирована на тестирование взаимодействия, mocking и end-to-end TDD, с особым упором на дизайн, основанный на ответственности, и подход «Говори, не спрашивай» к объектно-ориентированному дизайну, недавно повторно популяризированному Стивом Фриманом и Нэтом Прайсом, в потрясающей книге Growing Object Oriented Software Guided By Tests. http://codemanship.co.uk/parlezuml/blog/?postid=987
26. Это не значит, что мы считаем, что люди из лондонской школы ошибаются. Некоторые безумно умные люди работают именно так. Просто это не то, к чему мы привыкли.
27. Службы сервисного уровня и доменные службы имеют до смешного похожие имена. Мы обсудим эту тему позже. Почему всё называется сервисом?.
28. Обоснованное беспокойство по поводу написания тестов на более высоком уровне заключается в том, что это может привести к комбинаторному взрыву для более сложных случаев использования. В этих случаях может быть полезно перейти к модульным тестам более низкого уровня различных сотрудничающих объектов домена. Смотрите также События и шина сообщений и Опционально: Модульное тестирование Event Handlers изолированно с Fake Message Bus.
29. Возможно, вы встречали слово collaborators для описания объектов, которые работают вместе для достижения цели. Единица работы и репозиторий - отличный пример сотрудничества в объектном моделировании. В дизайне, ориентированном на ответственность, кластеры объектов, взаимодействующих в своих ролях, называются object neighborhoods ближайшими соседями, что, по нашему профессиональному мнению, совершенно восхитительно.
30. time.sleep() Хорошо работает в нашем случае использования, но это не самый надежный или эффективный способ воспроизвести ошибки параллелизма. Рассмотрите возможность использования семафоров или аналогичных примитивов синхронизации, разделяемых между потоками, чтобы получить лучшие гарантии поведения.
31. Если вы не используете Postgres, вам нужно будет прочитать другую документацию. Досадно, но разные базы данных дают совершенно разные определения. Oracle SERIALIZABLE эквивалентен Postgres REPEATABLE READ, для example.
32. Наш технический обозреватель Эд Юнг любит говорить, что когда вы переходите от императивного управления потоком к управлению потоком на основе событий, вы меняете orchestration на choreography.
33. Моделирование на основе событий настолько популярно, что для облегчения сбора требований на основе событий и разработки модели предметной области была разработана практика под названием event storming.
34. Если вы немного читали об архитектуре, управляемой событиями, вы можете подумать: "Некоторые из этих событий больше похожи на команды!" Терпение граждане! Мы пытаемся ввести одну концепцию за раз. В следующей главе, мы введем различие между командами и событиями.
35. «Простая» реализация в этой главе по существу использует сам модуль messagebus.py для реализации шаблона Singleton.
36. Мы используем эти термины как взаимозаменяемые, но CQS обычно применяется к одному классу или модулю: функции, которые считывают состояние, должны быть отделены от тех, которые его изменяют. А CQRS - это то, что вы применяете ко всему своему приложению: классы, модули, пути кода и даже базы данных, которые читают состояние, могут быть отделены от тех, которые его изменяют.
37. Принцип единственной ответственности (англ. single-responsibility principle, SRP) — принцип ООП, обозначающий, что каждый объект должен иметь одну ответственность и эта ответственность должна быть полностью инкапсулирована в класс. Все его поведения должны быть направлены исключительно на обеспечение этой ответственности.
38. Однако он по-прежнему является глобальным в области видимости модуля flask_app, если это имеет смысл. Это может вызвать проблемы, если вы когда-нибудь захотите протестировать свое приложение Flask в процессе, используя Flask Test Client вместо Docker, как это делаем мы. Стоит изучить Flask app factories, если вы этим займетесь.
39. Сокр. No Operation
40. Разделение образов для производства и тестирования иногда является хорошей идеей, но мы склонны заметить, что идти дальше и пытаться разделить различные образы для различных типов кода приложения (например, Web API против pub/sub клиента) обычно создаёт больше проблем, чем оно того стоит; слишком высокая стоимость с точки зрения сложности и потери времени rebuild/CI. YMMV.
41. Чисто питоновской альтернативой Makefiles является Invoke, которую стоит проверить, если все в вашей команде знают Python (или хотя бы знают его лучше, чем Bash!).
42. "Testing and Packaging" автор Hynek Schlawack предоставляет дополнительную информацию о папках src.
43. Это дает нам локальную систему разработки, которая "просто работает". (как можно больше). Вместо этого вы можете предпочесть жесткую проверку отсутствующих переменных окружения, особенно если любое из значений по умолчанию будет небезопасным в производстве.
44. Гарри немного устал от YAML. Он всюду, и все же он никогда не может вспомнить синтаксис или то, как нужно делать отступы.