[Windows Service] 使用 Topshelf 搭配 Quartz.Net 解決所有排程工作

利用 Topshelf 簡化 Windows Service 開發偵錯作業,並且搭配 Quatz.Net 強大靈活的工作排程功能,解決所有需要撰寫週期性或特定時間觸發作業的需求。

前言

最近手頭上的專案結束,授命加入一個正在進行中的專案執行 Code Review 工作,發現專案中有一支排程作業程式為方便偵錯而區分 Console 及 Windows Service 版本 (執行相同作業),另外又在服務程式邏輯中寫了許多複雜判斷邏輯,只為了決定何時該執行特定工作項目,而這些反而都會影響到主要作業的邏輯脈絡,因此趁假日來玩玩先前耳聞已久的 Topshelf 與 Quatz.Net 套件,期望可以有效率地處理類似需求。

本文利用 Topshelf 簡化 Windows Service 開發偵錯作業,並且搭配 Quatz.Net 強大靈活的工作排程功能,解決所有需要撰寫週期性或特定時間觸發作業之需求。

 

Topshelf

使用傳統開發 Windows Service 方式時,必定會面臨到如何在開發時期進行偵錯的問題,當然利用一些小技巧是可以達成 (ex. 參考 如何對Windows Service進行除錯 文章),但是到底還是要手動切換;透過 Topshelf 這個套件可以讓開發者直接使用 Console 方式進行開發,編譯出來就是一隻 console 程式且可以獨自運行,又可以透過命令列指令將這個 console 執行檔安裝成為 Windows Service 服務,達到易於開發、偵錯及靈活使用的優點,以下介紹。

 

實例演練

首先就是建立一個 Console 專案

透過 Nuget 下載 Topshelf 套件 (目前版本為 4.0.3)

接著簡單建立一個 MainService 物件,用來處理此服務主要作業邏輯,以及開始與結束作業邏輯;所以一個最精簡的 Timer 重複性作業結構如下所示,

using System.Timers;

namespace TopshelfConsole
{
    class MainService
    {
        private Timer _timer;

        public MainService()
        {
            _timer = new Timer(1000) { AutoReset = true };
            _timer.Elapsed += new ElapsedEventHandler(this.MainTask);
        }

        private void MainTask(object sender, ElapsedEventArgs args)
        {
            // do main task here
            Console.WriteLine("do main task at: " + DateTime.Now);
        }

        public void Start()
        {
            _timer.Start();
        }

        public void Stop()
        {
            _timer.Stop();
        }
    }
}

接著再程式進入點就可以使用 HostFactory 掛載 MainService 執行主要工作。

using Topshelf;

namespace TopshelfConsole
{
    class Program
    {
        static void Main(string[] args)
        {
            HostFactory.Run(x =>
            {
                x.Service<MainService>(s =>
                {
                    s.ConstructUsing(name => new MainService());
                    s.WhenStarted(ms => ms.Start());
                    s.WhenStopped(ms => ms.Stop());
                });

                x.SetServiceName("SampleServiceName");
                x.SetDisplayName("SampleDisplayName");
                x.SetDescription("SampleDescription");
                x.RunAsLocalSystem();
                x.StartAutomatically();
            });
        }
    }
}

直接執行,我們的服務就已經被 Host 到主控台應用程式上執行,而開發人員可以在這個階段就可以直接偵錯;要結束 Service 可以按下 Control + C 即可中斷。

 

安裝 Windows 服務

當然我們最終的目的就是要服務 Host 在 Windows Service 中,因此可以透過 command line 來執行安裝 / 移除作業,如果需要更多資訊可以輸入 help 參考命令集說明。

安裝輸入 YourConsoleName.exe install 執行即可

解除安裝輸入 YourConsoleName.exe uninstall 執行即可

 

Quartz.Net

