如何根据spring定时任务源码写一个自己的动态定时任务组件

396 阅读10分钟

d99d34d0a8e347f5b398150eda617fc2_tplv-k3u1fbpfcp-watermark.png

紧接上文,上篇文章解析了springboot定时任务源码以及分享了自己写动态定时任务组件的一些思路,但是由于篇幅问题并没有分享具体的代码实现,光分享思路不留下代码的行为可以说是流氓行为,于是便有了此篇文章。

若没有看过上文请阅读后再进行此文阅读,观感会好上不少。 上文链接在此。

本文的大致思路

上文分析了2种实现:

1.配置中心做触发器,时间一到,调用对应服务接口,通过反射执行任务。

2.定时任务仍在各个项目,配置中心只做定时任务修改开启关闭的接口调用。

第一种实现相对来说更加简单,于是我决定以第二种方式的实现来写这篇文章。若是第二种方式能搞懂,那第一种实现方式自然而然也就明了了。只是在集群模式下思考的问题有些许不同,但不并影响整体的行文思路。

大致的页面

image.png

具体代码

注 : 本文只提供最基础的代码实现。

数据库设计 请参照下方实体类

注: 都是最简单的实现

定时任务配置实体

/**
 * BatchSetting Entity
 *
 * @author yaoj
 * @since 2022/7/27
 */
@Data
public class TaskSetting  {
    @ApiModelProperty(value = "id")
    protected Integer id;
    @ApiModelProperty(value = "服务名称")
    private String serviceName;
    @ApiModelProperty(value = "类名 字段长度[100]")
    private String className;
    @ApiModelProperty(value = "方法名 字段长度[100]")
    private String methodName;
    @ApiModelProperty(value = "是否启用")
    private Integer isEnable;
    @ApiModelProperty(value = "CRON表达式 字段长度[30]")
    private String cron;
    @ApiModelProperty(value = "CRON表达式描述 字段长度[255]")
    private String cronDescription;
    @ApiModelProperty(value = "方法描述 字段长度[255]")
    private String methodDescription;
}

定时任务日志实体

/**
 *
 * @author yaoj
 * @since 2021/10/18
 */
@Data
public class TaskExecutionLog {
    @ApiModelProperty(value = "id")
    protected Integer id;
    @ApiModelProperty(value = "任务配置ID")
    private Integer TaskSettingId;
    @ApiModelProperty(value = "开始时间")
    private Date startTime;
    @ApiModelProperty(value = "结束时间")
    private Date endTime;
    @ApiModelProperty(value = "处理时间")
    private String processingTimeStr;
    @ApiModelProperty(value = "是否成功")
    private Integer isSuccess;
    @ApiModelProperty(value = "结果消息")
    private String resultMsg;
}

开启动态定时任务注解 @EnableMyDynamicSchedule

既然是组件,自然也要做成可拔插式的。在对应项目上加上主启动注解即可开启动态定时任务

/*
 * @author yaoj
 * @since 2022/7/27
 * 注解使用方法:主启动类加上此注解 开启动态定时任务配置
 * 所属工程必须实现{ISchedule.class,IScheduleLog.class}类 实现相关方法
 * ISchedule : 用于去配置中心抓取定时任务配置
   因为每个项目不一定有配置中心的数据源所以提供统一的接口由各个工程自行实现配置抓取
 * IScheduleLog : 用于aop切面 插入定时任务记录信息(为了方便查看定时任务执行情况,将日志记录插入数据库)
 */
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(MyScheduleImportSelector.class)
@Documented
public @interface EnableMyDynamicSchedule {

}
/**
 * @author yaoj
 * @since 2022/7/27
 **/
public class MyScheduleImportSelector implements ImportSelector {

    @Override
    public String[] selectImports(AnnotationMetadata importingClassMetadata) {
        return new String[]{
                // 定时任务注册类
                MyScheduledTaskRegistrar.class.getName(),
                // 配置中心调用的各个项目的controller层 用于修改本项目的定时任务
                MyScheduleController.class.getName(),
                // 配置中心调用的各个项目的service层 用于修改本项目的定时任务
                MyScheduleService.class.getName(),
                // aop 切面用于记录日志
                ScheduleMethodAop.class.getName()};
    }
}

