SpringBoot實現輕量級動態定時任務管控及元件化

糖拌西红柿發表於2024-11-22

關於動態定時任務

關於在SpringBoot中使用定時任務,大部分都是直接使用SpringBoot的@Scheduled註解,如下:

@Component
public class TestTask
{
    @Scheduled(cron="0/5 * *  * * ? ")   //每5秒執行一次
    public void execute(){
        SimpleDateFormat df = new SimpleDateFormat("yyyy-MM-dd HH:mm:ss"); 
        log.info("任務執行" + df.format(new Date()));
    }
}

或者或者使用第三方的工具,例如XXL-Job等。就XXL-Job而言,如果說是大型專案,硬體資源和專案環境都具備,XXL-Job確實是最佳的選擇,可是對於專案體量不大,又不想過多的引入外掛;使用XXL-Job多少有點“殺雞用牛刀”的意思;

之所以這樣說,是因為在SpringBoot中整合使用XXL-Job的步驟如下:

  1. 引入依賴,配置執行器Bean
  2. 在自己專案中編寫定時任務程式碼
  3. 部署XXL-Job
  4. 登入XXL-Job排程中心的 Web 控制檯,建立一個新的任務,選擇剛才配置的執行器和任務處理器,設定好觸發條件(如 Cron 表示式)和其他選項後儲存
  5. 生產環境下,還要把配置好任務的XXL-Job和專案一起打包
@Component
public class SampleXxlJob {

    @XxlJob("sampleJobHandler")
    public void sampleJobHandler() throws Exception {
        // 業務邏輯
        System.out.println("Hello XXL-JOB!");
    }
}

這一套步驟在中小型專案中,明顯成本大於效果,而使用XXL-Job無非就是想動態的去管理定時任務,可以在執行狀態下隨意的執行、中斷、調整執行週期、檢視執行結果,而不是像基於@Scheduled註解實現後,無法改變。

所以,這裡就基於SpringBoot實現動態排程定時任務,之前針對這個問題,我寫過一篇CSDN文章(連線放在下方),最近博主將相關的程式碼進行了彙總,並且在易用性和擴充套件性上進行了加強,基於COLA架構理論,封裝到了元件層

這次加強主要包括:

  1. 剝離了任務的持久化,使其依賴更簡潔,真正以starter的形式開箱即用
  2. 擴充套件了方法級的定時任務註冊,能夠像xxl-job一樣,一個註解搞定動態定時任務的註冊及管理

動態定時任務實現思路

關於動態定時任務的核心思路是反射+定時任務模板,這兩部分的核心框架不變,相關內容可以回顧我之前的部落格:

輕量級動態定時任務排程

這裡主要是針對強化的第二點進行思路解釋,第二點的強化是加入了類掃描機制,透過掃描,實現了自動註冊,規避了之前每新增一個定時任務都必須得預製SQL的步驟:

類級別定時任務實現思路:在原模板模式的基礎下,基於AbstractBaseCronTask類自定義的定時任務子類作為類級別定時任務,即一個類為一個定時任務,初始時由包掃描所有的子類,並使用反射將其例項化,逐一加入到程序管理中,並啟用定時排程。

基於@MethodJob的方法級別任務實現思路:以 AbstractBaseCronTask類為基礎,定義一個固定的子類BaseMethodLevelTask並在其內部限定任務的執行方式掃描所有標註了@MethodJob的方法及其所屬的Bean,連同Bean及方法的反射類作為建構函式,生成BaseMethodLevelTask物件因為BaseMethodLevelTask也是AbstractBaseCronTask的子類,則可以以類級別定時任務的方式,將其生成定時任務,並進行管理。

本質還是管理的AbstractBaseCronTask子類線上程池中的具體物件,不同的地方是類級別定時任務是一個具體的任務類僅生成一個物件,class路徑即是唯一的標識,而方法級別的定時任務均基於BaseMethodLevelTask生成無數個物件,具體標識則是建構函式傳入的Bean的反射物件和方法名。

