通讯模块剖析

一次完整的任务调度通讯流程


  1. - 1、“调度中心”向“执行器”发送http调度请求: “执行器”中接收请求的服务,实际上是一台内嵌Server,默认端口9999;
  2. - 2、“执行器”执行任务逻辑;
  3. - 3、“执行器”http回调“调度中心”调度结果: “调度中心”中接收回调的服务,是针对执行器开放一套API服务;

上面的3个步骤流程是XXL-job官网给出的任务调度的通讯流程。如果看过前面分布式定时任务系列5:XXL-job中blockingQueue的应用,分布式定时任务系列7:XXL-job源码分析之任务触发这2节,应该大致了解了XXL-job任务的触发与执行。不过既然XXL-job号称是分布式调度,无可避免的会涉及到远程调用。但在分析源码之前,还是再来看看对应的架构设计

架构设计

设计思想

将调度行为抽象形成“调度中心”公共平台,而平台自身并不承担业务逻辑,“调度中心”负责发起调度请求。

将任务抽象成分散的JobHandler,交由“执行器”统一管理,“执行器”负责接收调度请求并执行对应的JobHandler中业务逻辑。

因此,“调度”和“任务”两部分可以相互解耦,提高系统整体稳定性和扩展性;

从上面的描述中,可以抽象出下面设计模型:

ruoyi定时任务 调用system_xxl-job

系统组成

调度中心

  • 调度模块(调度中心)
    负责管理调度信息,按照调度配置发出调度请求,自身不承担业务代码。调度系统与任务解耦,提高了系统可用性和稳定性,同时调度系统性能不再受限于任务模块;
    支持可视化、简单且动态的管理调度信息,包括任务新建,更新,删除,GLUE开发和任务报警等,所有上述操作都会实时生效,同时支持监控调度结果以及执行日志,支持执行器Failover。

执行器

  • 执行模块(执行器)
    负责接收调度请求并执行任务逻辑。任务模块专注于任务的执行等操作,开发和维护更加简单和高效;
    接收“调度中心”的执行请求、终止请求和日志请求等。

源码目录

结合前一节的代码模块来理解,可以得出下面的结论:

ruoyi定时任务 调用system_初始化_02

  • xxl-job-admin,就是调度中心源码,也就是前面第7节的分析任务触发的模块
  • xxl-job-core,就是执行器源码,也是就是前面第5节的分析任务执行的模块(不过并不全面,只是里面的异步相关部分)
  • xxl-job-executor-samples,就是业务代码,跟前面几节里面的auth模块的一样,跟实际代码相关 

而这节的重点就是那个绿色箭头:调度执行,远程调用!! 

自研RPC

  • 从上面的架构图中,可以看到XXL-job框架对任务触发过程中涉及的远程调用称之为xxl-rpc模块。画一张部署图(一种可能的部署方式,不保熟),从部署的角度来探讨一下:

ruoyi定时任务 调用system_xxl-job_03

从上面这张图,可以看到有几个关键要素:

  • 任务注册,以及对应的任务自动发现
  • 路由选择,以及由此实现的负载均衡
  • 远程调用,以及源码实现的Netty
  • 任务执行,以及出于效率实现阻塞队列

所以,就从上面4个方面看看源码如何实现!

任务注册

XXL-job通过任务注册和自动发现的方式, 动态获取远程执行器地址并执行。这里要强调一下所谓的任务注册,是指业务系统将对应的IP地址注册到xxl-job-admin端,具体见上图。

任务注册的代码入口在com.xxl.job.core.executor.impl.XxlJobSpringExecutor:

  • XxlJobSpringExecutor实现了SmartInitializingSingleton接口,程序启动时,spring调用afterSingletonsInstantiated()方法触发任务注册
