如何利用spring实现一个简单任务调度

144 阅读4分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 12 月更文挑战」的第4天,点击查看活动详情

项目中目前使用的任务调度框架是xxl-job,是基于数据库的分布式任务框架。对于小型项目来说基本是够用了,也相对比较稳定。我们的新项目都是基于springcloud alibaba这一套来进行搭建,服务注册信息都是保存在nacos中。我们是否可以利用spring自带的任务管理来替换掉xxl-job,同时也将任务服务也注册成微服务,由nacos统一管理起来,剔除掉xxl-job的依赖,也可以开发更加美观好看的UI。

开干

spring自带的任务管理

spring中有一个任务持有类ScheduledTask,包含了task任务和ScheduledFuture,同步提供了取消任务的功能。可以看类上面的描述可以知道支持三种task类型。

image.png 我们可以发现核心就是这个任务注册类,注册不同类型的任务。

数据库设计

可以做一个简单的设计,支持上述三种类型的定时任务即可。

create table t_task
(
    id             int auto_increment
        primary key,
    task_name      varchar(20)       null comment '计划名称',
    task_type      varchar(10)       null comment 'fixed,delay,cron',
    task_switch    tinyint default 0 null comment '任务开关',
    cron           varchar(32)       null comment '执行周期值',
    service_name   varchar(32)       null comment '服务名称',
    service_method varchar(64)       null comment '方法名称',
    service_args   varchar(512)      null comment '参数,建议用json存储',
    fixed_delay    int               null comment '固定延迟',
    initial_delay  int               null comment '初始化延迟',
    fixed_rate     int               null comment '固定速率',
    create_time    datetime          null,
    update_time    datetime          null
)
    comment '任务表';

service开头的参数是为后续执行远程调用时,提供的必要参数。

任务的启动和停止

我们需要将启动的task任务进行持有,可以通过taskId进行后续任务的启动和取消。通过构造方法传入我们的任务注册器。

@Component
public class TaskHolder {

    private Map<Long, ScheduledTask> taskMap = new ConcurrentHashMap<>();

    private ScheduledTaskRegistrar scheduledTaskRegistrar;

    public TaskHolder(ThreadPoolTaskScheduler threadPoolTaskScheduler) {
        this.scheduledTaskRegistrar = new ScheduledTaskRegistrar();
        this.scheduledTaskRegistrar.setTaskScheduler(threadPoolTaskScheduler);
    }

    public void addTask(TaskBase taskBase) {

        if(!taskBase.isTaskSwitch()) {
            return;
        }

        TimeZone timeZone = TimeZone.getDefault();
        ScheduledTask st = null;
        switch (TaskTypeEnum.parse(taskBase.getTaskType())) {
            case CRON:
                st = scheduledTaskRegistrar.scheduleCronTask(new CronTask(new TaskRunner(taskBase), new CronTrigger(taskBase.getCron(), timeZone)));
                break;
            case FIXED:
                st = scheduledTaskRegistrar.scheduleFixedDelayTask(new FixedDelayTask(new TaskRunner(taskBase), taskBase.getFixedDelay(), taskBase.getInitialDelay()));
                break;
            case RATE:
                st = scheduledTaskRegistrar.scheduleFixedRateTask(new FixedRateTask(new TaskRunner(taskBase), taskBase.getFixedRate(), taskBase.getInitialDelay()));
                break;
            default:
                // do nothing
                ;
        }

        taskMap.put(taskBase.getId(), st);

    }

    public void stopTask(TaskBase taskBase) {
        taskMap.get(taskBase.getId()).cancel();
        taskMap.remove(taskBase.getTaskName());
    }

    public void startTask(TaskBase taskBase) {

        if (!taskMap.containsKey(taskBase.getId())) {
            addTask(taskBase);
        }

    }

}

在addTask方法中进行不同任务类型的处理。核心执行逻辑包装在TaskRunner中。

TaskRunner

首先需要思考,xxl-job是通过rpc来注册相应的服务到服务端来并进行管理。往往我们任务是分散在不同的微服务中,是很多组具有不同功能的服务集合。可以充分利用springcloud中的注册中心机制,直接获取不同类型的服务。对每组服务进行单个或者广播任务下发。架构思路如下:

image.png

@Slf4j
public class TaskRunner implements Runnable {

    private TaskBase taskBase;

    public TaskRunner(TaskBase taskBase) {
        this.taskBase = taskBase;
    }

