Dependency Injection w konsolowej apce .NET 6

Jak wpiąć Dependency Injection w aplikacji konsolowej .NET 6 — ServiceCollection, BuildServiceProvider i alternatywa z Host.CreateDefaultBuilder.

JAJarosław Armatys 7 min czytania
Wersja wideo tego wpisu — obejrzyj na YouTube

To druga lekcja serii, w której budujemy w .NET 6 aplikację konsolową do importu ogłoszeń firm z zewnętrznego API do WordPressa (a docelowo do innego CMS-a typu marketplace). W pierwszym odcinku podpięliśmy Entity Framework i wyciągaliśmy NIP i nazwę firmy "na piechotę" — przekazując DbContext przez parametr konstruktora z Program.cs. Działało, ale każdy kolejny serwis dostawałby kontekst przez parametr i szybko zrobiłby się z tego spaghetti. Dziś robimy refactoring pod Dependency Injection — tak, żeby kontener sam podawał zależności tam, gdzie ich potrzebujemy.

Pokażę dwie drogi: ręczny ServiceCollection + BuildServiceProvider (minimalna konfiguracja, jak robiliśmy to wcześniej, tylko lepiej) oraz drogę z Host.CreateDefaultBuilder, czyli ten sam mechanizm, którego używa ASP.NET Core pod spodem. Obie działają, obie są "poprawne" — wybierasz tę, która pasuje do skali projektu.

Po co DI w konsolówce

W ASP.NET Core DI jest domyślnie — framework sam buduje IServiceProvider, sam wstrzykuje zależności do kontrolerów i masz spokój. W aplikacji konsolowej tego nie dostajesz z automatu. Możesz więc iść po najmniejszej linii oporu i tworzyć obiekty ręcznie (new ImporterDbContext(...), potem new App(context), potem app.Run()), albo zrobić to porządnie raz i mieć z głowy do końca projektu.

Problem z "ręcznym" podejściem widać już w lekcji 1. Jak zobaczysz w Program.cs, po dodaniu DbContext przekazywaliśmy go przez konstruktor App. Jak dodamy kolejną klasę — np. CompanyImporter, WordPressClient, Logger — każda z nich chce coś od App albo od kontekstu. I nagle w Program.cs jest dziesięć linijek ręcznego sklejania obiektów, a każda zmiana w zależnościach wymaga przejścia po wszystkich miejscach.

DI nie jest "dla dużych aplikacji". DI jest po to, żebyś nie musiał ręcznie pamiętać, co komu podać — kontener robi to za ciebie. W konsolówce z 3 klasami różnica jest mała, w konsolówce z 20 klasami różnica jest między "kod się buduje sam" a "zmiana w jednym miejscu wymaga edycji pięciu plików".

Drugi powód to testy. Jak klasa App bierze IImporterDbContext z konstruktora, to w testach podajesz jej mocka albo in-memory DB. Jak tworzysz new DbContext(...) w środku App, to do testowania musisz kombinować z refleksją albo robić "testy" na prawdziwej bazie.

ServiceCollection od zera

Zaczynamy od tego, co mieliśmy w poprzedniej lekcji, tylko trochę porządkujemy. W Program.cs tworzymy ServiceCollection, rejestrujemy DbContext i nasze serwisy, budujemy provider i wyciągamy z niego "wejście" do aplikacji — klasę App. Kontekst bazy przestaje być przekazywany przez parametr — zamiast tego App bierze go przez konstruktor, a kontener DI sam go wstrzyknie.

var services = new ServiceCollection();
services.AddDbContext<ImporterDbContext>(options => options.UseSqlServer("..."));
services.AddScoped<IPlaceImporter, PlaceImporter>();

var provider = services.BuildServiceProvider();
var importer = provider.GetRequiredService<IPlaceImporter>();
await importer.RunAsync();

Zanim to zadziała, trzeba jeszcze jedna drobna rzecz — ImporterDbContext musi implementować interfejs (u mnie IImporterDbContext), który jest "spisem treści" jego DbSet-ów. Dzięki temu w innych klasach nie wstrzykujesz konkretnej implementacji, tylko abstrakcję — łatwiej ją potem podmienić albo zmockować.

public interface IImporterDbContext
{
    DbSet<Company> Companies { get; }
    Task<int> SaveChangesAsync(CancellationToken ct = default);
}

public class ImporterDbContext : DbContext, IImporterDbContext
{
    public DbSet<Company> Companies => Set<Company>();
    public ImporterDbContext(DbContextOptions<ImporterDbContext> options) : base(options) { }
}

W rejestracji warto wtedy "powiedzieć" kontenerowi, że IImporterDbContext rozwiązuje się przez ten sam obiekt co ImporterDbContext — inaczej dostałbyś dwie osobne instancje:

services.AddDbContext<ImporterDbContext>(opt => opt.UseSqlServer(connectionString));
services.AddScoped<IImporterDbContext>(sp => sp.GetRequiredService<ImporterDbContext>());