我們已經透過 Topshelf 將 Windows Service 開發門檻降到最低,接著要考慮一下若是固定每 N 分鐘執行的工作,其實只要透過上方 Timer 的 TimeInterval 設定就可以達到目的;但是需求往往複雜,可能這個作業是要每天在特定時間點執行(ex. AM 10:00 及 PM 6:00),或者是特定日期執行 (每月5號) 等特殊需求,而這個就是 Quartz.Net 的強項啦,透過其 cron 來描述作業被觸發的週期,從秒、分、時、日、月、星期、年都可以進行操作,滿足各項排程週期之需求。以下說明。

 

下載套件

首先透過 Nuget 下載 Quartz.Net 套件 (目前版本為 2.4.1)

 

建立工作 (Job)

接續先前範例,我們來調整使用 Quartz.Net 作為主要工作排程;首先實作 IJob 介面來建立 MainJob 類別,而需要實作的方法只有 Execute 而已,而這部分就是需要被執行的地方。其中 DisallowConcurrentExecutionAttribute 標籤表示 MainJob 同一時間只會有一個 Instance 在使用,也就是當次觸發 MainJob 後到下一次被觸發時該作業尚未完成,則下個觸發將會延後執行;反之若無此標籤,每次觸發都會建立 MainJob 實體,若前次工作尚未完成,再次觸發就會有同時存在兩個 MainJob 執行相同作業。

using Quartz;

namespace TopshelfConsole
{
    [DisallowConcurrentExecutionAttribute]
    public class MainJob : IJob
    {

        public void Execute(IJobExecutionContext context)
        {
            // do main task here
            Console.WriteLine("do main task at: " + DateTime.Now);
        }
    }
}

 

建立觸發點 (Trigger) 

工作是需要被觸發執行的,因此 Trigger 設定是很重要的,我們可以透過 TriggerBuilder 來建立 Trigger 物件,並透過 corn expressions 設定觸發週期規則,而 corn 表示式包括日期月份星期年(可略)七個參數自由搭配,並以符號來表示不同的操作定義(ex. 於秒設定為0/5表示從0秒開始每5秒執行一次)。

其他範例如下:

"10,20,25 * * * * ? *":每分鐘的第10、20、25秒會執行

"10 0/5 * * * ?":每5分鐘的第10秒會執行 (ex. 10:00:10 am, 10:05:10 am ...)

"0 20 10-13 ? * WED,FRI":每星期三與星期五的 10:20, 11:20, 12:20, 13:20 執行 

"0 0/30 8-9 5,20 * ?":每月5號及20號的 8:00, 8:30, 9:00, 9:30 執行 

 

如果有比較複雜的情境,無法使用單一表示式來定義,可以考慮定義兩個 Trigger 來觸發相同 Job 。

 

建立排程器 (Scheduler)

透過 Scheduler 排入須執行的 Job 與 Trigger 設定,並且操作整體排程的啟動 (Start) 與停用 (Shutdown) 行為。

 

Common Logging for NLog 設定

不知道大家在安裝 Quartz.Net 套件時有沒有注意到它是相依 Common.Logging 套件,表示 Quartz.Net 在執行時會透過 Common.Logging 介面輸出執行資訊,因此不管你用 NLog 還是 Log4Net 套件,都可以透過設定檔將 Log 轉接到開發人員慣用的 Log 套件。筆者比較習慣使用 NLog 所以就簡單紀錄一下設定過程。

首先需要透過 Nuget 下載 Common.Logging.NLog41 套件 (針對NLog 4.1版本)

然後直接在 App.config 直接加入以下代碼,主要就是告訴 Common.Logging 要轉接 Log 至 NLog 元件中,並且同時設定 NLog 輸出方式 (在此簡單使用TCP將資訊輸出,方便觀看即時 Log 資訊)

