WorkerW ASP.NET Core bardzo łatwo jest napisać usługę, która będzie robić coś w tle. Wraz z .NET  6 jeszcze łatwiej jest ją zarejestrować. Wiele osób nie wiem, że ASP.NET Core oferuje takie gotowe rozwiązanie. Dlatego kiedyś zrobiłem nawet o tym filmik na YouTube. Oto link do niego

Ten temat do mnie wrócił, gdy na moim Instagramie dostałem zlecenie, aby zrobić materiał na temat tego, co trzeba zrobić, aby elementy z cyklem życia "Scoped" mogły żyć wewnątrz BackgroundService.

Normalnie nie jest to możliwe, ponieważ BackgroundService jest singletonem, czyli wszystko, co istnieje wewnątrz niego albo też będzie singletonem, albo wyrzuci Ci wyjątek z cyklu "Stefan co ty robisz".

Ja zlecenia na materiały traktuje poważnie więc ten problem także omówimy.

A w czym może pomóc taki BackgroundService:

  • W jakiejkolwiek operacji, którą chcesz zrobić w jakiś odstępie czasowym
    • Co minutę
    • Co godzinie
    • Co dobę
  • Wysłanie e-mail poza wątek sieciowy
  • Określenie zmian w zasobach stanowych, czyli sprawdzasz co się zmieniło w danej tabelce w bazie danych
  • Długotrwałe zadania jak przetwarzanie kolejek RabbitMQ

Swoją drogą możesz też zadać inne dobre pytanie. Po co w ogóle korzystać z BackgroundService skoro mogę napisać swoją nieskończoną pętlę i wyjdzie mi w teorii na to samo.

Pamiętaj, że BackgroundService to element ASP.NET Core co znaczy, że możesz z nim pogadać poprzez zwykłe zapytania HTTP i to też Ci dziś pokaże.

Najpierw zacznijmy od podstaw. Najprostszy BackgroundService, który co sekundę napiszę napis "TEST" wygląda tak.

public class WorkerHostedService : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        //Twój kod startujący zaczyna się tu
        while (!stopToken.IsCancellationRequested)
        {
            await Task.Delay(1000);
            Console.WriteLine("TEST");
        }
        //Posprzątaj po kodzie gdy przestanie działać
    }
}

Jak widzisz musisz dziedziczyć po klasie "BackgroundService" i przeciążyć przynajmniej tę metodę. Dopóki token wycofujący nie pójdzie do tej usługi tak długo ona będzie działać. 

Dla bezpieczeństwa możesz także złapać wyjątek, gdy wycofywanie procesu uruchomi się w trakcie tej jedno sekundowej pauzy.  

public class WorkerHostedService2 : BackgroundService
{
    protected override async Task ExecuteAsync(CancellationToken stopToken)
    {
        //Twój kod startujący zaczyna się tu
        while (!stopToken.IsCancellationRequested)
        {
            try
            {
                await Task.Delay(1000, stopToken);
                Console.WriteLine("TEST");
            }
            catch (OperationCanceledException)
            {
                return;
            }

        }
        //Posprzątaj po kodzie gdy przestanie działać
    }
}

Jeśli interesuje Cię jak BackGroundService zrobić w .NET 5 to odsyłam Cię do mojego filmiku. W .NET 6 rejestracja jest dziecinie prosta. Oto kod pliku Program.cs

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHostedService<WorkerHostedService2>();

var app = builder.Build();
app.Run();

Teraz napiszmy lepszą usługę, która robi coś więcej niż piszę napis "TEST".

Pomysł z wyświetlaniem emoji został zapożyczony z tego wpisu także na temat BackgroundService, ale zmodyfikowałem przykład do własnych potrzeb.

W tym przykładzie mamy naszego "pracownika", który przechowuje listę emotikonów i co sekundę będzie on tę listę wyświetlał. 

public class WorkerEmoji : BackgroundService
{
    private Random random = new Random();

    private static List<string> _happySymbols = new()
    {
        "🔥",
        "😂",
        "😁",
        "🙏",
        "😎",
        "💪",
        "😘",
        "😍",
        "🤩",
        "🥰",
        "😉",
        "👍",
        "🥳"
    };

    private static List<string> _sadSymbols = new()
    {
        "😓",
        "😰",
        "😭",
        "😖",
        "😣",
        "😞",
        "😓",
        "😩",
        "😫",
        "😱",
        "😬",
        "👎",
        "🤢"
    };

    private static List<string> _emojis = new() { "🗣" };

    public string HTML => string.Join(
        string.Empty, _emojis.Select(e => HtmlEncoder.Default.Encode(e))
    );

    private string Output => string.Join(string.Empty, _emojis);

    private void AddEmoji(string emoji) => _emojis.Add(emoji);

