這篇文章還是回到實際的基礎封裝過程實現層面,用一個小東西來演示如何在常見業務代碼中梳理職責 ...
有段日子沒有更新,寫點東西冒個泡 。這篇文章過來講個小東西,也是大家在日常開發中也經常需要面臨的問題:後臺定時任務處理。估計大家看到這句就已經聯想到 QuartZ 等類似第三方類庫了,不好意思,後邊的事情和它們沒有關係。這裡要展開的是用.Net Core 下的 Generic Host 配合封裝簡版定時任務處理框架的過程。至於什麼是Generic Host,簡單來說就是一個簡化版不含Http管道等的非Web應用托管宿主服務,至於它如何來,其內有著什麼樣的實現細節,官方介紹已經足夠。這篇文章主要還是回到實際的基礎封裝過程實現層面,用一個小東西來演示如何在常見業務代碼中梳理職責,內容主要如下:
1. 概要分解
2. 封裝實現
3. 示例演示
4. 註意事項
一. 概要分解
如果對Generic Host 已經有瞭解的同學可能也看過網上其他文章,大多也都介紹用它如何實現定時任務處理。這些文章基本提供了一個通用實現,對業務實現還是稍顯啰嗦。這兩天整理邏輯有個任務不得不臨時定時處理,想到這個東西,花了點時間處理了下,東西不複雜不過還是想把這個思路分享給需要的朋友。
定時任務,分解來看特別簡單,就是兩個維度“ 定時 + 任務 ”,如果還有另外一個維度,那就是 任務運行的托管服務。在托管平臺上添加定時規則,根據規則觸發任務,工作結束。
1. 關於定時,主要就是一套任務觸發的規則,其作為一個調度者,只需要關心的是 在什麼時間,以何種頻率 觸發任務。 在.Net 下我們通過定時器(Timer - 構造函數包含這兩個核心參數,.net 下有兩個Timer實現,一個是System.Timer.Timer,一個是System.Threading.Timer, 這裡用的第二者,自由度更高)來實現,但是它不應該直接和具體的任務掛鉤,使用方也不應該每次都自己來處理Timer的初始化及相關回收釋放等相同操作,我們需要的是使用方只需告知框架層要執行什麼任務,和任務對應的時間規則。
2. 關於任務, 這個角色是一個任務的執行者, 定時調度者 告訴 任務執行者 在什麼時候開始執行和結束任務,其本身不會關註調度的實現。
3. 關於托管服務,也就是已經說過的Generic Host,當然你也可以使用windows服務等。它的職責就是保證給任務提供執行環境,並告訴任務定時器當前服務在什麼時候開始運行和關閉。 實現時提供了統一 IHostedService 介面,具體實現下邊實現會有展示。Generic Host 啟動方式有兩種形式:
a. 如果是.NetCore 站點,預設已經包含,只需要在 ConfigureServices 時註冊具體實現即可。
b. 可以獨立創建,比如控制台通過 new HostBuilder() 形式啟動,具體參見官方文檔。
為了更直觀的展示相關之間的關係,這裡我畫了個類圖來分解相關的職責,同時也是後邊具體實現的主要內容:
二. 封裝實現
從上邊類圖可以看出當前基礎框架主要由 BaseJobTrigger(觸發器基類),IJobExcutor(任務執行者介面),ListJobExcutor<IType>(通用列表迴圈任務執行者基類)。下邊分別就上邊三者貼出具體實現。
1. BaseJobTrigger(觸發器基類),實現代碼如下:
public abstract class BaseJobTrigger : IHostedService, IDisposable { private Timer _timer; private readonly TimeSpan _dueTime; private readonly TimeSpan _periodTime; private readonly IJobExecutor _jobExcutor; /// <summary> /// 構造函數 /// </summary> /// <param name="dueTime">到期執行時間</param> /// <param name="periodTime">間隔時間</param> /// <param name="jobExcutor">任務執行者</param> protected BaseJobTrigger(TimeSpan dueTime, TimeSpan periodTime, IJobExecutor jobExcutor) { _dueTime = dueTime; _periodTime = periodTime; _jobExcutor = jobExcutor; } #region 計時器相關方法 private void StartTimerTrigger() { if (_timer == null) _timer = new Timer(ExcuteJob,_jobExcutor,_dueTime, _periodTime); else _timer.Change(_dueTime, _periodTime); } private void StopTimerTrigger() { _timer?.Change(Timeout.Infinite, Timeout.Infinite); } private void ExcuteJob(object obj) { try { var excutor = obj as IJobExecutor; excutor?.StartJob(); } catch (Exception e) { LogUtil.Error($"執行任務({nameof(GetType)})時出錯,信息:{e}"); } } #endregion /// <summary> /// 系統級任務執行啟動 /// </summary> /// <returns></returns> public virtual Task StartAsync(CancellationToken cancellationToken) { try { StartTimerTrigger(); } catch (Exception e) { LogUtil.Error($"啟動定時任務({nameof(GetType)})時出錯,信息:{e}"); } return Task.CompletedTask; } /// <summary> /// 系統級任務執行關閉 /// </summary> /// <returns></returns> public virtual Task StopAsync(CancellationToken cancellationToken) { try { _jobExcutor.StopJob(); StopTimerTrigger(); } catch (Exception e) { LogUtil.Error($"停止定時任務({nameof(GetType)})時出錯,信息:{e}"); } return Task.CompletedTask; } public void Dispose() { _timer?.Dispose(); } }
這個主要是完成對定時器的封裝,StartAsync和StopAsync 為 IHostService 系統服務介面,表示托管服務的開始和結束。
2. IJobExcutor(任務執行者介面)
public interface IJobExecutor { /// <summary> /// 開始任務 /// </summary> void StartJob(); /// <summary> /// 結束任務 /// </summary> void StopJob(); }
3. ListJobExcutor<IType>(通用列表迴圈任務執行者基類)
public abstract class ListJobExcutor<IType>
: IJobExecutor { /// <summary> /// 運行狀態 /// </summary> public bool IsRuning { get;protected set; }
/// <summary> /// 開始任務 /// </summary> public void StartJob() { // 任務依然在執行中,不需要再次喚起 if (IsRuning) return; IsRuning = true; IList<IType> list = null; // 結清實體list do { for (var i = 0; IsRuning && i < list?.Count;i++) { ExcuteItem(list[i],i); } list = GetExcuteSource(); } while (IsRuning && list?.Count > 0); IsRuning = false; }
public void StopJob() { IsRuning = false; } /// <summary> /// 獲取list數據源 /// </summary> /// <returns></returns> protected virtual IList<IType> GetExcuteSource() { return null; } /// <summary> /// 個體任務執行 /// </summary> /// <param name="item">單個實體</param> /// <param name="index">在數據源中的索引</param> protected virtual void ExcuteItem(IType item,int index) { } }
這個是通用列表迴圈執的基礎封裝,因為業務中需要定時處理的大多是需要從資料庫或文件批量獲取數據,執行處理,例如到期提醒,定時清理超時訂單等場景。
其主要功能實現是 從 GetExcuteSource() 獲取執行數據源,迴圈並通過 ExcuteItem() 執行個體任務,直到沒有數據源返回,則此次任務執行結束,等待下次任務觸發。如果當次執行時間過長,超過計時器時間間隔,重覆觸發時 當前任務還在進行中,則不做任何處理。如果數據量過大需要併發執行,子類可以在 ExcuteItem 中非同步處理。這樣既可保證併發順序執行。
三. 示例演示
以上三個元素就構成了當前定時任務的主要基礎框架,在實際處理一個任務的過程中,我們需要定義一個執行者(XXXJobExcutor),一個觸發器(XXXJobTrigger,構造函數傳入觸發時間,間隔,執行者)即可。這裡用兩個示例來做演示
1. 基礎任務處理
public class TestJobTrigger:BaseJobTrigger { public TestJobTrigger() : base(TimeSpan.Zero, TimeSpan.FromMinutes(10), new TestJobExcutor()) { } } public class TestJobExcutor : IJobExecutor { public void StartJob() { LogUtil.Info("執行任務!"); } public void StopJob() { LogUtil.Info("系統終止任務"); } }
以上實現了TestJobTrigger 做任務觸發器,十分鐘執行一次。TestJobExcutor 作為具體執行者,做任務處理。啟動時只需在Startup.cs 中的ConfigureServices方法中添加如下代碼即可:
services.AddHostedService<TestJobTrigger>();
2. 列表迴圈處理
public class ListJobTrigger : BaseJobTrigger { public ListJobTrigger() : base(TimeSpan.Zero, TimeSpan.FromMinutes(10), new ListJobExcutor()) { } } public class ListJobExcutor : ListJobExcutor<string> { private int _page = 0; protected override IList<string> GetExcuteSource() { if (_page==0) { _page++; return new List<string>{ "1", "2", "3" }; } return null; } protected override void ExcuteItem(string item, int index) { LogUtil.Info(item); } }
這個示例定時獲取字元串列表,並列印。一樣在Startup中註冊即可。
四. 註意事項
1. 關於何時使用定時任務的問題
之所以要說這個問題,是因為我看過不少同學把定時任務這種方式當成萬能膠,哪裡有縫往哪貼,一個不行起兩個。其實有很多場景都可以通過其關聯事件加消息隊列來完成,比如發簡訊,接收發送請求後塞消息隊列並返回請求方接收成功,隊列消費者來負責和簡訊服務商介面交互。只有對一些對時間屬性有要求的處理,咱們通過定時任務等處理,如.....會員生日提醒....
2. 關於框架元素在解決方案的引用放置
一個建議: IJobExcutor,ListJobExcutor<IType> 可以放置在通用類庫中,BaseJobTrigger,因為其依賴IHostService 放置在站點目錄下比較合適。
3. 關於GenericHost的生存周期問題
如果你使用的是控制台啟動,則此問題暫時可以忽略。
如果你使用的是站點項目,並且還是通過IIS啟動,那麼你可能要註意了,因為.net core 的站點自身是有HOST宿主處理,IIS是其上代理,其啟動關閉,埠映射等由IIS內部完成。所以其依然受限於IIS的閑置回收影響,當IIS閑置回收時,其後的.Net Host也會被一同關閉,需要有新的請求進來時才會再次啟動。不過鑒於當前任務處理已經如此簡單,有個取巧的做法,實現一個站點自身的心跳檢測任務,IIS預設20分鐘回收,任務時間可以設為15分鐘(你也可以設置IIS站點回收時間),當然如果你的任務如果沒有那麼嚴格的時間要求你也可以不用處理,因為回收後一旦接受到新的請求,任務會再次發起。
如果你已經看到這裡,並且感覺還行的話可以在下方點個贊,或者也可以關註我的公總號(見二維碼)
_________________________________________