魔改xxl-job,徹底告別手動配置任務!

原創:微信公眾號 碼農參上,歡迎分享,轉載請保留出處。

哈嘍大家好啊,我是Hydra。

xxl-job是一款非常優秀的任務調度中間件,輕量級、使用簡單、支援分散式等優點,讓它廣泛應用在我們的項目中,解決了不少定時任務的調度問題。

我們都知道,在使用過程中需要先到xxl-job的任務調度中心頁面上,配置執行器executor和具體的任務job,這一過程如果項目中的定時任務數量不多還好說,如果任務多了的話還是挺費工夫的。

假設項目中有上百個這樣的定時任務,那麼每個任務都需要走一遍綁定jobHander後端介面,填寫cron表達式這個流程…

我就想問問,填多了誰能不迷糊?

於是出於功能優化(偷懶)這一動機,前幾天我萌生了一個想法,有沒有什麼方法能夠告別xxl-job的管理頁面,能夠讓我不再需要到頁面上去手動註冊執行器和任務,實現讓它們自動註冊到調度中心呢。

分析

分析一下,其實我們要做的很簡單,只要在項目啟動時主動註冊executor和各個jobHandler到調度中心就可以了,流程如下:

有的小夥伴們可能要問了,我在頁面上創建執行器的時候,不是有一個選項叫做自動註冊嗎,為什麼我們這裡還要自己添加新執行器?

其實這裡有個誤區,這裡的自動註冊指的是會根據項目中配置的xxl.job.executor.appname,將配置的機器地址自動註冊到這個執行器的地址列表中。但是如果你之前沒有手動創建過執行器,那麼是不會給你自動添加一個新執行器到調度中心的。

既然有了想法咱們就直接開干,先到github上拉一份xxl-job的源碼下來:

//github.com/xuxueli/xxl-job///github.com/xuxueli/xxl-job/

整個項目導入idea後,先看一下結構:

結合著文檔和程式碼,先梳理一下各個模組都是幹什麼的:

  • xxl-job-admin:任務調度中心,啟動後就可以訪問管理頁面,進行執行器和任務的註冊、以及任務調用等功能了
  • xxl-job-core:公共依賴,項目中使用到xxl-job時要引入的依賴包
  • xxl-job-executor-samples:執行示例,分別包含了springboot版本和不使用框架的版本

為了弄清楚註冊和查詢executorjobHandler調用的是哪些介面,我們先從頁面上去抓一個請求看看:

好了,這樣就能定位到xxl-job-admin模組中/jobgroup/save這個介面,接下來可以很容易地找到源碼位置:

按照這個思路,可以找到下面這幾個關鍵介面:

  • /jobgroup/pageList:執行器列表的條件查詢
  • /jobgroup/save:添加執行器
  • /jobinfo/pageList:任務列表的條件查詢
  • /jobinfo/add:添加任務

但是如果直接調用這些介面,那麼就會發現它會跳轉到xxl-job-admin的的登錄頁面:

其實想想也明白,出於安全性考慮,調度中心的介面也不可能允許裸調的。那麼再回頭看一下剛才頁面上的請求就會發現,它在Headers中添加了一條名為XXL_JOB_LOGIN_IDENTITYcookie

至於這條cookie,則是在通過用戶名和密碼調用調度中心的/login介面時返回的,在返回的response可以直接拿到。只要保存下來,並在之後每次請求時攜帶,就能夠正常訪問其他介面了。

到這裡,我們需要的5個介面就基本準備齊了,接下來準備開始正式的改造工作。

改造

我們改造的目的是實現一個starter,以後只要引入這個starter就能實現executorjobHandler的自動註冊,要引入的關鍵依賴有下面兩個:

<dependency>
    <groupId>com.xuxueli</groupId>
    <artifactId>xxl-job-core</artifactId>
    <version>2.3.0</version>
</dependency>

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-autoconfigure</artifactId>
</dependency>

1、介面調用

在調用調度中心的介面前,先把xxl-job-admin模組中的XxlJobInfoXxlJobGroup這兩個類拿到我們的starter項目中,用於接收介面調用的結果。

