Kerry 的筆記本
  • Table of contents
  • Kerry的Mac裝機必要
  • ASP.NET Core 教育訓練文件
    • .NET 9 OpenAPI 介紹與教學
    • 目錄
    • ASP.NET Core Authentication系列(一)理解Claim, ClaimsIdentity, ClaimsPrincipal
    • ASP.NET Core Authentication系列(三)Cookie選項
    • ASP.NET Core Authentication系列(二)實現認證、登錄和註銷
    • ASP.NET Core Authentication系列(四)基於Cookie實現多應用間單點登錄(SSO)
    • ASP.NET Core Consul 教學
    • ASP.NET Core Hangfire 排程管理
    • ASP.NET Core KeyCloak 實作
    • ASP.NET Core NLog-依照Environment使用Nlog.Config檔案
    • ASP.NET Core NLog-如何使用 NLog 將 log 寫到檔案
    • ASP.NET Core Nlog-發送訊息到ElasticSearch
    • 目錄
    • ASP.NET Core Quartz.NET 管理介面
    • ASP.NET Core RDLC 報表設計
    • ASP.NET Core SFTP (使用第三方套建 SSH.Net) - 類別庫為案例
    • ASP.NET Core 中使用 HttpReports 進行接口統計,分析, 可視化, 監控,追踪等
    • ASP.NET 使用 MassTransit 與 RabbitMQ,實現事件發佈、訂閱
    • Asp.Net Core 分散式Session – 使用 Redis
    • ASP.NET Core 前台會員修改個人資料
    • ASP.NET Core 前台會員忘記密碼與重設密碼
    • ASP.NET Core 前台會員登入
    • ASP.NET Core 前台會員註冊
    • ASP.NET Core 呼叫 API 發生 CORS 錯誤
    • ASP.NET Core 如何套網頁設計樣版
    • ASP.NET Core 客製化Model Validation 預設錯誤訊息
    • ASP.NET Core 後台查詢頁面教學
    • ASP.NET Core 網站生命週期
    • ASP.NET Feature Management 使用說明與教學
    • ASP.NET RulesEngine 介紹
    • ASP.NET WinForms APP 程式安裝檔
    • LinePay 支付完成後返回 LINE 應用而不跳出外部瀏覽器
    • EntityFramework
      • EF Core Migrations 完整教學手冊
      • EntityFramework Core DB Migrations
      • 使用 Entity Framework Core (EF Core) 的 Migrations 功能進行版本控制
    • NET 6
      • .NET 6 Autofac範例
      • .NET 6 Automapper範例
      • .NET 6 BenchmarkDotNet範例
      • .NET 6 Bogus範例
      • .NET 6 Dapper範例
      • .NET 6 Dapper語法說明
      • .NET 6 EFCore範例
      • .NET 6 EFCore語法說明
      • .NET 6 EPPlus圖表範例
      • .NET 6 EPPlus範例
      • .NET 6 Hangfire範例
      • .NET 6 HttpClient單元測試範例
      • .NET 6 MailKit前置作業
      • .NET 6 MailKit範例
      • .NET 6 Moq範例
      • .NET 6 NLog範例
      • .NET 6 NLog進階範例
      • .NET 6 Serilog範例
      • .NET 6 Serilog進階範例
      • .NET 6 Telegram.Bot前置作業
      • .NET 6 Telegram.Bot範例
      • .NET 6 Text.Json範例
      • .NET 6 swagger授權
      • .NET 6 swagger範例
      • .NET 6 xUnit範例
      • .NET 6 取得appsettings檔案內容
      • .NET 6 更改回傳Json時為大駝峰命名
      • .NET 6 解決System.Text.Json序列化後會將所有非ASCII轉為Unicode
    • WDMIS
      • CORS
      • FeatureManagement
      • Serilog
      • Spectre.Console
      • 資料模型實戰:從 MSSQL 設計到 .NET 8 WebAPI 實作(以刀具管理為例)
  • Azure
    • 如何在 ASP.NET CORE 5.0 WEB 應用程序中實現 AZURE AD 身份驗證
    • Azure App Configuration 使用教學
    • Azure Blob Storage
    • Azure DevOps 持續整合(CI) + Artifacts
  • CSharp
    • ASP.NET await 與 wait 的差異
    • AutoMapper —— 類別轉換超省力
    • C# 中的 HTTPClient — 入門指南
    • C# 正則表達式:從零到英雄指南
    • C# 集合, List<> 取交集、差集、聯集的方法
    • C#單元測試教學
    • CORS 介紹與設定方式
    • CSharp Coding Conventions
    • Using jQuery Unobtrusive AJAX in ASP.NET Core Razor Pages
    • 深入Dapper.NET源碼
    • 菜雞與物件導向
      • 菜雞與物件導向 (0): 前言
      • 菜雞與物件導向 (1): 類別、物件
      • 菜雞與物件導向 (10): 單一職責原則
      • 菜雞與物件導向 (11): 開放封閉原則
      • 菜雞與物件導向 (12): 里氏替換原則
      • 菜雞與物件導向 (13): 介面隔離原則
      • 菜雞與物件導向 (14): 依賴反轉原則
      • 菜雞與物件導向 (15): 最少知識原則
      • 菜雞與物件導向 (2): 建構式、多載
      • 菜雞與物件導向 (3): 封裝
      • 菜雞與物件導向 (4): 繼承
      • 菜雞與物件導向 (5): 多型
      • 菜雞與物件導向 (6): 抽象、覆寫
      • 菜雞與物件導向 (7): 介面
      • 菜雞與物件導向 (8): 內聚、耦合
      • 菜雞與物件導向 (9): SOLID
      • 菜雞與物件導向 (Ex1): 小結
  • DBeaver
    • 如何強制讓 DBeaver 在 Mac 上使用英文介面
  • DesignPattern
    • OAuth
    • Repository 模式 (Repository Pattern)
    • Single Sign On 實作方式介紹 (CAS)
    • 【SOP製作教學】新手適用,SOP範例、流程圖、製作流程全公開!
    • 【SOP製作教學】流程圖教學、重點範例、BPMN符號介紹!
    • 【SOP製作教學】流程圖符號整理、BPMN2.0進階符號教學!
    • 多奇數位 C# 程式碼撰寫規範 (C# Coding Guideline)
    • 軟體分層設計模式 (Software Layered Architecture Pattern)
    • 開源程式碼檢測平台 SonarQube
    • 菜雞新訓記
      • 菜雞新訓記 (0): 前言
      • 菜雞新訓記 (1): 使用 Git 來進行版本控制吧
      • 菜雞新訓記 (2): 認識 Api & 使用 .net Core 來建立簡單的 Web Api 服務吧
      • 菜雞新訓記 (3): 使用 Dapper 來連線到資料庫 CRUD 吧
      • 菜雞新訓記 (4): 使用 Swagger 來自動產生可互動的 API 文件吧
      • 菜雞新訓記 (5): 使用 三層式架構 來切分服務的關注點和職責吧
      • 菜雞新訓記 (6): 使用 依賴注入 (Dependency Injection) 來解除強耦合吧
      • 菜雞新訓記 (7): 使用 Fluent Validation 來驗證參數吧
  • DevOps
    • Repository 模式 (Repository Pattern)
    • pipeline工具研究
    • 單例模式 (Singleton Pattern)
    • 單元測試
    • 軟體分層設計模式 (Software Layered Architecture Pattern)
    • 雙重檢查鎖定模式 (Double-Checked Locking Pattern)
  • Docker
    • Docker 中部署 .NET 8 Web App 並支援 HTTPS
    • Docker指令大全
    • 第七章 安裝Nomad
    • Docker - 第三章 | 安裝 MSSQL
    • Docker - 第九章 | 安裝 datalust seq
    • 第二章 docker-compose 教學
    • Docker - 第五章 | 安裝 Redis
    • 第八章 安裝SonarQube
    • Docker - 第六章 | 安裝RabbitMQ
    • 第十一章 安裝 VtigerCRM
    • 第十二章 安裝KeyCloak
    • Docker - 第十章 | 安裝 Redmine
    • 第四章 安裝MySQL
    • Docker Desktop (含更改 Docker Image 路徑)
  • Git
    • Git Flow 指令大全(完整指令整理) 🚀
    • Git 安裝及配置SSH Key
    • Git 建立到上傳
    • 將現有專案的遠端儲存庫直接更改為新的儲存庫
    • Git 流程規劃
    • Git 語法大全
    • 30 天精通 Git 版本控管
      • 30 天精通 Git 版本控制
        • 第 01 天:认识 Git 版本控制
        • 第 02 天:在 Windows 平台必装的三套 Git 工具
        • 第 03 天:建立仓库
        • 第 04 天:常用的 Git 版本控制指令
        • 第 05 天:了解仓库、工作目录、物件与索引之间的关系
        • 第 06 天:解析 Git 资料结构 - 物件结构
        • 第 07 天:解析 Git 资料结构 - 索引结构
        • 第 08 天:关于分支的基本观念与使用方式
        • 第 09 天:比对文件与版本差异
        • 第 10 天:认识 Git 物件的绝对名称
        • 第 11 天:认识 Git 物件的一般参照与符号参照
        • 第 12 天:认识 Git 物件的相对名称
        • 第 13 天:暂存工作目录与索引的变更状态
        • 第 14 天: Git for Windows 选项设定
        • 第 15 天:标签 - 标记版本控制过程中的重要事件
        • 第 16 天:善用版本日志 git reflog 追踪变更轨迹
        • 第 17 天:关于合并的基本观念与使用方式
        • 第 18 天:修正 commit 过的版本历史记录 Part 1
        • 第 19 天:设定 .gitignore 忽略清单
        • 第 20 天:修正 commit 过的版本历史记录 Part 2
        • 第 21 天:修正 commit 过的版本历史记录 Part 3
        • 第 22 天:修正 commit 过的版本历史记录 Part 4 (Rebase)
        • 第 23 天:修正 commit 过的版本历史记录 Part 5
        • 第 24 天:使用 GitHub 远端仓库 - 入门篇
        • 第 25 天:使用 GitHub 远端仓库 - 观念篇
        • 第 26 天:多人在同一个远端仓库中进行版控
        • 第 27 天:通过分支在同一个远端仓库中进行版控
        • 第 28 天:了解 GitHub 的 fork 与 pull request 版控流程
        • 第 29 天:如何将 Subversion 项目汇入到 Git 仓库
        • 第 30 天:分享工作中几个好用的 Git 操作技巧
      • zh-tw
        • 第 01 天:認識 Git 版本控管
        • 第 02 天:在 Windows 平台必裝的三套 Git 工具
        • 第 03 天:建立儲存庫
        • 第 04 天:常用的 Git 版本控管指令
        • 第 05 天:了解儲存庫、工作目錄、物件與索引之間的關係
        • 第 06 天:解析 Git 資料結構 - 物件結構
        • 第 07 天:解析 Git 資料結構 - 索引結構
        • 第 08 天:關於分支的基本觀念與使用方式
        • 第 09 天:比對檔案與版本差異
        • 第 10 天:認識 Git 物件的絕對名稱
        • 第 11 天:認識 Git 物件的一般參照與符號參照
        • 第 12 天:認識 Git 物件的相對名稱
        • 第 13 天:暫存工作目錄與索引的變更狀態
        • 第 14 天: Git for Windows 選項設定
        • 第 15 天:標籤 - 標記版本控制過程中的重要事件
        • 第 16 天:善用版本日誌 git reflog 追蹤變更軌跡
        • 第 17 天:關於合併的基本觀念與使用方式
        • 第 18 天:修正 commit 過的版本歷史紀錄 Part 1
        • 第 19 天:設定 .gitignore 忽略清單
        • 第 20 天:修正 commit 過的版本歷史紀錄 Part 2
        • 第 21 天:修正 commit 過的版本歷史紀錄 Part 3
        • 第 22 天:修正 commit 過的版本歷史紀錄 Part 4 (Rebase)
        • 第 23 天:修正 commit 過的版本歷史紀錄 Part 5
        • 第 24 天:使用 GitHub 遠端儲存庫 - 入門篇
        • 第 25 天:使用 GitHub 遠端儲存庫 - 觀念篇
        • 第 26 天:多人在同一個遠端儲存庫中進行版控
        • 第 27 天:透過分支在同一個遠端儲存庫中進行版控
        • 第 28 天:了解 GitHub 的 fork 與 pull request 版控流程
        • 第 29 天:如何將 Subversion 專案匯入到 Git 儲存庫
        • 第 30 天:分享工作中幾個好用的 Git 操作技巧
  • Hands-On Labs - LineBotSDK 實作手札 (C#, .net core)
    • 00. 如何申請LINE Bot
    • CLI
      • 使用CLI來發送新的Channel Access Token(LINE Bot)
      • 使用CLI免費發送LINE Notify通知
    • basic
      • 如何發送LINE訊息(Push Message)
      • 如何發送LINE Template Messages
      • 如何發送ImageMap訊息
      • 如何發送Flex Message
      • 如何在訊息後面加上QuickReply快捷選項
    • liff
      • Lab 21: 建立第一個LIFF應用
    • webhook
      • 如何建立可Echo的基本LINE Bot
      • 如何在WebHook中取得用戶個人資訊(名稱、頭像、狀態)
      • 如何在WebHook中取得用戶上傳的圖片(Bytes)
  • Markdown
    • Markdown Cheatsheet 中文版
    • Markdown語法大全
    • 使用HackMD建立書本目錄
    • 使用HackMD建立簡報
  • SAP ABAP
    • ABAP開發環境和總體介紹
    • SAP MM模塊常用表總結
    • SAP QM數據庫表清單
    • SAP欄位與表的對應關係
  • SQL Server
    • [SQL SERVER] Like in
    • SQL Server 中,移除資料庫中所有的關聯限制
    • SQL Server 刪除資料庫中所有資料表
    • SQL Server View、Function 及 Stored Procedure 定義之快速備份
    • SSMS v18 清除登入畫面中,下拉選單歷史紀錄
    • [MS SQL]如何透過Database Mail進行郵件發送
    • [SQL SERVER]撰寫Stored Procedure小細節
    • 使用 Data Migration Assistant 移轉 SQL Server 資料庫與帳戶
    • 使用SSIS創建同步資料庫數據任務
  • Tools
    • 免費 FTP 伺服器 FileZilla Server 安裝教學 (新版設定)
  • VisualStudio
    • .NET CLI 指令碼介紹
    • Visual Studio 使用 Git 版本控制
    • 使用 Visual Studio 2022 可透過 .editorconfig 鎖定文字檔案的儲存編碼格式分享
  • Web API
    • ASP.NET Core 6 Web API 進行 JWT 令牌身份驗證
    • [ASP.NET Core]如何使用SwaggerAPI說明文件
    • ASP.NET Core Web Api實作JWT驗證筆記
    • ECFIT API 範例
    • JWT Token Authentication And Authorizations In .Net Core 6.0 Web API
    • 微服務架構 - 從狀態圖來驅動 API 的設計
  • Windows
    • [C#] 伺服器監控常用語法 (事件檢視器、CPU 硬碟使用率、程式執行狀況)
    • Configure IIS Web Server on Windows Server 2019
    • Log Paser Studio 分析 IIS W3C Log
    • Windows Server 2019 如何安裝 IIS 運行 ASP.NET 專案
    • 如何檢查安裝在 IIS 上的 .NET Core Hosting Bundle 版本
    • [IIS] 如何解決網站第一個請求 Request 特別慢 ?
    • IIS 不停機更版設置
    • SQL Server 2019 Standard 繁體中文標準版安裝
    • WINDOWS共用資料夾的網路認證密碼放在哪?如何清除?
    • 如何設定 ASP.NET CORE 網站應用程式持續執行在 IIS 上
  • 專案管理
    • SSDLC (Secure Software Development Life Cycle)
    • 系統開發原則
    • MIS及專案管理-使用Redmine
      • 第10章 - [日常管理]MIS部門週會工作進度追蹤
      • 第11章 - [日常管理]MIS部門主管月會報告管理
      • 第12章 - [日常管理]機房工作日誌
      • 第13章 - [日常管理]MIS部門耗用工時及工作進度檢討
      • 第14章 - [日常管理]MIS文件知識庫
      • 第15章 - [日常管理]整理及管理分享
      • 第16章 - [異常管理]使用者問題回報系統
      • 第17章 - [異常管理]資安事件及異常紀錄
      • 第18章 - [異常管理]整理及管理分享
      • 第19章 - [變革管理]MIS的專案及專案管理五大階段
      • 第1章 - [MIS及專案管理]中小企業MIS的鳥事
      • 第20章 - [變革管理]MIS的新專案管理:起始階段
      • 第21章 - [變革管理]MIS的新專案管理:規劃階段
      • 第22章 - [變革管理]MIS的新專案管理:執行階段
      • 第23章 - [變革管理]MIS的新專案管理:監控階段
      • 第24章 - [變革管理]MIS的新專案管理:結束階段
      • 第25章 - [變革管理]整理及管理分享
      • 第26章 - [ISMS管理]ISMS平台整體規劃
      • 第27章 - [ISMS管理]ISMS文管中心
      • 第28章 - [ISMS管理]ISMS表單紀錄的管理
      • 第29章 - [ISMS管理]整理及管理分享
      • 第2章 - [MIS及專案管理]專案管理的概念及MIS應用
      • 第30章 - 初心、來時路及感謝:系列文章總結回顧
      • 第3章 - [MIS及專案管理]管理工具的選擇
      • 第4章 - [Redmine]Redmine的安裝及設定
      • 第5章 - [Redmine]Redime系統邏輯說明
      • 第6章 - [Redmine]自行建立及維護表單
      • 第7章 - [Redmine]專案版面的規劃
      • 第8章 - [日常管理]AR管理
      • 第9章 - [日常管理]資訊服務申請
  • 微服務架構
    • DDD + CQRS + MediatR 專案架構
    • 微服務架構 #2, 按照架構,重構系統
    • 淺談微服務與網站架構的發展史
    • API First Workshop 設計概念與實做案例
      • API First #1 架構師觀點 - API First 的開發策略 - 觀念篇
      • API First #2 架構師觀點 - API First 的開發策略 - 設計實做篇
    • 基礎建設 - 建立微服務的執行環境
      • Part #1 微服務基礎建設 - Service Discovery
      • Part #2 微服務基礎建設 - 服務負載的控制
      • Part #3 微服務基礎建設 - 排隊機制設計
      • Part #4 可靠的微服務通訊 - Message Queue Based RPC
      • Part #5 非同步任務的處理機制 - Process Pool
    • 實做基礎技術 API & SDK Design
      • API & SDK Design #1, 資料分頁的處理方式
      • API & SDK Design #2, 設計專屬的 SDK
      • API & SDK Design #3, API 的向前相容機制
      • API & SDK Design #4, API 上線前的準備 - Swagger + Azure API Apps
      • API & SDK Design #5 如何強化微服務的安全性 API Token JWT 的應用
    • 建構微服務開發團隊
      • 架構面試題 #1, 線上交易的正確性
      • 架構面試題 #2, 連續資料的統計方式
      • 架構面試題 #3, RDBMS 處理樹狀結構的技巧
      • 架構面試題 #4 - 抽象化設計;折扣規則的設計機制
    • 架構師觀點 - 轉移到微服務架構的經驗分享
      • Part #1 改變架構的動機
      • Part #2 實際改變的架構案例
    • 案例實作 - IP 查詢服務的開發與設計
      • 容器化的微服務開發 #1 架構與開發範例
      • 容器化的微服務開發 #2 IIS or Self Host
  • 系統評估
    • RPA 與 WebAPI 評估
    • 數位轉型:從現有系統到數位化的未來
    • 數位轉型:從現有系統到數位化的未來
  • 面試
    • CV_黃子豪_2024
    • HR 問題集
    • .NET 工程師 面試問題集
    • 資深工程師 問題集
    • 資深開發人員 / 技術主管
    • 題目
Powered by GitBook
On this page
  • 前言
  • Quartz.NET 套件
  • 建立 Job
  • 建立 JobFactory
  • 定義 JobSchedule
  • 建立 QuartzHostedService
  • 託管 QuartzHostedService
  • 啟動 .NET Core 網站
  • 確保網站永遠處於執行狀態
  • 視覺化管理
  • 使用 SignalR 即時更新狀態
  • 監聽器
  • 前端設置
  • 成果發表
  • 後記
  • 參考資訊
  1. ASP.NET Core 教育訓練文件

ASP.NET Core Quartz.NET 管理介面

Previous目錄NextASP.NET Core RDLC 報表設計

Last updated 1 year ago

本文實作 IHostedService 介面將 Quartz.Net 排程作業託管於 ASP.NET Core 網站中,並以 SignalR 實現 real-time 排程狀態管理 Dashboard 頁面

前言


當應用網站有一些外部資料需要定時獲得,或是有些內部耗時作業需要批次逐筆消化時,都會需要排程作業來處理,而比較精簡的方式就是將排程作業 Host 在 .NET Core 應用程式上運行,本文會將 Quartz.NET 排程作業託管於 ASP.NET Core 網站中作為範例;解決了運行問題後所面臨到的就是維運,要如何讓維運人員可以清楚明瞭的掌握目前各個排程作業的執行狀況,這就必須提供一個即時性的 Dashboard 頁面來呈現相關資訊,這部分可透過 SignalR 技術讓 Dashboard 跟後端程式保持一個即時相互主動的溝通渠道,以此避免以往前端定期向後端 Pulling 資料所造成的網路資訊消耗。

Quartz.NET 套件


本文使用的 Quartz.NET 是一款開源的排程作業框架,透過 Scheduler、Trigger 及 Job 組合出所需要的作業執行策略,另外透過其 cron expression 來描述作業被觸發的時機,從秒、分、時、日、月、星期、年都可以進行操作,滿足各項排程執行頻率需求。

先於專案中透過 Nuget 下載安裝 Quartz.NET 套件 (目前版本為 3.2.3)。

img

建立 Job


首先定義作業內容為何,依據 Quartz.NET 定義的 IJob 介面來實作出一個 ReportJob 來,接著在 Execute方法中執行主要工作。其中有幾個需要注意的地方如下:

  • 標記 [DisallowConcurrentExecution] 標籤來禁止同一個 Job 被同時併發執行。

  • 透過 IJobExecutionContext 的 context.JobDetail.JobDataMap 取得在建立 JobDetail 時加入的自定義資訊,因此 Job 就可以依據傳入的參數不同而執行不同的任務。

  • 由於 Job 在 DI 容器中註冊為 singleton 實體,因此於 Job 內無法注入比 singleton 生命週期範圍還小物件實體 (e.g. scope),此時可注入 IServiceProvider 透過 CreateScope 來產生 scope ,並藉此手動建立出生命週期為 scpoe 的實體 (e.g. dbContext)。

  • 由於 Job 是可以被中斷,因此可以使用 IJobExecutionContext的 CancellationToken.IsCancellationRequested 作為作業被中斷的判斷條件,自行定義安全中斷工作的時機。

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

    private readonly IServiceProvider _provider;


    public ReportJob(ILogger<ReportJob> logger, IServiceProvider provider)
    {
        _provider = provider ?? throw new ArgumentNullException(nameof(provider));
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
    }


    public Task Execute(IJobExecutionContext context)
    {

        // 可取得自定義的 JobSchedule 資料, 可根據 JobSchedule 提供的內容建立不同 report 資料
        var schedule = context.JobDetail.JobDataMap.Get("Payload") as JobSchedule;
        var jobName = schedule.JobName;

        using (var scope = _provider.CreateScope())
        {
            // 如果要使用到 DI 容器中定義為 Scope 的物件實體時,由於 Job 定義為 singleton
            // 因此無法直接取得 Scope 的實體,此時就需要於 CreateScope 在 scope 中產生該實體
            // ex. var dbContext = scope.ServiceProvider.GetService<AppDbContext>();
        }


        _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - job{jobName} - start");
        for (int i = 0; i < 5; i++)
        {

            // 自己定義當 job 要被迫被被中斷時,哪邊適合結束
            // 如果沒有設定,當作業被中斷時,並不會真的中斷,而會整個跑完
            if (context.CancellationToken.IsCancellationRequested)
            {
                break;
            }

            System.Threading.Thread.Sleep(1000);
            _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - job{jobName} - working{i}");

        }


        _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - job{jobName} - done");
        return Task.CompletedTask;
    }
}

建立 JobFactory


由於排程中的 Job 實體希望由 DI 容器中取得,因此依據 Quartz.NET 定義的 IJobFactory 介面來實作 JobFactory ,在 NewJob 方法中從 DI 容器取出指定 JobType 的實體。

public class JobFactory : IJobFactory
{
    private readonly IServiceProvider _serviceProvider;


    public JobFactory(IServiceProvider serviceProvider)
    {
        _serviceProvider = serviceProvider ?? throw new ArgumentNullException(nameof(serviceProvider));
    }


    public IJob NewJob(TriggerFiredBundle bundle, IScheduler scheduler)
    {
        var jobType = bundle.JobDetail.JobType;

        // 從 DI 容器取出指定 Job Type 實體
        return _serviceProvider.GetRequiredService(jobType) as IJob;
    }

    public void ReturnJob(IJob job)
    {
        var disposable = job as IDisposable;
        disposable?.Dispose();
    }
}

定義 JobSchedule


剛已經定義了 ReportJob ,後續可能又會建立其他種 Job 如 SyncDataJob,因此我們希望透過一個通用性的 JobSchedule 類別來描述排程作業項目,後續將以 List 來呈現須執行的所有作業清單,例如 2 種 ReportJob 的工作再加上 1 種 SyncDataJob 工作,因此就會建立 3 筆 JobSchedule 資料,其中或許內含兩種不同 ReportJob 需要的識別碼 (自行增加屬性) 讓 ReportJob 被排程執行時可以區分 2 種要產出的報表;另外最重要的就是要提供觸發的時機,因此需要給予 cronExpression 來描述作業被觸發時機,以下是一個最精簡的示意結構。

public class JobSchedule
{
    public JobSchedule(Type jobType, string cronExpression, string jobName)
    {
        JobType = jobType ?? throw new ArgumentNullException(nameof(jobType));
        CronExpression = cronExpression ?? throw new ArgumentNullException(nameof(cronExpression));
        JobName = jobName ?? throw new ArgumentNullException(nameof(jobName));
    }

    /// <summary>
    /// Job識別名稱
    /// </summary>
    public string JobName { get; private set; }

    /// <summary>
    /// Job型別
    /// </summary>
    public Type JobType { get; private set; }

    /// <summary>
    /// Cron表示式
    /// </summary>
    public string CronExpression { get; private set; }

    /// <summary>
    /// Job狀態
    /// </summary>
    public JobStatus JobStatus { get; set; } = JobStatus.Init;
}


public enum JobStatus : byte
{
    [Description("初始化")]
    Init = 0,
    [Description("已排程")]
    Scheduled = 1,
    [Description("執行中")]
    Running = 2,
    [Description("已停止")]
    Stopped = 3,
}

建立 QuartzHostedService


我們的 Quartz.NET 排程器最終要 Host 在 .NET Core 應用程式上,因此要依據 IHostedService 介面來實作 QuartzHostedService 服務,而主要就是定義服務啟動時 StartAsync 及停止時 StopAsync 需要做什麼;本例都是針對排程器 Scheduler 的建置,另外也定義了許多作業層面的操作,例如取得各項作業的執行狀態 GetJobSchedules 、手動觸發作業 TriggerJobAsync 或手動終止作業 InterruptJobAsync 等相關的操作。

在 StartAsync 啟動服務中,加入了 2 筆工作來模擬來自 DB 控制的動態報表工作項目,應用情境會是針對 ReportJob 這個通用的報表作業,但透過 DB 來設置數個不同報表的查詢條件及觸發時機,將這些所有資訊都存放在 JobSchedule 物件中;一來排程器可以依照 JobSchedule 中的 JobType 與 cornExpression 建立作業,另外透過 jobDetail.JobDataMap.Put("Payload", jobSchedule) 將 JobSchedule 額外資訊放入 ReportJob 中後,又可以幫助 ReportJob 執行時判斷需要進行的工作細節,為作業執行保留了許多彈性。

new JobSchedule(jobName: "333", jobType: typeof(ReportJob), cronExpression: $"0/13 * * * * ?"); new JobSchedule(jobName: "444", jobType: typeof(ReportJob), cronExpression: $"0/20 * * * * ?");

public class QuartzHostedService : IHostedService
{

    private readonly ISchedulerFactory _schedulerFactory;

    private readonly IJobFactory _jobFactory;

    private readonly ILogger<QuartzHostedService> _logger;

    private readonly IEnumerable<JobSchedule> _injectJobSchedules;

    private List<JobSchedule> _allJobSchedules;



    public IScheduler Scheduler { get; set; }

    public CancellationToken CancellationToken { get; private set; }



    public QuartzHostedService(ILogger<QuartzHostedService> logger, ISchedulerFactory schedulerFactory, IJobFactory jobFactory, IEnumerable<JobSchedule> jobSchedules)
    {
        _logger = logger ?? throw new ArgumentNullException(nameof(logger));
        _schedulerFactory = schedulerFactory ?? throw new ArgumentNullException(nameof(schedulerFactory));
        _jobFactory = jobFactory ?? throw new ArgumentNullException(nameof(jobFactory));
        _injectJobSchedules = jobSchedules ?? throw new ArgumentNullException(nameof(jobSchedules));
    }



    /// <summary>
    /// 啟動排程器
    /// </summary>
    /// <param name="cancellationToken"></param>
    /// <returns></returns>
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        if (Scheduler == null || Scheduler.IsShutdown)
        {
            // 存下 cancellation token 
            CancellationToken = cancellationToken;

            // 先加入在 startup 註冊注入的 Job 工作
            _allJobSchedules = new List<JobSchedule>();
            _allJobSchedules.AddRange(_injectJobSchedules);

            // 再模擬動態加入新 Job 項目 (e.g. 從 DB 來的,針對不同報表能動態決定產出時機)
            _allJobSchedules.Add(new JobSchedule(jobName: "333", jobType: typeof(ReportJob), cronExpression: "0/13 * * * * ?"));
            _allJobSchedules.Add(new JobSchedule(jobName: "444", jobType: typeof(ReportJob), cronExpression: "0/20 * * * * ?"));

            // 初始排程器 Scheduler
            Scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
            Scheduler.JobFactory = _jobFactory;


            // 逐一將工作項目加入排程器中 
            foreach (var jobSchedule in _allJobSchedules)
            {
                var jobDetail = CreateJobDetail(jobSchedule);
                var trigger = CreateTrigger(jobSchedule);
                await Scheduler.ScheduleJob(jobDetail, trigger, cancellationToken);
                jobSchedule.JobStatus = JobStatus.Scheduled;
            }

            // 啟動排程
            await Scheduler.Start(cancellationToken);
        }
    }

    /// <summary>
    /// 停止排程器
    /// </summary>
    /// <returns></returns>
    public async Task StopAsync(CancellationToken cancellationToken)
    {
        if (Scheduler != null && !Scheduler.IsShutdown)
        {
            _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - Scheduler StopAsync");
            await Scheduler.Shutdown(cancellationToken);
        }
    }

    /// <summary>
    /// 取得所有作業的最新狀態
    /// </summary>
    public async Task<IEnumerable<JobSchedule>> GetJobSchedules()
    {
        if (Scheduler.IsShutdown)
        {
            // 排程器停止時更新各工作狀態為停止
            foreach (var jobSchedule in _allJobSchedules)
            {
                jobSchedule.JobStatus = JobStatus.Stopped;
            }
        }
        else
        {
            // 取得目前正在執行的 Job 來更新各 Job 狀態
            var executingJobs = await Scheduler.GetCurrentlyExecutingJobs();
            foreach (var jobSchedule in _allJobSchedules)
            {
                var isRunning = executingJobs.FirstOrDefault(j => j.JobDetail.Key.Name == jobSchedule.JobName) != null;
                jobSchedule.JobStatus = isRunning ? JobStatus.Running : JobStatus.Scheduled;
            }

        }

        return _allJobSchedules;
    }

    /// <summary>
    /// 手動觸發作業
    /// </summary>
    public async Task TriggerJobAsync(string jobName)
    {
        if (Scheduler != null && !Scheduler.IsShutdown)
        {
            _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - job{jobName} - TriggerJobAsync");
            await Scheduler.TriggerJob(new JobKey(jobName), CancellationToken);
        }
    }

    /// <summary>
    /// 手動中斷作業
    /// </summary>
    public async Task InterruptJobAsync(string jobName)
    {
        if (Scheduler != null && !Scheduler.IsShutdown)
        {
            var targetExecutingJob = await GetExecutingJob(jobName);
            if (targetExecutingJob != null)
            {
                _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - job{jobName} - InterruptJobAsync");
                await Scheduler.Interrupt(new JobKey(jobName));
            }

        }
    }

    /// <summary>
    /// 取得特定執行中的作業
    /// </summary>
    private async Task<IJobExecutionContext> GetExecutingJob(string jobName)
    {
        if (Scheduler != null)
        {
            var executingJobs = await Scheduler.GetCurrentlyExecutingJobs();
            return executingJobs.FirstOrDefault(j => j.JobDetail.Key.Name == jobName);
        }

        return null;
    }

    /// <summary>
    /// 建立作業細節 (後續會透過 JobFactory 依此資訊從 DI 容器取出 Job 實體)
    /// </summary>
    private IJobDetail CreateJobDetail(JobSchedule jobSchedule)
    {
        var jobType = jobSchedule.JobType;
        var jobDetail = JobBuilder
            .Create(jobType)
            .WithIdentity(jobSchedule.JobName)  
            .WithDescription(jobType.Name)
            .Build();

        // 可以在建立 job 時傳入資料給 job 使用
        jobDetail.JobDataMap.Put("Payload", jobSchedule);

        return jobDetail;
    }

    /// <summary>
    /// 產生觸發器
    /// </summary>
    /// <param name="schedule"></param>
    /// <returns></returns>
    private ITrigger CreateTrigger(JobSchedule schedule)
    {
        return TriggerBuilder
            .Create()
            .WithIdentity($"{schedule.JobName}.trigger") 
            .WithCronSchedule(schedule.CronExpression)
            .WithDescription(schedule.CronExpression)
            .Build();
    }
}

