Следить за обновлениями моего блога можно в канале Эргономичный код
Данный пост является базовым материалом для моего доклада на JPoint 2023, а так же продолжением поста «Рациональный подход к декомпозиции систем на модули или микросервисы» с теоретической частью подхода.
Примеры в программировании и особенно дизайне — это всегда боль. Слишком простые или синтетические не особо полезны. В реальные, со всеми их сложностями — мало кто станет вгружаться, кроме того, в них трудно отделить релевантные детали от шума. Кэмп является, на мой взгляд, золотой серединой.
Кэмп — реальный проект, который стоил семизначную сумму для заказчика, выполнялся командой из 12 человек (включая двух бэкэндеров) и сейчас запущен в промышленную эксплуатацию. Суммарно на выполнение проекта было затрачено 5500 человеко-часов, из которых 950 — на бэкенд.
Но есть нюанс — это был экспериментальный проект, который (с согласия заказчика) выполнялся силами исключительно молодых специалистов, а лиды только проводили ревью и помогали консультациями. Поэтому по фактическому объёму функциональности проект достаточно компактный.
Проект является специализированной геоинформационной системой для водителей-дальнобойщиков. В отличие от больших ГИС систем вроде Яндекс.Карт он отличается тем, что позволяет найти не просто гостиницу по дороге, а гостиницу где водитель может и сам переночевать, и рефрижератор на 86 «кубов» припарковать.
Соответственно, двумя ключевыми сущностями являются водители и «точки» (кафе, заправки, СТО и т. п.). Точки в систему вносят сами пользователи после предварительной модерации. С водителями связаны характеристики машин, которые они водят (сейчас — только тип машины и размер колёс), а с точками — характеристики машин, которые они в состоянии обслужить.
Кроме того, в системе есть пуш-уведомления пользователей о новостях приложения, а также о результатах модерации добавленной точки.
Вся эта функциональность отражена на следующей диаграмме эффектов:
Теперь давайте прогоним алгоритм декомпозиции на базе эффектов на этой диаграмме и посмотрим, что получится.
Кластеризация диаграммы эффектов проекта Кэмп
Напомню, общий алгоритм кластеризации диаграммы эффектов состоит из следующих шагов:
-
Кластеризация
-
Первичная кластеризация по алгоритму
Генерация кластеров
Расширение кластеров
Агрегация ресурсов
Завершение кластеризации вручную
-
-
Оптимизация
Именование кластеров
Визуализация графа кластеров
Анализ графа кластеров
-
Объединение кластеров (модулей)
Сокрытие подмодулей
Группировка функционально схожих кластеров
Этап кластеризации
Итерация 1, шаг генерации кластеров
На первом шаге надо перебрать все ресурсы и объединить их с сильно связанными с ними операциями. Перебирать будем в «естественном» порядке: сверху вниз, слева направо.
Поэтому начнём с ресурса «Сервис отправки СМС». С ним связана только операция «Запросить OTP», однако она сама связана эффектом записи с другим ресурсом, поэтому пока их откладываем.
То же самое с ресурсом «OTP» — он связан с операциями «Запросить OTP» и «Получить токен из отп», но обе операции имеют по два эффекта записи, поэтому этот ресурс также пока пропускаем.
Далее идёт ресурс «Токены». Его мы, наконец, можем объединить с операцией «Получить токен из логина/пароля» и получить первый кластер (на первом этапе я буду именовать кластеры по порядковому номеру их добавления на диаграмму). Операцию «Получить токен из отп» и в этом случае пока откладываем, так как она имеет два эффекта записи.
Теперь переходим к ресурсу «Пользователи». Этот ресурс является единственным для операций «Изменить пользователя» и «Удалить пользователя», а для операции чтения «Получить пользователя» он, очевидно, является первичным. Объединяем их все во второй кластер.
Затем рассмотрим схожие ресурсы «Типы машин» и «Размер колёс», оба ресурса связаны эффектами чтения с операциями чтения «Получить пользователя» и «Получить точки», но ни один из ресурсов не выступает первичным для этих операций, поэтому пока что пропустим их.
Теперь переходим к ресурсу «Топик „Точка промодерирована“». Связанные с ним операции — «Удалить точку» и «Изменить точку» также связаны эффектами записи с другими ресурсами, поэтому этот ресурс пока что оставляем некластеризованным.
После чего переходим к ресурсу «Точки на карте». Он явно является первичным для операции чтения «Получить точки», а также единственным ресурсом операции «Создать точку». Объединяем их в третий кластер.
Далее у нас снова схожая пара ресурсов «Услуги» и «Тэги». Оба ресурса связаны своим единственным эффектом чтения с операцией «Получить точки», которая уже входит в третий кластер — отложим их до шага расширения кластеров.
Теперь переходим к подграфу уведомлений.
Тут у нас есть ресурс «Сервис отправки Push-уведомлений», с которым связаны операции «Создать новостное уведомление» и «Создать персональные уведомления», у которых по два эффекта записи, поэтому пока их все отложим.
Зато в четвёртый кластер мы можем объединить ресурс «Уведомления» с операциями «Удалить уведомление», «Получить список новостных уведомлений» (это их единственный ресурс) и «Получить список персональных уведомлений» (для этой операции чтения он является первичным).
Наконец, в пятый кластер можно объединить последний ресурс «Прочитанные уведомления» и операцию «Прочитать уведомление», для которой он является единственным ресурсом.
На этом шаг генерации кластеров заканчивается и у нас получается такая промежуточная кластеризация:
Итерация 1, шаг расширения кластеров
Далее идёт этап расширения кластеров, на котором все некластеризованные элементы, связанные только с одним кластером, надо поместить в этот кластер. У нас сейчас таких элементов два — ресурсы «Услуги» и «Тэги» связаны только с третьим кластером — затягиваем их в него (изменённый кластер обозначен пунктирной линией):
Итерация 1, шаг агрегации ресурсов
Теперь переходим к следующему шагу — агрегации ресурсов. Для этого перебираем оставшиеся некластеризованные ресурсы и смотрим, есть ли для них «разумная» пара, с которой они связаны общей операцией. Перебор снова будем делать в «естественном» порядке.
Поэтому начинаем с ресурса «Сервис отправки СМС». Он через операцию «Запросить OTP» связан с ресурсом «OTP». Начнём с эмпирического критерия разумности — теряет ли смысл существования один из ресурсов при удалении другого из системы. На данный момент — да. Сервис отправки СМС используется только для отправки одноразовых паролей, поэтому если удалить ресурс «OTP», то сохранять ресурс «Сервис отправки СМС» сохранять смысла не будет. Поэтому объединяем их в одну группу.
Затем идёт пара ресурсов «Типы машин» и «Размер колёс». И снова начинаем с эмпирического критерия разумности. На этот раз каждый из ресурсов есть смысл сохранить даже при удалении другого. Кроме того, группировка этих ресурсов никак не продвинет нас в кластеризации, поэтому эти два ресурса оставляем как есть.
Далее переходим к ресурсу «Топик „Точка промодерирована“». Он через операции «Удалить точку» и «Изменить точку» связан с ресурсом «Точки на карте» и является механизмом оповещения об изменениях в последнем. Эти два ресурса мы группируем на основе эмпирического критерия разумности — если удалить коллекцию точек, то и оповещать будет не о чем.
Наконец, ресурсы «Сервис отправки Push-уведомлений» и «Уведомления». По эмпирическому критерию их не надо группировать — я могу засылать пуши напрямую и не хранить, или перейти к «пулл» уведомлениям. Однако на мой экспертный взгляд ни того ни другого не случится, а группировка этих ресурсов поможет мне продвинуть кластеризацию, поэтому эту пару я решаю агрегировать.
После выполнения всех этих агрегаций у нас получается следующий этап кластеризации (здесь агрегированные ресурсы обозначены штриховкой):
Теперь заходим на вторую итерацию и возвращаемся к шагу генерации кластеров.
Итерация 2, шаг генерации кластеров
На второй итерации генерации кластеров мы также проходимся по некластеризованным ресурсам, но теперь агрегированные ресурсы рассматриваем как одно целое. После агрегации ресурсов операция «Запросить OTP» стала связана одним эффектом записи с группой ресурсов «Сервис отправки СМС» и «ОТП» и теперь можно их кластеризовать:
После этого некластеризованными остались только ресурсы «Типы машин» и «Размер колёс», в отношении которых ничего не поменялось, поэтому переходим на следующий шаг.
Итерация 2, шаг расширения кластеров
После первой итерации шага агрегации ресурсов операции «Удалить точку» и «Изменить точку», а также «Создать новостное уведомление» и «Создать персональное уведомление» стали связаны только с элементами третьего и четвёртого кластера соответственно, поэтому теперь их можно затянуть в эти кластеры:
На этом алгоритм первичной кластеризации зашёл в тупик — операция «Получить токен из отп» связана двумя эффектами записи с разными кластерами, а ресурсы «Типы машин» и «Размеры колёс» связаны с разными кластерами двумя равноценными эффектами чтения. Пришло время расчехлить свой большой мозолистый мозг.
Этап ручного завершения кластеризации
Начнём с операции «Получить токен из отп». Как я писал в теоретической части, в этом случае у нас есть три варианта действий с сохранением изначальной структуры:
Поместить в собственный кластер.
Внести в шестой (первый) кластер.
Объединить первый и шестой кластер и внести туда.
Визуально эти варианты выглядят так:
В целом, все эти варианты не выглядят очевидно бессмысленными и могут быть обоснованы. Но давайте рассмотрим их с точки зрения «тяжести» графа — пусть связь между кластерами синей стрелкой будет стоить одну единицу, а красной — две. В этом случае вес вариантов выше будет следующий:
6
4 (4)
2
Вполне предсказуемо, выигрывает вариант 3, который исключает все эффекты между рассматриваемыми кластерами. Теперь надо проверить связанность этого кластера, с помощью именования. На мой взгляд, выбор имени для этого кластера не составляет труда — всё что попало внутрь касается авторизации и только авторизации, поэтому кластеру отлично подходит имя «Авторизация».
При желании тут можно разглядеть ещё одну возможность к перепроектированию системы и ещё большему снижению её сцепленности. В частности, операции получения токенов обращаются к коллекции «Пользователи» только за аутентификационными данными — телефоном/логином (ещё один спорный момент) и ролями пользователя. И если эту информацию вынести в отдельные ресурсы (которые внутри будут таблицами с внешним ключём на таблицу пользователей), то от связи на уровне кода между модулями авторизации и пользователей можно будет избавиться полностью. Но дальнейший разбор этого направления я оставлю за скобками и без того огромного поста.
Теперь у нас осталось только два некластеризованных элемента — ресурсы «Типы машин» и «Размеры колёс».
Для них три базовых варианта будут выглядеть так:
Объединить их в собственный кластер.
Внести во второй (третий) кластер.
Объединить второй и третий кластер и внести туда.
Вес графов этих вариантов будет следующий:
4
2 (2)
0
Тут снова предсказуемо побеждает третий (объединить кластер с пользователями и точками в один мегакластер) вариант. Но этот вариант — просто сразу нет. Не думаю, что здесь надо что-то пояснять.
Далее по сцепленности идёт вариант с помещением этих ресурсов в один из существующих кластеров, но в обоих случаях эти ресурсы создадут проблемы с именованием итоговых кластеров.
Поэтому я выбираю третий вариант и помещаю их в собственный кластер. Пусть это будет вариант с наибольшей сцепленностью, зато — и с наибольшей функциональной связанностью всех трёх получившихся кластеров. А сцепленность, хоть и наибольшая — но на чтение, и на неё можно посмотреть сквозь пальцы. Наконец, у нас вся система про то, чтобы матчить пользователей с точками, на основании характеристик машин — об этом стоит явно сказать на верхнеуровневой структуре модулей.
В итоге я получаю следующую первичную кластеризацию:
И теперь мы можем переходить к этапу её оптимизации.
Этап именования кластеров
На этом этапе мы по очереди рассматриваем кластеры и даём им имена, отражающие их содержимое.
Так, для кластера с элементами «Запросить OTP», «Сервис отправки СМС», «OTP», «Получить токен из отп», «Токены» и «Получить токен из логина/пароля» на мой взгляд отлично подходит имя «Авторизация».
Для кластера с элементами «Пользователи», «Изменить пользователя», «Удалить пользователя» и «Получить пользователя» — подходит имя «Пользователи».
Кластер с элементами «Типы машин» и «Размер колёс» можно назвать «Характеристики машин».
Самому большому кластеру с элементами «Точки на карте», «Топик результатов модерации», «Создать точку», «Удалить точку», «Изменить точку», «Получить точки», «Услуги», «Тэги» можно дать имя «Точки».
Затем кластеру с элементами «Уведомления», «Сервис отправки Push-уведомлений», «Создать новостное уведомление», «Создать персональное уведомление», «Удалить уведомление», «Получить список новостных уведомлений», «Получить список персональных уведомлений» подходит имя «Уведомления».
А вот для последнего кластера с элементами «Прочитанные уведомления» и «Прочитать уведомление» имя «Прочитанные уведомления» хоть и подходит, но меня заставляет поморщиться. Это слишком низкоуровневая штука, для верхнеуровневой структуры модулей.
Быстрым решением будет объединить этот кластер с кластером «Уведомления». Однако меня эта нестыковка вывела на другое решение.
Напомню, что в системе есть два вида уведомлений — персональные и новостные. На данный момент они хранятся в общем ресурсе — «Уведомления». И я задумался — а что общего у персональных и новостных уведомлений?
На самом деле — практически ничего.
Флаг прочтения есть только у персональных уведомлений. Уведомления отправляются разными экторами в разное время — персональные отправляются системой автоматически, а новостные — по запросу администратора. Они отправляются разным людям — персональные отправляются водителю, создавшему точку, а новостные — всем водителям. Даже методы API для отправки используются разные.
И так я пришёл к тому, что у меня была ошибка в изначальном дизайне — ресурсы «Уведомления» и «Сервис отправки Push-уведомлений» надо было разделить на два — для персональных и новостных уведомлений. И если это сделать, то всё сразу встаёт на свои места — этим двум новым кластерам легко дать имена «Новостные уведомления» и «Персональные уведомления» и они будут уже не так сильно выбиваться по уровню абстракции:
Теперь мы можем переходить к шагу анализа графа кластеров.
Этап анализа графа кластеров
На этом этапе первым делом необходимо этот граф визуализировать. Это та самая «верхнеуровневая структура модулей», на которой сразу видно элементы, выпадающие по уровню абстракции.
При анализе я в первую очередь смотрю на зависимости.
Разумно ли, что авторизация зависит от пользователей? Мне кажется разумно — мы же пользователей авторизуем.
Разумно ли, что пользователи и точки зависят от характеристик машин? Мне кажется разумно — характеристики машин являются абстрактными понятиями, характеризующими машины конкретных пользователей и конкретные точки.
Затем я проверяю, что все элементы графа имеют один уровень абстракции. И на мой взгляд «Новостные уведомления» и «Персональные уведомления» выпадают по уровню абстракции. А вот если их спрятать в более абстрактном модуле «Уведомления» — всё встанет на свои места.
И это даёт нам итоговую декомпозицию системы на пять верхнеуровневых модулей:
На этом декомпозиция системы завершена — можно создавать проект, там заводить по пакету (или модулю) на каждый кластер, в каждом пакете создавать класс сервиса, для каждой операции кластера в соответствующем классе определять метод и вперёд, можно кодировать.
Подход проверен и обоснован научно
Всё, что я описал выше — и саму концепцию декомпозиции на базе эффектов, и диаграмму эффектов и методику декомпозиции — я придумал сам долгими вечерами и ночами. А потом нашёл три научных статьи, описывающих такой же по сути подход:
Я не буду здесь подробно останавливаться на этих статьях — они в открытом доступе и заинтересованный читатель может изучить их самостоятельно — приведу лишь главный, с моей точки зрения, тезис:
The evaluation results give us reasons to believe that our approach identifies microservices in a quality that is comparable to a design done by human software designers. Our approach, however, achieved the microservices identification much faster and with less effort compared to human developers. While identifying the microservices was a matter of days for the students at KIT and SWU-RISE, by employing our approach it was a matter of hours.
Результаты оценки дают нам основания полагать, что наш подход выявляет микросервисы, качество которых сравнимо с дизайном, выполненным людьми. При этом наш подход позволяет выявлять микросервисы гораздо быстрее и с меньшими усилиями по сравнению с выполнением этой работы вручную. В то время как у студентов KIT и SWU-RISE выявление микросервисов потребовало несколько дней, с использованием нашего подхода это заняло несколько часов.
— Shmuel Tyszberowicz, Robert Heinrich, Bo Liu and Zhiming Liu, Identifying Microservices Using Functional Decomposition
Ограничения подхода к декомпозиции на базе эффектов
Но серебряных пуль не бывает и декомпозиция на базе эффектов также не является таковой и имеет свои ограничения.
Чтобы выполнить декомпозицию на базе эффектов, необходимо построить диаграмму эффектов. А для этого надо весьма подробно понимать, что и как надо сделать. А для этого надо техзадание. Соответственно, если у вас нет ТЗ — выполнить декомпозицию на базе эффектов не получится.
Но в этом случае можно сделать финт ушами. Начать программировать с любой декомпозицией — хоть по слоям. А потом, когда станет понятно что вы делаете и у вас накопится какой-то объём кода, выполнить обратный процесс — построить диаграмму эффектов по коду. Её уже декомпозировать и потом отрефакторить код в соответствии с результатом декомпозиции.
Также стоит иметь в виду, что этот подход всё ещё находится на стадии опытной эксплуатации, и максимальный размер проекта, который я по нему декомпозировал, составляет один человеко-год. Но с другой стороны, я не могу представить, чтобы кто-то в 2023 году сразу проектировал систему на десять человеко-лет вперёд. Скорее всего, вы и так пойдёте небольшими итерациями в год максимум, и на таком масштабе декомпозиция на базе эффектов отлично работает.
Наконец, с точки зрения типа и характера задач, подход на базе эффектов хорошо подходит для декомпозиции систем с богатым состоянием и правилами его изменения. Если же в системе состояния как такого немного — такую систему декомпозировать на базе эффектов уже не получится. В компиляторах, например, из ресурсов будет только коллекция исходников, из операций — «Скомплировать» и всё, декомпозировать нечего.
Характеристики подхода к декомпозиции на базе эффектов
Итак, для того чтобы максимизировать количество дофамина и минимизировать количество кортизола в команде, нам нужен подход к декомпозиции, который обладает следующими характеристиками:
Вообще есть;
Прост в изучении;
Прост в исполнении;
Даёт хорошие результаты вне зависимости от исполнителя;
Обладает ли подход к декомпозиции на базе эффектов этими характеристиками?
Во-первых, он безусловно есть.
Во-вторых и третьих, для меня он существенно проще в изучении и исполнении чем DDD. Является ли он таковым для вас — судить вам. Вы можете попробовать его применить в своём проекте или его небольшой части — это займёт немного времени, и вне зависимости от результатов поможет вам лучше понять свою систему. По моему опыту трудозатраты на декомпозицию на базе эффектов идут в соотношении 1–2 человеко-часа проектирования к 1 человеко-месяцу разработки. Соответственно, со скидкой на отсутствие опыта, на декомпозицию проекта на 2 человеко-месяца вам должно хватить одного человеко-дня.
Наконец, результаты декомпозиции на базе эффектов всё-таки зависят от исполнителя, но в меньшей степени, чем интуитивная декомпозиция или декомпозиция на базе границ в языке предметной области.
В итоге по своему опыту могу сказать, что подход к декомпоизиции на базе эффектов действительно помогает максимизировать количество дофамина (и минимизировать количество кортизола) от разработки и для команды и для бизнеса.