Oto klasa, której nie przetestujesz jednostkowo bez działającej bazy danych i serwera SMTP:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
public class OrderService
{
    private readonly SqlOrderRepository _repository = new SqlOrderRepository("Server=prod-db;Database=Orders;...");
    private readonly SmtpEmailSender _emailSender = new SmtpEmailSender("smtp.company.com");

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed");
    }
}

OrderService tworzy własne instancje SqlOrderRepository i SmtpEmailSender przez new, razem z connection stringiem. Każdy test dotykający PlaceOrder potrzebuje prawdziwej bazy Orders i działającego serwera pocztowego, inaczej padnie, zanim dojdzie do asercji. Logika, którą chcesz przetestować, to dwie linijki: zapisz zamówienie, wyślij potwierdzenie. Jest zespawana z dwiema konkretnymi klasami, których nie da się podmienić.

Co w praktyce oznacza dependency injection

Dependency injection polega na przekazaniu klasie obiektów, których potrzebuje, zamiast pozwalać jej samodzielnie je tworzyć. Klasa deklaruje, od czego zależy. Kod poza klasą decyduje, jaką konkretną implementację dostanie. Kontenery i cykle życia to narzędzia zbudowane wokół tego jednego pomysłu.

Przepisz OrderService tak, żeby przyjmował zależności przez konstruktor, jako interfejsy, a nie konkretne klasy:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
public class OrderService
{
    private readonly IOrderRepository _repository;
    private readonly IEmailSender _emailSender;

    public OrderService(IOrderRepository repository, IEmailSender emailSender)
    {
        _repository = repository;
        _emailSender = emailSender;
    }

    public void PlaceOrder(Order order)
    {
        _repository.Save(order);
        _emailSender.Send(order.CustomerEmail, "Order confirmed");
    }
}

OrderService nie wie już, czy repozytorium zapisuje do SQL Server, czy do listy w pamięci, ani czy e-mail leci przez SMTP, czy donikąd. Zna tylko dwa interfejsy i wywołuje ich metody. To, co zostanie podpięte, decyduje ten, kto tworzy OrderService: test przekazuje fake’i, aplikacja webowa przekazuje prawdziwe implementacje.

To jest constructor injection i tej formy będziesz używać niemal zawsze. Property injection (ustawianie publicznej właściwości po skonstruowaniu obiektu) i method injection (przekazanie zależności tylko do tej jednej metody, która jej potrzebuje) istnieją dla zależności opcjonalnych i wartości używanych jednorazowo. Wbudowany kontener w ASP.NET Core w ogóle nie obsługuje property injection. Zasada jest krótka: jeśli klasa nie może wykonać swojej pracy bez czegoś, to coś trafia do konstruktora.

Jak dependency injection ułatwia testowanie klasy

Skoro zależności są przekazywane jako parametry interfejsowe, test może wstrzyknąć do OrderService cokolwiek zechce:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class FakeOrderRepository : IOrderRepository
{
    public List<Order> SavedOrders { get; } = new();
    public void Save(Order order) => SavedOrders.Add(order);
}

public class FakeEmailSender : IEmailSender
{
    public List<string> SentTo { get; } = new();
    public void Send(string to, string subject) => SentTo.Add(to);
}

[Fact]
public void PlaceOrder_SavesOrderAndSendsConfirmation()
{
    var repository = new FakeOrderRepository();
    var emailSender = new FakeEmailSender();
    var service = new OrderService(repository, emailSender);

    service.PlaceOrder(new Order { CustomerEmail = "[email protected]" });

    Assert.Single(repository.SavedOrders);
    Assert.Equal("[email protected]", emailSender.SentTo.Single());
}

