Привет, Хабр! Меня зовут Павел Корсаков, я python-разработчик в облачном провайдере beeline cloud. Почти на всех собеседованиях задают вопросы про SOLID: Что это такое? Зачем нужен? Как его применяет кандидат? Как понимает принципы из него? Мы тоже спрашиваем про SOLID, потому что он часто выступает аргументом на ревью. Знание принципов помогает снять градус накала в комментариях под мёрдж-реквестом. Но вернемся к кандидатам. Чаще всего они рассказывают, что SOLID — это акроним, озвучивают все его принципы, но объяснить и привести примеры могут лишь для половины. На остальных либо плавают, либо сливаются. Интернет предлагает множество статей про SOLID как на русском, так и на английском языке. В тех статьях, что я видел, подача информации одинаковая. Именно в этом кроется причина, почему многие получают фрагментированные знания по SOLID, которые не собираются в единую картину.  Принципы SOLID разбираются в последовательности по буквам S, O, L, I, D, что неверно. Акроним сам по себе мнемонический и больше нужен для запоминания, а не для того, чтобы именно в таком порядке разбираться. К тому же такой «последовательный подход» не дает полного понимания SOLID. А вот подача в логической последовательности с закрепляющими примерами и комментариями помогает собрать нужную картинку. Это основной посыл, которым я руководствовался при написании статьи. Чтобы мой материал не получился очередной статьей про SOLID, я изменю формат подачи и последовательность объяснения принципов. Буду добавлять код небольшими инкрементами и на каждом из них указывать, какие принципы SOLID используются в том или ином случае.  Это авторский текст (не перевод) с примерами, которые я обычно использую для объяснения принципов SOLID. Приведу только пару ссылок на Википедию про SOLID и сошлюсь на первоисточник butunclebob.com. В моем идеальном мире SOLID начинается с принципа инверсии зависимостей. Википедия нам дает такое определение:   Классы должны зависеть от абстракций, а не от конкретных деталей. A. Модули верхних уровней не должны зависеть от модулей нижних уровней. Оба типа модулей должны зависеть от абстракций. B. Абстракции не должны зависеть от деталей. Детали должны зависеть от абстракций. Wiki   Это определение совершенно.   «Совершенство достигается не тогда, когда нечего добавить, а когда нечего убрать». Антуан де Сент-Экзюпери  Давайте разбираться по порядку. ABC — это класс-помощник, который указывает метакласс metaclass=ABCMeta в качестве параметров класса.  Вариант class AbstractAuthUser(metaclass=abc.ABCMeta) тоже рабочий, но Python предлагает нам синтаксический сахар, и мы его используем. Оставим первый вариант. Декоратор abstractmethod гарантирует, что у дочернего класса будут все методы, которые декорированы этим декоратором. Им нужно оборачивать все методы, которые будет использовать бизнес-логика. Разработчики, которые будут обращаться к классу аутентификации, могут быть уверены, что у него всегда есть методы is_authenticated, get_email, get_department, потому что они декорированы abstractmethod, а значит, обязательны для реализации в дочерних классах, унаследованных от абстрактного.    В абстрактном классе стоит указывать методы, которые будут вызываться извне. Это своего рода API класса (компонента). В свою очередь методы, которые не должны использоваться извне, не следует указывать в абстрактном классе. При реализации AuthUserAD методы, не являющиеся частью API класса, можно пометить одним подчеркиванием в начале имени. PEP8 Теперь у нас есть абстрактный класс. От него зависит класс, реализующий логику. Рекомендация, что классы должны зависеть от абстракций, реализована. Если вы в своем коде уже используете абстрактные классы — скорее всего, в коде с SOLID все хорошо. Ниже, при описании остальных принципов, мы часто будем возвращаться к этому принципу. Википедия описывает два подхода к пониманию этих принципов. Рассмотрим их подробнее. Принцип открытости/закрытости Мейера Однажды разработанная реализация класса в дальнейшем требует только исправления ошибок, а новые или измененные функции требуют создания нового класса. Этот новый класс может переиспользовать код исходного класса через механизм наследования. Производный подкласс может реализовывать или не реализовывать интерфейс исходного класса. Определение Мейера поддерживает идею наследования реализации. Реализация может быть переопределена через наследование, но спецификации интерфейса могут измениться. Существующая реализация должна быть закрыта для изменений, при этом новым реализациям не обязательно использовать существующий интерфейс*. Полиморфный принцип открытости/закрытости Wiki *Что такое интерфейс — подробно разберем в принципе разделения интерфейса. На собеседованиях соискатели иногда приводят примеры, которые подходят для принципа открытости/закрытости Мейера. Суть его сводится к тому, что для добавления нового функционала мы не меняем существующий класс, а наследуем от него и уже в новом классе добавляем нужный метод. Но такой подход идет вразрез с принципом подстановки Лисков (о нем ниже). У себя в коде мы используем полиморфный принцип открытости/закрытости. В тексте и в примерах я описываю именно его. Давайте предположим, что AuthUserAD — это жуткое легаси, которому лет шесть. И вариантов, чтобы кто-то дал свои комментарии, тоже нет.  AuthUserAD регулярно DDoS-ит и роняет сервер аутентификации. Админ предлагает дешевое решение — поднять несколько инстансов Keycloak. Инстансы Keycloak будут ходить в AD по расписанию, получать список всех пользователей и брать на себя нагрузку, которая лежала на AD. Вносить изменения в класс AuthUserAD — плохая идея. Трудно спрогнозировать риски, которые могут произойти от новых изменений. К тому же после внесения изменений его захочется переименовать, а это равносильно удалению класса. Если мы не исправим все места, где он вызывается по старому имени, то гарантированно уроним код. Сейчас самое время вспомнить о принципе открытости/закрытости. Хорошей идеей будет не трогать класс AuthUserAD. Лучше сделать его осуждаемым и написать новый класс для работы с новой схемой аутентификации. В таком классе вместо ошибок из Keycloak будем райзить кастомные ошибки. Немного подробнее о том, что я понимаю под фразой «сделать класс осуждаемым». В фреймворках встречается такое поведение: когда мы используем метод, в логи валится warning. Это говорит о том, что метод осуждаемый и перестанет поддерживаться с такой-то версии Python или с такой-то версии библиотеки. Я предлагаю тут сделать то же самое. Когда мы будем создавать инстанс осуждаемого класса, к нам в логи (и в Sentry) будет прилетать ошибка, которая указывает, что где-то используется осуждаемый класс.   Если мы где-то не заменили класс AuthUserAD на AuthUserKeycloak, у нас все продолжит работать, поскольку мы не удалили класс AuthUserAD. Он все еще присутствует в коде и работает лучше, чем прежде, поскольку нагрузка на AD уменьшится. При каждом создании инстанса AuthUserAD мы будем видеть ошибку в логах и постепенно безболезненно выпилим его из возможных мест. Принцип открытости/закрытости работает на нас. Принцип открытости/закрытости настолько сильно связан с принципом инверсии зависимостей, что второе можно назвать первым, только немного под другим ракурсом. Теперь у нас есть абстрактный класс AbstractAuthUser. От него зависят оба класса реализации AuthUserAD и AuthUserKeycloak. Причем нам не важны детали реализации каждого конкретного класса. Классу AuthUserKeycloak все равно, как реализован класс AuthUserAD и реализован ли он вообще.  Также у нас абстракция не зависит от деталей: классу AbstractAuthUser не важны детали реализации дочерних классов AuthUserAD на AuthUserKeycloak, например класс с заглушками для тестов AuthTest. Более того, мы добавили новый функционал в классе AuthUserKeycloak, не меняя закрытый для изменения класс AuthUserAD.   Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.   Wiki   Я считаю, что принцип подстановки Лисков — сердце SOLID и то, ради чего SOLID задумывался. Этот принцип связан с тем, что я описал выше.  В классах, помимо обязательных методов, описанных в абстрактном классе, могут быть свои методы. В них могут отличаться сигнатуры, названия методов и реализация (как же без полиморфизма). Например, для http-запроса на сервис аутентификации могут быть реализованы разные методы для проверки токена, получения почты и названия отдела. Это не противоречит SOLID. Главное, чтобы бизнес-логика работала только с методами из абстрактного класса и сигнатуры методов, с которыми будет работать бизнес-логика, были одинаковыми. Может показаться неочевидным, но в Python для принципа подстановки Лисков недостаточно ограничиваться методами, описанными в абстрактном классе. Если классы AuthUserAD и AuthUserKeycloak рейзят разные ошибки, это значит, что классы не удовлетворяют принципу подстановки Лисков.  В контексте SOLID использование кастомных исключений — больше, чем просто хорошая практика. Это расширение понимания принципа подстановки Лисков. Мы знаем, какие ошибки могут возникнуть, и если вы напишете свои кастомные ошибки — это будет отличной идеей. То есть не пробрасывать ошибки из AD и Keycloak, а обрабатывать их в классе и рейзить свои исключения. Поскольку новый класс AuthUserKeycloak реализует такие же обязательные методы, как и AuthUserAD и рейзит те же самые ошибки, то в эндпоинте (или middleware), где использовался класс AuthUserAD или его инстансы, мы можем безболезненно заменить его на новый AuthUserKeycloak. Принцип подстановки Лисков работает при такой реализации.  Мы написали собственные классы, теперь любую нашу реализацию можно заменить без внесения изменений в код. Для меня это самое важное в SOLID. Небольшое замечание. Декоратор abstractmethod гарантирует, что обязательные методы будут созданы, но он позволяет создать их с различными сигнатурами. А для принципа подстановки Лисков важно, чтобы сигнатуры были одинаковые или хотя бы совместимые. Без этого заменить базовый класс дочерним или один дочерний класс другим дочерним не получится. Возможно, кто-то скажет, что принцип подстановки Лисков связан с базовым и дочерним типами. И определение в Википедии говорит об этом: Функции, которые используют базовый тип, должны иметь возможность использовать подтипы базового типа, не зная об этом.   Поскольку AuthUserKeycloak реализует такие же обязательные методы, как и AuthUserAD, это никак не влияет на возможность использования одного класса вместо другого. Если, например, реализация методов get_email и get_department в AuthUserKeycloak такая же, как и в AuthUserAD, такое наследование возможно. Если же у AuthUserKeycloak своя реализация методов API класса — такое наследование не нужно. Кроме того, мы получим нежелательный побочный эффект в экземпляре метода AuthUserKeycloak — нам доступны все методы базового класса. Я считаю, что если мы можем один класс заменить другим при любом, даже сложном наследовании, то здесь соблюдается принцип подстановки Лисков. Всё, с наиболее сложными для понимания принципами мы закончили. Остались те, которые обычно не вызывают трудностей. Каждый объект должен иметь одну ответственность, и эта ответственность должна быть полностью инкапсулирована в класс.  Все его поведения должны быть направлены исключительно на обеспечение этой ответственности. Wiki   Здесь все более прозрачно. У класса AuthUserKeycloak может быть только одна причина для внесения изменений — изменения в аутентификацию или в работу с Keycloak. Если вы вносите изменения в класс для добавления новых пермишенов или для изменения способа сохранения, это уже дополнительные причины, а по рассматриваемому нами принципу причина должна быть одна. Поэтому авторизацию и работу с пермишинами необходимо вынести в отдельный класс, чтобы замена AuthUserKeycloak обратно на AuthUserAD не влияла на пермишены и на то, какие действия для пользователя разрешены или запрещены. Аналогично можно рассмотреть и хранение данных: если класс AuthUserKeycloak сохраняет какие-то данные, можно создать метод для этого действия, а он в свою очередь будет работать с экземпляром класса для хранения. Логика открытия файла или соединения, логика сохранения в файл или базу, закрытия файла или соединения должны быть вынесены из класса AuthUserKeycloak в соответствующий метод класса, который отвечает за работу с хранилищем. Хорошо, если для хранилища создать абстрактный класс, а от него уже наследовать дочерние классы. Например, для хранения в файле, в SQL базах данных через ORM, в NoSQL базах или другие варианты, а инстанс для нужного способа хранения использовать в AuthUserKeycloak. Я обычно использую один абстрактный класс и класс для ORM.   И последний незатронутый принцип. Чтобы его понять, нужно определиться с понятием интерфейса. Дело в том, что в Python нет явного понятия интерфейса, как в других языках программирования. Если говорить своими словами, то интерфейс — это абстракция, которая позволяет определить методы без конкретной реализации. И если в классе присутствуют все методы интерфейса, можно считать, что класс реализует интерфейс. Получается, что интерфейсам, про которые говорится в принципе разделения интерфейса, больше всего соответствуют абстрактные классы. После этого пояснения принцип становится очевидным. Фраза «много интерфейсов, специально предназначенных для клиентов, лучше, чем один интерфейс общего назначения» теперь становится понятной.  Прежде чем объяснять, что тут происходит, еще раз вспомним теорию. Программные сущности не должны зависеть от методов, которые они не используют. Принцип разделения интерфейсов говорит о том, что слишком «толстые» интерфейсы необходимо разделять на более маленькие и специфические, чтобы программные сущности маленьких интерфейсов знали только о методах, которые необходимы им в работе. В итоге при изменении метода интерфейса не должны меняться программные сущности, которые этот метод не используют. Wiki   В примере реализовано два абстрактных класса для работы с данными AbstractStore, AbstractReadOnlyStore и класс абстрактного коллектора AbstractCollector. Метод коллектора get_metrics_from_service ходит в инфраструктуру и имеет доступ к критичным данным. В разных коллекторах данные собираются из различных источников (api, базы данных, файлы и др.). Но здесь выполняется только сбор данных (только безопасные методы). Методы, которые принято относить к опасным, в нем использоваться не будут. Поэтому при написании класса для сбора данных нужно использовать AbstractReadOnlyStore. Тогда потенциально опасные методы будут исключены на уровне интерфейса. Метод коллектора save_metrics обычно выполняет только запись в базу данных. Получается, что для написания класса сохранения в базу достаточно реализовать только create и update. Эта реализация позволяет нам легко собрать такой класс:  Хотя это и будет наиболее соответствующий принципу разделения интерфейса класс, на практике мы так не делаем. А наследуемся от AbstractStore и в нем реализуем все методы CRUD. Можно сказать, что интерфейс с полным CRUD будет «толстым» для функционала сохранения. Принцип разделения интерфейсов тесно связан с принципом единой ответственности, если класс StoreReadOnlyDB занимается только сбором статистики. И в соответствии с принципом единой ответственности мы не закладываем в него другой функционал. Также в соответствии с принципом разделения интерфейсов из абстрактного класса (интерфейса) AbstractReadOnlyStore убираем методы, которые не будем использовать. В статье я привел примеры на Python. Но хочу заметить, что принципы — это речь не столько про классы, сколько про программные сущности. В моем понимании принципы SOLID с таким же успехом могут использоваться, например, в архитектуре ПО, при описании схем взаимодействия микросервисов. Надеюсь, что после прочтения статьи принципы SOLID стали для вас такими же очевидными, как для Роберта С. Мартина. Главная мысль, которую я хотел донести, это то, что все принципы тесно связаны друг с другом. Надеюсь, этот текст дополнил информацию из других источников и помог составить из нее законченную картину, понять, насколько тесно принципы связаны между собой. Я верю, что теперь вы легко ответите на все вопросы по SOLID на собеседовании и будете писать солидный код. Все правила, в том числе и SOLID, можно нарушать. В небольших проектах строгое соблюдение всех принципов SOLID избыточно. Но программисты, которые пишут небольшие проекты, растут. Растут и их проекты — становятся большими, очень большими и архитектурно сложными. В таких проектах соблюдение принципов SOLID крайне желательно или обязательно. Как быстро и эффективно выгореть на работе: рекомендации экспертов vs опыт выжившего — автор рассказывает, что такое профессиональное выгорание и почему люди выгорают. Какие способы борьбы с выгоранием работают, а какие нет? Как провести аудит безопасности Kubernetes — с помощью утилит Kube-bench, Kube-hunter, Kubescape, Trivy, собрать итоговый отчет о проверке и исправить уязвимости. Новости нормативки по ИБ. Рассказываем, какие нормативные акты появились в последнее время и что важно знать об их содержании. Как попасть в реестр отечественного ПО: документы, сроки и подводные камни — нашу заявку приняли с третьего раза, но теперь мы знаем, в чем была ошибка. beeline cloud — secure cloud provider. Разрабатываем облачные решения, чтобы вы предоставляли клиентам лучшие сервисы.