在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,通过一系列属性和方法实现了对后台任务的全方位管理。

  1. 属性说明
    • Key:用于唯一标识每个任务实例,方便在管理和监控时进行区分。
    • IsRunning:实时反映任务的运行状态,便于外部进行状态判断和控制。
    • JobMsg:记录任务执行过程中的关键信息,比如错误提示或执行进度等,有助于排查问题。
    • StopMsg:明确任务停止的具体原因,为后续分析提供依据。
    • StartTimeStopTime:精确记录任务的启动和停止时间,对于统计任务执行时长以及分析任务执行规律非常有用。
    • Remark:可用于添加任务的备注信息,方便开发人员了解任务的用途和特殊说明。
    • OrderId:虽然在实际任务执行逻辑中没有直接作用,但在展示任务列表时可用于排序,提升管理界面的可读性。
    • WorkContent:这是任务的核心执行逻辑,通过Func<Task>类型定义,使得具体的任务操作可以灵活定制。
    • JobType:定义了任务的执行类型,包括While(基于循环,确保上一次执行完成才进行下一次)、Timer(定时器模式,按固定间隔执行,不考虑上一次是否完成)和Single(单次执行任务)。
    • Delay:设置任务执行的时间间隔,在WhileTimer模式下都有重要作用。
    • ExectTime:仅在Timer模式下起作用,用于指定任务的具体执行时间,若为null则表示立即执行。
  2. 方法说明
    • 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来创建任务实例,并提供了一系列方法用于任务的添加、启动、停止和查询操作。

  1. 属性说明
    • _serviceProvider:用于创建任务实例的服务提供者,通过依赖注入的方式传入,确保了任务创建的灵活性和可扩展性。
    • _jobs:存储所有已添加任务的列表,方便后续的管理和操作。
  2. 方法说明
    • 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类为IServiceCollectionIApplicationBuilder提供了扩展方法,方便在ASP.NET Core应用中集成和使用后台任务管理框架。

  1. AddJobManager方法:向IServiceCollection中注册JobManager为单例服务,并自动扫描所有继承自BaseJob的非抽象子类,将它们也注册为单例服务。这样,在整个应用生命周期内,这些服务只会有一个实例,确保资源的有效利用。
  2. UseAddJobAll方法:重载方法,用于向JobManager中添加所有继承自BaseJob的任务,并根据参数决定是否启动这些任务。可以通过传入noStartJob数组指定哪些任务不启动,提供了灵活的任务管理策略。
  3. GetDerivedTypes方法:辅助方法,用于获取指定基类的所有非抽象子类类型,为任务的自动扫描和注册提供支持。

(七)在WebApi项目中的使用

WebApi项目的Program类中,可以使用以下代码来调用上述扩展方法:

builder.Services.AddJobManager();
app.UseAddJobAll(false, nameof(S1StackerErrorJob), nameof(S2StackerErrorJob));

这两行代码实现了将后台任务管理框架集成到WebApi项目中。首先调用AddJobManager方法将JobManager和所有任务类型注册到服务容器中,然后调用UseAddJobAll方法添加所有任务,但不启动S1StackerErrorJobS2StackerErrorJob这两个任务。通过这种方式,开发者可以根据实际需求灵活控制任务的添加和启动。

(八)注意事项

在使用过程中,如果使用了Masuit.Tools(码数吐司库)类库,要避免使用其“ASP.NET Core自动扫描注册服务”功能。因为该功能可能会导致继承自BackgroundService的类莫名其妙地自行启动,存在潜在的Bug,影响应用的稳定性和可维护性。

三、总结

通过对BackgroundService进行封装,我们构建了一个功能强大、灵活可配置的后台任务管理框架。这个框架支持多种任务执行模式,方便对任务进行集中管理和监控,同时提供了简单易用的扩展方法,使得在ASP.NET Core项目中集成和使用后台任务变得更加轻松。在实际开发中,开发者可以根据具体业务需求,灵活定制任务的执行逻辑和调度策略,提高应用的性能和稳定性。

posted @ 2025-01-24 09:30  柴油飞机  阅读(272)  评论(0)    收藏  举报