託管 QuartzHostedService


先在 Sartup 的 ConfigureServices 方法中向 DI 容器註冊先前建立的所有類別,最後透過 AddHostedService 加入需要 Host 的 QuartzHostedService 服務後,只要 .NET Core 應用網站一執行後就會自動地啟動該服務了,所以無需再去執行 StartAsync 方法啟動服務。

筆者在向 DI 容器註冊類別時,又加入了 2 筆測試工作項目,這種操作情境為「固定不變」的作業,只要是固定「作業內容」及「觸發時機」就可以透過這種方式注入 QuartzHostedService 中。

new JobSchedule(jobName: "111", jobType: typeof(ReportJob), cronExpression: "0/30 * * * * ?"); new JobSchedule(jobName: "222", jobType: typeof(ReportJob), cronExpression: "0/52 * * * * ?");

public class Startup
{
  
    public void ConfigureServices(IServiceCollection services)
    {
        services.AddControllersWithViews();


        //向DI容器註冊Quartz服務
        services.AddSingleton<IJobFactory, JobFactory>();
        services.AddSingleton<ISchedulerFactory, StdSchedulerFactory>();

        //向DI容器註冊Job
        services.AddSingleton<ReportJob>();

        //向DI容器註冊JobSchedule
        services.AddSingleton(new JobSchedule(jobName: "111", jobType: typeof(ReportJob), cronExpression: "0/30 * * * * ?"));
        services.AddSingleton(new JobSchedule(jobName: "222", jobType: typeof(ReportJob), cronExpression: "0/52 * * * * ?"));

        //向DI容器註冊Host服務
        services.AddSingleton<QuartzHostedService>();
        services.AddHostedService(provider => provider.GetService<QuartzHostedService>());

    }

}