    public void AddHappyEmoji()
    {
        int index = random.Next(0, _happySymbols.Count());
        AddEmoji(_happySymbols[index]);
    }
    public void AddSadEmoji()
    {
        int index = random.Next(0, _sadSymbols.Count());
        AddEmoji(_sadSymbols[index]);
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            try
            {
                Console.WriteLine($"{DateTime.Now:u}: Hello, {Output}");
                await Task.Delay(1000, stoppingToken);
            }
            catch (OperationCanceledException)
            {
                return;
            }
        }
    }
}

Rejestracja tego BackgroundService jest taka sama jak wcześniej. 

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddHostedService<WorkerEmoji>();

var app = builder.Build();
....

Teraz mój "pracownik" ma wystawioną metodę, która umożliwili nam dodawanie losowych smutnych i wesołych emotikonów do listy, która jest wyświetlana co sekundę.

Pisząc aplikację w stylu minimalnym możemy wyszukać naszego "WorkerEmoji" z listy wszystkich "IHostedService" jakie mogłyby być zarejestrowane w naszej aplikacji.

Korzystamy z metod "AddHappyEmoji()" i "AddSadEmoji()" gdy stukniemy odpowiedni adres HTTP.

app.MapGet("/happy", (HttpContext context) =>
{
    var worker =
    context.RequestServices.GetServices<IHostedService>()
    .OfType<WorkerEmoji>().FirstOrDefault();

    if (worker is not null)
    {
        worker.AddHappyEmoji();
        context.Response.StatusCode = (int)HttpStatusCode.NoContent;
        return "";
    }
    context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
    return "";
});

app.MapGet("/sad", (HttpContext context) =>
{
    var worker =
    context.RequestServices.GetServices<IHostedService>()
    .OfType<WorkerEmoji>().FirstOrDefault();

    if (worker is not null)
    {
        worker.AddSadEmoji();
        context.Response.StatusCode = (int)HttpStatusCode.NoContent;
        return "";
    }
    context.Response.StatusCode = (int)HttpStatusCode.InternalServerError;
    return "";
});

Domyślny adres natomiast wyświetli nam listę obecnych emotikonów.

app.MapGet("/", (HttpContext context) =>
{
    var worker =
    context.RequestServices.GetServices<IHostedService>()
    .OfType<WorkerEmoji>().FirstOrDefault();

    context.Response.ContentType = "text/html";
    return worker?.HTML;
});

Ten gif pokaże ci działanie mojej aplikacji

Animacja pokazuje jak działa aplikacja BackgroundService

Jak widzisz do listy dodaliśmy dwie wesołe emoji i jedną smutną i ta informacja znajduje się w naszym BackgroundService, który cały czas wyświetla emoji w konsoli co sekundę.

Niestety wciąż mam problem ze znakami specjalnymi więc wyświetlają mi się tylko znaki zapytania w konsoli.

Co dzieje się w konsoli w aplikacji BackgroundService

Pamiętaj także ta cała aplikacja to tak naprawdę plik "exe", który możesz uruchomić z wiersza poleceń.

Każda aplikacja ASP.NET Core to plik exe

A co jeśli potrzebujesz cyklu życia Scoped?

Przejdźmy teraz do tego, co mnie zmotywował do napisania tego wpisu. Jak przerobić BackgroundService tak, aby wewnątrz niego można było mieć elementy z cyklu życia Scoped.

BackgroundService jest singletonem, a to oznacza, że próba wstrzyknięcia do niego elementu "Scoped" zakończy się wyjątkiem.

Do BackgroundService możesz wstrzyknąć element z cyklem życia "Transient" i nie wyskoczy Ci wyjątek , ale ponieważ on się znajduje w "Singletonie" czyli w BackgroundService to tak naprawdę będzie on i tak żył w cyklu "Singleton".

O dziwo oficjalna dokumentacja MSDN ma przykład na ten problem. 

Tylko jakoś nie byłem w stanie zrozumieć, jaką mamy wartość dodaną w związku z tym, że mamy wewnątrz BackgroundService inne elementy z innym cyklem życia.

Od przykładu z MSDN zapożyczałem ten interfejs. Chcemy, aby każda usługa lub usługi wewnątrz naszego BackgroundService miały metodę, która wykona jakąś pracę.

internal interface IScopedProcessingService
{ 
    Task DoWork(CancellationToken stoppingToken);
}

Oto prosta usługa, która będzie wyświetlać napis w konsoli i odliczać liczbę zaczynając od zera.

Możesz zauważyć, że tutaj nie ma żadnej pętli i jest to zabieg celowy, bo ten mechanizm będzie w BackgroundService.

internal class ScopedSimpleTextService : IScopedProcessingService
{
    private int executionCount = 0;
    private readonly ILogger _logger;

