Pull to refresh

Comments 29

Будьте внимательны.

А давайте. Возьмите вот такой код:

var services = new ServiceCollection();
services.AddTransient<IFoo, Foo>();
services.AddTransient<Lazy<IFoo>>();

using var sp = services.BuildServiceProvider();
var foo = sp.GetRequiredService<Lazy<IFoo>>();
Console.WriteLine(foo.IsValueCreated);

interface IFoo;
class Foo : IFoo;

Что будет выведено в консоль? Почему True? ;)

Это собеседование?

Я думаю, зависит от того как реализовано свойство IsValueCreated и каким образом ServiceProvider инжектит оборачиваемый объект в Lazy. Угадал?

Какой приз мне полагается?

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

Значит ServiceProvider резолвит зависимость из контейнера прежде чем вставить в Lazy :)

Щас статью поправлю :)

Спасибо за тестирование.

А где пример с open generic? Как-то неудобно каждый раз регать сервис и плюс к нему ещё и Lazy-обёртку.

services.AddTransient(typeof(Lazy<>), typeof(LazyService<>));

public sealed class LazyService<T> : Lazy<T>
{
    public LazyService(IServiceProvider provider)
        : base(provider.GetRequiredService<T>)
    {
    }
}

Проблем с разными lifetime нет, сам Lazy transient. Да, для scoped-зависимостей может появиться несколько контейнеров Lazy, но экземпляр зависимости у них будет один в рамках scope.

Так и используется:

public class ProductController : ControllerBase
{
    private readonly Lazy<IDatabaseService> _db;

    public ProductController(Lazy<IDatabaseService> db)
    {
        _db = db;
    }
}

Не нужно отдельно регистрировать точный generic-тип

// этого делать не требуется!
builder.Services.AddScoped(sp => new Lazy<IDatabaseService>(() => sp.GetRequiredService<IDatabaseService>()));

Не готов погружатся в тему критики open generic, но критика присутствует.

Что вижу я в предложенном подходе - самое главное, скрытая логика регистрации.


В моём примере кода, всё проще - для регистрации типа с поддержкой Lazy используется метод расширения, что делает код не только прозрачным, но и самодокументируемым. Не нужна поддержка Lazy - не используете расширение, нужен разный lifetime - добавляете уникальный метод регистрации и используете. Всё прозрачно и понятно и под контролем.

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

Регистрация open generic это не "скрытая логика", а стандартный механизм DI, который уже широко используется в самом Microsoft.Extensions.DependencyInjection.
Примеры: ILogger<T>, IOptions<T>, IOptionsSnapshot<T>, IOptionsMonitor<T> - все они регистрируются как open generic и воспринимаются как нормальный контракт, а не магия.

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

Разница между подходами не в прозрачности, а в точке ответственности.
Точечные регистрации размазывают знание о Lazy по регистрации сервисов,
open generic концентрирует его в одном месте, ровно как это сделано для ILogger<T> или IOptions<T>.

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

Более того, Microsoft в своей официальной документации дают ссылки на другие IoC-контейнеры, прямо сообщая, что если вам нужны поддержка Func<T>, прокси и прочие плюшки, выбирайте. Нет замечаний по поводу "скрытой логики" или какой-то магии.

Дело не во вкусах, а в Хабре и здешней публике (отдельные индивиидумы).
Я бы мог попытатся разобратся в вашей аргументации, начал бы с вопроса, что такое "ортогональный контейнер" или хотя бы "точка ответственности".

Lazy<T> - это инфраструктурный способ разрешения зависимости, а не бизнес-логика. Его глобальная регистрация делает контейнер более предсказуемым и ортогональным...
Разница между подходами не в прозрачности, а в точке ответственности.

Но мне тут минусов понаставят, особо пробдвинутые и задвинутые неведомо в какие науки мыслители и борцы за чистоту Хабра. Так что оставим ненужные споры, я себе уже всё доказал.

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

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

Вы меня не обидели, я правда не понимаю, что вы иммели ввиду в терминах "ортогональный контейнер" и "точка ответственности".

Из за этого у меня нет чёткой картины, вашей аргументации, непонятно на что вы опираетесь, кроме схожих примеров с ILogger<> и ссылки на авторитет документации Microsoft.

Ортогональность - возможность использовать Lazy<T> независимо от регистрации других типов. Ещё один пример ортогональности: IEnumerable<T>, который напрямую поддерживается контейнером, без необходимости регистрировать IEnumerable для каждого специфичного типа.