啟動 .NET Core 網站


啟動 .NET Core 網站後 QuartzHostedService 會自動被啟動,而我們先前總共加了四個 ReportJob 類型的工作排程,可以從 log 中看到這些工作執行的狀況。

new JobSchedule(jobName: "111", jobType: typeof(ReportJob), cronExpression: "0/30 * * * * ?"); new JobSchedule(jobName: "222", jobType: typeof(ReportJob), cronExpression: "0/52 * * * * ?"); new JobSchedule(jobName: "333", jobType: typeof(ReportJob), cronExpression: "0/13 * * * * ?"); new JobSchedule(jobName: "444", jobType: typeof(ReportJob), cronExpression: "0/20 * * * * ?");

以 Job333 為例,當 corn expression 設定為 0/13 * * * * ? 表示從 0 秒開始,每 13 秒會執行一次,搭配分鐘設定為 * 表示執行的時機會是每分鐘的 0, 13, 26, 39, 52 秒時觸發,而我們從輸出可以驗證 Job333 確實如我們預期的時間點執行。

確保網站永遠處於執行狀態


  • 應用程式集區 - 進階設定 - 啟動模式 (Start Mode) = AlwaysRunning

  • 站台設定 - 進階設定 - 啟用預載 (Preload Enabled) = True

視覺化管理


