动态创建与修改定时任务

  最近遇到一个需求,需要能够按照特定的配置执行定时任务,而且定时任务需要在应用不重启的情况下动态增删改,Spring提供的@Scheduled注解是硬编码形式,只能实现固定的定时任务,随后经过一番探究,依托注明的quartz框架终于实现了该功能,下面来分享一下我的方案。

  • 首先引入quartz maven依赖:

    
        org.quartz-scheduler
        quartz
        
        
    
  • 我的定时任务配置是存储在MySQL数据库当中的,当程序启动时,初始化过程会的init()方法会读取一遍所有有效的定时任务配置,然后将其实例化为一个个对象,一个对象便代表了一个定时任务,我定义的类为 public class ScheduledClauseTriggerEngine implements ClauseTriggerEngine, Job, AutoCloseable,其中ClauseTriggerEngine为我自定义的接口,因为在项目中除定时触发外还有其他的任务触发方式,不再过多赘述,Job(org.quartz.Job)是quartz框架的一个接口,代表一个任务,在任务调度时,Jobexecute(JobExecutionContext context)方法会被调用,用以执行任务内容。所有实例化后的ScheduledClauseTriggerEngine对象会被存在一个Map容器中去,Key为定时任务的id,后续在定时任务增删改的时候,也会同步修改这个Map的内容。
  • 创建SchedulerFactoryBean(org.springframework.scheduling.quartz.SchedulerFactoryBean),并设置JobFactory,由于我这里只使用到SchedulerFactoryBean一次,因此这一小段代码写在构造方法中的,若是全局使用,需要在Spring的Configuration类中专门定义一个SchedulerFactoryBean类型的Bean(比较规范的用法)。

    SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean();
            schedulerFactoryBean.setJobFactory((bundle, scheduler) ->
                    triggers.get(Integer.parseInt(bundle.getJobDetail().getKey().getName())));
            schedulerFactoryBean.afterPropertiesSet();
            this.scheduler = schedulerFactoryBean.getScheduler();
            this.scheduler.start();

    setJobFactory这一步比较重要,默认的调度方案是通过反射实现的,即传入一个Job类型的class,然后反射实例化一个此类的对象,再去调用execute方法,通过JobExecutionContext传参,而我的方案是所有任务类都已实例化完成,我希望在触发时直接返回对应的对象实例去执行即可,因此需要去修改JobFactory,triggers是上一步中所说的存储定时任务的Map,而bundle.getJobDetail().getKey().getName()其实就是获取到了任务的key,在我这里其实也就是定时任务id,这个与后面步骤的代码相对应,也就是此时定时任务触发时,会拿到我提前准备好的Map中的对应任务实例去执行,覆盖默认行为。

  • 实例化ScheduledClauseTriggerEngine并创建定时任务,下面为比较重要的几行代码:

    // 此部分代码为实例化并创建一个定时任务的代码,我将其封装为了一个方法,方便调用,在初始化方法中查询所有定时任务循环调用即可
    // ... 忽略数据库查询及参数校验的过程
    
    // 创建JobDetail
    JobDetail jobDetail = new JobDetailImpl()
                  .getJobBuilder()
                  // 任务的id以及group,id为数据库中的id,在上一步设置的JobFactory,当改任务被调度时,会根据此id去获取到要执行的任务
                  .withIdentity(clauseTriggerId.toString(), SCHEDULE_JOB_GROUP_NAME)
                  // 任务描述,可选
                  .withDescription(clauseTrigger.getName())
                  // 这里直接传入ScheduledClauseTriggerEngine.class即可
                  .ofType(ScheduledClauseTriggerEngine.class)
                  .build();
    // 添加触发器并开始调度任务
    scheduler.scheduleJob(jobDetail, TriggerBuilder.newTrigger()
            // 要调度的任务的key
            .withIdentity(clauseTriggerId.toString())
            // 触发周期,triggerConfigToCronExpression是将数据库中的时间配置转换为corn表达式的方法
            .withSchedule(CronScheduleBuilder.cronSchedule(triggerConfigToCronExpression(clauseTrigger.getTriggerConfig())))
            .build());
    
    // 此时其实定时任务已经开始生效
    
    // 后续操作...
    triggerEngine.setTemplateList(scheduledTemplates);
    log.info("add one scheduled clause trigger: {}", clauseTrigger.getName());
    return triggerEngine;
  • 定时任务的增删改:

    • 根据id新增一个任务:

      public void addTrigger(int triggerId) {
        // 根据triggerId实例化和启动一个定时任务,并添加到定时任务的Map中去,instanceOneScheduledClauseTriggerEngine就是上一步所封装的方法
        ScheduledClauseTriggerEngine triggerEngine = triggers.computeIfAbsent(triggerId, k -> instanceOneScheduledClauseTriggerEngine(triggerId));
      }
    • 根据id删除一个任务:

      public void removeTrigger(int clauseTriggerId) {
            try {
                // 生成要删除的jobKey,注意此处必须传入刚才所使用的的group name,否则会使用默认的组名,便无法查询到我们想要删除的任务
                JobKey jobKey = JobKey.jobKey(String.valueOf(clauseTriggerId), SCHEDULE_JOB_GROUP_NAME);
                // 移除定时任务
                scheduler.deleteJob(jobKey);
                // 从Map中移除对应的对象
                triggers.remove(clauseTriggerId);
                log.info("remove schedule trigger: {}", jobKey);
            } catch (SchedulerException e) {
                log.error(e.getMessage(), e);
            }
      }
    • 根据id更新一个任务:

      public void updateTrigger(int triggerId) {
          // 由于我项目中的定时任务可能任务执行内容会变,因此我是将定时任务删除再重新添加,若定时任务只会有触发时间的变化,也可使用rescheduleJob方法只更新触发时间
          removeTrigger(triggerId);
          addTrigger(triggerId);
      }
  • 集群环境下需要注意的地方

    • 多台机器时,我需要只有一台机器执行任务,而其他机器不执行,在此处我使用了Redis作为锁,追求简便,当然也可使用zookeeper。

      @Override
      public void execute(JobExecutionContext context) {
          String jobKey = context.getJobDetail().getKey().toString();
          String redisKey = JOB_REDIS_PREFIX + jobKey;
          // 判断是否获取到锁
          //noinspection ConstantConditions
          if(redisTemplate.opsForValue().increment(redisKey) != 1L){
              log.info("job {} has in running", jobKey);
              return;
          }
      
          // 注意上面的判断必须放在try-finally块之外,否则会导致一个隐秘的BUG(无论当前机器是否获取到锁,都会执行finally中的方法,释放掉锁,产生错误)
      
          // 为锁加上默认过期时间
          redisTemplate.expire(redisKey, 3600, TimeUnit.SECONDS);
          try {
              MDC.put("traceId", randomId());
              log.info("execute schedule job: {}", jobKey);
              long l = System.currentTimeMillis();
              trigger();
              log.info("finish job: {}, used time: {}ms", jobKey, System.currentTimeMillis()-l);
          } catch (Exception ex){
              log.error(ex.getMessage(), ex);
          }finally {
              MDC.clear();
              // 释放锁
              redisTemplate.delete(redisKey);
          }
      }

      其实在之前,我写了一个注解:@RedisLock,可以通过注解方式直接为某个方法加分布式锁,但是注解不能传入变量,只能传入常量,在这个项目,锁的key是动态的,无法直接使用,便先采用直接写代码的形式,后期可以添加上此功能,通过注解传入SpringEL表达式解析方法入参,就可以实现动态key值了。

    • 多台机器定时任务更新问题,当定时任务配置更改时,我需要响应的修改定时任务,但是多台机器,我不能一台一台机器的手动去调用对应的方法,因此我想到了使用redis的发布订阅去完成,因为Redis的默认消息模式是群发模式,刚好符合我的需求,若项目中有MQ,也可配置一个群发的MQ Topic去实现,略微复杂一些。
      附我所使用的代码,供参考,基于spring-redis:

      @Bean
        RedisMessageListenerContainer redisMessageListenerContainer(RedisConnectionFactory connectionFactory,
                                                                    ScheduledTriggerService scheduledTriggerService,
                                                                    DwdDataTriggerService dwdDataTriggerService,
                                                                    ClauseExecuteService clauseExecuteService) {
            RedisMessageListenerContainer container = new RedisMessageListenerContainer();
            container.setConnectionFactory(connectionFactory);
      
            //trigger发布订阅
            container.addMessageListener((message, bytes) -> {
                String body = new String(message.getBody(), StandardCharsets.UTF_8);
      
                if (body.startsWith("scheduled-trigger-change")) {
                  Assert.isTrue(body.matches("^scheduled-trigger-change:((add)|(remove)|(update)):\\d+$"),"invalid scheduled-trigger-change message: " + body);
                    String[] split = body.split(":");
                    String type = split[1];
                    int triggerId = Integer.parseInt(split[2]);
      
                    switch (type) {
                        case "add":
                            scheduledTriggerService.addTrigger(triggerId);
                            break;
                        case "remove":
                            scheduledTriggerService.removeTrigger(triggerId);
                            break;
                        case "update":
                            scheduledTriggerService.updateTrigger(triggerId);
                            break;
                        default:
                            scheduledTriggerService.refreshAllTriggerEngine();
                            break;
                    }
                } else {
                    log.info("receive redis message, topic: {}, body, {}", new String(message.getChannel()), body);
                    dwdDataTriggerService.refreshClauseTrigger();
                }
            }, new ChannelTopic("trigger-config-change"));
            return container;
        }

      发送消息:

      redisTemplate.convertAndSend("trigger-config-change", "scheduled-trigger-change:update:0");

      当定时配置变更时,发送redis消息即可。
      总结:
      本篇文章基于我的实际项目,讲解了借助于quartz框架的定时任务动态增删改的方案,但是因项目而异,我也做了许多定制化的操作,我的思路就是一项定时任务配置对应一个对象实例,任务触发时直接拿到对应的对象实例进行调用,但是quartz框架的默认调度方案不是这样的,所以做了一下调整,此外还增加了集群环境的支持。本篇文章提供一种方案或者说思路,实际使用时还需要大家结合自己的需求进行合理更改或优化,例如当定时任务比较轻量的时候,我认为可不借助于框架,使用轮询也未尝不是一种简单有效的方案。

你可能感兴趣的:(java定时任务)