Точка ответственности - единое место, где описано поведение контейнера. Либо знание о Lazy<T> размазано по регистрациям, либо оно централизовано и не влияет на читаемость бизнес-регистраций.

И при чём тут "авторитет Microsoft"? Озвученные примеры регистрации открытых типов используются не просто широко, а максимально широко. А возможность регистраций открытых generic типов поддерживаются из коробки. Не очень понимаю, почему использование этой фичи вдруг объявлена злом и приводят вас к разочарованию.

Ок, имеем:

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

  2. Ортогональность - возможность использовать Lazy независимо от регистрации других типов.

У меня получилось:

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

Потом я долго думал.... Видимо вы хотели сказать, что:

Lazy - это инфраструктурный способ разрешения зависимости, а не бизнес-логика.

С учётом lifetime тут может быть влияние и на бизнес логику.

Если зарегистрировать в контейнере Lazy<>, то не нужно регистриовать Lazy для каждого типа T и это лучше так как меньше кода и следить за каждой регистрацией Lazy для T не нужно.

С этим я не спорил, это же очевидно, но непонятно, какие преимущества кроме "меньше кода" это даёт. Свои возражения о "скрытой регистрации Lazy для каждого типа T" я уже написал ранее. Мы с вами в этом вопросе никуда не продвинулись.

Если я неправильно понял, поправьте.

Идём далее...

Имеем:

  1. Разница между подходами не в прозрачности, а в точке ответственности.
    Точечные регистрации размазывают знание о Lazy по регистрации сервисов,
    open generic концентрирует его в одном месте, ровно как это сделано для ILogger или IOptions.

  2. Точка ответственности - единое место, где описано поведение контейнера. Либо знание о Lazy размазано по регистрациям, либо оно централизовано и не влияет на читаемость бизнес-регистраций.

У меня получилось:

Разница между подходами не в прозрачности, а в едином месте, где описано поведение контейнера. Либо знание о Lazy размазано по регистрациям, либо оно централизовано и не влияет на читаемость бизнес-регистраций.

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

Точечные регистрации размазывают знание о Lazy по регистрации сервисов, open generic концентрирует его в одном месте, ровно как это сделано для ILogger или IOptions.

Каким образом размазывают? Все регистрации прописаны явно, если регистрация не прописана тут ModuleBootstrapper, значит её нет - предельно простое правило.
Напротив, open generic Lazy<> скрывают регистрацию Lazy для каждого типа T. Это ли не размазывание логики регистрации типов?

Идём далее...

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

Поведение контейнера зависит только от реализации его методов, неважно как он был зарегистрирован. А вот логика работы связки Lazy + T может зависеть от lifetime, я это сразу указал в статье. Отсюда мой посыл - важно обеспечить гибкость против унификации.
Дублирование кода отсутствует в ModuleBootstrapper, там столько же строк кода сколько классов T.

Я как мог контр-аргументировал, но мне думается, что всё упирается во вкусы и предпочтения.

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

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

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

Более того, Microsoft в своей официальной документации дают ссылки на другие IoC-контейнеры, прямо сообщая, что если вам нужны поддержка Func, прокси и прочие плюшки, выбирайте. Нет замечаний по поводу "скрытой логики" или какой-то магии.

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

И при чём тут "авторитет Microsoft"? Озвученные примеры регистрации открытых типов используются не просто широко, а максимально широко.

Действительно, причём тут авторитет, когда ОХ КАК ШИРОКО, АЖ МАКСИМАЛЬНО ШИРОКО это используется. Ссылка на "это очень широко используется" (или "все так делают") - это логическая ошибка argumentum ad populum, популярность не доказывает правильность или эффективность подхода.

А возможность регистраций открытых generic типов поддерживаются из коробки.

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

Не очень понимаю, почему использование этой фичи вдруг объявлена злом и разочаровывают.

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

Резюмирую - мой единственный аргумент, остался для меня не менее значимым, чем до начала дискуссии.

С этим я не спорил, это же очевидно, но непонятно, какие преимущества кроме "меньше кода" это даёт.

Если можно писать меньше кода без каких-либо потерь или жертв, это однозначно - преимущество.

Свои возражения о "скрытой регистрации Lazy для каждого типа T" я уже написал ранее. Мы с вами в этом вопросе никуда не продвинулись.

Возражения строятся на постулате, что регистрация открытого типа это "скрытие". Я так не считаю, поэтому вы не можете продвинуться. Ваше утверждение не подтверждается широкой практикой, значит это строго индивидуальная ваша точка зрения. А против этого не попрёшь.

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

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

