Spring Boot支持Crontab任務改造

在以往的 Tomcat 項目中,一直習慣用 Ant 打包,即使用 build.xml 配置,通過ant -buildfile 的方式在機器上執行定時任務。雖然 Spring 本身支持定時任務,但都是服務一直運行支持。其實在項目中,大多數定時任務,還是藉助 Linux Crontab 來支持,需要時運行即可,不需要一直佔用機器資源。但是 Spring Boot 項目或者普通的 jar 項目,就沒這麼方便了。

Spring Boot 提供了類似 CommandLineRunner 的方式,很好的執行常駐任務;也可以藉助 ApplicationListener 和 ContextRefreshedEvent 等事件來做很多事情。藉助該容器事件,一樣可以做到類似 Ant 運行的方式來運行定時任務,當然需要做一些的項目改動。

1. 監聽目標對象

藉助容器刷新事件來監聽目標對象即可,可以認爲,定時任務其實每次只是執行一種操作而已。

比如這是一個寫好的例子,注意不要直接用 @Service 將其放入容器中,除非容器本身沒有其它自動運行的事件。

package com.github.zhgxun.learn.common.task;

import com.github.zhgxun.learn.common.task.annotation.ScheduleTask;
import lombok.extern.slf4j.Slf4j;
import org.springframework.boot.SpringApplication;
import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationListener;
import org.springframework.context.event.ContextRefreshedEvent;

import java.lang.reflect.InvocationTargetException;
import java.lang.reflect.Method;
import java.util.List;
import java.util.stream.Collectors;
import java.util.stream.Stream;

/**
 * 不自動加入容器, 用於區分是否屬於任務啓動, 否則放入容器中, Spring 無法選擇性執行
 * 需要根據特殊參數在啓動時注入
 * 該監聽器本身不能訪問容器變量, 如果需要訪問, 需要從上下文中獲取對象實例後方可繼續訪問實例信息
 * 如果其它類中啓動了多線程, 是無法接管異常拋出的, 需要子線程中正確處理退出操作
 * 該監聽器最好不用直接做線程操作, 子類的實現不干預
 */
@Slf4j
public class TaskApplicationListener implements ApplicationListener<ContextRefreshedEvent> {
    /**
     * 任務啓動監聽類標識, 啓動時注入
     * 即是 java -Dspring.task.class=com.github.zhgxun.learn.task.TestTask -jar learn.jar
     */
    private static final String SPRING_TASK_CLASS = "spring.task.class";

    /**
     * 支持該註解的方法個數, 目前僅一個
     * 可以理解爲控制檯一次執行一個類, 依賴的任務應該通過其它方式控制依賴
     */
    private static final int SUPPORT_METHOD_COUNT = 1;

    /**
     * 保存當前容器運行上下文
     */
    private ApplicationContext context;

    /**
     * 監聽容器刷新事件
     *
     * @param event 容器刷新事件
     */
    @Override
    @SuppressWarnings("unchecked")
    public void onApplicationEvent(ContextRefreshedEvent event) {
        context = event.getApplicationContext();
        // 不存在時可能爲正常的容器啓動運行, 無需關心
        String taskClass = System.getProperty(SPRING_TASK_CLASS);
        log.info("ScheduleTask spring task Class: {}", taskClass);
        if (taskClass != null) {
            try {
                // 獲取類字節碼文件
                Class clazz = findClass(taskClass);

                // 嘗試從內容上下文中獲取已加載的目標類對象實例, 這個類實例是已經加載到容器內的對象實例, 即可以獲取類的信息
                Object object = context.getBean(clazz);

                Method method = findMethod(object);

                log.info("start to run task Class: {}, Method: {}", taskClass, method.getName());
                invoke(method, object);
            } catch (ClassNotFoundException | IllegalAccessException | InvocationTargetException e) {
                e.printStackTrace();
            } finally {
                // 需要確保容器正常出發停止事件, 否則容器會殭屍卡死
                shutdown();
            }
        }
    }

    /**
     * 根據class路徑名稱查找類文件
     *
     * @param clazz 類名稱
     * @return 類對象
     * @throws ClassNotFoundException ClassNotFoundException
     */
    private Class findClass(String clazz) throws ClassNotFoundException {
        return Class.forName(clazz);
    }

