Перейти к основному содержимому

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

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

Хорошее программное обеспечение — это не только про то, чтобы каждый компонент работал.

Это ещё и про решения:

  • Что должно находиться вместе?
  • Что должно оставаться раздельным?
  • Какие модули должны знать друг о друге?
  • Сколько знаний должен иметь один компонент о другом?
  • Что должно произойти, когда одна часть системы меняется?

Два концепта помогают ответить на эти вопросы:

Связность (cohesion): насколько тесно связаны обязанности внутри модуля.

Связанность (coupling): насколько один модуль зависим от других модулей.

Цель обычно проста:

Высокая связность внутри модулей и низкая связанность между модулями.

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

Оно работает, но достаточно ли этого?

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

Это понятно.

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

В этот момент код может выглядеть абсолютно нормально.

Проблемы начинаются, когда система растёт.

Простой сервис заказов начинает:

  • Проверять информацию о клиенте.
  • Рассчитывать цены.
  • Применять скидки.
  • Проверять наличие товара.
  • Обрабатывать платёж.
  • Отправлять электронные письма.
  • Публиковать события.
  • Писать аудит-логи.
  • Генерировать счета.

Небольшое изменение в платёжном провайдере теперь требует открытия сервиса заказов. Изменение поведения электронной почты влияет на создание заказа. Новое правило скидок ломает несвязанный поток оформления заказа.
Код всё ещё работает — но его стало дорого менять.

Часто это проблема проектирования, связанная со связанностью и связностью.

Что такое связность?

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

Высокосвязный модуль сосредоточен на одной ясной цели.

Например, модуль аутентификации может содержать:

  • Вход.
  • Выход.
  • Обновление токена.
  • Сброс пароля.
  • Проверку сессии.

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

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

Код всё ещё может компилироваться, но у модуля нет чёткой идентичности.

Простая аналогия

Представьте ящик с инструментами.

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

Теперь представьте, что вы открываете тот же ящик и находите там:

  • Кухонную утварь.
  • Медицинские принадлежности.
  • Канцелярские товары.
  • Ключи от машины.
  • Садовые инструменты.

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

Программное обеспечение с низкой связностью ощущается так же.

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

Высокая связность в коде

Рассмотрим этот сервис:

Image description

Название UserService не объясняет фактические границы.

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

Более связный дизайн мог бы разделить их:

Image description

Теперь у каждого модуля есть более ясная цель.

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

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

В этом практическая выгода связности: связанные вещи остаются вместе, а несвязанные вещи остаются раздельно.

Что такое связанность?

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

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

Изменение в одном модуле может вынудить к изменениям в нескольких других.

Рассмотрим:

Image description

Этот сервис связан с:

  • Paystack.
  • Prisma.
  • Определённым форматом ответа платёжной системы.
  • Конкретной реализацией базы данных.

Если вы замените Paystack, измените библиотеку базы данных или измените платёжный процесс, этот сервис придётся менять.

Это сильная связанность.

Слабо связанный дизайн вместо этого зависит от стабильных контрактов:

Image description

Сервис заказов зависит от возможностей, а не от конкретной инфраструктуры:

Image description

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

Связность и связанность работают вместе

Связность и связанность — это не конкурирующие цели.

Они описывают две стороны структуры программного обеспечения:

  • Связность смотрит внутрь модуля.
  • Связанность смотрит между модулями.

Здоровый дизайн стремится к:

  • Высокой связности внутри
  • Низкой связанности между

Подумайте о хорошо организованной компании.

Каждый отдел сосредоточен на конкретной обязанности:

  • Финансы занимаются финансами.
  • HR занимается операциями с людьми.
  • Продажи занимаются клиентами и выручкой.
  • Инженерия занимается разработкой продукта.

Отделы всё ещё общаются, но делают это через ясные обязанности и определённые процессы.

Если каждый отдел выполняет каждую задачу, организация становится хаотичной.

Если отделы вообще не могут общаться, работа тоже останавливается.

Программные модули ведут себя похожим образом.

Цель — не нулевая связанность.

Цель — контролируемая связанность.

Почему нулевая связанность невозможна

