Dependency Injection в ASP.NET Core. Жизненный цикл зависимостей

Понимание жизненного цикла зависимостей в ASP.NET Core (и не только) — довольно сложная, но, одновременно и важная тема. До сих пор мы особенно не вдавались в этот момент, просто тренируясь регистрировать сервисы, получать их различными способами и т.д. Сегодня попробуем разобраться с жизненным циклом зависимости в ASP.NET на примере.

Виды зависимостей ASP.NET Core с точки зрения их жизненного цикла

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

  • Transient новый объект сервиса создается при каждом обращении к нему.  В течение одного запроса может быть несколько обращений к сервису, соответственно при каждом обращении будет создаваться новый объект
  • Scoped для каждой области действия создается свой объект сервиса. Например, применительно к ASP.NET Core областью действия может выступать часть middleware. И если в течение одного запроса есть несколько обращений к одному сервису в его области, то при всех этих обращениях будет использоваться один и тот же объект сервиса.
  • Singleton -здесь объект сервиса создается при первом обращении к нему и все последующие обращения к сервису используют один и тот же ранее созданный объект.

Чтобы зарегистрировать ту или иную зависимость в контейнере DI и указать её жизненный цикли используются методы, имеющие следующий шаблон Add[lifetime] — где lifetime — это один из трех вариантов, указанных выше.  Например, мы во всех ранее используемых примерах использовали метод:

builder.Services.AddTransient<T>();

Все методы Add[lifiteme] имеют ряд перегруженных версий, но, по сути, все они выполняют одну и ту же задачу — зарегистрировать зависимость с заданным жизненным циклом. Разберемся с тем, в чем отличие зависимостей с различными жизненными циклами. Чтобы продемонстрировать работу сервисов — напишем такой сервис:

public interface ILifetimeService
{
    public string GetTime();
}

public class LifeTime : ILifetimeService
{
    private readonly Stopwatch _stopwatch = Stopwatch.StartNew();
    private int cnt = 0;
    
    public LifeTime() 
    {
        Console.WriteLine("Создан новый объект LifeTime");
    }   
    
    public string GetTime() 
    {
        cnt++;
        return $"{cnt} Время работы {_stopwatch.Elapsed}";
    }
}

LifeTime возвращает, по-сути, время существования объекта, количество вызовов GetTime, а при создании объекта — выводит в консоль строку о том, что создан новый объект. Теперь посмотрим, как будет вести себя этот сервис с различными жизненными циклами.

AddTransient

Метод AddTransient регистрирует сервис с жизненным циклом Transient, то есть новый объект сервиса создается при каждом обращении к нему. Посмотрим, что это означает для нас:

public class Program
{
    public static void Main(string[] args)
    {
        var builder = WebApplication.CreateBuilder(args);

        builder.Services.AddTransient<ILifetimeService, LifeTime>();

        var app = builder.Build();

        app.Run(async context =>
        {
            context.Response.ContentType = "text/plain; charset=utf-8";
            if (context.Request.Path != "/favicon.ico")
            {
                var services = app.Services;
                var svc = services.GetService<ILifetimeService>();//первое обращение к сервису
                var elapsed_1 = svc?.GetTime();
                var svc2 = services.GetService<ILifetimeService>();//второе обращение к сервису
                var elapsed_2 = svc2?.GetTime();
                await context.Response.WriteAsync($"Первое обращение {elapsed_1} \r\n Второе обращение {elapsed_2}");
            }
        });

        app.Run();
    }
}

Здесь мы дважды обращаемся к сервису и, согласно описанию выше — сервис должен быть создан дважды. Результат работы приложения:

Как видите, объект был создан дважды, из-за чего и счётчик вызовов GetTime() всегда имел значение 1.

AddSingleton

Метод AddSingleton регистрирует сервис с жизненным циклом Singleton, то есть объект сервиса создается при первом обращении к нему и не уничтожается. Перепишем всего одну строку кода, чтобы продемонстрировать действие сервиса с таким жизненным циклом:

//builder.Services.AddTransient<ILifetimeService, LifeTime>();
builder.Services.AddSingleton<ILifetimeService, LifeTime>();

Теперь запустим приложение и посмотрим на результат. Для примера я несколько раз обновил страницу приложения, чтобы показать, что счётчик количества вызовов метода GetTime() постоянно изменяется:

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

AddScoped

Это, по-видимому, наиболее сложный в понимании вид сервиса. В Сети можно встретить и такие описания, как: «сервис создаётся единожды для каждого запроса«, что, на мой скромный взгляд, является ошибочным пониманием scoped-сервисов. Правильнее сказать, что такой сервис создается единожды для своей области действия. Во-первых, потому, что DI — это не только про ASP.NET и не всегда мы можем оперировать такими понятиями, как «запрос» или «контекст запроса». А вот вторых, один запрос может обслуживаться десятком различных компонентов middleware, а область действия сервиса может ограничиваться какой-то часть middleware.

