Про принципы Solid написано немало, в том числе на Хабре. Показывали в картинках, рассказывали на примерах.  

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

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

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

Начнем с практической части

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

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

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

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

Представим, как бы примерно мог выглядеть такой сервис:

Данный участок написан на языке С#. Если вы не очень знакомы с C#, то немного объясню: у нас есть метод SendMessage, на вход которому подается массив из сотрудников Employee и текстовое сообщение, и уже в логике работы метода мы создаем массив из выходных дней Holidays, куда добавляем субботу и воскресенье.

Далее мы запоминаем текущую дату и в цикле просто прибавляем к дате по одному дню. Затем мы проверяем, совпадает ли полученная дата с выходными днями, и как только она перестает совпадать с выходными днями, мы в обычном цикле ForEach для каждого пользователя вызываем метод SendMessage и отсылаем сообщение на e-mail. 

Все достаточно просто, поскольку сам код простой. Скорее всего, он будет успешно работать после небольшой отладки. 

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

Что делать? 

Идем к команде и просим совета. 

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

Решение будет выглядеть примерно так:

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

Пора переходить к следующему шагу:

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

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

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

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

Схематично это выглядит так:

 

У нас появился новый класс DbWorker, у которого есть два метода получения соединения: GetConnection и GetCalendar.

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

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

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

Казалось бы, вновь все хорошо и все работает, но предположим, что спустя некоторое время к нам приходит тестировщик и говорит, что все сломалось и перестали уходить сообщения после того, как он изменил дату на 31 декабря. Что же могло случиться?

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

Вернемся немного назад.

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

Для этого мы делали запрос в базу данных, который возвращал 365 дней начиная с текущей даты. 

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

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

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

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

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

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

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

Решение — создание интерфейса для нашего класса. Наверху находится и IDbWorker, который объявляет, что его наследники должны имплементировать класс GetConnection, и GetCalendar. Далее ниже от него наследуются 2 класса, которые и реализуют данные методы, но уже каждый по-своему в зависимости от тех задач, которые вложил в них разработчик.

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

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

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

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

В черном квадрате мы видим этот интерфейс.

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

В красном квадрате появился новый класс UserManager, который вместо получения календаря просто возвращает пустой объект, поскольку календарь для данного класса оказался невостребованным. У него есть свой метод GetUsers, но календарь ему не нужен, и разработчик просто вернул пустую сущность.

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

Да и попросту иметь такое в коде — дурной тон. Смотрим, как это можно исправить.

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

Соответственно, мы разделим интерфейс на более мелкие интерфейсы.

И получится, что у нас есть класс IDbWorker, у него остается один метод — GetConnection (он у нас в черном квадрате), от него наследуются другие интерфейсы.

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

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

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

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

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

Отлично! Мы вновь решили задачу, устранили уязвимость, сделали декомпозицию, провели юнит-тесты. Наше приложение эволюционировало и выглядит более презентабельно.

Но все не может быть так просто, верно?

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

 

Вспомним, как мы это делали раньше.

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

Как же нам изменить наш метод так, чтобы мы могли поддерживать и Skype тоже?

И еще момент: сейчас мы держим в голове Skype, но завтра захотят WhatsApp, потом Telegram или просто смс-сообщения и так далее.

Самое простое решение — изменить цикл следующим образом.

У нас есть несколько условных операторов, которые проверяют, есть ли у нас настройка SendType. 

Если есть настройка е-mail, то мы отсылаем с помощью EmailSender, если это голосовое сообщение, то используем VoiceSender, и так далее.

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

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

И вновь нам в голову приходит идея, как это реализовать:

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

Схематически это выглядит так:

Сверху слева у нас интерфейс этого самого провайдера, который реализует один метод Send.

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

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

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

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

А теперь давайте детально рассмотрим каждый принцип и то, как именно мы ему следовали.   

Что такое принципы Solid?

Solid —  это акроним, за которым стоят 5 принципов.

Single Responsibility

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

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

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

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

Open Closed Principle