Каждая полезная система содержит зависимости.

Системе заказов нужна информация об оплате. Дашборду нужны данные отчётности. Сервису уведомлений нужна информация о событиях. Контроллеру нужен прикладной сервис.

Цель — не удалить каждую зависимость.

Цель — сделать зависимости:

  • Необходимыми.
  • Явными.
  • Стабильными.
  • Небольшими.
  • Легко заменяемыми.
  • Легко тестируемыми.
  • Легко понятными.

Контроллер, зависящий от прикладного сервиса, — это нормально.

Прикладной сервис, знающий приватные детали реализации трёх баз данных, двух платёжных провайдеров и почтового сервера, — это тревожный сигнал.

Типы связанности

Связанность может проявляться в разных формах.

Связанность по содержимому (content coupling)
Один модуль напрямую обращается к внутренним данным другого модуля или изменяет их.

Image description

Это крайне опасно, потому что потребляющий модуль зависит от деталей реализации.

Если internalState изменится, сервис заказов сломается.

Общая связанность (common coupling)
Несколько модулей зависят от общего глобального состояния.

Image description

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

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

Управляющая связанность (control coupling)
Один модуль говорит другому, как себя вести, передавая флаги.

Image description

Это может стать проблемой, когда принимающий сервис накапливает множество ветвлений:

Image description

Иногда стратегия или абстракция канала понятнее.

Связанность по данным (data coupling)
Один модуль передаёт только те данные, которые нужны другому модулю.

Image description

Обычно это здоровее, потому что зависимость явная и ограниченная.

Связанность по сообщениям (message coupling)
Модули общаются через сообщения или события, не сильно завися от внутренней структуры друг друга.

Image description

Коммуникация на основе сообщений может уменьшить прямую связанность, хотя она вводит другие вопросы, такие как версионирование событий, гарантии доставки, повторные попытки и eventual consistency.

Типы связности

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

Функциональная связность
Модуль содержит элементы, которые работают вместе для выполнения одной чётко определённой задачи.

Пример:

Image description

Оба метода относятся к хешированию паролей.

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

Например:

Image description

Эти операции образуют ясную последовательность в процессе импорта файла.

Коммуникационная связность
Несколько операций работают с одними и теми же данными.

Например, модуль профиля может валидировать, обновлять и форматировать данные профиля пользователя.

Случайная связность
Модуль содержит несвязанные утилиты просто потому, что они были созданы примерно в одно время.

Image description

Такой модуль часто разрастается в свалку.

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

Практический пример: обработка заказов

Давайте сравним два дизайна.

Низкая связность и высокая связанность

Image description

У этого сервиса много обязанностей.

Он также связан с несколькими инфраструктурными системами.

Возможные последствия:

  • Большие и медленные тесты.
  • Сложное мокирование.
  • Высокий риск регрессии.
  • Неясная обработка ошибок.
  • Сложное повторное использование.

Каждое изменение затрагивает один и тот же класс.

Высокая связность и контролируемая связанность

Image description

Каждый компонент владеет сфокусированной обязанностью.

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

Этот дизайн не автоматически идеален.

Он может ввести больше файлов и абстракций.

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

Связанность в приложении на NestJS

NestJS поощряет внедрение зависимостей, что может помочь уменьшить связанность.

Сервис может получать зависимости через свой конструктор:

Image description

Это лучше, чем создавать конкретные зависимости внутри сервиса:

Image description

Внедрение через конструктор делает зависимости видимыми.

Оно также делает тестирование проще:

Image description

Сервис можно тестировать без подключения к реальной базе данных или отправки настоящих писем.

Однако одного внедрения зависимостей недостаточно для гарантии низкой связанности.

Вы всё ещё можете внедрить огромный конкретный сервис, который раскрывает слишком много обязанностей.

Качество границы важнее наличия механизма внедрения.

Связанность в модульном монолите

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

Например:

Image description

Модули могут общаться через:

  • Публичные прикладные интерфейсы.
  • Доменные события.
  • Команды.
  • Запросы.
  • Общие контракты.

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

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

Это особенно полезно для SaaS-продуктов на ранней стадии.