Чтобы продемонстрировать всё вышесказанное, напишем такой пример:

   public class Program
    {
        public static void Main(string[] args)
        {
            var builder = WebApplication.CreateBuilder(args);

            builder.Services.AddScoped<ILifetimeService, LifeTime>();

            var app = builder.Build();

            app.Run(async context =>
            {
                context.Response.ContentType = "text/plain; charset=utf-8";


                if (context.Request.Path != "/favicon.ico")
                {
                    using (var serviceScope = app.Services.CreateScope())
                    {
                        var services = serviceScope.ServiceProvider;

                        var svc = services.GetService<ILifetimeService>();//первое обращение к сервису
                        var elapsed_1 = svc?.GetTime();
                        var svc2 = services.GetService<ILifetimeService>();//второе обращение к сервису
                        var elapsed_2 = svc2?.GetTime();
                        await context.Response.WriteAsync($"Первое обращение {elapsed_1} \r\n Второе обращение {elapsed_2} \r\n");
                    }

                    using (var serviceScope = app.Services.CreateScope())
                    {
                        var services = serviceScope.ServiceProvider;

                        var svc = services.GetService<ILifetimeService>();//третье обращение к сервису
                        var elapsed_1 = svc?.GetTime();
                        var svc2 = services.GetService<ILifetimeService>();//четвертое обращение к сервису
                        var elapsed_2 = svc2?.GetTime();
                        await context.Response.WriteAsync($"Третье обращение {elapsed_1} \r\n Четвертое обращение {elapsed_2}");
                    }
                }


            });

            app.Run();
        }
    }
}

Здесь за один запрос от пользователя сервис запрашивается четыре раза. Следуя ошибочной логике о том, что scope-сервис живёт от запроса до запроса, мы должны были би увидеть в консоли один вызов конструктора, а счётчик вызовов метода GetTime() в результате должен быть равен 4 сразу после загрузки приложения. Посмотрим на результат:

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

using (var serviceScope = app.Services.CreateScope())
{
    var services = serviceScope.ServiceProvider;
}

Здесь мы создаем область действия нашего сервиса, вызывая метод IServiceProvider.CreateScope() и уже в этой области обращаемся к сервису и работаем с ним:

using (var serviceScope = app.Services.CreateScope())
{
    var services = serviceScope.ServiceProvider;

    var svc = services.GetService<ILifetimeService>();//первое обращение к сервису
    var elapsed_1 = svc?.GetTime();
    var svc2 = services.GetService<ILifetimeService>();//второе обращение к сервису
    var elapsed_2 = svc2?.GetTime();
    await context.Response.WriteAsync($"Первое обращение {elapsed_1} \r\n Второе обращение {elapsed_2} \r\n");
}

Таким образом, если теперь снова посмотреть на пример, то можно увидеть, что в рамках всего одного компонента middleware мы создали две области — отсюда и вывод в консоли (объект создался дважды). Если переписать пример так:

app.Run(async context =>
 {
     context.Response.ContentType = "text/plain; charset=utf-8";
     if (context.Request.Path != "/favicon.ico")
     {
         using (var serviceScope = app.Services.CreateScope())
         {
             var services = serviceScope.ServiceProvider;
             var svc = services.GetService<ILifetimeService>();//первое обращение к сервису
             var elapsed_1 = svc?.GetTime();
             var svc2 = services.GetService<ILifetimeService>();//второе обращение к сервису
             var elapsed_2 = svc2?.GetTime();
             await context.Response.WriteAsync($"Первое обращение {elapsed_1} \r\n Второе обращение {elapsed_2} \r\n");
  
             var svc3 = services.GetService<ILifetimeService>();//третье обращение к сервису
             var elapsed_3 = svc3?.GetTime();
             var svc4 = services.GetService<ILifetimeService>();//четвертое обращение к сервису
             var elapsed_4 = svc4?.GetTime();
             await context.Response.WriteAsync($"Третье обращение {elapsed_3} \r\n Четвертое обращение {elapsed_4}");
         }
     }
 });

То есть оставить одну область действия, то объект будет создаваться один раз. При этом, после обновления страницы — фактически отправки нового запроса, объект сервиса будет создаваться заново так как будут заново создаваться области действия (scope). Таким образом, можно выделить три момента работы со scoped-сервисами:

  1. для такого сервиса обязательно необходимо указывать область его действия
  2. объект такого сервиса «живет» только в своей области действия. Вышли из области действия — объект «убился».
  3. запрос — это НЕ область действия scoped-сервиса. Область действия сервиса может охватывать часть одного компонента middleware (см. пример выше), тогда как каждый запрос обрабатывается сразу всем конвейером (набором) компонентов middleware и в каждом компоненте могут быть свои вызовы scoped-сервиса, со своими областями действия и (см. пункт 1) объект сервиса будет создаваться заново для каждой области.

Итого

Сегодня мы разобрались с жизненным циклом сервисов в ASP.NET Core. Выделяются три вида сервисов — transient, scoped и singleton. Чтобы указать время жизни сервиса, при регистрации его в контейнере DI мы должны использовать методы AddTransient, AddScoped или AddSingleton. Для scoped-сервисов обязательно наличие области его действия, которая создается вызовом метода IServiceProvider.CreateScope().

Подписаться
Уведомить о
guest
0 Комментарий
Старые
Новые Популярные
Межтекстовые Отзывы
Посмотреть все комментарии