@ScheduleLog注解

/**
 * 动态定时任务注解
 * 加上此注解的方法会记录日志信息
 *
 * @author yaoj
 * @since 2022/7/27
 */
@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface ScheduleLog {

}

相关接口 实现都有各个项目实现

ISchedule 用于抓取任务配置

/**
 * 抓取定时任务配置接口
 *
 * @author yaoj
 * @since 2022/7/27
 */
public interface ISchedule {

    List<TaskSetting> findList(String serviceName);

}

IScheduleLog 用于记录日志

/**
 * 记录定时任务日志接口
 *
 * @author yaoj
 * @since 2022/7/27
 */
public interface IScheduleLog {

    int insertLog(TaskExecutionLog executionLog);

}

实体相关类

MyCronTask

对spring的cronTask做相关增强,加上类名和方法名称,如此便可以区分定时任务。

注:springboot解析cron表达式有一些坑:

1.在一些springboot比较落后的版本,如2.2.10.RELEASE,它使用的cron表达式解析CronSequenceGenerator。这个表达式解释类很多cron表达式时间都解析不了如0 0 0 ? * 4#3。 建议直接参考比较新的springboot版本如2.4.3,它已经使用CronExpression进行cron表达式的解析,大多数常用的表达式都可以解析,我暂时没发现有什么不能解析的,如果有请提醒我,谢谢! 你只需要将新springboot中的关于CronExpression的相关类都拷贝到自己的项目中,然后重新写一个自己的CronTrigger即可,之后再在MyCronTask构造方法中传入自己的CronTrigger即可。

此为cron表达式解析相关类,版本变化可能不同,仅供参考。

image.png

2.即使新版的CronExpression解析cron表达式仍存在一些问题,比如0 0 0 ? * 4#3,表示为每个月第三个周三执行,但是在springboot解析这个时间为每个月第三个周四。如果想修改可以直接cv出他所有的cron表达式解析代码,然后自行更改,具体位置为QuartzCronField类的parseDayOfWeek方法。

image.png 而且一些关于以星期结尾的cron表达式解析如0 5 2 ? * thur,在springboot中英文都只可以有三位,应该写成 0 5 2 ? * thu,否则也会报错。

image.png

/**
 * @author yaoj
 * @since 2022/7/27
 **/

public class MyCronTask extends TriggerTask {

    private String expression;

    private String methodName;

    private String className;

    public MyCronTask(Runnable runnable, String expression, String className, String methodName) {
        this(runnable, new CronTrigger(expression, TimeZone.getDefault()));
        this.className = className;
        this.methodName = methodName;
    }

    private MyCronTask(Runnable runnable, CronTrigger cronTrigger) {
        super(runnable, cronTrigger);
        this.expression = cronTrigger.getExpression();
    }

    public String getMethodName() {
        return StringUtils.trim(className.toLowerCase()) + "_" + StringUtils.trim(methodName);
    }

}

MyScheduledTask

參照spring的ScheduledTask,將CronTask换成MyCronTask


/**
 * @author yaoj
 * @since 2022/7/27
 **/
public final class MyScheduledTask {

    @Nullable
    volatile ScheduledFuture<?> future;
    private MyCronTask task;


    MyScheduledTask(MyCronTask task) {
        this.task = task;
    }

    public MyCronTask getTask() {
        return this.task;
    }

    public void cancel() {
        ScheduledFuture<?> future = this.future;
        if (future != null) {
            future.cancel(true);
        }
    }

    @Override
    public String toString() {
        return this.task.toString();
    }

ScheduleDto

用于做数据处理的基本dto

/**
 * 定时任务实体
 *
 * @author yaoj
 * @since 2022/7/27
 **/
@Data
@AllArgsConstructor
public class ScheduleDto {

    //任务类
    private String clazz;

    //定时任务方法
    private String method;

