Дизайн интеграции с ЕМИАС
В Проекте Э появилась задача выполнить интеграцию с ЕМИАС города Москвы. В какой-то момент показалось, что там будет "сложный случай" в кластеризации диаграммы эффектов. В конечном итоге всё обошлось, но я решил собрать из этого микропост с демонстрацией того, что делать в сложных случаях.
Интеграция с ЕМИАС состоит из трёх частей:
- При регистрации, пользователь может проставить галочку, ввести дополнительные данные (номер полиса ОМС и дату рождения) и в этом случае, система должна идентифицировать пользователя в ЕМИАС и дать отлуп, если не получилось. Вот здесь в конечном итоге обошлось и привязку пользователя к ЕМИАС вынесли в отдельный шаг.
- При подключении устройства пользователя к МП, его также надо привязывать и в ЕМИАС. У нас сейчас на бэке устройств нет, но мы их давно хотим.
- При сохранении замеров с устройства на бэке, их надо переслать в ЕМИАС. Это у нас уже третья подобная интеграция, так что события об изменении дневников у нас уже публикуются в RabbitMQ и на них надо просто подписаться.
Кроме того, второй очередью надо будет сделать привязку существующих пользователей к ЕМИАС.
Перед реализацией интеграции диаграмма эффектов релевантной части системы выглядит так:
Теперь добавим на диаграмму операции и ресурсы, необходимые для реализации интеграции:
- Операция "Привязать пользователя к ЕМИАС";
- Ресурс ЕМИАС;
- Ресурс "Привязка пользователей к ЕМИАС", который по сути будет хранить пару ИДов;
- Операция "Зарегистрировать пользователя с привязкой к ЕМИАС", которая должна обладать эффектами и регистрации пользователя и привязки к ЕМИАС;
- Операция "Обновить список устройств пользователя";
- Ресурс "Устройства пользователя";
Ресурс-топик "Устройства обновлены".
Я тут пользуясь форматом микропоста немного забежал вперёд: в "реальной жизни" этот ресурс может появиться не сразу, но я знаю, что хочу прийти к интеграции в отдельном сервисе, который полностью изолирован от ядра.
- Операция "Зарегистрировать устройство в ЕМИАС";
- Ресурс "Привязков устройств к ЕМИАС", который по сути будет хранить пару ИДов;
- Операция "Отправить замер в ЕМИАС", которая будет вызываться по появлению события в уже существующем топике "Замер добавлен".
Так же, так как у меня пока нет алгоритма рекластеризации, удалим существующие модули. Наконец, у меня есть идея включить экторов в алгоритм декомпозиции, но пока она очень абстрактная, так что эктора тоже убираем. Проделав всё это, получим следующую диаграмму:
Теперь давайте прогоним на этой диаграмме алгоритм первичной кластеризации. Не буду подробно разбирать прогон, так его ход вполне очевиден. В результате прогона мы получим следующую первичную кластеризацию:
На этой диаграмме остался тот самый "сложный случай" - алгоритм не может решить, куда отнести операцию "Зарегистрировать пользователя с привязкой к ЕМИАС". Давайте доставать свой мозолистый мозг.
Методичка по кластеризации на базе диаграммы эффектов гласит, что при решении сложных случаев с сохранением исходной диаграммы у нас есть три базовых варианта действий:
- Поместить проблемный элемент в один из существующих кластеров;
- Выделить проблемный элемент в собственный кластер.
- Объединить проблемный элемент и оба связанных с ним кластера в один мегакластер.
При разборе этой задачи я понял что есть ещё один вариант, который в целом подпадает под условие "сохранение исходной диаграммы" - удалить проблемный элемент.
Так же, для проблемных операций записи, методичка предлагает рассмотреть вариант расцепки этой операции через очередь сообщений.
Таким образом у нас получаются следующие возможные варианты дизайна:
- Помещение операции в кластер "Аккаунты";
- Помещение операции в кластер "ЕМИАС";
- Помещение операции в собственный кластер "Регистрация пользователя с привязкой к ЕМИАС";
- Объединить всё в кластер… "Аккаунты"?..;
- Расцепить через очередь сообщений с пляской от "Аккаунты";
- Расцепить через очередь сообщений с пляской от "ЕМИАС";
Давайте сначала отметём варианты, которые нам не позволят обеспечить выполнение функциональных и нефункциональных требований.
Первым таким вариантом будет третий - помещение операции в собственный кластер "Регистрация пользователя с привязкой к ЕМИАС". У него, на самом деле, есть два варианта развёртывания и оба они хороши с точки зрения дизайна, но сейчас не очень удобны с точки зрения реализации и развёртывания.
Первый вариант развёртывания - это отдельный сервис. Так мы начнём движение в сторону Backend For Frontend, что мне в целом кажется любопытным направлением. Однако пока что разворачивать отдельный сервис, ради одного эндпоинта практически без бизнес-логики не хочется.
Второй вариант развёртывания - положить этот код в МП.
Этот вариант плох тем, что у операции есть техно-логика - она должна обеспечивать согласованность/атомарность [в конечном итоге] регистрации и привязки пользователя. И на устройстве пользователя в целом возрастает риск, что посреди "транзакции" что-то пойдёт не так, плюс МП - не место для этой логики.
Следующий вариант-невариант - объединить всё в кластер "Аккаунты". Этот вариант не подходит потому что ядро (куда входит модуль Аккаунты) уже довольно сильно разрослось и не хочу тащить туда то, что можно не тащить. Кроме того, вместе с собственным кодом, эта интеграция потащит ещё и зависимость на Кафку, которой в ядре пока нет.
Наконец, последняя пара невариантов - расцепка через очередь. Она не подходит потому что сильно усложнит реализацию [вымышленного на тот момент] требования о синхронном сообщении пользователю об ошибке привязки к ЕМИАС. В этом случае МП надо будет в один из модулей пулять запрос на начало регистрации с привязкой, а потом показывать спиннер и поллить результат.
Таким образом у меня остаются только варианты помещения операции в модуль "Аккаунты" или "ЕМИАС". И оба мне не нравятся. Первый - потому что он размазывает интеграцию по двум модулям. Второй - потому что он размазывает юзкейс "Регистрация пользователя" по двум модулям.
В общем пришло время компромиссных решений.
Давайте теперь проверим оба варианта по всем известным мне принципам дизайна - возможно в процессе получим какой-нибудь железобетонный аргумент в пользу одного из вариантов. А если нет - выберем тот, что набрал больше баллов. Лишь бы не было ничьей:)
Итак, принципы по которым будем оценивать варианты:
- Мудрость древних
- Сокрытие информации
- Сцепленность
- Связанность
- SOLID
- Принципы дизайна пакетов Мартина
- The Reuse/Release Equivalence Principle
- The Common Closure Principle
- The Common Reuse Principle
- The Acyclic Dependencies Principle
- The Stable Dependencies Principle
- The Stable Abstractions Principle
- GRASP
- Information Expert
- Creator
- Controller
- Low Coupling
- High Cohesion
- Polymorphism
- Pure Fabrication
- Indirection
- Protected Variants
- Package Organization Guidelines
Сокрытие информации
У нас оба модуля хорошо скрывают свои секреты - структуры данных, протоколы взаимодействия с внешним миром и т.п. А в любом из вариантов они будут обмениваться буквальной парой строк - ОМС и дата рождения или почта и пароль соответственно. Поэтому тут у нас 1:1.
Сцепленность
Тут надо раскрыть ещё одно небольшое упрощение - на самом деле операция регистрации в модуле аккаунтов трогает намного больше ресурсов - отправляет письмо, сразу же создаёт сессию пользователю (досталось в наследство), обновляет дату последней активности (сами уже накосячили).
Поэтому если просто посчитать оригинальные стрелки, то получится, что сцепленность при помещении операции в модуль Аккаунтов будет ниже. Однако при реализации, естественно проблемная операция будет не напрямую в ресурсы лазить, а через соответствующие операции и я в целом в фоновом режиме думаю как мне добавить в диаграмму эффектов включение (переиспользование) эффектов одной операции в другую.
Поэтому давайте считать что в обоих вариантах будет одна стрелка. Тоже ничья? Не совсем.
У меня в загашнике уже года полтора лежит недописанный черновик о том, что не все зависимости одинаковые и "стоимость владения" зависимостью зависит от значения десяти атрибутов. Тут все десять разбирать не буду, разберу два - хрупкость и стабильность зависимости.
Хрупкость - это вероятность того что целевой код взорвётся в рантайме. И в этом случае операция "Привязать пользователя в ЕМИАС", которая ходит во внешнюю систему - намного более хрупкая, чем операция "Зарегистрировать пользователя", которая ходит только в БД внутри кластера (фейл отправки почты не влияет на результат операции).
Стабильность - это вероятность изменения АПИ целевого кода. И тут, опять, же операция привязки выглядит менее стабильной, так как код свежий и у нас и у ЕМИАС-а, а АПИ регистрации не менялось ни разу за всю свою жизнь. Пока что.
Отсюда следует, что "стоимость владения" зависимостью "Аккаунты" → "ЕМИАС" выше стоимости владения обратной зависимостью. Поэтому у нас появляется лидер - 1:2.
Связанность
Моя методичка предлагает оценивать связанность модуля посредством оценки простоты именования этого модуля. И в целом помещение проблемной операции в любой из модулей выглядит в равной степени разумным.
Так же существуют методы оценки связанности классов/объектов, все из которых базируются на степени использования полей класса в методах класса (в идеале - все поля во всех метода должны использоваться).
И в нашем случае добавленная операция будет связана со всеми ресурсами в любом из модулей. Поэтому по этому критерию снова ничья - 2:3
SOLID
Я продолжаю утверждать, что декомпозиция на базе эффектов порождает [труъ] объекты, поэтому давайте попробуем рассмотреть варианты с точки зрения SOLID-а.
SRP
Как я уже писал, у SRP есть три определения:
- The Single Responsibility Principle (SRP) states that a class or module should have one, and only one, reason to change;
- Gather together the things that change for the same reasons. Separate those things that change for different reasons;
- A module should be responsible to one, and only one, actor;
Давайте рассмотрим варианты с точки зрения каждого.
По первому определению, у нас оба варианта нарушают SRP. В первом случае у модуля аккаунтов появляется дополнительная причина для изменений - изменение в интеграции с ЕМИАС. А во втором модуль ЕМИАС может потребовать изменений в случае изменений в юз кейсе регистрации пользователя.
То же самое и со второй формулировкой. В первом случае, у нас изменения в ЕМИАС расползутся по двум модулям. А во втором - изменения в регистрации расползутся по двум модулям.
Наконец, третья формулировка. Благодаря ЕМИАС у нас появляется специализированный эктор - "Пользователь из Москвы". И тогда, в первом варианте модуль аккаунтов становится ответственным за юзкейсы двух экторов - "Пользователь" и "Пользователь из Москвы". Во втором же - за все юзкейсы пользователя из Москвы будет отвечать модуль ЕМИАС-а.
2:4
OCP
A software artifact should be open for extension but closed for modification.
Не в полной мере понимаю как его применять в данном контексте и в рамках микропоста не буду закапываться. Однако, если экстраполировать (а у нас с вероятностью 75% будет интеграция с ЕМИАС московской области, и вполне возможны интеграции с другими регионами) эту интеграцию, то первый вариант снова проигрывает. Так в этом случае у модуля аккаунтов (и сервиса ядра) количество зависимостей будет рости условно бесконечно.
2:5
LSP, ISP, DIP
Не применимы для диаграммы эффектов
Принципы дизайна пакетов
The Reuse/Release Equivalence Principle
The granule of reuse is the granule of release.
Мартин пишет:
From a software design and architecture point of view, this principle means that the classes and modules that are formed into a component must belong to a cohesive group.
This is weak advice: Saying that something should “make sense” is just a way of waving your hands in the air and trying to sound authoritative.
И так как я уже проверил, "мейкает ли сенс" каждый из вариантов в разделе "Связанность" - тут не буду повторяться.
The Common Closure Principle
Gather into components those classes that change for the same reasons and at the same times. Separate into different components those classes that change at different times and for different reasons.
Далее Мартин пишет:
This is the Single Responsibility Principle restated for components
SRP я уже рассмотрел - тоже не буду повторяться.
The Common Reuse Principle
Don’t force users of a component to depend on things they don’t need.
Продолжая уже добрую традицию - далее Мартин пишет:
Put another way, we want to make sure that the classes that we put into a component are inseparable — that it is impossible to depend on some and not on the others.
Тут уже интересней - такого мы ещё не рассматривали. Рассмотрим.
Хотя лучше не надо было бы - оба варианта нарушают этот принцип. В первом - модуль аккаунтов зависит только от операции привязки из всего модуля ЕМИАСа. Во втором - модуль ЕМИАСа зависит только от операции регистрации из всего модуля Аккаунтов.
Оставляю счёт как есть - 2:5.
The Acyclic Dependencies Principle
Allow no cycles in the component dependency graph.
Сервис ЕМИАСа, помимо регистрации, через очередь сообщений зависит ещё и от модулей устройств и дневника, которые все вместе деплоятся в сервис ядра.
Соотвественно, первый вариант создаст цикл в зависимостях. 2:6. Идём дальше.
The Stable Dependencies Principle
Depend in the direction of stability.
Стабильность компонента Мартин предлагает определять как трудоёмкость его изменения. В том числе из-за количества зависимых от него компонент.
И у модуля аккаунтов куча входящих зависимостей, а у ЕМИАСа - ни одной (на бэке).
Соотвественно, плюсик варианту два - 2:7.
The Stable Abstractions Principle
A component should be as abstract as it is stable
Не применим к диаграмме эффектов.
GRASP
Принципы GRASP описаны в Applying UML and Patterns, и так же как и SOLID относятся в первую очередь к дизайну классов, но я их приму во внимание, по тем же причинам, по которым принял во внимание SOLID.
Information Expert
Assign a responsibility to the information expert — the class that has the information necessary to fulfill the responsibility.
В данном случае, оба модуля являются "экспертами" для операции, поэтому любой из вариантов одинаково хорош/плох.
3:8
Creator
Не применим в данной ситуации
Low Coupling, High Cohesion
Assign a responsibility so that coupling remains low.
Assign a responsibility so that cohesion remains high.
В этих принципах Ларман не привносит ничего нового, поэтому пропускаем их.
Controller, Polymorphism, Purе Fabrication, Indirection, Protected Variants
Вся эта пачка принципов ведёт к выделению в отдельный модуль всего юз кейса "Регистрация пользователей" со всеми вариациями. Этот модуль будет "чистой выдумкой", которая будет содержать "контроллер", который будет принимать запросы, служить слоем "косвенности" между модулем аккаунтов и интеграций, выполнять "полиморфную" диспетчеризацию, на основе типа запроса и "защищать варианты" регистрации.
Однако, однако этот вариант мы пока что отмели и счёт остаётся неизменным - 3:8.
Package Organization Guidelines
В Applaying UML and Patterns есть раздел по принципам организации пакетов со следующими гайдлайнами:
- Package Functionally Cohesive Vertical and Horizontal Slices;
- Package a Family of Interfaces;
- Package by Work and by Clusters of Unstable Classes;
- Most Responsible Are Most Stable
- Factor out Independent Types
- Use Factories to Reduce Dependency on Concrete Packages
- No Cycles in Packages
Однако они либо не применимы в нашем случае (Factor out Independent Types, Use Factories to Reduce Dependency on Concrete Packages), либо (все остальные) пересекаются с тем, что мы уже рассмотрели.
Таким образом, итоговый счёт остаётся 3:8.
Этот счёт показывает, что выбор варианта действительно не однозначный - оба варианта имеют свои плюсы и минусы. Однако, количество плюсов второго варианта перевешивает. Его я и выбрал в альтернативной ветки реальности, где мне пришлось этот выбор делать.
Так же стоит отметить, что помимо чисто дизайнерских критериев при окончательном выборе стоит учесть ещё кучу аспектов - насколько удобно будет работать над каждым из модулей в каждом из вариантов, как обеспечить безопасность, как тестировать, как деплоить. Но так как этот пост уже довольно большой я не буду на них подробно останавливаться - там либо так же ничья, либо второй вариант предпочтительнее.
Заключение
Итак, алгоритм принятия решения по "сложному случаю" операции в декомпозиции по диаграмме эффектов состоит из следующих шагов:
- Оценить связанность проблемной операции;
Рассмотреть возможность исключения этой операции.
При том чем ниже связанность операции, тем больше усилий стоит предпринять для её исключения, вплоть до переговоров с заказчиком фичи.
Однако, в этих переговорах не стоит рассказывать про связанность и сложности реализации - как правило низкую связанность можно приземлить на "нелогичность/странность/сложность" в терминах бизнеса. В данном случае я бы упирал на усложнение UX формы регистрации, при том чем большее вариаций интеграций у нас будет, тем более будет сложный UX.
- Сформировать список всех возможных вариантов декомпозиции;
- Отфильтровать те варианты, которые не позволят (или сильно усложнят) обеспечить выполнение функциональных или нефункциональных требований;
- Для оставшихся вариантов определить набор критериев, которые вы считаете важными;
- Оценить каждый из вариантов по всем критериям;
- Выбрать тот вариант, который набрал больше баллов при оценке. Лишь бы не было ничьей:) Если столкнусь с этим - обязательно напишу пост о том, как я выкручивался.