首先附上xxl job项目源码地址:https://github.com/xuxueli/xxl-job
本文章比较长,主要讲解思路为:
这个项目是作为工程开发的同学们很值得学习的一个开源项目。代码整体风格比较好,模块化清晰。代码逻辑遵行Web的MVC架构,采用Spring boot + Mybatis的框架组合来组织代码。
代码总体分为三部分:
一.xxl-job-core: 这是公共服务模块,比如提供RPC远程调度,线程管理等。从业务角度去分析这个模块是没有意义的,很容易一脑雾水,因为这个模块不是独立的服务,它只是为xxl-job-admin和xxl-job-executors-sample提供了功能模块。
二. xxl-job-admin: web交互的后台引擎,这里称为调度中心。主要负责下面几件事情:
三. xxl-job-executors-sample。主要做以下两件事情:
图1. 代码整体逻辑架构图
磨刀不误砍材工,在正式深入分析这个项目之前,有些知识有必要预知下:
1.quartz的用法。
2.freemarker渲染前端界面的原理和用法。
3.java基本功,以及spring boot和mybatis相关框架知识。
xxl job的任务调度是依赖于quartz的。 quartz可用于创建执行数十,数百甚至数十万个作业的简单或复杂的计划; 任务定义为标准Java组件的任务,可以执行任何可以对其进行编程的任何内容。我们先从quartz官网的一个例子说起:
// 第一步,定义任务类。这个class必须要实现Job接口的execute方法。
public class HelloJob implements Job {
private static Logger _log = LoggerFactory.getLogger(HelloJob.class);
public HelloJob() {
}
public void execute(JobExecutionContext context)
throws JobExecutionException {
_log.info("Hello World! - " + new Date());
}
}
//2. 定义任务的执行逻辑,将任务和触发器绑定起来。
public class SimpleExample {
public void run() throws Exception {
log.info("------- 初始化----------------------");
// 首先,我们得到一个scheduler实例
SchedulerFactory sf = new StdSchedulerFactory();
Scheduler sched = sf.getScheduler();
log.info("------- 初始化完成 -----------");
// computer a time that is on the next round minute
Date runTime = evenMinuteDate(new Date());
log.info("------- 调度任务 -------------------");
// define the job and tie it to our HelloJob class
JobDetail job = JobBuilder.newJob(HelloJob.class).withIdentity("job1", "group1").build();
// Trigger the job to run on the next round minute
Trigger trigger = TriggerBuilder.newTrigger().withIdentity("trigger1", "group1").startAt(runTime).build();
// 告诉quartz利用trigger触发器来调度job
sched.scheduleJob(job, trigger);
log.info(job.getKey() + " will run at: " + runTime);
// Start up the scheduler (nothing can actually run until the
// scheduler has been started)
sched.start();
log.info("------- 任务已经已经启动了 -----------------");
// wait long enough so that the scheduler as an opportunity to
// run the job!
log.info("------- Waiting 65 seconds... -------------");
try {
// wait 65 seconds to show job
Thread.sleep(65L * 1000L);
// executing...
} catch (Exception e) {
//
}
// shut down the scheduler
log.info("------- 调度关闭 ---------------------");
sched.shutdown(true);
log.info("------- 关闭完成 -----------------");
}
public static void main(String[] args) throws Exception {
SimpleExample example = new SimpleExample();
example.run();
}
}
从上面的demo可以看出quartz的关键API:
从上面的几个接口,可以看到quartz设计非常精妙,将作业和触发器分开设计,同时调度器完成对作业的调度。
整个执行过程可以概括如下:
所以quartz的核心元素可以表示为如下图:
图3. quartz内部核心模块关系图
quartz不是以定时器的方式去执行任务的,而是通过线程池去完成。配置文件quartz.properties配置了线程池相关的参数。在quartz中,有两类线程,Scheduler调度线程和任务执行线程,其中任务执行线程通常使用一个线程池维护一组线程。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-bT3CYQD3-1571059534531)(https://cdn.nlark.com/yuque/0/2018/png/171710/1540885992917-894fa2a9-f022-4a35-80fb-18a84507f89c.png “”)]
图4. quartz的线程视图
调度线程主要有两个:执行常规调度的线程,和执行misfiredtrigger的线程。常规调度线程轮询存储的所有trigger,如果有需要触发的trigger,即到达了下一次触发的时间,则从任务执行线程池获取一个空闲线程,执行与该trigger关联的任务。Misfire线程是扫描所有的trigger,查看是否有misfiredtrigger,如果有的话根据misfire的策略分别处理(fire now 或者 wait for the next fire)。
quartz内部的数据是存入数据库的,总共有12张表。Quartz集群中,独立的Quartz节点并不与另一其的节点或是管理节点通信,而是通过相同的数据库表来感知到另一Quartz应用的。 到此,我认为quartz的核心要点应该介绍完了。
freemarker是一个java模板引擎。是一种基于模板和要改变的数据,并用来生成输出文本(HTML网页,电子邮件,配置文件,源代码等)的通用工具。类似于JSP,volecity。这里不细说,有这个概念就好了。
xxl job的源码阅读,需要一定的java工程功底。特别要熟悉下spring boot, mybatis框架。
xxl-job-admin是项目的核心,称为调度中心,也是一个典型的web项目架构。通常对于一个web程序来说,我们分析时,主要是关注两件事情:第一,这个程序在初始化(也就是程序启动的时候)干了哪些事情;第二,程序的Restful接口分析,这个是Web项目最大的主线。下面的分析我们也主要是从这两点分别展开。
JVM执行一个java程序时,会经历编译,加载,分配内存和执行等过程。spring boot采用了的bean方式初始化了一些对象,这些对象包括了数据库连接池,前端界面渲染的引擎,配置文件读取,quartz调度引擎,拦截器等等,这些对象一旦初始化,就会从JVM的方法区里实例化到堆内存里面去,可以供进程后续的调用。这里有个和我们业务直接相关的bean初始化,代码如下:
<bean id="xxlJobDynamicScheduler" class="com.xxl.job.admin.core.schedule.XxlJobDynamicScheduler" init-method="init" destory-method="destory">
<property name="scheduler" ref="quartzScheduler" />
<property name="accessToken" value="${xxl.job.accessToken" />
bean>
这个XxlJobDynamicScheduler类在初始化化时,执行了init方法。我们来重点分析下这个init方法干了哪些事情。
public void init() throws Exception {
// 1. 调度中心注册守护线程,就是一直守护着执行器的注册,维持着和执行器之间的心跳
JobRegistryMonitor.getInstance.start();
// 2. 任务失败处理的守护线程
JobFailMonitorHelper.getInstance().start();
// 3. 初始化本地调度中心服务
NetComServerFactory.putService(AdminBiz.class, XxlJobDynamicScheduler.adminBiz);
NetComServerFactory.setAcessToken(accessToken);
// 4.国际化
initI18n();
Assert.notNull(scheduler, "quartz scheduler is null");
logger.info(">>>>>>> init xxl-job admin success");
}
所以总的来说,这里主要是初始化了两个守护线程。一个是维持和执行器之间心跳的线程,一个是任务执行失败重试的线程。
Controller层是我们理解后台逻辑的入口,com.xxl.job.admin.controller包里面中共包含了六大模块:权限登录模块,调度中心和执行器通信的RPC模块,GLUE任务编辑模块,执行器管理模块,任务操作模块和任务日志管理模块。从用户正常的交互角度分析,这些模块是有先后顺序的。用户首先是通过账户密码登录系统,然后查看调度中心里有没有已经自动注册上的执行器,如果没有,那么需要手动添加执行器。后续就可以创建任务了。任务创建时,GLUE类型的任务可以在线编辑任务逻辑代码的。任务确认创建好了之后,可以手动即席执行,还可以配置cron表达式进行周期调度执行。最后通过日志界面,查看每个任务的执行逻辑。所以本节也会根据这个先后顺序来介绍每个模块的具体逻辑。
程序的配置文件里配置了初始化的username为admin,password为123456。spring boot的xml配置文件里配置了两个拦截器。具体如下:
<mvc:interceptors>
<mvc:interceptor>
<mvc:mapping path="/**" />
<bean class="com.xxl.job.admin.controller.interceptor.PermissionInterceptor" />
mvc:interceptor>
<mvc:interceptor>
<mvc:mapping path="/**" />
<bean class="com.xxl.job.admin.controller.interceptor.CookieInteceptor" />
mvc:interceptor>
mvc:interceptors>
所以我们有必要先看这两个拦截器做了哪些事情。因为代码较多,所以这里只展现核心的逻辑出来。
public class PermissionInterceptor extends HandlerInterceptorAdapter{
// 1. 静态代码块
static {
String username = XxlJobAdminConfig.getAdminConfig().getLoginUsername();
String password = XxlJobAdminConfig.getAdminConfig().getLoginPassword();
String tokenTmp = DigestUtils.md5Hex(username + "_" + password);
tokenTmp = new BigInteger(1, tokenTmp.getBytes()).toString(16);
}
// 2. 拦截方法,登录方法
public static boolean login(HttpServletResponse response, String username, String password, boolean ifRemember){
// 代码略
// 整体逻辑为:
// 1. 验证登录时的用户名和密码生成的MD5加密生成的token是否与内存中tokenTmp是否相等,如果不等,就直接返回false。
// 2. 如果相等,把token值以Cookie的方式存入response,当前端浏览器收到这个响应时,会把cookie值自动存入浏览器会话窗口里。
}
// 3.登出,注销账户的方法
public static void logout(HttpServletRequest request, HttpServletResponse response){
// 将token值从response里删除
}
// 4. 判断客户端是否处于登录状态
public static boolean ifLogin(HttpServletRequest request) {
// 判断依据是:
// 判断请求体request的Cookie里面token值是否和内存里的tokenTmp相等。如果浏览器存着cookie值,那么每次请求,浏览器都会自动把cookie带上的。
// 如果相等,那么处于登录状态;如果不等,那么处于非登录状态。
}
// 5. 服务端收到客户端请求时的拦截方法,默认任何请求都会先经过这层拦截器
@Override
public boolean preHandler(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception{
// 逻辑说明
// 通过ifLogin如果客户端不是处于登录状态,那么需要重定向到登录页。
}
}
public class CookieInterceptor extends HandlerInterceptorAdapter {
// 这是服务端响应客户端请求时执行的拦截,任何请求的响应之后,都会执行这个拦截器。
@Override
public void postHandle(HttpServletRequest request, HttpServletResponse response, Object handler, ModelAndView modelAndView) throws Exception {
// 执行的整体逻辑,把cookies全部以cookieMap对象存入modelAndView中。
}
}
以上是两个拦截器的总体逻辑,这里读者如果有疑问,最好是先去熟悉下拦截器的原理。
下面正式开始Controller的逻辑分析。
访问首页代码的执行体:
@RequestMapping("/")
public String index(Model model){
// 1. 请求model数据
Map<String,Object> dashboardMap = xxlJobService.dashboardInfo();
model.addAllAttributes(dashboard);
// 2. 返回index.ftl模板
return "index";
}
因为默认所以的请求都会先经过拦截器来判断浏览器会话层是否处于登录状态,所以这个路由的执行体在执行这部分逻辑之前,是要先经过拦截器的preHandler方法的。如果preHandler返回true,才再执行index函数执行体里面的逻辑。这也是spring框架比较隐晦的地方。后续的路由分析将不再强调这个,因为是一样的思想。
dashboardMap是Model的数据。这段逻辑是去统计数据库里的运行报表信息,包括任务数量,调度次数,执行器数量等三部分。然后将这些数据存入dashboardMap。返回前端的界面模板是index.ftl。freemarker模板引擎会结合model数据和views模板,最后把界面完整地呈现给前端浏览器。
首页的渲染,出了上述逻辑,还有日期分布折线图和成功比例饼型图的绘制。这两个图的绘制所需数据是请求的另一个接口。这个接口的请求,是前端的ajax请求发出的。路由逻辑如下:
@RequestMapping("/chartInfo)
@ResponseBody
public ReturnT<Map<String,Object>> chartInfo(Date startDate, Date endDate){
ReturnT<Map<String, Object>> chartInfo = xxlJobService.chartInfo(startDate, endDate);
return chartInfo;
}
这段执行体的主要逻辑是根据开始时间startDate和结束时间endDate去数据库里面捞出这段时间内的统计信息。默认是请求近一个月的数据信息。这里的统计数据是从XXL_JOB_QRTZ_TRIGGER_LOG表里面获得的。最后返回给前端的是json数据格式chartInfo。这里有必要强调下方法体的注解@ResponseBody的作用,如果有这个注解,那么返回给前端的必须是json数据格式。
用户登录页的controller层代码如下:
@RequestMapping("/toLogin")
@PermessionLimit(limit=false)
public String toLogin(Model model, HttpServletRequest request){
if(PermissionInteceptor.ifLogin(request)){
return "redirect:/"
}
return "login";
}
这段代码是先判断客户端是否为登录状态,如果是,那么直接重定向首页,无需用户再进行登录。如果不是处于登录状态,那么久给前端返回login.ftl模板。也就是进入了登录页。
登录事件执行体逻辑如下:
@RequestMapping(value="login", method=RequestMethod.POST)
@ResponseBody
@PermessionLimit(limit=false)
public ReturnT<string> loginDo(HttpServletRequest request, HttpServletResponse response, String userName, String password, String ifRemember){
// 1. 先验证是否处于登录状态,如果是,直接返回success
// 2. 校验用户名和密码的合法性,不能为空值
// 3. 判断是否要记住密码
// 4. 执行登录逻辑(这个逻辑很简单,就是判断用户输入的用户名和密码的md5加密值和后台的tmpToken是否相等。)如果相等,那么就把token值存入响应体,最后会把cookie存到浏览器。
// 5. 判断是否登录成功
}
因为这部分的代码相对而言有点长,所以代码没有罗列了。只写出了代码的执行逻辑。这样读者对着代码去读,就会觉得思路很清晰的。注意这个登录请求时post请求,注解里有标注。
代码如下:
@RequestMapping(value="logout", method=RequestMethod.POST)
@ResponseBody
@PermssionLimit(limit=false)
public ReturnT<String> logout(HttpServletRequest request, HttpServletResponse response){
if(PermissionInterceptor.ifLogin(request)){
PermissionInterceptor.logout(request, response);
}
return ReturnT.SUCCESS;
}
先判断客户端是否处于登录状态,如果是,那么就执行PermissionInterceptor.logout函数体。这个函数的逻辑是将response响应体的Cookie删掉了。从而会更新客户端(浏览器)里的cookie为空值。从未就是客户端不符合登录状态。
这个模块里还内插了一个小的controller,如下:
@RequestMapping("/help")
public String help(){
return "help"
}
这个逻辑没有做任何事情,就是返回静态页help.ftl。
到此调度中心的权限登录逻辑分析完了,这也是一个基本的Web应用产品都要做的事情。这里的思想甚至都可以行成模块。
说起调度中心和执行器之间的RPC通信模块,这里有必要先提下执行器。后续章节会具体讲解执行器的逻辑,但是这里要先提下执行器和调度中心的简单的交互逻辑。执行器自动注册时,主要是向调度中心发起RPC请求, 所以这里调度中心供一个rpc接口供执行器过来注册。rpc框架需要解决的一个问题是: 像调用本地接口一样调用远程的接口。于是如何组装数据报文,经过网络传输发送至服务提供方,屏蔽远程接口调用的细节,便是动态代理需要做的工作。
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-lEZPUf0v-1571059534532)(https://cdn.nlark.com/yuque/0/2018/png/171710/1540895256542-99c45aaa-5892-4c51-9899-888432ef6377.png “”)]
图4. RPC过程示意图
如上图所示,假设Computer1在调用sayHi()方法,对于Computer1而言调用sayHi()方法就像调用本地方法一样,调用 –>返回。但从后续调用可以看出Computer1调用的是Computer2中的sayHi()方法,RPC屏蔽了底层的实现细节,让调用者无需关注网络通信,数据传输等细节。
RPC的整体执行过程可以描述为:
1)服务消费方(client)调用以本地调用方式调用服务;
2)client stub接收到调用后负责将方法、参数等组装成能够进行网络传输的消息体;
3)client stub找到服务地址,并将消息发送到服务端;
4)server stub收到消息后进行解码;
5)server stub根据解码结果调用本地的服务;
6)本地服务执行并将结果返回给server stub;
7)server stub将返回结果打包成消息并发送至消费方;
8)client stub接收到消息,并进行解码;
9)服务消费方得到最终结果。
调度中心提供这个RPC接口的代码逻辑为:
private RpcResponse doInvoke(HttpServletRequest request) {
try {
// 反序列化请求体
byte[] requestBytes = HttpClientUtil.readBytes(request);
if (requestBytes == null || requestBytes.length==0) {
RpcResponse rpcResponse = new RpcResponse();
rpcResponse.setError("RpcRequest byte[] is null");
return rpcResponse;
}
RpcRequest rpcRequest = (RpcRequest) HessianSerializer.deserialize(requestBytes, RpcRequest.class);
// 触发
RpcResponse rpcResponse = NetComServerFactory.invokeService(rpcRequest, null);
return rpcResponse;
} catch (Exception e) {
logger.error(e.getMessage(), e);
RpcResponse rpcResponse = new RpcResponse();
rpcResponse.setError("Server-error:" + e.getMessage());
return rpcResponse;
}
}
@RequestMapping(AdminBiz.MAPPING)
@PermessionLimit(limit=false)
public void api(HttpServletRequest request, HttpServletResponse response) throws IOException {
//触发,动态代理执行逻辑
RpcResponse rpcResponse = doInvoke(requst);
//序列化
byte[] responseBytes = HessianSerializer.serialize(rpcResponse);
response.setContentType("text/html;charset=utf-8");
response.setStatus(HttpServletResponse.SC_OK);
OutputStream out = response.getOutputStream();
out.write(responseBytes);
out.flush();
}
这段代码主要执行逻辑在这一行:RpcResponse rpcResponse = doInvoke(request)。执行逻辑总结为如下流程:
在线编辑任务代码主要针对GLUE类型的任务。如果是bean类型的任务是不提供编辑修改任务代码的。该模块主要提供的功能有回溯GLUE类任务的历史版本,以及更新任务的脚本内容等操作。
该接口的controller代码如下:
@RequestMapping
public String index(Model model, int jobId){
// 1. 通过jobId获取任务信息。
XxlJobInfo = xxlJobInfoDao.loadById(jobId);
// 2. 通过jobId获取GLUE历史版本信息。
List<XxlJobLogGlue> jobLogGlues = xxlJobLogGlueDao.findByJobId(jobId);
// 3. 验证任务是否存在
if(jobInfo == null){
throw new RuntimeException(I18nUtil.getString("jobinfo_glue_jobid_invalid");
}
// 4. 验证任务是否是GlueType,如果不是抛出异常。
if(GlueTypeEnum.BEAN == GlueTypeEnum.match(jobInfo.getGlueType())){
throw new RuntimeException(I18nUtil.getString("jobinfo_glue_gluetype_unvalid"));
}
// 5. 将数据和模板返回
model.addAttribute("GlueTypeEnum", GlueTypeEnum.values());
model.addAttribute("jobInfo",jobInfo);
model.addAttribute("jobLogGlues",jobLogGlues);
return "jobcode/jobcode.index";
}
这个逻辑在代码注释里写比较清晰了,这里一次性返回了该任务的历史所有GLUE版本,通过前端去操作可以编辑任务版本的脚本内容 。
任务在线编辑好之后,可以点击保存按钮,将新的逻辑脚本更新到数据库。
@RequestMapping("/save")
@ResponseBody
public ReturnT<String> save(Model model, int id, String glueSource, String glueRemark){
// 逻辑有点长,所以这里只列出执行逻辑:
// 概括起来,就是通过任务id,glue内容以及对应的glueRemark(版本)去更新数据库,将该版本的脚本内容保存起来
}
这个接口要接受前端参数:任务id,脚本内容,以及对应得版本号,去执行修改版本的脚本内容。这里的版本号也就是该任务的最新版本号了,后续任务执行时是读取最新的版本号内容去执行的。
同一个执行器集群内AppName(xxl.job.executor.appname)需要保持一致,AppName执行器心跳注册分组依据。一个执行器组内可以只有一个执行器,也可以有多个执行器。当有多个执行器时,这些执行器的地址是使用逗号相连的方式存入数据库表的。
controller层代码逻辑:
@RequestMapping
public String index(Model model) {
// job group (executor)
List<XxlJobGroup> list = xxlJobGroupDao.findAll();
model.addAttribute("list", list);
return "jobgroup/jobgroup.index";
}
这个接口是去数据库里表XXL_JOB_QRTZ_TRIGGER_GROUP里插入该执行器组的所有执行器。将所以的执行器查询出来。然后把列表返回给前端界面。
添加执行器的接口逻辑如下:
@RequestMapping("/save")
@ResponseBody
public ReturnT<String> save(XxlJobGroup xxlJobGroup){
// 1. 校验:appName检验,执行器名称校验,如果是手动注册,那么要验证执行器address的合法性
if (xxlJobGroup.getAppName()==null || StringUtils.isBlank(xxlJobGroup.getAppName())) {
return new ReturnT<String>(500, (I18nUtil.getString("system_please_input")+"AppName") );
}
if (xxlJobGroup.getAppName().length()<4 || xxlJobGroup.getAppName().length()>64) {
return new ReturnT<String>(500, I18nUtil.getString("jobgroup_field_appName_length") );
}
if (xxlJobGroup.getTitle()==null || StringUtils.isBlank(xxlJobGroup.getTitle())) {
return new ReturnT<String>(500, (I18nUtil.getString("system_please_input") + I18nUtil.getString("jobgroup_field_title")) );
}
// 这里特别是对执行器的address列表进行了校验
if (xxlJobGroup.getAddressType()!=0) {
if (StringUtils.isBlank(xxlJobGroup.getAddressList())) {
return new ReturnT<String>(500, I18nUtil.getString("jobgroup_field_addressType_limit") );
}
String[] addresss = xxlJobGroup.getAddressList().split(",");
for (String item: addresss) {
if (StringUtils.isBlank(item)) {
return new ReturnT<String>(500, I18nUtil.getString("jobgroup_field_registryList_unvalid") );
}
}
}
// 2. 将新的执行器信息添加到数据库
int ret = xxlJobGroupDao.save(xxlJobGroup);
return (ret>0)?ReturnT.SUCCESS:ReturnT.FAIL;
}
这里的逻辑是比较清晰的。对新添加的执行器的相关数据校验,然后就数据存到数据库里面去。同一执行器组的appName要一样。
代码如下:
@RequestMapping("/update")
@ResponseBody
public ReturnT<String> update(XxlJobGroup xxlJobGroup){
// 1.校验数据,和2.4.2接口的校验方式类似.
// 更新数据库
}
这里的第一步校验数据整体是和2.4.2接口类似的。但是增加了对注册方式的处理,执行器的注册方式有两种:手动注册和自动注册。如果是自动注册,就通过注册时的appName参数去获取数据库表已经存在的执行器组内的执行器address列表,有点类似于复制了一个执行器组。如果是手动注册,那么只要校验填写的addressList是否符合规范。
逻辑代码:
@RequestMapping("/remove")
@ResponseBody
public ReturnT<Stirng> remove(int id){
// 1. 如果该执行器正在执行任务,那么是不允许删除该任务的
int count = xxlJobInfoDao.pageListCount(0,10,id,null,null);
if(count > 0){
return new ReturnT<500,I18nUtil.getString("jobgroup_del_limit_0");
}
// 2.如果执行器组里只剩下一个执行器了,那么这个操作是不允许再删除执行器了.
List<XxlJobGroup> allList = xxlJobGroupDao.findAll();
if (allList.size() == 1) {
return new ReturnT<String>(500, I18nUtil.getString("jobgroup_del_limit_1") );
}
int ret = xxlJobGroupDao.remove(id);
return (ret>0)?ReturnT.SUCCESS:ReturnT.FAIL;
}
这里主要是在执行数据库删除执行器之前,先做两步验证,首先是如果有任务和该执行器绑定,那么这个执行器是不能删除的,然后如果执行器组仅剩下最后一个执行器了,那么执行器也是不能删除的。
任务操作模块主要是针对任务的增删改查等操作以任务的执行, 杀死, 暂停,恢复执行等操作。但这一块的功能我在测试过程中,发现存在着争议,下面就一一分析下这些接口。
Controller的代码如下:
@RequestMapping
public String index(Model model, @RequestParam(required = false, defaultValue = "-1") int jobGroup) {
// 枚举-字典
model.addAttribute("ExecutorRouteStrategyEnum", ExecutorRouteStrategyEnum.values()); // 路由策略-列表
model.addAttribute("GlueTypeEnum", GlueTypeEnum.values()); // Glue类型-字典
model.addAttribute("ExecutorBlockStrategyEnum", ExecutorBlockStrategyEnum.values()); // 阻塞处理策略-字典
// 任务组
List<XxlJobGroup> jobGroupList = xxlJobGroupDao.findAll();
model.addAttribute("JobGroupList", jobGroupList);
model.addAttribute("jobGroup", jobGroup);
return "jobinfo/jobinfo.index";
}
@RequestMapping("/pageList")
@ResponseBody
public Map<String, Object> pageList(@RequestParam(required = false, defaultValue = "0") int start,
@RequestParam(required = false, defaultValue = "10") int length,
int jobGroup, String jobDesc, String executorHandler, String filterTime) {
return xxlJobService.pageList(start, length, jobGroup, jobDesc, executorHandler, filterTime);
}
index函数是列出路由策略,GLUEType类型,执行器阻塞类型,执行器组等列表信息,这些信息最终是渲染到jobinfo.index.ftl中去,形成前端界面任务管理界面里的条件选择。就是用户可以选择条件(比如路由策略等)去查询符合条件的任务。界面初始化时发起了一个ajax请求,这个ajax请求就是请求了任务列表api。也就是代码里的/pageList路由。这个路由就是分页查询任务列表了。
新增任务的Controller:
@RequestMapping("/add")
@ResponseBody
public ReturnT<String> add(XxlJobInfo jobInfo) {
return xxlJobService.add(jobInfo);
}
整体逻辑体现在xxlJobService.add(jobInfo)里,首先是检验参数的合理性,特别是对子任务的id进行了校验,就是要保证子任务是已经存在的任务;然后把新增的任务存到存到数据库表xxl_job_qrtz_trigger_info里面去;最后,调用quartz的scheduler.scheduleJob()接口,把任务的cron调度交给quartz去管理,将任务和执行器绑定起来。这里整体逻辑支持了事务回滚,如果最后一步失败,那么会把表xxl_job_qrtz_trigger_info里刚刚插入的任务给删掉。
代码如下:
@RequestMapping("/update")
@ResponseBody
public ReturnT<String> update(XxlJobInfo jobInfo) {
return xxlJobService.update(jobInfo);
}
xxlJobService.update的逻辑和add的逻辑类似,首先也是校验数据,然后是更新数据库表xxl_job_qrtz_trigger_info,最后调用quartz的scheduler.rescheduleJob()接口重新调度该任务。
@RequestMapping("/remove")
@ResponseBody
public ReturnT<String> remove(int id) {
return xxlJobService.remove(id);
}
删除任务首先是调用quartz的scheduler.unscheduleJob()接口,相当于是卸载任务;然后是去数据库表该任务相关的东西先删除掉,比如任务信息,任务日志信息,还有任务的GLUE历史版本信息,这些全部要删除掉。
@RequestMapping("/pause")
@ResponseBody
public ReturnT<String> pause(int id) {
return xxlJobService.pause(id);
}
暂停任务其实是调用quartz的schedule.pauseTrigger()接口,不过这个接口在测试的过程是由问题的,就是对某个正在执行的任务执行这个暂停操作,其实没效果,因为无法将一个正在执行的任务进程杀死掉。这是个bug。
@RequestMapping("/resume")
@ResponseBody
public ReturnT<String> resume(int id) {
return xxlJobService.resume(id);
}
当任务暂停之后,然后再把其恢复继续执行,这个接口实际上执行的是quartz的scheduler.resumeTrigger()接口,但是在真实的测试环节中,这个接口也是没有效果。感觉也是个bug。
@RequestMapping("/trigger")
@ResponseBody
//@PermessionLimit(limit = false)
public ReturnT<String> triggerJob(int id, String executorParam) {
// force cover job param
if (executorParam == null) {
executorParam = "";
}
// 触发core依赖模块里的事件,触发类型是手动,
JobTriggerPoolHelper.trigger(id, TriggerTypeEnum.MANUAL, -1, null, executorParam);
return ReturnT.SUCCESS;
}
触发任务执行的整体逻辑是执行JobTriggerPoolHelper.trigger函数,这里尤其要注意该函数的第二个参数,代表了触发类型,这是手动触发。其背后逻辑是新创建一个任务扔到触发线程池里面去执行,触发线程池是一个coreSize=32,maxSize=256的线程池。所以任务不是立马执行的,而是线程池的方式执行的, 任务只是添加到了阻塞队列里去了。
调度中心会根据任务的路由策略去选择哪个执行器来执行任务,比如执行器组的第一个执行器,执行器组的最后一个执行器,或者随机获取一个执行器等等, 但是如果该任务是分片广播的方式去执行任务,情况要注意下。每个任务是和一个执行器组绑定在一起的,调度会把任务下发到该执行器组的每一个执行器,让每一个执行器都执行该任务。
日志管理界面的初始化是请求了两个接口:
@RequestMapping
public String index(Model model, @RequestParam(required = false, defaultValue= "0") Integer jobiId){
List<XxlJobGroup> jobGroupList = xxlJobGroupDao.findAll();
model.addAttribute("JobGroupList", jobGroupList);
if(jobId > 0){
XxlJobInfo jobInfo = xxlJobInfoDao.loadById(jobId);
model.addAttribute("jobInfo",jobInfo);
}
return "joblog/joblog.index";
}
这是日志管理界面初始化时首先要加载到所有的执行器,执行器列表是作为前端的筛选条件的。返回joblog.index.ftl模板。如果jobId大于0,那么就展现该任务的日志信息。随之,前端通过ajax请求了pageList接口,加载日志列表:
@RequestMapping("/pageList")
@ResponseBody
public Map<String,Object> pageList(@RequestParam(required = false, defaultValue = "0") int start,
@RequstParam(required = false, defaultValue="10") int length, int jobGroup, int jobId,
int logStatus, String filterTime){
Date triggerrTimeStart = null;
Date triggerTimeEnd = null;
if(StringUtils.isNotBlank(filterTime)){
String[] temp = filterTime.split("-");
if(temp != null && temp.length == 2){
try {
triggerTimeStart = DateUtils.parseDate(temp[0], new String[]{"yyyy-MM-dd HH:mm:ss"});
triggerTimeEnd = DateUtils.parseDate(temp[1], new String[]{"yyyy-MM-dd HH:mm:ss"});
} catch(ParseException e){ }
}
}
List<XxlJobLog> list = xxlJobLogDao.pageList(start, length, jobGroup, jobId, triggerTimeStart, triggerTimeEnd,logStatus);
int list_count = xxlJobLogDao.pageListCount(start, length, jobGroup, jobId, triggerTimeStart, triggerTimeEnd,logStatus);
Map<String, Object> maps = new HashMap<String, Object>();
maps.put("recordsTotal", list_count);
maps.put("recordsFiltered", list_count);
maps.put("data",list);
return maps;
}
这个接口就是分页查询日志列表了,根据查询条件去查询符合条件的日志。
接口代码:
@RequestMapping("/getJobsByGroup")
@ResponseBody
public ReturnT<List<XxlJobInfo>> getJobsByGroup(int jobGroup){
List<XxlJobInfo> list = xxlJobInfoDao.getJobsByGroup(jobGroup);
return new ReturnT<List<XxlJobInfo>>(list);
}
这个接口提供了通过执行器去加载该执行器下面的任务。这是在条件筛选时触发的,级联条件筛选。
接口代码:
@RequestMapping("/clearLog")
@ResponseBody
public ReturnT<String> clearLog(int jobGroup, int jobId, int type){
// 按照时间返回,清除日志
}
这个接口含义就是根据清理方式(type)来对日志清晰清除,比如清理一个月之前的日志,清晰一周之前的日志。一般来说,越早的日志对我们的价值越小,用户主要关注近期的日志。
备注:代码里还有其他的接口:/logKill, /logDetailPage, /logDetailCat。这三个接口,貌似没有看到它们的用处,暂时不做分析。
执行器主要是做两部分工作,第一,负责任务的执行。第二,负责初始化线下编辑好的bean任务。执行器模块是以bean的方式来初始化对象的,这里先看xml配置文件。
<bean id="xxlJobExecutor" class="com.xxl.job.core.executor.XxlJobExecutor" init-method="start" destory-method="destory" >
<property name="adminAddresses" value="${value.job.admin.addresses}" />
<property name="appName" value="${xxl.job.executor.appname" />
<property name="ip" value="${xxl.job.executor.ip}" />
<property name="port" value="${xxl.job.executor.port}" />
<property name="accessToken" value="${xxl.job.accessToken" />
<property name="logPath" value="${xxl.job.executor.logpath}" />
<property name="logRetentionDays" value="${xxl.job.executor.logretentiondays}" />
bean>
这个bean初始化时执行了init-method方法.所以XxlJobExecutor的start方法是执行器服务初始化时做的处理。 重点关注下这部分初始化逻辑。
public void start() throws Exception {
//1. 初始化调度中心的本地列表
initAdminBizList(adminAddresses,accessToken);
//2. 初始化执行器的任务库
initJobHandlerRepository(applicationContext);
// 3. 初始化日志路径
XxlJobFileAppender.initLogPath(logPath);
// 4. 初始化执行服务器
initExecutorServer(port,ip, appName, accessToken);
// 5. 日志清除线程
JobLogFileCleanThread.getInstance().start(logRetionDays);
}
这部分程序初始化逻辑主要做了5件事情,下面我们具体依次对这5件事情进行分析。
在这里要重点强调下调度中心和执行器之间的RPC交互。一方面,执行器通过rpc去调度中心注册,每隔30秒通过调度中心向数据库写入心跳;另一方面,当调度中心要执行任务时,是远程调用执行器的方法。RPC要求远程类和本地创建的代理类要集成共同的接口,所以RPC模块放在了xxl-job-core公共库。这样执行器模块和调度中心模块都需要依赖。
本次源码分析是针对xxl job的1.9.2版本。
xxl job的github最新的版本改动较大,项目全面迁移到了spring boot, 减少了很多配置项。特别是rpc模块,作者利用了自己开源的一个xxl-rpc-core框架,这个框架的网络模型是基于NIO,性能比BIO要好。
后续会持续更近其最新版本做出的改动。