- 作者简介:大家好,我是爱吃芝士的土豆倪,24届校招生Java选手,很高兴认识大家
- 系列专栏:Spring源码、JUC源码、Kafka原理、分布式技术原理
- 如果感觉博主的文章还不错的话,请三连支持一下博主哦
- 博主正在努力完成2023计划中:源码溯源,一探究竟
- 联系方式:nhs19990716,加我进群,大家一起学习,一起进步,一起对抗互联网寒冬
事实上,市面上有很多分布式任务调度框架,比如大名鼎鼎的xxl-job,以及各个大厂自研的分布式任务调度框架等,但是说到自研,为什么自研呢?
针对上述一些特点,那么了解一个框架,或者说自研一个框架,需要怎么做?自研一个,自己做一个简易版,只实现核心功能即可!!!
那么对于实现分布式任务调度来说,除了实现核心功能外,还需要轻便型,最好是,简单配置后就能立马使用,要多简单就多简单,所以简便性也是设计简易版分布式任务调度的核心思路。
前面说到了,想要简便性,那么该怎么办?是都在配置文件中配置好?还是怎么样?
其实为了达到简便性,我们可以使用注解的方式,也就是说,我们把想要定时控制的方法上面加个注解,然后它就可以定时执行了,这样最简便了。当了解到了这一点,接下来开始介绍核心架构。
一个容易理解的设计思路一般都是先从图解开始的。
首先扫描其中所有带有 某种注解 的方法,将其注册到注册中心(Zk)中,然后我们的管理后台扫描这些任务在后台页面中显示,最终在利用Zk中watcher的特性,监听某个节点的话,通过其变化,动态的控制任务的启动,修改配置参数等功能。
/**
这段代码定义了一个自定义注解 DcsScheduled,它可以用来标记方法,并指定该方法作为一个 Dcs 调度任务。
*/
@Retention(RetentionPolicy.RUNTIME) // 指定该注解在运行时保留,因此可以通过反射来访问该注解的信息。
@Target(ElementType.METHOD) // 指定该注解只能应用在方法上。
public @interface DcsScheduled {
String desc() default "缺省"; // 用于描述调度任务的说明,默认取值为"缺省"。
String cron() default ""; // 指定调度任务的 cron 表达式,用于设置任务的执行时间规则。
boolean autoStartup() default true; // 指定是否自动启动调度任务,默认为 true。
}
/**
这段代码定义了一个自定义注解 EnableDcsScheduling,它可以用来在Spring Boot应用中启用Dcs调度功能。
*/
@Target({ElementType.TYPE}) // 指定该注解只能应用在类上。
@Retention(RetentionPolicy.RUNTIME) // 指定该注解在运行时保留,因此可以通过反射来访问该注解的信息。
@Import({DcsSchedulingConfiguration.class}) // 指定在应用中导入 DcsSchedulingConfiguration 类的配置。
@ImportAutoConfiguration({SchedulingConfig.class, CronTaskRegister.class, DoJoinPoint.class})
//指定在应用中自动导入 SchedulingConfig、CronTaskRegister 和 DoJoinPoint 类的配置。
@ComponentScan("cn.nhs.*") // 指定扫描并加载 cn.nhs 包及其子包下的所有组件。
public @interface EnableDcsScheduling {
}
// 获取上下文将其注入全局上下文中
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
Constants.Global.applicationContext = applicationContext;
}
/**
postProcessAfterInitialization 方法是在 Spring 容器实例化 Bean 并完成初始化后立即调用的。
具体来说,它是在 Bean 初始化完成之后、即将返回给调用者之前被调用的。
相当于Spring生命周期中的钩子函数
*/
@Override
public Object postProcessAfterInitialization(Object bean, String beanName) throws BeansException {
// 获取到对应的bean,如果之前已经存在在 set集合中了,相当于被处理过了,那么就直接从set集合中返回。
Class<?> targetClass = AopProxyUtils.ultimateTargetClass(bean);
if (this.nonAnnotatedClasses.contains(targetClass)) return bean;
// 遍历bean中所有的方法
Method[] methods = ReflectionUtils.getAllDeclaredMethods(bean.getClass());
for (Method method : methods) {
// 去找使用了 @DcsScheduled 注解的方法
DcsScheduled dcsScheduled = AnnotationUtils.findAnnotation(method, DcsScheduled.class);
if (null == dcsScheduled || 0 == method.getDeclaredAnnotations().length) continue;
// 当指定的键 beanName 在 Map 中不存在时,计算一个新的值并将其插入到 Map 中。如果指定的键存在,则直接返回对应的值。
List<ExecOrder> execOrderList = Constants.execOrderMap.computeIfAbsent(beanName, k -> new ArrayList<>());
ExecOrder execOrder = new ExecOrder();
execOrder.setBean(bean);
execOrder.setBeanName(beanName);
execOrder.setMethodName(method.getName());
execOrder.setDesc(dcsScheduled.desc());
execOrder.setCron(dcsScheduled.cron());
execOrder.setAutoStartup(dcsScheduled.autoStartup());
execOrderList.add(execOrder);
this.nonAnnotatedClasses.add(targetClass);
}
return bean;
}
/**
实现 ApplicationListener 接口可以监听 Spring 容器的刷新事件。
当 Spring 容器启动或刷新时,会触发 ContextRefreshedEvent 事件,从而调用 onApplicationEvent 方法。
在 onApplicationEvent 方法中,可以编写自己的逻辑来处理容器刷新事件。
*/
@Override
public void onApplicationEvent(ContextRefreshedEvent contextRefreshedEvent) {
try {
ApplicationContext applicationContext = contextRefreshedEvent.getApplicationContext();
//1. 初始化配置
init_config(applicationContext);
//2. 初始化服务
init_server(applicationContext);
//3. 启动任务
init_task(applicationContext);
//4. 挂载节点
init_node();
//5. 心跳监听
HeartbeatService.getInstance().startFlushScheduleStatus();
logger.info("schedule init config、server、task、node、heart done!");
} catch (Exception e) {
throw new RuntimeException(e);
}
}
//1. 初始化配置
private void init_config(ApplicationContext applicationContext) {
try {
StarterServiceProperties properties = applicationContext.getBean("nhs-schedule-starterAutoConfig", StarterAutoConfig.class).getProperties();
Constants.Global.zkAddress = properties.getZkAddress();
Constants.Global.schedulerServerId = properties.getSchedulerServerId();
Constants.Global.schedulerServerName = properties.getSchedulerServerName();
InetAddress id = InetAddress.getLocalHost();
Constants.Global.ip = id.getHostAddress();
} catch (Exception e) {
logger.error("middleware schedule init config error!", e);
throw new RuntimeException(e);
}
}
//2. 初始化服务
private void init_server(ApplicationContext applicationContext) {
try {
//获取zk连接
CuratorFramework client = ZkCuratorServer.getClient(Constants.Global.zkAddress);
//节点组装
// /cn/nhs/schedule/server/schedule-spring-boot-starter-test
path_root_server = StrUtil.joinStr(path_root, LINE, "server", LINE, schedulerServerId);
// /cn/nhs/schedule/server/schedule-spring-boot-starter-test/ip/本机ip地址
path_root_server_ip = StrUtil.joinStr(path_root_server, LINE, "ip", LINE, Constants.Global.ip);
//创建节点&递归删除本服务IP下的旧内容
ZkCuratorServer.deletingChildrenIfNeeded(client, path_root_server_ip);
ZkCuratorServer.createNode(client, path_root_server_ip);
ZkCuratorServer.setData(client, path_root_server, schedulerServerName);
//添加节点&监听
// /cn/nhs/schedule/exec
ZkCuratorServer.createNodeSimple(client, Constants.Global.path_root_exec);
ZkCuratorServer.addTreeCacheListener(applicationContext, client, Constants.Global.path_root_exec);
} catch (Exception e) {
logger.error("schedule init server error!", e);
throw new RuntimeException(e);
}
}
//3. 启动任务
private void init_task(ApplicationContext applicationContext) {
CronTaskRegister cronTaskRegistrar = applicationContext.getBean("nhs-schedule-cronTaskRegister", CronTaskRegister.class);
Set<String> beanNames = Constants.execOrderMap.keySet();
for (String beanName : beanNames) {
List<ExecOrder> execOrderList = Constants.execOrderMap.get(beanName);
for (ExecOrder execOrder : execOrderList) {
if (!execOrder.getAutoStartup()) continue;
SchedulingRunnable task = new SchedulingRunnable(execOrder.getBean(), execOrder.getBeanName(), execOrder.getMethodName());
cronTaskRegistrar.addCronTask(task, execOrder.getCron());
}
}
}
private void init_node() throws Exception {
Set<String> beanNames = Constants.execOrderMap.keySet();
for (String beanName : beanNames) {
List<ExecOrder> execOrderList = Constants.execOrderMap.get(beanName);
for (ExecOrder execOrder : execOrderList) {
String path_root_server_ip_clazz = StrUtil.joinStr(path_root_server_ip, LINE, "clazz", LINE, execOrder.getBeanName());
String path_root_server_ip_clazz_method = StrUtil.joinStr(path_root_server_ip_clazz, LINE, "method", LINE, execOrder.getMethodName());
String path_root_server_ip_clazz_method_status = StrUtil.joinStr(path_root_server_ip_clazz, LINE, "method", LINE, execOrder.getMethodName(), "/status");
//添加节点
ZkCuratorServer.createNodeSimple(client, path_root_server_ip_clazz);
ZkCuratorServer.createNodeSimple(client, path_root_server_ip_clazz_method);
ZkCuratorServer.createNodeSimple(client, path_root_server_ip_clazz_method_status);
//添加节点数据[临时]
ZkCuratorServer.appendPersistentData(client, path_root_server_ip_clazz_method + "/value", JSON.toJSONString(execOrder));
//添加节点数据[永久]
ZkCuratorServer.setData(client, path_root_server_ip_clazz_method_status, execOrder.getAutoStartup() ? "1" : "0");
}
}
}
//所有子节点监听
public static void addTreeCacheListener(final ApplicationContext applicationContext, final CuratorFramework client, String path) throws Exception {
/**
具体而言,TreeCache是ZooKeeper的一个监听器,可以监控指定节点及其子节点的变化。
通过创建TreeCache实例并启动它,可以在ZooKeeper中的指定节点上设置监听器,以便在节点发生变化时触发相应的事件。
启动TreeCache后,它会从指定节点开始递归地缓存其下所有的子节点和数据,并且持续监控这些节点的状态变化。
*/
TreeCache treeCache = new TreeCache(client, path);
treeCache.start();
// 为 treeCache 添加一个监听器,当节点发生变化时,会调用对应的回调函数进行处理。
treeCache.getListenable().addListener((curatorFramework, event) -> {
// 这段代码的作用是从 ZooKeeper 的监听事件中解析出一个 Instruct 对象。
/*
具体来说,代码首先判断事件中是否包含有效数据,如果没有则直接返回。然后将事件中的数据转换为字节数组,
再将字节数组转换为字符串,并根据一些条件进行判断,确保该字符串是一个合法的 JSON 格式。
如果判断失败,则直接返回。若判断成功,则利用 JSON.parseObject() 方法将该 JSON
字符串解析成一个 Instruct 对象,并返回该对象。
*/
if (null == event.getData()) return;
byte[] eventData = event.getData().getData();
if (null == eventData || eventData.length < 1) return;
String json = new String(eventData, Constants.Global.CHARSET_NAME);
if ("".equals(json) || json.indexOf("{") != 0 || json.lastIndexOf("}") + 1 != json.length()) return;
Instruct instruct = JSON.parseObject(new String(event.getData().getData(), Constants.Global.CHARSET_NAME), Instruct.class);
// 在回调函数中,判断事件的类型,如果是节点新增或更新,则根据节点中存储的信息以及一些条件进行相应的业务逻辑处理。
switch (event.getType()) {
case NODE_ADDED:
case NODE_UPDATED:
// 如果当前本机的ip 与 schedulerServerId 都与 回调返回的相等。
if (Constants.Global.ip.equals(instruct.getIp()) &&
Constants.Global.schedulerServerId.equals(instruct.getSchedulerServerId())) {
//获取对象
CronTaskRegister cronTaskRegistrar = applicationContext.getBean("nhs-schedule-cronTaskRegister", CronTaskRegister.class);
boolean isExist = applicationContext.containsBean(instruct.getBeanName());
if (!isExist) return;
Object scheduleBean = applicationContext.getBean(instruct.getBeanName());
// /cn/nhs/schedule/server/schedule-spring-boot-starter-test/ip/机器ip/clazz/类对象名称/method/方法名称/status
String path_root_server_ip_clazz_method_status = StrUtil.joinStr(path_root, Constants.Global.LINE, "server", Constants.Global.LINE, instruct.getSchedulerServerId(), Constants.Global.LINE, "ip", LINE, instruct.getIp(), LINE, "clazz", LINE, instruct.getBeanName(), LINE, "method", LINE, instruct.getMethodName(), "/status");
//执行命令 0关闭、1启动、2更新
Integer status = instruct.getStatus();
switch (status) {
case 0: // 关闭
cronTaskRegistrar.removeCronTask(instruct.getBeanName() + "_" + instruct.getMethodName());
// 重新将状态设置回去
setData(client, path_root_server_ip_clazz_method_status, "0");
logger.info("schedule task stop {} {}", instruct.getBeanName(), instruct.getMethodName());
break;
case 1: // 启动
cronTaskRegistrar.addCronTask(new SchedulingRunnable(scheduleBean, instruct.getBeanName(), instruct.getMethodName()), instruct.getCron());
setData(client, path_root_server_ip_clazz_method_status, "1");
logger.info("schedule task start {} {}", instruct.getBeanName(), instruct.getMethodName());
break;
case 2: // 更新
cronTaskRegistrar.removeCronTask(instruct.getBeanName() + "_" + instruct.getMethodName());
cronTaskRegistrar.addCronTask(new SchedulingRunnable(scheduleBean, instruct.getBeanName(), instruct.getMethodName()), instruct.getCron());
setData(client, path_root_server_ip_clazz_method_status, "1");
logger.info("schedule task refresh {} {}", instruct.getBeanName(), instruct.getMethodName());
break;
}
}
break;
case NODE_REMOVED:
break;
default:
break;
}
});
}
// 添加任务 & 启动任务
public void addCronTask(SchedulingRunnable task, String cronExpression) {
// 首先判断是否已经存在这个任务了,如果存在,那么先移除这个任务
if (null != Constants.scheduledTasks.get(task.taskId())) {
removeCronTask(task.taskId());
}
// 然后再启动
CronTask cronTask = new CronTask(task, cronExpression);
Constants.scheduledTasks.put(task.taskId(), scheduleCronTask(cronTask));
}
private ScheduledTask scheduleCronTask(CronTask cronTask) {
ScheduledTask scheduledTask = new ScheduledTask();
// 线程池去执行任务,然后使用 scheduledTask.future 同步接收
scheduledTask.future = this.taskScheduler.schedule(cronTask.getRunnable(), cronTask.getTrigger());
return scheduledTask;
}
// 移除任务
public void removeCronTask(String taskId) {
ScheduledTask scheduledTask = Constants.scheduledTasks.remove(taskId);
if (scheduledTask == null) return;
// 其内部调用 ScheduledFuture 的 cancel 方法
scheduledTask.cancel();
}
@Aspect
@Component("nhs-schedule")
public class DoJoinPoint {
private Logger logger = LoggerFactory.getLogger(DoJoinPoint.class);
@Pointcut("@annotation(cn.nhs.schedule.annotation.DcsScheduled)")
public void aopPoint() {
}
// 定义了一个环绕通知(@Around("aopPoint()")),在目标方法执行前后进行拦截和处理
// 在doRouter方法中,获取目标方法的执行时间,并在执行结束后记录日志
@Around("aopPoint()")
public Object doRouter(ProceedingJoinPoint jp) throws Throwable {
long begin = System.currentTimeMillis();
Method method = getMethod(jp);
try {
return jp.proceed();
} finally {
long end = System.currentTimeMillis();
logger.info("\nschedule method:{}.{} take time(m):{}", jp.getTarget().getClass().getSimpleName(), method.getName(), (end - begin));
}
}
// getMethod方法用于获取目标方法的Method对象
private Method getMethod(JoinPoint jp) throws NoSuchMethodException {
Signature sig = jp.getSignature();
MethodSignature methodSignature = (MethodSignature) sig;
return getClass(jp).getMethod(methodSignature.getName(), methodSignature.getParameterTypes());
}
// getClass方法用于获取目标对象的Class对象
private Class<? extends Object> getClass(JoinPoint jp) throws NoSuchMethodException {
return jp.getTarget().getClass();
}
}
目前这里的功能并没有扩展,基本只是打印执行耗时,如果需要监听任务执行的详细信息,可以在这里控制。
@SpringBootApplication
@EnableDcsScheduling
public class ApiTestApplication {
public static void main(String[] args) {
SpringApplication.run(ApiTestApplication.class, args);
}
}
@Component("demoTaskOne")
public class DemoTaskOne {
@DcsScheduled(cron = "0/3 * * * * *", desc = "01定时任务执行测试:taskMethod01", autoStartup = false)
public void taskMethod01() {
System.out.println("测试定时任务1");
}
@DcsScheduled(cron = "0/3 * * * * *", desc = "01定时任务执行测试:taskMethod02", autoStartup = false)
public void taskMethod02() {
System.out.println("测试定时任务2");
}
}
@SpringBootApplication
public class ImcApplication extends SpringBootServletInitializer {
@Override
protected SpringApplicationBuilder configure(SpringApplicationBuilder builder) {
return builder.sources(ImcApplication.class);
}
public static void main(String[] args) {
SpringApplication.run(ImcApplication.class, args);
}
}
当我们启动时
可以看到,我们启动的任务间隔是3s一次
在测试端日志显示符合我们的规律。
同时也可以做到不修改代码的情况下进行修改计划时间。
也可以实现不关闭服务的前提下关闭任务
其实核心在于zk的watcher进行监听指定节点的变化,而通过管理后台的每次启动或者暂停,都将要改变节点的信息赋值到正在监听的节点,然后正在监听的节点发现指定节点发生变化,进行回调然后执行后续的全部动作,这也就是简易版本的分布式任务调度框架的时间了。
schedule-springboot-starter-main: 实现分布式任务调度中间件,能够动态的开启、关闭任务,并且可以动态的修改参数 (gitee.com)
schedule-springboot-starter-test:实现分布式任务调度中间件的测试 (gitee.com)
schedule-springboot-controller:实现分布式任务调度中间件的后台管理 (gitee.com)