只有累積,沒有奇蹟

2021年9月29日 星期三

[NETCore] ASP.NET Core 建立排程服務 - 使用 Generic Host 搭配 Quartz.Net - Part 3

前言
這是 ASP.NET Core 建立排程服務 - 使用 Generic Host 搭配 Quartz.Net 系列文第三篇,這系列文的目標是使用 ASP.NET Core Generic Host 搭配排程套件 Quartz.Net,程式註冊為 Windows Service 服務執行,
第一篇介紹了 Gereric Host 泛型主機的基本設定,第二篇介紹 ASP.NET Core 與 Quartz.Net 的整合,接下來這一篇是要將開發好的應用程式註冊為 Windows Service,若有問題或是錯誤的地方歡迎各位高手給予指導
GenericHostLab SampleCode 傳送門 : http://bit.ly/2Y1mqYN
ServiceBase
我們的目標是將開發完的應用程式註冊使用 Windows Service 來執行,因此在 Service 執行的動作像是啟動、停止、關閉等動作都會希望知道並透過 log 記錄下來,才可以更清楚的瞭解其運行方式與錯誤時偵錯,在 ASP.NET 中可以透過 system.serviceprocess 來取得 Service 資訊,透過指令到 nuget 下載此套件
Install-Package System.ServiceProcess.ServiceController -Version 4.5.0
接著新增一個類別 ServiceBaseLifetime 繼承 system.ServiceProcess 命名空間中的 ServiceBase 類別與實作 IHostLifetime 介面,在覆寫 serviceBase 方法像是 OnStart、OnStop 以及 run 等,並在希望關注的方法中加上 log 方便蒐集更多 Service 執行時的 log 資訊,代碼如下
namespace GenericHostLab.Service
{
    public class ServiceBaseLifetime : ServiceBase, IHostLifetime
    {
        private readonly TaskCompletionSource<object> delayStart = new TaskCompletionSource<object>();

        public ServiceBaseLifetime(IApplicationLifetime applicationLifetime)
        {
            ApplicationLifetime = applicationLifetime ?? throw new ArgumentNullException(nameof(applicationLifetime));
        }

        private IApplicationLifetime ApplicationLifetime { get; }

        public Task WaitForStartAsync(CancellationToken cancellationToken)
        {
            cancellationToken.Register(() => delayStart.TrySetCanceled());
            ApplicationLifetime.ApplicationStopping.Register(Stop);
            ApplicationLifetime.ApplicationStopped.Register(Stop);
            new Thread(Run).Start();
            return delayStart.Task;
        }

        public Task StopAsync(CancellationToken cancellationToken)
        {
            Stop();
            return Task.CompletedTask;
        }

        private void Run()
        {
            try
            {
                Run(this);
                delayStart.TrySetException(new InvalidOperationException("Stopped without starting"));
            }
            catch (Exception ex)
            {
                delayStart.TrySetException(ex);
            }
        }

        protected override void OnStart(string[] args)
        {
            delayStart.TrySetResult(null);
            base.OnStart(args);
        }
        protected override void OnStop()
        {
            ApplicationLifetime.StopApplication();
            base.OnStop();
        }

        protected override void OnShutdown()
        {
            base.OnShutdown();
        }
    }
}
新增 ServiceBaseLifetime 類別後,下一步可以針對 IHostBuilder 加入擴充方法,方便在 Main 中直接使用,在 Service 中新增 ServiceBaseLifetimeHostExtensions 類別,代碼如下
namespace GenericHostLab.Service
{
    public static class ServiceBaseLifetimeHostExtensions
    {
        private static IHostBuilder UseServiceBaseLifetime(this IHostBuilder hostBuilder)
        {
            return hostBuilder.ConfigureServices((hostContext, services) => services.AddSingleton<IHostLifetime, ServiceBaseLifetime>());
        }

