Связность и связанность в современных системах

Связность и связанность в современных системах

Меня зовут Иван Башарин, руководитель лаборатории AI и архитектор решений в компании «Электронная торговая площадка Газпромбанка». Я расскажу о достаточно абстрактных понятиях — связанности и связности.

Связанность — это мера зависимости между модулями или компонентами системы. Она описывает, насколько сильно один модуль зависит от другого.

В программировании существует несколько видов связанности, которые могут варьироваться от сильной (высокой) до слабой (низкой):

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

— Слабая связанность: модули минимально зависят друг от друга. Это позволяет вносить изменения в один модуль без необходимости изменения других, что улучшает гибкость и сопровождаемость системы

Существует много видов связанности, выделяют три основных:

  1. По данным — модули обмениваются данными напрямую или через параметризацию

  2. По управлению — один модуль управляет поведением другого

  3. По содержимому — один модуль напрямую меняет данные другого

Давайте попробуем разобрать на примерах.

Существует класс процессора, существует класс отправителя.

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. Это делает код менее гибким и увеличивает вероятность ошибок.

Непредсказуемость: если несколько классов используют один и тот же метод с различными командами, это может привести к путанице и трудностям в отладке.

И второстепенный, но тоже плохой приведенный тут вариант — зацепление по данным, т. к. команды в примере мы передаем текстом.

Что делать с таким кодом и как реализовывать схожие задачи, расскажу чуть позже.

Немного теории: что же лучше?

Сильная связанность приводит к сложности понимания логики системы.

zihwha5vyyof65cg7qqgem7avkg.png

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

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

Поэтому, конечно, ответ: «Нам нужна слабая связанность между модулями проекта».

dfqqlmfvl1_gfbbteizuh8sfg2g.png

Почему это не совсем так, тоже расскажу позже.

Связность — это абстрактная величина «однонаправленности» элементов модуля. Правда ведь, звучит логично: все элементы модуля должны быть связаны и выполнять одну задачу.

Выделяют три основных вида связности:

— Функциональная — все элементы выполняют одну задачу

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

— Процедурная — элементы модуля выполняются в определенной последовательности

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

Представим модуль, который в каком-либо виде присутствует у каждого — рассылки пользователям каких-либо уведомлений.

ylp-xaqfb6lepat9r6agkf7-vpq.png

— Нам надо получит само событие — по таймауту, пользовательскому действию

— Проверить его на корректность: мало ли что нам туда отдали

— Дополнить данными. Вряд ли у нас в событии есть все, что надо, — почтовые адреса, темы, дополнительные данные контрагентов и т. п.

— Сформировать собственно текст рассылки

— Положить куда-то историю и логи. Например, в ЛК БСК мы должны хранить все рассылки минимум три года

— И собственно отправить письмо

Модуль выглядит логично, не правда ли? Выполняет одно действие — генерирует рассылку. Действия выполняет последовательно — еще лучше!

Но теперь почему это не так.

— Валидация событий относится к событийной модели сервиса

— Непосредственно отправка сообщения является внешней зависимостью и даже блокирующим фактором

— Хранение статистики и логов — два отдельных решения: одно на аналитических паттернах, а другое системное

— Дополнение данных — так же внешнее подключение и внешняя зависимость

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

w0x333bxvluqwcpnffku9vtv71q.png

Почему и это не совсем так, расскажу дальше.

Теория связности очень похожа на связанность. Такие же степени и такие же проблемы, только наоборот:

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

juw93c8ur0c84ctrqexbkqhaule.png

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

zcfkihpfxiuhhypabcxh2v9adta.png

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

Широко используются шаблоны проектирования.

— Компонент иногда фигурирует отдельным шаблоном. Это достаточно простой подход приведения кода к атомарным частям. Буквально из примера про белочек — взяли часть, отвечающую за подключение к СМТП, и вынесли в отдельный компонент

— Очередь событий отлично подходит для дополнения данными или валидации. Последовательность действий для формирования результата позволит контролировать весь процесс без лишних зависимостей

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

Возвращаемся к нашему реестру платежей.

Просто использование паттерна «Наблюдатель» позволит реализовать тот же функционал, но с сохранением адекватной связи:

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

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

Модульность

hycfjeiuk1ngqlwhtywblcfugyw.png

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

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

cisx8maafzo5ne5mhr7ydntvaus.png

Интерфейсы не в концепции разработки и имплементации, а в модульности и едином интерфейсе взаимодействия с таким модулем

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

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

Отвечающих теме принципов два:

  1. Принцип единой ответственности. Каждый объект должен решать одну задачу, и эта задача должна быть полностью инкапсулирована в класс. Генерирующий отчет класс должен меняться только по двум причинам: может измениться содержание или формат отчета. Это не включает в себя добавление новых форматов отчета, профилей экспорта. А строго — сам отчет, его содержание и формат

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

В целом задача выглядит достаточно прозрачной, не правда ли?

Создаем модули, внутри которых зависимые друг от друга компоненты, а сами модули связываем друг с другом через RPC, DTO или другие «отвязанные» от происходящего внутри модуля объекты.

Все, к сожалению, не так просто. Представьте сервис, состоящий из сотен отдельных модулей или микросервисов, — все связаны между собой через API и DTO, вся передача через кафку, все максимально отвязаны один от другого.

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

— Сложности в управлении интеграцией. Даже пару десятков микросервисов связать между собой сложно, что говорить о сотне

— Проблемы согласованности данных — модули могут ожидать одной и той же информации, но не согласовывать между собой средства валидации

— Трудность понимания общего контекста проекта

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

Про излишне высокую связность сложно сказать что-то плохое, но я скажу!

Основное — это те же проблемы общего контекста проекта и сложность модульного тестирования. Даже выполняющий одну «задачу» модуль может быть достаточно объемным, тестировать такой будет достаточно сложно.

fhhfz7sp1mghhrvgvd8ermhl_io.png

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

mbotlnw0-5qwztuxfnghomzpg3e.png

У каждого проекта, команды и выбранной архитектуры свои отношения.

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

© Habrahabr.ru