ASP.NET Core WebAPI中按请求范围执行托管服务及定时后台服务问题
嘿,针对你在ASP.NET Core WebApi里要按请求范围执行托管服务的需求,结合你已经有一个每30秒运行的Singleton后台服务的情况,我给你分两种常见场景来提供解决方案,都是项目里验证过的实用方法:
默认的IHostedService是Singleton生命周期,没法直接注入请求范围(Scoped)的服务。要让你的30秒定时任务能在请求上下文里跑,我们可以借助IServiceScopeFactory手动创建服务范围,在这个范围内使用Scoped服务,相当于模拟一次请求的生命周期。
第一步:改造你的SimpleService
给它注入IServiceScopeFactory,然后在定时执行的逻辑里创建服务范围:
public class SimpleService : BackgroundService { private readonly IServiceScopeFactory _scopeFactory; private readonly ILogger<SimpleService> _logger; // 构造函数注入需要的服务 public SimpleService(IServiceScopeFactory scopeFactory, ILogger<SimpleService> logger) { _scopeFactory = scopeFactory; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { while (!stoppingToken.IsCancellationRequested) { _logger.LogInformation("定时任务启动,创建请求范围..."); // 创建服务范围,这就相当于模拟了一个请求上下文 using var scope = _scopeFactory.CreateScope(); // 从范围里获取你需要的Scoped服务 var scopedBusinessService = scope.ServiceProvider.GetRequiredService<IScopedBusinessService>(); // 在这里执行你的业务逻辑,完全在请求范围内 await scopedBusinessService.DoRequestScopedWorkAsync(); _logger.LogInformation("定时任务执行完成,销毁请求范围"); await Task.Delay(TimeSpan.FromSeconds(30), stoppingToken); } } }
第二步:注册你的Scoped服务
别忘了在Startup.cs的ConfigureServices里把你的请求范围服务注册进去:
services.AddScoped<IScopedBusinessService, ScopedBusinessService>();
这样每次定时任务跑的时候,都会生成一个独立的请求范围,Scoped服务在这个范围内的生命周期和真实请求完全一致,不会有Singleton服务的线程安全问题。
如果你的需求是每进来一个请求,就触发一个后台任务执行(比如异步处理请求的后续逻辑,不阻塞请求响应),那可以用「任务队列 + 后台服务消费」的模式,同样能保证任务在请求范围内执行。
第一步:先搞个任务队列
用来存放每个请求要执行的后台任务:
public interface IBackgroundTaskQueue { // 加入任务到队列 void QueueBackgroundWorkItem(Func<IServiceProvider, CancellationToken, Task> workItem); // 从队列取出任务 Task<Func<IServiceProvider, CancellationToken, Task>> DequeueAsync(CancellationToken stoppingToken); } public class BackgroundTaskQueue : IBackgroundTaskQueue { private readonly Channel<Func<IServiceProvider, CancellationToken, Task>> _queue; public BackgroundTaskQueue(int capacity) { // 设置队列容量,避免请求太多撑爆内存 var options = new BoundedChannelOptions(capacity) { FullMode = BoundedChannelFullMode.Wait }; _queue = Channel.CreateBounded<Func<IServiceProvider, CancellationToken, Task>>(options); } public void QueueBackgroundWorkItem(Func<IServiceProvider, CancellationToken, Task> workItem) { if (workItem == null) throw new ArgumentNullException(nameof(workItem)); _queue.Writer.TryWrite(workItem); } public async Task<Func<IServiceProvider, CancellationToken, Task>> DequeueAsync(CancellationToken stoppingToken) { return await _queue.Reader.ReadAsync(stoppingToken); } }
第二步:写个消费队列的后台服务
这个服务会一直监听队列,有任务就拿出来执行:
public class QueueConsumerHostedService : BackgroundService { private readonly ILogger<QueueConsumerHostedService> _logger; private readonly IBackgroundTaskQueue _taskQueue; private readonly IServiceScopeFactory _scopeFactory; public QueueConsumerHostedService(IBackgroundTaskQueue taskQueue, IServiceScopeFactory scopeFactory, ILogger<QueueConsumerHostedService> logger) { _taskQueue = taskQueue; _scopeFactory = scopeFactory; _logger = logger; } protected override async Task ExecuteAsync(CancellationToken stoppingToken) { _logger.LogInformation("队列消费服务启动,开始监听任务队列..."); while (!stoppingToken.IsCancellationRequested) { // 从队列取任务 var workItem = await _taskQueue.DequeueAsync(stoppingToken); try { // 创建请求范围,执行任务 using var scope = _scopeFactory.CreateScope(); await workItem(scope.ServiceProvider, stoppingToken); } catch (Exception ex) { _logger.LogError(ex, "执行后台任务时出错了"); } } } }
第三步:把这些服务注册到容器
在Startup.cs的ConfigureServices里加这些代码:
// 注册任务队列,设置容量为100(根据你的请求量调整) services.AddSingleton<IBackgroundTaskQueue>(_ => new BackgroundTaskQueue(capacity: 100)); // 注册队列消费的后台服务 services.AddHostedService<QueueConsumerHostedService>(); // 注册你的Scoped业务服务 services.AddScoped<IScopedBusinessService, ScopedBusinessService>();
第四步:加个中间件触发请求任务
写个中间件,每个请求进来时就把任务扔进队列:
public class RequestTriggeredTaskMiddleware { private readonly RequestDelegate _next; private readonly IBackgroundTaskQueue _taskQueue; public RequestTriggeredTaskMiddleware(RequestDelegate next, IBackgroundTaskQueue taskQueue) { _next = next; _taskQueue = taskQueue; } public async Task InvokeAsync(HttpContext context) { // 把请求相关的后台任务加入队列 _taskQueue.QueueBackgroundWorkItem(async (sp, ct) => { var scopedService = sp.GetRequiredService<IScopedBusinessService>(); // 还能获取请求的信息,比如路径、参数 var requestPath = context.Request.Path; await scopedService.DoWorkForRequestAsync(requestPath, ct); }); // 继续处理请求,不阻塞响应 await _next(context); } } // 写个扩展方法方便注册中间件 public static class RequestTriggeredTaskMiddlewareExtensions { public static IApplicationBuilder UseRequestTriggeredBackgroundTasks(this IApplicationBuilder builder) { return builder.UseMiddleware<RequestTriggeredTaskMiddleware>(); } }
最后在Startup.cs的Configure方法里注册这个中间件:
app.UseRequestTriggeredBackgroundTasks();
这样每个请求进来时,都会异步触发一个后台任务,任务在独立的请求范围内执行,既能使用Scoped服务,又不会拖慢请求的响应速度。
几个要注意的点
- 服务范围的释放:用
using var scope创建的范围会在代码块结束时自动释放,一定要这么写,避免Scoped服务没被回收导致内存泄漏。 - 取消令牌的传递:把
stoppingToken传给所有异步方法,这样应用关闭时任务能及时终止,不会出现僵尸任务。 - 队列容量调整:如果你的请求量很大,记得调大队列的容量,或者改成非阻塞的模式,避免请求因为队列满而卡住。
内容的提问来源于stack exchange,提问作者hyvte