登錄介面

創建一個JobLoginService,在調用業務介面前,需要通過登錄介面獲取cookie,並在獲取到cookie後,快取到本地的Map中。

private final Map<String,String> loginCookie=new HashMap<>();

public void login() {
    String url=adminAddresses+"/login";
    HttpResponse response = HttpRequest.post(url)
            .form("userName",username)
            .form("password",password)
            .execute();
    List<HttpCookie> cookies = response.getCookies();
    Optional<HttpCookie> cookieOpt = cookies.stream()
            .filter(cookie -> cookie.getName().equals("XXL_JOB_LOGIN_IDENTITY")).findFirst();
    if (!cookieOpt.isPresent())
        throw new RuntimeException("get xxl-job cookie error!");

    String value = cookieOpt.get().getValue();
    loginCookie.put("XXL_JOB_LOGIN_IDENTITY",value);
}

其他介面在調用時,直接從快取中獲取cookie,如果快取中不存在則調用/login介面,為了避免這一過程失敗,允許最多重試3次。

public String getCookie() {
    for (int i = 0; i < 3; i++) {
        String cookieStr = loginCookie.get("XXL_JOB_LOGIN_IDENTITY");
        if (cookieStr !=null) {
            return "XXL_JOB_LOGIN_IDENTITY="+cookieStr;
        }
        login();
    }
    throw new RuntimeException("get xxl-job cookie error!");
}

執行器介面

創建一個JobGroupService,根據appName和執行器名稱title查詢執行器列表:

public List<XxlJobGroup> getJobGroup() {
    String url=adminAddresses+"/jobgroup/pageList";
    HttpResponse response = HttpRequest.post(url)
            .form("appname", appName)
            .form("title", title)
            .cookie(jobLoginService.getCookie())
            .execute();

    String body = response.body();
    JSONArray array = JSONUtil.parse(body).getByPath("data", JSONArray.class);
    List<XxlJobGroup> list = array.stream()
            .map(o -> JSONUtil.toBean((JSONObject) o, XxlJobGroup.class))
            .collect(Collectors.toList());
    return list;
}

我們在後面要根據配置文件中的appNametitle判斷當前執行器是否已經被註冊到調度中心過,如果已經註冊過那麼則跳過,而/jobgroup/pageList介面是一個模糊查詢介面,所以在查詢列表的結果列表中,還需要再進行一次精確匹配。

public boolean preciselyCheck() {
    List<XxlJobGroup> jobGroup = getJobGroup();
    Optional<XxlJobGroup> has = jobGroup.stream()
            .filter(xxlJobGroup -> xxlJobGroup.getAppname().equals(appName)
                    && xxlJobGroup.getTitle().equals(title))
            .findAny();
    return has.isPresent();
}

註冊新executor到調度中心:

public boolean autoRegisterGroup() {
    String url=adminAddresses+"/jobgroup/save";
    HttpResponse response = HttpRequest.post(url)
            .form("appname", appName)
            .form("title", title)
            .cookie(jobLoginService.getCookie())
            .execute();
    Object code = JSONUtil.parse(response.body()).getByPath("code");
    return code.equals(200);
}

任務介面

創建一個JobInfoService,根據執行器idjobHandler名稱查詢任務列表,和上面一樣,也是模糊查詢:

public List<XxlJobInfo> getJobInfo(Integer jobGroupId,String executorHandler) {
    String url=adminAddresses+"/jobinfo/pageList";
    HttpResponse response = HttpRequest.post(url)
            .form("jobGroup", jobGroupId)
            .form("executorHandler", executorHandler)
            .form("triggerStatus", -1)
            .cookie(jobLoginService.getCookie())
            .execute();

    String body = response.body();
    JSONArray array = JSONUtil.parse(body).getByPath("data", JSONArray.class);
    List<XxlJobInfo> list = array.stream()
            .map(o -> JSONUtil.toBean((JSONObject) o, XxlJobInfo.class))
            .collect(Collectors.toList());

    return list;
}

註冊一個新任務,最終返回創建的新任務的id

