Понятнее о S.O.L.I.D

Большинство разработчиков с разговорами о принципах архитектурного дизайна, да и принципах чистой архитектуры вообще, обычно сталкивается разве что на очередном собеседовании. А зря. Мне приходилось работать с командами, которые ничего не слышали о S.O.L.I.D, и команды эти пролетали по срокам разработки на многие месяцы. Другие же команды, которые следовали принципам дизайна и тратили очень много времени на буквоедство, соблюдение принципов чистой архитектуры, код-ревью и написание тестов, в результате значительно экономили время Заказчика, писали лёгкий, чистый, удобочитаемый код, и, самое главное, получали от этого кайф.

Сегодня мы поговорим о том, как следовать принципам S.O.L.I.D и получать от этого удовольствие.



Что такое S.O.L.I.D? Погуглите — и получите 5 принципов, которые в 90% случаев описываются очень скупо. Скупость эта потом выливается в непонимание и долгие споры. Я же предлагаю вернуться к одному из признанных источников и хотя бы на время закрыть этот вопрос.

Источником принципов S.O.L.I.D принято считать книгу Роберта Мартина «Чистая архитектура». Если у Вас есть время прочесть книгу, лучше отложите эту статью и почитайте книгу. Если времени у Вас нет, а завтра собес — велком.

Итак, 5 принципов:

Single Responsibility Principle — принцип единственной ответственности.
Open-Closed Principle — принцип открытости/закрытости.
Liskov Substitution Principle — принцип подстановки Барбары Лисков.
Interface Segregation Principle — принцип разделения интерфейсов.
Dependency Inversion Principle — принцип инверсии зависимости.


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

Single Responsibility Principle — принцип единственной ответственности


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

Сформулировать его можно так:

Модуль должен иметь только одну причину для изменения. Или: модуль должен отвечать только за одну заинтересованную группу.

Вот тут становится непонятно, поэтому мы обратимся к признанному первоисточнику — книге Роберта Мартина, у которого есть отличный пример на этот счёт. Воспользуемся им.

Предположим, что существует какая-то система, в которой ведётся учёт сотрудников. Сотрудники интересны как отделу кадров, так и бухгалтерии. Для их нужд, в числе прочих, в сервисе EmployeeService есть 2 метода:

fun calculateSalary(employeeId: Long): Double //рассчитывает зарплату сотрудника
fun calculateOvertimeHours(employeeId: Long): Double //рассчитывает сверхурочные часы

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

Логично, что для расчёта зарплаты бухгалтерии может потребоваться учесть сверхурочные часы сотрудника. В таком случае, метод calculateSalary() может вызвать calculateOvertimeHours() и применить его результаты в своих формулах.

Окей, прошло полгода, и в отделе кадров решили поменять алгоритм расчёта сверхурочных. Предположим, раньше один сверхурочный час рассчитывается не по коэффициенту * 2, а стал — по коэффициенту * 2,5. Разработчик получил задание, изменил формулу, проверил, что всё работает, и успокоился. Город засыпает, просыпается бухгалтерия. А у бухгалтерии ничего не поменялось, они считают зарплаты по тем же формулам, вот только в этой формуле теперь будут другие цифры, потому что calculateSalary() ходит в calculateOvertimeHours(), а там теперь по просьбе отдела кадров сверхурочные не * 2, а * 2,5. Упс…

Теперь, если мы вернёмся к описанию принципа, всё станет намного понятнее.

Модуль должен иметь только одну причину для изменения. Эта причина, в нашем случае — потребности только отдела кадров, но не бухгалтерии. Модуль должен отвечать только за одну заинтересованную группу. calculateOvertimeHours() должен обрабатывать только одну группу — отдел кадров, несмотря на то, что он может показаться универсальным. Работа этого метода никаким образом не должна касаться бухгалтерии.

Теперь, я надеюсь, стало понятнее.

Каким может быть решение проблемы выше?

Решений проблемы может быть несколько. Но что Вам точно будет необходимо сделать — это разделить функциональность в разные методы — например, calculateOvertimeHoursForAccounting() и calculateOvertimeHoursForHumanRelations(). Каждый из этих методов будет отвечать за свою заинтересованную группу и принцип единой ответственности не будет нарушен.

Open-Closed Principle — принцип открытости/закрытости


Принцип гласит:

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

Возьмём пример из суровой реальности.

У нас есть приложение, и, предположим, довольно неплохое. В нём есть сервис, который пользуется внешним сервисом. Предположим, это сервис котировок ЦБ РФ. В простой реализации архитектура этого сервиса будет выглядеть следующим образом:



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

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

