簡體   English   中英

從 ASP.NET Core 中的控制器操作運行后台任務

[英]Run a background task from a controller action in ASP.NET Core

我正在使用 C# 和 ASP.NET Core 2.0 開發帶有 REST API 的 Web 應用程序。

我想要實現的是,當客戶端向端點發送請求時,我將運行與客戶端請求上下文分離的后台任務,如果任務成功啟動,該任務將結束。

我知道有HostedService但問題是HostedService在服務器啟動時啟動,據我所知沒有辦法從控制器手動啟動HostedService

這是一個演示問題的簡單代碼。

[Authorize(AuthenticationSchemes = "UsersScheme")]
public class UsersController : Controller
{
    [HttpPost]
    public async Task<JsonResult> StartJob([FromForm] string UserId, [FromServices] IBackgroundJobService backgroundService)
    {
        // check user account
        (bool isStarted, string data) result = backgroundService.Start();

        return JsonResult(result);
    }
}

您仍然可以將IHostedService作為后台任務的基礎與BlockingCollection結合使用。

BlockingCollection創建包裝器,以便您可以將其作為單例注入。

public class TasksToRun
{
    private readonly BlockingCollection<TaskSettings> _tasks;

    public TasksToRun() => _tasks = new BlockingCollection<TaskSettings>();

    public void Enqueue(TaskSettings settings) => _tasks.Add(settings);

    public TaskSettings Dequeue(CancellationToken token) => _tasks.Take(token);
}

然后在IHostedService實現中“偵聽”任務,並在任務“到達”時執行它。
如果集合為空, BlockingCollection將停止執行 - 因此您的while循環不會消耗處理器時間。
.Take方法接受cancellationToken .Take作為參數。 使用令牌,您可以在應用程序停止時取消“等待”下一個任務。

public class BackgroundService : IHostedService
{
    private readonly TasksToRun _tasks;

    private CancellationTokenSource _tokenSource;

    private Task _currentTask;

    public BackgroundService(TasksToRun tasks) => _tasks = tasks;

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        _tokenSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
        while (cancellationToken.IsCancellationRequested == false)
        {
            try
            {
                var taskToRun = _tasks.Dequeue(_tokenSource.Token);

                // We need to save executable task, 
                // so we can gratefully wait for it's completion in Stop method
                _currentTask = ExecuteTask(taskToRun);               
                await _currentTask;
            }
            catch (OperationCanceledException)
            {
                // execution cancelled
            }
        }
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        _tokenSource.Cancel(); // cancel "waiting" for task in blocking collection

        if (_currentTask == null) return;

        // wait when _currentTask is complete
        await Task.WhenAny(_currentTask, Task.Delay(-1, cancellationToken));
    }
}

在控制器中,您只需將要運行的任務添加到我們的集合中

public class JobController : Controller
{
    private readonly TasksToRun _tasks;

    public JobController(TasksToRun tasks) => _tasks = tasks;

    public IActionResult PostJob()
    {
        var settings = CreateTaskSettings();

        _tasks.Enqueue(settings);

        return Ok();
    }
}

用於阻塞收集的包裝器應注冊為單例依賴注入

services.AddSingleton<TasksToRun, TasksToRun>();

注冊后台服務

services.AddHostedService<BackgroundService>();

微軟在https://docs.microsoft.com/en-us/aspnet/core/fundamentals/host/hosted-services?view=aspnetcore-2.1記錄了相同的內容

它使用BackgroundTaskQueue 來完成,它從Controller 分配工作,工作由派生自BackgroundService 的QueueHostedService 執行。

這受到了skjagini's answer 中鏈接的文檔的極大啟發,並進行了一些改進。

我認為在此處重申整個示例可能會有所幫助,以防鏈接在某些時候中斷。 我做了一些調整; 最值得注意的是,我注入了一個IServiceScopeFactory ,以允許后台進程自己安全地請求服務。 我在這個答案的最后解釋了我的推理。


核心思想是創建一個任務隊列,用戶可以將其注入到他們的控制器中,然后將任務分配給它。 長期運行的托管服務中存在相同的任務隊列,該服務一次出列一個任務並執行它。

任務隊列:

public interface IBackgroundTaskQueue
{
    // Enqueues the given task.
    void EnqueueTask(Func<IServiceScopeFactory, CancellationToken, Task> task);

    // Dequeues and returns one task. This method blocks until a task becomes available.
    Task<Func<IServiceScopeFactory, CancellationToken, Task>> DequeueAsync(CancellationToken cancellationToken);
}

public class BackgroundTaskQueue : IBackgroundTaskQueue
{
    private readonly ConcurrentQueue<Func<IServiceScopeFactory, CancellationToken, Task>> _items = new();

    // Holds the current count of tasks in the queue.
    private readonly SemaphoreSlim _signal = new SemaphoreSlim(0);

    public void EnqueueTask(Func<IServiceScopeFactory, CancellationToken, Task> task)
    {
        if(task == null)
            throw new ArgumentNullException(nameof(task));

        _items.Enqueue(task);
        _signal.Release();
    }