Каким образом размазывают? Все регистрации прописаны явно, если регистрация не прописана тут ModuleBootstrapper, значит её нет - предельно простое правило.

Явность регистраций никуда не девается. Если мне нужен IDataBaseService, я только его и регистрирую. Ленивость это не свойство регистрации, а свойство внедрения.

Действительно, причём тут авторитет, когда ОХ КАК ШИРОКО, АЖ МАКСИМАЛЬНО ШИРОКО это используется. Ссылка на "это очень широко используется" (или "все так делают") - это логическая ошибка argumentum ad populum, популярность не доказывает правильность или эффективность подхода.

Когда я говорю "широко используется", это не в смысле "все так делают". Если вы инженер и мыслите как инженер, то должны прекрасно понимать что это значит. Это прямо и недвусмысленно означает, что подобный подход с внедрением открытых generic-типов легко будет понятен разработчикам, так как они его уже плотно используют.

Так что ваша "логическая ошибка", вообще мимо кассы, извините. Блеснуть не удалось :)

Резюмирую - мой единственный аргумент, остался для меня не менее значимым, чем до начала дискуссии.

Ну я тоже резюмирую. Ваши рассуждения строятся вокруг тезиса, который вы определили как аксиому: "Lazy<T> это скрытая хакерская логика регистрации". А я должен исходя из вашей аксиомы построить убедительную аргументацию по принципу "да, но...".

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

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

Из моей практики - если какие-то зависимости нужны "не всегда", то компонент "многовато на себя берёт" и лучше его разбить на более атомарные куски. Тот самый "S" из SOLID. Рано или поздно Lazy цепочки перепутаются, особенно если использовать разное время жизни. Да и читать граф зависимостей, в котором "тут играем, тут не играем" IMHO сложнее.

Ведь бывают не только такие ситуации: "тут играем, тут не играем". Что насчёт кеша? Например, только в случае промаха, нужен ещё один граф объектов для извлечения значения и помещения его в кеш. Никакой SOLID не нарушается, а наоборот улучшает эффективность функции кеширования.

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

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

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

Но как времянка до рефакторинга может помочь снизить боль

Да, в моем случае так и произошло, еще очень помогало разбитие класса на partial-файлы, в каждом из который сидели свои проперти с зависимостями. Но когда их стало 54 (!), тогда я наконец-то продал продакту необходимость рефакторинга :D

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

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

О чем идет речь понятно, я пытаюсь сказать, что статья лечит симптомы и лечение само по себе не вызывает вопросов. Главное соблюдать дозировку - со временем жизни компонентов и контейнеров (ссылки на которые захватывает Lazy<>), по мере усложнения системы, могут начаться сложности.

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

  • Замедлению startup времени — создание объекта сервиса, даже если он не будет использоваться - не будет вызван по условиям в коде;

  • Увеличению потребления памяти — все без исключения объекты создаются заранее.

Если есть такие проблемы, то скорее всего в проекте не верно используется DI.Конструкторы в подходе с DI должны быть использованы только для DI, т.е.в конструкторах должна быть проверка аргументов и инициалищация полей значениями из этих аргументов. Использование Lazy "заметание проблем под коврик" и ни чего хорошего не обещает. Лучше исправить сразу и перенести всю тяжёлую логику из конструкторов в методы. Вот я в своей статье писал про это:

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

Ваши рассуждения к описаной проблеме имеют слабое отношение.
Lazy не даёт строится дереву зависимостей на всю глубину, будет построен только 1-й уровень. Насколько у вас "легковесные" конструкторы - тут никакой роли не играет.

В начале статьи вы привели список проблем, которые решаете. Я же предложил способ исправить корень проблем, а не бороться с последствиями.

Чтобы управлять созданием зависимости для получения экземпляра по требованию или нескольких экземпляров лучше использовать фабрики или их простую форму - Func<T>. Использование Lazy<T> у вас это костыль, которым вы добавляете технический долг. Вы получите проблемы с управлением временем жизни и утилизацией.

В примерах я использую Lazy, WeakReference и т.п. в зависимостях, но не рекомендовал бы это делать в боевом коде. Mark Seemann в своей книге по DI, вроде бы как раз приводил пример с Lazy, и там есть аргументы почему это не очень хорошо.

В начале статьи вы привели список проблем, которые решаете. Я же предложил способ исправить корень проблем, а не бороться с последствиями.

Сами придумали, что одна из многих проблем теперь стала корнем всех проблем? Или кто-то подсказал? Mark Seemann так считает?

Чтобы управлять созданием зависимости для получения экземпляра по требованию или нескольких экземпляров лучше использовать фабрики или их простую форму - Func<T>.