public Integer addJobInfo(XxlJobInfo xxlJobInfo) {
    String url=adminAddresses+"/jobinfo/add";
    Map<String, Object> paramMap = BeanUtil.beanToMap(xxlJobInfo);
    HttpResponse response = HttpRequest.post(url)
            .form(paramMap)
            .cookie(jobLoginService.getCookie())
            .execute();

    JSON json = JSONUtil.parse(response.body());
    Object code = json.getByPath("code");
    if (code.equals(200)){
        return Convert.toInt(json.getByPath("content"));
    }
    throw new RuntimeException("add jobInfo error!");
}

2、創建新註解

在創建任務時,必填欄位除了執行器和jobHandler之外,還有任務描述負責人Cron表達式調度類型運行模式。在這裡,我們默認調度類型為CRON、運行模式為BEAN,另外的3個欄位的資訊需要用戶指定。

因此我們需要創建一個新註解@XxlRegister,來配合原生的@XxlJob註解進行使用,填寫這幾個欄位的資訊:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
public @interface XxlRegister {
    String cron();
    String jobDesc() default "default jobDesc";
    String author() default "default Author";
    int triggerStatus() default 0;
}

最後,額外添加了一個triggerStatus屬性,表示任務的默認調度狀態,0為停止狀態,1為運行狀態。

3、自動註冊核心

基本準備工作做完後,下面實現自動註冊執行器和jobHandler的核心程式碼。核心類實現ApplicationListener介面,在接收到ApplicationReadyEvent事件後開始執行自動註冊邏輯。

@Component
public class XxlJobAutoRegister implements ApplicationListener<ApplicationReadyEvent>, 
        ApplicationContextAware {
    private static final Log log =LogFactory.get();
    private ApplicationContext applicationContext;
    @Autowired
    private JobGroupService jobGroupService;
    @Autowired
    private JobInfoService jobInfoService;

    @Override
    public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
        this.applicationContext=applicationContext;
    }

    @Override
    public void onApplicationEvent(ApplicationReadyEvent event) {
        addJobGroup();//註冊執行器
        addJobInfo();//註冊任務
    }
}

自動註冊執行器的程式碼非常簡單,根據配置文件中的appNametitle精確匹配查看調度中心是否已有執行器被註冊過了,如果存在則跳過,不存在則新註冊一個:

private void addJobGroup() {
    if (jobGroupService.preciselyCheck())
        return;

    if(jobGroupService.autoRegisterGroup())
        log.info("auto register xxl-job group success!");
}

自動註冊任務的邏輯則相對複雜一些,需要完成:

  • 通過applicationContext拿到spring容器中的所有bean,再拿到這些bean中所有添加了@XxlJob註解的方法
  • 對上面獲取到的方法進行檢查,是否添加了我們自定義的@XxlRegister註解,如果沒有則跳過,不進行自動註冊
  • 對同時添加了@XxlJob@XxlRegister的方法,通過執行器id和jobHandler的值判斷是否已經在調度中心註冊過了,如果已存在則跳過
  • 對於滿足註解條件且沒有註冊過的jobHandler,調用介面註冊到調度中心

具體程式碼如下:

private void addJobInfo() {
    List<XxlJobGroup> jobGroups = jobGroupService.getJobGroup();
    XxlJobGroup xxlJobGroup = jobGroups.get(0);

    String[] beanDefinitionNames = applicationContext.getBeanNamesForType(Object.class, false, true);
    for (String beanDefinitionName : beanDefinitionNames) {
        Object bean = applicationContext.getBean(beanDefinitionName);

        Map<Method, XxlJob> annotatedMethods  = MethodIntrospector.selectMethods(bean.getClass(),
                new MethodIntrospector.MetadataLookup<XxlJob>() {
                    @Override
                    public XxlJob inspect(Method method) {
                        return AnnotatedElementUtils.findMergedAnnotation(method, XxlJob.class);
                    }
                });
        for (Map.Entry<Method, XxlJob> methodXxlJobEntry : annotatedMethods.entrySet()) {
            Method executeMethod = methodXxlJobEntry.getKey();
            XxlJob xxlJob = methodXxlJobEntry.getValue();

            //自動註冊
            if (executeMethod.isAnnotationPresent(XxlRegister.class)) {
                XxlRegister xxlRegister = executeMethod.getAnnotation(XxlRegister.class);
                List<XxlJobInfo> jobInfo = jobInfoService.getJobInfo(xxlJobGroup.getId(), xxlJob.value());
                if (!jobInfo.isEmpty()){
                    //因為是模糊查詢,需要再判斷一次
                    Optional<XxlJobInfo> first = jobInfo.stream()
                            .filter(xxlJobInfo -> xxlJobInfo.getExecutorHandler().equals(xxlJob.value()))
                            .findFirst();
                    if (first.isPresent())
                        continue;
                }

                XxlJobInfo xxlJobInfo = createXxlJobInfo(xxlJobGroup, xxlJob, xxlRegister);
                Integer jobInfoId = jobInfoService.addJobInfo(xxlJobInfo);
            }
        }
    }
}

4、自動裝配

創建一個配置類,用於掃描bean

@Configuration
@ComponentScan(basePackages = "com.xxl.job.plus.executor")
public class XxlJobPlusConfig {
}

將它添加到META-INF/spring.factories文件:

org.springframework.boot.autoconfigure.EnableAutoConfiguration=\
  com.xxl.job.plus.executor.config.XxlJobPlusConfig

到這裡starter的編寫就完成了,可以通過maven發布jar包到本地或者私服:

mvn clean install/deploy

測試

新建一個springboot項目,引入我們在上面打好的包:

<dependency>
    <groupId>com.cn.hydra</groupId>
    <artifactId>xxljob-autoregister-spring-boot-starter</artifactId>
    <version>0.0.1</version>
</dependency>

application.properties中配置xxl-job的資訊,首先是原生的配置內容:

xxl.job.admin.addresses=//127.0.0.1:8080/xxl-job-admin
xxl.job.accessToken=default_token
xxl.job.executor.appname=xxl-job-executor-test
xxl.job.executor.address=
xxl.job.executor.ip=127.0.0.1
xxl.job.executor.port=9999
xxl.job.executor.logpath=/data/applogs/xxl-job/jobhandler
xxl.job.executor.logretentiondays=30

此外還要額外添加我們自己的starter要求的新配置內容:

# admin用戶名
xxl.job.admin.username=admin
# admin 密碼
xxl.job.admin.password=123456
# 執行器名稱
xxl.job.executor.title=test-title

完成後在程式碼中配置一下XxlJobSpringExecutor,然後在測試介面上添加原生@XxlJob註解和我們自定義的@XxlRegister註解:

@XxlJob(value = "testJob")
@XxlRegister(cron = "0 0 0 * * ? *",
        author = "hydra",
        jobDesc = "測試job")
public void testJob(){
    System.out.println("#公眾號:碼農參上");
}


@XxlJob(value = "testJob222")
@XxlRegister(cron = "59 1-2 0 * * ?",
        triggerStatus = 1)
public void testJob2(){
    System.out.println("#作者:Hydra");
}

@XxlJob(value = "testJob444")
@XxlRegister(cron = "59 59 23 * * ?")
public void testJob4(){
    System.out.println("hello xxl job");
}

啟動項目,可以看到執行器自動註冊成功:

再打開調度中心的任務管理頁面,可以看到同時添加了兩個註解的任務也已經自動完成了註冊:

從頁面上手動執行任務進行測試,可以執行成功:

到這裡,starter的編寫和測試過程就算基本完成了,項目中引入後,以後也能省出更多的時間來摸魚學習了~

最後

項目的完整程式碼已經傳到了我的github上,小夥伴們如果有需要的可以自行下載。公眾號【碼農參上】後台回復【xxl】獲取項目git地址,也歡迎來給我點個star支援一下~

那麼,這次的分享就到這裡,我是Hydra,我們下篇再見。

作者簡介,碼農參上,一個熱愛分享的公眾號,有趣、深入、直接,與你聊聊技術。歡迎添加好友,進一步交流。