.Net Core 简单定时任务框架封装
有段日子没有更新,写点东西冒个泡 。这篇文章过来讲个小东西,也是大家在日常开发中也经常需要面临的问题:后台定时任务处理。估计大家看到这句就已经联想到 QuartZ 等类似第三方类库了,不好意思,后边的事情和它们没有关系。这里要展开的是用.Net Core 下的 Generic Host 配合封装简版定时任务处理框架的过程。至于什么是Generic Host,简单来说就是一个简化版不含Http管道等的非Web应用托管宿主服务,至于它如何来,其内有着什么样的实现细节,官方介绍已经足够。这篇文章主要还是回到实际的基础封装过程实现层面,用一个小东西来演示如何在常见业务代码中梳理职责,内容主要如下:
1. 概要分解
2. 封装实现
3. 示例演示
4. 注意事项
一. 概要分解
如果对Generic Host 已经有了解的同学可能也看过网上其他文章,大多也都介绍用它如何实现定时任务处理。这些文章基本提供了一个通用实现,对业务实现还是稍显啰嗦。这两天整理逻辑有个任务不得不临时定时处理,想到这个东西,花了点时间处理了下,东西不复杂不过还是想把这个思路分享给需要的朋友。
定时任务,分解来看特别简单,就是两个维度“ 定时 + 任务 ”,如果还有另外一个维度,那就是 任务运行的托管服务。在托管平台上添加定时规则,根据规则触发任务,工作结束。
1. 关于定时,主要就是一套任务触发的规则,其作为一个调度者,只需要关心的是 在什么时间,以何种频率 触发任务。 在.Net 下我们通过定时器(Timer - 构造函数包含这两个核心参数,.net 下有两个Timer实现,一个是System.Timer.Timer,一个是System.Threading.Timer, 这里用的第二者,自由度更高)来实现,但是它不应该直接和具体的任务挂钩,使用方也不应该每次都自己来处理Timer的初始化及相关回收释放等相同操作,我们需要的是使用方只需告知框架层要执行什么任务,和任务对应的时间规则。
2. 关于任务, 这个角色是一个任务的执行者, 定时调度者 告诉 任务执行者 在什么时候开始执行和结束任务,其本身不会关注调度的实现。
3. 关于托管服务,也就是已经说过的Generic Host,当然你也可以使用windows服务等。它的职责就是保证给任务提供执行环境,并告诉任务定时器当前服务在什么时候开始运行和关闭。 实现时提供了统一 IHostedService 接口,具体实现下边实现会有展示。Generic Host 启动方式有两种形式:
a. 如果是.NetCore 站点,默认已经包含,只需要在 ConfigureServices 时注册具体实现即可。
b. 可以独立创建,比如控制台通过 new HostBuilder() 形式启动,具体参见官方文档。
为了更直观的展示相关之间的关系,这里我画了个类图来分解相关的职责,同时也是后边具体实现的主要内容:
二. 封装实现
从上边类图可以看出当前基础框架主要由 BaseJobTrigger(触发器基类),IJobExcutor(任务执行者接口),ListJobExcutor<IType>(通用列表循环任务执行者基类)。下边分别就上边三者贴出具体实现。
1. BaseJobTrigger(触发器基类),实现代码如下:
public abstract class BaseJobTrigger : IHostedService, IDisposable { private Timer _timer; private readonly TimeSpan _dueTime; private readonly TimeSpan _periodTime; private readonly IJobExecutor _jobExcutor; /// <summary> /// 构造函数 /// </summary> /// <param name="dueTime">到期执行时间</param> /// <param name="periodTime">间隔时间</param> /// <param name="jobExcutor">任务执行者</param> protected BaseJobTrigger(TimeSpan dueTime, TimeSpan periodTime, IJobExecutor jobExcutor) { _dueTime = dueTime; _periodTime = periodTime; _jobExcutor = jobExcutor; } #region 计时器相关方法 private void StartTimerTrigger() { if (_timer == null) _timer = new Timer(ExcuteJob,_jobExcutor,_dueTime, _periodTime); else _timer.Change(_dueTime, _periodTime); } private void StopTimerTrigger() { _timer?.Change(Timeout.Infinite, Timeout.Infinite); } private void ExcuteJob(object obj) { try { var excutor = obj as IJobExecutor; excutor?.StartJob(); } catch (Exception e) { LogUtil.Error($"执行任务({nameof(GetType)})时出错,信息:{e}"); } } #endregion /// <summary> /// 系统级任务执行启动 /// </summary> /// <returns></returns> public virtual Task StartAsync(CancellationToken cancellationToken) { try { StartTimerTrigger(); } catch (Exception e) { LogUtil.Error($"启动定时任务({nameof(GetType)})时出错,信息:{e}"); } return Task.CompletedTask; } /// <summary> /// 系统级任务执行关闭 /// </summary> /// <returns></returns> public virtual Task StopAsync(CancellationToken cancellationToken) { try { _jobExcutor.StopJob(); StopTimerTrigger(); } catch (Exception e) { LogUtil.Error($"停止定时任务({nameof(GetType)})时出错,信息:{e}"); } return Task.CompletedTask; } public void Dispose() { _timer?.Dispose(); } }
这个主要是完成对定时器的封装,StartAsync和StopAsync 为 IHostService 系统服务接口,表示托管服务的开始和结束。
2. IJobExcutor(任务执行者接口)
public interface IJobExecutor { /// <summary> /// 开始任务 /// </summary> void StartJob(); /// <summary> /// 结束任务 /// </summary> void StopJob(); }
3. ListJobExcutor<IType>(通用列表循环任务执行者基类)
public abstract class ListJobExcutor<IType>
: IJobExecutor { /// <summary> /// 运行状态 /// </summary> public bool IsRuning { get;protected set; }
/// <summary> /// 开始任务 /// </summary> public void StartJob() { // 任务依然在执行中,不需要再次唤起 if (IsRuning) return; IsRuning = true; IList<IType> list = null; // 结清实体list do { for (var i = 0; IsRuning && i < list?.Count;i++) { ExcuteItem(list[i],i); } list = GetExcuteSource(); } while (IsRuning && list?.Count > 0); IsRuning = false; }
public void StopJob() { IsRuning = false; } /// <summary> /// 获取list数据源 /// </summary> /// <returns></returns> protected virtual IList<IType> GetExcuteSource() { return null; } /// <summary> /// 个体任务执行 /// </summary> /// <param name="item">单个实体</param> /// <param name="index">在数据源中的索引</param> protected virtual void ExcuteItem(IType item,int index) { } }
这个是通用列表循环执的基础封装,因为业务中需要定时处理的大多是需要从数据库或文件批量获取数据,执行处理,例如到期提醒,定时清理超时订单等场景。
其主要功能实现是 从 GetExcuteSource() 获取执行数据源,循环并通过 ExcuteItem() 执行个体任务,直到没有数据源返回,则此次任务执行结束,等待下次任务触发。如果当次执行时间过长,超过计时器时间间隔,重复触发时 当前任务还在进行中,则不做任何处理。如果数据量过大需要并发执行,子类可以在 ExcuteItem 中异步处理。这样既可保证并发顺序执行。
三. 示例演示
以上三个元素就构成了当前定时任务的主要基础框架,在实际处理一个任务的过程中,我们需要定义一个执行者(XXXJobExcutor),一个触发器(XXXJobTrigger,构造函数传入触发时间,间隔,执行者)即可。这里用两个示例来做演示
1. 基础任务处理
public class TestJobTrigger:BaseJobTrigger { public TestJobTrigger() : base(TimeSpan.Zero, TimeSpan.FromMinutes(10), new TestJobExcutor()) { } } public class TestJobExcutor : IJobExecutor { public void StartJob() { LogUtil.Info("执行任务!"); } public void StopJob() { LogUtil.Info("系统终止任务"); } }
以上实现了TestJobTrigger 做任务触发器,十分钟执行一次。TestJobExcutor 作为具体执行者,做任务处理。启动时只需在Startup.cs 中的ConfigureServices方法中添加如下代码即可:
services.AddHostedService<TestJobTrigger>();
2. 列表循环处理
public class ListJobTrigger : BaseJobTrigger { public ListJobTrigger() : base(TimeSpan.Zero, TimeSpan.FromMinutes(10), new ListJobExcutor()) { } } public class ListJobExcutor : ListJobExcutor<string> { private int _page = 0; protected override IList<string> GetExcuteSource() { if (_page==0) { _page++; return new List<string>{ "1", "2", "3" }; } return null; } protected override void ExcuteItem(string item, int index) { LogUtil.Info(item); } }
这个示例定时获取字符串列表,并打印。一样在Startup中注册即可。
四. 注意事项
1. 关于何时使用定时任务的问题
之所以要说这个问题,是因为我看过不少同学把定时任务这种方式当成万能胶,哪里有缝往哪贴,一个不行起两个。其实有很多场景都可以通过其关联事件加消息队列来完成,比如发短信,接收发送请求后塞消息队列并返回请求方接收成功,队列消费者来负责和短信服务商接口交互。只有对一些对时间属性有要求的处理,咱们通过定时任务等处理,如.....会员生日提醒....
2. 关于框架元素在解决方案的引用放置
一个建议: IJobExcutor,ListJobExcutor<IType> 可以放置在通用类库中,BaseJobTrigger,因为其依赖IHostService 放置在站点目录下比较合适。
3. 关于GenericHost的生存周期问题
如果你使用的是控制台启动,则此问题暂时可以忽略。
如果你使用的是站点项目,并且还是通过IIS启动,那么你可能要注意了,因为.net core 的站点自身是有HOST宿主处理,IIS是其上代理,其启动关闭,端口映射等由IIS内部完成。所以其依然受限于IIS的闲置回收影响,当IIS闲置回收时,其后的.Net Host也会被一同关闭,需要有新的请求进来时才会再次启动。不过鉴于当前任务处理已经如此简单,有个取巧的做法,实现一个站点自身的心跳检测任务,IIS默认20分钟回收,任务时间可以设为15分钟(你也可以设置IIS站点回收时间),当然如果你的任务如果没有那么严格的时间要求你也可以不用处理,因为回收后一旦接受到新的请求,任务会再次发起。
如果你已经看到这里,并且感觉还行的话可以在下方点个赞,或者也可以关注我的公总号(见二维码)
_________________________________________