DI контейнер это не фабрика? А что? Про Func<T> дал пример почему их неудобно использовать.

Использование Lazy<T> у вас это костыль, которым вы добавляете технический долг. Вы получите проблемы с управлением временем жизни и утилизацией.

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

Выше "специалист" тоже звучные обороты использовал - "точка ответственности", "ортогоноальность контейнера", видимо это кого то впечатляет и лишает способности рассуждать. Я не из таких, не впечатлён, поэтому прошу вас писать проще и с обоснованием тезисов - без ссылок на личный опыт и предпочтения и без ссылок на авторитеты, даже заграничные.

Сами придумали, что одна из многих проблем теперь стала корнем всех проблем? Или кто-то подсказал? Mark Seemann так считает?

Вот начало вашей статьи:

Dependency Injection (DI) — это популярный механизм внедрения зависимостей, который идеально соответствует принципам SOLID (Dependency Inversion Principle). В .NET использование DI (Microsoft.Extensions.DependencyInjection) стало стандартом де-факто.

Однако у DI есть важный недостаток: при создании корневого объекта (например, контроллера) контейнер резолвит всё дерево зависимостей, включая глубоко вложенные. В крупных системах с микросервисами или тяжелыми сервисами (БД, внешние API, ML-модели) это приводит к:

  • Замедлению startup времени — создание объекта сервиса, даже если он не будет использоваться - не будет вызван по условиям в коде;

  • Увеличению потребления памяти — все без исключения объекты создаются заранее.

Решение: использование DI с Lazy<T> — ленивая инициализация зависимостей

Класс Lazy<T> создает объект только при первом обращении к свойству .Value

Вы описали проблемы, а затем предложили решение. Позвольте мне повторить: если конструкторы спроектированы правильно, граф объектов строится очень быстро. Примерно 0,1 наносекунды на объект или 1 микросекунда на 10к объектов. И даже если один объект создается в 1000 раз медленнее, на создание всего графа уйдут миллисекунды. Граф из 10к объектов это очень-очень большое приложение, мы туда не включаем динамически создаваемые объекты. Очевидно, что наилучшее решение понять какие конструкторы каких классов работают медленно и точечно исправить эту проблему.

DI контейнер это не фабрика? А что?

DI контейнер это не фабрика и не локатор сервисов, погуглите "DI единственный корень композиции". Если вы думаете, что фабрика - печаль.

Про Func<T> дал пример почему их неудобно использовать.

Вот что вы написали:

Lazy vs Func

Есть большая разница, если вместо Lazy<T> использовать Func<T>.

При использовании Lazy<T> создание обёрнутого объекта произойдёт один раз при первом вызове .Value, повторные вызовы .Value всегда будут возвращать готовый объект из Lazy<T>.

Все что вы делаете тут в DI называется "управлением времени жизни объекта". Одна из основных задач DI контейнера как раз и заключается в "управлении времени жизни объекта". Потребитель не должен об этом знать в общем случае. Вы же поверх "управления временем жизни объекта" DI контейнера - добавляете своё управление. Если у вас объект сроится медленно, читайте то, что я написал выше.

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

Это самые обычные и простые термины, совсем не хотел вас впечатлить)) Погуглите или спросите у ИИ. Уверен, что 99.9% людей, которые прочитали вашу статью, знают их.

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

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

  • приводите в пример частный случай, когда легковесные конструкторы мало снижают время построения полного дерева зависимостей, как это отменяет что строится полное дерево зависимстей, никак. Вместо этого вы идёте путём отрицания самой постановки задачи - "Как не промочить ноги? Не ходите по лужам". Это гениальный совет.

  • ServiceProvider выступает как фабрика, инкапсулируя логику создания экземпляров сервисов с учетом их lifetime (Transient, Scoped, Singleton) и зависимостей. Это соответствует определению Factory Method из GoF: "Определите интерфейс для создания объекта, но позвольте подклассам решать, какой класс инстанцировать". Больше я это обсуждать не буду.

  • Я не просил объяснять термины, я просил объяснить как вы эти термины используете в контексте использования Lazy<T>. Как они связаны, где техдолг и т.д.? Не уходите от ответа, не подменяйте тему обсуждения.

  • Ну и последнее - как же не перейти на личности, когда не смог ответить ни на один вопрос? :)

В начале не хватает ещё одного пункта про циклические зависимости. Lazy эту проблему так же решает.

Циклические зависимости это проблема дизайна. Для быстрого решения достаточно фабрик.

Sign up to leave a comment.

Articles