InheritedWidget — антипаттерн?

Service Locator — зло. InheritedWidget — это сервис локатор с ограничениями.
В этой статье разберемся, как решают эти ограничения проблемы сервис локатора, и решают ли...

Чем InheritedWidget лучше переноса зависимостей

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

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

Таким образом, паттерн с использованием InheritedWidget'а становится интересным для использования.

Как использовать InheritedWidget

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

Сильная связность между клиентом и зависимостью никуда не пропадает, что становится причиной множества проблем. Например, таким образом созданный сервис невозможно тестировать, поскольку он будет зависеть от контекста — его можно будет протестировать только в виджете, который находится под InheritedWidget'ом.

Скоуп InheritedWidget'a

У InheritedWidget'a есть scope доступа к сервисам, зарегистрированным в нем, который ограничен его дочерним поддеревом виджетов. т. е. доступ к его сервисам может получить любой виджет его дочернего поддерева, а не любой компонент приложения.

Доступ к сервисам InheritedWidget'a могут получить только через контекст, а другие компоненты системы, не находящиеся в нужном месте дерева виджетов (подInheritedWidget'ом) не могут получить к ним доступ. Если InheritedWidget отвечает за сборку сервиса, доступ к которому он предлагает, то тестирование этого сервиса становится сложным, поскольку компоненты тестирования должны будут располагаться под InheritedWidget'ом.

Поэтому, InheritedWidget не должен отвечать за сборку сервисов. Сборка всех сервисов приложения должна происходить в DI‑контейнере, который должен располагаться в composition root'е. Таким образом, станет возможным тестирование каждого сервиса, используемого в приложении.

InheritedWidget может сам получать нужные ему зависимости и использовать DI‑container в composition root'е как service locator, но в использовании сервис локатора есть множество проблем, поэтому такой способ не подходит.

Инжектор, находящийся в composition root'е, должен через конструктор InheritedWidget'а осуществлять внедрение всех необходимых ему зависимостей. Если InheritedWidget будет находится вглуби деерва виджетов, то произойдет проблема переноса зависимостей через конструкторы промежуточных виджетов дерева. Поэтому InheritedWidget должен быть высокоуровневым виджетом и находится близко к корню дерева виджетов.
Наиболее логично использовать InheritedWidget'ы в качестве поставщика сервисов, используемых в скринах приложения. Таким образом, InheritedWidget'ы должны быть коренными виджетами скринов и требовать через свой конструктор от инжектора все зависимости, используемые в скрине.

Сервисы для скринов в InheritedWidget'е

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

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

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

Проблема раздувания InheritedWidget'ов

Если InheritedWidget'ы — высокоуровневые компоненты в корне скринов, то они могут стать слишком большим хранилищем зависимостей, которые используются внутри всего поддерева скрина.

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

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

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

Ограничение доступа к сервисам InheritedWidget'а.

Проблема InheritedWidget'а заключается в том, что доступ к зарегистрированным в нем сервисам, которые предназначены для низкоуровневых виджетов поддерева, получают виджеты находящиеся выше по уровню виджетов назначения. Они не должны иметь доступ к этим сервисам из‑за принципа меньшего знания, чтобы не иметь доступа к функциям и информации, к которым они не должны иметь доступ.

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

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

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

Сравнение с service locator'ом.

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

