在.NET Core中,从控制器运行单个后台任务的最简单方法是什么?

30
我有一个ASP.NET Core Web应用程序,其中包含WebAPI控制器。我想要做的就是在某些控制器中启动后台进程,但是该控制器在任务完成之前应该继续返回。我不希望服务的使用者等待该任务完成。
我已经看过所有有关IHostedService和BackgroundService的帖子,但似乎都不是我想要的。此外,所有这些示例都向您展示如何设置事物,但没有展示如何实际调用它,或者我不理解一些内容。
我尝试过这些方法,但是在Startup中注册IHostedService时,它会立即运行。这不是我想要的。我不想在启动时运行任务,而是在需要时从控制器中调用它。此外,我可能有几个不同的任务,因此只注册services.AddHostedService()将无法工作,因为我可能有一个MyServiceB和MyServiceC,那么我如何从控制器中获取正确的服务(我不能注入IHostedService)?
总的来说,我看到的所有内容都是一堆杂乱无章的代码,而这似乎是一个非常简单的问题。我错过了什么?

你尝试过使用异步方法吗?(文档:https://learn.microsoft.com/zh-cn/dotnet/csharp/programming-guide/concepts/async/) - mrbitzilla
你不会直接调用 IHostedService,而是以某种方式向其发送消息。文档中的一个默认示例 - Queued background tasks(在这种情况下,Func<CancellationToken, Task> workItem 是消息)。 - Guru Stron
“这应该是一件很简单的事情”,这样会很好,但实际上它并不简单。当代码尝试运行时,没有任何保证进程能够持续到足以让代码完成的时间,因此确保代码可预测地完成是非常困难的。 - Alexei Levenkov
这个回答解决了你的问题吗?在ASP.NET Core中从控制器操作运行后台任务 - janw
3个回答

17
您有以下选项:
1. IHostedService类可以是长时间运行的方法,可以在您的应用程序的生命周期内后台运行。为了使它们处理某种类型的后台任务,您需要在应用程序中实现一些“全局”队列系统,以便控制器可以存储数据/事件。这个队列系统可以简单到一个Singleton类,其中包含一个ConcurrentQueue,您将其传递给您的控制器,或者像IDistributedCache或更复杂的外部发布/订阅系统一样。然后,您只需在IHostedService中轮询队列并根据其运行某些操作即可。这是Microsoft处理队列的IHostedService实现示例:https://learn.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-3.1&tabs=visual-studio#queued-background-tasks 请注意,Singleton类方法在多服务器环境中可能会出现问题。 Singleton方法的示例实现可以如下:
// Needs to be registered as a Singleton in your Startup.cs
public class BackgroundJobs {
  public ConcurrentQueue<string> BackgroundTasks {get; set;} = new ConcurrentQueue<string>();
}

public class MyController : ControllerBase{
  private readonly BackgroundJobs _backgroundJobs;
  public MyController(BackgroundJobs backgroundJobs) {
    _backgroundJobs = backgroundJobs;
  }

  public async Task<ActionResult> FireAndForgetEndPoint(){
    _backgroundJobs.BackgroundTasks.Enqueue("SomeJobIdentifier");
  }
}

public class MyBackgroundService : IHostedService {
  private readonly BackgroundJobs _backgroundJobs;
  public MyBackgroundService(BackgroundJobs backgroundJobs)
  {
    _backgroundJobs = backgroundJobs;
  }

  public void StartAsync(CancellationToken ct)
  {
    while(!ct.IsCancellationRequested)
    {
      if(_backgroundJobs.BackgroundTasks.TryDequeue(out var jobId))
      {
        // Code to do long running operation
      }
    Task.Delay(TimeSpan.FromSeconds(1)); // You really don't want an infinite loop here without having any sort of delays.
    }
  }
}
  1. 创建一个返回Task的方法,在该方法中传入IServiceProvider,并在其中创建新的作用域,以确保当控制器操作完成时ASP.NET不会终止任务。示例如下:
IServiceProvider _serviceProvider;

public async Task<ActionResult> FireAndForgetEndPoint()
{
  // Do stuff
  _ = FireAndForgetOperation(_serviceProvider);
  Return Ok();
}

public async Task FireAndForgetOperation(IServiceProvider serviceProvider)
{
  using (var scope = _serviceProvider.CreateScope()){
    await Task.Delay(1000);
    //... Long running tasks
  }
}

更新: 这里是微软类似操作的示例: https://learn.microsoft.com/en-us/aspnet/core/performance/performance-best-practices?view=aspnetcore-3.1#do-not-capture-services-injected-into-the-controllers-on-background-threads

1
我看过微软的“Queued Background Tasks”示例,但我无法弄清如何更改以适应我的目的。我不想要MonitorLoop,并且我无法弄清如何从我的控制器手动排队任务。实际上,在这一点上,我非常想像你最初建议的那样使用Task.Run,因为我有一种感觉它是“不良实践”的唯一原因是它仍然占用相同的CPU周期。但在我的情况下,这没关系。我可以接受它占用相同的周期,我只是想在启动之前返回给用户。 - RebelScum
"MonitorLoop"在其中是一个示例。您不需要实现它。您需要的唯一的事情就是拥有至少一个属性作为任务队列的类,并将该类注册为单例,使用依赖注入将其注入到控制器和"BackgroundService"中。 - NavidM
3
"Task.Run" 是不良做法,因为这意味着你没有充分利用 DI(依赖注入)。实际上,如果您在 HttpRequest 范围内使用它,在后台任务中,您可能会使用一个已释放的对象并收到异常。因此,这将给您的应用程序引入不确定行为。 - Emy Blacksmith
1
是的,你必须创建作用域并手动从中检索对象,否则,像Ramen说的那样,事物可能会被释放。就个人而言,我认为这是一种代码异味,但这是一种方法来做到这一点。 - NavidM

17

根据您的问题,我理解您想创建一个类似于往数据库记录日志这样的 fire and forget 任务。在这种情况下,您不必等待日志插入到数据库中。为了找到一个容易实现的解决方案,我也花了很多时间。这是我找到的:

在您的控制器参数中添加 IServiceScopeFactory。这不会影响请求正文或头信息。之后创建一个作用域并通过它调用您的服务。

[HttpPost]
public IActionResult MoveRecordingToStorage([FromBody] StreamingRequestModel req, [FromServices] IServiceScopeFactory serviceScopeFactory)
{
    // Move record to Azure storage in the background
    Task.Run(async () => 
    {
        try
        {
            using var scope = serviceScopeFactory.CreateScope();
            var repository = scope.ServiceProvider.GetRequiredService<ICloudStorage>();
            await repository.UploadFileToAzure(req.RecordedPath, key, req.Id, req.RecordCode);
        }
        catch(Exception e)
        {
            Console.WriteLine(e);
        }
    });
    return Ok("In progress..");
}

发布请求后,您将立即收到“进行中...”文本,但任务将在后台运行。

还有一件事,如果您不以这种方式创建任务并尝试调用数据库操作,则会收到此类错误,这意味着您的数据库对象已死亡,并且您正在尝试访问它;

无法访问已处理的对象。此错误的常见原因是处置从依赖项注入解析的上下文,然后稍后尝试在应用程序的其他地方使用相同的上下文实例。如果您在上下文上调用Dispose()或将上下文包装在using语句中,则可能会发生这种情况。如果您使用依赖项注入,则应让依赖项注入容器负责处理上下文实例。\r\n对象名称:“DBContext”。

我的代码基于存储库模式。您不应忘记在< strong>Startup.cs中注入服务类。

services.AddScoped<ICloudStorage, AzureCloudStorage>();

在这里找到详细的文档


7
什么是在.NET Core控制器中运行单个后台任务的最简单方法?
我不希望服务的使用者必须等待此作业完成。
问题在于ASP.NET是编写Web服务的框架,这些服务是响应请求的应用程序。但是,一旦您的代码说“我不希望服务的使用者必须等待”,那么您就在谈论在请求之外运行代码(即请求外部代码)。这就是为什么所有解决方案都很复杂的原因:您的代码必须绕过/扩展框架本身,试图强制其执行其未设计执行的操作。

唯一适当的请求外部代码的解决方案是使用一个持久化队列和独立的后台进程。任何在进程内部的解决方案(例如,带有IHostedServiceConcurrentQueue)都会存在可靠性问题;特别是这些解决方案偶尔会丢失工作。


网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接