Quartz.Net分布式任务管理平台
无关主题:一段时间没有更新文章了,与自己心里的坚持还是背驰,虽然这期间在公司做了统计分析,由于资源分配问题,自己或多或少的原因,确实拖得有点久了,自己这段时间也有点松懈,借口就不说那么多了,还是进入主题吧。
前言:我相信大多数人公司的业务上都有定时任务这么个功能,我们公司也不例外,刚来公司的时候使用Quartz.Net为我们组做了第一个任务,大致流程是:新建一个控制台程序,引用需要的程序集,Execute方法中写着咱们需要定时的任务的业务逻辑,同样这边需要用的一些数据库操作类引用的是WebApi项目中的一些类库,然后拿着服务去服务器上部署。随着时间的推移问题和不方便性慢慢被暴露,总结一下这样的方式在我公司发生的问题:
第一:随着任务量的增加,我会分配新的同事去开发这样的任务,可能由于我没有交流清楚,导致新的同事会自己下载所需的程序集,上面已经说了服务会引用WebApi中的类库会导致同样的程序集本版不匹配,同样改变Api中的一些程序集依然会出现这样的问题。
第二:业务逻辑集成在了服务类中很多时候产品“大大”会更改逻辑由于部署的方式 那么我们需要下载服务重新编译再次部署。
第三:任务在服务器上没有监听那么意味着服务挂了我们没有感知,案例:我们组会和其他组有着共同的任务,由于一些原因导致数据可能没有正确的相应,这个服务会去检测,客户问我昨晚的数据怎么到现在还是这样,排查后发现服务已经挂了。
第四:我们的服务中充满了业务逻辑导致逻辑分散,同样压力交给了定时服务,那么会导致服务的运行周期和任务执行的时间会冲突。
第五:就是可能部署的人今天请假了,导致这样的部署方式变得不在那么容易。
那么问题出现了,看见了总不能当作没发生,开始自行研究得到今天要分享的主题,但是针对上面的问题我相信很多的解决方案,在各位园友的公司中使用了上述方式没问题也是有可能的。
主题:为了解决上面的问题,便捷性,做出如下“架构图”:
对应我们的项目层次图如下:
说一下两幅图的对应关系按顺序称一图和二图:
一图中的Quartz服务节点对应这二图中的Quartz.Net_RemoteServer;
一图中的任务基类对应着二图中的Quartz.Net_JobBase;
一图中的任务子类对应着二图中的TestJob1,TestJob2
对于Mvc站点中我们使用了两层,关于分层这一说比较艺术不作过多讨论,我们项目中没有什么业务逻辑Web直接和“仓储”直接做交互,使用的是EF(ORM);表现层我们使用BootStrap和Vue.Js完成前端工作。·
先简单对一图作一下解释:
第一部分:Quartz服务节点是我们任务运行的调度器,既然是分布式,我们会将调度器部署在三台服务器,Quartz默认是基于内存的既然我们要分布式 ,我们需要持久化,本版本是基于SqlServer,同时Quartz框架在数据库中用锁实现了每个任务只会被一台服务器调用,那么当某个服务器上的调度器挂掉之后,Quartz会检测发现挂了之后会使用其他服务器上的节点来接手挂掉节点中的所有任务,这个都是Quartz自身提供的。具体我们看Quartz.Net_RemoteServer类库:
Program类中我们将配置调度器这里使用代码配置:
1 class Program 2 { 3 static void Main(string[] args) 4 5 { 6 var properties = new NameValueCollection(); 7 properties["quartz.scheduler.instanceName"] = "RemoteServer"; 8 properties["quartz.threadPool.type"] = "Quartz.Simpl.SimpleThreadPool, Quartz"; 9 properties["quartz.threadPool.threadCount"] = "5"; 10 properties["quartz.threadPool.threadPriority"] = "Normal"; 11 properties["quartz.scheduler.exporter.type"] = "Quartz.Simpl.RemotingSchedulerExporter, Quartz"; 12 properties["quartz.scheduler.exporter.port"] = "555";//端口号 13 properties["quartz.scheduler.exporter.bindName"] = "QuartzScheduler";//名称 14 properties["quartz.scheduler.exporter.channelType"] = "tcp";//通道名 15 properties["quartz.scheduler.exporter.channelName"] = "httpQuartz"; 16 properties["quartz.scheduler.exporter.rejectRemoteRequests"] = "true"; 17 properties["quartz.jobStore.clustered"] = "true";//集群配置 18 //下面为指定quartz持久化数据库的配置 19 properties["quartz.jobStore.type"] = "Quartz.Impl.AdoJobStore.JobStoreTX, Quartz"; 20 properties["quartz.jobStore.tablePrefix"] = "QRTZ_"; 21 properties["quartz.jobStore.driverDelegateType"] = "Quartz.Impl.AdoJobStore.SqlServerDelegate, Quartz"; 22 properties["quartz.jobStore.dataSource"] = "myDS"; 23 properties["quartz.dataSource.myDS.connectionString"] = @"Data Source=.;Initial Catalog=QuartzManager;User ID=sa;Password=123456"; 24 properties["quartz.dataSource.myDS.provider"] = "SqlServer-20"; 25 properties["quartz.scheduler.instanceId"] = "AUTO"; 26 var schedulerFactory = new StdSchedulerFactory(properties); 27 var scheduler = schedulerFactory.GetScheduler(); 28 scheduler.ListenerManager.AddJobListener(new MyJobListener(), GroupMatcher<JobKey>.AnyGroup()); 29 scheduler.Start(); 30 31 } 32 }
这是Quartz代码配置,很多项感兴趣的大家可以自行搜索一下意思;这边注释了客户端将要用的信息,同时我们增加监听器来监听运行服务的状况,Quartz提供了三种监听IJobListener,ITriggerListener,ISchedulerListener,和相应的实现类JobListenerSupport,TriggerListenerSupport,SchedulerListenerSupport,在这里我们使用的job的监听器同时我们继承实现类就好了,因为很多很方法不需要。
我们会在任务完成时向数据库中写入此任务当前状态,或者异常信息
1 jobId = Convert.ToInt32(context.JobDetail.JobDataMap["jobId"]); 2 name = context.Scheduler.GetTriggerState(context.Trigger.Key).ToString(); 3 triggerState = _changeType(context.Scheduler.GetTriggerState(context.Trigger.Key)); 4 customerJobInfoModel = _customerJobInfoRepository.LoadCustomerInfo(x => x.Id == jobId); 5 customerJobInfoModel.TriggerState = triggerState; 6 if (jobException == null) 7 { 8 9 10 Console.WriteLine("任务编号{0};执行时间:{1},状态:{2}", context.JobDetail.JobDataMap["jobId"], DateTime.Now, name); 11 } 12 else 13 { 14 customerJobInfoModel.Exception = jobException.Message; 15 Console.WriteLine("jobId{0}执行失败:{1}", context.JobDetail.JobDataMap["jobId"], jobException.Message); 16 } 17 _customerJobInfoRepository.UpdateCustomerJobInfo(customerJobInfoModel);
第二部分:要想实现任务执行我们得实现Quartz提供得Excute方法,也就是咱们的具体任务类,任务基类的设计来目的是:首先它是个抽象类,我们的任务基类会引用所需程序集并实现Excute方法,并提供子类必须要实现的方法,这样我们具体开发任务的时候继承我们的基类实现基类中的方法 而我们任务子类除了引用了基类不会在引用其他第三方程序集。继续看Quartz.Net_RemoteServer类库:
在这里就像看到一样我们将需要的程序集在基类中引用包括Quartz.dll,日志等,BaseJob封装了任务执行所必要的方法,和子类必须实现的方法:
1 public abstract class JobBase : IJob 2 { 3 public string RequestUrl { get; set; } 4 public JobBase() 5 { 6 SetRequestUrl(); 7 } 8 public virtual void Execute(IJobExecutionContext context) 9 { 10 try 11 { 12 HttpClient hc = new HttpClient(); 13 hc.GetAsync(RequestUrl); 14 } 15 catch (Exception ex) 16 { 17 throw new Exception(ex.ToString()); 18 } 19 } 20 public abstract void SetRequestUrl(); 21 }
同时可以看到我们的执行的任务本质上就是调用了Api中实现业务的接口,至此我们将业务逻辑和和部分压力点转移到Api中是我们任务做的东西和职责很明确:根据运行周期来执行一个业务,业务本身并不属于自己的功能点,接下来我们在看看具体子类,上面的这些东西在我这边完成后,想加任务的同事只需要编写任务子类就可以在通过管理界面操作,子类的代码如下:
1 using Quartz.Net_JobItem; 2 using System; 3 using System.Collections.Generic; 4 using System.Linq; 5 using System.Text; 6 using System.Threading.Tasks; 7 8 namespace TestJob1 9 { 10 public class Job1:JobBase 11 { 12 public Job1() { 13 SetRequestUrl(); 14 } 15 public override void SetRequestUrl() 16 { 17 base.RequestUrl = "http://localhost:53582/QuartzJobManage/Test"; 18 } 19 } 20 }
可以看到已经简单的动动小手指就可以了,轻松完成,同时咱们可以看到我们除了引用了基类没有引用其他第三方的框架,来避免上面的问题出现。基类在编译dll已经放入了Quartz的服务节点和Web站点中。子类我们同样需要放入着两个类库中,方便Quartz服务节点可以找到要执行的任务,和Web站点做反射需要
第三部分:Mvc站点将提供可视化的管理界面。首先看一下界面的模样:
导航栏是Quartz任务所有的状态,列表是我们任务的具体信息,在一图中中我们将在任务可视化界面中提供:添加任务,运行任务,修改任务运行周期,暂停任务,恢复任务,删除任务操作,也是目前我们需要在公司上线的第一个版本所有功能点。每个状态下的任务能使用的功能会有所不一样,接下来将逐一展示以上提到的功能点实现:
1:添加功能:我们需要将Quartz需要的任务的和我们需要的信息存入到我们自己的表中,以便运行任务的时候从表中取出任务信息提供Quartz。
添加任务的界面,我们需要给出这样的信息以及咱们自己编写的任务子类的dll,我们在这会将dll保存到Quartz服务节点和Web站点中。任务类全名是命名空间加上子类的名字。其他都是Quartz文档上都是会介绍的。程序集名称和上传的文件匹配。具体代码如下:
1 [HttpPost] 2 /// <summary> 3 /// 添加任务 4 /// </summary> 5 /// <param name="jobName">任务名称</param> 6 /// <param name="jobGroupName">任务所在组名称</param> 7 /// <param name="triggerName">触发器名称</param> 8 /// <param name="triggerGroupName">触发器所在的组名称</param> 9 /// <param name="cron">执行周期表达式</param> 10 /// <param name="dllName">任务程序集名称(xxxx.dll)</param> 11 /// <param name="fullJobName">任务类全名</param> 12 /// <param name="jobDescription">任务描述</param> 13 /// <param name="jobstartTime">开始时间</param> 14 /// <returns></returns> 15 16 public JsonResult AddJob(string jobName, string jobGroupName, string triggerName, string triggerGroupName, string cron, string dllName, string fullJobName, string jobDescription, DateTime jobStartTime) 17 { 18 HttpPostedFileBase dllFile = Request.Files[0] as HttpPostedFileBase; 19 if (dllFile == null || dllFile.ContentLength <= 0) 20 { 21 return Json(ResponseDataFactory.CreateAjaxResponseData("-10003", "无任务文件", null)); 22 } 23 _savePathInWeb(dllFile); 24 //TODO:添加到数据库自己的表 25 var jobId = _customerJobInfoRepository.AddCustomerJobInfo(jobName, jobGroupName, triggerName, triggerGroupName, cron, dllName, fullJobName, jobDescription, jobStartTime); 26 _savePathInRemoteServer(dllFile); 27 return Json(ResponseDataFactory.CreateAjaxResponseData("1", "添加成功", jobId)); 28 29 }
添加完之后界面会变成如下这样:
可以看到这个任务可以执行运行和删除,运行周期是10秒钟执行一次。下面接着点击运行让这个任务跑起来:
这样这个任务就以咱们设置的运行周期运行起来,同时运行的任务提供了暂停,删除,更改运行周期操作:先看任务的运行效果,Quartz服务节点钟我们使用监听器对服务的监听同时输出了一些信息:
同时运行任务执行背后的执行逻辑为:
1 [HttpPost] 2 /// <summary> 3 /// 启动任务 4 /// </summary> 5 /// <param name="jobId">任务编号</param> 6 /// <returns></returns> 7 public JsonResult RunJob(int jobId) 8 { 9 var ajaxResponseData = _operateJob(jobId, (jobDetail) => { jobDetail.TriggerState = 0; _customerJobInfoRepository.UpdateCustomerJobInfo(jobDetail); return _jobHelper.RunJob(jobDetail); }); 10 return Json(ajaxResponseData); 11 }
1 /// <summary> 2 /// 运行任务 3 /// </summary> 4 /// <param name="jobInfo">任务信息</param> 5 /// <returns></returns> 6 public bool RunJob(Customer_JobInfo jobInfo) 7 { 8 Assembly assembly = Assembly.LoadFile(AppDomain.CurrentDomain.BaseDirectory + $"bin/{jobInfo.DLLName}"); 9 var type = assembly.GetType(jobInfo.FullJobName); 10 JobKey jobKey = _createJobKey(jobInfo.JobName, jobInfo.JobGroupName); 11 if (!_scheduler.CheckExists(jobKey)) 12 { 13 IJobDetail job = JobBuilder.Create(type) 14 .WithIdentity(jobKey) 15 .UsingJobData(_createJobDataMap("jobId", jobInfo.Id)) 16 .Build(); 17 18 CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.CronSchedule(jobInfo.Cron); 19 ITrigger trigger = TriggerBuilder.Create().StartNow()//StartAt(DateTime.SpecifyKind(jobInfo.JobStartTime, DateTimeKind.Local)) 20 .WithIdentity(jobInfo.TriggerName, jobInfo.TriggerGroupName) 21 .ForJob(jobKey) 22 .WithSchedule(scheduleBuilder.WithMisfireHandlingInstructionFireAndProceed()) 23 .Build(); 24 25 26 _scheduler.ScheduleJob(job, trigger); 27 28 } 29 return true; 30 }
这里就用了到了我们第一步添加任务时上传的dll和任务类的全名 我们通过加载程序集通过全名找到我们需要执行的任务类,同时假如额外信息,JobId,以便监听器可以使用id更新自己的表。同样我们可以更改运行周期来调节任务调度的频次:
那么在更改运行周期之后我们需要重新构建触发器
1 /// <summary> 2 /// 更改任务运行周期 3 /// </summary> 4 /// <param name="jobInfo">任务信息</param> 5 /// <returns></returns> 6 public bool ModifyJobCron(Customer_JobInfo jobInfo) 7 { 8 CronScheduleBuilder scheduleBuilder = CronScheduleBuilder.CronSchedule(jobInfo.Cron); 9 var triggerKey = _createTriggerKey(jobInfo.TriggerName, jobInfo.TriggerGroupName); 10 ITrigger trigger = TriggerBuilder.Create().StartAt(DateTime.SpecifyKind(jobInfo.JobStartTime, DateTimeKind.Local)) 11 .WithIdentity(jobInfo.TriggerName, jobInfo.TriggerGroupName) 12 .WithSchedule(scheduleBuilder.WithMisfireHandlingInstructionDoNothing()) 13 .Build(); 14 _scheduler.RescheduleJob(triggerKey, trigger); 15 return true; 16 }
这个点是需要注意的!!!
接下来我们使用暂停功能:
暂停列表中我们对此任务可进行恢复和删除操作,那么恢复操作就是恢复任务运行,删除就是从调度器中删除这个任务同时我们将自己的表中标记删除。
到现在我们的整体流程就已经走了,但是最终我们的主题是分布式,当然这个已经能满足一部分需求了,所以接下来由于环境有限我这边会启动两个服务节点能模拟多个服务器然后我们关闭掉一个服务节点看另一个是否能拿到任务进行,启动任务后我们可以看到:
当我们关闭刚才正在调度此任务的调度器之后看看另一个调度器的结果:
可以看到 另一个调度器执行刚才那个任务,原理是 我们在服务端设置了检查时间每个调度器会以这个时间去数据库钟检测另外的节点是否正常,当挂掉后会从数据库钟取出任务信息再次调度。至此我们的分布式调度平台就大致分享完了。由于是第一版,所以很多地方还不合理或者需要改进会在之后的时间磨合中不断去完善。
最后定时框架有很多,大家要的是 第一符合自己的业务,第二自己能熟悉掌控的去选择技术。
题外:分享一些在这次主题钟关于代码这一块的东西,当然对于大牛来说这都是小儿科了,大家可以看到这个项目代码量并不是很大,没有什么业务逻辑,层次也很简单。
1:因为没有什么业务逻辑的处理,我们不需要很多逻辑层,所以大家对分层这块一定要具体情况具体对待。
2:面向抽象编程基本的东西我们还是要去遵守,即使像这个简单的层次的项目,我项目中使用的MEF进行依赖注入的
1 [Export] 2 public class QuartzJobManageController : Controller 3 { 4 5 [Import("CustomerJobInfoRepository")] 6 public ICustomerJobInfoRepository _customerJobInfoRepository { get; set; } 7 [Import("JobHelper")] 8 private JobHelper _jobHelper { get; set; } 9 public QuartzJobManageController() 10 { 11 } 12 }
[Export("CustomerJobInfoRepository",typeof(ICustomerJobInfoRepository))] internal class CustomerJobInfoRepository : ICustomerJobInfoRepository { private readonly QuartzManagerEntities _dbContext; public CustomerJobInfoRepository() { _dbContext = DbContextFactory.DbContext; } }
同时可以看到我们实现类钟的访问级别,很多时候 上层依然可以看到具体的实现,接口形同虚设,包括上面所提到的任务基类和任务子类,一方面解决了我们程序集不同人可能引入不同版本,本质上还是抽象使依赖降到最低
3:同时一些工厂的使用,大家可以看看设计原则和设计模式
给大家一下参考文章:http://www.cnblogs.com/aflyun/p/6515813.html