c#
September 27, 2024

SOLID простым языком

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

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

Одним из принципов построения правильной архитектуры приложений в ООП является принципы, а точнее принципы SOLID. SOLID – это не столько самостоятельное слово, а акроним, составленный из первых букв пяти принципов, кстати, далеко не всех, описанных Робертом Мартином. И в рамках этой статьи мы посмотрим по внимательнее на них и разберем на практике, где и как их применять. Стоит оговориться, что одной статьи для полного погружения в «чистую архитектуру» будет мало, но старт, пожалуй, вполне хороший.

S. Single Responsibility Principle (Принцип единственной ответственности)

Очень часто упоминание этого принципа сводится к следующей парадигме:

– каждый класс должен выполнять одну задачу

– каждый метод должен выполнять только одну функцию,

И нельзя сказать, что здесь есть какие-то противоречия. Действительно, каждый метод должен выполнять одну задачу, а если он выполняет больше одной, то следует метод разбить на два и более мелких. Это улучшит читаемость, тестируемость и поддерживаемость. В случае с классами, ситуация схожая: если в рамках одного класса методы выполняют одну задачу, например CRUD-операции в репозитории, то все нормально.

Но, есть нюанс. Сам Боб Мартин в книге «Чистая архитектура» пишет о том, что такое толкование термина «единственная ответственность» некорректно. Этот термин не столько описывает внутреннюю структуру определённого класса, сколько указывает на его предназначение для конкретного потребителя. Его цель — не просто выполнять определённую функцию, а изменяться только под нужды конкретного потребителя, для которого он предназначен.

Что это означает на практике? В практическом смысле все обстоит таким образом: если у нас есть некий сервис (класс), формирующий какой-то результат, то все действия с этим сервисом (добавление/изменение/удаление логики) должны производиться только для того одного «потребителя», для которого он предназначен. Пожалуй, в этом случае стоит привести пример, который раскроет тему на практике.

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

  • CalculatePay();
  • ReportHours();
  • Save();

Каждый из этих методов используется разными «потребителями»:

  • бухгалтерия пользуется методом CalculatePay();
  • отдел кадров использует ReportHours();
  • а методом Save() пользуются администраторы баз данных.

Чем же плох такой подход? Давайте представим, что есть метод GetRegularHours(), который рассчитывает рабочие часы для сотрудников и используется в этих методах. Пусть это будет приватный метод и находится в том же классе.

В какой-то момент, в бухгалтерии решили обновить систему расчета заработной платы и им стало необходимо поменять алгоритм учета рабочего времени. Разработчик, приглашенный бухгалтерией внес изменения в метод GetRegularHours() и вроде бы все довольны.

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

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

O. Open-Closed Principle (Принцип открытости-закрытости)

Принцип открытости-закрытости звучит в обсуждениях не так часто, как Принцип единственной ответственности, однако с точки зрения проектирования архитектуры приложения он крайне важен. Кстати, сформулирован он был в 1988 г. Бертраном Мейером и звучит он так: «Программные сущности должны быть открыты для расширения и закрыты для изменения». Что же это означает?

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

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

Предположим, есть организация, в которой формируется финансовый отчет в виде PDF файла. В какой-то момент, в организации начали внедрять новое ПО, предоставляющее визуальное отображение различных показателей компании с графиками и метриками. Использование pdf документа в таких условиях не представляется удобным. Значит нужно сформировать данные для передачи во внедряемую систему. Текущий код, при правильной организации не будет изменен, если реализована возможность на определенном этапе получить «сырые» данные и использовать их для формирования нового представления и отправки в обновленную систему.

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

L. Liskov substitute principle (Принцип подстановки Барбары Лисков)

Все в том же 1988 году Барбара Лисков написала так:

«Здесь требуется что-то вроде следующего свойства подстановки: если для каждого объекта o1 типа S существует такой объект o2 типа T, что для всех программ P, определенных в терминах T, поведение P не изменяется при подстановке o1 вместо o2, то S является подтипом T.»

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

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

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

Как видим, в корзину добавляются элементы CartItem, в которых содержится информация об элементе и его количестве, а так же, в зависимости от количества получается цена. В корзине так же есть метод CalculateSum(), который считает общую стоимость элементов в корзине. Здесь очень хорошо видно, как именно подставляются объекты разных классов (Product и Service) в CartItem, но при этом поведение методов у CartItem и Cart не меняется в зависимости от содержимого. Это и есть то самое правило подстановки Барбары Лисков.

I. Interface Segregation principle (Принцип разделения интерфейсов)

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

Предположим, у нас есть класс Human, который реализует интерфейс IWorker. И есть класс Robot, который также реализует интерфейс IWorker. Пока все правильно, но, взгляните на схему:

У интерфейса IWorker есть методы, которые не используются классом Robot, однако мы вынуждены их реализовывать из-за того, что реализуем интерфейс. Более того, при использовании приложения может возникнуть ситуация, когда исключение будет вызвано, поскольку вместо Human метод Eat был вызван у Robot. Кроме того, явная реализация заведомо ненужных методов вынуждает писать много дополнительного «мертвого» кода.

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

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

Таким образом, разделив интерфейсы, мы можем вызвать метод ManageWork() и использовать в нем как Human, так и Robot. При этом Robot реализует только один интерфейс, а Human все три. Однако это никак не сказывается на работе приложения.

D. Dependency Inversion principle (Принцип инверсии зависимостей)

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

Сразу же посмотрим, как это реализуется. Для примера рассмотрим использование DI контейнера в приложении, реализующем Телеграм бота:

Есть интерфейс IUserRepository, в котором описаны методы стандартного CRUD-репозитория. Есть класс UserRepository его реализующий. В контейнер зависимостей мы добавили интерфейс и класс его реализующий, а в классе ClientActiveSessionUpdatesHandler используется в качестве зависимости IUserRepository.

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

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

Заключение

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