對此部分感興趣的可以一起參與開發,該專案地址:Gitee原始碼,主要為其中task-component模組。

元件使用方法

根據Git地址,將原始碼down下,編譯安裝到本地私倉中,以Maven的形式引入即可,該元件遵循Spring-starter規範,開箱即用。

        <dependency>
            <groupId>com.gcc.container.components</groupId>
            <artifactId>task-component</artifactId>
            <version>1.0.0</version>
        </dependency>

Yaml配置說明

給出一個yaml模板:

gcc-task:
  task-class-package : *
  data-file-path: /opt/myproject/task_info.json

對於task-component的配置只有兩個,task-class-packagedata-file-path

屬性 說明 是否必須 預設值
task-class-package

指定定時任務存放的包路徑,不填寫或無此屬性則預設全專案掃描,填寫後可有效減少初始化時間

*
data-file-path

定時任務管理的資料存放檔案及其路徑,預設不填寫則存放專案執行目錄下,如自行擴充套件實現,則該配置自動失效

class:db/task_info.json

此處的兩個配置項都包含預設值,所以不進行yml的gcc-task仍舊可以使用該元件

新建定時任務

對於該元件在進行定時任務的建立時,有兩種,分別是類級定時任務和方法級定時任務,兩者的區別在於是以類為基本的單位還是以方法為一個定時任務的單位,對於執行效果則並無區別,根據個人喜好,選擇使用哪一種即可

類級定時任務

新增一個定時任務邏輯,則需要實現基類AbstractBaseCronTask ,並加入註解 @ClassJob

ClassJob的引數如下