        public static Task RunAsServiceAsync(this IHostBuilder hostBuilder, CancellationToken cancellationToken = default)
        {
            return hostBuilder.UseServiceBaseLifetime().Build().RunAsync(cancellationToken);
        }
    }
} 
其中 UseServiceBaseLifetime 將剛剛新增的 ServiceBaseLifetime 服務註冊到 DI 容器中,公開方法為 RunAsServiceAsync 提供外面使用,因此可以在 main 方法中直接使用  RunAsServiceAsync ,代碼如下
static async Task Main(string[] args)
{
    var isService = !(Debugger.IsAttached || args.ToList().Contains("--console"));

    try
    {
        var builder = CreateHostBuilder();

        if (isService)
        {
            await builder.RunAsServiceAsync();
        }
        else
        {
            await builder.RunConsoleAsync();
        }
    }
    catch (Exception e)
    {
        Console.WriteLine(e);
        throw;
    }
}
代碼中第三行是取得是否使用 debug 來執行,第七行為執行一開始介紹的 CreateHostBuilder 產生 HostBuilder,接著在根據 isService 是否為 windows service 來決定執行的方式,如果是 debug 的話會 RunConsoleAsync ,如果是透過 Service 執行則是跑自定義的擴充方法 RunAsServiceAsync。

Publish
在這一步中將透過 Visual Studio 內建的部署功能將應用程式部署到指定資料夾,在專案檔點擊右鍵按下 publish 功能,會請你選擇要發布的位置的方式是 Azure 或是 資料夾,以這次的範例中要建立 Windows Service,因此選擇指定資料夾 D:\Web\GenericHostedService
在部署的過程中也可以設定發布相關的 Release 資訊,像是 Debug 或是 Release Mode、發佈時的 ASP.NET Core 版本、Runtime 環境以及要發布到的路徑等設定,由於應用程式排程要擺放的主機 OS 環境為 Windows,因此 runtime 選擇 win64 
確認設定無誤之後,就可以按下 publish 將應用程式發布到指定的資料夾中,待發布完成之後我們可以來到指定的資料夾確認是否有發佈成功,由於剛剛所選擇的 runtime 環境是 Windows 因此可以在資料夾底下看到 ProjectName.exe 執行檔,如果環境選擇的是 Linux 則不會有 exe 只會有 .dll,這邊可以點擊 .exe 看看 Console 執行檔是否正常執行
如果發現部署的結果沒有執行檔,可以開啟專案檢查看看是否有遺漏對應的 property,在 Visual Studio 2019 快速開啟的方式是直接點專案就可以開啟 .csproj,舊的 Visual Studio 版本則是透過找到檔案後再開啟方式確認
<PropertyGroup>
  <OutputType>Exe</OutputType>
  <TargetFramework>netcoreapp2.2</TargetFramework>
  <LangVersion>7.1</LangVersion>
</PropertyGroup>