Teraz App wygląda czysto:

public class App
{
    private readonly IImporterDbContext _db;

    public App(IImporterDbContext db)
    {
        _db = db;
    }

    public async Task StartAsync()
    {
        var companies = await _db.Companies.ToListAsync();
        // ... reszta logiki importu
    }
}

Uruchomienie z Program.cs to dwie linijki — GetRequiredService<App>() i StartAsync(). Żadnego new, żadnego przekazywania kontekstu przez parametr. Jak dodasz w przyszłości CompanyImporter, zarejestrujesz go w ServiceCollection, wrzucisz do konstruktora App i wszystko działa.

Host.CreateDefaultBuilder — szybszy setup

ServiceCollection + BuildServiceProvider to minimum, które wystarcza. Ale jak tylko zaczniesz chcieć konfiguracji z appsettings.json, logowania (ILogger<T>), konfiguracji per environment (Development/Production) albo IHostedService — lepiej od razu iść w Host.CreateDefaultBuilder. To ten sam mechanizm, co w ASP.NET Core, tylko bez warstwy HTTP.

var host = Host.CreateDefaultBuilder(args)
    .ConfigureServices((ctx, services) =>
    {
        services.AddDbContext<ImporterDbContext>(options => options.UseSqlServer(ctx.Configuration.GetConnectionString("Default")));
        services.AddScoped<IPlaceImporter, PlaceImporter>();
    })
    .Build();

using var scope = host.Services.CreateScope();
var importer = scope.ServiceProvider.GetRequiredService<IPlaceImporter>();
await importer.RunAsync();

Co dostajesz za darmo:

  • appsettings.json + appsettings.Development.json wczytywane automatycznie (ctx.Configuration),
  • ILogger<T> działa od ręki — wstrzykujesz do dowolnej klasy i masz strukturalne logi,
  • zmienne środowiskowe z prefiksem DOTNET_ / ASPNETCORE_ są respektowane,
  • scope — bardzo ważne przy EF Core, bo DbContext jest Scoped i poza scope'em rzuci wyjątkiem.

CreateScope() to nie jest kosmetyka. AddDbContext rejestruje kontekst jako Scoped — bez własnego scope'u w konsolówce dostaniesz InvalidOperationException: Cannot consume scoped service ... from singleton. Jeden scope na całe uruchomienie importera jest OK, ale jak importujesz w pętli tysiące rekordów, rozważ scope per batch.

Alternatywnie, zamiast ręcznie rozwiązywać serwis, możesz zarejestrować swoją logikę jako IHostedService albo BackgroundService — wtedy host.RunAsync() sam ją uruchomi, a ty masz gotową obsługę Ctrl+C i graceful shutdown. W tym projekcie zostanę przy wariancie z CreateScope(), bo import jest jednorazowy (puść, policz, wyjdź) — pełny BackgroundService zostawiam sobie na moment, gdy będziemy chcieli cron/harmonogram.

Zasada praktyczna: jak piszesz dwugodzinną konsolówkę na "policz coś i zapisz do pliku" — wystarczy ręczny ServiceCollection. Jak piszesz narzędzie, które ma żyć miesiącami i zyskiwać kolejne funkcje — od razu Host.CreateDefaultBuilder. Migracja między nimi to kilka linijek, ale mniej się namyślasz po drodze.

Ostatnia rzecz, której nie pokazałem na wideo, a o którą zaczęły się pytania pod filmem — czasy życia (Transient / Scoped / Singleton). W skrócie: DbContext jest Scoped, "bezstanowe" serwisy typu importer z pojedynczą metodą RunAsync bez pól — spokojnie mogą być Singleton albo Transient. Zły dobór czasu życia to najczęstsze źródło bugów w DI w .NET, więc w kolejnym odcinku serii (albo osobnym nagraniu) rozwinę to na praktycznych przykładach.

Co dalej?

W kolejnej lekcji rozszerzamy model Company o pozostałe pola (adres, branżę, dane kontaktowe), dopisujemy serwis, który wyciąga konkretne rekordy z bazy źródłowej i przygotowuje je do mapowania pod WordPressa. Dzięki DI, które mamy już wpięte, każdy nowy serwis to jedna rejestracja w ServiceCollection i jedna linijka w konstruktorze — bez dotykania Program.cs poza miejscem rejestracji.

Kod z tej lekcji (Lekcja-2): https://github.com/jarmatys/dotnet-wordpress-importer

Jak utknąłeś na którymś kroku — pisz w komentarzach pod filmem. Tam odpowiadam najszybciej, a pytania od was często lądują jako osobne odcinki serii.

Tagi:#course-dotnet-wordpress#dotnet#dependency-injection#csharp

Masz pytanie do tego wpisu?

Napisz do mnie — chętnie porozmawiam o szczegółach technicznych, albo o tym jak zastosować to u Ciebie w projekcie.