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
Объясни подробно.
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 стартует один раз при запуске и выполняется долго. Если бы ты сделал так:
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
У тебя цикл:
while (...)
{
using (var scope = _serviceProvider.CreateScope())
{
var publisher = scope.ServiceProvider.GetRequiredService<IPublisherService>();
...
publisher.Publish(...)
await Task.Delay(...)
}
}Это значит:
- На каждую итерацию создаётся новый scope.
- Внутри scope создаётся новый
PublisherService(scoped). - После
usingscope уничтожается, и все scoped IDisposable-зависимости тоже корректно освобождаются.
Это хороший шаблон, если твой publisher внутри использует DbContext или другие scoped ресурсы: каждый “тик” работы — отдельная “операция” с чистыми зависимостями.
SubscriberBackgroundService
Здесь другое:
using (var scope = _serviceProvider.CreateScope())
{
var subscriber = scope.ServiceProvider.GetRequiredService<ISubscriberService>();
subscriber.Subscribe(...);
while (!stoppingToken.IsCancellationRequested)
{
await Task.Delay(...)
}
}Ты создаёшь scope один раз, затем подписываешься и держишь этот scope живым до остановки приложения.
Это потенциально проблемно по нескольким причинам:
- Scoped-объект живёт как singleton
SubscriberServiceфактически становится “вечным” (пока сервис работает). Если бы внутри него былDbContext, ты бы держалDbContextоткрытым бесконечно — это плохо (подключения, трекинг, рост памяти, stale state). - Утечки через подписку
Ты добавляешь handler вEventHub(singleton). Этот handler — лямбда, она может удерживать ссылки на объекты из scope. Если scope когда-то завершится, но handler останется вEventHub, получится удержание/утечка. У тебя scope не завершается до остановки — но это всё равно “красный флаг”: singleton хранит делегаты, которые могут держать scoped-состояние. - Непонятная модель “scope для подписки”
Подписка — это скорее “конфигурационное действие”, а не “операция”. Обычно подписки делают на singleton/долгоживущих обработчиках, либо подписчик должен уметь отписываться.
Так почему у тебя Scoped регистрация вообще нужна?
Строго по текущему коду:
PublisherServiceиSubscriberServiceне обязаны быть scoped, потому что зависят только от singletonEventHub.- Их можно сделать
Singleton, и тогдаBackgroundServiceмог бы получать их напрямую безCreateScope().
Но scoped становится оправданным, если:
- завтра
PublisherServiceначнёт писать в БД черезDbContext; SubscriberServiceбудет логировать в БД/кэш в рамках операции;- ты хочешь модель “одна итерация публикации = один scope”.
Рекомендации, как сделать правильно (по смыслу)
Вариант A: сделать Publisher/Subscriber singleton (если они реально stateless)
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 принято внедрять:
private readonly IServiceScopeFactory _scopeFactory;и потом:
using var scope = _scopeFactory.CreateScope();Это чуть чище и явно выражает намерение “создавать scope”.
Если скажешь, планируешь ли ты внутри PublisherService/SubscriberService использовать DbContext/EF Core или какие-то ресурсы, я предложу конкретную “правильную” архитектуру для подписчика (чтобы не держать scoped-сервисы живыми вечно и не получить утечки через EventHub).
Напиши тогда правильный код.
--------------
Вот код, который есть сейчас
// 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 здесь не нужен. Правильнее сделать:
EventHub—Singleton(общая “шина” на всё приложение)PublisherService/SubscriberService— тожеSingleton(они тонкие обёртки надEventHub)- В
BackgroundServiceне создавать scope вообще, а внедрять нужные сервисы напрямую - (Опционально, но желательно) сделать
EventHubпотокобезопасным, т.к. publisher и subscriber работают в разных потоках
Ниже — рабочий вариант “как надо” для твоего кейса.
1) EventHub (потокобезопасный)
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 (без изменений по сути)
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);
}
}
}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 один раз
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
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)
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 часто рекомендуют
- Типичный сценарий ASP.NET Core — HTTP-запрос = scope
В веб‑приложении большинство прикладных сервисов работает “в рамках запроса”: один запрос → один scope → один набор зависимостей. Поэтому “сервис бизнес-логики” часто делаютScoped, чтобы:
- он мог спокойно использовать
DbContext(который почти всегдаScoped); - он не жил дольше запроса и не держал лишнее состояние;
- не было гонок из-за случайно разделяемого состояния между запросами.
- Singleton + scoped-зависимость = проблема
Если ты зарегистрируешь сервис какSingleton, а он внутри получаетDbContext(scoped), то это либо:
- вообще не запустится (DI ругнётся “Cannot consume scoped service from singleton”), либо
- начнутся очень неприятные баги, если кто-то обойдёт это через
IServiceProviderи сохранит scoped объект “навсегда”.
- Scoped помогает не делать сервисы “случайно stateful”
Singleton заставляет мыслить в терминах потокобезопасности и общего состояния на всё приложение. Scoped проще: сервис создаётся заново на scope, меньше шансов “утечь” памятью через подписки/кэши/коллекции.
Почему в твоём случае Singleton выглядит логичнее
У тебя EventHub — явно “общий автобус событий” внутри процесса. Его нормальная жизнь — всё время работы приложения, значит Singleton подходит.
А вот PublisherService и SubscriberService в твоём примере:
- не держат
DbContext; - не зависят от scoped‑сервисов;
- по сути являются тонкими обёртками над
EventHub.
Поэтому Singleton для них тоже допустим (и даже логичен), если они не хранят пользовательское/запросное состояние и потокобезопасны.
Важный нюанс про HostedService и scoped
HostedService по умолчанию живёт как singleton. Если внутри HostedService тебе нужны scoped‑зависимости (например, репозиторий с DbContext), то правильный паттерн такой:
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 в фоне).
Комментариев нет:
Отправить комментарий