    public ScopedSimpleTextService(ILogger<ScopedSimpleTextService> logger)
    {
        _logger = logger;
    }

    public async Task DoWork(CancellationToken stoppingToken)
    {

        executionCount++;

        _logger.LogInformation(
            "Scoped Processing Service is working. Count: {Count}", executionCount);

    }
}

Przerobiłem także "WorkerEmoji" na "ScopedEmojiService". Działa ona tak samo jak wcześniej. Nie ma tylko pętli.

public class ScopedEmojiService : IScopedProcessingService
{
    private Random random = new Random();

    private static List<string> _happySymbols = new()
    {
        "🔥",
        "😂",
        "😁",
        "🙏",
        "😎",
        "💪",
        "😘",
        "😍",
        "🤩",
        "🥰",
        "😉",
        "👍",
        "🥳"
    };

    private static List<string> _sadSymbols = new()
    {
        "😓",
        "😰",
        "😭",
        "😖",
        "😣",
        "😞",
        "😓",
        "😩",
        "😫",
        "😱",
        "😬",
        "👎",
        "🤢"
    };

    private List<string> _emojis = new() { "🗣" };

    public string HTML => string.Join(
        string.Empty, _emojis.Select(e => HtmlEncoder.Default.Encode(e))
    );

    private string Output => string.Join(string.Empty, _emojis);

    private void AddEmoji(string emoji) => _emojis.Add(emoji);

    public void AddHappyEmoji()
    {
        int index = random.Next(0, _happySymbols.Count());
        AddEmoji(_happySymbols[index]);
    }
    public void AddSadEmoji()
    {
        int index = random.Next(0, _sadSymbols.Count());
        AddEmoji(_sadSymbols[index]);
    }


    public async Task DoWork(CancellationToken stoppingToken)
    {
        Console.WriteLine($"{DateTime.Now:u}: Hello, {Output}");
    }
}

Lista przechowująca stan nie mogę być statyczna, aby ten przykład miał sens.

private  List<string> _emojis = new() { "🗣" };

Teraz zajrzyjmy do naszego BackgroundService, który te usługi będzie wykonywał. 

Do pokazania jakiegoś sensu istnieją elementów z cyklem życia "Scoped"...

Wymyśliłem, że co 12 sekund będą robił "RESET" i utworzą mi się nowe instancje "ScopedEmojiService" i "ScopedSimpleTextService "

Co sekundę wykonam zadanie z "ScopedEmojiService" i "ScopedSimpleTextService" i po 10 razie przerwę ich prace. Potem odczekam 2 sekundy, aby zrobić reset i zrobić cały proces od początku.

public class ConsumeScopedServiceHostedService : BackgroundService
{
    private readonly ILogger<ConsumeScopedServiceHostedService> _logger;

    public ConsumeScopedServiceHostedService(IServiceProvider services,
        ILogger<ConsumeScopedServiceHostedService> logger)
    {
        Services = services;
        _logger = logger;
    }

    public IServiceProvider Services { get; }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation(
            "Consume Scoped Service Hosted Service running.");
        
        while (!stoppingToken.IsCancellationRequested)
        {
            await DoWork(stoppingToken);
            await Task.Delay(2000);
            Console.WriteLine("==================");
            Console.WriteLine("R-E-S-E-T");
            Console.WriteLine("==================");
        }

    }

    private async Task DoWork(CancellationToken stoppingToken)
    {
        _logger.LogInformation(
            "Consume Scoped Service Hosted Service is working.");

        using (var scope = Services.CreateScope())
        {
            var scopedProcessingServices =
                scope.ServiceProvider
                    .GetServices<IScopedProcessingService>();

            for (int i = 0; i < 10; i++)
            {
                foreach (var scopedProcessingService in scopedProcessingServices)
                {
                    await scopedProcessingService.DoWork(stoppingToken);
                }
                await Task.Delay(1000);
            }

        }
    }

    public override async Task StopAsync(CancellationToken stoppingToken)
    {
        _logger.LogInformation(
            "Consume Scoped Service Hosted Service is stopping.");

        await base.StopAsync(stoppingToken);
    }
}

Jak widzisz ja nie wstrzykuje do konstruktora listy interfejsów "IScopedProcessingService"

Wewnątrz BackgroundService muszę ustalić zasady cyklu życia "Scoped" dla tych elementów, a co za tę idzie wyciągnąć je bardziej brutalnie poprzez użycie "scope.ServiceProvider.GetServices<IScopedProcessingService>()".

Warto także zaznaczyć, że wstrzykiwanie sobie "IServiceProvider" tworzy antywzorzec "ServiceLocator", ale w tym przypadku nie możemy tego uniknąć.

