Связность и связанность в современных системах
Меня зовут Иван Башарин, руководитель лаборатории AI и архитектор решений в компании «Электронная торговая площадка Газпромбанка». Я расскажу о достаточно абстрактных понятиях — связанности и связности.
Связанность — это мера зависимости между модулями или компонентами системы. Она описывает, насколько сильно один модуль зависит от другого.
В программировании существует несколько видов связанности, которые могут варьироваться от сильной (высокой) до слабой (низкой):
– Сильная связанность: модули имеют много зависимостей друг от друга, что делает систему менее гибкой и сложной для изменения. Например, если изменение в одном модуле требует изменений в других модулях, это свидетельствует о высокой связанности
– Слабая связанность: модули минимально зависят друг от друга. Это позволяет вносить изменения в один модуль без необходимости изменения других, что улучшает гибкость и сопровождаемость системы
Существует много видов связанности, выделяют три основных:
По данным — модули обмениваются данными напрямую или через параметризацию
По управлению — один модуль управляет поведением другого
По содержимому — один модуль напрямую меняет данные другого
Давайте попробуем разобрать на примерах.
Существует класс процессора, существует класс отправителя.
class DataProcessor:
def process_data(self, data):
return [d * 2 for d in data]
class DataSender:
def send_data(self, data):
print("Sending data:", data)
class Application:
def init(self):
self.processor = DataProcessor()
self.sender = DataSender()
def run(self):
data = [1, 2, 3, 4]
processed_data = self.processor.process_data(data)
self.sender.send_data(processed_data)
if name == "__main__":
app = Application()
app.run
()
Первый в этом примере просто умножает элементы массива на два.
Второй при этом просто выводит массив.
Application создает экземпляры DataProcessor и DataSender.
Зацепление по данным: если мы захотим изменить способ обработки или отправки данных, нам придется модифицировать не сам метод отправки, а класс Application.
Давайте разберем более интересный случай.
class PaymentRegistry:
def init(self):
self.is
_on = False
def toggle(self, command):
if command == “MAKE":
self.is
_on = True
print(“выполнили платежи")
elif command == “Cancel":
self.is
_on = False
print(“запретили платежи")
class Switch:
def init(self, light):
self.light = light
def makeYourDreamComeTrue(self, command):
self.light.toggle(command)
if name == "__main__":
rp = PaymentRegistry()
switch = Switch(rp)
switch. makeYourDreamComeTrue(" Cancel ")
Все мы работаем с данными, банком, платежами и документами.
Посмотрим на псевдокод, реализующий процесс выполнения реестра платежей.
Существует класс реестра, класс вынесения решения по реестру платежей и собственно код для вызова.
PaymentRegistry представляет объект реестра платежей и содержит метод toggle.
Toggle принимает команду для выполнения или отклонения платежей
Switch — это «выполнитель», имеет метод makeYourDreamComeTrue. makeYourDreamComeTrue передает команду в метод toggle класса PaymentRegistry.
Попробуйте угадать, какой из плохих вариантов я демонстрирую.
Представлен основной вариант — плохой вариант зацепления по управлению.
Метод toggle зависит от переданной команды отклонения или выполнения.
Сложность изменения: если в будущем потребуется изменить логику управления светом (например, добавить новые команды), это потребует изменения в классе Switch. Это делает код менее гибким и увеличивает вероятность ошибок.
Непредсказуемость: если несколько классов используют один и тот же метод с различными командами, это может привести к путанице и трудностям в отладке.
И второстепенный, но тоже плохой приведенный тут вариант — зацепление по данным, т. к. команды в примере мы передаем текстом.
Что делать с таким кодом и как реализовывать схожие задачи, расскажу чуть позже.
Немного теории: что же лучше?
Сильная связанность приводит к сложности понимания логики системы.

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

Почему это не совсем так, тоже расскажу позже.
Связность — это абстрактная величина «однонаправленности» элементов модуля. Правда ведь, звучит логично: все элементы модуля должны быть связаны и выполнять одну задачу.
Выделяют три основных вида связности:
Функциональная — все элементы выполняют одну задачу
Логическая — элементы модуля выполняют схожие функции, но необязательно одновременно
m, Процедурная — элементы модуля выполняются в определенной последовательности
До этого были примеры кода, в связности упростим демонстрацию.
Представим модуль, который в каком-либо виде присутствует у каждого — рассылки пользователям каких-либо уведомлений.

Нам надо получит само событие — по таймауту, пользовательскому действию
Проверить его на корректность: мало ли что нам туда отдали
Дополнить данными. Вряд ли у нас в событии есть все, что надо, — почтовые адреса, темы, дополнительные данные контрагентов и т. п.
Сформировать собственно текст рассылки
Положить куда-то историю и логи. Например, в ЛК БСК мы должны хранить все рассылки минимум три года
И собственно отправить письмо
Модуль выглядит логично, не правда ли? Выполняет одно действие — генерирует рассылку. Действия выполняет последовательно — еще лучше!
Но теперь почему это не так.
Валидация событий относится к событийной модели сервиса
Непосредственно отправка сообщения является внешней зависимостью и даже блокирующим фактором
Хранение статистики и логов — два отдельных решения: одно на аналитических паттернах, а другое системное
Дополнение данных — так же внешнее подключение и внешняя зависимость
И все выглядит достаточно логично. Разделяем модуль по таким вот белочкам, для каждой белочки строим свой домик и получаем высокую связность: каждая белочка будет есть свой орешек.

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

Низкая связность — это точно наоборот: проблемы во всех предыдущих пунктах

Кратко расскажу о способах нормализации связанности и связности в проектах, приведу базовые общие практики.
Широко используются шаблоны проектирования.
Компонент иногда фигурирует отдельным шаблоном. Это достаточно простой подход приведения кода к атомарным частям. Буквально из примера про белочек — взяли часть, отвечающую за подключение к СМТП, и вынесли в отдельный компонент
Очередь событий отлично подходит для дополнения данными или валидации. Последовательность действий для формирования результата позволит контролировать весь процесс без лишних зависимостей
Нельзя забывать о базовых, самых первых паттернах: фабрики, одиночки и наблюдатели популярны, удобны и используются повсеместно
Возвращаемся к нашему реестру платежей.
Просто использование паттерна «Наблюдатель» позволит реализовать тот же функционал, но с сохранением адекватной связи:
class PaymentRegistry:
def init(self):
self.is
_on = False
def turn_on(self):
self.is
_on = True #выполнили платежи
def turn_off(self):
self.is
_on = False #запретили платежи
class Switch:
def init(self):
self.callbacks = []
def add_callback(self, callback):
self.callbacks.append(callback)
def makeYourDreamComeTrue(self, action):
for callback in self.callbacks:
callback(action)
# Основная логика
if name == "__main__":
light = Light()
switch = Switch()
# Регистрация методов управления
switch.add_callback(lambda action: light.turn_on() if action == "ON" else light.turn_off())
# Управляем
switch.press
("ON")
switch.press
("OFF")
Уведомляем реестр платежей о необходимости изменения состояния без непосредственной передачи команды
Вводим механизм событий и управления этими событиями через Switch
Switch содержит список обратных вызовов и метод добавления callback-ов
Метод makeYourDreamComeTrue вызывает соответствующее действие
В основной логике вызываем через лямбда (функцию обратного вызова) нужные методы
Однако видно увеличение объема кода. В этом случае такое увеличение оправдано — все же в настоящей реализации его будет еще больше.
Возвращаемся к способам нормализации.
Иерархическая декомпозиция и GRASP-паттерны
Шаблоны проектирования, разработанные для решения общих задач по взаимодействию компонентов и модулей сервиса
Выделяют 8 основных GRASP-паттернов:
Информационный эксперт. Обязанности назначаются объекту, обладающему максимумом (всей) информацией для выполнения обязанности
Создатель. Создать отдельный класс, управляющий созданием объекта другого класса, если он содержит, агрегирует, записывает или использует объекты. Похоже на фабрику, не правда ли?
Контроллер. Выполняет операции с внешними инициаторами
Слабое зацепление / сильная связанность. Паттерн, соответствующий теме. Описывает в целом подход к модулям системы
Полиморфизм. Базовый принцип ООП, о них знают все
Чистая выдумка. Создаем полностью отвязанные друг от друга взаимодействия объектов и точек хранения этих объектов
Перенаправление. Буквально концепция MVC
Устойчивость к изменениям. Шаблон, защищающий элементы от изменения другими элементами. Должен реализовывать отдельный интерфейс для изменений и доступа
Модульность

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

Интерфейсы не в концепции разработки и имплементации, а в модульности и едином интерфейсе взаимодействия с таким модулем
Не могу не остановиться отдельно на тех самых принципах SOLID, которые всегда спрашивают на собеседованиях.
В целом вся концепция SOLID рассчитана на создание системы, которую легко поддерживать и расширять — и тут ключевой момент — в течение долгого времени.
Отвечающих теме принципов два:
Принцип единой ответственности. Каждый объект должен решать одну задачу, и эта задача должна быть полностью инкапсулирована в класс. Генерирующий отчет класс должен меняться только по двум причинам: может измениться содержание или формат отчета. Это не включает в себя добавление новых форматов отчета, профилей экспорта. А строго — сам отчет, его содержание и формат
Принцип открытости/закрытости. Согласно этому принципу, модули и компоненты должны быть открыты для расширения — и снова важный пункт — путем создания новых типов сущностей, т. е. для того же класса с отчетом мы не можем в нем же создавать новые шаблоны экспорта и форматы файлов. Мы должны создавать новые классы: потомков, декораторов, — но не менять сам начальный компонент
В целом задача выглядит достаточно прозрачной, не правда ли?
Создаем модули, внутри которых зависимые друг от друга компоненты, а сами модули связываем друг с другом через RPC, DTO или другие «отвязанные» от происходящего внутри модуля объекты.
Все, к сожалению, не так просто. Представьте сервис, состоящий из сотен отдельных модулей или микросервисов, — все связаны между собой через API и DTO, вся передача через кафку, все максимально отвязаны один от другого.
В такой схеме наверняка появится избыточность кода: при низкой связанности модули и компоненты будут дублировать код друг друга
Сложности в управлении интеграцией. Даже пару десятков микросервисов связать между собой сложно, что говорить о сотне
Проблемы согласованности данных — модули могут ожидать одной и той же информации, но не согласовывать между собой средства валидации
Трудность понимания общего контекста проекта
Именно такая история произошла в сервисах Амазона в 2010-х годах. Сервисы были максимально декомпозированы, функционал вынесен в отдельные связанные между собой микросервисы и после краткой радости и последующих проблем с разработкой и даже функционированием итогового продукта Амазон запустил обратный процесс и начал собирать монолиты.
Про излишне высокую связность сложно сказать что-то плохое, но я скажу!
Основное — это те же проблемы общего контекста проекта и сложность модульного тестирования. Даже выполняющий одну «задачу» модуль может быть достаточно объемным, тестировать такой будет достаточно сложно.

Если поискать термины «связанность» и «связность» в интернете, вы найдете такие же, картинки. На них будет нарисована красивая схема «как надо делать» и «как делать не надо». Однако, на мой взгляд, объективно корректного уровня отношения связанности и связности не существует. В каких-то проектах банковского сопровождения можно выделять типы документов в отдельные несвязанные модули и не переживать. Где-то в сервисах работы с МЧД и зависимостями вплоть до ФНС без жесткого контроля всех составляющих процессов просто не обойтись.

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