在排程作業都可以順利運作後,對於維運人員最重要的是可以管理這些作業運行的狀態,因此設計視覺化的介面來了解目前狀態,簡單列一下需求吧。

  • 能夠即時看到各作業執行的狀態 (排程中、執行中、停止)。

  • 可以針對特定作業手動執行觸發及終止。

  • 可以針對整個排程器執行啟動及停止。

初步構想就是在 .NET Core 應用網站上加上 Dashboard 頁面來顯示各作業的執行狀態,並且提供一些控制功能。

先定義出畫面上顯示資訊用的 JobScheduleSummary 物件類別。

public class JobScheduleSummary
{

    /// <summary>
    /// Job識別名稱
    /// </summary>
    public string JobName { get; set; }

    /// <summary>
    /// Job類型
    /// </summary>
    /// 
    public string JobType { get; set; }

    /// <summary>
    /// Cron表示式
    /// </summary>
    /// 
    public string CronExpression { get; set; }

    /// <summary>
    /// Job狀態名
    /// </summary>
    /// 
    public string JobStatusName { get; set; }

    /// <summary>
    /// Job狀態碼
    /// </summary>
    /// 
    public JobStatus JobStatusId { get; set; }

}

使用 SignalR 即時更新狀態


針對排程作業狀態的更新頻率來說,當作業被啟動或結束時會希望立即反映在畫面上,而在排程作業漫長的等待期又是長時間的狀態停滯,因此使用 Timer 一直去 Pulling 資料絕對不會是件好事,而 SignalR 絕對是這類需求的最佳解。

