از زمان ASP.NET Core 2.1، قابلیت جدیدی به نام Generic Host، به آن اضافه شدهاست که از آن میتوان برای انجام کارهای متداول پس زمینه، مانند ارسال ایمیلهای خبرنامهی یک برنامه، تهیه فایلهای پشتیبان و غیره استفاده کرد.
Generic Host چیست؟
Generic Host یکی از ویژگیهای جدید ASP.NET Core 2.1 است. هدف آن جداسازی HTTP pipeline برنامه، از Web Host API آن است. یکی از مزایای اینکار، امکان استفادهی از آن نه فقط در پروژههای وب، بلکه در پروژههای کنسول نیز میباشد. به این ترتیب میتوان کارهای غیر HTTP را از برنامهی وب مجزا کرد تا به کارآیی بیشتری رسید و برای این منظور اینترفیس IHostedService را که در فضای نام Microsoft.Extensions.Hosting قرار دارد، برای ثبت کارهای پسزمینهی خارج از اعمال web host جاری، ارائه دادهاند:
namespace Microsoft.Extensions.Hosting
{
public interface IHostedService
{
Task StartAsync(CancellationToken cancellationToken);
Task StopAsync(CancellationToken cancellationToken);
}
}
بنابراین برای ایجاد یک HostedService، نیاز است سرویس کارهای پسزمینهی ما، اینترفیس IHostedService را پیاده سازی کند. متد StartAsync آن جائیاست که تنها یکبار پس از آغاز برنامه اجرا میشود و هدف آن اجرای کار پسزمینهی مدنظر است. متد StopAsync نیز دقیقا پیش از خاتمهی برنامه فراخوانی خواهد شد تا اگر نیاز به پاکسازی منابعی وجود داشته باشد، بتوان از این فرصت استفاده کرد. به این ترتیب اگر نیاز به اجرای متناوب کار پسزمینهای وجود دارد، پیاده سازی آن به خود ما واگذار شدهاست.
یک مثال: معرفی کار پسزمینهای که هر دو ثانیه یکبار انجام میشود
در SampleHostedService زیر، عبارت Hosted service executing به همراه زمان جاری، هر دو ثانیه یکبار لاگ میشود و اگر برنامه را توسط دستور dotnet run اجرا کنید، میتوانید خروجی آنرا در کنسول، مشاهده کنید:
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
namespace MvcTest
{
public class SampleHostedService : IHostedService
{
private readonly ILogger<SampleHostedService> _logger;
public SampleHostedService(ILogger<SampleHostedService> logger)
{
_logger = logger;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Starting Hosted service");
while (!cancellationToken.IsCancellationRequested)
{
_logger.LogInformation("Hosted service executing - {0}", DateTime.Now);
await Task.Delay(TimeSpan.FromSeconds(2), cancellationToken);
}
}
public Task StopAsync(CancellationToken cancellationToken)
{
_logger.LogInformation("Stopping Hosted service");
return Task.CompletedTask;
}
}
}
در ادامه برای معرفی این کار پسزمینه به سیستم به صورت یک سرویس با طول عمر Singleton خواهیم داشت:
namespace MvcTest
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddSingleton<IHostedService, SampleHostedService>();
روش دیگر انجام اینکار استفاده از متد الحاقی AddHostedService است:
services.AddHostedService<SampleHostedService>();
مزیت اینکار این است که متد Configure واقع در کلاس Startup یک چنین امضایی را دارد:
public void Configure(IApplicationBuilder app, IHostingEnvironment env)
و IHostingEnvironment هم در فضای نام Microsoft.AspNetCore.Hosting واقع شدهاست و هم در فضای نام Microsoft.Extensions.Hosting که IHostedService در آن قرار دارد. به همین جهت چون متد AddHostedService، تعریف IHostedService را مخفی میکند، خطای زمان کامپایلی را جهت مشخص سازی صریح فضای نام IHostingEnvironment دریافت نخواهید کرد:
Startup.cs(82,56): error CS0104: 'IHostingEnvironment' is an ambiguous reference between
'Microsoft.AspNetCore.Hosting.IHostingEnvironment' and 'Microsoft.Extensions.Hosting.IHostingEnvironment'
مشکلات پیاده سازی کار پسزمینهی SampleHostedService فوق
هر چند اگر مثال فوق را اجرا کنید، خروجی مناسبی را دریافت خواهید کرد، اما دارای این اشکال مهم نیز هست:
D:\MvcTest>dotnet run
info: MvcTest.SampleHostedService[0]
Starting Hosted service
info: MvcTest.SampleHostedService[0]
Hosted service executing - 02/19/2019 14:45:10
info: MvcTest.SampleHostedService[0]
Hosted service executing - 02/19/2019 14:45:12
info: MvcTest.SampleHostedService[0]
Hosted service executing - 02/19/2019 14:45:14
Ctrl+C
Application is shutting down...
Hosting environment: Development
Content root path: D:\MvcTest
Now listening on: https://localhost:5001
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
پس از اجرای دستور dotnet run، سرویس پس زمینه شروع به کار کردهاست. پس از مدتی کلیدهای Ctrl+C را فشردهایم تا این حلقهی بینهایت و برنامه خاتمه یابد. اینجا است که مشاهده میکنید تازه قسمت هاست برنامهی وب ما شروع به کار کردهاست؛ یعنی دقیقا زمانیکه پروسهی برنامه در حال خاتمه یافتن است. چرا اینگونه رفتار کردهاست؟
از دیدگاه ASP.NET Core، یک کار پس زمینه زمانی خاتمه یافته محسوب میشود که متد StartAsync، مقدار Task.CompletedTask را بازگرداند؛ در غیراینصورت، در حال اجرا درنظر گرفته میشود و چون در پیاده سازی فوق این نکته رعایت نشدهاست، این Task همواره در حال اجرا و خاتمه نیافته محسوب میشود و نوبت به مابقی کارها نخواهد رسید. همچنین در قسمت StopAsync نیز بهتر است یک فیلد CancellationTokenSource تعریف شدهی در سطح کلاس را مورد استفاده قرار داد و متد Cancel آنرا فراخوانی کرد تا اطلاع رسانی صحیحی را به متد StartAsync در مورد خاتمهی برنامه، انجام دهد.
برای این منظور و جهت ساده سازی و پیاده سازی تمام این نکات، از اینترفیس خام IHostedService، یک کلاس abstract به نام BackgroundService نیز در فضای نام Microsoft.Extensions.Hosting پیش بینی شدهاست:
namespace Microsoft.Extensions.Hosting
{
public abstract class BackgroundService : IHostedService, IDisposable
{
protected BackgroundService();
public virtual void Dispose();
public virtual Task StartAsync(CancellationToken cancellationToken);
public virtual Task StopAsync(CancellationToken cancellationToken);
protected abstract Task ExecuteAsync(CancellationToken stoppingToken);
}
}
برای استفادهی از آن تنها کافی است متد ExecuteAsync آنرا پیاده سازی کنیم. به این ترتیب اینبار پیاده سازی SampleHostedService به صورت زیر تغییر میکند:
namespace MvcTest
{
public class PrinterHostedService : BackgroundService
{
private readonly ILogger<SampleHostedService> _logger;
public PrinterHostedService(ILogger<SampleHostedService> logger)
{
_logger = logger;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
_logger.LogInformation("Starting Hosted service");
while (!stoppingToken.IsCancellationRequested)
{
_logger.LogInformation("Hosted service executing - {0}", DateTime.Now);
await Task.Delay(TimeSpan.FromSeconds(2), stoppingToken);
}
}
}
}
اینبار اگر این کار پسزمینه را به سیستم معرفی:
namespace MvcTest
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddHostedService<PrinterHostedService>();
و سپس برنامه را اجرا کنیم:
D:\MvcTest>dotnet run
Hosting environment: Development
infoContent root path: D:\MvcTest
Now listening on: https://localhost:5001
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
: MvcTest.SampleHostedService[0]
Starting Hosted service
info: MvcTest.SampleHostedService[0]
Hosted service executing - 02/19/2019 15:00:23
info: MvcTest.SampleHostedService[0]
Hosted service executing - 02/19/2019 15:00:25
info: MvcTest.SampleHostedService[0]
Hosted service executing - 02/19/2019 15:00:27
Application is shutting down...
^C
مشاهده میکنیم که ابتدا هاست وب برنامه شروع به کار کردهاست و سپس سرویس انجام کارهای پسزمینه در حال اجرا است و به این ترتیب اجرای این سرویس پسزمینه، تداخلی را در کار برنامهی وب ایجاد نکردهاست. بنابراین از این پس بجای استفادهی از IHostedService خام، از نمونهی بهبود یافتهی BackgroundService آن استفاده کنید.
یک نکته: تزریق وابستگی DbContext برنامه در یک سرویس کار پسزمینه
IHostedServiceها با طول عمر singleton به سیستم تزریق وابستگیها معرفی میشوند. در این حالت اگر سرویسهایی با طول عمر transient و یا scoped را به آنها تزریق کنید، دیگر طول عمر مدنظر شما را نداشته و آنها هم به صورت singleton عمل خواهند کرد. هر چند خود سیستم تزریق وابستگیهای NET Core. با صدور استثنائی، از این مساله جلوگیری میکند (در این مورد در مطالب «
مهارتهای تزریق وابستگیها در برنامههای NET Core. - قسمت چهارم - پرهیز از الگوی Service Locator در برنامههای وب» و همچنین «
قسمت سوم - رهاسازی منابع سرویسهای IDisposable» بیشتر بحث شدهاست). یک چنین مواردی را به صورت زیر با تزریق IServiceScopeFactory و ساخت صریح یک Scope میتوان مدیریت کرد:
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
public abstract class ScopedBackgroundService : BackgroundService
{
private readonly IServiceScopeFactory _serviceScopeFactory;
public ScopedBackgroundService(IServiceScopeFactory serviceScopeFactory)
{
_serviceScopeFactory = serviceScopeFactory;
}
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
using (var scope = _serviceScopeFactory.CreateScope())
{
await ExecuteInScope(scope.ServiceProvider, stoppingToken);
}
}
public abstract Task ExecuteInScope(IServiceProvider serviceProvider, CancellationToken stoppingToken);
}
از این پس برای تعریف کارهای پسزمینهای که نیاز به تزریق سرویسهایی با طول عمر Scoped یا Transient دارند، میتوان کلاس سرویس وظیفه را از ScopedBackgroundService مشتق کرد و سپس متد ExecuteInScope آنرا پیاده سازی نمود. serviceProvider ای که در اینجا در اختیار مصرف کننده قرار میگیرد، داخل Scope قرار دارد و توسط آن میتوان سرویسهای مدنظر را توسط متدهایی مانند serviceProvider.GetRequiredService، دریافت کرد.
طراحی سرویس کارهای پسزمینهی زمانبندی شده
ASP.NET Core، متد ExecuteAsync را یکبار بیشتر اجرا نمیکند. بنابراین پیاده سازی تایمری که بخواهد برای مثال ارسال ایمیلهای خبرنامهی سایت را هر روز ساعت 11 شب انجام دهد، به خود ما واگذار شدهاست. برای پیاده سازی بهتر این تایمر میتوان از کتابخانهی
NCrontab که توسط نویسندهی کتابخانهی معروف ELMAH تهیه شدهاست، استفاده کرد که با برنامههای NET Core. نیز سازگاری دارد:
dotnet add package ncrontab
عبارات Cron، روش بسیار متداولی برای تعریف و انجام کارهای زمانبندی شده در سیستمهای لینوکسی هستند. برای مثال عبارت * * * 0 1 سبب اجرای یک وظیفه، هر روز یک دقیقه پس از نیمهشب، میشود و فرمت کلی 5 قسمتی آن، به صورت زیر است:
┌───────────── minute (0 - 59)
│ ┌───────────── hour (0 - 23)
│ │ ┌───────────── day of month (1 - 31)
│ │ │ ┌───────────── month (1 - 12)
│ │ │ │ ┌───────────── day of week (0 - 6) (Sunday to Saturday;
│ │ │ │ │ 7 is also Sunday on some systems)
│ │ │ │ │
│ │ │ │ │
* * * * *
و یا عبارت 6 قسمتی آن چنین مفهومی را دارد:
* * * * * *
- - - - - -
| | | | | |
| | | | | +--- day of week (0 - 6) (Sunday=0)
| | | | +----- month (1 - 12)
| | | +------- day of month (1 - 31)
| | +--------- hour (0 - 23)
| +----------- min (0 - 59)
+------------- sec (0 - 59)
اگر ScopedBackgroundService فوق را با CrontabSchedule یاد شده ترکیب کنیم، میتوانیم به یک کلاس abstract دیگر برسیم که طراحی کلاس پایهی اجرای کارهای زمانبندی شده را ارائه میدهد:
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using NCrontab;
using static NCrontab.CrontabSchedule;
public abstract class ScheduledScopedBackgroundService : ScopedBackgroundService
{
private CrontabSchedule _schedule;
private DateTime _nextRun;
protected abstract string Schedule { get; }
public ScheduledScopedBackgroundService(IServiceScopeFactory serviceScopeFactory)
: base(serviceScopeFactory)
{
_schedule = CrontabSchedule.Parse(Schedule, new ParseOptions { IncludingSeconds = true });
_nextRun = _schedule.GetNextOccurrence(DateTime.Now);
}
public override async Task ExecuteInScope(IServiceProvider serviceProvider, CancellationToken stoppingToken)
{
do
{
var now = DateTime.Now;
if (now > _nextRun)
{
await ScheduledExecuteInScope(serviceProvider, stoppingToken);
_nextRun = _schedule.GetNextOccurrence(DateTime.Now);
}
await Task.Delay(1000, stoppingToken); //1 second delay
}
while (!stoppingToken.IsCancellationRequested);
}
public abstract Task ScheduledExecuteInScope(IServiceProvider serviceProvider, CancellationToken stoppingToken);
}
این کلاس پایه، توسط متد CrontabSchedule.Parse، مقدار رشتهای Schedule را با فرمت Cron (فرمت 6 قسمتی که دارای ثانیه هم هست) دریافت و پردازش میکند. سپس متد GetNextOccurrence، زمان بعدی اجرای این وظیفه را مشخص میکند.
روش استفادهی از آن برای تعریف یک وظیفهی جدید نیز به صورت زیر است:
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Logging;
public class MyScheduledTask : ScheduledScopedBackgroundService
{
private readonly ILogger<MyScheduledTask> _logger;
public MyScheduledTask(
IServiceScopeFactory serviceScopeFactory,
ILogger<MyScheduledTask> logger) : base(serviceScopeFactory)
{
_logger = logger;
}
protected override string Schedule => "*/10 * * * * *"; //Runs every 10 seconds
public override Task ScheduledExecuteInScope(IServiceProvider serviceProvider, CancellationToken stoppingToken)
{
_logger.LogInformation("MyScheduledTask executing - {0}", DateTime.Now);
return Task.CompletedTask;
}
}
در اینجا ابتدا کار با پیاده سازی کلاس پایه ScheduledScopedBackgroundService شروع میشود. سپس باید مقدار Schedule را با فرمت 6 قسمتی مشخص کرد. برای مثال در سرویس فوق، این تنظیم سبب اجرای هر 10 ثانیه یکبار این وظیفه میگردد. در آخر، خود وظیفه داخل متد ScheduledExecuteInScope تعریف خواهد شد که serviceProvider دریافتی آن، داخل یک Scope قرار دارد.
روش معرفی آن به سیستم نیز مانند قبل است:
namespace MvcTest
{
public class Startup
{
public void ConfigureServices(IServiceCollection services)
{
services.AddHostedService<MyScheduledTask>();
در این حالت اگر برنامه را اجرا کنید، یک چنین خروجی را که بیانگر اجرای هر 10 ثانیه یکبار وظیفهی تعریف شدهاست، مشاهده میکنید:
D:\MvcTest>dotnet run
Hosting environment: Development
Content root path: D:\MvcTest
Now listening on: https://localhost:5001
Now listening on: http://localhost:5000
Application started. Press Ctrl+C to shut down.
info: MyScheduledTask[0]
MyScheduledTask executing - 02/19/2019 19:18:50
info: MyScheduledTask[0]
MyScheduledTask executing - 02/19/2019 19:19:00
info: MyScheduledTask[0]
MyScheduledTask executing - 02/19/2019 19:19:10
Application is shutting down...
^C