.NET CORE QuartzJob定時任務+Windows/Linux部署

前言

以前總結過一篇基於Quartz+Topshelf+.netcore實現定時任務Windows服務 //www.cnblogs.com/gt1987/p/11806053.html。回顧起來發現有點野路子的感覺,沒有使用.netcore推薦的基於 HostedService 的方式,也沒有體現.net core跨平台的風格。於是重新寫了一個Sample。

Work Service

首先搭建項目框架。

  • 版本 .netcore3.1
  • 建立一個Console程式項目模板,修改 project 屬性為 <Project Sdk="Microsoft.NET.Sdk.Worker">
  • Nuget引入 Microsoft.Extensions.Hosting,支援配置+Logging+注入等基本框架內容。
  • Nuget引入 Quartz.Jobs 組件。

後來發現vs2019實際有一個 Worker Service 項目模板,直接選擇建立即可,不用上面這麼麻煩~~

QuartzJob、HostedService集成

集成的主要思路為以 HostedService 作為服務承載,啟動的時候 載入 QuartzJob 定時任務配置並啟動。而 HostedService 則自動接入.netcore服務程式體系。
由於 QuartzJob 暫沒有專門的.netcore版本,這裡我們首先要作下特別處理,實現一個JobFactory用於集成.net core依賴注入框架。

public class MyJobFactory : IJobFactory
{
    private readonly IServiceProvider _serviceProvider;

    public MyJobFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider;
    }

    public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
    {
        return _serviceProvider.GetRequiredService(bundle.JobDetail.JobType) as IJob;
    }

    public void ReturnJob(IJob job)
    {
        //IJob已經在.net core容器體系下,應該考慮通過DI的方式 dispose
        //IJob對象的銷毀 if implement IDisposable
        //var dispose = job as IDisposable;
        //dispose?.Dispose();
    }
}

定義一個SampleJob:

[DisallowConcurrentExecution]
public class SampleJob : IJob
{
    private readonly ILogger<SampleJob> _logger;

    public SampleJob(ILogger<SampleJob> logger)
    {
        _logger = logger;
    }

    public void Dispose()
    {
        _logger.LogInformation($"{nameof(SampleJob)} disposed.");
    }

    public async Task Execute(IJobExecutionContext context)
    {
        _logger.LogInformation($"{nameof(SampleJob)} executed at {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}");
        await Task.CompletedTask;
    }
}

定義自己的HostedService,在Start方法裡面配置了定時任務並啟動

public class QuartzJobHostedService : IHostedService
{
    private readonly IScheduler _scheduler;
    private readonly ILogger<QuartzJobHostedService> _logger;

    public QuartzJobHostedService(IScheduler scheduler,
        ILogger<QuartzJobHostedService> logger)
    {
        _scheduler = scheduler;
        _logger = logger;
    }

    public async Task StartAsync(CancellationToken cancellationToken)
    {
        //just for sample test,should configuration in config file when dev
        //sample job 
        var job = CreateJob(typeof(SampleJob));
        var trigger = CreateTrigger("SampleJob", "0/5 * * * * ?");
        await _scheduler.ScheduleJob(job, trigger, cancellationToken);

        //disposed job
        var job2 = CreateJob(typeof(DisposedSampleJob));
        var trigger2 = CreateTrigger("DisposeSampleJob", "0/10 * * * * ?");
        await _scheduler.ScheduleJob(job2, trigger2, cancellationToken);

        await _scheduler.Start(cancellationToken);

        _logger.LogInformation("jobScheduler started.");
    }

    public async Task StopAsync(CancellationToken cancellationToken)
    {
        await _scheduler?.Shutdown(cancellationToken);

        _logger.LogInformation("jobScheduler stoped.");
    }


    private ITrigger CreateTrigger(string name, string cronExpression)
    {
        return TriggerBuilder
            .Create()
            .WithIdentity($"{name}.trigger")
            .WithCronSchedule(cronExpression)
            .Build();
    }

    private IJobDetail CreateJob(Type jobType)
    {
        return JobBuilder
            .Create(jobType)
            .WithIdentity(jobType.FullName)
            .WithDescription(jobType.Name)
            .Build();
    }
}

