Следить за обновлениями блога можно в моём канале: Эргономичный код
Данный пост является базовым материалом для моего доклада на JPoint 2023
Чего от разработки ПО хотят разработчики, продакты и владельцы бизнеса?
Одного и того же — побольше дофаминчика (гормон счастья), поменьше кортизольчика (гормон стресса). Притом источники и дофамина, и кортизола у них одни и те же. Дофамин вырабатывается, когда фичи выпускаются в срок и без багов, а кортизол — когда сроки срываются и вылазят баги и регрессии. Бизнесу будет ближе финансовая версия — срыв сроков и баги очевидным образом приводят к увлечению стоимости разработки. Что приводит к выбросу кортизола уже у владельцев.
Как обеспечить высокий уровень дофамина всей команды?
Для того чтобы поддерживать высокий уровень дофамина и низкий уровень кортизола у всех участников процесса, нам необходимо декомпозировать системы на модули. Декомпозиция помогает обеспечить высокую скорость и качество разработки за счёт:
Ограничения объёма информации, которую надо осознать и закодировать на этапе первичной реализации функциональности;
Ограничения объёма кода, который необходимо изучить и адаптировать к изменениям в требованиях;
Исключения конфликтов при параллельной разработке независимых функций системы;
Создания возможности независимого выпуска и развёртывания отдельных функций системы.
Однако, этого можно достичь, только в том случае, если получившиеся модули обладают высокой функциональной связанностью и низкой сцепленностью.
Кроме того, для максимизации выброса дофамина скорости разработки и минимизации выброса кортизола стоимости разработки, необходимо чтобы сама работа по декомпозиции системы проходила быстро и давала качественные результаты.
Как обеспечить высокий уровень дофамина всей команды при выполнении декомпозиции?
А для того чтобы эта работа проходила быстро и давала качественные результаты, необходимо, чтобы она была структурирована и стандартизирована. То есть нужна некоторая методика или рациональный подход к решению задачи декомпозиции. И тут у нашей индустрии есть проблемы.
По моему опыту, в ВУЗах декомпозиции либо не обучают совсем, либо обучают монструозным и фактически устаревшим методологиям вроде RUP-а. В других странах дела обстоят не лучше. Например, про ВУЗы США Джон Остерхаут пишет то же самое:
Problem decomposition is the central design task that programmers face every day, and yet, other than the work described here, I have not been able to identify a single class in any university where problem decomposition is a central topic. We teach for loops and object-oriented programming, but not software design.
Декомпозиция — это центральная задача проектирования, с которой программисты сталкиваются каждый день, и всё же, кроме работы, описанной здесь, я не смог определить ни одного курса ни в одном университете, где декомпозиция является центральной темой. Мы учим циклам и объектно-ориентированному программированию, но не проектированию ПО.
— John Ousterhout, A Philosophy of Software Design
Из-за этого, в большинстве команд, которые я видел в последние годы, проектирование и декомпозиция заключалась в паре часов несистематизированного рисования прямоугольников и стрелок одним-двумя разработчиками. А полноценное проектирование и декомпозицию я видел в последний раз в 2005 году и они заняли несколько месяцев.
Для того чтобы обеспечить высокий уровень дофамина и на этапе проектирования и декомпозиции и на этапе последующей реализации, нам необходим подход к декомпозиции, который:
Вообще есть;
Прост в изучении;
Прост в исполнении;
Даёт хорошие результаты вне зависимости от исполнителя;
Это и будет тем самым рациональным подходом к декомпозиции, который нам необходим.
А что не так с классикой?
У опытного разработчика тут может возникнуть вопрос: «Зачем нужен очередной велосипед? Этих подходов к декомпозиции — тысячи!». Я честно проделал домашнюю работу, и перед тем как придумать собственный велосипед изучил более двадцати различных подходов к декомпозиции начиная с 60-ых годов, многое взял себе, но ни один из них мне не удалось положить на свою каждодневную практику.
Подробно основные распространённые сейчас подходы я рассмотрел в отдельном посте и здесь приведу только причины, по которым я не смог взять их в свою практику:
Декомпозиция по слоям не масштабируется и даёт плохие результаты с точки зрения связанности и сцепленности.
Декомпозиции по фичам и компонентам плохо описаны, и при попытке их применения возникает множество вопросов, на которые у источников нет ответов.
DDD сложен и в изучении, и применении. Мне ни разу не удалось «продать» DDD хотя бы РП, фронтенд-разработчикам, аналитикам и QA-инженерам в собственной команде. Кроме того, наличие «на борту» экспертов предметной области, критически важное для DDD, в моей практике является скорее исключением, чем правилом.
Изучением и апробированием классики я занимался 6 лет — с 2014 до 2020 года. Так и не найдя внятного ответа на вопрос «Как мне декомпозировать систему?», во второй половине 2020 я начал искать собственный подход к декомпозиции и написал разделы книги о таблице эффектов и компонентах (осторожно, устаревшие и не редактированные черновики). К марту 2021 года я придумал объединить их в один граф и в итоге это превратилось в декомпозицию на базе эффектов.
Декомпозиция на базе эффектов
Концептуальная модель системы
Для того чтобы применить подход к декомпозиции на базе эффектов, систему необходимо представить в виде графа операций и элементов состояния, связанных эффектами чтения и записи. После чего процесс декомпозиции фактически сводится к кластеризации этого графа. Пока что создать полностью автоматический алгоритм кластеризации, который бы давал удовлетворительные результаты, мне не удалось, поэтому кластеризация выполняется вручную. И так как человеку проще выполнять кластеризацию графа, представленного визуально, я разработал специальную диаграмму, для представления графов эффектов.
Концептуальная модель системы и нотация диаграммы подробно описаны в спецификации. Упрощённо же можно считать, что:
Операции — это эндпоинты REST API;
Ресурсы — таблицы БД;
Эффекты записи — SQL INSERT/UPDATE/DELETE запросы;
Эффекты чтения — SQL SELECT-запросы.
Соответственно, для построения диаграммы эффектов надо для каждого метода API добавить на диаграмму по прямоугольнику светло-синего цвета, для каждой таблицы добавить по прямоугольнику тёмно-синего цвета, для каждого запроса модификации данных добавить красную стрелку между соответствующей операцией и ресурсом, а для каждого запроса чтения данных — синюю стрелку. В результате у вас получится картинка, состоящая из таких элементов:
Здесь, очевидным образом, операция «Зарегистрировать пользователя» вносит данные в таблицу «Пользователи», а операция «Аутентифицировать пользователя» считывает данные из этой таблицы. Процесс построения диаграммы эффектов реального проекта с примерами всех распространённых видов ресурсов и операций описан в посте Диаграмма эффектов: пример построения.
Также важно отметить, что все элементы диаграммы эффектов один в один транслируются в код:
Операции — в методы классов сервисов приложения.
Ресурсы — в классы сущностей и репозиториев (событий и топиков брокеров сообщений, DTO и клиентов REST API и т. д.).
Эффекты — в вызовы методов классов репозиториев в методах классов сервисов.
После визуализации системы с помощью диаграммы эффектов необходимо выполнить её кластеризацию.
Кластеризация диаграммы эффектов
В основе подхода к кластеризации диаграммы лежит несколько простых идей:
Ресурсы являются глобальными переменными.
Между всеми модулями, которые взаимодействуют с одним ресурсом, появляется сцепленность через общее окружение (common environment coupling).
Один из основных методов снижения сцепленности системы в целом — это локализация сцепленности через общее окружение внутри одного модуля.
Запись глобальной переменной порождает большую сцепленность, чем чтение.
В связях между модулями не должно быть циклов.
Если модулю сложно дать хорошее имя, отражающее его содержание, это говорит о низкой функциональной связанности модуля.
То, что запись порождает большую сцепленность, чем чтение — может быть не очевидно. Однако это легко продемонстрировать, если рассмотреть их в контексте многопоточной работы. Считывать корректно опубликованную глобальную переменную могут сколь угодно много потоков без какой-либо синхронизации и проблем. Но, как только кто-то начинает изменять эту переменную, всё тут же становится намного сложнее: теперь надо обеспечить безопасный доступ и не создать дедлок, обеспечить протокол взаимодействия (сначала запись, потом чтение), следить за тем, чтобы операция записи не стала бутылочным горлышком в производительности системы и т. д.
Вооружившись этими идеями, легко определить требования к хорошей кластеризации диаграммы эффектов (декомпозиции системы):
Между кластерами нет циклов.
Эффекты записи (красные стрелки) инкапсулированы в одном кластере.
Количество эффектов чтения (синих стрелок), пересекающих границы кластеров, минимально.
Каждому кластеру легко дать имя, отражающее его содержание.
Для простых диаграмм такая кластеризация может быть видна на глаз. Примером простой диаграммы является диаграмма эффектов проекта True Story Project:
Здесь сразу же бросаются в глаза 3–4 кластера:
Работа с изображениями;
Формирование фида;
Интеграция с 2ГИС;
Интеграция с Яндекс.Картами.
На этом примере хорошо видно, что интуитивная декомпозиция зависит и от разработчика диаграммы (как он расположит элементы) и от наблюдателя — я на этой диаграмме вижу 4 кластера, но некоторые люди «автоматически» объединяют интеграции в один модуль.
Кроме того, большие или запутанные системы на глаз кластеризовать не получится. Для того чтобы структурировать и ускорить процесс кластеризации таких диаграмм, а также снизить влияние исполнителя на результат, я разработал специальную методику.
Методика кластеризации диаграммы эффектов
Методика состоит из двух больших этапов — кластеризации и оптимизации кластеров. Для этапа кластеризации существует итеративный алгоритм, который простые диаграммы может кластеризовать полностью, а в сложных случаях — упростить и упорядочить рутинную работу, а также подсветить разработчику части системы, требующие особого внимания.
Общий алгоритм состоит из следующих шагов:
Кластеризация
Первичная кластеризация по алгоритму
Генерация кластеров
Расширение кластеров
Агрегация ресурсов
Завершение кластеризации вручную
Оптимизация
Именование кластеров
Визуализация графа кластеров
Анализ графа кластеров
Объединение кластеров (модулей)
Сокрытие подмодулей
Группировка функционально схожих кластеров
Алгоритм первичной кластеризации диаграммы эффектов
Алгоритм первичной кластеризации является итеративным, и каждая итерация состоит из трёх шагов:
Генерация кластеров.
Расширение кластеров.
Агрегация ресурсов.
Генерация кластеров
Этап генерации кластеров заключается в том, чтобы перебрать все некластеризованные ресурсы и кластеризовать их с операциями, которые:
Связаны только с этим ресурсом.
Связаны с этим ресурсом своим единственным эффектом записи.
Являются операциями чтения, для которых данный ресурс выступает первичным. Определение первичного ресурса (и вообще его наличия) остаётся на усмотрение исполнителя.
Расширение кластеров
Этап расширения кластеров — это самый простой и на 100% механический этап. Он заключается в том, чтобы перебрать все некластеризованные элементы, связанные только c элементами внутри одного кластера, и добавить их в этот кластер.
Агрегация ресурсов
Этап агрегации ресурсов заключается в том, чтобы оставшиеся некластеризованные ресурсы попытаться объединить в «разумные» группы между собой или с кластеризованными ресурсами. Строго говоря, на этапе агрегации надо перебрать все возможные попарные соединения и выбрать из них «разумные». Однако «разумные» пары, как правило, имеют общую операцию, поэтому эмпирический алгоритм агрегации выглядит так:
Для каждого некластеризовнного ресурса выбрать ресурсы, с которыми у него есть общая операция.
Если в списке есть «разумная» пара данному ресурсу — сгруппировать их.
Формального критерия разумности у меня нет, но есть эмпирический. Группа является разумной, если удаление одного из ресурсов делает существование второго бессмысленным. Этот критерий может дать ложноотрицательный результат — если исходить только из него, то можно пропустить разумную группу ресурсов, которая, с учётом особенностей предметной области или ограничений системы, является таковой. Но c ложноположительными результатами — объединением в одну группу ресурсов, которое не является разумным — я в своей практике ещё не сталкивался.
Далее сгруппированные ресурсы рассматриваются как единое целое — все эффекты связывающие ресурсы этой группы с одной и той же операцией считаются одним эффектом. Если операцию связывают с группой и эффекты чтения и эффекты записи, то считается, что операция связана с группой эффектом записи.
После агрегации ресурсов снова возвращаемся к этапу генерации кластеров. Если на следующей итерации этапы генерации и расширения кластеров не привели к уменьшению количества некластеризованных элементов, то, теоретически, этап агрегации можно повторить и продолжать это делать до включения всех оставшихся ресурсов в одну группу. Однако практически уже на второй последовательной итерации агрегации (когда одна группа некластеризованных ресурсов содержит в себе три базовых ресурса) нужно быть начеку. Большие группы зачастую указывают на операции, которые делают слишком много работы и как следствие обладают высокой сцепленностью — в этом случае придётся вернуться к этапу проектирования самих операций и ресурсов.
В результате применения этого алгоритма вы получите либо полную, либо частичную кластеризацию. В случае если алгоритм зашёл в тупик и породил только частичную кластеризацию, её необходимо завершить вручную, основываясь на собственной экспертизе, понимании предметной области и ограничений проекта.
Ручное завершение кластеризации
При ручном завершении кластеризации сначала стоит попытаться закончить кластеризацию без внесения изменений в множества операций и ресурсов. Однако иногда первоначальные операции и ресурсы никак не укладываются в хорошие кластеры. В этом случае придётся изначальные операции и ресурсы немного доработать напильником.
Завершение кластеризации с сохранением базовой диаграммы
На этой стадии останутся некластеризованными только те элементы, которые связаны с двумя и более кластерами (в противном случае они бы были кластеризованы на шаге расширения кластеров). И тут, если сохранять исходную структуру диаграммы, есть три базовых варианта действий:
Выделение в собственный кластер. Если элемент выглядит связанным со всеми кластерами в равной степени — его можно поместить в собственный кластер. В этот же кластер можно добавить другие элементы, связанные с теми же кластерами.
Внесение в один из существующих кластеров. Если с одним из кластеров элемент связан бОльшим количеством связей или они кажутся «сильнее» — его можно внести в этот кластер. В случае операции стоит принять во внимание её клиента (внешнюю сущность, инициирующую выполнение операции) — если с одним из кластеров у неё общий клиент, то связь с этим кластером сильнее;
Объединение в мегакластер. Если кластеры, связанные с элементом, имеют высокую функциональную связанность — их все можно объединить в один кластер.
При выборе варианта необходимо руководствоваться сцепленностью и функциональной связанностью дизайна. Для оценки сцепленности каждого варианта надо посчитать в релевантной части получившегося графа количество синих и удвоенное (для отражения их большей сцепляющей силы) количество красных стрелок, пересекающих границы кластеров. Полученное число и будет относительной оценкой сцепленности варианта. И при прочих равных лучше выбрать вариант с меньшим значением этого числа.
Если брать в расчёт только сцепленность, то наилучшим вариантом всегда будет третий вариант, сводящий количество стрелок, пересекающих границы кластера к нулю. Однако эта логика ведёт нас к абсурдному выводу: просто всё всегда объединять в один суперкластер с нулевой сцепленностью. И нулевой функциональной связанностью. А нашей же задачей является, разбить систему на набор модулей с низкой (не нулевой) сцепленностью и высокой функциональной связанностью.
Поэтому необходимо проверять ещё и функциональную связанность вариантов. Для этого каждому кластеру надо дать имя.
Если вы выбрали первый вариант (выделение в собственный кластер) и название кластера кажется слишком «низкоуровневым» — скорее всего вы идёте к патологической расцепленности и от выделения лучше воздержаться.
Если вы выбрали второй (поместить в существующий кластер) или третий вариант (объединить всё в один мегакластер) и дать получившемуся кластеру хорошее имя не получается — вы идёте к божественному объекту и лучше поискать другой вариант.
Если ни один из этих вариантов не даёт удовлетворительный на ваш взгляд результат, то придётся менять исходную диаграмму. Тут универсального алгоритма нет, но могу дать несколько рекомендаций, с которых можно начать поиск решения.
Завершение кластеризации с изменением базовой диаграммы
Для любых проблемных ресурсов в первую очередь надо рассмотреть вариант разделения их на несколько независимых ресурсов. Для этого надо проверить — все ли эффекты считывают/изменяют ресурс целиком или одну и ту же его часть? Или один из эффектов считывает только колонки A и B (из ресурса таблицы), а второй — C и D? В этом случае стоит рассмотреть вариант разделения таблицы (и ресурса) на две — с колонками A и B и C и D. Та же самая логика работает и для эффектов записи.
Операции записи можно попробовать кластеризовать с помощью расцепки через очередь сообщений. Для этого необходимо:
Выделить основной эффект операции.
Поместить операцию в кластер, с которым её связывает основной эффект.
В тот же кластер добавить ресурс очереди сообщений для оповещения об основном эффекте.
Добавить к исходной операции эффект записи по публикации сообщения в эту очередь.
Все остальные эффекты отвязать от исходной операции.
Отвязанные эффекты привязать к новым операциям в тех кластерах, ресурсы которых модифицируются этими эффектами и которые будут вызываться после публикации сообщения.
Расцепку изменяемых ресурсов также можно выполнить с помощью очереди сообщений и схожей процедуры:
Выделить основной эффект на ресурс.
Перенести ресурс в кластер, с которым его связывает основной эффект.
В кластер второй операции добавить ресурс очереди сообщений о выполнении этой операции.
В кластер основной операции добавить операцию, которая выполняет вторичный эффект и вызывается в ответ на появление сообщения в очереди из предыдущего пункта.
У второй операции эффект на исходный ресурс заменить на эффект записи в эту очередь.
Для кластеризации ресурсов только на чтение можно рассмотреть вариант их дублирования. Для этого в каждом кластере, считывающим проблемный ресурс, надо создать копию исходного ресурса и эффекты чтения направить туда, а исходный ресурс удалить.
Наконец, операцию только на чтение можно попытаться кластеризовать комбинацией дублирования ресурсов и расцепкой через очередь сообщений. Для этого для каждого ресурса проблемной операции надо:
Продублировать ресурс.
В кластеры исходных ресурсов добавить ресурсы очередей сообщений о модификации ресурса.
Ко всем операциям, имеющим эффект записи на ресурс, добавить эффект записи ресурса очереди сообщений.
Для каждого дубля ресурса добавить операцию, обновляющую этот ресурс в ответ на появление сообщения в очереди.
Эффект считывания ресурса проблемной операцией перенаправить на его дубль.
Это не исчерпывающий список возможных вариантов кластеризации проблемных элементов, однако эти способы достаточно часто работают. В крайнем случае, я надеюсь, они подтолкнут вас к открытию собственного варианта решения вашей проблемы.
После получения полной кластеризации можно переходить к этапу оптимизации кластеров.
Оптимизация кластеров
Этап оптимизации кластеров состоит из следующих шагов:
Именование кластеров
Визуализация графа кластеров
Анализ графа кластеров
Объединение кластеров (модулей)
Сокрытие подмодулей
Группировка функционально схожих модулей
На первом шаге каждому кластеру необходимо дать имя, отражающее его содержание. В случае хорошей декомпозиции — это не составит труда. Если же определить имя какого-то кластера не получается, то необходимо рассмотреть его внимательнее. Часто такие проблемы решаются с помощью разделения проблемного кластера на два более мелких и сфокусированных. Но поиск разумного имени кластера может привести и к перепроектированию ресурсов и операций.
После того как каждому кластеру дано разумное имя, надо построить визуализацию графа кластеров. Такая визуализация помогает увидеть «лес за деревьями» и оценить «разумность» уже самого леса.
Получив граф кластеров, проверьте его на соответствие вашему здравому смыслу. Я для этого фокусируюсь в первую очередь на связях и их направлении:
Разумно ли, что этот модуль зависит от того?
Может ли целевой модуль зависимости существовать без зависимого?
Какой из модулей более стабилен (более стабильным должен быть целевой модуль)?
У вас могут быть свои вопросы для оценки соответствия здравому смыслу.
Наконец, последний шаг — найти и объединить подмодули и функционально схожие модули. Подмодуль — это модуль, обеспечивающий работу одного более высокоуровневого модуля. В этом случае кластер подмодуля необходимо поместить внутрь кластера модуля.
Функционально схожие модули — это модули, выполняющие разными способами одну и ту же функциональность, либо выполняющие разные подфункции одной общей функции. Такие модули надо объединить в общий кластер. Этому кластеру также надо дать имя и если это вызывает затруднения, то от объединения лучше отказаться.
Всё, теперь можно создавать структуру пакетов, соответствующую структуре кластеров, в каждом пакете создавать по классу сервиса со всеми операциями кластера и по классу репозитория/клиента/топика для каждого ресурса кластера.
На этом теоретическая часть закончена, но это только половина пути — через неделю разберём пример декомпозиции диаграммы эффектов реального проекта.