четверг, 5 февраля 2026 г.

26.02.06, Chad, Quotes, Projects, Scoped, SingleTone

26.02.06, Chad, Quotes, Projects, Scoped, SingleTone

https://ask.chadgpt.ru/share/536b6d02-44c8-4aa3-b88c-e9f58e958fde

F:\Projects\VS\26\Giga\StockQuotes\StockQuotesApp02\StockQuotesApp02.csproj
F:\Projects\VS\26\Giga\StockQuotes\StockQuotesApp03\StockQuotesApp03.csproj

Chad

А что за история с ScopedServices используемых в BAckgroundServices
Объясни подробно.
using StockQuotesApp.Background;
using StockQuotesApp.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<EventHub>();
builder.Services.AddScoped<IPublisherService, PublisherService>();
builder.Services.AddScoped<ISubscriberService, SubscriberService>();

// Регистрация фоновых сервисов

builder.Services.AddHostedService<StockQuotePublisherBackgroundService>();
builder.Services.AddHostedService<StockQuoteSubscriberBackgroundService>();

var app = builder.Build();
app.MapGet("/", () => "Stock Quotes App is running!");
app.Run();
---
// Background/StockQuotePublisherBackgroundService.cs
using System.Threading;
using System.Threading.Tasks;

namespace StockQuotesApp.Background
{
public class StockQuotePublisherBackgroundService : BackgroundService
{
private const int TimeMiliSeconds = 1000;
private readonly IServiceProvider _serviceProvider;

public StockQuotePublisherBackgroundService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using (var scope = _serviceProvider.CreateScope())
{
var publisher = scope.ServiceProvider.GetRequiredService<IPublisherService>();

// Генерируем случайные цены для разных тикеров
Random random = new Random();
double price = random.NextDouble() * 100 + 100;
string message = $"Price updated to ${price:F2}";

publisher.Publish("AAPL", message); // Apple Inc.
await Task.Delay(TimeMiliSeconds, stoppingToken); // Ждем пять секунд перед следующим сообщением
}
}
}
}
---
using System.Threading;
using System.Threading.Tasks;

namespace StockQuotesApp.Background
{
public class StockQuoteSubscriberBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;

public StockQuoteSubscriberBackgroundService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using (var scope = _serviceProvider.CreateScope())
{
var subscriber = scope.ServiceProvider.GetRequiredService<ISubscriberService>();

// Подписываемся на обновление цен AAPL ОДИН РАЗ
subscriber.Subscribe("AAPL", message =>
{
var currentTime = DateTime.Now.ToString("yy.MM.dd HH:mm:ss");
Console.WriteLine($"{currentTime} Received update from AAPL: {message}");
});

// Поддерживаем бесконечный цикл ожидания
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
catch (Exception ex) {
Console.WriteLine($"Exception: {ex.Message}");
}

}
}
}
}
---
// Services/PublisherService.cs
using System.Threading.Tasks;

namespace StockQuotesApp.Services
{
public class PublisherService : IPublisherService
{
private readonly EventHub _eventHub;

public PublisherService(EventHub eventHub)
{
_eventHub = eventHub;
}

public void Publish(string ticker, string message)
{
Console.WriteLine($"Publishing {message} for {ticker}");
_eventHub.InvokeHandlers(ticker, message); // Оповещаем подписчиков
}
}
---
// Services/SubscriberService.cs
using System.Collections.Generic;

namespace StockQuotesApp.Services
{
public class SubscriberService : ISubscriberService
{
private readonly EventHub _eventHub;

public SubscriberService(EventHub eventHub)
{
_eventHub = eventHub;
}

public void Subscribe(string ticker, Action<string> handler)
{
_eventHub.AddHandler(ticker, handler);
}
}
--------
Почему используем Scoped регистрацию ?

BackgroundService (и вообще любой IHostedService) по умолчанию живёт весь срок жизни приложения. То есть он по факту Singleton. Отсюда и “история”:

  • Singleton можно напрямую внедрять в BackgroundService — ок.
  • Scoped нельзя напрямую внедрять в BackgroundService, потому что у Scoped смысл: “живёт в рамках scope” (в вебе это обычно один HTTP-запрос). А у hosted service нет “естественного” scope, он не привязан к запросу.

Поэтому правильный паттерн такой: внедряем в hosted service IServiceProvider (или лучше IServiceScopeFactory) и создаём scope вручную, когда нужно получить scoped-зависимости.


Что такое Scoped и почему это важно именно для BackgroundService

Жизненные циклы DI

  • Singleton — один экземпляр на всё приложение.
  • Scoped — один экземпляр на scope (в ASP.NET Core обычно один scope = один HTTP request).
  • Transient — новый экземпляр каждый раз при запросе из контейнера.

BackgroundService стартует один раз при запуске и выполняется долго. Если бы ты сделал так:

C#
Копировать код
public StockQuotePublisherBackgroundService(IPublisherService publisher) { ... }

а IPublisherService зарегистрирован как Scoped, то контейнер не может корректно это сделать: scoped-сервис нельзя “пришить” к singleton’у без scope. В типичной конфигурации это приводит к ошибке вида:

  • Cannot consume scoped service from singleton (или аналогичная).

Почему всё-таки часто регистрируют бизнес-сервисы как Scoped

Обычно потому что они зависят от других scoped вещей, например:

  • DbContext (почти всегда scoped)
  • IHttpContextAccessor-зависимые вещи (часто scoped/связанные с запросом)
  • unit-of-work/transaction per scope
  • кэш/контекст операций на время одной “логической операции”

Даже если сейчас PublisherService/SubscriberService зависят только от EventHub (singleton), их могут сделать scoped “на вырост” или из привычки (в web-приложениях большинство сервисов делают scoped).


Что происходит в твоём коде на самом деле

PublisherBackgroundService

У тебя цикл:

C#
Копировать код
while (...)
{
    using (var scope = _serviceProvider.CreateScope())
    {
        var publisher = scope.ServiceProvider.GetRequiredService<IPublisherService>();
        ...
        publisher.Publish(...)
        await Task.Delay(...)
    }
}

Это значит:

  • На каждую итерацию создаётся новый scope.
  • Внутри scope создаётся новый PublisherService (scoped).
  • После using scope уничтожается, и все scoped IDisposable-зависимости тоже корректно освобождаются.

Это хороший шаблон, если твой publisher внутри использует DbContext или другие scoped ресурсы: каждый “тик” работы — отдельная “операция” с чистыми зависимостями.

SubscriberBackgroundService

Здесь другое:

C#
Копировать код
using (var scope = _serviceProvider.CreateScope())
{
    var subscriber = scope.ServiceProvider.GetRequiredService<ISubscriberService>();

    subscriber.Subscribe(...);

    while (!stoppingToken.IsCancellationRequested)
    {
        await Task.Delay(...)
    }
}

Ты создаёшь scope один раз, затем подписываешься и держишь этот scope живым до остановки приложения.

Это потенциально проблемно по нескольким причинам:

  1. Scoped-объект живёт как singleton
    SubscriberService фактически становится “вечным” (пока сервис работает). Если бы внутри него был DbContext, ты бы держал DbContext открытым бесконечно — это плохо (подключения, трекинг, рост памяти, stale state).
  2. Утечки через подписку
    Ты добавляешь handler в EventHub (singleton). Этот handler — лямбда, она может удерживать ссылки на объекты из scope. Если scope когда-то завершится, но handler останется в EventHub, получится удержание/утечка. У тебя scope не завершается до остановки — но это всё равно “красный флаг”: singleton хранит делегаты, которые могут держать scoped-состояние.
  3. Непонятная модель “scope для подписки”
    Подписка — это скорее “конфигурационное действие”, а не “операция”. Обычно подписки делают на singleton/долгоживущих обработчиках, либо подписчик должен уметь отписываться.

Так почему у тебя Scoped регистрация вообще нужна?

Строго по текущему коду:

  • PublisherService и SubscriberService не обязаны быть scoped, потому что зависят только от singleton EventHub.
  • Их можно сделать Singleton, и тогда BackgroundService мог бы получать их напрямую без CreateScope().

Но scoped становится оправданным, если:

  • завтра PublisherService начнёт писать в БД через DbContext;
  • SubscriberService будет логировать в БД/кэш в рамках операции;
  • ты хочешь модель “одна итерация публикации = один scope”.

Рекомендации, как сделать правильно (по смыслу)

Вариант A: сделать Publisher/Subscriber singleton (если они реально stateless)

C#
Копировать код
builder.Services.AddSingleton<IPublisherService, PublisherService>();
builder.Services.AddSingleton<ISubscriberService, SubscriberService>();

Тогда hosted services могут зависеть от интерфейсов напрямую, и scope не нужен.

Вариант B: оставить scoped, но scope создавать “на операцию”

  • Для publisher — как сейчас (scope на итерацию) нормально.
  • Для subscriber — обычно так: подписка делается один раз, но обработчик при каждом событии создаёт scope и достаёт нужные scoped сервисы в момент обработки.

Идея:

  • подписчик хранит только делегат;
  • когда пришло событие, создаём scope и работаем.

Чтобы это было удобно, часто подписка делается не через scoped SubscriberService, а через singleton, который при обработке создаёт scope.


И ещё: IServiceScopeFactory лучше, чем IServiceProvider

В hosted service принято внедрять:

C#
Копировать код
private readonly IServiceScopeFactory _scopeFactory;

и потом:

C#
Копировать код
using var scope = _scopeFactory.CreateScope();

Это чуть чище и явно выражает намерение “создавать scope”.


Если скажешь, планируешь ли ты внутри PublisherService/SubscriberService использовать DbContext/EF Core или какие-то ресурсы, я предложу конкретную “правильную” архитектуру для подписчика (чтобы не держать scoped-сервисы живыми вечно и не получить утечки через EventHub).

Я не собираюсь использовать DBContext внутри PublisherService/SubscriberService
Напиши тогда правильный код.
--------------
Вот код, который есть сейчас
// Background/StockQuotePublisherBackgroundService.cs
using System.Threading;
using System.Threading.Tasks;

namespace StockQuotesApp.Background
{
public class StockQuotePublisherBackgroundService : BackgroundService
{
private const int TimeMiliSeconds = 1000;
private readonly IServiceProvider _serviceProvider;

public StockQuotePublisherBackgroundService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
while (!stoppingToken.IsCancellationRequested)
{
using (var scope = _serviceProvider.CreateScope())
{
var publisher = scope.ServiceProvider.GetRequiredService<IPublisherService>();

// Генерируем случайные цены для разных тикеров
Random random = new Random();
double price = random.NextDouble() * 100 + 100;
string message = $"Price updated to ${price:F2}";

publisher.Publish("AAPL", message); // Apple Inc.
await Task.Delay(TimeMiliSeconds, stoppingToken); // Ждем пять секунд перед следующим сообщением
}
}
}
}
----------
// Background/StockQuoteSubscriberBackgroundService.cs
using System.Threading;
using System.Threading.Tasks;

namespace StockQuotesApp.Background
{
public class StockQuoteSubscriberBackgroundService : BackgroundService
{
private readonly IServiceProvider _serviceProvider;

public StockQuoteSubscriberBackgroundService(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}

protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using (var scope = _serviceProvider.CreateScope())
{
var subscriber = scope.ServiceProvider.GetRequiredService<ISubscriberService>();

// Подписываемся на обновление цен AAPL ОДИН РАЗ
subscriber.Subscribe("AAPL", message =>
{
var currentTime = DateTime.Now.ToString("yy.MM.dd HH:mm:ss");
Console.WriteLine($"{currentTime} Received update from AAPL: {message}");
});

// Поддерживаем бесконечный цикл ожидания
while (!stoppingToken.IsCancellationRequested)
{
try
{
await Task.Delay(TimeSpan.FromSeconds(1), stoppingToken);
}
catch (Exception ex) {
Console.WriteLine($"Exception: {ex.Message}");
}

}
}
}
}
----
// Services/PublisherService.cs
using System.Threading.Tasks;

namespace StockQuotesApp.Services
{
public class PublisherService : IPublisherService
{
private readonly EventHub _eventHub;

public PublisherService(EventHub eventHub)
{
_eventHub = eventHub;
}

public void Publish(string ticker, string message)
{
Console.WriteLine($"Publishing {message} for {ticker}");
_eventHub.InvokeHandlers(ticker, message); // Оповещаем подписчиков
}
}
----
// Services/SubscriberService.cs
using System.Collections.Generic;

namespace StockQuotesApp.Services
{
public class SubscriberService : ISubscriberService
{
private readonly EventHub _eventHub;

public SubscriberService(EventHub eventHub)
{
_eventHub = eventHub;
}

public void Subscribe(string ticker, Action<string> handler)
{
_eventHub.AddHandler(ticker, handler);
}
}
-----
// Interfaces.cs
public interface IPublisherService
{
void Publish(string ticker, string message);
}

public interface ISubscriberService
{
void Subscribe(string ticker, Action<string> handler);
}

public class EventHub : Dictionary<string, List<Action<string>>>
{
public void AddHandler(string ticker, Action<string> handler)
{
if (!ContainsKey(ticker))
this[ticker] = new List<Action<string>>();

this[ticker].Add(handler);
}

public void InvokeHandlers(string ticker, string message)
{
if (TryGetValue(ticker, out var handlers))
{
foreach (var handler in handlers)
handler(message);
}
}
}
----
using StockQuotesApp.Background;
using StockQuotesApp.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<EventHub>();
builder.Services.AddScoped<IPublisherService, PublisherService>();
builder.Services.AddScoped<ISubscriberService, SubscriberService>();

// Регистрация фоновых сервисов

builder.Services.AddHostedService<StockQuotePublisherBackgroundService>();
builder.Services.AddHostedService<StockQuoteSubscriberBackgroundService>();

var app = builder.Build();
app.MapGet("/", () => "Stock Quotes App is running!");
app.Run();
----
Вроде все.

Раз ты не используешь scoped-зависимости (типа DbContext) внутри PublisherService/SubscriberService, то scoped здесь не нужен. Правильнее сделать:

  • EventHubSingleton (общая “шина” на всё приложение)
  • PublisherService/SubscriberService — тоже Singleton (они тонкие обёртки над EventHub)
  • В BackgroundService не создавать scope вообще, а внедрять нужные сервисы напрямую
  • (Опционально, но желательно) сделать EventHub потокобезопасным, т.к. publisher и subscriber работают в разных потоках

Ниже — рабочий вариант “как надо” для твоего кейса.


1) EventHub (потокобезопасный)

C#
Копировать код
using System.Collections.Concurrent;

public class EventHub
{
    private readonly ConcurrentDictionary<string, ConcurrentBag<Action<string>>> _handlers = new();

    public void AddHandler(string ticker, Action<string> handler)
    {
        var bag = _handlers.GetOrAdd(ticker, _ => new ConcurrentBag<Action<string>>());
        bag.Add(handler);
    }

    public void InvokeHandlers(string ticker, string message)
    {
        if (_handlers.TryGetValue(ticker, out var handlers))
        {
            foreach (var handler in handlers)
            {
                handler(message);
            }
        }
    }
}

Если потокобезопасность сейчас не критична, можно оставить твой Dictionary, но это потенциальная проблема при одновременной подписке/публикации.


2) Сервисы Publisher/Subscriber (без изменений по сути)

C#
Копировать код
namespace StockQuotesApp.Services
{
    public class PublisherService : IPublisherService
    {
        private readonly EventHub _eventHub;

        public PublisherService(EventHub eventHub) => _eventHub = eventHub;