首先建立 SchedulerHub 作為 SignalR 伺服端控管前後端溝通的渠道,能夠接收 client 端的請求,並且也能主動通知 client 狀態異動。由於需要取得排程器的資訊並進行操作,因此注入 QuartzHostedService 服務,藉由此服務對排程器或作業進行操控。

public class SchedulerHub : Hub
{
    private QuartzHostedService _quartzHostedService;

    /// <summary>
    /// 建構子
    /// </summary>
    /// <param name="quartzHostedService">Quartz排程服務</param>
    public SchedulerHub (QuartzHostedService quartzHostedService)
    {
        _quartzHostedService = quartzHostedService;
    }

    /// <summary>
    /// 要求取得Job狀態
    /// </summary>
    public async Task RequestJobStatus()
    {
        if (Clients != null)
        {
            var jobs = await _quartzHostedService.GetJobSchedules();
            var jobSummary = jobs.Select(e => 
                    new JobScheduleSummary { 
                        JobName = e.JobName, 
                        CronExpression = e.CronExpression, 
                        JobStatusName = e.JobStatus.GetDescription(), 
                        JobStatusId = e.JobStatus, 
                        JobType = e.JobType.FullName 
                    }
                );

            await Clients.Caller.SendAsync("ReceiveJobStatus", jobSummary);
        }
    }

