Всем привет, читатели Habr! В этой статье я расскажу про DI (Dependency Injection) и также покажу, как я использую его на практике. Погнали!

Сначала простыми словами про DI

Недавно я скачал приложение, которое показывает сколько времени я провожу в своем смартфоне и мою зависимость от него. Что ж, в некоторые дни я проводил больше 5 часов и мне показало сильную зависимость. У меня Xiaomi Redmi Note 8.

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

Теперь к практике

Представим себе такую ситуацию, что каждую первую неделю вы должны показывать заказчику UI с моканными данными, а уже в конце спринта полностью готовую логику. Я надеюсь вы знакомы с паттерном Repository, который отвечает за получение данных, если нет - рекомендую ознакомиться. Самый простой способ решение проблемы - написать в репозитории методы, которые отдают моканные данные, а потом добавить методы для получения реальных данных (или заменить). Однако, поступая таким образом, мы нарушаем один из принципов SOLID, а именно Single Responsibility. Как быть в таком случае? Создать абстрактный класс нашего репозитория и после этого сделать 2 реализации, одна из которых отвечает за моканные данные, а другая - за реальные. И в Bloc или в любой другой класс, который отвечает за State Managment, вы инжектите абстракцию, тем самым давая возможность заменить репозиторий с моками на репозиторий для реальных данных.

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

Время повышать уровень

Однако мы должны постоянно повышать свой уровень! И давайте сейчас его повысим :)

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

Когда мы заменяем одну реализацию на другую, то тем самым нарушаем этот принцип. И разработчики пакета Injectable тоже об этом подумали, поэтому мы можем создавать наши зависимости под разными environments. Для этого нам нужно будет под каждый environment создать main файл, описать их все в launch.json и проинить с каждого файла зависимости с определенным environment.

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

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


  1. ookami_kb
    02.03.2022 20:20
    +2

    Однако, поступая таким образом, мы нарушаем один из принципов SOLID, а именно Single Responsibility.

    SRP тут вообще не при чем. Если так хочется поговорить про SOLID, то это D – Dependency Inversion Principle.

    Когда мы заменяем одну реализацию на другую, то тем самым нарушаем этот принцип.

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


    1. vovaklh Автор
      04.03.2022 00:53

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


      1. ookami_kb
        04.03.2022 01:28
        +1

        Нет, не перепутал. Почитайте Clean Architecture:

        This is how the OCP works at the architectural level. Architects separate functionality based on how, why, and when it changes, and then organize that separated functionality into a hierarchy of components. Higher-level components in that hierarchy are protected from the changes made to lower-level components.

        ...

        The OCP is one of the driving forces behind the architecture of systems. The goal is to make the system easy to extend without incurring a high impact of change. This goal is accomplished by partitioning the system into components, and arranging those components into a dependency hierarchy that protects higher-level components from changes in lower-level components.

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

        Заменить мок на реальный репозиторий, когда он готов – это вообще не про OCP. Подстановка разных реализаций интерфейса через аннотации – тоже. Это же просто прямое следствие инверсии зависимостей: если мы зависим от абстракций, то мы можем подставить любую реализацию.

        А то, что в injectable вы описали подстановку с помощью аннотаций, а не через `return isTest ? Mock() : Impl()` – то вы не сделали систему "закрытой для изменений, но открытой для расширений". Попробуйте, например, добавить еще одну реализацию или еще одну environment, не изменяя существующий код.


        1. vovaklh Автор
          04.03.2022 09:14

          Ваш пример с isTest ? напомнил мне цитату из чистого кода "Каждый оператор наследования можно заменить с помощью наследования". Плюс у меня сейчас и так 3 environment. В Injectable сейчас всего их 3 и думаю этого достаточно.

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


          1. ookami_kb
            04.03.2022 12:09

            Ваш пример с isTest ? напомнил мне цитату из чистого кода "Каждый оператор наследования можно заменить с помощью наследования".

            Да, только это здесь не при чем.

            Плюс у меня сейчас и так 3 environment. В Injectable сейчас всего их 3 и думаю этого достаточно.

            Хорошо, но к расширяемости кода это не имеет отношения.

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

            Если подходить с позиции: "Я художник, я так вижу", то да, всё так, и смысла дискутировать нет.


  1. sergeymolchanovsky
    03.03.2022 07:05

    injectable не нужно использовать вообще. Никогда-никогда.

    1) "Настоящий" DI позволяет автоматически подменять интерфейсы на инстансы конкретных классов. При обращении к переменной интерфейса, мы магически получаем нужный инстанс. Подобный DI в Дарте невозможен в силу отсутствия рефлексии.

    Пример: Zenject в Unity (который я тоже критикую, но в данном случае подойдет для примера)

    public class TestInstaller : MonoInstaller
    {		
        public override void InstallBindings()
        {
            Container.Bind<IWeapon>().To<Sword>();        
        }
    }
    
    
    public class Player
    {
        [Inject]
        IWeapon weapon;
    }

    Готово. Теперь, при обращении к weapon мы гарантированно знаем, что в нем содержится реализация Sword. Чтобы подменить реализацию, достаточно подправить нужную строчку в InstallBindings.

    injectable же не избавит нас от ручных обращений GetIt.instance<IWeapon> во всех местах, где требуется Sword. Все что он делает - это создает нечитаемую портянку с кучей registerSingleton, которую проще написать самому. И к которым все равно надо обращаться вручную.

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

    2) "Настоящий" DI обязан предоставлять верификацию в compile-time. Мы можем прогнать её до запуска приложения и убедиться, что все абстракции подменяются на реализации. В том же Zenject она есть. injectable такой возможности не предоставляет.


    1. ksbes
      03.03.2022 10:03
      +2

      Ну такой «настоящий» DI нарушает другой принцип: «явное всегда лучше неявного». С#, конечно не Python, но но на истинность утверждения это не влияет. За удобства всегда приходится платить. И в конкретных случаях надо выбирать что важнее.

      Да и «портянки» строк вида
      Котенйер.bind<что-то>.to<что-то>.ПриТакихТоУсловиях(<куча_разнообразных_параметров>)
      — тоже никуда не деваются. И, в лучшем случае, сгребаются в один файл, а то и разбрасываются по всему коду, так что хрен найдёшь.


      1. sergeymolchanovsky
        05.03.2022 11:42

        1. Этот принцип чисто питоновский. В других языках может быть по-другому. Джависты, например, обожают именовать интерфейсы не IFoo (явно показывать, что это интерфейс), а просто Foo (мотивируя это принципом дядюшки Боба, что пользователю не нужно знать, что он работает с абстракцией).

        2. Где вы тут в примере увидели "расбрасывание" по всему коду? Все упаковывается в Installer.

        P.S. Я осуждаю Zenject по другой причине - в Unity и так из коробки отличный механизм связывания через визуальный инспектор. Лепить поверх Zenject = масло масляное, тонны бойлерплейтного кода в инсталлерах и переусложнение проекта. Привел его тут просто как пример хорошего DI.


    1. vovaklh Автор
      04.03.2022 00:56
      +1

      Injectable позволяет избавиться от GetIt.instance на основе создания модулей или добавления аннотаций к классам. Это аналог Dagger и Hilt из нативы. Так же хочется сказать, что программирование - это не Хогвартс, и в коде никакой магии не должно быть