Вы можете сохранять развёртывание простым, а обязанности — организованными.

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

Микросервисы не создают низкую связанность автоматически.

Система может иметь отдельные развёртываемые сервисы и при этом быть сильно связанной, если:

  • Каждый запрос требует пяти синхронных вызовов сервисов.
  • Сервисы используют одни и те же таблицы базы данных.
  • Один сервис зависит от внутренней схемы другого.
  • Все сервисы должны развёртываться вместе.
  • Небольшое изменение контракта ломает многих потребителей.
  • Сбой одного сервиса обрушивает весь рабочий процесс.

Это распределённая связанность.

Сервисы разделены в коде и развёртывании, но тесно связаны в поведении.

Хорошо спроектированный микросервис должен владеть значимой возможностью и общаться через стабильные контракты.

Но даже тогда сетевое взаимодействие вводит издержки:

  • Задержка.
  • Таймауты.
  • Повторные попытки.
  • Частичные сбои.
  • Версионирование.
  • Наблюдаемость.
  • Eventual consistency.

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

Количество сервисов не является мерой качества дизайна.

Связность и проектирование базы данных

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

Модуль в идеале должен владеть данными, необходимыми для его обязанности.

Например:

  • Модуль биллинга → подписки, счета, биллинговые транзакции
  • Модуль инвентаря → уровни запасов, резервирования, склады
  • Модуль идентификации → пользователи, учётные данные, сессии, разрешения

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

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

Это не означает, что каждый модуль должен иметь отдельную базу данных.

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

Важный вопрос:

Кто владеет этими данными, и как другие модули должны получать к ним доступ?

Ясное владение улучшает связность.
Контролируемый доступ уменьшает связанность.

Связанность и событийно-ориентированный дизайн

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

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

Image description

он может публиковать событие:

Image description

Модуль уведомлений подписывается на это событие.

Это создаёт более слабую прямую связанность, потому что модулю заказов не нужно знать, как работают уведомления.

Однако событийно-ориентированные системы вводят новые компромиссы:

  • События могут задерживаться.
  • События могут доставляться более одного раза.
  • Потребители могут обрабатывать события не по порядку.
  • Схемы событий должны развиваться осторожно.
  • Для отладки требуется трассировка через асинхронные потоки.
  • Данные могут стать eventually consistent.

События уменьшают некоторые формы связанности, но не устраняют сложность системы.

Они переносят сложность с прямых вызовов на контракты, доставку и наблюдаемость.

Признаки высокой связанности

Ваша кодовая база может быть сильно связанной, когда:

  • Небольшое изменение требует правок во многих модулях.
  • Тесты требуют запуска всего приложения.
  • Классы сами создают свои зависимости.
  • Модули обращаются к приватным данным друг друга.
  • Общий модуль утилит содержит бизнес-логику для всего.
  • Несколько сервисов зависят от одной и той же схемы базы данных.
  • Замена провайдера требует изменения бизнес-логики.
  • Сбой одной зависимости приводит к сбою несвязанных функций.
  • Разработчики избегают рефакторинга определённых областей, потому что влияние непредсказуемо.

Высокая связанность создаёт волну изменений.

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

Признаки низкой связности

Модуль может иметь низкую связность, когда:

  • Его название расплывчато, например CommonService или Utils.
  • Его методы обслуживают несвязанные бизнес-функции.
  • Он часто меняется по разным причинам.
  • Разработчики не уверены, куда добавлять новую функциональность.
  • У модуля слишком много зависимостей.
  • Его тесты покрывают несвязанное поведение.
  • Удаление одного метода оставляет остальные методы несвязанными.
  • Модуль требует долгого объяснения, прежде чем кто-либо поймёт его назначение.

Полезный вопрос:

Если бы мне пришлось описать этот модуль одним предложением, было бы описание ясным?

Если ответ «нет», модулю, возможно, нужна лучшая граница.

Как улучшить связность

Группируйте поведение по бизнес-возможностям
Вместо организации всего по техническому типу:

  • controllers/
  • services/
  • repositories/
  • utils/

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

Image description

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

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

Например, переходы статуса заказа могут принадлежать доменной модели заказа или сервису заказов, а не общему файлу утилит.