    @Override
    public void run() {

        // 获取对应服务列表
        DiscoveryClient discoveryClient = SpringUtil.getBean(DiscoveryClient.class);
        List<ServiceInstance> serviceInstances = discoveryClient.getInstances(taskBase.getServiceName());

        // 循环列表发起请求
        serviceInstances.forEach(si -> {
            ClientRequest clientRequest = ClientRequest.builder().serviceName(si.getServiceId())
                    .serviceMethod(taskBase.getServiceMethod())
                    .serviceArgs(taskBase.getServiceArgs())
                    .taskId(taskBase.getId())
                    .execId(UUID.fastUUID().toString(true)).build();
            String response = HttpUtil.post(StrUtil.format("http://{}:{}/client/receive", si.getHost(), si.getPort()), JSONUtil.toJsonStr(clientRequest));

            log.info("response => {}", response);
        });

    }
}

主要思路是通过DiscoveryClient获取某个服务的服务列表,进行调用。这里也可以指定规则是随机还是第一个服务调用,通过配置都可以很简单的实现。

http调用困惑

上面提交xxljob是通过rpc来进行远程调用,我们通过http调用的话不可能直接在客户端手动写一个controller来进行请求的接收,这样侵入性太大。所以我们要换一个思路,通过spring实现手动注册一个requestmapping,实现请求的接收和返回。这个时候就需要我们完成一个客户端的starter,让客户端来集成,通过既有端口来暴露相应的服务出来。

手动注册requestMapping

@Configuration
public class ClientTaskConfig {

    @EventListener
    public void appStarterListener(ApplicationStartedEvent event) {

        // 手动注册controller
        ConfigurableApplicationContext applicationContext = event.getApplicationContext();
        RequestMappingHandlerMapping handlerMapping = applicationContext.getBean(RequestMappingHandlerMapping.class);

        // 设置 patterns
        PatternsRequestCondition requestCondition = new PatternsRequestCondition("client/receive");
        // 注册方法和url
        RequestMappingInfo requestMappingInfo = new RequestMappingInfo("clientReceive", requestCondition, null, null, null, null, null, null);
        handlerMapping.registerMapping(requestMappingInfo, new ClientTaskHandler(), ReflectUtil.getMethod(ClientTaskHandler.class, "doTask", ClientRequest.class));

    }

}

我们定义匹配固定的url,使用一个处理器来处理相应的请求。

EventListener

通过监听应用启动的上下文,来进行RequestMappingHandlerMapping这个bean的获取,然后进行手动操作。

@Slf4j
public class ClientTaskHandler {

    @ResponseBody
    public ClientResponse doTask(@RequestBody ClientRequest clientRequest) {

        log.info(JSONUtil.toJsonStr(clientRequest));

        // 通过method,args 反射调用类即可
        return ClientResponse.ok(new ClientResponse.ClientResponseData());

    }

}

简单的一个任务执行方法,通过请求参数中的method,args 反射调用类即可。就可以远程执行我们的定时任务方法了。

执行结果

image.png

可以看到我们已经能够通过定时任务远程调用到客户端的方法并返回结果。至此一个简单的任务调度架构完成。

WEB接口

@RestController
@RequestMapping("v1/task")
@RequiredArgsConstructor
public class TaskController {

    @NonNull
    private TaskService taskService;

    /**
     * 任务分页
     * @return
     */
    @RequestMapping("page")
    public ResponseEntity<Page<TaskBase>> page(@RequestBody Page page) {

        return ResponseEntity.ok(taskService.taskPage(page));
    }


    @RequestMapping("stop")
    public ResponseEntity stop(@RequestBody TaskBase taskBase) {

        taskService.stopTask(taskBase);
        return ResponseEntity.ok().build();
    }

    @RequestMapping("start")
    public ResponseEntity<TaskBase> start(@RequestBody TaskBase taskBase) {

        taskService.startTask(taskBase);
        return ResponseEntity.ok().build();
    }

}

暴露启动,停止,分页等方法供页面使用。

改进

  • 比如说我们想要实现执行过程的日志实时打印,显然这种方法是做不到的。后续可以采用grpc进行接口暴露,实现clientStream模式,连接到服务器上的grpc端口进行日志记录并上传。
  • 还可以通过spring的IOC特性,调用所有的spring中的bean并进行执行,也可以通过注解标记哪些类是需要执行定时任务的方法。并注册到服务端中。
  • 都是依托于微服务,failover可以自己采用feign这一套规则来进行处理包装。
  • 线程池目前采用的是统一的线程池进行的处理,后期可以根据任务的耗时,自动分配慢线程池和快线程池。互相隔离。如果采用grpc客户端流模式,其实就使用了异步方案,规避了上面的线程池问题。