0. 簡介
在某些時候我們可能會需要執行後臺任務,或者是執行一些週期性的任務。比如說可能每隔 1 個小時要清除某個臨時資料夾內的資料,可能使用者會要針對某一個使用者群來群發一組簡訊。前面這些就是典型的應用場景,在 Abp 框架裡面為我們準備了後臺作業和後臺工作者來幫助我們解決這個問題。
後臺作業與後臺工作者的區別是,前者主要用於某些耗時較長的任務,而不想阻塞使用者的時候所使用。後者主要用於週期性的執行某些任務,從 “工作者” 的名字可以看出來,就是一個個工人,而且他們每個工人都擁有單獨的後臺執行緒。
0.1 典型場景
後臺作業
- 某個使用者按下了報表按鈕來生成一個需要長時間等待的報表。你新增這個工作到佇列中,當報表生成完畢後,傳送報表結果到該使用者的郵箱。
- 在後臺作業中傳送一封郵件,有些問題可能會導致傳送失敗(網路連線異常,或者主機當機);由於有後臺作業以及持久化機制,在問題排除後,可以重試以保證任務的成功執行。
後臺工作者
- 後臺工作者能夠週期性地執行舊日誌的刪除。
- 後臺工作者可以週期性地篩選出非活躍性使用者,並且傳送迴歸郵件給這些使用者。
1. 啟動流程
後臺作業與後臺工作者都是通過各自的 Manager(IBackgroundJobManager
/IBackgroundWorkerManager
) 來進行管理的。而這兩個 Manager 分別繼承了 ISingletonDependency
介面,所以在啟動的時候就會自動注入這兩個管理器以便開發人員管理操作。
這裡值得注意的一點是,IBackgroundJobManager
介面是 IBackgroundWorker
的派生介面,而 IBackgroudWorker
是歸屬於 IBackgroundWorkerManager
進行管理的。
所以,你可以在 AbpKernelModule
裡面看到如下程式碼:
public sealed class AbpKernelModule : AbpModule
{
public override void PostInitialize()
{
// 註冊可能缺少的元件
RegisterMissingComponents();
// ... 忽略的程式碼
// 各種管理器的初始化操作
// 從配置項中讀取,是否啟用了後臺作業功能
if (Configuration.BackgroundJobs.IsJobExecutionEnabled)
{
var workerManager = IocManager.Resolve<IBackgroundWorkerManager>();
// 開始啟動後臺工作者
workerManager.Start();
// 增加後臺作業管理器
workerManager.Add(IocManager.Resolve<IBackgroundJobManager>());
}
}
}
可以看到,後臺作業管理器是作為一個後臺工作者被新增到了 IBackgroundWorkerManager
當中來執行的。
2. 程式碼分析
2.1 後臺工作者
2.1.1 後臺工作者管理器
Abp 通過後臺工作者管理器來管理後臺作業佇列,所以我們首先來看一下後臺工作者管理器介面的定義是什麼樣子的。
public interface IBackgroundWorkerManager : IRunnable
{
void Add(IBackgroundWorker worker);
}
還是相當簡潔的,就一個 Add
方法用來新增一個新的後臺工作者物件。只是在這個地方,可以看到該介面又是整合自 IRunnable
介面,那麼該介面的作用又是什麼呢?
轉到其定義可以看到,IRunable
介面定義了三個基本的方法:Start()
、Stop()
、WaitStop()
,而且他擁有一個預設實現 RunableBase
,其實就是用來標識一個任務的執行狀態。
public interface IRunnable
{
// 開始執行任務
void Start();
// 停止執行任務
void Stop();
// 阻塞執行緒,等待任務執行完成後標識為停止。
void WaitToStop();
}
public abstract class RunnableBase : IRunnable
{
// 用於標識任務是否執行的布林值變數
public bool IsRunning { get { return _isRunning; } }
private volatile bool _isRunning;
// 啟動之後表示任務正在執行
public virtual void Start()
{
_isRunning = true;
}
// 停止之後表示任務結束執行
public virtual void Stop()
{
_isRunning = false;
}
public virtual void WaitToStop()
{
}
}
到目前為止整個程式碼都還是比較簡單清晰的,我們接著看 IBackgroundWorkerManager
的預設實現 BackgroundWorkerManager
類,首先我們看一下該類擁有哪些屬性與欄位。
public class BackgroundWorkerManager : RunnableBase, IBackgroundWorkerManager, ISingletonDependency, IDisposable
{
private readonly IIocResolver _iocResolver;
private readonly List<IBackgroundWorker> _backgroundJobs;
public BackgroundWorkerManager(IIocResolver iocResolver)
{
_iocResolver = iocResolver;
_backgroundJobs = new List<IBackgroundWorker>();
}
}
在後臺工作者管理器類的內部,預設有一個 List 集合,用於維護所有的後臺工作者物件。那麼其他的 Start()
等方法肯定是基於這個集合進行操作的。
public override void Start()
{
base.Start();
_backgroundJobs.ForEach(job => job.Start());
}
public override void Stop()
{
_backgroundJobs.ForEach(job => job.Stop());
base.Stop();
}
public override void WaitToStop()
{
_backgroundJobs.ForEach(job => job.WaitToStop());
base.WaitToStop();
}
可以看到實現還是比較簡單的,接下來我們繼續看他的 Add()
方法是如何進行操作的?
public void Add(IBackgroundWorker worker)
{
_backgroundJobs.Add(worker);
if (IsRunning)
{
worker.Start();
}
}
在這裡我們看到他會針對 IsRunning
進行判定是否立即啟動加入的後臺工作者物件。而這個 IsRunning
屬性值唯一產生變化的情況就在於 Start()
方法與 Stop()
方法的呼叫。
最後肯定也有相關的銷燬方法,用於釋放所有注入的後臺工作者物件,並將集合清除。
private bool _isDisposed;
public void Dispose()
{
if (_isDisposed)
{
return;
}
_isDisposed = true;
// 遍歷集合,通過 Ioc 解析器的 Release 方法釋放物件
_backgroundJobs.ForEach(_iocResolver.Release);
// 清空集合
_backgroundJobs.Clear();
}
所以,針對於所有後臺工作者的管理,都是通過 IBackgroundWorkerManager
來進行操作的。
2.1.2 後臺工作者
看完了管理器,我們來看一下 IBackgroundWorker
後臺工作者物件是怎樣的構成。
public interface IBackgroundWorker : IRunnable
{
}
貌似只是一個空的介面,其作用主要是標識某個型別是否為後臺工作者,轉到其抽象類實現 BackgroundWorkerBase
,裡面只是注入了一些輔助物件與本地化的一些方法。
public abstract class BackgroundWorkerBase : RunnableBase, IBackgroundWorker
{
// 配置管理器
public ISettingManager SettingManager { protected get; set; }
// 工作單元管理器
public IUnitOfWorkManager UnitOfWorkManager
{
get
{
if (_unitOfWorkManager == null)
{
throw new AbpException("Must set UnitOfWorkManager before use it.");
}
return _unitOfWorkManager;
}
set { _unitOfWorkManager = value; }
}
private IUnitOfWorkManager _unitOfWorkManager;
// 獲得當前的工作單元
protected IActiveUnitOfWork CurrentUnitOfWork { get { return UnitOfWorkManager.Current; } }
// 本地化資源管理器
public ILocalizationManager LocalizationManager { protected get; set; }
// 預設的本地化資源的源名稱
protected string LocalizationSourceName { get; set; }
protected ILocalizationSource LocalizationSource
{
get
{
// 如果沒有配置源名稱,直接丟擲異常
if (LocalizationSourceName == null)
{
throw new AbpException("Must set LocalizationSourceName before, in order to get LocalizationSource");
}
if (_localizationSource == null || _localizationSource.Name != LocalizationSourceName)
{
_localizationSource = LocalizationManager.GetSource(LocalizationSourceName);
}
return _localizationSource;
}
}
private ILocalizationSource _localizationSource;
// 日誌記錄器
public ILogger Logger { protected get; set; }
protected BackgroundWorkerBase()
{
Logger = NullLogger.Instance;
LocalizationManager = NullLocalizationManager.Instance;
}
// ... 其他模板程式碼
}
我們接著看繼承並實現了 BackgroundWorkerBase
的型別 PeriodicBackgroundWorkerBase
,從字面意思上來看,該型別應該是一個定時後臺工作者基類。
重點在於 Periodic
(定時),從其型別內部的定義可以看到,該型別使用了一個 AbpTimer
物件來進行週期計時與具體工作任務的觸發。我們暫時先不看這個 AbpTimer
,僅僅看 PeriodicBackgroundWorkerBase
的內部實現。
public abstract class PeriodicBackgroundWorkerBase : BackgroundWorkerBase
{
protected readonly AbpTimer Timer;
// 注入 AbpTimer
protected PeriodicBackgroundWorkerBase(AbpTimer timer)
{
Timer = timer;
// 繫結週期執行的任務,這裡是 DoWork()
Timer.Elapsed += Timer_Elapsed;
}
public override void Start()
{
base.Start();
Timer.Start();
}
public override void Stop()
{
Timer.Stop();
base.Stop();
}
public override void WaitToStop()
{
Timer.WaitToStop();
base.WaitToStop();
}
private void Timer_Elapsed(object sender, System.EventArgs e)
{
try
{
DoWork();
}
catch (Exception ex)
{
Logger.Warn(ex.ToString(), ex);
}
}
protected abstract void DoWork();
}
可以看到,這裡基類繫結了 DoWork()
作為其定時執行的方法,那麼使用者在使用的時候直接繼承自該基類,然後重寫 DoWork()
方法即可繫結自己的後臺工作者的任務。
2.1.3 AbpTimer 定時器
在上面的基類我們看到,基類的 Start()
、Stop()
、WaitTpStop()
方法都是呼叫的 AbpTimer
所提供的,所以說 AbpTimer
其實也繼承了 RunableBase
基類並實現其具體的啟動與停止操作。
其實 AbpTimer
的核心就是通過 CLR 的 Timer
來實現週期性任務執行的,不過預設的 Timer
類有兩個比較大的問題。
- CLR 的
Timer
並不會等待你的任務執行完再執行下一個週期的任務,如果你的某個任務耗時過長,超過了Timer
定義的週期。那麼Timer
會開啟一個新的執行緒執行,這樣的話最後我們系統的資源會因為執行緒大量重複建立而被拖垮。 - 如何知道一個
Timer
所執行的業務方法已經真正地被結束了。
所以 Abp 才會重新封裝一個 AbpTimer
作為一個基礎的計時器。第一個問題的解決方法很簡單,就是在執行具體繫結的業務方法之前,通過 Timer.Change()
方法來讓 Timer
臨時失效。等待業務方法執行完成之後,再將 Timer
的週期置為使用者設定的週期。
// CLR Timer 繫結的回撥方法
private void TimerCallBack(object state)
{
lock (_taskTimer)
{
if (!_running || _performingTasks)
{
return;
}
// 暫時讓 Timer 失效
_taskTimer.Change(Timeout.Infinite, Timeout.Infinite);
// 設定執行標識為 TRUE,表示當前的 AbpTimer 正在執行
_performingTasks = true;
}
try
{
// 如果繫結了相應的觸發事件
if (Elapsed != null)
{
// 執行相應的業務方法,這裡就是最開始繫結的 DoWork() 方法
Elapsed(this, new EventArgs());
}
}
catch
{
}
finally
{
lock (_taskTimer)
{
// 標識業務方法執行完成
_performingTasks = false;
if (_running)
{
// 更改週期為使用者指定的執行週期,等待下一次觸發
_taskTimer.Change(Period, Timeout.Infinite);
}
Monitor.Pulse(_taskTimer);
}
}
}
針對於第二個問題,Abp 通過 WaitToStop()
方法會阻塞呼叫這個 Timer
的執行緒,並且在 _performingTasks
標識位是 false
的時候釋放。
public override void WaitToStop()
{
// 鎖定 CLR 的 Timer 物件
lock (_taskTimer)
{
// 迴圈檢測
while (_performingTasks)
{
Monitor.Wait(_taskTimer);
}
}
base.WaitToStop();
}
至於其他的 Start()
方法就是使用 CLR 的 Timer
更改其執行週期,而 Stop()
就是直接將 Timer
的週期設定為無限大,使計時器失效。
2.1.4 總結
Abp 後臺工作者的核心就是通過 AbpTimer
來實現週期性任務的執行,使用者只需要繼承自 PeriodicBackgroundWorkerBase
,然後將其新增到 IBackgroundWorkerManager
的集合當中。這樣 Abp 在啟動之後就會遍歷這個工作者集合,然後週期執行這些後臺工作者繫結的方法。
當然如果你繼承了 PeriodicBackgroundWorkerBase
之後,可以通過設定建構函式的 AbpTimer
來指定自己的執行週期。
2.2 後臺作業佇列
後臺工作佇列的管理是通過 IBackgroundJobManager
來處理的,而該介面又繼承自 IBackgroundWorker
,所以一整個後臺作業佇列就是一個後臺工作者,只不過這個工作者有點特殊。
2.2.1 後臺作業管理器
IBackgroundJobManager
介面的定義其實就兩個方法,一個 EnqueueAsync<TJob, TArgs>()
用於將一個後臺作業加入到執行佇列當中。而 DeleteAsync()
方法呢,顧名思義就是從佇列當中移除指定的後臺作業。
首先看一下其預設實現 BackgroundJobManager
,該實現同樣是繼承自 PeriodicBackgroundWorkerBase
並且其預設週期為 5000 ms。
public class BackgroundJobManager : PeriodicBackgroundWorkerBase, IBackgroundJobManager, ISingletonDependency
{
// 事件匯流排
public IEventBus EventBus { get; set; }
// 輪訓後臺作業的間隔,預設值為 5000 毫秒.
public static int JobPollPeriod { get; set; }
// IOC 解析器
private readonly IIocResolver _iocResolver;
// 後臺作業佇列儲存
private readonly IBackgroundJobStore _store;
static BackgroundJobManager()
{
JobPollPeriod = 5000;
}
public BackgroundJobManager(
IIocResolver iocResolver,
IBackgroundJobStore store,
AbpTimer timer)
: base(timer)
{
_store = store;
_iocResolver = iocResolver;
EventBus = NullEventBus.Instance;
Timer.Period = JobPollPeriod;
}
}
基礎結構基本上就這個樣子,接下來看一下他的兩個介面方法是如何實現的。
EnqueueAsync<TJob, TArgs>
方法通過傳入指定的後臺作業物件和相應的引數,同時還有任務的優先順序。將其通過 IBackgroundJobStore
進行持久化,並返回一個任務的唯一 JobId 以便進行刪除操作。
public async Task<string> EnqueueAsync<TJob, TArgs>(TArgs args, BackgroundJobPriority priority = BackgroundJobPriority.Normal, TimeSpan? delay = null)
where TJob : IBackgroundJob<TArgs>
{
// 通過 JobInfo 包裝任務的基本資訊
var jobInfo = new BackgroundJobInfo
{
JobType = typeof(TJob).AssemblyQualifiedName,
JobArgs = args.ToJsonString(),
Priority = priority
};
// 如果需要延時執行的話,則用當前時間加上延時的時間作為任務下次執行的時間
if (delay.HasValue)
{
jobInfo.NextTryTime = Clock.Now.Add(delay.Value);
}
// 通過 Store 進行持久話儲存
await _store.InsertAsync(jobInfo);
// 返回後臺任務的唯一標識
return jobInfo.Id.ToString();
}
至於刪除操作,在 Manager 內部其實也是通過 IBackgroundJobStore
進行實際的刪除操作的。
public async Task<bool> DeleteAsync(string jobId)
{
// 判斷 jobId 的值是否有效
if (long.TryParse(jobId, out long finalJobId) == false)
{
throw new ArgumentException($"The jobId `{jobId}` should be a number.", nameof(jobId));
}
// 使用 jobId 從 Store 處篩選到 JobInfo 物件的資訊
BackgroundJobInfo jobInfo = await _store.GetAsync(finalJobId);
if (jobInfo == null)
{
return false;
}
// 如果存在有 JobInfo 則使用 Store 進行刪除操作
await _store.DeleteAsync(jobInfo);
return true;
}
後臺作業管理器實質上是一個週期性執行的後臺工作者,那麼我們的後臺作業是每 5000 ms 執行一次,那麼他的 DoWork()
方法又在執行什麼操作呢?
protected override void DoWork()
{
// 從 Store 當中獲得等待執行的後臺作業集合
var waitingJobs = AsyncHelper.RunSync(() => _store.GetWaitingJobsAsync(1000));
// 遍歷這些等待執行的後臺任務,然後通過 TryProcessJob 進行執行
foreach (var job in waitingJobs)
{
TryProcessJob(job);
}
}
可以看到每 5 秒鐘我們的後臺作業管理器就會從 IBackgroundJobStore
當中拿到最大 1000 條的後臺作業資訊,然後遍歷這些資訊。通過 TryProcessJob(job)
方法來執行後臺作業。
而 TryProcessJob()
方法,本質上就是通過反射構建出一個 IBackgroundJob
物件,然後取得序列化的引數值,通過反射得到的 MethodInfo
物件來執行我們的後臺任務。執行完成之後,就會從 Store 當中移除掉執行完成的任務。
針對於在執行過程當中所出現的異常,會通過 IEventBus
觸發一個 AbpHandledExceptionData
事件記錄後臺作業執行失敗時的異常資訊。並且一旦在執行過程當中出現了任何異常的情況,都會將該任務的 IsAbandoned
欄位置為 true
,當該欄位為 true
時,該任務將不再回被執行。
PS:就是在
GetWaitingJobsAsync()
方法時,會過濾掉 IsAbandoned 值為true
的任務。
private void TryProcessJob(BackgroundJobInfo jobInfo)
{
try
{
// 任務執行次數自增 1
jobInfo.TryCount++;
// 最後一次執行時間設定為當前時間
jobInfo.LastTryTime = Clock.Now;
// 通過反射取得後臺作業的型別
var jobType = Type.GetType(jobInfo.JobType);
// 通過 Ioc 解析器得到一個臨時的後臺作業物件,執行完之後既被釋放
using (var job = _iocResolver.ResolveAsDisposable(jobType))
{
try
{
// 通過反射得到後臺作業的 Execute 方法
var jobExecuteMethod = job.Object.GetType().GetTypeInfo().GetMethod("Execute");
var argsType = jobExecuteMethod.GetParameters()[0].ParameterType;
var argsObj = JsonConvert.DeserializeObject(jobInfo.JobArgs, argsType);
// 結合持久話儲存的引數資訊,呼叫 Execute 方法進行後臺作業
jobExecuteMethod.Invoke(job.Object, new[] { argsObj });
// 執行完成之後從 Store 刪除該任務的資訊
AsyncHelper.RunSync(() => _store.DeleteAsync(jobInfo));
}
catch (Exception ex)
{
Logger.Warn(ex.Message, ex);
// 計算下一次執行的時間,一旦超過 2 天該任務都執行失敗,則返回 null
var nextTryTime = jobInfo.CalculateNextTryTime();
if (nextTryTime.HasValue)
{
jobInfo.NextTryTime = nextTryTime.Value;
}
else
{
// 如果為 null 則說明該任務在 2 天的時間內都沒有執行成功,則放棄繼續執行
jobInfo.IsAbandoned = true;
}
// 更新 Store 儲存的任務資訊
TryUpdate(jobInfo);
// 觸發異常事件
EventBus.Trigger(
this,
new AbpHandledExceptionData(
new BackgroundJobException(
"A background job execution is failed. See inner exception for details. See BackgroundJob property to get information on the background job.",
ex
)
{
BackgroundJob = jobInfo,
JobObject = job.Object
}
)
);
}
}
}
catch (Exception ex)
{
Logger.Warn(ex.ToString(), ex);
// 表示任務不再執行
jobInfo.IsAbandoned = true;
// 更新 Store
TryUpdate(jobInfo);
}
}
2.2.2 後臺作業
後臺作業的預設介面定義為 IBackgroundJob<in TArgs>
,他只有一個 Execute(TArgs args)
方法,用於接收指定型別的作業引數,並執行。
一般來說我們不建議直接通過繼承 IBackgroundJob<in TArgs>
來實現後臺作業,而是繼承自 BackgroundJob<TArgs>
抽象類。該抽象類內部也沒有什麼特別的實現,主要是注入了一些基礎設施,比如說 UOW 與 本地化資源管理器,方便我們開發使用。
後臺作業本身是具體執行的物件,而 BackgroundJobInfo
則是儲存了後臺作業的 Type 型別和引數,方便在需要執行的時候通過反射的方式執行後臺作業。
2.2.2 後臺作業佇列儲存
從 IBackgroundJobStore
我們就可以猜到以 Abp 框架的套路,他肯定會有兩種實現,第一種就是基於記憶體的 InMemoryBackgroundJobStore
。而第二種呢,就是由 Abp.Zero 模組所提供的基於資料庫的 BackgroundJobStore
。
IBackgroundJobStore
介面所定義的方法基本上就是增刪改查,沒有什麼複雜的。
public interface IBackgroundJobStore
{
// 通過 JobId 獲取後臺任務資訊
Task<BackgroundJobInfo> GetAsync(long jobId);
// 插入一個新的後臺任務資訊
Task InsertAsync(BackgroundJobInfo jobInfo);
/// <summary>
/// Gets waiting jobs. It should get jobs based on these:
/// Conditions: !IsAbandoned And NextTryTime <= Clock.Now.
/// Order by: Priority DESC, TryCount ASC, NextTryTime ASC.
/// Maximum result: <paramref name="maxResultCount"/>.
/// </summary>
/// <param name="maxResultCount">Maximum result count.</param>
Task<List<BackgroundJobInfo>> GetWaitingJobsAsync(int maxResultCount);
/// <summary>
/// Deletes a job.
/// </summary>
/// <param name="jobInfo">Job information.</param>
Task DeleteAsync(BackgroundJobInfo jobInfo);
/// <summary>
/// Updates a job.
/// </summary>
/// <param name="jobInfo">Job information.</param>
Task UpdateAsync(BackgroundJobInfo jobInfo);
}
這裡先從簡單的記憶體 Store 說起,這個 InMemoryBackgroundJobStore
內部使用了一個並行字典來儲存這些任務資訊。
public class InMemoryBackgroundJobStore : IBackgroundJobStore
{
private readonly ConcurrentDictionary<long, BackgroundJobInfo> _jobs;
private long _lastId;
public InMemoryBackgroundJobStore()
{
_jobs = new ConcurrentDictionary<long, BackgroundJobInfo>();
}
}
相當簡單,這幾個介面方法基本上就是針對與這個並行字典操作的一層封裝。
public Task<BackgroundJobInfo> GetAsync(long jobId)
{
return Task.FromResult(_jobs[jobId]);
}
public Task InsertAsync(BackgroundJobInfo jobInfo)
{
jobInfo.Id = Interlocked.Increment(ref _lastId);
_jobs[jobInfo.Id] = jobInfo;
return Task.FromResult(0);
}
public Task<List<BackgroundJobInfo>> GetWaitingJobsAsync(int maxResultCount)
{
var waitingJobs = _jobs.Values
// 首先篩選出不再執行的後臺任務
.Where(t => !t.IsAbandoned && t.NextTryTime <= Clock.Now)
// 第一次根據後臺作業的優先順序進行排序,高優先順序優先執行
.OrderByDescending(t => t.Priority)
// 再根據執行次數排序,執行次數越少的,越靠前
.ThenBy(t => t.TryCount)
.ThenBy(t => t.NextTryTime)
.Take(maxResultCount)
.ToList();
return Task.FromResult(waitingJobs);
}
public Task DeleteAsync(BackgroundJobInfo jobInfo)
{
_jobs.TryRemove(jobInfo.Id, out _);
return Task.FromResult(0);
}
public Task UpdateAsync(BackgroundJobInfo jobInfo)
{
// 如果是不再執行的任務,刪除
if (jobInfo.IsAbandoned)
{
return DeleteAsync(jobInfo);
}
return Task.FromResult(0);
}
至於持久化到資料庫,無非是注入一個倉儲,然後針對這個倉儲進行增刪查改的操作罷了,這裡就不在贅述。
2.2.3 後臺作業優先順序
後臺作業的優先順序定義在 BackgroundJobPriority
列舉當中,一共有 5 個等級,分別是 Low
、BelowNormal
、Normal
、AboveNormal
、High
,他們從最低到最高排列。