    /// <summary>
    /// 通知Job狀態改變
    /// </summary>
    public async Task NotifyJobStatusChange()
    {
        if (Clients != null)
        {
            await Clients.All.SendAsync("JobStatusChange");
        }
    }

    /// <summary>
    /// 手動觸發Job執行
    /// </summary>
    public async Task TriggerJob(string jobName)
    {
        await _quartzHostedService.TriggerJobAsync(jobName);
    }

    /// <summary>
    /// 手動中斷Job執行
    /// </summary>
    public async Task InterruptJob(string jobName)
    {
        await _quartzHostedService.InterruptJobAsync(jobName);
    }

        
    /// <summary>
    /// 開啟排程器
    /// </summary>
    public async Task StartScheduler()
    {
        await _quartzHostedService.StartAsync(_quartzHostedService.CancellationToken);
    }

    /// <summary>
    /// 關閉排程器
    /// </summary>
    public async Task StopScheduler()
    {
        await _quartzHostedService.StopAsync(_quartzHostedService.CancellationToken);
    }


    /// <summary>
    /// 用戶連線事件
    /// </summary>
    public override async Task OnConnectedAsync()
    {
        await Groups.AddToGroupAsync(Context.ConnectionId, "SignalR Users");
        await NotifyJobStatusChange();
        await base.OnConnectedAsync();
    }


    /// <summary>
    /// 用戶斷線事件
    /// </summary>
    public override async Task OnDisconnectedAsync(Exception exception)
    {
        await Groups.RemoveFromGroupAsync(Context.ConnectionId, "SignalR Users");
        await base.OnDisconnectedAsync(exception);
    }
}

監聽器