        public void Publish(string ticker, string message)
        {
            Console.WriteLine($"Publishing {message} for {ticker}");
            _eventHub.InvokeHandlers(ticker, message);
        }
    }
}
C#
Копировать код
namespace StockQuotesApp.Services
{
    public class SubscriberService : ISubscriberService
    {
        private readonly EventHub _eventHub;

        public SubscriberService(EventHub eventHub) => _eventHub = eventHub;

        public void Subscribe(string ticker, Action<string> handler)
        {
            _eventHub.AddHandler(ticker, handler);
        }
    }
}

3) BackgroundService publisher — без scope, Random один раз

C#
Копировать код
using StockQuotesApp.Services;

namespace StockQuotesApp.Background
{
    public class StockQuotePublisherBackgroundService : BackgroundService
    {
        private const int TimeMiliSeconds = 1000;
        private readonly IPublisherService _publisher;
        private readonly Random _random = new();

        public StockQuotePublisherBackgroundService(IPublisherService publisher)
        {
            _publisher = publisher;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            while (!stoppingToken.IsCancellationRequested)
            {
                double price = _random.NextDouble() * 100 + 100;
                string message = $"Price updated to ${price:F2}";

                _publisher.Publish("AAPL", message);

                await Task.Delay(TimeMiliSeconds, stoppingToken);
            }
        }
    }
}

Важно: new Random() внутри цикла — плохая идея (может давать одинаковые значения при частых вызовах). Здесь исправлено.


4) BackgroundService subscriber — подписка один раз, без scope

C#
Копировать код
using StockQuotesApp.Services;

namespace StockQuotesApp.Background
{
    public class StockQuoteSubscriberBackgroundService : BackgroundService
    {
        private readonly ISubscriberService _subscriber;

        public StockQuoteSubscriberBackgroundService(ISubscriberService subscriber)
        {
            _subscriber = subscriber;
        }

        protected override async Task ExecuteAsync(CancellationToken stoppingToken)
        {
            _subscriber.Subscribe("AAPL", message =>
            {
                var currentTime = DateTime.Now.ToString("yy.MM.dd HH:mm:ss");
                Console.WriteLine($"{currentTime} Received update from AAPL: {message}");
            });

            // Просто "живём", пока не остановят
            await Task.Delay(Timeout.Infinite, stoppingToken);
        }
    }
}

Твой бесконечный цикл с Delay(1s) можно упростить до Timeout.Infinite.


5) Регистрация DI (Program.cs)

C#
Копировать код
using StockQuotesApp.Background;
using StockQuotesApp.Services;

var builder = WebApplication.CreateBuilder(args);

builder.Services.AddSingleton<EventHub>();
builder.Services.AddSingleton<IPublisherService, PublisherService>();
builder.Services.AddSingleton<ISubscriberService, SubscriberService>();

builder.Services.AddHostedService<StockQuotePublisherBackgroundService>();
builder.Services.AddHostedService<StockQuoteSubscriberBackgroundService>();

var app = builder.Build();
app.MapGet("/", () => "Stock Quotes App is running!");
app.Run();

Если хочешь, могу добавить “правильное” отписывание (чтобы можно было удалять handler) и/или сделать EventHub с Subscribe() возвращающим IDisposable — это удобно и безопасно для долгоживущих подписок.

Вроде работает
Application started. Press Ctrl+C to shut down.
info: Microsoft.Hosting.Lifetime[0]
Hosting environment: Development
info: Microsoft.Hosting.Lifetime[0]
Content root path: F:\Projects\VS\26\Giga\StockQuotes\StockQuotesApp03
Publishing Price updated to $154,34 for AAPL
26.02.05 11:45:55 Received update from AAPL: Price updated to $154,34
Publishing Price updated to $190,62 for AAPL
26.02.05 11:45:56 Received update from AAPL: Price updated to $190,62
Publishing Price updated to $171,47 for AAPL
26.02.05 11:45:57 Received update from AAPL: Price updated to $171,47
Publishing Price updated to $170,82 for AAPL
26.02.05 11:45:58 Received update from AAPL: Price updated to $170,82
Publishing Price updated to $132,11 for AAPL
26.02.05 11:45:59 Received update from AAPL: Price updated to $132,11
info: Microsoft.Hosting.Lifetime[0]
Application is shutting down...
Exception: StockQuoteSubscriberBackgroundService A task was canceled.
Exception: StockQuotePublisherBackgroundService A task was canceled.
-------------
Но Почему же в литературе рекомендуют использовать Scoped регистрацию ?
НЕ очень понятно.