    public async Task<Func<IServiceScopeFactory, CancellationToken, Task>> DequeueAsync(CancellationToken cancellationToken)
    {
        // Wait for task to become available
        await _signal.WaitAsync(cancellationToken);

        _items.TryDequeue(out var task);
        return task;
    }
}

在任務隊列的核心,我們有一個線程安全的ConcurrentQueue<> 由於我們不想在新任務可用之前輪詢隊列,因此我們使用SemaphoreSlim對象來跟蹤隊列中當前的任務數。 每次我們調用Release ,內部計數器都會遞增。 WaitAsync方法會阻塞,直到內部計數器大於 0,然后將其遞減。

為了出隊和執行任務,我們創建了一個后台服務:

public class BackgroundQueueHostedService : BackgroundService
{
    private readonly IBackgroundTaskQueue _taskQueue;
    private readonly IServiceScopeFactory _serviceScopeFactory;
    private readonly ILogger<BackgroundQueueHostedService> _logger;

    public BackgroundQueueHostedService(IBackgroundTaskQueue taskQueue, IServiceScopeFactory serviceScopeFactory, ILogger<BackgroundQueueHostedService> logger)
    {
        _taskQueue = taskQueue ?? throw new ArgumentNullException(nameof(taskQueue));
        _serviceScopeFactory = serviceScopeFactory ?? throw new ArgumentNullException(nameof(serviceScopeFactory));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }

    protected override async Task ExecuteAsync(CancellationToken stoppingToken)
    {
        // Dequeue and execute tasks until the application is stopped
        while(!stoppingToken.IsCancellationRequested)
        {
            // Get next task
            // This blocks until a task becomes available
            var task = await _taskQueue.DequeueAsync(stoppingToken);

            try
            {
                // Run task
                await task(_serviceScopeFactory, stoppingToken);
            }
            catch(Exception ex)
            {
                _logger.LogError(ex, "An error occured during execution of a background task");
            }
        }
    }
}

最后,我們需要讓我們的任務隊列可用於依賴注入,並啟動我們的后台服務:

public void ConfigureServices(IServiceCollection services)
{
    // ...
    
    services.AddSingleton<IBackgroundTaskQueue, BackgroundTaskQueue>();
    services.AddHostedService<BackgroundQueueHostedService>();
    
    // ...
}

我們現在可以將后台任務隊列注入我們的控制器並使任務入隊:

public class ExampleController : Controller
{
    private readonly IBackgroundTaskQueue _backgroundTaskQueue;

    public ExampleController(IBackgroundTaskQueue backgroundTaskQueue)
    {
        _backgroundTaskQueue = backgroundTaskQueue ?? throw new ArgumentNullException(nameof(backgroundTaskQueue));
    }

    public IActionResult Index()
    {
        _backgroundTaskQueue.EnqueueTask(async (serviceScopeFactory, cancellationToken) =>
        {
            // Get services
            using var scope = serviceScopeFactory.CreateScope();
            var myService = scope.ServiceProvider.GetRequiredService<IMyService>();
            var logger = scope.ServiceProvider.GetRequiredService<ILogger<ExampleController>>();
            
            try
            {
                // Do something expensive
                await myService.DoSomethingAsync(cancellationToken);
            }
            catch(Exception ex)
            {
                logger.LogError(ex, "Could not do something expensive");
            }
        });

        return Ok();
    }
}

為什么使用IServiceScopeFactory

理論上,我們可以直接使用我們注入控制器的服務對象。 這可能適用於單例服務,也適用於大多數范圍服務。

但是,對於實現IDisposable范圍服務(例如DbContext ),這可能會中斷:在將任務入隊后,控制器方法返回並且請求完成。 然后框架清理注入的服務。 如果我們的后台任務足夠慢或延遲,它可能會嘗試調用已處理服務的方法,然后會遇到錯誤。

為了避免這種情況,我們排隊的任務應該始終創建自己的服務范圍,並且不應該使用來自周圍控制器的服務實例。

您可以在ThreadPool使用另一個線程:

排隊執行的方法。 該方法在線程池線程可用時執行。

public class ToDoController : Controller
{
    private readonly IServiceScopeFactory _serviceScopeFactory;
    public ToDoController(IServiceScopeFactory serviceScopeFactory)
    {
        _serviceScopeFactory = serviceScopeFactory;
    }
    public string Index()
    {
        ThreadPool.QueueUserWorkItem(delegate {
            // Get services
            using var scope = _serviceScopeFactory.CreateScope();
            var toDoSvc= scope.ServiceProvider.GetRequiredService<IToDoService>();
            DoHeavyWork(toDoSvc);
        });
        return "Immediate Response";
    }
}

暫無
暫無

聲明:本站的技術帖子網頁,遵循CC BY-SA 4.0協議,如果您需要轉載,請注明本站網址或者原文地址。任何問題請咨詢:yoyou2525@163.com.

 
粵ICP備18138465號  © 2020-2024 STACKOOM.COM