Test wykonuje się w milisekundach i nie dotyka żadnej bazy danych ani sieci. To skrajny, jednostkowy koniec spektrum opisanego w Testy Jednostkowe vs Integracyjne vs End-to-End: jedna klasa, podmienione kolaboratory, sprawdzone jedno zachowanie. Te ręcznie napisane klasy to fake’i, małe działające implementacje z własnym stanem. Biblioteka do mockowania, jak Moq czy NSubstitute, generuje za ciebie ten sam rodzaj zastępnika, więc nie musisz pisać klasy dla każdego interfejsu.

Co robi kontener DI i dlaczego wcale go nie potrzebujesz

Nic z powyższego nie wymagało frameworka. Prawdziwe zależności możesz spiąć ręcznie w punkcie wejścia aplikacji:

1
2
3
var repository = new SqlOrderRepository(connectionString);
var emailSender = new SmtpEmailSender(smtpHost);
var orderService = new OrderService(repository, emailSender);

Nazywa się to „pure DI” (albo „poor man’s DI”) i dla małej aplikacji to w zupełności wystarczy. Wzorzec polega na przekazywaniu zależności z zewnątrz. Kontener automatyzuje to spinanie i zaczyna się zwracać, gdy masz dziesiątki klas z nakładającymi się zależnościami, a zmiana jednego konstruktora oznacza edycję kodu spinającego w pięciu miejscach.

ASP.NET Core ma wbudowany kontener. Każdy interfejs rejestrujesz względem implementacji raz, przy starcie, a framework rozwiązuje cały graf zależności za każdym razem, gdy buduje kontroler, handler minimal API albo dowolny inny zarejestrowany serwis:

1
2
3
4
5
6
7
8
var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IOrderRepository>(sp =>
    new SqlOrderRepository(builder.Configuration.GetConnectionString("Orders")));
builder.Services.AddTransient<IEmailSender>(sp =>
    new SmtpEmailSender(builder.Configuration["Smtp:Host"]));

var app = builder.Build();

SqlOrderRepository i SmtpEmailSender przyjmują connection string i nazwę hosta, a nie bezparametrowy konstruktor, więc każda rejestracja jest fabryką: lambdą, która dostaje kontener (sp) i zwraca instancję. Gdy żadna implementacja nie potrzebuje takiej wartości, krótsza jest forma z dwoma argumentami typu: AddScoped<IOrderRepository, SqlOrderRepository>().

Zarejestruj tak samo sam OrderService (builder.Services.AddScoped<OrderService>()) i możesz poprosić o niego w konstruktorze kontrolera. Kontener zbuduje go, rozwiąże IOrderRepository i IEmailSender na podstawie tych rejestracji, a ty nigdy nie wywołasz new.

Cykle życia serwisów w ASP.NET Core: singleton, scoped, transient

AddScoped, AddTransient i AddSingleton rejestrują w ten sam sposób; nazwa metody to cykl życia, a wybór złego cyklu powoduje większość błędów DI w ASP.NET Core.

Cykl życiaNowa instancja tworzonaTypowe zastosowanie
TransientZa każdym razem, gdy jest potrzebnaBezstanowe, tanie serwisy (formatter, walidator)
ScopedRaz na żądanie HTTPWszystko związane z bieżącym żądaniem: DbContext, unit of work
SingletonRaz, na cały czas życia aplikacjiWspółdzielony, bezpieczny wątkowo stan: konfiguracja, pula połączeń, cache

Powyższa rejestracja AddScoped daje każdemu żądaniu własny SqlOrderRepository. Jeśli repozytorium opakowuje DbContext, dwa równoległe żądania nigdy nie dzielą niedokończonej transakcji. Rejestracja AddTransient tworzy nowy SmtpEmailSender przy każdym wstrzyknięciu. To nie problem, bo sender nie trzyma żadnego stanu związanego z żądaniem.

Captive dependencies: serwis scoped uwięziony w singletonie

To jest zasada cyklu życia, która potrafi ugryźć. Serwis może bezpiecznie zależeć tylko od czegoś, co żyje co najmniej tak długo jak on sam. Singleton może przyjmować inne singletony. Serwis scoped może przyjmować scoped albo singleton. Transient może przyjąć wszystko. Problem pojawia się, gdy singleton przyjmuje w konstruktorze zależność scoped:

1
2
3
4
5
6
7
8
9
public class ReportCache // zarejestrowany jako singleton
{
    private readonly AppDbContext _db; // zarejestrowany jako scoped

    public ReportCache(AppDbContext db)
    {
        _db = db;
    }
}

Kontener buduje ReportCache raz, a otrzymany AppDbContext zostaje w tym polu na cały czas życia aplikacji. To jest captive dependency. Każde kolejne żądanie dzieli jeden DbContext, klasę, która nie jest ani bezpieczna wątkowo, ani pomyślana do długiego życia. Objawia się to nieaktualnymi odczytami albo wyjątkami InvalidOperationException mówiącymi, że druga operacja na tym kontekście zaczęła się, zanim poprzednia się zakończyła.

ASP.NET Core łapie to już w trakcie developmentu. Przy włączonej walidacji zakresów, co jest domyślne w środowisku Development, rozwiązanie singletona potrzebującego serwisu scoped rzuca InvalidOperationException przy starcie, zamiast psuć dane na produkcji. Jeśli singleton naprawdę od czasu do czasu potrzebuje serwisu scoped, wstrzyknij IServiceScopeFactory, utwórz zakres tam, gdzie go potrzebujesz, i rozwiąż serwis z tego zakresu, zamiast trzymać go w polu.

Antywzorce dependency injection: service locator i przeładowane konstruktory

Service locator w przebraniu. Wstrzyknięcie samego kontenera i wyciąganie z niego zależności wewnątrz metody wygląda jak DI, ale wyrzuca to, co DI daje:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
public class OrderService
{
    private readonly IServiceProvider _provider;

    public OrderService(IServiceProvider provider)
    {
        _provider = provider;
    }

    public void PlaceOrder(Order order)
    {
        var repository = _provider.GetService<IOrderRepository>();
        repository.Save(order);
    }
}

Konstruktor nie mówi już, czego potrzebuje OrderService. Żeby się tego dowiedzieć, trzeba przeczytać każdą metodę, a brakująca rejestracja wychodzi na jaw jako null w środku PlaceOrder, zamiast jako błąd przy budowaniu klasy. Wstrzykuj konkretne interfejsy, których używasz, a nie obiekt, który ci je rozdaje.

Konstruktor z dwunastoma parametrami. Klasa, która potrzebuje IOrderRepository, IEmailSender, IInventoryService, IPaymentGateway, IAuditLogger i siedmiu kolejnych, robi za dużo rzeczy naraz. DI tego nie spowodowało, tylko to uwidoczniło. To ten sam objaw, który opisuje Czym Jest Spaghetti Code i Jak Go Zrefaktoryzować. Podziel klasę według odpowiedzialności, a konstruktor sam się skurczy. Upchnięcie dwunastu zależności w jednym obiekcie „services” ukrywa problem, zamiast go rozwiązać.

Oba te antywzorce łatwo prześlizgują się przez code review. Przestają się prześlizgiwać, gdy ktoś po raz pierwszy spróbuje napisać dla tej klasy test jednostkowy.

Od czego zacząć z dependency injection

Wybierz w swoim kodzie jedną klasę, która wywołuje new na kliencie bazy danych, kliencie HTTP albo senderze poczty. Zmień jej konstruktor tak, żeby przyjmował interfejs, podłącz prawdziwą implementację ręcznie w Program.cs i napisz jeden test, który przekazuje fake’a. W aplikacji ASP.NET Core pomiń ręczne spinanie i zarejestruj klasę w builder.Services, skoro kontener już tam jest. Dla wszystkiego, co trzyma stan, wybieraj cykl życia świadomie, i zostaw włączoną walidację zakresów, żeby framework wyłapał captive dependency, zanim zrobią to twoi użytkownicy.

Powiązane artykuły