在Asp.Net Core中高效封装BackgroundService
一、封装背景与目标
在实际项目里,后台任务的执行模式丰富多样。有的任务需要确保上一次执行完成后,下一次才开始,以避免资源冲突和数据不一致;而有的任务则要求按照固定时间间隔周期性执行,即便前一次尚未结束。同时,对任务的监控、管理以及灵活配置也至关重要。我们封装BackgroundService的核心目标,就是打造一个功能全面、易于使用且高度可定制的后台任务管理框架,满足不同任务执行模式的需求,并且能够方便地对任务进行启动、停止、查询以及状态监控等操作。
二、关键代码解析
(一)BaseJob抽象类
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Logging;
using Project.Infrastructure.Utils;
namespace Project.Infrastructure.JobModule;
public abstract class BaseJob : BackgroundService
{
private readonly ILogger<BaseJob> logger = ServiceLocator.GetService<ILogger<BaseJob>>();
private Timer? _timer;
public BaseJob()
{
Key = GetType().Name;
}
/// <summary>
/// Key
/// </summary>
public string Key { get; private set; }
/// <summary>
/// true为启动 false为停止
/// </summary>
private volatile bool _isRunning;
public bool IsRunning
{
get => _isRunning;
set => _isRunning = value;
}
/// <summary>
/// 信息
/// </summary>
public string? JobMsg { get; set; }
/// <summary>
/// 停止原因
/// </summary>
public string? StopMsg { get; set; }
/// <summary>
/// 启动时间
/// </summary>
public DateTime? StartTime { get; set; }
/// <summary>
/// 停止时间
/// </summary>
public DateTime? StopTime { get; set; }
/// <summary>
/// 备注
/// </summary>
public string? Remark { get; set; }
/// <summary>
/// 排序号(无实际作用 只是单纯在后台任务界面 排序所用)
/// </summary>
public short OrderId { get; set; } = 1;
/// <summary>
/// WorkContent
/// </summary>
public Func<Task> WorkContent { get; set; }
/// <summary>
/// 任务类型 默认使用While
/// </summary>
public EmJobType JobType { get; set; } = EmJobType.While;
/// <summary>
/// 轮询间隔时间 默认两秒,使用定时器请主动赋值两秒对于定时器太快了
/// </summary>
public TimeSpan Delay { get; set; } = TimeSpan.FromSeconds(2);
/// <summary>
/// (只有JobType设定为定时器时 才会有用)执行时间 默认为null 即为立即执行
/// </summary>
public TimeSpan? ExectTime { get; set; } = null;
public override async Task StartAsync(CancellationToken cancellationToken)
{
StartTime = DateTime.Now;
if (IsRunning == false)
{
StopMsg = string.Empty;
JobMsg = string.Empty;
await base.StartAsync(cancellationToken);
}
}
/// <summary>
/// 通常在使用时只需要赋值WorkContent即可 JobType默认为While Delay默认为2秒 如果JobType为Timer则Delay默认为1天以及ExectTime默认为立即执行
/// </summary>
/// <param name="stoppingToken"></param>
/// <exception cref="ArgumentNullException"></exception>
protected override async Task ExecuteAsync(CancellationToken stoppingToken)
{
if (WorkContent == null) throw new ArgumentNullException(nameof(WorkContent), $"key:{Key}的Work为空");
try
{
IsRunning = true;
logger.LogInformation($"Job {Key} 开始执行");
switch (JobType)
{
case EmJobType.While:
while (!stoppingToken.IsCancellationRequested)
{
try
{
await WorkContent.Invoke().ConfigureAwait(false);
await Task.Delay(Delay, stoppingToken).ConfigureAwait(false);
stoppingToken.ThrowIfCancellationRequested();
}
catch (Exception ex)
{
logger.LogError(ex, $"Job {Key} 执行出错: {ex.Message}");
JobMsg = $"执行出错: {ex.Message}";
}
}
break;
case EmJobType.Timer:
TimeSpan dueTime = TimeSpan.Zero;//默认为立即执行 如果不为空则为指定执行时间
if (ExectTime != null)
{
// 获取当前系统时间,包含年、月、日、时、分、秒等信息
DateTime now = DateTime.Now;
// 计算今天的目标执行时间:
// now.Date 会提取当前日期的零点时刻(即忽略时分秒)
// 然后使用 Add(ExectTime) 方法将时分秒设置为 ExectTime 指定的时间
// 例如,如果 ExectTime 是 15:00:00,则表示今天的 15 点整
DateTime targetTimeToday = now.Date.Add(ExectTime.Value);
// 处理跨天情况:
// 如果目标时间已经过了今天(例如当前时间是 16 点,而目标时间是 15 点)
if (targetTimeToday < now)
{
// 则将目标时间调整到明天的同一时间
// 通过 AddDays(1) 方法将日期加一天
targetTimeToday = targetTimeToday.AddDays(1);
}
// 计算首次执行的延迟时间:
// 用目标执行时间(包含日期)减去当前时间,得到距离首次执行的时间间隔
// 例如,当前时间是 10:00,目标时间是 15:00,则 dueTime 为 5 小时
// 如果当前时间是 16:00,目标时间是 15:00(已过),目标时间会调整为次日 15:00,则 dueTime 为 23 小时
dueTime = targetTimeToday - now;
}
_timer = new Timer(async _ =>
{
try
{
await WorkContent();
}
catch (Exception ex)
{
logger.LogError(ex, $"Job {Key} 执行出错: {ex.Message}");
JobMsg = $"执行出错: {ex.Message}";
}
}, null, dueTime, Delay);
await stoppingToken.WhenCanceled();
break;
case EmJobType.Single:
await WorkContent.Invoke();
logger.LogInformation($"Job {Key} 单次执行完成");
JobMsg = "单次Work已结束";
break;
}
}
catch (Exception ex)
{
JobMsg = "异常:" + ex.Message;
await StopAsync(stoppingToken, $"BaseJob捕获到异常已停止:{ex.Message}");
}
finally
{
IsRunning = false; // 确保 IsRunning 在方法退出时设置为 false
_timer?.Dispose(); // 释放定时器资源
}
}
public virtual async Task StopAsync(CancellationToken cancellationToken, string stopMsg)
{
logger.LogError($"Job {Key} 退出");
StopMsg = stopMsg;
StopTime = DateTime.Now;
IsRunning = false;
_timer?.Change(Timeout.Infinite, Timeout.Infinite); // 停止定时器
await base.StopAsync(cancellationToken);
}
/// <summary>
/// 更新JobMsg
/// </summary>
/// <param name="msg">更新的消息</param>
/// <param name="func">是否需要执行一些操作</param>
public void UpdJobMsg(string msg, Action? func = null)
{
if (msg != JobMsg)
{
JobMsg = msg;
func?.Invoke();
}
}
}
这个抽象类是整个后台任务框架的核心基石。它继承自BackgroundService,通过一系列属性和方法实现了对后台任务的全方位管理。
- 属性说明:
Key
:用于唯一标识每个任务实例,方便在管理和监控时进行区分。IsRunning
:实时反映任务的运行状态,便于外部进行状态判断和控制。JobMsg
:记录任务执行过程中的关键信息,比如错误提示或执行进度等,有助于排查问题。StopMsg
:明确任务停止的具体原因,为后续分析提供依据。StartTime
和StopTime
:精确记录任务的启动和停止时间,对于统计任务执行时长以及分析任务执行规律非常有用。Remark
:可用于添加任务的备注信息,方便开发人员了解任务的用途和特殊说明。OrderId
:虽然在实际任务执行逻辑中没有直接作用,但在展示任务列表时可用于排序,提升管理界面的可读性。WorkContent
:这是任务的核心执行逻辑,通过Func<Task>
类型定义,使得具体的任务操作可以灵活定制。JobType
:定义了任务的执行类型,包括While
(基于循环,确保上一次执行完成才进行下一次)、Timer
(定时器模式,按固定间隔执行,不考虑上一次是否完成)和Single
(单次执行任务)。Delay
:设置任务执行的时间间隔,在While
和Timer
模式下都有重要作用。ExectTime
:仅在Timer
模式下起作用,用于指定任务的具体执行时间,若为null
则表示立即执行。
- 方法说明:
StartAsync
:重写自BackgroundService
,在任务启动时被调用。它记录任务启动时间,并在任务未运行时初始化相关状态,然后调用基类的StartAsync
方法启动任务。ExecuteAsync
:同样重写自BackgroundService
,是任务执行的核心方法。首先检查WorkContent
是否为空,若为空则抛出异常。接着根据JobType
的不同值,分别执行不同的任务执行逻辑。在While
模式下,通过循环不断执行WorkContent
,并在每次执行后按照Delay
设置的时间间隔等待,同时处理可能出现的异常;在Timer
模式下,根据ExectTime
计算首次执行的延迟时间,并创建定时器,按照设定的间隔时间执行WorkContent
,同样处理异常情况;在Single
模式下,直接执行一次WorkContent
。无论哪种模式,在任务执行结束或出现异常时,都会进行相应的状态更新和资源释放操作。StopAsync
:用于停止任务。记录任务停止时间和原因,停止定时器(如果有),并调用基类的StopAsync
方法完成任务停止操作。UpdJobMsg
:用于更新JobMsg
属性,同时可以根据需要执行额外的操作,方便在任务执行过程中动态更新任务信息。
(二)任务类型枚举
/// <summary>
/// Job类型
/// </summary>
public enum EmJobType
{
/// <summary>
/// 使用While循环执行
/// </summary>
While = 0,
/// <summary>
/// 使用定时器循环
/// </summary>
Timer = 1,
/// <summary>
/// 单次任务
/// </summary>
Single = 2
}
这个枚举类型EmJobType
为任务执行模式提供了清晰的定义。While
模式适合那些对任务执行顺序有严格要求,不允许并发执行的场景,比如涉及数据库事务的复杂操作;Timer
模式适用于需要定期执行的任务,如定时数据备份、日志清理等;Single
模式则用于一次性的任务,例如系统初始化时的某些一次性配置操作。
(三)基本使用案例
public class TestJob : BaseJob
{
public TestJob()
{
base.Remark = "测试Job";
base.WorkContent = () =>
{
Console.WriteLine("Test");
return Task.CompletedTask;
};
}
}
TestJob
类继承自BaseJob
,展示了一个最基本的任务实现。通过在构造函数中设置Remark
备注信息,并定义WorkContent
为简单的控制台输出操作,演示了如何快速创建一个可执行的后台任务。这种简洁的实现方式使得开发人员能够轻松上手,根据实际业务需求替换WorkContent
的具体逻辑。
(四)定时器任务案例
/// <summary>
/// 清除日志
/// </summary>
public class LogClearJob : BaseJob
{
private readonly ILogger<LogClearJob> logger = ServiceLocator.GetService<ILogger<LogClearJob>>();
/// <summary>
/// 构造
/// </summary>
public LogClearJob()
{
base.OrderId = 0;
base.Remark = "定时清除日志";
base.JobType = EmJobType.Timer;
base.Delay = TimeSpan.FromDays(1);
base.ExectTime = new TimeSpan(10, 50, 0);//每天10.50
base.WorkContent = ()=>
{
try
{
logger.LogInformation("LogClear启动成功");
JobMsg = "LogClear启动成功";
ClearLogs();
return Task.CompletedTask;
}
catch (Exception ex)
{
base.UpdJobMsg(ex.Message,() => logger.LogError($"日志清除失败:{ex.Message}"));
return Task.CompletedTask;
}
};
}
private void ClearLogs()
{
ISqlSugarClient client = ServiceLocator.GetService<ISqlSugarClient>(); // 获取SqlSugarScope实例
var currentDate = DateTime.Now.Date;
var targetDate = currentDate.AddDays(-ConfigHelp.LogClearDay); // 计算需要删除的日期
var targetWCSLog = Path.Combine(AppDomain.CurrentDomain.BaseDirectory,
$"Logs/allLog-{targetDate:yyyy-MM-dd}.log"); // 计算需要删除的文件名
JobMsg = "下次执行时间:" + DateTime.Now.Add(base.Delay).ToString("yyyy-MM-dd HH:mm:ss");
// 删除文件
var delFileLog = DeleteLogFile(targetWCSLog);
// 删除数据库日志
var delDbLog = DeleteDatabaseLogs(client, targetDate);
// 记录操作结果
if (!string.IsNullOrEmpty(delFileLog) ||!string.IsNullOrEmpty(delDbLog))
logger.LogInformation(delFileLog + "————" + delDbLog);
else
logger.LogInformation("没有日志被删除");
}
/// <summary>
/// 删除日志文件
/// </summary>
/// <param name="filePath"></param>
/// <returns></returns>
private string DeleteLogFile(string filePath)
{
if (File.Exists(filePath))
{
File.Delete(filePath);
return $"WCSLog文件:{filePath}";
}
return string.Empty;
}
/// <summary>
/// 删除数据库日志
/// </summary>
/// <param name="client"></param>
/// <param name="targetDate"></param>
/// <returns></returns>
private string DeleteDatabaseLogs(ISqlSugarClient client, DateTime targetDate)
{
var delCount = client.Deleteable<LogEntity>(log => log.Logged.Contains(targetDate.Date.ToString("yyyy/MM/dd"))).ExecuteCommand();
if (delCount > 0) return $"数据库-{delCount}";
return string.Empty;
}
}
LogClearJob
类同样继承自BaseJob
,是一个典型的定时器任务案例。在构造函数中,设置OrderId
为0,Remark
为“定时清除日志”,明确任务用途和排序。将JobType
设置为Timer
,并设置Delay
为每天一次(TimeSpan.FromDays(1)
),ExectTime
为每天的10点50分(new TimeSpan(10, 50, 0)
)。WorkContent
定义了具体的日志清除逻辑,包括记录启动信息、调用ClearLogs
方法进行实际的日志删除操作(分别删除文件系统中的日志文件和数据库中的日志记录),并在出现异常时进行错误记录和JobMsg
的更新。
(五)JobManager类
public class JobManager
{
private readonly IServiceProvider _serviceProvider;
private readonly List<JobInfo> _jobs = new();
public JobManager(IServiceProvider serviceProvider)
{
_serviceProvider = serviceProvider;
}
public void AddJob<T>() where T : BaseJob
{
//ActivatorUtilities.CreateInstance 方法接受以一个参数:
//_serviceProvider:这是 IServiceProvider 对象,它提供了创建服务实例的能力。
//因为已经给T了 所以不需要第二个参数了 而且类也是无参的构造函数
var job = ActivatorUtilities.CreateInstance<T>(_serviceProvider);
_jobs.Add(new JobInfo(job));
}
public void AddJob(Type jobType)
{
//ActivatorUtilities.CreateInstance 方法接受三个参数:
//_serviceProvider:这是 IServiceProvider 对象,它提供了创建服务实例的能力。
//第二个参数:这是 Type 对象,表示您想要创建的类的类型。
//第三个参数:这个参数在创建实例时被传递给构造函数。
var job = (BaseJob)ActivatorUtilities.CreateInstance(_serviceProvider, jobType);
_jobs.Add(new JobInfo(job));
}
public async Task StartJobAsync(string key, CancellationToken cancellationToken)
{
var job = GetJobInfo(key);
if (job != null) await job.Service.StartAsync(cancellationToken);
}
public async Task StartJobAllAsync(CancellationToken cancellationToken)
{
foreach (var job in _jobs)
{
if (job.IsRunning) continue;
try
{
await job.Service.StartAsync(cancellationToken);
}
catch (Exception ex)
{
LogHelp.LogError($"{job.Key}启动异常{ex.Message}");
}
}
}
public async Task StopJobAsync(string key, CancellationToken cancellationToken)
{
var job = GetJobInfo(key);
if (job != null) await job.Service.StopAsync(cancellationToken, "手动停止");
}
public async Task StopJobAllAsync(CancellationToken cancellationToken)
{
foreach (var job in _jobs) await job.Service.StopAsync(cancellationToken, "手动停止");
}
public IEnumerable<JobInfo> GetJobInfos()
{
return _jobs.OrderBy(s => s.Service.OrderId);
}
public JobInfo GetJobInfo(string key)
{
return _jobs.FirstOrDefault(j => j.Key == key);
}
}
JobManager
类承担着管理所有后台任务的重要职责,它借助IServiceProvider
来创建任务实例,并提供了一系列方法用于任务的添加、启动、停止和查询操作。
- 属性说明:
_serviceProvider
:用于创建任务实例的服务提供者,通过依赖注入的方式传入,确保了任务创建的灵活性和可扩展性。_jobs
:存储所有已添加任务的列表,方便后续的管理和操作。
- 方法说明:
AddJob<T>()
:泛型方法,利用ActivatorUtilities.CreateInstance
创建指定类型的任务实例,并将其添加到_jobs
列表中。AddJob(Type jobType)
:非泛型方法,根据传入的Type
类型创建任务实例,并添加到任务列表。StartJobAsync(string key, CancellationToken cancellationToken)
:根据任务的Key
查找对应的任务实例,并启动该任务。若任务不存在则不进行操作。StartJobAllAsync(CancellationToken cancellationToken)
:遍历所有任务,若任务未运行则启动它,同时捕获并记录启动过程中可能出现的异常。StopJobAsync(string key, CancellationToken cancellationToken)
:根据Key
查找任务实例,并手动停止该任务。StopJobAllAsync(CancellationToken cancellationToken)
:停止所有任务,并标记停止原因为“手动停止”。GetJobInfos()
:返回所有任务的信息列表,并按照OrderId
进行排序,方便展示和管理。GetJobInfo(string key)
:根据Key
查找并返回对应的任务信息。
(六)JobExtension类
public static class JobExtension
{
/// <summary>
/// 注入JobManager
/// </summary>
/// <param name="services"></param>
public static void AddJobManager(this IServiceCollection services)
{
services.AddSingleton<JobManager>();
var assemblies = AppDomain.CurrentDomain.GetAssemblies();
var jobTypes = assemblies.SelectMany(a => a.GetTypes())
.Where(t => typeof(BaseJob).IsAssignableFrom(t) && !t.IsAbstract);
foreach (var jobType in jobTypes)
{
services.AddSingleton(jobType);
}
}
/// <summary>
/// 添加所有Job 默认启动
/// </summary>
/// <param name="app"></param>
/// <param name="noStartJob">不启动哪些Job</param>
/// <param name="IsStart">是否启动 默认为true</param>
/// <returns></returns>
public static void UseAddJobAll(this IApplicationBuilder app, bool isStart = true, params string[] noStartJob)
{
// 获取所有继承自 BaseJob 的子类类型
var jobTypes = AppDomain.CurrentDomain.GetDerivedTypes<BaseJob>();
// 获取 JobManager 实例
var jobManager = app.ApplicationServices.GetRequiredService<JobManager>();
foreach (var jobType in jobTypes)
{
if (noStartJob.Contains(jobType.Name)) continue;
jobManager.AddJob(jobType);
}
if (isStart) jobManager.StartJobAllAsync(CancellationToken.None).GetAwaiter();
}
/// <summary>
/// 添加所有Job 默认启动
/// </summary>
/// <param name="app"></param>
/// <param name="IsStart">是否启动 默认为true</param>
/// <returns></returns>
public static void UseAddJobAll(this IApplicationBuilder app, bool isStart = true)
{
// 获取所有继承自 BaseJob 的子类类型
var jobTypes = AppDomain.CurrentDomain.GetDerivedTypes<BaseJob>();
// 获取 JobManager 实例
var jobManager = app.ApplicationServices.GetRequiredService<JobManager>();
foreach (var jobType in jobTypes) jobManager.AddJob(jobType);
if (isStart) jobManager.StartJobAllAsync(CancellationToken.None).GetAwaiter();
}
private static IEnumerable<Type> GetDerivedTypes<T>(this AppDomain appDomain)
{
return appDomain.GetAssemblies()
.SelectMany(assembly => assembly.GetTypes())
.Where(type => typeof(T).IsAssignableFrom(type) && !type.IsAbstract && type != typeof(T));
}
}
JobExtension
类为IServiceCollection
和IApplicationBuilder
提供了扩展方法,方便在ASP.NET Core
应用中集成和使用后台任务管理框架。
AddJobManager
方法:向IServiceCollection
中注册JobManager
为单例服务,并自动扫描所有继承自BaseJob
的非抽象子类,将它们也注册为单例服务。这样,在整个应用生命周期内,这些服务只会有一个实例,确保资源的有效利用。UseAddJobAll
方法:重载方法,用于向JobManager
中添加所有继承自BaseJob
的任务,并根据参数决定是否启动这些任务。可以通过传入noStartJob
数组指定哪些任务不启动,提供了灵活的任务管理策略。GetDerivedTypes
方法:辅助方法,用于获取指定基类的所有非抽象子类类型,为任务的自动扫描和注册提供支持。
(七)在WebApi项目中的使用
在WebApi
项目的Program
类中,可以使用以下代码来调用上述扩展方法:
builder.Services.AddJobManager();
app.UseAddJobAll(false, nameof(S1StackerErrorJob), nameof(S2StackerErrorJob));
这两行代码实现了将后台任务管理框架集成到WebApi
项目中。首先调用AddJobManager
方法将JobManager
和所有任务类型注册到服务容器中,然后调用UseAddJobAll
方法添加所有任务,但不启动S1StackerErrorJob
和S2StackerErrorJob
这两个任务。通过这种方式,开发者可以根据实际需求灵活控制任务的添加和启动。
(八)注意事项
在使用过程中,如果使用了Masuit.Tools
(码数吐司库)类库,要避免使用其“ASP.NET Core自动扫描注册服务”功能。因为该功能可能会导致继承自BackgroundService
的类莫名其妙地自行启动,存在潜在的Bug
,影响应用的稳定性和可维护性。
三、总结
通过对BackgroundService
进行封装,我们构建了一个功能强大、灵活可配置的后台任务管理框架。这个框架支持多种任务执行模式,方便对任务进行集中管理和监控,同时提供了简单易用的扩展方法,使得在ASP.NET Core
项目中集成和使用后台任务变得更加轻松。在实际开发中,开发者可以根据具体业务需求,灵活定制任务的执行逻辑和调度策略,提高应用的性能和稳定性。