Конечно же, через интерфейсы. Стоит нам разделить объявление функциональности и её реализацию, как эта проблема будет решена. Теперь мы можем реализовать интерфейс FinancialQuotesRestService сколько угодно раз; в нашем случае, это будет FinancialQuotesRestServiceImpl и FinancialQuotesRestServiceMock. На тестовом стенде приложение будет запускаться по Mock-профилю, на продакшене оно будет запускаться в обычном режиме.



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



Мы добавили новую функциональность, но прежние две затронуты не были. Даже если команда разработчиков полностью обновится и приложение будут разрабатывать совсем другие программисты, им не придётся трогать старый код. Таким образом, достигается главная цель Принципа Открытости-Закрытости — сделать систему легко расширяемой и обезопасить её от влияния изменений.

Liskov Substitution Principle — принцип подстановки Барбары Лисков


Принцип подстановки Барбары Лисков можно сформулировать так:

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

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

public interface FinancialQuotesRestService {

    List<Quote> getQuotes();
}

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

В нашем случае, контроллер будет выглядеть так:

@RestController("/quotes")
@RequiredArgsConstructor
public class FinancialQuotesController {
    
    private final FinancialQuotesRestService service;
    
    @GetMapping
    public ResponseEntity<List<Quote>> getQuotes() {
        return ResponseEntity.ok(service.getQuotes());
    }
}

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

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

Interface Segregation Principle — принцип разделения интерфейсов


Принцип можно сформулировать так:

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

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

public interface CrudService<T> {
    
    T create(T t);
    
    T update(T t);
    
    T get(Long id);
    
    void delete(Long id);
}

Окей, мы реализовали его в сервисе UserService:

@Service
@RequiredArgsConstructor
public class UserService implements CrudService<User> {

    private UserRepository repository;
    
    @Override
    public User create(User user) {
        return repository.save(user);
    }

    @Override
    public User update(User user) {
        return repository.update(user);
    }

    @Override
    public User get(Long id) {
        return repository.find(id);
    }

    @Override
    public void delete(Long id) {
        repository.delete(id);
    }
}

Далее, нам потребовалось реализовать класс PersonService, который мы тоже наследуем от CrudService. Но проблема в том, что сущности Person — не удаляемые, и нам не нужна реализация метода delete(). Как быть? Можно сделать так, например:

    @Override
    public void delete(Long id) {
        //не реализуется
    }

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

Что делать в такой ситуации?

Выделить реализацию CRUD без удаления в отдельный интерфейс. Например, так:

public interface CruService<T> {

    T create(T t);

    T update(T t);

    T get(Long id);
}

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

@Service
@RequiredArgsConstructor
public class PersonService implements CruService<Person> {

    private final PersonRepository repository;

    @Override
    public Person create(Person person) {
        return repository.save(person);
    }

    @Override
    public Person update(Person person) {
        return repository.update(person);
    }

    @Override
    public Person get(Long id) {
        return repository.find(id);
    }
}

Теперь сущность Person нельзя удалить! PersonService реализует интерфейс, в котором не объявлено ничего лишнего. В этом и есть соблюдение Принципа разделения интерфейсов.

Dependency Inversion Principle — принцип инверсии зависимости


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

Связи должны строиться на основе абстракций. Если вы вызываете какой-то сервис (например, автовайрите его), Вы должны вызывать его интерфейс, а не реализацию.

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

У этого принципа, впрочем, есть исключения. Можно строить зависимости от класса, если он предельно стабилен. Например, класс String. Вероятность изменения чего-либо в String всё-таки очень мала (несмотря на то, что я сам описывал добавления в функциональность в String в Java 11 в одной из предыдущих статей, хехе). В случае со стабильным классом String, мы можем себе позволить вызывать его напрямую. Как и в случае с другими стабильными классами.

В общем, Принцип инверсии зависимости можно постулировать так:

Абстракции стабильны. Реализации нестабильны. Строить зависимости необходимо на основе стабильных компонентов. Стройте зависимости от абстракций. Не стройте их от реализаций.

Заключение


Мы, разработчики, не только пишем код (и это лучшие моменты в нашей работе). Мы вынуждены его поддерживать. Чтобы эти моменты нашей работы не стали худшими, используйте принципы S.O.L.I.D. Использование принципов S.O.L.I.D, по моему опыту, окупается в течение одного спринта. Потом сами себе скажете спасибо.

Похожие публикации

