Диаграмма эффектов: объектно-ориентированная декомпозиция

Введение

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

Поиск максимально понятного ответа на этот вопрос стал моей идеей фикс. Спустя два года я его нашёл: "Систему надо декомпозировать на модули, инкапсулирующие эффекты". Как это делать? Это я раскладывал по полочкам ещё год. И в результате у меня родилась ясная методика декомпозиции на базе эффектов.

Декомпозиция на базе эффектов или объектно-ориентированная декомпозиция

(todo: === почему всё-таки ОО-декомпозиция?)

Терминология

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

  • эффект - это акт взаимодействия (чтения или записи) программы с глобальным состоянием. Звучит, возможно, непонятно, но каждый программист ежедневно работает с эффектами. Присваивание глобальной переменной, запись в файл, обращение к ресурсу REST API внешней системы, считывание текущего времени, обновление строки в реляционной БД - это всё эффекты;
  • ресурс - это именованная часть глобального состояния, с которой взаимодействует программа. Статическая переменная, файл, ресурс REST API внешней системы, текущее время, таблица реляционной БД - это всё примеры ресурсов.
  • операция - это атомарня функция, доступная пользователям (людям или машинам) системы. Сейчас операции информационных систем как правило определяются в виде методов REST API.

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

Соответсвенно декомпозиция на базе эффектов - это такая декомпозиция, которая помещает классы сервисов приложения и классы нужных им ресурсов в один модуль.

Зачем брать эффекты в основу декомпозиции?

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

Правомерность этих утверждений подтверждается серией научных статей (todo: линка), в которой авторы приводят немного другой по меахнике исполнения, но такой же по сути подход к декомпозиции. Кульминацией этой серии является статья, в которой авторы сравинвают декомпозицию на базе эффектов с декомпозицией по Предметно-Ориентированному проектированию (DDD) и приходят к выводу, что декомпозиция на базе эффектов даёт результат, аналогичный DDD но существенно быстрее ("Часы", вместо "дней").

Методика выполнения декомпозиции на базе эффектов

Декомозиция на базе эффектов состоит из двух больших шагов:

  1. Построение диаграммы эффектов на основе требований;
  2. Выполнение кластеризации диаграммы. Такие кластеры я в дальнейшем буду называть модулями.

Первый шаг подробно описан в посте (todo: линка), в конце которого мы получили следующую диаграмму эффектов проекта TSP:

true story effects orig

В этом же посте мы рассмотрим второй шаг.

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

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

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

  • Каждому кластеру можно дать чёткое и лаконичное название, отражающее входящие в него элементы.

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

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

Если эти критерии держать в голове (или подкорке), то кластеризацию можно выполнить неявно процессе построения диаграммы и для небольшой диаграммы она будет видна невооружённым глазом:

tsp decomposition intuitive anim.drawio

Здесь и далее чёрными прямоугольниками обозначаются границы кластеров.

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

Алгоритм кластеризации на базе эффектов

fun clusterize(diagram: Diagram) {
    var proceed = true
    while (diagram.hasFreeElements() && proceed) {
        proceed = false
        for (r in diagram.freeResources) {
            val tightlyCoupledOperations = r.operations
                .filter { o -> o.resources.size == 1 ||
                               (o.writtenResources.size == 1 && o.writtenResources[0] == r) ||
                               (o.isReadOnly && God.isPrimaryResource(o, r)) }
            diagram.makeCluster(r, tightlyCoupledOperations)
            proceed = tightlyCoupledOperations.isNotEmpty()
        }

        for (e in diagram.freeElements) {
            val adjaсentClusters = e.elements.map { it.cluster }.toSet()
            if (adjecentClusters.size == 1) {
                diagram.extendCluster( adjacentClusters.first(), e)
                proceed = true
            }
        }

        for (r in diagram.freeResources) {
            val possiblePairs = r.operations.flatMap { it.resources }
            val bestMatch = God.findBestMatch(r, possiblePairs)
            if (bestMatch != null) {
                diagram.aggregate(r, bestMatch)
                proceed = true
            }
        }
    }
    if (diagram.hasFreeElements) {
        diagram.finalyzeClusterization()
    }
    diagram.nameClusters()
    diagram.hideSubmodules()
    diagram.groupModules()
}

Алгоритм первичной кластеризации итеративный и каждая итерация состоит из трёх этапов:

  1. Генерация кластеров
  2. Расширение кластеров
  3. Агрегация ресурсов

