欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

Asp.Net Core 轻松学-基于微服务的后台任务调度管理器

程序员文章站 2023-12-06 14:55:58
在 Asp.Net Core 中,我们常常使用 System.Threading.Timer 这个定时器去做一些需要长期在后台运行的任务,但是这个定时器在某些场合却不太灵光,而且常常无法控制启动和停止,我们需要一个稳定的,类似 WebHost 这样主机级别的任务管理程序,但是又要比 WebHost ... ......

前言

    在 asp.net core 中,我们常常使用 system.threading.timer 这个定时器去做一些需要长期在后台运行的任务,但是这个定时器在某些场合却不太灵光,而且常常无法控制启动和停止,我们需要一个稳定的,类似 webhost 这样主机级别的任务管理程序,但是又要比 webhost 要轻便。

    由此,我找到了官方推荐的 ihostedservice 接口,该接口位于程序集 microsoft.extensions.hosting.abstractions 的 命名空间 microsoft.extensions.hosting。该接口自 .net core 2.0 开始提供,按照官方的说法,由于该接口的出现,下面的这些应用场景的代码都可以删除了。

历史场景列表

  1. 轮询数据库以查找更改的后台任务
  2. 从 task.run() 开始的后台任务
  3. 定期更新某些缓存的计划任务
  4. 允许任务在后台线程上执行的 queuebackgroundworkitem 实现
  5. 在 web 应用后台处理消息队列中的消息,同时共享 ilogger 等公共服务

1. 原理解释

1.1 首先来看接口 ihostedservice 的代码,这需要花一点时间去理解它的原理,你也可以跳过本段直接进入第二段

namespace microsoft.extensions.hosting
{
    //
    // summary:
    //     defines methods for objects that are managed by the host.
    public interface ihostedservice
    {
        //
        // summary:
        // triggered when the application host is ready to start the service.
        task startasync(cancellationtoken cancellationtoken);
        //
        // summary:
        // triggered when the application host is performing a graceful shutdown.
        task stopasync(cancellationtoken cancellationtoken);
    }
}

1.2 非常简单,只有两个方法,但是非常重要,这两个方法分别用于程序启动和退出的时候调用,这和 timer 有着云泥之别,这是质变。

1.3 从看到 ihostedservice 这个接口开始,我就习惯性的想,按照微软的惯例,某个接口必然有其默认实现的抽象类,然后我就看到了 microsoft.extensions.hosting.backgroundservice ,果然,前人种树后人乘凉,在 backgroundservice 类中,接口已经实现好了,我们只需要去实现 executeasync 方法

1.4 backgroundservice 内部代码如下,值得注意的是 backgroundservice 从 .net core 2.1 开始提供,所以,使用旧版本的同学们可能需要升级一下

public abstract class backgroundservice : ihostedservice, idisposable
{
    private task _executingtask;
    private readonly cancellationtokensource _stoppingcts = 
                                                   new cancellationtokensource();

    protected abstract task executeasync(cancellationtoken stoppingtoken);

    public virtual task startasync(cancellationtoken cancellationtoken)
    {
        // store the task we're executing
        _executingtask = executeasync(_stoppingcts.token);

        // if the task is completed then return it, 
        // this will bubble cancellation and failure to the caller
        if (_executingtask.iscompleted)
        {
            return _executingtask;
        }

        // otherwise it's running
        return task.completedtask;
    }
    
    public virtual async task stopasync(cancellationtoken cancellationtoken)
    {
        // stop called without start
        if (_executingtask == null)
        {
            return;
        }

        try
        {
            // signal cancellation to the executing method
            _stoppingcts.cancel();
        }
        finally
        {
            // wait until the task completes or the stop token triggers
            await task.whenany(_executingtask, task.delay(timeout.infinite,
                                                          cancellationtoken));
        }

    }

    public virtual void dispose()
    {
        _stoppingcts.cancel();
    }
}

1.5 backgroundservice 内部实现了 ihostedservice 和 idisposable 接口,从代码实现可以看出,backgroundservice 充分实现了任务启动注册和退出清理的逻辑,并保证在任务进入 gc 的时候及时的退出,这很重要。

2. 开始使用