    /**
     * 獲取目標對象中符合條件的方法
     *
     * @param object 目標對象實例
     * @return 符合條件的方法
     */
    private Method findMethod(Object object) {
        Method[] methods = object.getClass().getDeclaredMethods();
        List<Method> schedules = Stream.of(methods)
                .filter(method -> method.isAnnotationPresent(ScheduleTask.class))
                .collect(Collectors.toList());
        if (schedules.size() != SUPPORT_METHOD_COUNT) {
            throw new IllegalStateException("only one method should be annotated with @ScheduleTask, but found "
                    + schedules.size());
        }
        return schedules.get(0);
    }

    /**
     * 執行目標對象方法
     *
     * @param method 目標方法
     * @param object 目標對象實例
     * @throws IllegalAccessException    IllegalAccessException
     * @throws InvocationTargetException InvocationTargetException
     */
    private void invoke(Method method, Object object) throws IllegalAccessException, InvocationTargetException {
        method.invoke(object);
    }

    /**
     * 執行完畢退出運行容器, 並將返回值交給執行環節, 比如控制檯等
     */
    private void shutdown() {
        log.info("shutdown ...");
        System.exit(SpringApplication.exit(context));
    }
}

其實該處僅需要啓動執行即可,容器啓動完畢事件也是可以的。

2. 標識目標方法

目標方法的標識,最方便的是使用註解標註。

package com.github.zhgxun.learn.common.task.annotation;

import java.lang.annotation.Documented;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;

@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
@Documented
public @interface ScheduleTask {
}

3. 編寫任務

package com.github.zhgxun.learn.task;

import com.github.zhgxun.learn.common.task.annotation.ScheduleTask;
import com.github.zhgxun.learn.service.first.LaunchInfoService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Service;

import java.util.concurrent.TimeUnit;

@Service
@Slf4j
public class TestTask {

    @Autowired
    private LaunchInfoService launchInfoService;

    @ScheduleTask
    public void test() {
        log.info("Start task ...");
        log.info("LaunchInfoList: {}", launchInfoService.findAll());

        log.info("模擬啓動線程操作");
        for (int i = 0; i < 5; i++) {
            new MyTask(i).start();
        }

        try {
            TimeUnit.SECONDS.sleep(3);
        } catch (InterruptedException e) {
            e.printStackTrace();
        }
    }
}

class MyTask extends Thread {
    private int i;
    private int j;
    private String s;

    public MyTask(int i) {
        this.i = i;
    }

    @Override
    public void run() {
        super.run();
        System.out.println("第 " + i + " 個線程啓動..." + Thread.currentThread().getName());
        if (i == 2) {
            throw new RuntimeException("模擬運行時異常");
        }
        if (i == 3) {
            // 除數不爲0
            int a = i / j;
        }
        // 未對字符串對象賦值, 獲取長度報空指針錯誤
        if (i == 4) {
            System.out.println(s.length());
        }
    }
}

4. 啓動改造

啓動時需要做一些調整,即跟普通的啓動區分開。這也是爲什麼不要把監聽目標對象直接放入容器中的原因,在這裏顯示添加到添加容器中,這樣就不影響項目中類似 CommandLineRunner 的功能,畢竟這種功能是容器啓動完畢就能運行的。如果要改造,會涉及到很多硬編碼。

package com.github.zhgxun.learn;

import com.github.zhgxun.learn.common.task.TaskApplicationListener;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.boot.builder.SpringApplicationBuilder;

@SpringBootApplication
public class LearnApplication {

    public static void main(String[] args) {
        SpringApplicationBuilder builder = new SpringApplicationBuilder(LearnApplication.class);
        // 根據啓動注入參數判斷是否爲任務動作即可, 否則不干預啓動
        if (System.getProperty("spring.task.class") != null) {
            builder.listeners(new TaskApplicationListener()).run(args);
        } else {
            builder.run(args);
        }
    }
}

5. 啓動注入

-Dspring.task.class 即是啓動注入標識,當然這個標識不要跟默認的參數混淆,需要區分開,否則可能始終獲取到系統參數,而無法獲取用戶參數。

java -Dspring.task.class=com.github.zhgxun.learn.task.TestTask -jar target/learn.jar
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章