AdBlock похитил этот баннер, но баннеры не зубы — отрастут

Подробнее
Реклама

Комментарии 37

    +2
    Принцип подстановки Барбары Лисков можно сформулировать так:

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


    Вот интересно, гдеж вы взяли такую формулировку этого принципа? В вики есть формулировки от Барбары, и от Роберта, и обе они, что характерно, используют термин «подтип» (subtype). Причем как в русской редакции, так и в английской. А вы это слово куда дели?
      0
      Спешу удовлетворить Ваш интерес. Из книги Роберта Мартина «Чистая архитектура», издание 2018 года, 77 страница. Слова «подтип» там нет. Изучать чистую архитектуру по википедии… Ну, такое… :) Уж лучше хабр :)
        0

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

          +5

          Во-первых, там слово "подтип" есть, как раз на этой самой 77й странице.


          Во-вторых, взятый вами фрагмент относился к назначению принципа, а не к его формулировке (формулировки этого принципа на 77й странице нет).

            –5
            Вы хотите, чтобы я выкатил неподготовленному читателю в ознакомительной статье о SOLID формулировку от Барбары Лисков? Вы жестокий человек :)
            +3
            Видите ли, без этого слова данный принцип не имеет смысла.
          0

          Можно очень четко описать что это за команда которая никогда даже не слышала! про эти принципы и каким образом именно это!!! повлияло на их продуктивность?


          В 100% случаев с которыми я сталкивался в сроки не укладывались потомучто никто никогда не хочет работать (что абсалютно нормально т.к зп разработчиков не зависит от прибыли).

            –2
            Добавлю чёткости. На предыдущем месте работы одна команда писала заказ для одного крупного ритейлера (привет, Николай). О S.O.L.I.D ничего не слышали (привет, Джонни). Пролетели по срокам более чем в 2 раза. На код без слёз не взглянешь.
              +5
              У меня есть пример команды, которая свято следовала всем Солидам, Скрамам и прочим ТДД, и как вы думаете что? Правильно, они точно так же пролетели по срокам, потому что они не решали задачу заказчика, а писали красивый код и проводили ретроспективы на целый рабочий день
                –2
                Тоже бывает. Но реже.
                  0

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

                    0
                    небольших и средних проектов

                    Да, но только, если проект «выстрелит» потом убить кучу времени разбор такого кода, чтобы запилить очередную фичу, которая нужна «еще вчера».
                      –1
                      Нет, не проще. И уже на следующий день не понятнее. Попробуйте чистую архитектуру. Уверен, Вам понравится. Ничего хитрого там нет.
              +10
              В интернете уйма статей с названиями типа «Просто о SOLID», «Понятнее о SOLID», «Еще понятнее о SOLID». А может что-то не так с самим SOLID, если нужно одно и то же разжевывать по сто раз?
                –4
                Изначально статья называлась «Что не так с S.O.L.I.D», Вы попали пальцем в небо :)
                0

                Пожалуйста, объясните, как принцип единственной ответственности применяется на верхних уровнях иерархии системы?

                  –1
                  Что Вы понимаете под верхними уровнями иерархии?
                    0

                    Ну, функция main() в С/С++ программе, которая делает много всего. Типа Adobe Photoshop.

                      0
                      main является компонентом самого низкого уровня.

                      Компонент Main — это конечная деталь, политика самого низкого уровня.
                      Он является точкой входа в систему. От него ничего не зависит, кроме работоспособности системы. Его задача — создать все Фабрики, Стратегии
                      и другие глобальные средства и затем передать управление высокоуровневым абстракциям в системе.


                      Роберт Мартин, «Чистый код».

                      Я думал, Вы про микросервисы, к примеру.
                        0

                        А можно простейший пример? Пусть есть файл(ы) в каждом из которых первая строка некий идентификатор (наименование параметра), потом числа в столбик (для простоты любой конкретное число, например 3). Программа должна просто посчитать среднее по числам из файла и вывести результат на консоль с указанием названия параметра.


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

                    +1

                    Если я правильно понял автора (и S в SOLID), то именно поэтому это не принцип "единственной ответственности", а принцип единственной зависимости от требований. То есть верхние уровни иерархии системы ответственны за одно требование, обычно какое-нибудь общее и не должны меняться от изменения компонентов/требований ниже.

                    +4

                    Разбираясь с SRP, я обратил внимание на следующее: в оригинале (Robert Martin — Clean Architecture), финальное определение принципа звучит так:


                    A module should be responsible to one, and only one, actor.

                    Обратите внимание на фразу "responsible to", которую на русский язык все переводят как "ответственный за". Я не эксперт в английском, но, если не ошибаюсь, "ответственный за" переводится на английский как "responsible for". На сколько я понимаю, предлог "to" в оригинале говорит не об ответственности "за что-то", а об ответственности "перед кем-то/чем-то". Тогда, на мой взгляд, этот принцип на русском языке становиться намного понятней:


                    Модуль должен быть ответственным перед единственным актором (потребителем).


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

                      0
                      Да, так и есть.
                        +1

                        То есть "одна функция должна быть вызвана только в одно месте".

                          0
                          А что делать если модуль обслуживает потребности нескольких акторов? Копировать его рядышком?
                          А если этот модуль используется несколькими другими модулями?
                            0

                            Сделать N+1 модуль: по одному для каждого актора из внешнего мира и библотечный, который все они используют. Актором этого библиотечного будет программист, придкрживающицся, например, Dry. И причина для его изменения: во всех N модулях появился дублирующийся код.

                          +4

                          Ох уж этот SOLID. В нем только LSP четко описан, но не Мартином, а самой Лисков, рекомендую читать оригинал.


                          Модуль должен иметь только одну причину для изменения.

                          Ню ню, смешно, что это подаётся как некая «мудрость» опытными разработчиками, да ещё и в форме догмы. Я понимаю когда джуны читают и радуются, им может казаться, что вот он, ответ на вопрос «как проектировать системы». Ну давайте посмотрим на SRP внимательнее.


                          «Причиной для изменений» может явиться любое событие в будущем. “Событие/причина» это множество «элементарных событий/причин» (вспоминаем тер. вер.) Очевидно, что если нам нужно иметь строго одну причину для изменений, то этой причиной может быть только «элементарное событие». Разложить всю реальность на элементарные события мы не умеем. Приехали. Можно выкручиваться, но тогда нужно реальность заменить моделью, в которой каких-то событий не будет. Например там может не быть переезда с Linux на Windows или задачи типа «нужно все ускорить в 1000 раз на том же железе». Но в реальности то все это и многое другое есть. В итоге SRP тем проще при унять, чем меньше об окружающей действительности знает применяющий, идеальное оружие воинствующей невежественности.


                          Мне больше нравится «принцип понятной цели» или ППЦ! Он гласит — «после прочтения текста модуля достаточно подготовленный читатель должен понять, что этот модуль делает и (важно) как эта деятельность соответствует деятельности других модулей»


                          Могу поспорить, что мой один ППЦ помощнее всего SOLID будет.

                            +1
                            достаточно подготовленный читатель

                            Какими критериями разработчик должен обладать, чтобы стать ДПЧ?
                              0
                              Для каждой системы будет немного по разному, единой формулы не будет, да и вообще, формулы тут не будет, а будет все «на глазок». «Достаточно подготовленный» — достаточно хорошо знает предметную область, достаточно хорошо знает систему, достаточно хорошо знает применяемые технологии.

                              Когда ДПЧ завалит задачу можно будет решить, либо он не ДПЧ, либо модуль не ППЦ и скорректировать ситуацию :)
                            +1
                            Вы видимо устали к пятому пункту и принцип инверсии зависимости описан также как и везде — сова из слов, которую в упор не понятно как натягивать на пень реальности.
                              0
                              Мы тут имеем дело профессиональными гадалками от программирования, не удивительно, что формулировки такие, что туда можно весь мир вписать при желании.
                              0
                              А у бухгалтерии ничего не поменялось, они считают зарплаты по тем же формулам, вот только в этой формуле теперь будут другие цифры, потому что calculateSalary() ходит в calculateOvertimeHours(), а там теперь по просьбе отдела кадров сверхурочные не 2, а 2,5. Упс…

                              То есть проблема в том, что код не покрыт тестами. При чём тут SRP?


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

                              То есть OCP создаёт проблему на ровном месте, ок.


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

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


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

                              Какое-то масло маслянное. Барбара точно это говорила?


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

                              Неизменяемый контракт? Мы точно про LSP говорим, а не про OCP?


                              Теперь сущность Person нельзя удалить! PersonService реализует интерфейс, в котором не объявлено ничего лишнего.

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

                                0

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

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

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

                                  0

                                  Пример interface crud extends cru не помешал бы статье

                                  0
                                  Стройте зависимости от абстракций. Не стройте их от реализаций.

                                  А где тут инверсия? (закрываю глаза ладонями) А нету инверсии!
                                    0

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

                                    Самое читаемое