2.1 首先创一个通用的任务管理类 backmanagerservice ,该类继承自 backgroundservice

    public class backmanagerservice : backgroundservice
    {
        backmanageroptions options = new backmanageroptions();
        public backmanagerservice(action<backmanageroptions> options)
        {
            options.invoke(this.options);
        }
        protected override async task executeasync(cancellationtoken stoppingtoken)
        {
            // 延迟启动
            await task.delay(this.options.checktime, stoppingtoken);

            options.onhandler(0, $"正在启动托管服务 [{this.options.name}]....");
            stoppingtoken.register(() =>
            {
                options.onhandler(1, $"托管服务  [{this.options.name}] 已经停止");
            });

            int count = 0;
            while (!stoppingtoken.iscancellationrequested)
            {
                count++;
                options.onhandler(1, $" [{this.options.name}] 第 {count} 次执行任务....");
                try
                {
                    options?.callback();
                    if (count == 3)
                        throw new exception("模拟业务报错");
                }
                catch (exception ex)
                {
                    options.onhandler(2, $" [{this.options.name}] 执行托管服务出错", ex);
                }
                await task.delay(this.options.checktime, stoppingtoken);
            }
        }

        public override task stopasync(cancellationtoken cancellationtoken)
        {
            options.onhandler(3, $" [{this.options.name}] 由于进程退出,正在执行清理工作");
            return base.stopasync(cancellationtoken);
        }
    }
  • backmanagerservice 类继承了 backgroundservice ,并实现了 executeasync(cancellationtoken stoppingtoken) 方法,在 executeasync 方法内,先是延迟启动任务,接下来进行注册和调度,这里使用 while 循环判断如果令牌没有取消,则一直轮询,而轮询的关键在于下面的代码
protected override async task executeasync(cancellationtoken stoppingtoken)
    {
        ...
        while (!stoppingtoken.iscancellationrequested)
            {
                ...
                await task.delay(this.options.checktime, stoppingtoken);
            }
    }

while 循环内部使用 task.delay 设置时间,在 this.options.checktime 计时结束后继续下一轮的调度任务
实际上,task.delay 方法内部也是使用了 system.threading.timer 类进行计时,但是,当内部的 timer 计时结束后,会马上被 dispose 掉

2.2 任务管理类 backmanagerservice 包含一个带参数的构造方法,是一个匿名委托,需要传入参数 backmanageroptions,该参数表示一个任务的调度参数

2.3 创建 backmanageroptions 任务调度操作类

    public class backmanageroptions
    {
        /// <summary>
        ///  任务名称
        /// </summary>
        public string name { get; set; }
        /// <summary>
        ///  获取或者设置检查时间间隔,单位:毫秒,默认 10 秒
        /// </summary>
        public int checktime { get; set; } = 10 * 1000;
        /// <summary>
        ///  回调委托
        /// </summary>
        public action callback { get; set; }
        /// <summary>
        ///  执行细节传递委托
        /// </summary>
        public action<backhandler> handler { get; set; }

        /// <summary>
        ///  传递内部信息到外部组件中,以方便处理扩展业务
        /// </summary>
        /// <param name="level">0=info,1=debug,2=error,3=exit</param>
        /// <param name="message"></param>
        /// <param name="ex"></param>
        /// <param name="state"></param>
        public void onhandler(int level, string message, exception ex = null, object state = null)
        {
            handler?.invoke(new backhandler() { level = level, message = message, exception = ex, state = state });
        }
    }

2.4 该 backmanageroptions 任务调度操作类包含了一些基础的设置内容,比如任务名称,执行周期间隔,回调委托 callback,任务管理器内部执行细节传递委托 handler,这些定义非常有用,下面会用到

2.5 其中,执行细节传递委托 handler 包含一个参数,其实就是传递的细节,非常简单的一个实体对象类,无非就是信息级别,消息描述,异常信息,执行对象

    public class backhandler
    {
        /// <summary>
        ///  0=info,1=debug,2=error
        /// </summary>
        public int level { get; set; }
        public string message { get; set; }
        public exception exception { get; set; }
        public object state { get; set; }
    }

2.6 定义好上面的 3 个对象后,现在来创建一个订单管理类,用于定时轮询数据库订单是否超时未付款,然后返还库存

 public class ordermanagerservice
    {
        public void checkorder()
        {
            console.foregroundcolor = consolecolor.yellow;
            console.writeline("==业务执行完成==");
            console.foregroundcolor = consolecolor.gray;
        }

        public void onbackhandler(backhandler handler)
        {
            switch (handler.level)
            {
                default:
                case 0: break;
                case 1:
                case 3: console.foregroundcolor = consolecolor.yellow; break;
                case 2: console.foregroundcolor = consolecolor.red; break;
            }
            console.writeline("{0} | {1} | {2} | {3}", handler.level, handler.message, handler.exception, handler.state);
            console.foregroundcolor = consolecolor.gray;

            if (handler.level == 2)
            {
                // 服务执行出错,进行补偿等工作
            }
            else if (handler.level == 3)
            {
                // 退出事件,清理你的业务
                cleanup();
            }
        }

        public void cleanup()
        {
            console.foregroundcolor = consolecolor.yellow;
            console.writeline("==清理完成==");
            console.foregroundcolor = consolecolor.gray;
        }
    }

2.7 这个 ordermanagerservice 业务类定义了 3 个方法,checkorder 检查订单,onbackhandler 输出执行信息,cleanup 在程序退出的时候去做一些清理工作,非常简单,前两个方法是用于注册到 backmanagerservice 任务调度器中,后一个是内部方法。