Na koniec pozostało nam zarejestrować nasz nowym BackgroundService i wszystkie klasy, które implementują interfejs "IScopedProcessingService"

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddScoped<IScopedProcessingService, ScopedSimpleTextService>();
builder.Services.AddScoped<IScopedProcessingService, ScopedEmojiService>();

builder.Services.AddScoped<ScopedEmojiService>
    (serviceprovider => 
    {   return serviceprovider.GetServices<IScopedProcessingService>()
        .OfType<ScopedEmojiService>().FirstOrDefault(); ;
    });

builder.Services.AddHostedService<ConsumeScopedServiceHostedService>();

var app = builder.Build();
.....

Kod modyfikacji listy emoji tym razem bardziej uprościłem, ale działa tak samo.

app.MapGet("/", (ScopedEmojiService emojiService, HttpContext context) =>
{
    context.Response.ContentType = "text/html";
    return emojiService.HTML;
});

app.MapGet("/happy", (ScopedEmojiService emojiService) =>
{
    emojiService.AddHappyEmoji();
});

app.MapGet("/sad", (ScopedEmojiService emojiService) =>
{
    emojiService.AddSadEmoji();
});

app.Run();

...i to nie koniec. Ponieważ celowo zrobiłem błąd.

Pomyśl skoro nasz "IScopedProcessingService"  żyje teraz w cyklu życia "Scoped" znaczy to, że instancja, do której się odwołuje przez zapytania HTTP a instancja, która żyje wewnątrz BackgroundService to dwa różne obiekty.

Czyli żadna modyfikacja teraz nie będzie widoczna. Nie mogę też zrobić statycznej listy, ponieważ chce, aby tak lista resetowała swój stan, gdy obiekt jest kasowany.

Najlepiej jest więc naszą usługę, którą chcemy modyfikować poprzez zapytania HTTP wystawić jako właściwość tylko do odczytu.

public class ConsumeScopedServiceHostedService : BackgroundService
{
    public ScopedEmojiService EmojiService { get;private set; }

Będzie moment, kiedy ta właściwość będzie bez referencji, ale w trakcie działania pracy ta właściwość będzie wskazywała na nasz serwis emotikonowy, który chcemy modyfikować. 

private async Task DoWork(CancellationToken stoppingToken)
{
    _logger.LogInformation(
        "Consume Scoped Service Hosted Service is working.");

    using (var scope = Services.CreateScope())
    {
        var scopedProcessingServices =
            scope.ServiceProvider
                .GetServices<IScopedProcessingService>();

        EmojiService = scopedProcessingServices.OfType<ScopedEmojiService>()
            .FirstOrDefault();

        for (int i = 0; i < 10; i++)
        {
            foreach (var scopedProcessingService in scopedProcessingServices)
            {
                await scopedProcessingService.DoWork(stoppingToken);
            }
            await Task.Delay(1000);
        }

    }
}

Teraz musimy zmienić kod w pliku "program.cs".

builder.Services.AddScoped<ScopedEmojiService>
    (serviceprovider => 
    {   return serviceprovider.GetServices<IHostedService>()
        .OfType<ConsumeScopedServiceHostedService>().FirstOrDefault()
        .EmojiService; ;
    });

Gdy będę chciał odwołać się do "ScopedEmojiService" to wyszukam sobie mojego BackgroundService o typie "ConsumeScopedServiceHostedService" i odwołam się do właściwości wewnątrz niego. 

Wypadało też zabezpieczyć się przed Nullem, który może wystąpić, bo przypisanie do właściwości przecież nie jest natychmiastowe. 

app.MapGet("/", (ScopedEmojiService emojiService, HttpContext context) =>
{
    if (emojiService != null)
    {
        context.Response.ContentType = "text/html";
        return emojiService.HTML;
    }
return ""; }); app.MapGet("/happy", (ScopedEmojiService emojiService) => { if (emojiService != null) emojiService.AddHappyEmoji(); }); app.MapGet("/sad", (ScopedEmojiService emojiService) => { if (emojiService != null) emojiService.AddSadEmoji(); }); app.Run();

Czy konstruktor mógłby nas od tego uchronić? Tak, tylko pamiętaj my tworzy obiekty w cyklu życia Scope niezależnie od cyklu życia BackgroundService.

Dlatego nie możemy tego tak rozwiązać. 

Teraz gdy uruchomię aplikację to mogę zobaczyć odliczanie do 10, które zaraz zresetuje się do zera i tak od początku.

Mogę też zobaczyć wyświetlanie listy emotikonów, do której mogę dodawać elementy, ale ta lista zresetuje się po 12 sekundach więc zmiana nie jest trwała.

Animacja jak działa BackgroundService w elementami z cyklem życia Scoped

To wszystko, co musisz wiedzieć na temat BackgroundService. Kod do pobrania na GitHub 👇

PanNiebieski/ScopedBackgroundServiceInAspNetCore (github.com)