引數說明樣例
cron 定時任務預設的執行週期,僅在首次初始化該任務使用(必填 10 0/2 * * * ?
desc 任務描述,非必填 這是測試任務
bootup 是否開機自啟動,預設值為 false false

cron 屬性僅在第一次新增該任務時提供一個預設的執行週期,必須填寫,後續任務載入後,定時任務相關資料會被存放在檔案或資料庫中,此時則以檔案或資料庫中該任務的cron為主,程式碼中的註解則不會生效,如果想重置,則刪除已經持久化的任務即可。

一個完整的Demo如下:

@TaskJob(cron = "10 0/2 * * * ?" ,desc = "這是一個測試任務",bootup = true)
public class TaskMysqlOne extends AbstractBaseCronTask {

    public TaskMysqlOne(TaskEntity taskEntity) {
        super(taskEntity);
    }

    @Override
    public void beforeJob() {
    }

    @Override
    public void startJob() {
    }

    @Override
    public void afterJob() {
    }
}

繼承AbstractBaseCronTask 必須要實現攜帶TaskEntity引數的建構函式beforeJob()startJob()afterJob() 三個方法即可。原則上這三個方法是規範定時任務的執行,實際使用,只需要把核心邏輯放在三個方法中任何一個即可

因定時任務類是非SpringBean管理的類,所以在自定義的定時任務類內無法使用任何Spring相關的註解(如@Autowired)但是卻可以透過自帶的getServer(Class<T> className)方法來獲取任何Spring上下文中的Bean

例如,你有一個UserService的介面及其Impl的實現類,想在定時任務類中使用該Bean,則可以:

@TaskJob(cron = "10 0/2 * * * ?" ,desc = "這是一個測試任務",bootup = true)
public class TaskMysqlOne extends AbstractBaseCronTask {

    public TaskMysqlOne(TaskEntity taskEntity) {
        super(taskEntity);
    }

    @Override
    public void beforeJob() {
    }

    @Override
    public void startJob() {
        List<String> names = getServer(UserService.class).searchAllUserName();
        //後續邏輯……
        //其他邏輯
    }

    @Override
    public void afterJob() {

    }
}

方法級定時任務

如果不想新建類,或者不想受限於AbstractBaseCronTask的束縛,則可以像xxl-job定義定時任務一樣,直接在某個方法上標註@MethodJob註解即可。

@MethodJob的引數如下:

引數說明樣例
cron 定時任務預設的執行週期,僅在首次初始化該任務使用(必填 10 0/2 * * * ?
desc 任務描述,非必填 這是測試任務
bootup 是否開機自啟動,預設值為 false false

通ClassJob一樣,cron 屬性僅在第一次新增該任務時提供一個預設的執行週期,必須填寫,後續任務載入後,定時任務相關資料會被存放在檔案或資料庫中,此時則以檔案或資料庫中該任務的cron為主,程式碼中的註解則不會生效,如果想重置,則刪除已經持久化的任務即可。

下面是一個例子:

//正常定義的Service介面
public interface AsyncTestService {
    void  taskJob();

}

//Service介面實現類
@Service
public class AsyncTestServiceImpl implements AsyncTestService {

    @MethodJob(cron = "11 0/1 * * * ?",desc = "這是個方法級任務")
    @Override
    public void taskJob() {
        log.info("方法級別任務查詢關鍵字為企業的資料");
        QueryWrapper<ArticleEntity> query = new QueryWrapper<>();
        query.like("art_name","企業");
        List<ArticleEntity> data = articleMapper.selectList(query);
        log.info("查出條數為{}",data.size());
    }
}

注:該註解僅支援SpringBoot中標註為@Component@Service@Repository的Bean

動態排程任務介面說明

定時任務相關的管理操作均封裝在TaskScheduleManagerService介面中,介面內容如下:

public interface TaskScheduleManagerService {
    /**
     * 查詢在用任務
     * @return
     */
    List<TaskVo> searchTask(SearchTaskDto dto);
    /**
     * 查詢任務詳情
     * @param taskId 任務id
     * @return TaskEntity
     */
    TaskVo searchTaskDetail(String taskId);
    /**
     * 執行指定任務
     * @param taskId 任務id
     * @return TaskRunRetDto
     */
    TaskRunRetVo runTask(String taskId);
    /**
     * 關停任務
     * @param taskId 任務id
     * @return TaskRunRetDto
     */
    TaskRunRetVo shutdownTask(String taskId);
    /**
     * 開啟任務
     * @param taskId 任務id
     * @return TaskRunRetDto
     */
    TaskRunRetVo openTask(String taskId);
    /**
     * 更新任務資訊
     * @param entity 實體
     * @return TaskRunRetDto
     */
    TaskRunRetVo updateTaskBusinessInfo(TaskEntity entity);
}

可直接在外部專案中注入使用即可:

@RestController
@RequestMapping("/myself/manage")
public class TaskSchedulingController {
    
    //引入依賴後可直接使用注入
    @Autowired
    private TaskScheduleManagerService taskScheduleManagerService;

    @GetMapping("/detail")
    @Operation(summary = "具體任務物件")
    public Response searchDetail(String taskId){
        return Response.success(taskScheduleManagerService.searchTaskDetail(taskId));
    }

    @GetMapping("/shutdown")
    @Operation(summary = "關閉指定任務")
    public Response shutdownTask(String taskId){
        return Response.success(taskScheduleManagerService.shutdownTask(taskId));
    }

    @GetMapping("/open")
    @Operation(summary = "開啟指定任務")
    public Response openTask(String taskId){
        return Response.success(taskScheduleManagerService.openTask(taskId));
    }
}

介面效果:

可使用該介面,進行UI頁面開發。

關於任務持久化的擴充套件

在實現思路中提到過,task-component的執行原理需要將註冊後的任務持久化,下次再啟動專案時,則直接使用持久化的TaskEntity來載入定時任務。

考慮到定時任務的數量不大,且對於互動要求不高,另外考慮封裝成元件的獨立性普適性,不想額外引入資料庫依賴和ORM框架,所以元件預設的是以JSON檔案的形式進行儲存,實際使用中,考慮到便利性,可以自行對持久化部分進行擴充套件。

所謂持久化,其實本制是持久化TaskEntity物件,TaskEntity物件如下:

@Data
public class TaskEntity implements Serializable {

    /**
    * 任務ID(唯一)
    */
    private String taskId;

    /**
    * 任務名稱
    */
    private String taskName;

    /**
    * 任務描述
    */
    private String taskDesc;

    /**
    * 遵循cron 表示式
    */
    private String taskCron;

    /**
     * 類路徑
     */
    private String taskClass;

     /**
     * 任務級別  CLASS_LEVEL 類級別,METHOD_LEVEL 方法級別
     */
    private TaskLevel taskLevel;

    /**
     * 任務註冊時間
     */
    private String taskCreateTime;
    /**
     * 是否啟用,1啟用,0不啟用
     */
    private Integer taskIsUse;

    /**
     * 是否系統啟動後立刻執行 1是。0否
     */
    private Integer taskBootUp;

    /**
     * 上次執行狀態 1:成功,0:失敗
     */
    private Integer taskLastRun;
    /**
     * 任務是否在記憶體中 1:是,0:否
     */
    private Integer taskRamStatus;

     /**
     * 外部配置 (擴充套件待使用欄位)
     */
    private String taskOutConfig;

    /**
     * 載入配置
     */
    private String loadConfigure;
}

擴充套件的主要操作為兩步:

  1. 新建存放taskEntity的表
  2. 實現TaskRepository介面

首先新建資料庫表,這裡以Mysql為例給出建表語句:

DROP TABLE IF EXISTS `tb_task_info`;
CREATE TABLE `tb_task_info` (
  `task_id` varchar(100) NOT NULL PRIMARY KEY ,
  `task_name` varchar(255) DEFAULT NULL,
  `task_desc` text,
  `task_cron` varchar(20) DEFAULT NULL,
  `task_class` varchar(100) DEFAULT NULL COMMENT '定時任務類路徑',
  `task_level` varchar(50) DEFAULT NULL COMMENT '任務級別:類級別(CLASS_LEVEL)、方法級別(METHOD_LEVEL)',
  `task_is_use` tinyint DEFAULT NULL COMMENT '是否啟用該任務,1:啟用,0禁用',
  `task_boot_up` tinyint DEFAULT NULL COMMENT '是否為開機即執行,1:初始化即執行,0,初始化不執行',
  `task_out_config` text CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci COMMENT '定時任務額外配置項,採用json結構存放',
  `task_create_time` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8mb3_general_ci DEFAULT NULL COMMENT '定時任務追加時間',
  `task_last_run` tinyint DEFAULT NULL COMMENT '任務上次執行狀態;1正常,0執行失敗,null未知',
  `task_ram_status` tinyint DEFAULT NULL COMMENT '任務當前狀態;1記憶體執行中,0記憶體移除',
  `loadConfigure` text  COMMENT '載入相關配置',  
PRIMARY KEY (`task_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC;

實現TaskRepository介面,介面內容為:

public interface TaskRepository {
    /**
     * 新增資料,必須保證entity每個欄位都插入
     * @param entity
     * @return int
     */
    int save(TaskEntity entity);
    /**
     * 根據TaskId刪除整條資料
     * @param id id
     * @return int
     */
    int removeByTaskId(String id);
    /**
     * 更新整個任務(除taskId外,其他欄位資料均根據實體更新)
     * @param entity 實體
     * @return int
     */
    int update(TaskEntity entity);
    /**
     * 查詢全部任務
     * @return List<TaskEntity>
     */
    List<TaskEntity> queryAllTask();

    /**
     * 查詢資料:根據實體中的欄位進行查詢
     * @param entity 查詢
     * @return TaskEntity
     */
    List<TaskEntity> queryData(TaskEntity entity);

    /**
     * 查詢具體的任務實體
     * @param taskId 任務id
     * @return TaskEntity
     */
    TaskEntity queryData(String taskId);
}

只需要新建類,然後實現該介面即可,如下是基於Mybatis-Plus進行的實現樣例:

@Mapper
public interface TaskDataMapper extends BaseMapper<TaskEntity> {
}

@Repository
@Primary
public class TaskRepositoryImpl implements TaskRepository {
    @Autowired
    private TaskDataMapper taskDataMapper;
    @Override
    public int save(TaskEntity entity) {
        entity.setTaskCreateTime(DateUtil.now());
        return taskDataMapper.insert(entity);
    }
    @Override
    public int update(TaskEntity entity) {
        UpdateWrapper<TaskEntity> update = new UpdateWrapper<>();
        update.eq("task_id",entity.getTaskId());
        return taskDataMapper.update(entity,update);
    }
    @Override
    public int removeByTaskId(String id) {
        QueryWrapper<TaskEntity> query = new QueryWrapper<>();
        query.eq("task_id",id);
        return taskDataMapper.delete(query);
    }
    @Override
    public List<TaskEntity> queryAllTask() {
        return taskDataMapper.selectList(new QueryWrapper<>());
    }
    @Override
    public TaskEntity queryData(String id) {
        QueryWrapper<TaskEntity> query = new QueryWrapper<>();
        query.eq("task_id",id);
        return taskDataMapper.selectOne(query);
    }
    @Override
    public List<TaskEntity> queryData(TaskEntity entity) {
        QueryWrapper<TaskEntity> query = new QueryWrapper<>();
        query.orderByAsc("task_create_time");
        if(null != entity) {
            if (null != entity.getTaskIsUse()) {
                query.eq("task_is_use", entity.getTaskIsUse());
            }
            if (null != entity.getTaskBootUp()) {
                query.eq("task_boot_up", entity.getTaskBootUp());
            }
            if (!StringUtils.isEmpty(entity.getTaskDesc())) {
                query.like("task_desc", entity.getTaskDesc());
            }
        }
        return taskDataMapper.selectList(query);
    }
}

至此,專案中則可以以資料庫表的形式來管理定時任務:

任務執行日誌:

[main] com.web.test.Application                 : Started Application in 3.567 seconds (JVM running for 4.561)
[main] .g.c.c.t.c.InitTaskSchedulingApplication : 【定時任務初始化】 容器初始化
[main] o.s.s.c.ThreadPoolTaskScheduler          : Initializing ExecutorService
[main] .g.c.c.t.c.InitTaskSchedulingApplication : 【定時任務初始化】定時任務初始化任務開始
[main] c.g.c.c.task.compont.TaskScheduleImpl    : 【定時任務初始化】裝填任務:TaskTwoPerson [ 任務執行週期:15 0/2 * * * ? ] [ bootup:0]
[main] c.g.c.c.task.compont.TaskScheduleImpl    : 【定時任務初始化】裝填任務:TaskMysqlOne [ 任務執行週期:10 0/2 * * * ? ] [ bootup:1]
[main] c.g.c.c.task.compont.TaskScheduleImpl    : 【定時任務初始化】裝填任務:taskJob [ 任務執行週期:11 0/1 * * * ? ] [ bootup:0]
[main] .g.c.c.t.c.InitTaskSchedulingApplication : 【定時任務初始化】定時任務初始化任務完成
[main] c.g.c.c.task.service.AfterAppStarted     : 【定時任務自執行】執行開機自啟動任務
[main] TaskMysqlOne                             : ---------------------任務 TaskMysqlOne 開始執行-----------------------
[main] TaskMysqlOne                             : 任務描述:這是一個測試任務
[main] TaskMysqlOne                             : 我是張三
[main] TaskMysqlOne                             : 任務耗時:約 0.0 s
[main] TaskMysqlOne                             : ---------------------任務 TaskMysqlOne 結束執行-----------------------

[task-thread-1] taskJob                                  : ---------------------任務 taskJob 開始執行-----------------------
[task-thread-1] taskJob                                  : 任務描述:這是個方法級任務
[task-thread-1] c.w.t.service.impl.AsyncTestServiceImpl  : 方法級別任務查詢關鍵字為企業的資料
[task-thread-1] c.w.t.service.impl.AsyncTestServiceImpl  : 查出條數為1
[task-thread-1] taskJob                                  : 任務耗時:約 8.45 s
[task-thread-1] taskJob                                  : ---------------------任務 taskJob 結束執行-----------------------

相關文章