7.1 ABP后台服务 - 后台作业和后台工人

7.1.1 简介

ABP提供了后台作业和后台工人,来执行应用程序中的后台线程的某些任务。

7.1.2 后台作业

由于各种各样的原因,你需要后台作业以队列和持久化的方式来排队执行某些任务。

例如:

  • 用户等待执行一个长时任务。例如:某个用户按下了报表按钮生成一个需要长时间等待的报表。你添加这个工作到队列中,当报表生成完毕后,发送报表结果到该用户的邮箱。

  • 重试创建并持久化任务操作,以保证任务的成功执行。例如:在后台作业中发送一封邮件,有些问题可能会导致发送失败(网络连接异常,或者主机宕机);由于有后台作业以及持久化机制,在问题排除后,可以重试执行失败的任务,以保证任务的成功执行。

关于作业持久化 了解更多信息可以查看源码:Background Job Store

1. 创建后台作业

通过继承 BackgroundJob<TArgs> 类或者直接实现 IBackgroundJob<TArgs> 接口,我们可以创建一个后台作业。

下面是一个极其简单的后台作业示例:

public class TestJob : BackgroundJob<int>, ITransientDependency
{
    public override void Execute(int number)
    {
        Logger.Debug(number.ToString());
    }
}

后台作业中定义了一个方法:Execute 并有一个输入参数。正如示例中所示,参数被定义为泛型参数。

后台作业必须使用依赖注入进行注册,而实现 ITransientDependency 是最简单的方式。

下面我们将定义一个在后台队列中发送邮件的作业:

public class SimpleSendEmailJob : BackgroundJob<SimpleSendEmailJobArgs>, ITransientDependency
{
    private readonly IRepository<User, long> _userRepository;
    private readonly IEmailSender _emailSender;

    public SimpleSendEmailJob(IRepository<User, long> userRepository, IEmailSender emailSender)
    {
        _userRepository = userRepository;
        _emailSender = emailSender;
    }

    [UnitOfWork]
    public override void Execute(SimpleSendEmailJobArgs args)
    {
        var senderUser = _userRepository.Get(args.SenderUserId);
        var targetUser = _userRepository.Get(args.TargetUserId);

        _emailSender.Send(senderUser.EmailAddress, targetUser.EmailAddress, args.Subject, args.Body);
    }
}

我们注入了用户仓储(取得用户邮箱信息)和邮件发送服务,简单实现邮件的发送。在这里 SimpleSendEmailJobArgs 是作业参数,定义如下:

[Serializable]
public class SimpleSendEmailJobArgs
{
    public long SenderUserId { get; set; }

    public long TargetUserId { get; set; }

    public string Subject { get; set; }

    public string Body { get; set; }
}

作业参数应该是可序列化的,因为该参数需要被序列化并存储到数据库中。 在ABP中作业管理默认使用的是 JSON 序列化的方式(所以不需要添加[Serializable]特性)。当然最好是使用 [Serializable] 特性,因为我们可以自由的切换到其他作业管理中,以后可能会使用Binary序列化等等。

参数应该做到简洁,不应该包含实体或者其他非序列化的对象。正如 SimpleSendEmailJob 所展示的,我们应该仅存储实体的Id并从仓储内得到该实体的作业。

2. 添加作业到队列中

在定义后台作业后,我们可以注入并使用 IBackgroundJobManager 接口来添加作业到队列中。

上面我们已经定义了TestJob类,让我们来看下如何使它排队:

public class MyService
{
    private readonly IBackgroundJobManager _backgroundJobManager;

    public MyService(IBackgroundJobManager backgroundJobManager)
    {
        _backgroundJobManager = backgroundJobManager;
    }

    public void Test()
    {
        _backgroundJobManager.Enqueue<TestJob, int>(42);
    }
}

我们以42作为参数来排队。IBackgroundJobManager将会被实例化,并使用42这个参数来执行TestJob这个作业。

让我们为SimpleSendEmailJob添加一个新作业:

[AbpAuthorize]
public class MyEmailAppService : ApplicationService, IMyEmailAppService
{
    private readonly IBackgroundJobManager _backgroundJobManager;

    public MyEmailAppService(IBackgroundJobManager backgroundJobManager)
    {
        _backgroundJobManager = backgroundJobManager;
    }

    public async Task SendEmail(SendEmailInput input)
    {
            await _backgroundJobManager.EnqueueAsync<SimpleSendEmailJob, SimpleSendEmailJobArgs>(
            new SimpleSendEmailJobArgs
            {
                Subject = input.Subject,
                Body = input.Body,
                SenderUserId = AbpSession.GetUserId(),
                TargetUserId = input.TargetUserId
            });
    }
}

Enqueue (or EnqueueAsync) 方法还有其它参数如:prioritydelay

3. 后台作业管理

BackgroundJobManager 默认实现了IBackgroundJobManager接口。它能够被其它的后台作业提供者给替换掉(如:集成Hangfire)。在 BackgroundJobManager 中默认定义了一些信息:

  • 在单线程中,它是一种简单的 FIFO(先进先出) 的作业队列。使用 IBackgroundJobStore 来持久化作业。

  • 对作业重试执行,直到作业执行成功(不抛出任何异常但是记录进日志)或者操作超时。默认作业超时设置是2天。

  • 当作业执行成功后,作业将会从数据库中删除。如果执行超时,那么该作业会被设置为 abandoned 并保留在数据库中。

  • 作业重试执行的时间间隔会慢慢增长。第一次重试是等待1分钟,第二次是等待2分钟,第三次是等待4分钟等等。

  • 以固定的时间间隔轮询存储中的作业。查询作业是通过优先级和重试次数来排序的。

后台作业存储

BackgroundJobManager 默认是需要数据存储来保存和检索作业的。如果你没有实现 IBackgroundJobStore,那么它会使用 InMemoryBackgroundJobStore 来存储作业,当然作业不会被持久化到数据库中。你可以简单的实现它来存储作业到数据库中或者你可以在module-zero直接使用,因为它已被实现。

如果你使用了第三方作业管理(如:Hangfire),那就不需要实现 IBackgroundJobStore

4. 配置

你可以在你的模块方法:PreInitialize 中,使用 Configuration.BackgroundJobs 配置后台作业系统。

禁用后台作业

你可能想在你的应用中禁用后台作业:

public class MyProjectWebModule : AbpModule
{
    public override void PreInitialize()
    {
        Configuration.BackgroundJobs.IsJobExecutionEnabled = false;
    }

    //...
}

这种需求及其少见。但是想象一下,同时开启同一个应用程序的多个实例且使用的是同一个数据库。在这种情况下,每个应用程序的作业将查询相同的数据库并执行它们。这会导致相同的任务的多次执行,并还会导致其它的一些问题。为了阻止这个,你有两个选择:

  • 你可以仅为你应用程序的一个实例开启后台作业

  • 你可以禁用Web应用的所有实例的后台作业,并且创建一个独立的应用程序(如:Windows服务)来执行你的后台作业。

5. 异常处理

由于后台作业管理默认应该重试失败的作业,它会处理(并记录日志)所有的异常。如果你想当异常发生的时候被通知,你可以创建事件处理来处理 AbpHandledExceptionData。后台会触发这个事件并且附上一个 BackgroundJobException 异常对象,该对象包裹了真实的异常(对该异常获取 InnerException)。

6. 集成Hangfire

后台作业管理者可以被其他后台作业管理者替换。详情请参考集成Hangfire

7.1.3 后台工人

后台工人是不同于后台作业的。在应用程序中,它们是运行在后台单个线程中。一般来说,它们周期性的执行一些任务,例如:

  • 后台工人能够周期性地执行旧日志的删除

  • 后台工人可以周期性地确定非活跃性用户并且发送邮件给这些用户,使这些用户返回到你的网站中。