<?xml version="1.0" encoding="utf-8" ?>
<configuration>

  <!-- Config Sections 設定 -->
  <configSections>
    
    <sectionGroup name="common">
      <section name="logging" type="Common.Logging.ConfigurationSectionHandler, Common.Logging" />
    </sectionGroup>

    <section name="nlog" type="NLog.Config.ConfigSectionHandler, NLog"/>
    
  </configSections>


  <!-- Common.Logging 轉接至 NLog 輸出 -->
  <common>
    <logging>
      <factoryAdapter type="Common.Logging.NLog.NLogLoggerFactoryAdapter, Common.Logging.NLog41">
        <arg key="configType" value="INLINE" />
      </factoryAdapter>
    </logging>
  </common>


  <!-- NLog 輸出設定 -->
  <nlog xmlns="http://www.nlog-project.org/schemas/NLog.xsd" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">

    <targets>
      <target name="TcpOutlet" type="NLogViewer" address="tcp://127.0.0.1:4505"/>
    </targets>
    
    <rules>
      <logger name="*" levels="Trace,Debug,Info,Warn,Error,Fatal" writeTo="TcpOutlet" />
    </rules>
    
  </nlog>

  <startup> 
    <supportedRuntime version="v4.0" sku=".NETFramework,Version=v4.5.2" />
  </startup>
  
</configuration>

接著我們就可以修改一下先前完成的 MainJob 類別,加入 Log 機制來輸出目前處理狀態如下。

using System;
using Quartz;
using Common.Logging;
using System.Reflection;

namespace TopshelfConsole
{
    [DisallowConcurrentExecutionAttribute]
    public class MainJob : IJob
    {
        private readonly ILog _logger
            = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);

        public void Execute(IJobExecutionContext context)
        {
            try
            {
                // do main job here
                _logger.Info("do main task at: " + DateTime.Now);

            }
            catch (Exception ex)
            {
                _logger.Error("main job error.", ex);
            }
        }
    }
}

 

組裝 Job + Trigger + Scheduler 取代 Timer 觸發

先前在 MainService 中是使用 Timer 直接觸發執行主要工作,但目前我們已經將工作轉移至 MainJob 中,因此將於 MainService 建立 Scheduler 來建立排程作業 (Job + Trigger),取代原有 Timer 的工作,並且可以更靈活的控制 Job 執行的時機與週期,以下就是調整完畢後的MainService程式。 

using Common.Logging;
using Quartz;
using Quartz.Impl;
using System;
using System.Reflection;

namespace TopshelfConsole
{
    class MainService
    {
        private IScheduler _scheduler;

        private readonly ILog _logger
            = LogManager.GetLogger(MethodBase.GetCurrentMethod().DeclaringType);

        public MainService()
        {

            // create main job
            IJobDetail job = JobBuilder.Create<MainJob>()
                .WithIdentity("MainJob", "MainGroup")
                .Build();

            // create trigger (fire every 5 seconds)
            ITrigger trigger = TriggerBuilder.Create()
                .WithIdentity("MainTrigger", "MainGroup")
                .WithCronSchedule("0/5 * * * * ? *")
                .StartAt(DateTime.UtcNow)
                .WithPriority(1)
                .Build();

            // schedule job + trigger
            _scheduler = StdSchedulerFactory.GetDefaultScheduler();
            _scheduler.ScheduleJob(job, trigger);
        }


        public void Start()
        {
            // start scheduler
            _scheduler.Start();
        }

        public void Stop()
        {
            // shutdown scheduler
            _scheduler.Shutdown();
        }
    }
}

 

執行結果

最後將此 console 執行檔安裝到 Windows Service 中,啟動後 Log 確實會透過 Common.Logging 轉至 NLog 並依據設定從 TCP 輸出至 Viewer 中,且執行時間確實是每五秒鐘執行一次;此外除了我們自己加入的Log外,Quartz 本身在執行的時候也會透過 Common.Logging 介面輸出相關資訊,提供開發人員額外 Debug 參考資訊。

 

參考資訊

Configuring Topshelf - Show me the code!

Common.Logging + NLog

Quartz.NET - Lesson 6: CronTrigger

 

 

 


希望此篇文章可以幫助到需要的人

若內容有誤或有其他建議請不吝留言給筆者喔 !