InheritedWidget позволяет сохранит открытый интерфейс, в отличие от сервис локатора.
Если виджет использует InheritedWidget для получения зависимостей, то это отражается в его интерфейсе. Запрос к InheritedWidget'у происходит из специальных методов виджета, которые являются частью его интерфейса. Эти методы извлекают зависимости из InheritedWidget'а и устанавливают их в качестве значений полей зависимостей.
Это позволяет отслеживать сложность виджетов (если виджет обретает слишком много зависимостей, то он становится слишком сложным, и с этим нужно бороться.
Также, позволяет узнать ответственность виджета — по зависимостям можно определить что делает виджет (например, если есть зависимость в виде репозитория для отправки запроса регистрации, значит виджет отвечает за скрин регистрации).

Вывод

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

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


  1. Dragon274
    20.09.2024 17:48
    +1

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


    1. maratxat Автор
      20.09.2024 17:48
      +1

      спасибо, рад стараться)


  1. OlegZH
    20.09.2024 17:48

    Для новичков можно было написать введение. А то в первом же предложении: "Service Locator - зло." Что это такое? Где? И... по какому праву?


    1. maratxat Автор
      20.09.2024 17:48

      Боюсь, что это объяснение может оказаться слишком долгим, для этой статьи. Да и написано про него уже предостаточно, поэтому мне видится аксиомой, что сервис локатор - антипаттерн.

      Поэтому, пометил статью средним порогом вхождения


      1. OlegZH
        20.09.2024 17:48

        Всё понятно. Займусь, сначала, базой, то есть — бучами, фаулерами, бруксами и мартинами. Но потом (если буду жив, конечно!) я обязательно приду и заново ознакомлюсь в Вашей точкой зрения.


        1. maratxat Автор
          20.09.2024 17:48

          статья останется ждать вас)
          успехов!


  1. mlazebny
    20.09.2024 17:48

    Сильная связность между клиентом и зависимостью никуда не пропадает, что становится причиной множества проблем. Например, таким образом созданный сервис невозможно тестировать, поскольку он будет зависеть от контекста — его можно будет протестировать только в виджете, который находится под InheritedWidget'ом.

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

    У InheritedWidget'a есть scope доступа к сервисам, зарегистрированным в нем, который ограничен его дочерним поддеревом виджетов. т. е. доступ к его сервисам может получить любой виджет его дочернего поддерева, а не любой компонент приложения.

    Доступ к сервисам InheritedWidget'a могут получить только через контекст, а другие компоненты системы, не находящиеся в нужном месте дерева виджетов (подInheritedWidget'ом) не могут получить к ним доступ. Если InheritedWidget отвечает за сборку сервиса, доступ к которому он предлагает, то тестирование этого сервиса становится сложным, поскольку компоненты тестирования должны будут располагаться под InheritedWidget'ом.

    Никто и не утверждал что нужно собирать зависимости в Inherited Widget, вы используете его не по назначению. Он не должен содержать никакой логики. InheritedWidget используется исключительно для того, чтобы передавать кусочки информации вниз по дереву:

    class DependenciesScope extends InheritedWidget {
      /// {@macro dependencies_scope}
      const DependenciesScope({
        required super.child,
        required this.dependencies,
        super.key,
      });
    
      /// Container with dependencies.
      final DependenciesContainer dependencies;
    
      /// Get the dependencies from the [context].
      static DependenciesContainer of(BuildContext context) =>
          context.inhOf<DependenciesScope>(listen: false).dependencies;
    
      @override
      void debugFillProperties(DiagnosticPropertiesBuilder properties) {
        super.debugFillProperties(properties);
        properties.add(
          DiagnosticsProperty<DependenciesContainer>('dependencies', dependencies),
        );
      }
    
      @override
      bool updateShouldNotify(DependenciesScope oldWidget) => false;
    }

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

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

    Вся статья про то, что сервис локатор зло и здесь вы берете сервис локатор.

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

    Касательно того, что вы называете get_it DI контейнером это неправильно. Это сервис локатор, потому что вы сами (СВОИМИ РУКАМИ) создаете граф зависимостей, то есть описываете все связи между объектами вручную. Эту проблему решает injectable, его можно считать DI контейнером. Тем не менее, он не решает проблемы сервис локатора, так как основан на нем. Использовать его так же не рекомендуется.


    1. maratxat Автор
      20.09.2024 17:48

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

      Никто и не утверждал что нужно собирать зависимости в Inherited Widget, вы используете его не по назначению. Он не должен содержать никакой логики. InheritedWidget используется исключительно для того, чтобы передавать кусочки информации вниз по дереву:

      Я рассмотрел два варианта использования InheritedWidget'а. Первый - как фабрика зависимостей сразу над виджетом использования. Второй - как высокоуровневый виджет над виджетом скрина, который получает зависимости из DI-контейнера.
      Данные примеры были приведены для того, чтобы обосновать почему именно первый способ является плохой практикой и почему второй способ наиболее предпочтителен.

      Вся статья про то, что сервис локатор зло и здесь вы берете сервис локатор.


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

      • DI-контейнер - это контейнер, который используется исключительно в composition root'е. Здесь контейнер - не глобальная переменная.

      • Service Locator - это контейнер, к которому компоненты приложения имеют глобальный доступ из любой точки приложения

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

      Касательно того, что вы называете get_it DI контейнером это неправильно. Это сервис локатор, потому что вы сами (СВОИМИ РУКАМИ) создаете граф зависимостей, то есть описываете все связи между объектами вручную. Эту проблему решает injectable, его можно считать DI контейнером. Тем не менее, он не решает проблемы сервис локатора, так как основан на нем. Использовать его так же не рекомендуется.

      Возможно, в качестве di-container'а, действительно лучше использовать injectable а не get_it.
      И я повторюсь, di-контейнер не используется как глоабальная переменная, поскольку в противном случае его уже нужно называть сервис локатором.


      1. mlazebny
        20.09.2024 17:48
        +1

        Если использовать get_it как локальную переменную в Composition Root, то в этом еще меньше смысла. Зачем тогда он вообще нужен?) Посмотрите инициализацию в моем шаблоне:

        https://github.com/hawkkiller/sizzle_starter

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


        1. maratxat Автор
          20.09.2024 17:48

          Цитата из статьи:

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

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

          Статья, конечно, не о том, как реализовать di-контейнер во флаттере.

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

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

          Посмотрел ваш шаблон. Я в Flutter, да и вообще в mob dev'е не так давно, поэтому сложно понять что хорошо а что плохо. Много статьей, где пишут не самые правильные вещи (как оказывается впоследствии). Поэтому приходится многое придумывать самому. А у вас в шаблоне я увидел ту реализацию DI, к которой сам пришел. Фактически, та же концепция, о которой я писал в статье. Изучу подробнее, буду использовать.

          Как оказывается, на ваш блог я уже натыкался ранее, но по объективным причинам не читал)


  1. renatxat
    20.09.2024 17:48

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


    1. maratxat Автор
      20.09.2024 17:48

      Интересная тематика, но очень высокий порог входа.

      Потому статья и помечена средним маркером сложности

      Не хватает примеров или хотя бы ссылок на используемые термины.

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

      Да и фатализма у вас не отнять

      Риторика прочитанных статей и книжек изменила мой лексикон, поэтому теперь и я аналогично изрекаюсь)


      1. OlegZH
        20.09.2024 17:48

        Потому статья и помечена средним маркером сложности

        Может быть, позволите один маленький вопросик? А что подразумевается по зависимостью? Зависимость чего, с чем или от чего?


        1. maratxat Автор
          20.09.2024 17:48

          На картинке типичное определение термина


          1. OlegZH
            20.09.2024 17:48

            Да. Спасибо. Значит, речь идёт о простой функциональной зависимости. Другими словами, при реализации класса A нужно знать класс B.

            Только, у Вас на картинке, наверное, какая-то ошибка. Зависимость — это связь, а не класс. Просто, эта связь возникает, в том числе, и указанным образом, когда используется экземпляр. А можно объявить функцию, которая принимает аргментом экземпляр класса B.

            Я не спорю, а рассуждаю.

            В языке программирования C/C++ головная боль с такими зависимостями — это потенциальные утечки памяти: нужно следить за порядком (и корректностью) выполнения конструкторов/ деструкторов.


            1. maratxat Автор
              20.09.2024 17:48

              Это пример зависимости в контексте DI-паттерна. Он встречается чаще всего. Есть зависимости, например, от интерфейса и от абстрактного класса. В отдельных случаях интерфейсной зависимости клиент может становится зависимым от дефолтной реализации интерфейса. Много всякого есть, но концепция одна и та же.

              Слово связь уместна, но не полнозвучна, чтобы понять концепцию.