    //cron表达式
    private String cron;

    public String getMethodWithClass() {
        return StringUtils.trim(clazz.toLowerCase()) + "_" + StringUtils.trim(method);
    }

    public String getClazz() {
        return StringUtils.trim(clazz.toLowerCase());
    }

    public String getMethod() {
        return StringUtils.trim(method);
    }

    public String getCron() {
        return StringUtils.trim(cron);
    }

}

核心类

MyScheduledTaskRegistrar

用于定时任务注册的核心类

/**
 * @author yaoj
 * @since 2022/7/27
 **/
@Component
public class MyScheduledTaskRegistrar implements ApplicationListener<ContextRefreshedEvent>, DisposableBean {

    private TaskScheduler taskScheduler;
    private Set<MyScheduledTask> scheduledTasks = new LinkedHashSet<>(16);

    /**
     * 此处采用监听器方式,在spring容器启动后开始初始化定时任务
     *
     * @param event
     */
    @Override
    public void onApplicationEvent(ContextRefreshedEvent event) {
        scheduleTasks();
    }

    private void scheduleTasks() {
        // 这边为创建ScheduledThreadPoolExecutor定时任务线程池,具体参数本文不讨论
        ScheduledExecutorService localExecutor = ThreadPoolUtils.newScheduledThreadPoolExecutor();
        this.taskScheduler = new ConcurrentTaskScheduler(localExecutor);
        // 初始化定时任务 逻辑请看下面的DynamicTaskUtils工具类内容
        DynamicTaskUtils.initTask();
    }

    public void addScheduledTask(@Nullable MyScheduledTask task) {
        if (task != null) {
            this.scheduledTasks.add(task);
        }
    }

    public Set<MyScheduledTask> getScheduledTasks() {
        return scheduledTasks;
    }

    @Nullable
    public MyScheduledTask scheduleCronTask(MyCronTask task) {
        // 此方法简略了很多 具体可以参照springboot的源码
        MyScheduledTask scheduledTask = new MyScheduledTask(task);
        scheduledTask.future = this.taskScheduler.schedule(task.getRunnable(), task.getTrigger());
        return scheduledTask;
    }

    @Override
    public void destroy() {
        for (MyScheduledTask task : this.scheduledTasks) {
            task.cancel();
        }
    }
}

DynamicTaskUtils工具类

为了少建一些实体,故有些地方用map,切勿在意一些细节

/**
 * 动态定时任务工具类
 *
 * @author yaoj
 * @since 2022/7/27
 **/
@Slf4j
public class DynamicTaskUtils {

    private static MyScheduledTaskRegistrar registrar = null;
    private static ISchedule iSchedule = null;

    private static MyScheduledTaskRegistrar getRegistrar() {
        if (null == registrar) {
            registrar = SpringUtils.getBean(MyScheduledTaskRegistrar.class);
        }
        return registrar;
    }

    private static ISchedule getISchedule() {
        if (null == iSchedule) {
            iSchedule = SpringUtils.getBean(ISchedule.class);
        }
        return iSchedule;
    }

    /**
     * 新增定时任务
     *
     * @param list
     */
    public static Map<String, Object> addTaskBatch(List<ScheduleDto> list) {
        synchronized (DynamicTaskUtils.class) {
            // 当前系统中存在的定时任务
            List<String> sysTaskNow = getRegistrar().getScheduledTasks().stream()
                    .map(e -> e.getTask().getMethodName()).collect(Collectors.toList());
            // 过滤出需要新增的定时任务
            list = list.stream().filter(e -> !sysTaskNow.contains(e.getClazz() + "_" + e.getMethod())).collect(Collectors.toList());
            Map<String, Object> resultMap = new HashMap<>();
            resultMap.put("load", false);
            resultMap.put("errorList", new ArrayList<>());
            if (!list.isEmpty()) {
                List<String> errorList = new ArrayList<>();
                for (ScheduleDto task : list) {
                    try {
                        MyCronTask cronTask = new MyCronTask(() -> {
                            Object bean = SpringUtils.getBean(task.getClazz());
                            ReflectionUtils.invokeMethod(bean, task.getMethod(), null, null);
                        }, task.getCron(), task.getClazz(), task.getMethod());
                        // 此处封装好了cronTask加入定时任务线程池
                        getRegistrar().addScheduledTask(getRegistrar().scheduleCronTask(cronTask));
                    } catch (Exception e) {
                        errorList.add(task.getMethod());
                    }
                }
                if (!errorList.isEmpty()) {
                    log.error("定时任务【{}】新增失败!!!", String.join(",", errorList));
                    resultMap.put("load", false);
                    resultMap.put("errorList", errorList);
                    return resultMap;
                }
                resultMap.put("load", true);
                log.info("定时任务【{}】新增成功!!!", list.stream().map(ScheduleDto::getMethod)
                        .collect(Collectors.joining(",")));
            }
            return resultMap;
        }
    }

