Spring定时任务与nacos集成实现动态修改定时任务执行时间

192 阅读4分钟

背景:

服务中配置了一组定时任务, 在运行过程中, 由于任务执行情况的不确定, 
需要在调试或运行过程中能够动态调整定时任务的执行时间, 而不必每次修改执行时间都需要重启服务.
项目使用了nacos进行配置, 通过监听nacos配置修改, 来修改定时任务执行时间.
另外, 需要能够通过接口直接调起定时任务.

tips: 此处尝试使用nacos更新事件, 发现由于nacos更新后, RefreshScope没有执行完, 导致获取不到最新的完整配置, 此处改为监听RefreshScopeRefreshedEvent来保证配置完整(同时也带来若干次不必要的定时任务刷新, 代码中会对比任务crontab前后差异, 以此为据决定是否更新)

代码部分

启动类

package cn.cantabile.andante.schedule;

import org.springframework.boot.SpringApplication;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.annotation.EnableScheduling;

@EnableScheduling  // 开启定时任务
@EnableAsync       // 开启异步执行
public class TagPlatformScheduleApplication {
    public static void main(String[] args) {
        SpringApplication.run(ScheduleApplication.class, args);
    }

}

IJob

package cn.cantabile.andante.schedule.job;

import org.springframework.scheduling.annotation.Async;
import java.util.Map;


public interface IJob extends Runnable {

    String getJobName();

    @Async   // 定时任务支持异步执行
    public abstract void run();

    void runWithParams(Map<String, Object> params);
}

AbstractJob

package cn.cantabile.andante.schedule.job;

import lombok.extern.slf4j.Slf4j;

import java.util.Map;

@Slf4j
public abstract class AbstractJob implements IJob {

    @Override
    public void run() {
        // 获取定时任务执行参数
        Map<String, Object> params = getRunParams();
        // 执行任务
        runWithParams(params);
    }

    public abstract void runWithParams(Map<String, Object> params) throws Exception;
    
    public abstract Map<String, Object> getRunParams();

    @Override
    public String getJobName() {
        return this.getClass().getSimpleName();
    }
}

SampleJobA

package cn.cantabile.andante.schedule.job.impl;

import cn.cantabile.andante.schedule.job.AbstractJob;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;

import java.util.Map;
import java.util.HashMap;

/**
 * 定时任务测试验证
 */
@Slf4j
@Service
public class SampleJobA extends AbstractJob {
    @Override
    public void doJob_(Map<String, Object> params) throws Exception {
        log.info("SampleJobA run with params: {}", params);
        // 模型执行
        try {
            Thread.sleep(10000);
        } catch (Exception e) {
            log.error("SampleJobA run with params: {} failed, ex: ", params, e);
        }
        log.info("SampleJobA done.");
    }
    
    @Override
    public Map<String, Object> getRunParams() {
        return new HashMap<>();
    }
}

动态任务scheduler

package cn.cantabile.andante.schedule;

import cn.cantabile.andante.schedule.config.JobConfig;
import lombok.extern.slf4j.Slf4j;
import org.apache.logging.log4j.util.Strings;
import org.springframework.cloud.context.scope.refresh.RefreshScopeRefreshedEvent;
import org.springframework.context.ApplicationListener;
import org.springframework.scheduling.SchedulingException;
import org.springframework.scheduling.TaskScheduler;
import org.springframework.scheduling.annotation.SchedulingConfigurer;
import org.springframework.scheduling.concurrent.ConcurrentTaskScheduler;
import org.springframework.scheduling.config.ScheduledTaskRegistrar;
import org.springframework.scheduling.support.CronTrigger;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;

import javax.annotation.Resource;
import java.util.Map;
import java.util.Objects;
import java.util.Optional;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ScheduledFuture;

/**
 * 动态任务调度器
 * 实现SchedulingConfigurer用户动态修改服务中定时任务
 * 实现ApplicationListener<RefreshScopeRefreshedEvent>用于监控RefreshScopeRefreshedEvent事件
 */
@Slf4j
@Component
public class DynamicJobScheduler implements SchedulingConfigurer, ApplicationListener<RefreshScopeRefreshedEvent> {
    // 当前定时任务配置
    private static final ConcurrentHashMap<String, String> currentJobCronMap = new ConcurrentHashMap<>();
    // 当前运行的定时任务ScheduledFuture
    private static final ConcurrentHashMap<String, ScheduledFuture<?>> SCHEDULED_FUTURES = new ConcurrentHashMap<>();
    ScheduledTaskRegistrar taskRegistrar;

    private final JobConfig jobConfig;

    public DynamicJobScheduler(JobConfig jobConfig) {
        this.jobConfig = jobConfig;
    }

    @Resource
    Map<String, IJob> jobMap;
    private final Map<String, IJob> jobs = new ConcurrentHashMap<>();

    public IJob getJobWithName(String jobName) {
        if (jobs.get(jobName) != null) {
            return jobs.get(jobName);
        }
        for (Map.Entry<String, IJob> entry : jobMap.entrySet()) {
            IJob job = entry.getValue();
            if (jobName.equals(job.getJobName())) {
                jobs.put(jobName, job);
                return job;
            }
        }
        log.warn("没有找到任务: {}", jobName);
        return null;
    }

    @Override
    public void configureTasks(ScheduledTaskRegistrar taskRegistrar) {
        this.taskRegistrar = taskRegistrar;
        refreshSchedule();
    }