溝通渠道搞定了,接著就是誰能主動通知 Job 及 Scheduler 的變化,好讓排程作業狀態有變動時主動通知 client 端更新顯示的作業狀態;我們可依據 Quartz.NET 定義的 IJobListener 介面來實作 JobListener物件,透過 Job 監聽器來統一提供這些事件,這樣就可以精準掌握各 Job 狀態變化了。以下是實作細節說明。

  • 我們在 Job 發生變化時需要操作 SchedulerHub 來通知 client 更新狀態,如果直接注入 SchedulerHub 會造成循環參考 ( JobListener -> SchedulerHub -> QuartzHostedService -> JobListener ... ),所以只能使用 IServiceProvider 透過 GetRequiredService 方法手動從 DI 容器取出物件實體。

  • 於本例中只使用到 JobToBeExecuted 及 JobWasExecuted 兩個事件,分別表示 Job 開始執行及結束,這邊要特別注意我們無法在這兩個事件中主動使用 schedulerHub.RequestJobStatus() 發送狀態資訊給前端用戶,因為 Job 會在這兩個事件結束後才會正式進入預期的狀態,所以僅能以 schedulerHub.NotifyJobStatusChange() 通知方式告知 client 狀態已經改變,然後再經由 client 主動向後端取得更新後的狀態。

  • JobToBeExecuted:工作將被執行 (當下 Job 狀態尚未進入 Executing 清單),所以不能直接「主動」傳送當下狀態給 SchedulerHub 送出,因取得的 Job 狀態都還在排程中,非預期的執行中狀態。

  • JobWasExecuted:工作執行完畢 (當下 Job 狀態尚未移出 Executing 清單),所以不能直接「主動」傳送當下狀態給 SchedulerHub 送出,因取得的 Job 狀態都還在執行中,非預期的排程中狀態。

public class JobListener : IJobListener
{

    private readonly ILogger<JobListener> _logger;

    private readonly IServiceProvider _serviceProvider = null;  // 要用這個來產生 schedulerHub 實體,避免直接注入造成循環參考



    string IJobListener.Name => "Jobs Listener";



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



    public async Task JobToBeExecuted(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        // 工作將被執行 (目前Job狀態尚未進入Executing清單)

        var jobName = context.JobDetail.Key.Name;
        _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - job{jobName} - JobToBeExecuted");

        var schedulerHub = _serviceProvider.GetRequiredService<SchedulerHub>();
        await schedulerHub.NotifyJobStatusChange();
         
    }

    public async Task JobWasExecuted(IJobExecutionContext context, JobExecutionException jobException, CancellationToken cancellationToken = default)
    {
        // 工作執行完畢 (目前Job狀態尚未移出Executing清單)

        var jobName = context.JobDetail.Key.Name;
        _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - job{jobName} - JobWasExecuted");

        var schedulerHub = _serviceProvider.GetRequiredService<SchedulerHub>();
        await schedulerHub.NotifyJobStatusChange();

    }

    public Task JobExecutionVetoed(IJobExecutionContext context, CancellationToken cancellationToken = default)
    {
        return Task.CompletedTask;
    }

}

另外,我們也想知道排程器 Scheduler 被啟動或結束的狀態,因此可依據 ISchedulerListener 介面來實作 SchedulerListener 物件。在此我們只關注 Scheduler 啟動或結束相關的事件,因此只需在這些事件中如同 JobListener 處理方式來以 schedulerHub.NotifyJobStatusChange() 通知方式告知 client 狀態已經改變,然後再經由 client 主動向後端取得更新後的狀態。

public class SchedulerListener : ISchedulerListener
{
    private readonly ILogger<SchedulerListener> _logger;

    private readonly IServiceProvider _serviceProvider = null;  // 要用這個來產生 schedulerHub 實體,避免直接注入造成循環參考



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



    public async Task SchedulerShutdown(CancellationToken cancellationToken = default)
    {
        _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - SchedulerShutdown");
        var schedulerHub = _serviceProvider.GetRequiredService<SchedulerHub>();
        await schedulerHub.NotifyJobStatusChange();
    }

    public async Task SchedulerShuttingdown(CancellationToken cancellationToken = default)
    {
        _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - SchedulerShuttingdown");
        var schedulerHub = _serviceProvider.GetRequiredService<SchedulerHub>();
        await schedulerHub.NotifyJobStatusChange();
    }

    public async Task SchedulerStarted(CancellationToken cancellationToken = default)
    {
        _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - SchedulerStarted");
        var schedulerHub = _serviceProvider.GetRequiredService<SchedulerHub>();
        await schedulerHub.NotifyJobStatusChange();
    }

    public async Task SchedulerStarting(CancellationToken cancellationToken = default)
    {
        _logger.LogInformation($"@{DateTime.Now:HH:mm:ss} - SchedulerStarting");
        var schedulerHub = _serviceProvider.GetRequiredService<SchedulerHub>();
        await schedulerHub.NotifyJobStatusChange();
    }

  
    /* ...略... */
}

接著於 DI 容器中註冊 JobListener、SchedulerListener 及 SchedulerHub 後,加入 SignalR 服務及設定 SignalR Router 就大功告成了。

public class Startup
{

    public void ConfigureServices(IServiceCollection services)
    {

        //向DI容器註冊Quartz服務
        services.AddSingleton<IJobFactory, JobFactory>();
        services.AddSingleton<ISchedulerFactory, StdSchedulerFactory>();
        services.AddSingleton<IJobListener, JobListener>();  // 註冊 JobListener
        services.AddSingleton<ISchedulerListener, SchedulerListener>();  // 註冊 SchedulerListener


        /* ... 略 ... */

       
        // 註冊DB容器schedulerHub實體
        services.AddSingleton<SchedulerHub>();

        // 設定 SignalR 服務
        services.AddSignalR();
    }

    
    public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
    {
        /* ... 略 ... */

        app.UseEndpoints(endpoints =>
        {
            endpoints.MapControllerRoute(
                name: "default",
                pattern: "{controller=Home}/{action=Index}/{id?}");

            // 設定 signalR 的 router
            endpoints.MapHub<SchedulerHub>("/schedulerHub");
        });


         
    }
}

對了,最後最重要的別忘了在 Scheduler 中加入 JobListener 及 SchedulerListener 監聽器。至此 SignalR 伺服端設置已經完成了。

public class QuartzHostedService : IHostedService
{

    private readonly IJobListener _jobListener;

    private readonly ISchedulerListener _schedulerListener;

    /* ... 略 ... */


    public QuartzHostedService(ILogger<QuartzHostedService> logger, ISchedulerFactory schedulerFactory, IJobFactory jobFactory, IEnumerable<JobSchedule> jobSchedules, IJobListener jobListener, ISchedulerListener schedulerListener)
    {
         _jobListener = jobListener ?? throw new ArgumentNullException(nameof(jobListener));
         _schedulerListener = schedulerListener ?? throw new ArgumentNullException(nameof(schedulerListener));

          /* ... 略 ... */

    }

 
    public async Task StartAsync(CancellationToken cancellationToken)
    {
        // 啟動排程器

        if (Scheduler == null || Scheduler.IsShutdown)
        {
            
            /* ... 略 ... */


            // 初始 scheduler
            Scheduler = await _schedulerFactory.GetScheduler(cancellationToken);
            Scheduler.JobFactory = _jobFactory;
            Scheduler.ListenerManager.AddJobListener(_jobListener);  // 加入 Job 監聽器
            Scheduler.ListenerManager.AddSchedulerListener(_schedulerListener); // 加入 Scheduler 監聽器

            
            /* ... 略 ... */

        }

    }

  /* ... 略 ... */

}

前端設置