    /**
     * 修改定时任务
     *
     * @param list
     */
    public static boolean updateTaskBatch(List<ScheduleDto> list) {
        synchronized (DynamicTaskUtils.class) {
            if (deleteTaskBatch(list)) {
                boolean b = (boolean) addTaskBatch(list).get("load");
                if (!b)
                    log.info("定时任务【{}】修改失败!!!", list.stream().map(ScheduleDto::getMethod)
                            .collect(Collectors.joining(",")));
                return b;
            }
            log.info("定时任务【{}】修改成功!!!", list.stream().map(ScheduleDto::getMethod)
                    .collect(Collectors.joining(",")));
            return true;
        }
    }

    /**
     * 删除定时任务
     *
     * @param list
     */
    public static boolean deleteTaskBatch(List<ScheduleDto> list) {
        synchronized (DynamicTaskUtils.class) {
            try {
                Set<MyScheduledTask> scheduledTasks = getRegistrar().getScheduledTasks();
                Set<String> methodSet = list.stream()
                        .map(ScheduleDto::getMethodWithClass).collect(Collectors.toSet());
                scheduledTasks.stream().filter(e -> methodSet.contains(e.getTask().getMethodName())).forEach(MyScheduledTask::cancel);
                scheduledTasks.removeIf(e -> methodSet.contains(e.getTask().getMethodName()));
            } catch (Exception e) {
                log.info("定时任务【{}】删除失败!!!", list.stream().map(ScheduleDto::getMethod)
                        .collect(Collectors.joining(",")));
                return false;
            }
            log.info("定时任务【{}】删除成功!!!", list.stream().map(ScheduleDto::getMethod)
                    .collect(Collectors.joining(",")));
            return true;
        }
    }

    /**
     * 初始定时任务
     */
    public static void initTask() {
        List<TaskSetting> list = getISchedule().findList("serviceName");
        List<ScheduleDto> scheduleDtoList = list.stream().map(e ->
                new ScheduleDto(e.getClassName(), e.getMethodName(), e.getCron())
        ).collect(Collectors.toList());
        Map<String, Object> addTaskBatchMap = DynamicTaskUtils.addTaskBatch(scheduleDtoList);
        boolean b = (boolean) addTaskBatchMap.get("load");
        if (b) {
            log.info("定时任务加载成功!!!");
        } else {
            List<String> errorList = (List<String>) addTaskBatchMap.get("errorList");
            log.info("定时任务部分加载成功!!!");
            log.error("定时任务【{}】加载失败!!!", String.join(",", errorList));
        }
    }
}

日志切面类ScheduleMethodAop

/**
 * 定时任务日志记录切面
 *
 * @author yaoj
 * @since 2022/7/27
 **/
@Component
@Aspect
public class ScheduleMethodAop {

    @Autowired
    private IScheduleLog iScheduleLog;