    private void refreshSchedule() {
        log.info("更新前定时任务的配置是: {}", currentJobCronMap);
        Map<String, String> jobCronMap = jobConfig.getJobCronMap();
        // 如果Nacos中的定时任务配置为空, 关闭当前所有定时任务
        if (CollectionUtils.isEmpty(jobCronMap)) {
            log.warn("Nacos中的定时任务配置为空, 关闭当前所有定时任务.");
            currentJobCronMap.entrySet().removeIf(entry -> {
                log.info("删除关闭的策略任务, jobName={}, cron={}", entry.getKey(), entry.getValue());
                cancel(entry.getKey());
                return true;
            });
            return;
        }
        log.info("Nacos中的定时任务配置是: {}", jobCronMap);
        // 遍历当前列表, 清理需要关闭的Job
        currentJobCronMap.entrySet().removeIf(entry -> {
            String newCron = jobCronMap.get(entry.getKey());
            if (createTrigger(newCron) == null) {
                log.info("删除关闭或无效的策略任务, jobName={}, cron={}, newCron={}", entry.getKey(), entry.getValue(), newCron);
                cancel(entry.getKey());
                return true;
            }
            return false;
        });

        try {
            jobCronMap.forEach((jobName, cron) -> {
                IJob job = getJobWithName(jobName);
                if (job != null) {
                    // String finalCron = Strings.isBlank(cron) ? "-" : cron.trim();
                    CronTrigger trigger = createTrigger(cron);
                    // 此处仅新增任务的trigger可能为null.
                    if (trigger == null) {
                        log.warn("新增定时任务失败, jobName={}, cron={}", jobName, cron);
                        return;
                    }
                    if (SCHEDULED_FUTURES.containsKey(jobName)) {
                        // 更新定时任务
                        if (trigger.getExpression().equals(currentJobCronMap.get(jobName))) {
                            log.info("定时任务没有发生任何变化, jobName={}, cron={}", jobName, currentJobCronMap.get(jobName));
                            return;
                        } else {
                            log.info("更新定时任务配置, jobName={}, cron={}, newCron={}", jobName, currentJobCronMap.get(jobName), cron);
                            reset(jobName, job, trigger);
                        }
                    } else {
                        // 新增定时任务
                        log.info("新增定时任务配置, jobName={}, newCron={}", jobName, cron);
                        addTriggerTask(jobName, job, trigger);
                    }
                    currentJobCronMap.put(jobName, trigger.getExpression());
                }
            });
        } catch (Exception e) {
            throw new RuntimeException(e);
        } finally {
            log.info("最新的定时任务配置是: {}", currentJobCronMap);
        }
    }

    /**
     * 添加任务
     *
     * @param jobName 任务名称
     * @param job     任务
     * @param trigger 触发器
     */
    public void addTriggerTask(String jobName, IJob job, CronTrigger trigger) {
        if (SCHEDULED_FUTURES.containsKey(jobName)) {
            throw new SchedulingException("the JobName[" + jobName + "] was added.");
        }
        TaskScheduler taskScheduler = Optional.ofNullable(taskRegistrar.getScheduler()).orElse(new ConcurrentTaskScheduler());
        ScheduledFuture<?> future = taskScheduler.schedule(job, trigger);
        SCHEDULED_FUTURES.put(jobName, future);
    }

    /**
     * 取消任务
     *
     * @param jobName JobName
     */
    public void cancel(String jobName) {
        ScheduledFuture<?> future = SCHEDULED_FUTURES.get(jobName);
        if (Objects.nonNull(future)) {
            SCHEDULED_FUTURES.get(jobName).cancel(false);
        }
        SCHEDULED_FUTURES.remove(jobName);
    }

    /**
     * 重置任务
     *
     * @param jobName JobName
     * @param job     任务
     * @param trigger 触发器
     */
    public void reset(String jobName, IJob job, CronTrigger trigger) {
        cancel(jobName);
        addTriggerTask(jobName, job, trigger);
    }


    /**
     * 获取正在调度的JobName列表
     *
     * @return 正在调度的JobName列表
     */
    public Set<String> getJobNames() {
        return SCHEDULED_FUTURES.keySet();
    }

    /**
     * 是否存在任务
     *
     * @param jobName 任务名称
     * @return 是否存在任务
     */
    public boolean hasTask(String jobName) {
        return SCHEDULED_FUTURES.containsKey(jobName);
    }

    /**
     * 任务调度是否已经初始化完成
     *
     * @return 初始化完成
     */
    public boolean inited() {
        return this.taskRegistrar != null && this.taskRegistrar.getScheduler() != null;
    }


    @Override
    public void onApplicationEvent(RefreshScopeRefreshedEvent event) {
        log.info("@RefreshScope注解的配置类已经刷新");
        refreshSchedule();
    }

    /**
     * 验证cron表达式是否有效
     *
     * @param cron cron表达式
     * @return 是否有效
     */
    private CronTrigger createTrigger(String cron) {
        if (Strings.isBlank(cron) || "-".equals(cron.trim())) {
            return null;
        }
        try {
            return new CronTrigger(cron.trim());
        } catch (IllegalArgumentException e) {
            log.error("cron表达式不合法: {}", cron);
            return null;
        }
    }

    public Map<String, String> listAllJobs() {
        return currentJobCronMap;
    }

    public String getJobInfo(String jobName) {
        return currentJobCronMap.get(jobName);
    }
}

crontab配置类

package cn.cantabile.andante.schedule.config;

import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.cloud.context.config.annotation.RefreshScope;
import org.springframework.stereotype.Component;

import java.util.Map;


@Component
@RefreshScope
@ConfigurationProperties(prefix = "job")
@Data
public class JobConfig {
    private Map<String, String> jobCronMap;
}