Следующий принцип это принцип открытости/закрытости, Open Closed Principle.

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

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

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

Liskov’s Substitution Principle 

Следующий принцип — принцип подстановки, или Liskov’s Substitution Principle.

Этот принцип гласит, что наследник должен дополнять функционал родителя, а не замещать его.

Как же этот принцип отразился у нас?

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

Как же мы решили данную проблему? А помог нам следующий принцип.

Interface segregation principle

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

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

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

Dependency Inversion Principle 

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

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

Если вы не сталкивались с этим принципом, то, скорее всего, мало что поняли. Поэтому покажу на примере того, что мы сделали.

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

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

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

Вместо итогов

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

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

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

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

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

Публикация создана на основе доклада Виталия Бальзирова в рамках лектория компании ITentika.

Комментарии (13)


  1. plFlok
    21.10.2022 14:29
    +5

    решение, которое первое приходит в голову

    оно прекрасно


  1. insighter
    21.10.2022 14:52
    +5

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


    1. Kristina_Dmitrievykh Автор
      21.10.2022 17:38
      -2

      Не согласимся. Приведение кода в порядок в подобном формате соответствуют принципам Solid.


      1. DistortNeo
        21.10.2022 22:51

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


  1. OldNileCrocodile
    21.10.2022 17:02

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

    - Вовсе нет.

    GetConnection() используется внутри GetCalendar(). Делать его публичным нету смысла. Так как запросто можно его вызвать в открытую и не закрыть Connection. Второе, а что делать, если у каждого клиента поменялся Connection? Нужен способ задавать ConnectionStrings, по которым DbWorker-ы подключаются к базе. Для этого необходимо определить интерфейс настройки Worker-а (какой-нибудь IWorkerManager).


    1. Kristina_Dmitrievykh Автор
      21.10.2022 17:50
      -1

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


      1. hello_my_name_is_dany
        21.10.2022 18:08
        +3

        По итогу - лёгкий код превратили в сложный


      1. OldNileCrocodile
        23.10.2022 21:20
        +1

        Настолько утрированы, что Worker занимается не только подключением и выполнением запроса к базе, но и чтением РЕЗУЛЬТАТА, извлечением и преобразованием данных. Сразу нарушение Single Responsibility. Потому что название лишь говорит о том, что она работает с базой, и всё. Возможно, нам, например, захочется тестировать преобразование данных (формат дат из одной культуры в другую, к примеру из 23.10.2022 в 2022/10/23). И нам не потребуется работать с базой. Но у нас кроме workers пока ничего нет. Или мы получаем данные в виде JSON из базы. Worker-у не надо знать JSON, CSS, CSV и другие форматы лишь чтобы вернуть корректный результат. Он лишь возвращает ответ из базы и всё. А приведение ответа из базы к адекватному формату - другая сущность.


  1. mSnus
    21.10.2022 18:19
    +5

    Знаете, я много раз замечал: если человек неграмотен, то он неграмотен во всём примерно одинаково.

    Если вы постоянно пишете holydays и emplyee, то ждать грамотного подхода от всей статьи не приходится. Так и вышло: (N+1)-я попытка разжевать SOLID вышла слегка неграмотной.

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


  1. lexxpavlov
    21.10.2022 20:46
    -2

    Хорошая статья. Хорошо показывает, зачем и как можно использовать принципы SOLID. Эти принципы просты только для тех, кто из уже хорошо понимает и/или хорошо подкован в программировании вообще и архитектуре в частности, а для джунов эти принципы сложны, как раз из-за недостатка опыта сложно увидеть грабли. Статья на хорошем примере показывает, что бывает без этих принципов.

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


  1. ioncorpse
    22.10.2022 21:25

    Допустим у нас SOLID. Все хорошо? Не факт, но неплохо.

    Допустим у нас дикий треш, как его довести до ума? Вероятно переписать все напрочь. Статья про это же?


  1. ArduinoFlow
    24.10.2022 10:56

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


  1. sittox
    24.10.2022 10:56

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