    @Around(value = "@annotation(scheduleLog)")
    public void dealTask(ProceedingJoinPoint joinPoint, ScheduleLog scheduleLog) {
        // redis 分布式锁解决 集群问题
        TaskExecutionLog log = new TaskExecutionLog();
        log.setStartTime(new Date());
        try {
            // 执行定时任务
            joinPoint.proceed();
        } catch (Throwable throwable) {
            //写入数据库定时任务执行中便于可视化展示
            String message = throwable.getMessage();
            log.setEndTime(new Date());
            log.setIsSuccess(GlobalConstant.NO);
            log.setResultMsg(StringUtils.isNotBlank(getIp()) ? getIp() + message : message);
            iScheduleLog.insertLog(log);
            return;
        }
        log.setEndTime(new Date());
        log.setIsSuccess(GlobalConstant.YES);
        log.setResultMsg(StringUtils.isNotBlank(getIp()) ? getIp() + "成功!" : "成功!");
        iScheduleLog.insertLog(log);
    }

    private String getIp() {
        try {
            InetAddress addr = InetAddress.getLocalHost();
            String hostAddress = "IP:【" + addr.getHostAddress() + "】";
            String hostName = "主机名称:【" + addr.getHostName() + "】";
            return hostAddress + hostName;
        } catch (Exception e) {
            return "";
        }
    }
}

各个项目共通的controller和service

MyScheduleController

/**
 * 修改定时任务控制层
 *
 * @author yaoj
 * @since 2022/7/27
 **/
@RestController
@Api("修改定时任务控制层")
@RequestMapping("feign/erp/schedule")
public class MyScheduleController {

    @Autowired
    private MyScheduleService service;

    @ApiOperationSupport(author = "yaoj")
    @ApiOperation(value = "修改定时任务")
    @PutMapping
    public ResultDto<Boolean> update(@RequestBody @Validated TaskSetting batchSetting) {
        return ResultUtils.success(service.update(batchSetting));
    }

    @ApiOperationSupport(author = "yaoj")
    @ApiOperation(value = "删除")
    @DeleteMapping
    public ResultDto<Boolean> delete(@RequestBody @Validated TaskSetting batchSetting) {
        return ResultUtils.success(service.delete(batchSetting));
    }

    @ApiOperationSupport(author = "yaoj")
    @ApiOperation(value = "启用")
    @PutMapping("enable")
    public ResultDto<Boolean> enable(@RequestBody @Validated List<TaskSetting> entity) {
        return ResultUtils.success(service.enable(entity));
    }

    @ApiOperationSupport(author = "yaoj")
    @ApiOperation(value = "禁用")
    @PutMapping("forbid")
    public ResultDto<Boolean> forbid(@RequestBody @Validated List<TaskSetting> entity) {
        return ResultUtils.success(service.forbid(entity));
    }

    @ApiOperationSupport(author = "yaoj")
    @ApiOperation(value = "执行定时任务")
    @PutMapping("execute")
    public ResultDto<Boolean> execute(@RequestBody @Validated TaskSetting entity) {
        return ResultUtils.success(service.execute(entity));
    }
}

MyScheduleService

/**
 * 修改定时任务业务层
 *
 * @author yaoj
 * @since 2022/7/27
 **/
@Service
public class MyScheduleService {

    /**
     * 修改线程池中的定时任务
     *
     * @param batchSetting
     */
    public boolean update(TaskSetting batchSetting) {
        ScheduleDto scheduleDto = new ScheduleDto(batchSetting.getClassName(), batchSetting.getMethodName(), batchSetting.getCron());
        return DynamicTaskUtils.updateTaskBatch(Collections.singletonList(scheduleDto));
    }

    /**
     * 删除线程池中的定时任务
     *
     * @param batchSetting
     */
    public boolean delete(TaskSetting batchSetting) {
        ScheduleDto scheduleDto = new ScheduleDto(batchSetting.getClassName(), batchSetting.getMethodName(), batchSetting.getCron());
        return DynamicTaskUtils.deleteTaskBatch(Collections.singletonList(scheduleDto));
    }

    /**
     * 新增线程池中的定时任务
     *
     * @param batchSettings
     * @return
     */
    public boolean enable(List<TaskSetting> batchSettings) {
        List<ScheduleDto> scheduleDtoList = batchSettings.stream().map(e ->
                new ScheduleDto(e.getClassName(), e.getMethodName(), e.getCron())).collect(Collectors.toList());
        return (boolean) DynamicTaskUtils.addTaskBatch(scheduleDtoList).get("load");
    }

