Dependency Injection

Этот пост является третьей частью серии из трех частей, посвященных внедрению зависимостей в .NET 6. Возможно, вы захотите сначала прочитать часть 1 и часть 2.

Чтобы завершить серию статей о внедрении зависимостей (Dependency Injection) в .NET 6, давайте обсудим, как контейнер создает отдельные зависимости. Способ, благодаря которому это происходит, называется временем жизни службы зависимости.

В .NET 6 реализовано три срока службы службы:

  • Переходный (Transied)
  • Область применения (Scoped)
  • Синглтон (Singleton)

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

Transied — каждый раз новый экземпляр

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

Мы задаем зависимости временное время жизни службы, используя метод AddTransient в файле Program.cs:

builder.Services.AddTransient<IMovieRepository, MovieRepository>();

Первый элемент (IMovieRepository) — это абстракция, а второй элемент (MovieRepository, без буквы I) — реализация, используемая для этой абстракции. Таким образом, мы теоретически могли бы иметь две разные реализации для любой данной абстракции и использовать AddTransient для указания реализации, которую мы хотим внедрить контейнером.

Теперь представьте, что у нас есть два класса моделей страниц, MoviePageModel и MovieDetailsPageModel, каждый из которых использует IMovieRepository в качестве зависимости.

using Microsoft.AspNetCore.Mvc.RazorPages;

namespace DependencyInjectionNET6Demo.Pages;

public class MoviesPageModel : PageModel
{
    private readonly IMovieRepository _movieRepo;
    public List<Movie> Movies { get; set; } = new List<Movie>();

    public MoviesPageModel(IMovieRepository movieRepo)
    {
        _movieRepo = movieRepo;
    }

    public void OnGet()
    {
        //Implementation
    }
}
public class MovieDetailsModel : PageModel
{
    private readonly IMovieRepository _movieRepo;
    
    public MovieDetailsModel(IMovieRepository movieRepo)
    {
        _movieRepo = movieRepo;
    }

    public void OnGet(int id)
    {
        Movie = _movieRepo.GetByID(id);
    }
}

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

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

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

Scoped — на каждый запрос свой экземпляр.

Зависимости, объявленные как ограниченные (Scoped), создаются один раз для каждого запроса приложения. «Запрос приложения» различается в разных типах приложений; в веб-приложениях ASP.NET «запрос приложения» — это веб-запрос HTTP.

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

Мы объявляем зависимость как ограниченную с помощью метода AddScoped:

builder.Services.AddScoped<ICustomLogger, CustomLogger>();

Чтобы лучше понять это время жизни, давайте воспользуемся абстракцией ICustomLogger с реализацией CustomLogger:

namespace DependencyInjectionNET6Demo.Services.Interfaces;

public interface ICustomLogger
{
    void Log(Exception ex);
    void Log(string info);
}
using DependencyInjectionNET6Demo.Services.Interfaces;

namespace DependencyInjectionNET6Demo.Services;

public class CustomLogger : ICustomLogger
{
    public void Log(Exception ex)
    {
        //Implementation
    }

    public void Log(string info)
    {
        //Implementation
    }
}

Наш MovieRepository запрашивает ICustomLogger в своем конструкторе:

namespace DependencyInjectionNET6Demo.Repositories;

public class MovieRepository : IMovieRepository
{
    private readonly IActorRepository _actorRepo;
    private readonly ICustomLogger _logger;

    public MovieRepository(IActorRepository actorRepo, ICustomLogger logger)
    {
        _actorRepo = actorRepo;
        _logger = logger;
    }
    
    //...Rest of implementation
}

Теперь давайте представим, что у нас есть новая страница Razor с именем MovieDetails.cshtml, которая использует класс MovieDetailsModel в качестве файла кода программной части:

using Microsoft.AspNetCore.Mvc.RazorPages;

namespace DependencyInjectionNET6Demo.Pages;

public class MovieDetailsModel : PageModel
{
    private readonly IMovieRepository _movieRepo;
    private readonly ICustomLogger _logger;
    public Movie Movie = new();

    public MovieDetailsModel(IMovieRepository movieRepo, 
                             ICustomLogger logger)
    {
        _movieRepo = movieRepo;
        _logger = logger;
    }

    public void OnGet(int id)
    {
        Movie = _movieRepo.GetByID(id);
        _logger.Log("GET action fired!");
    }

    public void OnPost()
    {
        _logger.Log("POST action fired!");
    }
}

Другими словами: MovieDetailsModel зависит от ICustomLogger И IMovieRepository, а MovieRepository также зависит от ICustomLogger. При внедрении зависимостей и MovieDetailsModel, и MovieRepository получат один и тот же экземпляр CustomLogger, поскольку он был добавлен в контейнер, как зависимость с ограниченной областью действия, и эти объекты внедряются во время одного и того же HTTP-запроса.

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

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

Другим примером того, что мы могли бы захотеть сделать зависимостью с ограниченной областью действия, может быть информация о самом HTTP-запросе, например файлы cookie. Эта информация должна существовать на протяжении всего запроса, но также должна использоваться для каждого последующего запроса.

Singleton — один экземпляр почти всегда.

Синглтон — это зависимость, которая создается контейнером один раз при запуске приложения, а затем внедряется в каждый зависимый класс, которому нужен его экземпляр. Этот экземпляр будет существовать до тех пор, пока приложение не будет закрыто или перезапущено (Microsoft прямо говорит, что мы не должны реализовывать код для управления временем жизни одноэлементной службы и должны позволить контейнеру обрабатывать это за нас).

В реальном мире одним из примеров Singleton зависимости может быть служба кеша, которая поддерживает кеш памяти и методы доступа к этому кешу.

public interface ICacheService
{
    void Add(string key, object value);
    T Get<T>(string key);
}
using Microsoft.Extensions.Caching.Memory;

namespace DependencyInjectionNET6Demo.Services;

public class CacheService : ICacheService
{
    private readonly IMemoryCache _cache;

    public CacheService(IMemoryCache cache)
    {
        _cache = cache;
    }

    public void Add(string key, object value)
    {
        _cache.Set(key, value);
    }

    public T Get<T>(string key)
    {
        return _cache.Get<T>(key);
    }
}

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

Зависимости Singleton могут управлять своим внутренним состоянием; поскольку зависимость создается только один раз, внутреннее состояние экземпляра может изменяться и поддерживаться. Это то, что отличает их от временных или ограниченных зависимостей.