Генерация кластеров заключается в том, чтобы перебрать все некластеризаванные ресурсы и кластеризовать их с операциями, которые:

  1. Связаны только с этим ресурсом
  2. Связаны с этим ресурсом своим единственным эффектом записи
  3. Являются операциями чтения, для которых данных ресурс является первичным. Определение первичного ресурса (и вообще его наличия) остаётся на усмотрение исполнителя.

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

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

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

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

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

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

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

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

  1. Для некластеризованных операций записи в первую очередь стоит рассмотреть вариант расцепки операции через очередь сообщений.
  2. Если с одним из кластеров элемент связан большим количеством связей или эти связи кажутся "сильнее" - его можно внести в тот кластер, с которым он сильнее связан. В случае операции, тут стоит принять во внимание её клиента - если с одним из кластеров у неё общий клиент, то связь с этим кластером кажется сильнее;
  3. Если элемент выглядит связанным со всеми кластерами в равной степени - его можно поместить в собственный кластер. В этот же кластер, возможно, можно будет добавить другие элементы связанные с теми же кластерами.
  4. Если кластеры, связанные элементом имеют высокую функциональную связанность - их можно объединить в один кластер.
  5. Если и первый и второй вариант выглядят странно или нелогично - возможно стоит вернуться к дизайну операций и ресурсов.
  6. Ещё вариант - пересмотреть состав существующих кластеров, возможно тогда получится получить логичную картинку.

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

После того, как каждому кластеру дано разумное имя полезно проделать ещё одно упражнение - нарисовать граф кластеров. Такая визуализация помогает увидеть "лес за деревьями" и оценить "разумность" уже самого леса.

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

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

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

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


Алгоритм первичной кластеризации итеративный и каждая итерация состоит из двух этапов:

  1. Сбор "низко висящих фруктов"
  2. Агрегация ресурсов

Сбор низко висящих фруктов заключается в том, чтобы перебрать все некластеризаванные ресурсы и кластеризовать их с операциями, которые:

  1. Связаны только с этим ресурсом
  2. Связаны с этим ресурсом своим единственным эффектом записи
  3. Являются операциями чтения, для которых данных ресурс является первичным

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

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

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

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

После выполнения первичной кластеризации, у вас скорее всего останутся некластеризованными некоторые только считываемые ресурсы и/или операции на чтение, которые связаны с двумя и более кластерами. С ними можно поступить двумя очевидными способами и одним не очевидным способом. Очевидные способы - внести в один из кластеров или поместить в собственный кластер. Выбрать один из них вам помогут две классических качественных характеристики связей - функциональная связанность и сцепленность. Если вы оцениваете, что элемент функционально связан или сцеплен с одним из кластеров сильнее, чем с остальными - поместите элемент в этот кластер. В противном случае - создайте кластер вокруг этого элемента. Но тут, к сожалению, мы заходим в "терра аморфиус" - я не знаю алгоритма определения связанности и сцепленности, кроме как на глаз. И если со сцепленность ещё хоть как-то можно опереться на частоту обращения и объём передаваемых данных данных, то связанность определяется просто на глаз.

Ещё более сложным для описания является неочевидный способ - ещё раз задуматься о дизайне ресурсов и операций. По своему опыту я могу сказать, что "повисшие" могут указывать на ошибки в проектировании ресурсов. Тут, к сожалению, вы уже окончательно сами по себе и я не могу дать универсальной инструкции, как именно надо задумываться о дизайне. Пока что, по крайней мере. Зато я могу показать пример.


Алгоритм выполняется над диаграммой эффектов (в краткой нотации без событий) и фактически состоит из двух действий - добавить новый прямоугльник (границы кластера) на диагрмму и внести элемент диаграммы (операцию или ресурс) внуть существующего прямоугольника. Алгоритм проще всего описать с помощью блок-схемы:

decomposition algorithm.drawio

и примера его применения к специально запутанной диаграмме эффектов TSP:

tsp decomposition algo anim.drawio

Давайте расмотрим шаги, из которых состоит кластеризация, визуализированная в анимации.

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

Шаг 2: выбираем следующую красную стрелку. Пусть это будет "Сохранить изображение". Вытягиваем её (вместе с операцией и ресурсом) в сторонку и обводим. На этот раз у нас много стрелок ушло за границу

Шаг 3: подтягиваем внутрь модуля операции, которые зависят только от ресурса "Изображения".

Шаг 4: выбираем следующую стрелку - пусть это будет "Опубликовать новый фид".

Шаг 5: операций, связанных с ресурсом темы "Сгенерирован новый фид" больше нет, зато есть ресурсы, связанные с операцией "Перегенерировать фид" - подтягиваем их внутрь модуля.

Шаг 6: выбираем последнюю красную стрелку - "Сохранить фид Яндекса". Обводим её. И сразу подтягиваем последний оставшийся ресурс.

Шаг 7: даём имена прямоугольникам (в порядке появления). "Интеграция с 2Гис", "Изображения", "Генерация фида", "Интеграция с Яндекс.Карты".

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

