# SpringBoot + Quartz持久化筆記 練習專案要用到排程功能,因為之前工作上有用到quartz, 就沒有考慮簡單使用spring scheduler,練習看看從頭開始配置quartz。 簡單說下我的理解,quartz就是一個任務調度的套件, 其中有幾個核心: * Scheduler – 主要核心,用於調度操作所有排程 * Job – 透過scheduler執行任務,任務class要實現的interface * JobDetail – 指定要運行的任務class * Trigger – 定義觸發任務方式或頻率 Scheduler負責調度所有任務,jobDetail指定要運行的任務類, 並且jobDetail和trigger配對為一組接受調度 ## 相關配置 ### 預設表 任務調度的儲存有兩種模式 1. RAMJobStore: 暫存在JVM內存,不依靠外部資料庫所以速度快,但是重啟就會丟失 2. jdbcjobstore: 持久化儲存,依賴外部資料庫,做持久化不怕丟失,但速度相對慢 這邊只說明持久化流程 要做持久化必須手動運行官方腳本創建預設table,腳本位置在這 ![image](https://hackmd.io/_uploads/SklORgFaA.png) 抓想要使用的db類型腳本執行,像我是使用spring.sql.init.schema-locations的方式自動運行腳本,主要想法是為了方便用git統一管理 ==然後又因為原先腳本語法為IF EXISTS就DROP再重新CREATE TABLE,不想要舊資料被洗掉, 就手動改腳本語法變成IF NOT EXISTS再CREATE TABLE== 然後這邊大致提一下有用到的預設表以及其功能: ![image](https://hackmd.io/_uploads/HJ3hnGYT0.png) ### 依賴 有了預設表之後,schdule的默認動作應該就可以基於這些表正常執行,接下來就是程式碼配置 一樣先引入dependency,資料庫依賴和基本的springboot依賴就不提了 ```java= //就Quartz <dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-quartz</artifactId> </dependency> ``` ### Properties配置 然後是properties配置,有兩個部分 ```java= //quartz.properties quartz相關配置 //指定調度器識別名稱,可以用於集群(相同名稱) org.quartz.scheduler.instanceName=ErpScheduler //指定Quartz執行序池大小(同時可倂行執行的任務數量,超出就是排隊) org.quartz.threadPool.threadCount=10 //執行優先級,1(最低)到10(最高) org.quartz.threadPool.threadPriority=5 //配置資料庫持久化 org.quartz.jobStore.class=org.quartz.impl.jdbcjobstore.JobStoreTX org.quartz.jobStore.driverDelegateClass=org.quartz.impl.jdbcjobstore.StdJDBCDelegate org.quartz.jobStore.dataSource=quartzDataSource //設定可忍受的超時臨界值(Misfires)可以看最後的說明 org.quartz.jobStore.misfireThreshold=100000 spring.quartz.job-store-type=jdbc //quartz不自動初始化quartz相關資料表,後面會配置給spring做自動調度執行腳本 spring.quartz.jdbc.initialize-schema=never ``` ```java= //application.properties 這邊只列相關的 //開啟執行sql腳本初始化 spring.sql.init.mode=always //初始化的(排程)資料,按須使用 spring.sql.init.data-locations=classpath:sql/scheduler-data.sql //放上剛剛的官方腳本讓spring執行 spring.sql.init.schema-locations=classpath:sql/tables_sqlServer.sql //先讓JPA數據加載完再執行腳本,避免有外鍵連接問題 spring.jpa.defer-datasource-initialization=true ``` 但其實不寫properties也行,加載文件的方式有很多種,也可以java code處理就好 ### 程式碼配置(Schedule) 這邊是用建bean的方式建schedule,方便後續其他地方調用 ```java= @Configuration public class QuartzConfig { LogFactory LOG = new LogFactory(QuartzConfig.class); private final ApplicationContext applicationContext; private final DataSource quartzDataSource; private final QuartzJobService quartzJobService; @Autowired public QuartzConfig(ApplicationContext applicationContext, DataSource quartzDataSource, QuartzJobService quartzJobService) { this.applicationContext = applicationContext; this.quartzDataSource = quartzDataSource; this.quartzJobService = quartzJobService; } @Bean public SchedulerFactoryBean schedulerFactoryBean() { //Spring 和 Quartz 集成的核心類 SchedulerFactoryBean schedulerFactoryBean = new SchedulerFactoryBean(); //配置ApplicationContext,可以讓quartz調用bean schedulerFactoryBean.setApplicationContext(applicationContext); //SpringBeanJobFactory確保Quartz的Job可以從Spring容器中拿 schedulerFactoryBean.setJobFactory(jobFactory()); //要持久化,配置專案資料源 schedulerFactoryBean.setDataSource(quartzDataSource); try{ //schedule先做初始化,讀取預設表中已配置的調度任務 schedulerFactoryBean.afterPropertiesSet(); //(可省略)設置triggers,這部份另外講,看下面※ schedulerFactoryBean.setTriggers(getNewTrigger(schedulerFactoryBean.getScheduler())); LOG.info("init quartz scheduler"); }catch (SchedulerException | ParseException e){ LOG.error("排程檢查發生錯誤,{0}", e.getMessage()); }catch (ClassNotFoundException e){ LOG.error("類名轉換發生錯誤,{0}", e.getMessage()); }catch (Exception e) { LOG.error("排程發生未知錯誤,{0}", e.getMessage()); } return schedulerFactoryBean; } private Trigger[] getNewTrigger(Scheduler scheduler) throws SchedulerException, ClassNotFoundException, ParseException { List<QuartzJobModel> allModel = quartzJobService.findAll();//所有現有任務 List<Trigger> triggerList = new ArrayList<>(); for (QuartzJobModel model : allModel) { //如果還沒註冊進Quartz預設表,就加入 if(!scheduler.checkExists(new TriggerKey(model.getName(), model.getGroupName()))){ //createTrigger手動建立trigger,可以往下看Trigger配置說明 triggerList.add(quartzJobService.createTrigger(model).getObject()); } } return triggerList.toArray(new Trigger[0]); } private JobFactory jobFactory() { return new SpringBeanJobFactory(); } //建bean,方便後續調用 @Bean public Scheduler scheduler() { return schedulerFactoryBean().getObject(); } } ``` --- #### ※設計問題 **正常來說要啟動schedule,做到schedulerFactoryBean.afterPropertiesSet()初始化就可以了,但這邊練習專案因為操作設計的關係,希望是可以做到由前端經由API控制排程CRUD,但又不想去主動操作預設表,所以又另外建立了一個TABLE,做Entity呈現自己想要展示的所有內容,QuartzJobService的設計就是除了使用[schedule提供的API](##Schdule操作)來操作之外,還會額外操作自己建立的新表,這邊做的就是檢查自定義表中的所有任務是否都有註冊進Quartz預設表,如果沒有就同步加入** --- ### 程式碼配置(Job) 上面配置完用來操作功能的schedule,接著就來配置待執行的任務(Job) 簡單來說就是創建一個Class,並且實作==org.quartz.Job==的==execute()== 方法 當被調用時就會執行execute內的cdoe ```java= @Component//按需使用 @NoArgsConstructor//按需使用 public class TestJob implements Job { LogFactory LOG = new LogFactory(TestJob.class); @Override public void execute(JobExecutionContext jobExecutionContext) { LOG.warn("test success!"); } } ``` 其中要注意幾件事 * Quartz要求,Job必須要有無參建構子,如果沒有其他constructure不寫@NoArgsConstructor也沒關係,是擔心會有遺漏所以統一這樣寫 * 這邊會寫@Component是因為希望Job的生成交由Spring,因為會有較複雜的任務內容需要調用其他spring bean,如果只是簡單任務不寫也沒關係 ### Trigger配置 上面提到的[專案設計問題](####※設計問題),這邊的Trigger會生成的時機只有兩個,一個是專案啟動時比對資料庫內自訂表是否有新的任務,或是前端用戶呼叫API新增排程,所以設計上Trigger創建都是由參數帶入,就沒有做什麼配置,只是寫了一個方法對照自定義的Entity做Trigger創建,然後Trigger種類繁多,==這邊基本上全部都是用Cron Trigger== 先看自定義Entity: ```java= public class QuartzJobModel { private long id; private String name;//Identity private String groupName;//Identity private String cron;//執行頻率 private String param;//如果需要參數的暫訂欄位 private String info;//備註 private String classPath;//待執行Job的路徑 private boolean status = false;//預設為暫停狀態 } ``` 其實就是排程需要的幾種屬性,由外部帶入或是資料庫取出 再來就是建立一個完整的(CronTrigger + JobDetail) ```java= //Class.forName型別警告,忽視 @SuppressWarnings("unchecked") public CronTriggerFactoryBean createTrigger(QuartzJobModel model) throws ClassNotFoundException, ParseException { Class<? extends Job> jobClass; jobClass = (Class<? extends Job>) Class.forName(model.getClassPath()); //使用CronTriggerFactoryBean創建一個完整CronTrigger CronTriggerFactoryBean trigger = new CronTriggerFactoryBean(); //創建JobDetail JobDetailFactoryBean jobDetailFactoryBean = new JobDetailFactoryBean(); //設置待執行任務相關屬性 jobDetailFactoryBean.setJobClass(jobClass); jobDetailFactoryBean.setName(model.getName()); jobDetailFactoryBean.setGroup(model.getGroupName( )); //設置持久化,可以獨立於Trigger存在,對應Trigger被刪除JobDetail還是會存於資料庫 jobDetailFactoryBean.setDurability(true); //設置完就初始化實例 jobDetailFactoryBean.afterPropertiesSet();// if (jobDetailFactoryBean.getObject() != null) { //把剛剛初始化的JobDetail設置給trigger trigger.setJobDetail(Objects.requireNonNull(jobDetailFactoryBean.getObject())); //設置錯過觸發點不補執行 //持久化會記錄觸發時間在資料庫中,假設trigger執行頻率是10秒一次, //如果關閉專案或是出錯持續一分鐘,那下次運行檢查就會直接執行6次(60s/10s),所以設置成不補執行 trigger.setMisfireInstruction(CronTrigger.MISFIRE_INSTRUCTION_DO_NOTHING); //設置觸發時間,格是為Cron表達式 trigger.setCronExpression(model.getCron()); //設置Identity trigger.setName(model.getName()); trigger.setGroup(model.getGroupName()); } try { //設置完就初始化Trigger trigger.afterPropertiesSet(); } catch (ParseException e) { LOG.error("Quartz Trigger執行錯誤:{0}", e.getMessage()); throw e; } return trigger; } ``` 這樣就能建立一個完整的待調用Trigger包含任務,後續只要設置給Schdule,就可以透過Identity去調用Schedule觸發任務,接著往下看Schedule怎麼操作 ## Schdule操作 上面都配置完了,就可以透過bean拿到當前schedule去操作看是要啟用停用還是新增刪除,這邊以自己配置的service為例,其中有些quartzJobRepository操作是針對自定義表,QuartzJobRequest代表外部接收參數 ```java= @Service @Transactional public class QuartzJobService { private Scheduler scheduler; private QuartzJobRepository quartzJobRepository; @Autowired public void setQuartzJobRepository(QuartzJobRepository quartzJobRepository) { this.quartzJobRepository = quartzJobRepository; } @Autowired//bean拿schedule public void setScheduler(Scheduler scheduler) { this.scheduler = scheduler; } //如果要展示就是從自定義表返回 public List<QuartzJobModel> findAll() { return quartzJobRepository.findAll(); } public void add(QuartzJobRequest request) throws ClassNotFoundException, SchedulerException, ParseException { //存進自定義表 quartzJobRepository.save(model); //用剛剛的createTrigger方法,把外部傳入參數變成CronTriggerFactoryBean CronTriggerFactoryBean trigger = createTrigger(request.toModel); AddQuartzJob(trigger); //如果設置狀態為false就先暫停 if (!model.isStatus()) { //scheduler.pauseJob(JobKey) 暫停排程任務 scheduler.pauseJob(Objects.requireNonNull(trigger.getObject()).getJobKey()); } } //把新增的任務加到現有排程器內 private void AddQuartzJob(CronTriggerFactoryBean trigger) throws SchedulerException { //scheduler.scheduleJob(jobDetail, trigger) 新增排程任務 scheduler.scheduleJob((JobDetail) trigger.getJobDataMap().get("jobDetail"), trigger.getObject()); } public ResponseEntity<ApiResponse> update(QuartzJobRequest request) throws ClassNotFoundException, SchedulerException, ParseException { ResponseEntity<ApiResponse> response = ApiResponse.success(ApiResponseCode.SUCCESS); //更新自定義表 quartzJobRepository.update(request.toModel()); CronTrigger trigger = createTrigger(request.toModel()).getObject(); if (trigger == null) throw new SchedulerException(); //更新現有排程器內的任務內容 //scheduler.rescheduleJob(triggerKey, trigger) 更新排程 scheduler.rescheduleJob(trigger.getKey(), trigger); return response; } public void delete(QuartzJobRequest model) throws SchedulerException { JobKey jobKey = new JobKey(model.getName(), model.getGroupName()); scheduler.deleteJob(jobKey);//清除伺服器當前排程器內任務 //從自定義表刪除 quartzJobRepository.delete(model); } public ResponseEntity<ApiResponse> toggle(QuartzJobRequest model) throws SchedulerException { boolean status = model.isStatus(); JobKey jobKey = new JobKey(model.getName(), model.getGroupName()); if (status) { scheduler.resumeJob(jobKey); } else { scheduler.pauseJob(jobKey); } return ApiResponse.success(ApiResponseCode.SUCCESS); } public void exec(QuartzJobRequest model) throws SchedulerException { //單次觸發scheduler.triggerJob(triggerKey) scheduler.triggerJob(new JobKey(model.getName(), model.getGroupName())); } } ``` * **新增:** scheduler.scheduleJob(jobDetail, trigger) 註冊到 Quartz 調度器中,並開始按 Trigger 的定義排程執行 Job CronTriggerFactoryBean初始化時會把參數放入JobDataMap,要取出的默認字樣是"jobDetail"(官方源碼)![image](https://hackmd.io/_uploads/B10UwHK60.png) * **暫停:** scheduler.pauseJob(JobKey) JobKey就是JobName+JobGroup(官方源碼) ![image](https://hackmd.io/_uploads/Sy9qLHYp0.png) * **啟動:** scheduler.resumeJob(jobKey) 同上,啟動排程 * **更新:** scheduler.rescheduleJob(triggerKey, trigger) 邏輯是依照triggerKey去把新的trigger覆蓋掉舊的 * **刪除:** scheduler.deleteJob(jobKey) Quartz刪除的部分有分幾種方式 scheduler.unscheduleJob(triggerKey):刪除指定的Trigger,但保留對應JobDetail scheduler.deleteJob(jobKey):刪除指定的JobDetail及其對應Trigger * **單次觸發:** scheduler.triggerJob(triggerKey) 立即觸發一次對應排程執行Job.execute();不影響原先排程 還有其他Api這邊沒用到,大概說明 * scheduler.getJobDetail(jobKey): 獲取指定 JobKey 的 JobDetail。 * scheduler.getTrigger(triggerKey): 獲取指定 TriggerKey 的 Trigger。 * scheduler.getTriggersOfJob(jobKey): 獲取所有與指定 JobKey 關聯的 Trigger。 * scheduler.pauseTrigger(triggerKey): 暫停指定的 Trigger。Job 不會執行,但 Trigger 的設定保留在調度器中。 * scheduler.resumeTrigger(triggerKey): 恢復之前被暫停的 Trigger,使 Job 可以重新根據 Trigger 執行。 * scheduler.checkExists(jobKey): 檢查指定的 JobKey 是否存在於調度器中。 * scheduler.checkExists(triggerKey): 檢查指定的 TriggerKey 是否存在於調度器中。 * scheduler.getSchedulerState(): 獲取調度器的當前狀態(如 STARTED、PAUSED 等)。 * scheduler.clear(): 清除調度器中的所有作業和觸發器。這個方法會刪除所有的 JobDetail 和 Trigger。 ## 其他 以上的做法很單一,其實有很多種取代方式,像是Bean也可以寫xml直接配上trigger讓schedule讀取,可以參考[spring+quartz, 使用xml配置的方式](https://blog.csdn.net/Evankaka/article/details/45365051?ops_request_misc=%257B%2522request%255Fid%2522%253A%25221F26D4A2-1C88-4195-9FA4-DB154CD18FB5%2522%252C%2522scm%2522%253A%252220140713.130102334.pc%255Fblog.%2522%257D&request_id=1F26D4A2-1C88-4195-9FA4-DB154CD18FB5&biz_id=0&utm_medium=distribute.pc_search_result.none-task-blog-2~blog~first_rank_ecpm_v1~rank_v31_ecpm-7-45365051-null-null.nonecase&utm_term=quartz) 然後JobDetail創建方式除了JobDetailFactoryBean也可以用JobBuilder * JobBuilder: 更適合需要在代碼中動態創建和配置 JobDetail 的情境,並不依賴於 Spring 框架。 ```java= JobDetail jobDetail = JobBuilder.newJob(MyJob.class) .withIdentity("myJob", "group1") .withDescription("My Job Description") .build(); ``` * JobDetailFactoryBean: 提供了更好的與 Spring 框架集成的方式,讓 Quartz 作業的配置和管理可以在 Spring 配置文件中進行,並且支持依賴注入和其他 Spring 特性。 ```java= @Bean public JobDetailFactoryBean jobDetailFactoryBean() { JobDetailFactoryBean factoryBean = new JobDetailFactoryBean(); factoryBean.setJobClass(MyJob.class); factoryBean.setName("myJob"); factoryBean.setGroup("group1"); factoryBean.setDurability(true); return factoryBean; } ``` ### 有關Misfires [可以稍微看下別人解釋](https://www.cnblogs.com/pzy4447/p/5201674.html) 簡單來說就是默認閾值是60秒,超出會觸發misfires,設定trigger.setMisfireInstruction(CronTrigger.MISFIRE_INSTRUCTION_DO_NOTHING); 就不補執行,直接順延到下個執行時間 會有這個問題是因為,任務觸發時會紀錄上次運行時間,以此來計算misfires 但沒辦法判別沒有運行的原因,不管是暫時暫停觸發還是出錯,又或著是執行序池滿了在排隊,只會依照紀錄上次觸發時間判斷,如果距離上次觸發時間>60秒(默認)就會認為是misfires,就會依照設定的規則去執行錯過任務 實例來說,假設默認閾值60秒,任務觸發時間設定為1秒1次,手動暫停觸發再啟動後,會有兩種情形: * 經過50秒再手動開啟觸發: 因為未超出閾值,沒被判定為misfire,設定規則未生效,因此距離上次觸發間隔50秒,<font style="color:red;">補執行50次觸發任務(如果沒有短頻率排程倒沒關係,但有的話就是大問題,我的解決方式是[縮短misfires閾值](####縮短misfires時間))</font> * 經過60秒再手動開啟觸發: 因為超出閾值,被判定為misfire,所以依照設定規則MISFIRE_INSTRUCTION_DO_NOTHING不補執行,等待下次自動觸發 #### 縮短misfires時間 網路上看到很多方式都是從properties去做配置org.quartz.jobStore.misfireThreshold=5000 但自己實際測試都無效,==猜測可能是版本問題?我也不定== 後來找到方法是直接配置在SchedulerFactoryBean創建的地方 ![image](https://hackmd.io/_uploads/HJOyDac60.png) 新增這段,用setQuartzProperties設定時長 完美生效,讚!