Разделяйте несвязанные причины для изменения
Если один модуль меняется из-за требований к оплате, требований к электронной почте и требований к отчётности, вероятно, он содержит несколько обязанностей.

Разделите его вокруг этих паттернов изменений.

Избегайте общих свалок
Будьте осторожны с:

  • utils.
  • helpers.
  • common.
  • misc.
  • shared-service.

Эти папки могут быть полезны, но часто становятся местами, куда код складывается без ясного владельца.

Как уменьшить связанность

Зависьте от абстракций
Определяйте контракты вокруг возможностей:

Image description

Прикладному сервису не нужно знать библиотеку базы данных.

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

Это делает зависимости явными и заменяемыми.

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

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

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

Используйте их там, где асинхронное поведение и eventual consistency приемлемы.

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

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

Ограничивайте общее изменяемое состояние
Общее изменяемое состояние — один из самых простых способов создать непредсказуемую связанность.

Предпочитайте явные входы и выходы.

Баланс: связность против связанности

Можно перестараться.

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

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

Это иногда называют случайной сложностью (accidental complexity).

Хорошая архитектура балансирует:

  • Связность.
  • Связанность.
  • Простоту.
  • Производительность.
  • Понимание командой.
  • Операционные издержки.

Вы не хотите модули настолько большие, что всё смешано вместе.

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

Правильная граница обычно там, где:

  • Обязанности меняются вместе.
  • Данные доступны вместе.
  • Бизнес-правила принадлежат вместе.
  • Модуль можно объяснить ясно.
  • Зависимость значима.

Полезный фреймворк для код-ревью

При ревью модуля спрашивайте:

О связности

  • Эти методы принадлежат одной бизнес-возможности?
  • Используют ли они одни и те же данные?
  • Меняются ли они по похожим причинам?
  • У модуля одна ясная цель?

О связанности

  • Что этот модуль знает о других модулях?
  • Зависит ли он от деталей реализации?
  • Можно ли легко заменить зависимость?
  • Влияет ли небольшое изменение здесь на многих потребителей?
  • Являются ли зависимости явными?

О границах

  • Кто владеет этим бизнес-правилом?
  • Кто владеет этими данными?
  • Эта коммуникация синхронная или асинхронная?
  • Контракт стабилен?
  • Абстракция решает реальную проблему?

Эти вопросы помогают оценивать дизайн за пределами того, синтаксически ли чист код.

Связь с SOLID

Связанность и связность тесно связаны с принципами SOLID.

  • Single Responsibility улучшает связность.
  • Open/Closed уменьшает ненужные изменения стабильных модулей.
  • Liskov Substitution создаёт надёжные контракты.
  • Interface Segregation уменьшает ненужные зависимости.
  • Dependency Inversion уменьшает связанность с инфраструктурой.

Они также связаны с:

  • Инкапсуляцией.
  • Разделением ответственности.
  • Domain-driven design.
  • Модульной архитектурой.
  • Clean architecture.
  • Hexagonal architecture.
  • Событийно-ориентированным дизайном.

Но основополагающая идея остаётся простой:

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

Финальные мысли

Хорошее программное обеспечение не состоит из изолированных компонентов, которые никогда не общаются.

Оно состоит из сфокусированных компонентов, которые общаются намеренно.

Высокая связность даёт каждому модулю чёткую идентичность.

Низкая связанность предотвращает ненужное распространение изменений.

Когда связность низкая, модули становятся запутанными.

Когда связанность высокая, вся система становится хрупкой.

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

Цель — не устранить зависимости.

Цель — сделать зависимости осознанными, видимыми и управляемыми.

Поэтому в следующий раз, когда вы создаёте сервис, модуль, класс или микросервис, задайте два вопроса:

Всё ли внутри этого компонента принадлежит вместе?

И:

Знает ли этот компонент о внешнем мире больше, чем ему нужно?

Эти два вопроса могут предотвратить много будущей боли.

Потому что поддерживаемое программное обеспечение — это не программное обеспечение без сложности.

Это программное обеспечение, в котором сложности дано ясное место для жизни.

Источникdevto