Scoped рекомендуют не “потому что так надо всегда”, а потому что это безопасная настройка по умолчанию для сервисов, которые зависят от scoped‑вещей (в первую очередь от DbContext, HttpClientFactory-хэндлеров через IHttpClientFactory не обязательно, IOptionsSnapshot, IServiceProvider в рамках запроса, и т. п.).

Почему Scoped часто рекомендуют

  1. Типичный сценарий ASP.NET Core — HTTP-запрос = scope
    В веб‑приложении большинство прикладных сервисов работает “в рамках запроса”: один запрос → один scope → один набор зависимостей. Поэтому “сервис бизнес-логики” часто делают Scoped, чтобы:
  • он мог спокойно использовать DbContext (который почти всегда Scoped);
  • он не жил дольше запроса и не держал лишнее состояние;
  • не было гонок из-за случайно разделяемого состояния между запросами.
  1. Singleton + scoped-зависимость = проблема
    Если ты зарегистрируешь сервис как Singleton, а он внутри получает DbContext (scoped), то это либо:
  • вообще не запустится (DI ругнётся “Cannot consume scoped service from singleton”), либо
  • начнутся очень неприятные баги, если кто-то обойдёт это через IServiceProvider и сохранит scoped объект “навсегда”.
  1. Scoped помогает не делать сервисы “случайно stateful”
    Singleton заставляет мыслить в терминах потокобезопасности и общего состояния на всё приложение. Scoped проще: сервис создаётся заново на scope, меньше шансов “утечь” памятью через подписки/кэши/коллекции.

Почему в твоём случае Singleton выглядит логичнее

У тебя EventHub — явно “общий автобус событий” внутри процесса. Его нормальная жизнь — всё время работы приложения, значит Singleton подходит.

А вот PublisherService и SubscriberService в твоём примере:

  • не держат DbContext;
  • не зависят от scoped‑сервисов;
  • по сути являются тонкими обёртками над EventHub.

Поэтому Singleton для них тоже допустим (и даже логичен), если они не хранят пользовательское/запросное состояние и потокобезопасны.

Важный нюанс про HostedService и scoped

HostedService по умолчанию живёт как singleton. Если внутри HostedService тебе нужны scoped‑зависимости (например, репозиторий с DbContext), то правильный паттерн такой:

C#
Копировать код
public class Worker : BackgroundService
{
    private readonly IServiceScopeFactory _scopeFactory;

    public Worker(IServiceScopeFactory scopeFactory) => _scopeFactory = scopeFactory;

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        while (!stoppingToken.IsCancellationRequested)
        {
            using var scope = _scopeFactory.CreateScope();
            var scopedService = scope.ServiceProvider.GetRequiredService<IMyScopedService>();
            await scopedService.DoWorkAsync(stoppingToken);

            await Task.Delay(1000, stoppingToken);
        }
    }
}

То есть: HostedService singleton → внутри создаём scope на итерацию/операцию.

Про исключения “A task was canceled”

Это нормальная история при корректном завершении приложения: stoppingToken отменяется, Task.Delay(..., stoppingToken) бросает TaskCanceledException. Обычно это не “ошибка”, а ожидаемый путь остановки. Если не хочешь видеть это как “Exception”, просто не логируй его как ошибку или перехвати и молча выйди.


Если скажешь, планируешь ли ты подключать БД/EF Core или какие-то внешние клиенты в PublisherService/SubscriberService, я подскажу оптимальные lifetime’ы именно под твой сценарий (и как безопаснее организовать scope в фоне).

Комментариев нет:

Отправить комментарий