Quartz.NET 3.0.7 + MySql 實現動態排程作業+動態切換版本+多作業引用同一程式集不同版本+持久化+...
前端時間,接到領導任務,寫了一個排程框架.今天決定把心路歷程記錄在這裡.做個紀念.也方便提供給我這樣的新手朋友,避免大家踩同樣的坑.
在生活中,"經驗教訓"常常一起出現,但在如今的快餐年代,太多人往往只關注經驗,希望可以一步登天.
在巨人的肩膀上固然可以看得更高,更遠,但任何事物都應該辯證的看.
經驗固然可以讓人走捷徑,
但教訓可以讓人不走彎路.
希望這篇"心路歷程"能讓大家有所收穫,也希望各位大佬留下寶貴意見.
需求
直接上領導的原話:
拆分需求
在部落格園看了幾篇 Quartz.NET 的入門貼後,對該框架有了一個大致的瞭解.接下來就開始設計了.
正所謂路要一步一步走,飯要一口一口吃.
於是我需求的劃分成如下幾個功能點和需要解決的問題:
1.利用反射動態建立Job;
2.排程服務如何知道有新的任務來了?是排程服務輪詢資料庫?還是管理後臺通知排程服務?又或者遠端代理?
3.需要一個管理後臺,提供啟動,暫停,恢復,停止等功能;
4.至於叢集,Quartz.NET 本身就提供該功能,只不過要使用它的持久化方案而已.這個點只需要在配置檔案上做做手腳就可以了,並不需要怎麼開發.
5.管理後臺如何實現啟動,暫停,恢復,停止等功能?靠遠端代理?還是通過其他方式?
開始幹
要想通過 dll 方式靈活新增必然要用到反射.這點毋庸置疑.
Quartz.NET 建立一個Job的核心程式碼如下:
IJobDetail jobDetail = JobBuilder.Create(typeof(Job)).Build()
同時,Job 類需要實現 IJob 介面,實現Execute() 方法.(關於 Quartz.NET 的基礎知識本篇就不介紹了,部落格園有很多大神寫了很多好文章)
那麼,我只要能拿到 Type 不就完事兒了麼?
這不 so easy.... 麼
有新的排程任務了,就新建一個類庫,Nuget 安裝 Quartz.NET ,然後新建類,實現IJob介面,實現 Execute() 方法,排程服務裡面反射載入程式集,拿到 type ,完事兒...
於是乎,我提筆就幹,寫下了如下程式碼:
Assembly assembly = Assembly.LoadFile("程式集物理路徑"); Type type = assembly.GetType("型別完全限定名");
至於排程服務怎麼知道有新的排程任務來了,這個屬於管理後臺如何與排程服務通訊的問題,這個問題不是當前需要解決的,暫時放一邊,後面再考慮.
上面程式碼寫完後,測試了下,沒問題,執行正常.
但是,問題來了.
1.我這個排程任務要切換版本怎麼辦?
2.我好幾個排程任務引用了同一個程式集的不同版本怎麼辦?
3.我這個排程任務裡面要用自己的配置檔案怎麼辦?
如果有朋友沒有理解到上面這3個問題,我再舉例說明一下:
第一個問題:
現在有兩個排程任務
1. 類庫專案 TestJob1.dll ,定義了一個型別: Job1 ,其完全限定名為 TestJob1.Job1
2. 類庫專案 TestJob2.dll ,定義了一個型別: Job2 ,其完全限定名為 TestJob2.Job2
現在排程服務已經執行起來了,我通過某種方式通知到排程服務,並且已經成功反射載入了上述兩個程式集.
如果這時候, TestJob1.dll 需要更新.怎麼辦?直接覆蓋?不行的,會提示你:
"把排程服務關了,再覆蓋"
這個可以有...
但是,我這個排程服務還管理者 Job2 ...實際工作中,可能有更多.為了更新某一個排程任務的版本就關閉整個排程服務,讓所有的排程任務都停擺?Boss會砍死你的.
"我的排程任務都是每天凌晨執行,白天關一下沒問題".------ What are you talking about ?
第二個問題:
同樣以 TestJob1.dll 和 TestJob2.dll 舉例.
假如這兩個排程任務都引用了同一個程式集 Tools.dll ,但是版本不一樣.TestJob1.dll 引用 Tools.dll v1.0.0.0 ,TestJob2.dll 引用的是 v1.0.0.1
那麼如果反射載入 TestJob1.dll 和 TestJob2.dll 的時候到底會載入哪個版本的 Tools.dll 呢?
誰先載入,就會載入誰引用的版本.
比如,如果先反射載入了TestJob1.dll ,那麼會載入Tools.dll v1.0.0.0 .這時候再反射載入 TestJob2.dll 時,不會再載入 Tools.dll 了.
我曾奢望用什麼騷操作能載入同一個程式集的不同版本,或者說更新到高版本也行;最終以失敗而告終.
所以,如果TestJob2.dll 用到了 v1.0.0.1 裡面的新方法,那麼很遺憾,排程服務執行時會報錯,大概提示是:未在程式集 Tools.dll v1.0.0.0 中找到方法 .......
第三個問題:
依然以 TestJob1.dll 為例.
我在該類庫專案中,新建應用程式配置檔案:
<configuration> <appSettings> <add key="name" value="釋放自我"/> </appSettings> </configuration>
public class Job1 { public string Name = System.Configuration.ConfigurationManager.AppSettings["name"]; }
大家覺得反射後,建立的 Job1 的例項能拿到"釋放自我"麼?肯定是拿不到了啦...除非你把配置寫在 排程服務 的配置檔案中..但是不可能我每加一個排程任務,都去排程服務的配置檔案中新增配置吧..而且還有可能重名.當然,你要用File讀取,當我沒說...
那麼,能不能程式集用的時候再載入,用完就解除安裝.再用的時候再引用呢?
這時候,我想到<CLR via C# 第4版>這本書提到過:
"程式集載入後不能解除安裝,只能通過解除安裝 AppDomain 來解除安裝程式集".因為我知道,程式集載入後是不能解除安裝的,
於是乎,我翻開 <CLR via C# 第4版> ,依葫蘆畫瓢,天真而充滿自信的寫出如下程式碼:
TestJob1.dll :
public class Job1 : MarshalByRefObject, IJob {public Task Execute(IJobExecutionContext context) { Console.WriteLine("我不會寫PPT,只會幹活"); return Task.FromResult(0); } }
TestConsole.exe (排程服務):
string assemblyPath = @"H:\0開發專案\Go.Job.QuartzNET\TestJob1\bin\1\TestJob1.dll"; AppDomainSetup setup = new AppDomainSetup(); setup.ShadowCopyFiles = "true";//這句話非常重要,核心中的核心,沒有它,就算跨域也沒有價值.這句程式碼的效果是:你看到的程式集並不是正在用的程式集.用的是 它們的 Shadow. setup.ApplicationBase = System.IO.Path.GetDirectoryName(assemblyPath); AppDomain appDomain = AppDomain.CreateDomain("newDomain", null, setup); object job = appDomain.CreateInstanceFromAndUnwrap(assemblyPath, "TestJob1.Job1"); Type type = job.GetType(); IScheduler scheduler = StdSchedulerFactory.GetDefaultScheduler().Result; scheduler.Start(); IJobDetail jobDetail = JobBuilder.Create(type).WithIdentity("job1", "job1").Build(); ITrigger trigger = TriggerBuilder.Create() .WithIdentity("trigger1", "trigger1") .WithSimpleSchedule(s => s.WithIntervalInSeconds(3) .RepeatForever()).StartNow() .Build(); scheduler.ScheduleJob(jobDetail, trigger);
結果執行報錯,錯在這一行:
注意看 type ,是 MarshalByRefObject 型別.這型別,讓 Quartz.NET 怎麼建立 JobDetail...
於是,我又稍微改了改,讓 排程服務 新增 TestJob1.dll 的引用
同時,跨域按"引用"封送過來後,強轉為 Job1:
執行,沒毛病...
修改一下Job1,複製一下,看會不會報錯,居然OK了,沒有向上面提到的第一個問題那樣,報下面這個錯誤.
這意味這程式碼可以在不關閉 排程服務的情況切換版本了...
但是仔細一想,不對啊! 排程服務執行起來後,我怎麼新增引用......再說了,我怎麼知道要轉成哪個 Job 型別?
這時候,一句名言用上心頭:
凡是能用技術問題解決的問題,都可以通過包一層來解決 ,於是乎我改了一下程式碼:
新建了一個BaseJob類庫,通過 Nuget 安裝 Quartz.NET
三個類庫的引用關係為:
TestConsole(排程服務)引用 BaseJob,兩者都需要安裝 Quartz.NET
TestJob1 引用 BaseJob.
TestConsole 沒有引用 TestJob1
public abstract class BaseJob : MarshalByRefObject, IJob { public Task Execute(IJobExecutionContext context) { Run(); return Task.FromResult(0); } protected abstract void Run(); }
public class Job1 : BaseJob.BaseJob { protected override void Run() { Console.WriteLine("版本1"); } }
排程服務中,跨域按"引用"封送後強轉成 BaseJob
執行一下,看看效果:
肯定是扯蛋的嘛!
排程服務都沒有引用 TestJob1 怎麼可能拿得到 Job1 的 Type,拿到的 Type 只會是 BaseJob
什麼?關閉排程服務,把 TestJob1.dll copy到 排程服務的執行目錄下.嗯,這個方法能解決問題.
但是,我想說一句:
"what are you talking about"
我徹底懵逼了......
長時間的掙扎後,終於,在部落格園找到一位大神2年前的一篇文章: https://www.cnblogs.com/zhuzhiyuan/p/6180870.html
當時看了不到幾行, " 作業管理(執行)池 " 幾個字簡直讓我醍醐灌頂!!!
至於後面的故事,大家可以看大神的文章了......
不過我這裡還是繼續寫,算是對自己開發過程的一個總結.
要用"池"的概念,就必須提到Quartz.NET 框架的兩個知識點:
為了更好理解,我們先新建一個 JobCenter , 這是也我這個框架用的到類:
public class JobCenter: IJob { public async Task Execute(IJobExecutionContext context) { await Task.FromResult(0); } }
第一個知識點:
我們在建立一個JobDetail 的時候,需要通過 WithIdentity 方法註冊"名稱"和"分組",如:
IJobDetail jobDetail = JobBuilder.Create<JobCenter>() .WithIdentity("測試名稱","測試組") .Build();
大家完全可以這樣理解:
就當下面的紅色程式碼被"某種"神祕力量隱藏了,而 WithIdentity("測試名稱","測試組") 方法就相當於是例項化 JobCenter 型別時傳入了兩個入參.
上面的程式碼建立了一個 jobDetail,等同於建立了一個 JobCenter 型別的例項,其中建構函式的入參是"測試名稱"和"測試組".
public class JobCenter : IJob { private readonly string _name; private readonly string _group; public JobCenter(string name, string group) { _name = name; _group = group; } public async Task Execute(IJobExecutionContext context) { //裡面是具體的邏輯 await Task.FromResult(0); } }
第二個知識點:
我們建立一個 JobDetail 的時候,是可以通過 SetJobData(...) 方法來儲存資料的,比如紅色部分:
var data = new Dictionary<string, object>() { ["jobInfo"] = new JobInfo()//JobInfo 這個類後面會講到 }; IJobDetail jobDetail = JobBuilder.Create<JobCenter>() .WithIdentity("測試名稱","測試組") .SetJobData(new JobDataMap(data)) .Build();
這兩個知識點 + 作業池+跨 AppDomain 按"引用"封送就構成了整個框架的核心.
由於定義了一個JobCenter,並且用到了池,所以 BaseJob 也不需要繼承 IJob 了:
/// <summary> /// 邏輯Job基類 /// </summary> public abstract class BaseJob : MarshalByRefObject {/// <summary> /// 具體邏輯 /// </summary> protected abstract void Execute(); /// <summary> /// 將物件生存期更改為永久,因為預設5分鐘不呼叫,會被回收. /// </summary> /// <returns></returns> public override object InitializeLifetimeService() { return null; } }
核心虛擬碼:
//定義一個Job池. //在建立 jobDetail 前,先建立邏輯job,即通過跨域按"引用"封送,拿到邏輯job的代理物件的引用. //然後在建立 jobDetail 的時候,將該 jobDetail 的資訊 jobInfo 存入 JobDataMap 永久儲存起來. //同時,將該 jobDetail 執行時所要正真呼叫的 邏輯job(也就是 BaseJob 的子類)資訊存入 job 池. //trigger 觸發時, //從該 jobDetail 儲存的資料中取出 jobInfo //根據 jobInfo 從 job 池中查詢 對應的 邏輯job. //呼叫 邏輯job 的 Execute()方法執行具體邏輯. 再簡單講就是,觸發器觸發一個作業時,作業先去作業池找到屬於它自己的邏輯作業,然後再執行邏輯作業.
這裡提前講一點:
作業池是在記憶體中,如果宕機是會丟失的;
而 JobDetail 和 Trigger 的資料都是在資料庫中,不會丟失.(框架採用了官方的持久化方案).
所以需要寫程式碼來處理這種意外情況.
終於...上面提到的3個問題被完全解決了...萬里長征終於邁出了第一步!!!