3. 注册 backmanagerservice 任务调度器到进程中

3.1 定义好业务类后,我们需要把它注册到进程中,以便程序启动和退出的时候自动执行

3.2 在 startup.cs 的 configureservices 方法中注册托管主机,看下面的代码

        // this method gets called by the runtime. use this method to add services to the container.
        public void configureservices(iservicecollection services)
        {
            services.addmvc().setcompatibilityversion(compatibilityversion.version_2_2);

            services.addsingleton<microsoft.extensions.hosting.ihostedservice, backmanagerservice>(factory =>
            {
                ordermanagerservice order = new ordermanagerservice();
                return new backmanagerservice(options =>
                 {
                     options.name = "订单超时检查";
                     options.checktime = 5 * 1000;
                     options.callback = order.checkorder;
                     options.handler = order.onbackhandler;
                 });
            });
        }

3.3 上面的代码通过将 backmanagerservice 注册到托管主机中,并在初始化的时候设置了 backmanageroptions ,然后将 ordermanagerservice 的方法注册到 backmanageroptions 的委托中,实现业务执行

3.4 运行程序,观察输出结果

Asp.Net Core 轻松学-基于微服务的后台任务调度管理器

3.4 输出结果清晰的表示创建的托管服务运行良好,我们来看一下执行顺序

执行顺序

  1. 启动托管服务
  2. 执行“订单超时检查”任务,连续执行了 3 次,间隔 5 秒,每次执行都向外部传递了执行细节信息
  3. 由于我们故意设置任务执行到第 3 次的时候模拟抛出异常,可以看到,异常被正确的捕获并安全的传递到外部
  4. 任务继续执行
  5. 强制终止了程序,然后托管服务收到了程序停止的信号并立即进行了清理工作,通知外部业务委托执行清理
  6. 清理完成,托管服务停止并退出

3.5 注册多个托管服务,通过定义的 backmanagerservice 任务调度器,我们甚至具备了同时托管数个任务的能力,而我们只需要在 configureservices 增加一行代码

        public void configureservices(iservicecollection services)
        {
            services.addmvc().setcompatibilityversion(compatibilityversion.version_2_2);

            services.addsingleton<microsoft.extensions.hosting.ihostedservice, backmanagerservice>(factory =>
            {
                ordermanagerservice order = new ordermanagerservice();
                return new backmanagerservice(options =>
                 {
                     options.name = "订单超时检查";
                     options.checktime = 5 * 1000;
                     options.callback = order.checkorder;
                     options.handler = order.onbackhandler;
                 });
            });

            services.addsingleton<microsoft.extensions.hosting.ihostedservice, backmanagerservice>(factory =>
            {
                ordermanagerservice order = new ordermanagerservice();
                return new backmanagerservice(options =>
                {
                    options.name = "成交数量统计";
                    options.checktime = 2 * 1000;
                    options.callback = order.checkorder;
                    options.handler = order.onbackhandler;
                });
            });
        }

3.6 为了方便,我们还是使用 ordermanagerservice 来模拟业务,只是把任务名称改成 "成交数量统计",并设置任务执行周期间隔为 2 秒

3.7 现在来运行程序,观察输出

Asp.Net Core 轻松学-基于微服务的后台任务调度管理器

3.8 输出结果正常,两个托管服务独立运行,互不干扰,蓝色为 "成交数量统计",白色为 "订单超时检查"

结语

  • 得益于 .net core 提供的轻量型主机 ihostedservice,我们可以方便的把后台任务注册到托管主机中,托管主机随着宿主进程的启动和退出执行相关的业务逻辑,这点非常重要,由于这种人性化的设计,我们可以在宿主进程启动和退出的时候去做一些业务级别的工作。
  • 值得注意的是,ihostedservice 中的方法 startasync 会在服务启动的时候马上执行,这可能导致宿主进程并未完全初始化业务数据,导致托管任务报错,所以我们采用了延迟启动,即在 startasync 内部使用代码阻止任务立即执行
  protected override async task executeasync(cancellationtoken stoppingtoken)
        {
            // 延迟启动
            await task.delay(this.options.checktime, stoppingtoken);
            ...
        }
  • 在默认情况下, cancellationtoken 令牌取消的超时时间为 5 秒,如果你希望留更多的时间给业务处理,可以通过下面的代码修改,比如本示例设置为 15 秒后超时
        public static void main(string[] args)
        {
            createwebhostbuilder(args)
                .useshutdowntimeout(timespan.fromseconds(15))
                .build().run();
        }
  • 本次行文略显罗嗦,代码量也稍大了一些,主要是希望大家可以去理解原理后,使用起来心里比较有底一些

示例代码下载

https://files.cnblogs.com/files/viter/ron.backhost.zip