Register Windows Service
終於進入最後一個步驟,如果有從頭到尾看完的朋友辛苦了(跪拜) !! 這一步要將寫好的應用程式註冊到 Windows Service 服務,Windows Service 註冊方式在之前 註冊 Windows Service 服務 文章中有介紹過基本指令還有可以透過命令提示字元以及 powershell 兩種方式註冊,有興趣可以到文章了解這裡就不在多加描述,我們直接使用 powershell 指令來進行其註冊動作,輸入下列指令將應用程式註冊至服務中
New-Service -Name "GenericHostService" -BinaryPathName "D:\Web\GenericHostedService\GenericHostLab.exe" -DisplayName "GenericHostLab Service" -StartupType Manual -Description "This is a test service."
參數介紹


  • Name : 指定服務的名稱
  • BinaryPathName : 服務的執行檔案路徑位置
  • DisplayName : 顯示的名稱
  • StartupType : 啟動類型
  • Description : 描述
  • 建立成功之後,可以再執行指令進行啟動服務
    start-service -Name "GenericHostService""
    啟動完成就可以在 Service 裡看到剛剛開啟的服務
    另外在 2019 年 6 月 Microsoft 推出 Windows Terminal,可搭配自己喜歡的桌面在輸入指令時使用(爽度破表!!!!),這邊直接用 Windows Terminal Preview 進行輸入 powershell 指令,使用 admin 開啟 Windows Terminal Preview 軟體之後,上述指令輸入之後完整過程如下
    確認服務正常執行,正式宣告完成本次任務打完收工 !!!! 

    感想
    終於完成在 .NET Core 建立排程服務的系列文,這系列文是自己覺得近期工作上比較值得分享的項目之一,從一開始對於 .NET Core Host Service 陌生開始摸索到程式可以正常啟動,到後來深入了解與同事討論針對重要細節進行研究在重構程式碼,在完成專案後自己感覺也對 ASP.NET Core 也更進一步的認識,因此在整理成文章的過程也花了不少的時間對細節來做解釋,目的就是希望有遇到跟我相同需求的朋友可以更快地透過這系列文來上手,省下開發者踩雷到處碰壁失敗的經驗,也很感謝耐心看完的開發者朋友們,如果有更好的做法與建議歡迎提出來一起討論,最後如果有想了解更多 ASP.NET Core 的朋友推薦可以閱讀 Andrew Lock 部落格文章,相信對開發更有幫助,Happy Coding :)

    2021年9月14日 星期二

    [NETCore] ASP.NET Core 建立排程服務 - 使用 Generic Host 搭配 Quartz.Net - Part 2

    前言
    這是 ASP.NET Core 建立排程服務 - 使用 Generic Host 搭配 Quartz.Net 系列文第二篇,這系列文的目標是使用 ASP.NET Core Generic Host 搭配排程套件 Quartz.Net,程式註冊為 Windows Service 服務執行,
    在上一篇介紹了在 main 方法中建立 HostBuilder 並加入 TimeHostedService 執行,這一篇是重點是在 ASP.NET Core 中使用 Quartz.Net 排程套件若有問題或是錯誤的地方歡迎各位高手給予指導
    GenericHostLab SampleCode 傳送門 : http://bit.ly/2Y1mqYN
    Quartz
    上一步建立完 Generic Host 基本設定之後,接下來另一個重點就是使用 Quartz 來建立排程機制,Quartz.Net 是一套功能齊全的工作排程框架,由 Java 熱門的排程框架 Quartz 移植到 .NET 上,open source 且提供彈性的設定讓開發者使用,安裝方式與一般套件相同,首先先到 Nuget Package Manage 搜尋 "quartz",安裝目前最新版的 Quartz.NET 套件
    或是在 Nuget Package Console 輸入指令
    Install-Package Quartz 
    完畢後可以到 .csproj 專案中查看是否有安裝成功
    <ItemGroup>
        <PackageReference Include="Quartz" Version="3.0.7" />    
    </ItemGroup> 

    建立 IJob & IJobFactory
    完成 quartz.net 的安裝動作之後,接下來是設定 Quartz 相關設定與配置,之前在 Quartz.NET 初體驗 中
    有介紹過在 Quartz.NET 中有幾個重要的元件像是 Job Schedule,需要客製的話需要實作各自的 interface,第一步先來建立 TestJob 類別,並實作 Execute 方法將我們要處理的內容邏輯寫在裡面,由於是範例代碼就印出現在的時間,另外可以在 TestJob 加上 DisallowConcurrentExecution 預防相同 Job 同時間重複執行
    namespace GenericHostLab.Job
    {
        [DisallowConcurrentExecution]
        public class TestJob : IJob
        {
            private readonly ILogger _logger;
    
            public TestJob(ILogger<TestJob> logger)
            {
                this._logger = logger;
            }
            public Task Execute(IJobExecutionContext context)
            {
                _logger.LogInformation($"{DateTime.Now} : TestJob Execute ...");
                return Task.CompletedTask;
            }
        }
    } 
    下一步是建立 SingletonJobFactory 類別,主要作用是實作 IJobFactory 接口並透過工廠方法來確保新增 IJob 是安全的
    public class SingletonJobFactory : IJobFactory
    {
        private readonly IServiceProvider _serviceProvider;
        public SingletonJobFactory(IServiceProvider serviceProvider)
        {
            _serviceProvider = serviceProvider;
        }
    
        public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
        {
            return _serviceProvider.GetRequiredService(bundle.JobDetail.JobType) as IJob;
        }
    
        public void ReturnJob(IJob job) { }
    } 

    建立 Schedule
    一般來說會是多個排程同時執行,為了建立 Job 方便建立一個 JobSchedule 類別其中定義了 JobType 與設定 CronJob 排程的執行時間,Cron expressions 表達式可以定義 Job 所需要執行頻率的規則,例如我排程執行的時間/頻率是每 3 秒鐘會執行一次,在 Cron 就使用 0/3 * * * * ? 來表達,這裡推薦 Cron expression 產生器 讓我們可以更方便的產生需要的 CronTrigger
    public class JobSchedule
    {
        public JobSchedule(Type jobType, string cronExpression)
        {
            JobType = jobType;
            CronExpression = cronExpression;
        }
    
        public Type JobType { get; }
        public string CronExpression { get; }
    } 
    接著就是要將 JobSchedule、SingletonJobFactory 在啟動時註冊至 Service中,在 .NET Core DI 內建提供三種服務容器生命週期 (LifeTime) 可提供開發者設定,這裡都使用 Singleton 方式讓使用時在 Application 只會有一份 Instance
    .ConfigureServices((hostContext, services) =>
    {
        services.Configure<HostOptions>(option =>
        {
            option.ShutdownTimeout = TimeSpan.FromSeconds(30);
        });
    
        services.AddSingleton<IJobFactory, SingletonJobFactory>();
        services.AddSingleton<ISchedulerFactory, StdSchedulerFactory>();
    
        var testJob = new JobSchedule(jobType: typeof(TestJob),cronExpression: "0/3 * * * * ?");
        services.AddSingleton<TestJob>();
        services.AddSingleton(testJob);  

    實作 IHostedService
    接著建立 QuartzHostedService 類別並實作 IHostedService 介面,上一篇有對 IHostedService 做簡單介紹,們可以透過其特性在 QuartzHostedService 類別中定義我們所想要在應用程式或服務啟動與關閉時的工作代碼如下
    namespace GenericHostLab.Service
    {
        public class QuartzHostedService: IHostedService
        {
            private readonly ISchedulerFactory _schedulerFactory;
            private readonly IJobFactory _jobFactory;
            private readonly IEnumerable<JobSchedule> _jobSchedules;
            private readonly ILogger<QuartzHostedService> _logger;
            private IScheduler _scheduler;
    
            public QuartzHostedService(ILoggerFactory loggerFactory,
                ISchedulerFactory schedulerFactory, IEnumerable<JobSchedule> jobSchedules, IJobFactory jobFactory)
            {
                _logger = loggerFactory.CreateLogger<QuartzHostedService>();
                _schedulerFactory = schedulerFactory;
                _jobSchedules = jobSchedules;
                _jobFactory = jobFactory;
            }
            public async Task StartAsync(CancellationToken cancellationToken)
            {
                _logger.LogInformation("QuartzHostedService Start...");
    
                _scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
                _scheduler.JobFactory = _jobFactory;
    
                foreach (var schedule in _jobSchedules)
                {
                    await _scheduler.ScheduleJob(
                        JobBuilder
                            .Create(schedule.JobType)
                            .WithIdentity(schedule.JobType.FullName)
                            .WithDescription(schedule.JobType.Name)
                            .Build(),
                        TriggerBuilder
                            .Create()
                            .WithIdentity($"{schedule.JobType.FullName}.trigger")
                            .WithCronSchedule(schedule.CronExpression)
                            .WithDescription(schedule.CronExpression)
                            .Build()
                        , cancellationToken);
                }
    
                await _scheduler.Start(cancellationToken);
            }
    
            public async Task StopAsync(CancellationToken cancellationToken)
            {
                _logger.LogInformation("QuartzHostedService Stop...");
                await _scheduler?.Shutdown(cancellationToken);
            }
        }
    }
    代碼內容首先在建構子使用 DI 注入 schedulerFactory、jobSchedules 與 jobFactory,QuartzService 重點則是 StartAsync 方法 Quartz 設定也就是在 StartAsync 方法中進行,使用 foreach 將所有要執行的 jobSchedule 逐一加到 _scheduler 中,再透過 _scheduler.Start 來啟動排程;於 StopAsync 方法中加入將 schedule shotdown 停止排程運作的邏輯;另外為了觀察是否正常運作,我在 Start 與 Stop 方法都有加上 log 觀察輸出結果。
    最後一步是將 QuartzHostedService 加入 service 中,在 ConfigureServices 使用 AddHostedService 擴充方法將 QuartzHostedService 加入 service,代碼如下
    .ConfigureServices((hostContext, services) =>
    {
        // 省略
        services.AddHostedService<QuartzHostedService>();
    }
    接著執行應用程式,可以看到應用程式啟動後每三秒在 console 畫面顯示目前的時間,也可以從 console log 看到運行 QuartzJostedServer Start 的紀錄

    以上就完成 Quartz.NET 在 Gereric Host 的應用,大功告成打完收工(?
    但之前提到過最後的目標是放在 Windows Service 中執行,因此下一篇將繼續介紹如何將 Generic Host 註冊 Windows Server 的流程與設定,如果這篇有不清楚或是看不懂的地方歡迎提出來一起討論 :)

    參考
    Implement background tasks in microservices with IHostedService and the BackgroundService class
    Creating a Quartz.NET hosted service with ASP.NET Core
    .NET 泛型主機

    Copyright © m@rcus 學習筆記 | Powered by Blogger

    Design by Anders Noren | Blogger Theme by NewBloggerThemes.com