最後我們看一下服務的啟動配置,注意IScheduler和IJobFactory的生命周期,這裡由於 StdSchedulerFactory.GetDefaultScheduler() 的原因,必須是Singleton來兼容。

class Program
{
    static void Main(string[] args)
    {
        var host = CreateHostBuilder(args).Build();
        host.Run();
    }


    public static IHostBuilder CreateHostBuilder(string[] args) =>
        Microsoft.Extensions.Hosting.Host.CreateDefaultBuilder(args)
            .ConfigureLogging(loggingBuilder =>
            {
                loggingBuilder.AddConsole();
                loggingBuilder.SetMinimumLevel(LogLevel.Information);
            })
            .ConfigureServices((context, services) =>
            {
                services.AddTransient<SampleJob>()
                    .AddTransient<DisposedSampleJob>()
                    .AddTransient<IDisposableService, DisposableService>()
                    .AddSingleton<IJobFactory, MyJobFactory>()
                    .AddSingleton<IScheduler>(sp =>
                    {
                        var scheduler = StdSchedulerFactory.GetDefaultScheduler().ConfigureAwait(false).GetAwaiter().GetResult();
                        scheduler.JobFactory = sp.GetRequiredService<IJobFactory>();
                        return scheduler;
                    })
                    .AddHostedService<QuartzJobHostedService>();
            })
            //if install by topshelf,don't need this
            .UseWindowsService();

}

這樣一個簡單的定時任務服務就搭建完成了,可以本地啟動運行。但是如果要部署到伺服器上作為windows服務或者linux服務,還需要作一點額外的工作。

IDisposable 問題

這裡插入另外一個話題,我們看到 IJobFactory 有一個 ReturnJob 方法用於處理Job對象的資源釋放問題。但是我們知道在.netcore依賴注入體系下,任何通過注入獲取的對象一定不能通過自己手動方式來處理資源釋放問題。參考官方文檔 //docs.microsoft.com/zh-cn/aspnet/core/fundamentals/dependency-injection?view=aspnetcore-3.1#design-services-for-dependency-injection。那麼如何處理需要手動釋放例如 IDisposable 的問題呢?
這裡我們建立一個繼承 IDisposable 介面服務

//通常情況下 不應該將IDisposebale介面 註冊為 Transient or Scope。改用工廠模式創建
public interface IDisposableService : IDisposable
{
}

public class DisposableService : IDisposableService
{
    private ILogger<DisposableService> _logger;

    public DisposableService(ILogger<DisposableService> logger)
    {
        _logger = logger;
    }

    public void Dispose()
    {
        _logger.LogInformation($"{nameof(DisposableService)} has disposed at {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}.");
    }
}

依賴IDisposableService的SampleJob:

/// <summary>
/// 需要dispose
/// 1.IDisposableService可以註冊為Singleton,會自動dispose
/// 2.如果不能註冊單例,則如本例方式通過IServiceProvider.CreateScope方式處理
/// </summary>
[DisallowConcurrentExecution]
public class DisposedSampleJob : IJob
{
    private readonly ILogger<DisposedSampleJob> _logger;
    private readonly IServiceProvider _serviceProvider;

    public DisposedSampleJob(ILogger<DisposedSampleJob> logger,
        IServiceProvider serviceProvider)
    {
        _logger = logger;
        _serviceProvider = serviceProvider;
    }

    public async Task Execute(IJobExecutionContext context)
    {
        //if IDisposableService register Transient,use CreateScope to dispose IDisposableService 
        //if IDisposableService register singleton,it can be inject directly and dispose automatically
        using (var scope = _serviceProvider.CreateScope())
        {
            var service = scope.ServiceProvider.GetRequiredService<IDisposableService>();
            _logger.LogInformation($"{nameof(DisposedSampleJob)} executed at {DateTime.Now.ToString("yyyy-MM-dd HH:mm:ss")}.");
            await Task.CompletedTask;
        }
    }
}

這裡如注釋,要麼將 IDisposableService 註冊為單例,直接注入引用,.net core DI框架會自動處理資源釋放。如果某些原因不能註冊單例,則需要採取不太推薦的 定位器模式,如上面程式碼中實現方式來處理。

部署windows服務