    /**
     * 删除线程池中的定时任务
     *
     * @param batchSettings
     * @return
     */
    public boolean forbid(List<TaskSetting> batchSettings) {
        List<ScheduleDto> scheduleDtoList = batchSettings.stream().map(e ->
                new ScheduleDto(e.getClassName(), e.getMethodName(), e.getCron())).collect(Collectors.toList());
        return DynamicTaskUtils.deleteTaskBatch(scheduleDtoList);
    }

    /**
     * 执行定时任务
     *
     * @param entity
     * @return
     */
    public boolean execute(TaskSetting entity) {
        try {
            Object bean = SpringUtils.getBean(entity.getClassName());
            ReflectionUtils.invokeMethod(bean, entity.getMethodName(), null, null);
            return true;
        } catch (Exception e) {
            return false;
        }
    }
}

配置中心相关

配置中心的增删改查的页面这里就不做赘述了。

但是有两个问题仍然需要思考:

1.如何通过微服务名调取各个服务的接口?

2.如何解决集群问题,保证修改定时任务的时候打到对应服务的各个节点?

解决办法:

1.众所周知在微服务中服务间调用都是用的feign组件,但是我不可能一个服务就去写一个feign接口,接口中除了serviceName不一样里面的方法都一样,而且每多一个服务就要改代码加一个feign的接口类属实是有点多余了。springcloud其实也提供了对应的手动配置feign的相关操作。以下是最简单的例子。

/**
 * feign客户端手动配置
 *
 * @author yaoj
 * @since 2022/7/27
 **/
public class FeignClientCreateUtils {
    //key:系统名称+contextId  Object:feign客户端
    private static Map<String, Object> cacheFeignClient = new ConcurrentHashMap<>();

    private static FeignClientBuilder builder;

    public static <T> T getFeignClient(String serviceName, Class<T> clazz) {

        FeignClient feignClient = clazz.getAnnotation(FeignClient.class);
        //先从缓存查找
        if (cacheFeignClient.containsKey(serviceName + feignClient.contextId())) {
            return (T) cacheFeignClient.get(serviceName + feignClient.contextId());
        }

        FeignClientBuilder.Builder<T> myFeignClient = builder.forType(clazz, serviceName.toLowerCase());
        T feign = myFeignClient.contextId(feignClient.contextId()).path(feignClient.path()).build();
        cacheFeignClient.put(serviceName + feignClient.contextId(), feign);
        return feign;
    }

    // 初始化工作可在spring容器启动后进行
    public static void init(ApplicationContext applicationContext) {
        if (null == builder) {
            builder = new FeignClientBuilder(applicationContext);
        }
    }
}

2.集群下如何保证项目的各个节点都打到。得益于微服务的整体架构,其实我们是可以在注册中心通过微服务名称拿到所有节点的信息的。比如nacosNacosDiscoveryProperties,直接@autowired到你的service层即可,通过它你可以根据微服务名拿到所有Instance,这样ip和端口都有了,那如何打到各个服务不就迎刃而解了么。至于后续操作那就不多做赘述了。方法很多,这里只是提供一些简单思路而已,不是本文讨论的重点。

我的一些收获

这次是站在大佬的肩膀上做的3次开发, 写一个类似于这种功能的组件需要考虑的东西很多,看似非常简单,但是坑还是蛮多的。上述问题只是我碰到的我觉得值得一提的问题,还有很多代码细节以及思路的细节问题就不多说了。

这也是我第二次尝试写这些东西,可能还存在这很多未知的bug,但是单节点已经进行了实践洗礼并无问题。

所以如果有不对的地方万望大佬轻喷,再次先谢谢了。

非常感谢能看到这里的jym,如文章中有错误,请务必指正! 注:本文代码只供参考使用,细节部分很多地方都是需要修改优化的。代码只是提供一个比较详细的思路而已。