至於前端就只要依照 SignalR 開發方式進行即可,首先在 _Layout.cshtml 中載入所需要的 js 檔案。

@*加入 signalr.js + vue.js *@
<script src="https://cdnjs.cloudflare.com/ajax/libs/microsoft-signalr/3.1.7/signalr.min.js"></script>
<script src="https://unpkg.com/vue"></script>
<script src="https://unpkg.com/http-vue-loader"></script>

筆者已經習慣使用 vue / react 進行前端開發,因此本例使用 vue 來完成前端 Home/index.cshtml 的互動。主要就是使用 signalR.HubConnectionBuilder 建立起與後端的連線,接著定義可被後端觸發的事件,以及可以直接呼叫後端 SchedulerHub 方法的動作,程式邏輯約略如下。

@{
    ViewData["Title"] = "Home Page";
}

<div class="text-center">

    <div id="app">
        <h1 class="display-4">System Scheduler</h1>
        <div class="scheduler-actions">
            <button v-on:click="startScheduler" :disabled="!isSchedulerStop">Start Scheduler</button>
            <button v-on:click="stopScheduler" :disabled="isSchedulerStop">Stop Scheduler</button>
            <button v-on:click="refresh">Refresh {{counter}}</button>
        </div>

        <table class="schedule-table">
            <thead class="schedule-table__header">
                <tr>
                    <td align="center">
                        Job Name
                    </td>
                    <td align="center">
                        Job Type
                    </td>
                    <td align="center">
                        CRON
                    </td>
                    <td align="center">
                        Status
                    </td>
                    <td>
                        Actions
                    </td>
                </tr>
            </thead>
            <tr v-for="(job,index) in jobs" :key="index">
                <td align="center">
                    {{job.jobName}}
                </td>
                <td align="center">
                    {{job.jobType}}
                </td>
                <td align="center">
                    {{job.cronExpression}}
                </td>
                <td align="center" :class="isExecuting(job.jobStatusId) ? 'schedule-table--active' : ''">
                    {{job.jobStatusName}}
                </td>
                <td>
                    <button v-on:click="()=>trigger(job.jobName)" :disabled="!isStandby(job.jobStatusId)">Trigger</button>
                    <button v-on:click="()=>Interrupt(job.jobName)" :disabled="!isExecuting(job.jobStatusId)">Interrupt</button>
                </td>
            </tr>
        </table>

        <div class="scheduler-actions">
             {{time}}
        </div>
    </div>
</div>


<script>


    var app = new Vue({
        el: '#app',
        data: {
            connection: null,
            jobs: [],
            counter: 0,
            time: '',
            timeInterval: null
        },
        async mounted() {

            this.connection = new signalR.HubConnectionBuilder()
                .withUrl("/schedulerHub")
                .build();

            // 被後端呼叫接收 Job 目前狀態
            this.connection.on("ReceiveJobStatus", (jobs) => {
                this.jobs = jobs;
                this.counter += 1;
            });

            // 被後端呼叫接收 Job 狀態改變的通知
            this.connection.on("JobStatusChange", () => {
                this.connection.invoke("RequestJobStatus").catch(err => console.error(err));
            });

            this.connection.start().catch(err => console.error(err));

            this.time = this.getTime();
            this.timeInterval = window.setInterval(() => this.time = this.getTime(), 300);
        },
        destroyed() {
            window.clearInterval(this.timeInterval);
        },
        computed: {
            isSchedulerStop() {
                return this.jobs.findIndex(j => j.jobStatusId === 3) > -1
            }
        },
        methods: {
            isExecuting(status) {
                return status === 2;
            },
            isStandby(status) {
                return status === 1;
            },
            refresh() {
                // 呼叫後端提供所有 Job 的狀態
                this.connection.invoke("RequestJobStatus").catch(err => console.error(err));
            },
            trigger(jobName) {
                // 呼叫後端觸發特定Job
                this.connection.invoke("TriggerJob", jobName).catch(err => console.error(err));
            },
            Interrupt(jobName) {
                // 呼叫後端終止特定Job
                this.connection.invoke("InterruptJob", jobName).catch(err => console.error(err));
            },
            startScheduler() {
                // 呼叫後端啟動排程
                this.connection.invoke("StartScheduler").catch(err => console.error(err));
            },
            stopScheduler() {
                // 呼叫後端終止排程
                this.connection.invoke("StopScheduler").catch(err => console.error(err));
            },
            getTime() {
                var dt = new Date();
                var DD = ("0" + dt.getDate()).slice(-2);
                var MM = ("0" + (dt.getMonth() + 1)).slice(-2);
                var YYYY = dt.getFullYear();
                var hh = ("0" + dt.getHours()).slice(-2);
                var mm = ("0" + dt.getMinutes()).slice(-2);
                var ss = ("0" + dt.getSeconds()).slice(-2);
                return YYYY + "-" + MM + "-" + DD + " " + hh + ":" + mm + ":" + ss;
            }
        }
    });


</script>

成果發表


先回憶一下各工作的執行時機,驗證一下是否能如實呈現各個 Job 工作的狀態。

Job Name

cron expression

remark

111

0/30 * * * * ?

每分鐘的 0, 30 秒觸發

222

0/52 * * * * ?

每分鐘的 0, 52 秒觸發

333

0/13 * * * * ?

每分鐘的 0, 13, 26, 39, 52 秒觸發

444

0/20 * * * * ?

每分鐘的 0, 20, 40 秒觸發

各 Job 確實依 cron 設定觸發,並如實地將狀態呈現在頁面上,表示 Listener 及 SignalR 通訊皆正常。

可點下 Interrupt 強制中斷作業。

可點下 Trigger 觸發作業執行。

最後測試關閉排程器及啟動排程器也正常。太棒了,我們終於手把手完成了一個排程器啦!

後記


本文實現了一個 Host 在 .NET Core 應用程式上的排程器,然後又使用 SignalR 技術讓 Dashboard 保持前後端雙向溝通,在初步的使用情境應該是可以滿足了;另外,如果還要延伸處理當然除了一些訪問權限上的管控外,也需要讓 Dashboard 可呈現各個作業相關的 log 紀錄,讓維護人員知道這個排程的過去及未來,是否有確實執行、是否有發生錯誤,甚至也可以考慮在捕捉到 Job Exception 時自動 Mail 通知相關維運人員等,都是可以再延伸應用的部分。

參考資訊


img

由於我們的排程器是附屬於在主要的 ASP.NET Core 應用網站上,所以如果要讓這個排程器永續經營就必須確保該應用網站永遠處於執行的狀態,若該應用網站是布署在 IIS 上就必須特別注意它的回收機制,有可能會造成應用程式中斷的情況產生,這部分許多前輩已經用血淚整理出應對之道,細節請參考黑暗執行續 文章說明,本文僅簡單列出可設定補強之處。

img

img
img
img
img

想要實際體驗同步狀態更新效果的朋友,可開啟多個 頁玩玩;完整程式碼請參考筆者 專案。

執行定期排程
DEMO
Github
Quartz.NET
在ASP.NET Core中創建基於Quartz.NET託管服務輕鬆實現作業調度
Hangfire 筆記2 - 執行定期排程
Quartz.net 3.x使用總結(一)——簡單使用
img