public class XxlJobSpringExecutor extends XxlJobExecutor implements ApplicationContextAware, SmartInitializingSingleton, DisposableBean {
// start
    @Override
    public void afterSingletonsInstantiated() {

        // 任务执行器初始化
        initJobHandlerMethodRepository(applicationContext);

        // refresh GlueFactory,先不管这种
        GlueFactory.refreshInstance(1);

        // super start
        try {
            // 执行器启动,包括任务注册    
            super.start();
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }
}

任务执行器初始化

跟进initJobHandlerMethodRepository(applicationContext)方法:

private void initJobHandlerMethodRepository(ApplicationContext applicationContext) {
        if (applicationContext == null) {
            return;
        }
        // 扫描Spring管理的类,寻找带XxlJob注解的任务方法,代码不难
        String[] beanDefinitionNames = applicationContext.getBeanNamesForType(Object.class, false, true);
        for (String beanDefinitionName : beanDefinitionNames) {
            Object bean = applicationContext.getBean(beanDefinitionName);

            Map<Method, XxlJob> annotatedMethods = null;   // referred to :org.springframework.context.event.EventListenerMethodProcessor.processBean
            try {
                annotatedMethods = MethodIntrospector.selectMethods(bean.getClass(),
                        new MethodIntrospector.MetadataLookup<XxlJob>() {
                            @Override
                            public XxlJob inspect(Method method) {
                                return AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class);
                            }
                        });
            } catch (Throwable ex) {
                logger.error("xxl-job method-jobhandler resolve error for bean[" + beanDefinitionName + "].", ex);
            }
            if (annotatedMethods==null || annotatedMethods.isEmpty()) {
                continue;
            }

            for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {
                Method executeMethod = methodXxlJobEntry.getKey();
                XxlJob xxlJob = methodXxlJobEntry.getValue();
                // 将扫描到带XxlJob注解的任务方法进行注册
                registJobHandler(xxlJob, bean, executeMethod);
            }
        }

这个方法的主要目的扫描Spring容器管理的类,找到带XxlJob注解的任务方法,然后进行"注册"。

跟进registJobHandler(XxlJob xxlJob, Object bean, Method executeMethod)方法:

protected void registJobHandler(XxlJob xxlJob, Object bean, Method executeMethod){
        if (xxlJob == null) {
            return;
        }

        String name = xxlJob.value();
        //make and simplify the variables since they'll be called several times later
        Class<?> clazz = bean.getClass();
        String methodName = executeMethod.getName();
        if (name.trim().length() == 0) {
            throw new RuntimeException("xxl-job method-jobhandler name invalid, for[" + clazz + "#" + methodName + "] .");
        }
        if (loadJobHandler(name) != null) {
            throw new RuntimeException("xxl-job jobhandler[" + name + "] naming conflicts.");
        }


        executeMethod.setAccessible(true);

        // init and destroy
        Method initMethod = null;
        Method destroyMethod = null;

        // 通过反射获取任务初始化方法(可选,不一定有)
        if (xxlJob.init().trim().length() > 0) {
            try {
                initMethod = clazz.getDeclaredMethod(xxlJob.init());
                initMethod.setAccessible(true);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException("xxl-job method-jobhandler initMethod invalid, for[" + clazz + "#" + methodName + "] .");
            }
        }
        // 通过反射获取任务销毁方法(可选,不一定有)
        if (xxlJob.destroy().trim().length() > 0) {
            try {
                destroyMethod = clazz.getDeclaredMethod(xxlJob.destroy());
                destroyMethod.setAccessible(true);
            } catch (NoSuchMethodException e) {
                throw new RuntimeException("xxl-job method-jobhandler destroyMethod invalid, for[" + clazz + "#" + methodName + "] .");
            }
        }

        // 任务"注册"
        registJobHandler(name, new MethodJobHandler(bean, executeMethod, initMethod, destroyMethod));

    }

这里就是通过反射获取任务的相关信息:包括任务名称,初始化,销毁方法。还是以一个例子解释一下:

@Component
public class SampleXxlJob {
 /**
     * 1、简单任务示例(Bean模式)
     */
    @XxlJob("demoJobHandler")
    public void demoJobHandler() throws Exception {
        XxlJobHelper.log("XXL-JOB, Hello World.");

        for (int i = 0; i < 5; i++) {
            XxlJobHelper.log("beat at:" + i);
            TimeUnit.SECONDS.sleep(2);
        }
        // default success
    }
}

这个例子里面通过反射获取的信息如下,可以debug跟踪一下:

  • 任务名称为:demoJobHandler;初始化方法:为空;销毁方法:为空
  • Object Bean为SampleXxlJob
  • Method executeMethod为demoJobHandler()

ruoyi定时任务 调用system_定时任务_04

最后看下registJobHandler(String name, IJobHandler jobHandler)方法,有一个内存Map保存任务,任务执行的时候就要依赖它了。

private static ConcurrentMap<String, IJobHandler> jobHandlerRepository = new ConcurrentHashMap<String, IJobHandler>();
    public static IJobHandler loadJobHandler(String name){
        return jobHandlerRepository.get(name);
    }
    public static IJobHandler registJobHandler(String name, IJobHandler jobHandler){
        logger.info(">>>>>>>>>>> xxl-job register jobhandler success, name:{}, jobHandler:{}", name, jobHandler);
        return jobHandlerRepository.put(name, jobHandler);
    }

执行器启动

再看下执行器启动,代码在com.xxl.job.core.executor.XxlJobExecutor,也即前面afterSingletonsInstantiated()方法最后的super.start()。

public void start() throws Exception {

        // 日志路径初始化,关于细节在前面章节分析过,不是本节重点
        XxlJobFileAppender.initLogPath(logPath);

        // admin端地址初始脂,主要是admin端的URL地址,用户远程注册及心跳连接
        initAdminBizList(adminAddresses, accessToken);

        // 日志清理线程初始化
        JobLogFileCleanThread.getInstance().start(logRetentionDays);

        // 回调函数初始化,关于回调函数,会在后面几节具体分析
        TriggerCallbackThread.getInstance().start();

        // 执行器服务初始化
        initEmbedServer(address, ip, port, appname, accessToken);
    }
  • 日志相关处理的第7节已经介绍过,关于细节在前面第7章节分析过,不是本节重点
  • 回调函数初始化,关于回调函数,会在后面几节具体分析(预计第10章节)
  • 执行器服务初始化,重点分析这里

先看下initAdminBizList(String adminAddresses, String accessToken)方法:实例化admin端地址,主要是admin端的URL地址,用户远程注册及心跳连接

// admin端服务地址列表,在执行器端配置,可以配置多个,逗号分割
private static List<AdminBiz> adminBizList;
private void initAdminBizList(String adminAddresses, String accessToken) throws Exception {
        if (adminAddresses!=null && adminAddresses.trim().length()>0) {
            for (String address: adminAddresses.trim().split(",")) {
                if (address!=null && address.trim().length()>0) {

                    AdminBiz adminBiz = new AdminBizClient(address.trim(), accessToken);

                    if (adminBizList == null) {
                        adminBizList = new ArrayList<AdminBiz>();
                    }
                    adminBizList.add(adminBiz);
                }
            }
        }
    }

这个地址其实就是接入XXL-job的配置地址,可以配置多个,以逗号分割(集群模式)

ruoyi定时任务 调用system_xxl-job_05

执行器内嵌netty-http-server启动

这里重点看下一下initEmbedServer(String address, String ip, int port, String appname, String accessToken)方法:

// 内嵌netty-http-server启动,里面的地址,IP,端口都从配置文件读取
private void initEmbedServer(String address, String ip, int port, String appname, String accessToken) throws Exception {

        // 获取配置的端口,如果没有则默认为9999
        port = port>0?port: NetUtil.findAvailablePort(9999);
// 如果配置的ip为空,则默认获取当前机器的IP
        ip = (ip!=null&&ip.trim().length()>0)?ip: IpUtil.getIp();

        // 注册地址:默认取配置的address, 如果为空则使用获得的ip:port
        if (address==null || address.trim().length()==0) {
            String ip_port_address = IpUtil.getIpPort(ip, port);   
            address = "http://{ip_port}/".replace("{ip_port}", ip_port_address);
        }

        // 安全检验的accessToken,这里先不纠结
        if (accessToken==null || accessToken.trim().length()==0) {
            logger.warn(">>>>>>>>>>> xxl-job accessToken is empty. To ensure system security, please set the accessToken.");
        }

        // 启动netty服务器
        embedServer = new EmbedServer();
        embedServer.start(address, port, appname, accessToken);
    }

跟进start(final String address, final int port, final String appname, final String accessToken)方法,这个就是netty-http-server的核心代码了,里面是XXL-job实现的内嵌http服务器:

public void start(final String address, final int port, final String appname, final String accessToken) {
        executorBiz = new ExecutorBizImpl();
        thread = new Thread(new Runnable() {

            @Override
            public void run() {

                // param
                EventLoopGroup bossGroup = new NioEventLoopGroup();
                EventLoopGroup workerGroup = new NioEventLoopGroup();
                ThreadPoolExecutor bizThreadPool = new ThreadPoolExecutor(
                        0,
                        200,
                        60L,
                        TimeUnit.SECONDS,
                        new LinkedBlockingQueue<Runnable>(2000),
                        new ThreadFactory() {
                            @Override
                            public Thread newThread(Runnable r) {
                                return new Thread(r, "xxl-job, EmbedServer bizThreadPool-" + r.hashCode());
                            }
                        },
                        new RejectedExecutionHandler() {
                            @Override
                            public void rejectedExecution(Runnable r, ThreadPoolExecutor executor) {
                                throw new RuntimeException("xxl-job, EmbedServer bizThreadPool is EXHAUSTED!");
                            }
                        });


                try {
                    // 启动netty-http服务器
                    ServerBootstrap bootstrap = new ServerBootstrap();
                    bootstrap.group(bossGroup, workerGroup)
                            .channel(NioServerSocketChannel.class)
                            .childHandler(new ChannelInitializer<SocketChannel>() {
                                @Override
                                public void initChannel(SocketChannel channel) throws Exception {
                                    channel.pipeline()
                                            .addLast(new IdleStateHandler(0, 0, 30 * 3, TimeUnit.SECONDS))  // beat 3N, close if idle
                                            .addLast(new HttpServerCodec())
                                            .addLast(new HttpObjectAggregator(5 * 1024 * 1024))  // merge request & reponse to FULL
                                            // 添加http处理器
                                            .addLast(new EmbedHttpServerHandler(executorBiz, accessToken, bizThreadPool));
                                }
                            })
                            .childOption(ChannelOption.SO_KEEPALIVE, true);

                    // bind
                    ChannelFuture future = bootstrap.bind(port).sync();

                    logger.info(">>>>>>>>>>> xxl-job remoting server start success, nettype = {}, port = {}", EmbedServer.class, port);

                    // 进行执行器注册(将当前执行器注册到xxl-job-admin端) 
                    startRegistry(appname, address);

                    // wait util stop
                    future.channel().closeFuture().sync();

                } catch (InterruptedException e) {
                    if (e instanceof InterruptedException) {
                        logger.info(">>>>>>>>>>> xxl-job remoting server stop.");
                    } else {
                        logger.error(">>>>>>>>>>> xxl-job remoting server error.", e);
                    }
                } finally {
                    // stop
                    try {
                        workerGroup.shutdownGracefully();
                        bossGroup.shutdownGracefully();
                    } catch (Exception e) {
                        logger.error(e.getMessage(), e);
                    }
                }

            }

里面代码如果没有使用过netty的可能不太好理解,不过稍微熟悉一下netty的使用再来看还是比较容易懂。大致逻辑就是:

  • 开启一个线程
  • 任务里面启动一个netty实现的http服务器,监听对应的http请求
  • 进行执行器注册(将当前执行器注册到xxl-job-admin端) 

注册模型

现在继续跟进


startRegistry(final String appname, final String address)


方法,看下具体是的注册过程是怎样的:

public void startRegistry(final String appname, final String address) {
        首先开启一个注册线程,重点是ExecutorRegistryThread:从名字就可以看出来是专门为注册服务的
        ExecutorRegistryThread.getInstance().start(appname, address);
    }

ExecutorRegistryThread是XXL-job里面多线程的典型写法,开启一个Thread,然后通过死循环进行定时注册,再通过启/停标识来控制注册/下线:

public class ExecutorRegistryThread {
    private static Logger logger = LoggerFactory.getLogger(ExecutorRegistryThread.class);

    // 单例
    private static ExecutorRegistryThread instance = new ExecutorRegistryThread();
    public static ExecutorRegistryThread getInstance(){
        return instance;
    }

    private Thread registryThread;
    // 注册标识,通过volatile可视性控制,充当开关
    private volatile boolean toStop = false;
    public void start(final String appname, final String address){

        // valid
        if (appname==null || appname.trim().length()==0) {
            logger.warn(">>>>>>>>>>> xxl-job, executor registry config fail, appname is null.");
            return;
        }
        if (XxlJobExecutor.getAdminBizList() == null) {
            logger.warn(">>>>>>>>>>> xxl-job, executor registry config fail, adminAddresses is null.");
            return;
        }

        registryThread = new Thread(new Runnable() {
            @Override
            public void run() {

                // 如果是开启注册,则进行死循环进行注册
                while (!toStop) {
                    try {
                        RegistryParam registryParam = new RegistryParam(RegistryConfig.RegistType.EXECUTOR.name(), appname, address);
                        for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
                            try {
                                // 通过远程调用,发起HTTP注册请求,是实际的注册代码
                                ReturnT<String> registryResult = adminBiz.registry(registryParam);
                                if (registryResult!=null && ReturnT.SUCCESS_CODE == registryResult.getCode()) {
                                    registryResult = ReturnT.SUCCESS;
                                    logger.debug(">>>>>>>>>>> xxl-job registry success, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                                    break;
                                } else {
                                    logger.info(">>>>>>>>>>> xxl-job registry fail, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                                }
                            } catch (Exception e) {
                                logger.info(">>>>>>>>>>> xxl-job registry error, registryParam:{}", registryParam, e);
                            }

                        }
                    } catch (Exception e) {
                        if (!toStop) {
                            logger.error(e.getMessage(), e);
                        }

                    }

                    try {
                        // 线程休眠30s,再发起下一次注册,达到心跳检测的目的
                        if (!toStop) {
                            TimeUnit.SECONDS.sleep(RegistryConfig.BEAT_TIMEOUT);
                        }
                    } catch (InterruptedException e) {
                        if (!toStop) {
                            logger.warn(">>>>>>>>>>> xxl-job, executor registry thread interrupted, error msg:{}", e.getMessage());
                        }
                    }
                }

                // 实例下线,先不看这志代码,逻辑同注册
                try {
                    RegistryParam registryParam = new RegistryParam(RegistryConfig.RegistType.EXECUTOR.name(), appname, address);
                    for (AdminBiz adminBiz: XxlJobExecutor.getAdminBizList()) {
                        try {
                            ReturnT<String> registryResult = adminBiz.registryRemove(registryParam);
                            if (registryResult!=null && ReturnT.SUCCESS_CODE == registryResult.getCode()) {
                                registryResult = ReturnT.SUCCESS;
                                logger.info(">>>>>>>>>>> xxl-job registry-remove success, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                                break;
                            } else {
                                logger.info(">>>>>>>>>>> xxl-job registry-remove fail, registryParam:{}, registryResult:{}", new Object[]{registryParam, registryResult});
                            }
                        } catch (Exception e) {
                            if (!toStop) {
                                logger.info(">>>>>>>>>>> xxl-job registry-remove error, registryParam:{}", registryParam, e);
                            }

                        }

                    }
                } catch (Exception e) {
                    if (!toStop) {
                        logger.error(e.getMessage(), e);
                    }
                }
                logger.info(">>>>>>>>>>> xxl-job, executor registry thread destroy.");

            }
        });
        registryThread.setDaemon(true);
        registryThread.setName("xxl-job, executor ExecutorRegistryThread");
        registryThread.start();
    }

    public void toStop() {
        toStop = true;

        // interrupt and wait
        if (registryThread != null) {
            registryThread.interrupt();
            try {
                registryThread.join();
            } catch (InterruptedException e) {
                logger.error(e.getMessage(), e);
            }
        }

    }

}

跟进registry(RegistryParam registryParam)方法,可以看到里面的真面目了:发起远程HTTP请求调用:

public ReturnT<String> registry(RegistryParam registryParam) {
        return XxlJobRemotingUtil.postBody(addressUrl + "api/registry", accessToken, timeout, registryParam, String.class);
    }

再看看admin端具体的注册接口:com.xxl.job.admin.controller.JobApiController#api,

public ReturnT<String> registry(RegistryParam registryParam) {
        return JobRegistryHelper.getInstance().registry(registryParam);
    }



public ReturnT<String> registry(RegistryParam registryParam) {

		// valid
		if (!StringUtils.hasText(registryParam.getRegistryGroup())
				|| !StringUtils.hasText(registryParam.getRegistryKey())
				|| !StringUtils.hasText(registryParam.getRegistryValue())) {
			return new ReturnT<String>(ReturnT.FAIL_CODE, "Illegal Argument.");
		}

		// 异步执行,当作注册任务放入对应的线程中执行
		registryOrRemoveThreadPool.execute(new Runnable() {
			@Override
			public void run() {
                // 更新注册信息
				int ret = XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registryUpdate(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());
				
// 如果ret<1,表示更新失败(没有注册记录),重新生成对应的注册记录
if (ret < 1) {
					XxlJobAdminConfig.getAdminConfig().getXxlJobRegistryDao().registrySave(registryParam.getRegistryGroup(), registryParam.getRegistryKey(), registryParam.getRegistryValue(), new Date());

					// fresh
					freshGroupRegistryInfo(registryParam);
				}
			}
		});

		return ReturnT.SUCCESS;
	}

而最终会保存到注册表:xxl_job_registry

CREATE TABLE `xxl_job_registry` (
  `id` int(11) NOT NULL AUTO_INCREMENT,
  `registry_group` varchar(50) NOT NULL,
  `registry_key` varchar(255) NOT NULL,
  `registry_value` varchar(255) NOT NULL,
  `update_time` datetime DEFAULT NULL,
  PRIMARY KEY (`id`),
  KEY `i_g_k_v` (`registry_group`,`registry_key`,`registry_value`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

而里面的registry_key其实就执行器的应用名称,在接入时配置指定的;registry_value对应的实例的执行地址,也是在配置文件指定的(若没有则会动态获取),比如前面例子里面的任务例子:first_job,启动之后,在注册表中会产生记录如下

ruoyi定时任务 调用system_定时任务_06