# 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,腳本位置在這

抓想要使用的db類型腳本執行,像我是使用spring.sql.init.schema-locations的方式自動運行腳本,主要想法是為了方便用git統一管理
==然後又因為原先腳本語法為IF EXISTS就DROP再重新CREATE TABLE,不想要舊資料被洗掉,
就手動改腳本語法變成IF NOT EXISTS再CREATE TABLE==
然後這邊大致提一下有用到的預設表以及其功能:

### 依賴
有了預設表之後,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"(官方源碼)
* **暫停:** scheduler.pauseJob(JobKey)
JobKey就是JobName+JobGroup(官方源碼)

* **啟動:** 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創建的地方

新增這段,用setQuartzProperties設定時長
完美生效,讚!