1. 创建后台工人

创建后台工人,我们应该实现 IBackgroundWorker 接口。根据你的需要,你可以继承 BackgroundWorkerBase 或者 PeriodicBackgroundWorkerBase

假设有一个非活跃性的用户,该用户最近30天都没有访问我们的应用程序。

public class MakeInactiveUsersPassiveWorker : PeriodicBackgroundWorkerBase, ISingletonDependency
{
    private readonly IRepository<User, long> _userRepository;

    public MakeInactiveUsersPassiveWorker(AbpTimer timer, IRepository<User, long> userRepository)
        : base(timer)
    {
        _userRepository = userRepository;
        Timer.Period = 5000; //5 seconds (good for tests, but normally will be more)
    }

    [UnitOfWork]
    protected override void DoWork()
    {
        using (CurrentUnitOfWork.DisableFilter(AbpDataFilters.MayHaveTenant))
        {
            var oneMonthAgo = Clock.Now.Subtract(TimeSpan.FromDays(30));

            var inactiveUsers = _userRepository.GetAllList(u =>
                u.IsActive &&
                ((u.LastLoginTime < oneMonthAgo && u.LastLoginTime != null) || (u.CreationTime < oneMonthAgo && u.LastLoginTime == null))
                );

            foreach (var inactiveUser in inactiveUsers)
            {
                inactiveUser.IsActive = false;
                Logger.Info(inactiveUser + " made passive since he/she did not login in last 30 days.");
            }

            CurrentUnitOfWork.SaveChanges();
        }
    }
}

这是一个真实性的代码,你可以在module-zero找到。

  • 如果派生自 PeriodicBackgroundWorkerBase,你需要实现 DoWork 方法,在该方法中实现你周期性执行的逻辑。

  • 如果派生自 BackgroundWorkerBase 或者直接实现 IBackgroundWorker 接口,你将要重写/实现 Start,Stop, WaitStop 方法。 Start 和 Stop 是非阻塞方法, WaitStop应该等待后台工人完成当前的重要任务。

2. 注册后台工人

在创建后台工人后,我们应该添加它到 IBackgroundWorkerManager。通常是在我们的模块的 PostInitialize 方法中配置:

public class MyProjectWebModule : AbpModule
{
    //...

    public override void PostInitialize()
    {
        var workManager = IocManager.Resolve<IBackgroundWorkerManager>();
        workManager.Add(IocManager.Resolve<MakeInactiveUsersPassiveWorker>());
    }
}

一般情况我们都是在PostInitialize做的,这没有严格的要求。你能够在任何地方注入IBackgroundWorkerManager并添加后台工人到运行时中。当你的应用程式停止后,IBackgroundWorkerManager将停止和释放所有被注册过的后台工人。

3. 后台工人生命周期

后台工人通常是作为单例来执行的。这里也没有严格的限制,如果你需要同一个后台工人在多个实例中,你可以使它做为瞬时对象并添加多个实例到IBackgroundWorkerManager,在这种情况下,后台工人将被参数化(你有一个LogCleaner类,但是有2个后台LogCleaner工人实例,它们会看到和清除不同的日志文件夹)。

4. 高级计划

ABP的后台worker系统是相当简单的。如上所述,除了周期性的运行workes,它没有调度系统。如果你想要更高级的调度系统功能,建议你使用 Quartz或者其他类库。

7.1.4 使你的应用程序一直运行

后台作业和后台工人仅在你的应用程序运行的时候才工作。Asp.Net应用默认是关闭的, 如果web应用长时间没有被请求执行。所以,如果你的后台作业是宿主在Web应用中执行的,你应该确保你的web应用是被配置为一直执行的。否则,后台作业仅在你的web应用在使用的时候才会执行。

有一些技术可以实现这个,最简单的方法是用一个外部程序向你的web应用周期性的发送请求。因此,你也可以检测你的web应用是否启动并运行。Hangfire文档中描述了另外的实现方法。