如果要部署到windows服務,則還需要引入 Microsoft.Extensions.Hosting.WindowsServices組件,在構建 IHostBuilder的時候加入 UseWindowsService()即可。它主要的功能是將整個系統的生命周期接入windows服務的生命周期。(默認的是console控制台程式生命周期)。

然後就是煩人的部署到windows服務。這裡提供了install和uninstall2個腳本,使用的是window sc工具。

install.bat

set serviceName=QuartzJob.Sample.JobService
set serviceFilePath=F:\gt_work\MyProject\git_project\gt.SomeSamples\QuartzJob.Sample\bin\Release\netcoreapp3.1\QuartzJob.Sample.exe
set serviceDescription=sample job

sc create %serviceName%  BinPath=%serviceFilePath%
sc config %serviceName%    start=auto  
sc description %serviceName%  %serviceDescription%
sc start  %serviceName%
pause

uninstall.bat

set serviceName=QuartzJob.Sample.JobService

sc stop   %serviceName% 
sc delete %serviceName% 

pause

通過管理員許可權啟動即可正常部署和卸載windows服務

部署Linux服務

如果要部署到Linux服務的話,我查到的有兩種方式,一種是Systemd方式,需要引入 Microsoft.Extensions.Hosting.Systemd組件,加入 UseSystemd。另一種方式使用SuperVisor來創建服務。這裡我嘗試使用了SuperVisor來實現。

我的Linux版本是Centos7,這裡剛開始我找了一台Centos6的機器,在安裝.netcore sdk這步就走不下去了,這裡注意下。

  • 註冊microsoft密鑰 sudo rpm -Uvh //packages.microsoft.com/config/centos/7/packages-microsoft-prod.rpm

  • 安裝.netcore sudo yum install dotnet-sdk-3.1

  • 安裝SuperVisor yum install -y supervisor,這裡也許要安裝依賴 yum install epel-release

  • 配置啟動,在/etc/supervisord.d/ 新建 QuartzJob.Sample.ini 配置文件。directory指向到發布包目錄。

      [program:QuartzJob.Sample]
      command=dotnet QuartzJob.Sample.dll
      directory=~/gt/QuartzJob.Sample
      environment=ASPNETCORE__ENVIRONMENT=Production
      user=root
      stopsignal=INT
      autostart=false
      autorestart=false
      startsecs=1
      stderr_logfile=/var/log/quartzJob.err.log
      stdout_logfile=/var/log/quartzJob.out.log
    

這麼做的原因可以查看 /etc/supervisord.conf 配置中這一段 files=supervisord.d/*ini,表示默認載入啟動supervisord.d目錄下的 .ini文件配置

  • 啟動SuperVisor,sudo service supervisord start。服務正常啟動

集成Topshelf

Topshelf組件在framework時代是一款非常方便生成服務windows服務的工具,可以通過程式碼的方式配置並生成windows服務。可惜目前沒有看到.netcore的配合版本。且由於依賴windows系統,似乎不太切合.netcore跨平台的特性。這裡給出集成的方式,只適用windows平台。

    static void Main(string[] args)
    {
        var host = CreateHostBuilder(args).Build();

        HostFactory.Run(x =>
        {
            x.Service<IHost>(s =>
            {
                s.ConstructUsing(n => host);
                s.WhenStarted(tc => tc.StartAsync());
                s.WhenStopped(tc => tc.StopAsync().Wait());
            });
            x.RunAsLocalSystem();

            x.SetDisplayName("Quartz.Sample.JobService");
            x.SetServiceName("Quartz.Sample.JobService");
        });
    }

install 命令:

  • .\QuartzJob.Sample.exe install
  • .\QuartzJob.Sample.exe start

uninstall 命令:

  • .\QuartzJob.Sample.exe stop
  • .\QuartzJob.Sample.exe uninstall

這裡的原理就是用Topshelf的Host替代.netcore的Host,在Topshelf Host啟動時再啟動.netcore Host。反正看著很變扭。
另外特別注意 s.WhenStarted(tc => tc.StartAsync()); 這裡使用的是StartAsync方法而不是Start方法,因為Start方法是同步堵塞的,在部署到windows服務時,由於這一步堵塞,會導致windows服務一直卡在啟動狀態直至超時啟動失敗。