Как стать автором
Поиск
Написать публикацию
Обновить

Встречаем yx_scope: DI-фреймворк для работы со скоупами в открытом доступе

Уровень сложностиСредний
Время на прочтение21 мин
Количество просмотров5.4K
Всего голосов 15: ↑15 и ↓0+20
Комментарии12

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

ссылка на ваш репозиторий выдает 404

Спасибо, исправил!

В "классических" ioc-контейнерах обычно есть три вида зависимостей - singleton, scoped и transient. Когда я делал контейнер под python (https://github.com/reagento/dishka/) я делал линейную иреархию скоупов и меня часто спрашивают зачем это нужно. Подскажите, а зачем нужно дерево скоупов? Насколько это оправдано?

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

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

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

Почему в таком случае не иметь просто несколько контейнеров?

Глобально причины две:

1. Теряется null-safety. Если делать просто плоский набор контейнеров, тогда мы лишаем себя возможности описывать структуру, когда один контейнер должен существовать только в рамках другого. Это можно будет реализовать, но тогда придётся везде либо писать проверку на null для существования родительского скоупа, либо делать force-unwrap, но тогда null-safety полностью теряется.

2. Теряется возможность автоматически закрывать всё поддерево скоупов. Это нужно будет делать вручную. С деревом скоупов же можно у родительского скоупа сделать drop, и все его дочерние скоупы автоматически закроются вместе с ним.

Я правильно понимаю, что вы не рассматриваете конкурентные инстансы одного скоупа? В серверной разработке мы можем одновременно обрабатывать много запросов и у каждого будет своя "копия" скоупа со своими коннекшенами к базе DAO и прочим (смотря что реально юзает). Возможна ли такая ситуация на мобилке?

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

Я правильно понимаю, что вы не рассматриваете конкурентные инстансы одного скоупа? … Возможна ли такая ситуация на мобилке?

Через yx_scope можно создавать несколько скоупов одного типа, такой пример можно посмотреть в example.

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

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

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

И тут, по хорошему, объекты скоупа должны использоваться только когда скоуп доступен — либо внутри контекста этого скоупа (т.е. внутри зависимостей, принадлежащих этому скоупу), либо в UI, который подписан на изменение состояния скоупа.

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

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

Такой вопрос: почему нет возможности объявить асинхронную зависимость которая бы инициализировалась асинхронно при создании? Я имею ввиду прямо в билдере вернуть Future. Вместо того чтобы отдельно на созданном объекте вызывать init. Просто есть например объекты сторонних библиотек, которые инициализируются именно так. Как пример могу привести метод Hive.openBox. Я вот например не знаю как переделать создание бокса так чтобы он создался, но не инициализировался.

Если я правильно понял вопрос, то такая возможность существует, достаточно типизировать зависимость нужным типом:

late final futureTypeDep 
  = dep<Future<Type>>(() => /* зависимость, возвращающая Future */)

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

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

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

Но вы правильно заметили, что не все классы внешних библиотек можно инстанцировать, но не инициализировать. Для таких случаев самое простое решение — написать собственный класс-обёртку, у которого есть методы init/dispose, в которых он создаёт и сохраняет нужный инстанс. И этот класс обёртку уже можно безболезненно использовать в DI.

Да, каждый раз писать такую обёртку — бойлерплейт, поэтому это одна из идей, которую мы уже рассматриваем как один из следующих шагов в yx_scope — добавить в библиотеку такой универсальный класс-обёртку, который можно будет использовать в initializeQueue.

Да, я об этом. Что ж, буду следить за прогрессом. Пока вот такое написал:

class AsyncInitializerDep<T> implements AsyncLifecycle {
  final Future<T> Function() initializer;
  final Future<void> Function(T)? disposer;
  late final T value;

  AsyncInitializerDep(this.initializer, {
    this.disposer,
  });

  @override
  Future<void> init() async {
    value = await initializer();
  }

  @override
  Future<void> dispose() async {
    await disposer?.call(value);
  }
}

Вроде можно использовать с asyncDep вместо того чтобы плодить бойлерплейт.

Подскажите, а я вот не понял как вообще получить зависимость потом? Типа у вас в примере создается холдер, потом асинхронно вызывается на нем метод create, а без этого никак? Вот условно в одном месте приложения я создал этот холдер, а в другом мне надо получить зависимость. Мне что везде прокидывать этот холдер по цепочке? Это же неудобно. Для виджетов вы обертку сделали, а если это не в виджете? Например в каком-то кубите. С классическим подходом я делал просто GetIt.I<MyClass>() и все. Одним вызовом статичной функции я в любом месте проекта получал зависимость. Как с вашей библиотекой это делать я не понял.

P.S. Выглядит как-то все уж очень запутанно и сложно. GetIt в 100 раз проще)

Пока вот такое написал

Да, похожее решение я и имел в виду.

Типа у вас в примере создается холдер, потом асинхронно вызывается на нем метод create, а без этого никак?

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

Мне что везде прокидывать этот холдер по цепочке? Это же неудобно. Для виджетов вы обертку сделали, а если это не в виджете? Например в каком-то кубите.

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

С классическим подходом я делал просто GetIt.I<MyClass>() и все.

Да, в GetIt можно просто сделать статический вызов GetIt.I<MyClass>(), но такой вызов чреват многими проблемами, как минимум это сложнее тестировать и контролировать, какие зависимости как друг с другом связываются.

В принципе похожего поведения можно добиться и в yx_scope, если создать  top-level ScopeHolder, проинициализировать его в main и обращаться к нему через force-unwrap: scopeHolder.scope!.someDep. Тогда можно будет в любом месте делать так же как и GetIt.I<MyClass>(), но все преимущества yx_scope тогда теряют свою пользу.

Зарегистрируйтесь на Хабре, чтобы оставить комментарий