Шаг 8: выполняем обобщение. На мой взгляд наглядность декомпозиции предметной области повысится, если мы скроем модули "Интеграция с 2Гис" и "Интеграция с Яндекс.Карты" в более абстрактном модуле "Интеграция с геосервисами". Для этого мы добавим ещё один прямоугольник вокруг соответствующих модулей.

Шаг 9: применяем здравый смысл. Внимательно смотрим на каждый модуль. Что находится внутри? Это согласуется с именем модуля? От каких модулей он зависит? Это разумно? Сейчас на мой взгляд к самой декомпозиции уже не придраться. Поэтому вместо здравого смысла мы применим творческое начало и немного "причешем" диаграмму, чтобы она смотрелась "аккуратно" на наш субъективный взгляд.

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

Декомпозиция диаграммы эффектов проекта "Кэмп"

Шаг 1:

Декомпозиция диаграммы эффектов проекта "Кэмп"

Но как я уже говорил, проект TSP вполне можно было декомпозировать и "на глаз", поэтому давайте дополнительно рассмотрим ещё декомпозицию по алгоритму немного упрощённой диаграммы эффектов проекта Кэмп. Для того, чтобы нивелировать "предвзятость" диаграммы "разложенной" вручную, будем декомпозировать диаграмму "разложенную" graphviz-ом.

Для этого я перевёл руками исходную диаграмму в graphbiz-файл, а затем сконвертировал его в svg-файл стандартными средствами:

camp neato

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

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

camp neato main
camp neato push

Теперь давайте прогоним обе визуализации через алгоритм и начнём с графа основной функциональности Кэмпа.

Приложение 1. Использование graphviz для постороения диаграммы эффектов

Визуализация диаграммы с помощью graphviz и интуитивная декомпозиция

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

(todo: расписать - как указать neato, как указать двойной вес эффектов записи, как указать цвета узлов и связей)

strict digraph  {
    overlap = scale
    sep = 0.5

    "Отправить фид в 2Гис" [shape="rectangle" style="filled" fillcolor="#b6d7f0"]
    "Обновить фид для Яндекса" [shape="rectangle" style="filled" fillcolor="#b6d7f0"]
    "Выдать фид Яндекса" [shape="rectangle" style="filled" fillcolor="#b6d7f0"]

    "Загрузить изображение" [shape="rectangle" style="filled" fillcolor="#b6d7f0"]
    "Скачать изображение" [shape="rectangle" style="filled" fillcolor="#b6d7f0"]
    "Выдать список изображений организации" [shape="rectangle" style="filled" fillcolor="#b6d7f0"]
    "Удалить изображение" [shape="rectangle" style="filled" fillcolor="#b6d7f0"]

    "Перегенерировать фид" [shape="rectangle" style="filled" fillcolor="#b6d7f0"]

    "Интеграция с 2Гис" [shape="rectangle" style="filled" fillcolor="#85bbf0"]
    "Фид Яндекса" [shape="rectangle" style="filled" fillcolor="#85bbf0"]

    "Изображения" [shape="rectangle" style="filled" fillcolor="#85bbf0"]

    "Организации" [shape="rectangle" style="filled" fillcolor="#85bbf0"]
    "Дополнительная информация" [shape="rectangle" style="filled" fillcolor="#85bbf0"]
    "Тема Сгенерирован новый фид" [shape="rectangle" style="filled" fillcolor="#85bbf0"]

    "Отправить фид в 2Гис" -> "Интеграция с 2Гис" [color="#b85450";weight=2]
    "Обновить фид для Яндекса" -> "Фид Яндекса" [color="#b85450";weight=2]

    "Загрузить изображение" -> "Изображения" [color="#b85450";weight=2]
    "Удалить изображение" -> "Изображения" [color="#b85450";weight=2]

    "Перегенерировать фид" -> "Тема Сгенерирован новый фид" [color="#b85450";weight=2]

    "Фид Яндекса" -> "Выдать фид Яндекса" [color="#6c8ebf";weight=1]

    "Изображения" -> "Скачать изображение" [color="#6c8ebf";weight=1]
    "Изображения" -> "Выдать список изображений организации" [color="#6c8ebf";weight=1]

    "Изображения" -> "Перегенерировать фид" [color="#6c8ebf";weight=1]
    "Организации" -> "Перегенерировать фид" [color="#6c8ebf";weight=1]
    "Дополнительная информация" -> "Перегенерировать фид" [color="#6c8ebf";weight=1]
}

И визуализирован так:

tsp neato

В этой визуализации группы, пожалуй, менее очевидны, но тем не менее видны и graphviz невозможно упрекнуть в подгонке результатов.

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