Spring Boot integracija Quartz-a za zakazano objavljivanje članaka u Codingmore
Uvod
U praktičnom projektu Codingmore 🐱 bila je potrebna funkcija zakazanog objavljivanja članaka, pa mi je sasvim prirodno palo na pamet rešenje Quartz - jedan star, ali još uvek robustan open-source framework za zakazivanje zadataka.

Pamtim da sam ga koristio još 2014. godine, prilikom razvoja platforme za trgovinu terminskim ugovorima; svakog dana u zoru bilo je potrebno izračunati talas transakcionih podataka i generisati dnevni izveštaj - u kombinaciji sa „Cron izrazima" (o njima je bilo reči u prethodnom odeljku) sve je radilo vrlo skladno.
Nažalost, kasnije se platforma stabilizovala, ali su nove politike dokinule trgovinu terminskim ugovorima, pa je i moja šansa za bogaćenje propala. Kad pomislim, zaista je žao, hahaha.

Vreme prolazi, a Quartz se razvio do te mere da se danas može spojiti sa Spring Boot projektom bez ikakvih šavova, pa je korišćenje još glatkije nego nekada u okviru Spring projekata.
O Quartz-u
Quartz je moćan open-source framework za zakazivanje zadataka, koji je na GitHubu skupio preko 5k zvezdica. Od pojedinačnih aplikacija do distribuiranih sistema, sve se može integrisati sa Quartz-om.

Pre nego što počnemo da koristimo Quartz, hajde da razjasnimo 4 ključna koncepta:
- Job: zadatak, odnosno konkretan sadržaj koji treba izvršiti.
- JobDetail: detalji zadatka; Job je sadržaj koji treba izvršiti, a JobDetail istovremeno sadrži strategiju i plan zakazivanja tog zadatka.
- Trigger: okidač; pomoću Cron izraza može se navesti vreme izvršenja zadatka.
- Scheduler: raspoređivač; može registrovati više JobDetail i Trigger instanci, a služi za raspoređivanje, pauziranje i brisanje zadataka.
Integracija Quartz-a
Quartz čuva zadatke na dva načina: u memoriji i u bazi podataka. Memorija se gubi nakon restarta programa, pa ćemo ovoga puta koristiti bazu radi trajnog pamćenja zadataka.
Prvi korak: u fajl pom.xml dodajemo Quartz starter.
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-quartz</artifactId>
<version>2.6.7</version>
</dependency>Drugi korak: u application.yml dodajemo konfiguraciju vezanu za Quartz; objašnjenje konfiguracije nalazi se direktno u komentarima.
spring:
quartz:
job-store-type: jdbc # podrazumevano memorija (memory); ovde koristimo bazu
wait-for-jobs-to-complete-on-shutdown: true # pri gašenju sačekaj da se zadaci završe
overwrite-existing-jobs: true # dozvoli prepisivanje postojećih zadataka
jdbc:
initialize-schema: never # da li automatski inicijalizovati Quartz šemu tabela putem SQL-a
properties: # izvorna Quartz konfiguracija
org:
quartz:
scheduler:
instanceName: scheduler # ime instance raspoređivača
instanceId: AUTO # ID instance raspoređivača se generiše automatski
# JobStore - srodna konfiguracija
jobStore:
class: org.quartz.impl.jdbcjobstore.JobStoreTX # klasa implementacije JobStore-a
driverDelegateClass: org.quartz.impl.jdbcjobstore.StdJDBCDelegate # koristi JDBC-kompatibilan drajver
tablePrefix: QRTZ_ # prefix Quartz tabela
useProperties: false # da li svojstva iz JobDataMap-a čuvati kao stringove
# Konfiguracija thread pool-a
threadPool:
threadCount: 25 # veličina thread pool-a. Podrazumevano 10.
threadPriority: 5 # prioritet niti
class: org.quartz.simpl.SimpleThreadPool # klasa implementacije thread pool-a; raspoređivaču pruža pool fiksne veličineQuartz podrazumevano čuva zadatke u memoriji; radi trajnosti ovde prelazimo na JDBC i navodimo spring.quartz.jdbc.initialize-schema=never, čime ćemo tabele kreirati ručno. Druga dva moguća izbora - ALWAYS i EMBEDDED - ne odgovaraju našim potrebama:
- ALWAYS: inicijalizuje svaki put
- EMBEDDED: inicijalizuje samo ugrađene baze, kao što su H2, HSQL
Gde da nađemo SQL naredbe za ručno kreiranje tabela?
GitHub adresa: https://github.com/quartz-scheduler/quartz/tree/master/quartz-core/src/main/resources/org/quartz/impl/jdbcjobstore
Da bi ih drugari lakše preuzeli, stavio sam ih u izvorni kod ovog tutorijala:

Ako koristiš IntelliJ IDEA Ultimate (flagship), pri prvom otvaranju SQL fajla on će te pitati da navedeš izvor podataka. Na gornjoj slici konfigurisao sam lokalnu MySQL bazu; nakon uspešnog uvoza u bazi se mogu videti sledeće tabele:

Glavne tabele Quartz baze su sledeće:
| Table Name | Description |
|---|---|
| QRTZ_CALENDARS | Čuva Calendar informacije Quartz-a |
| QRTZ_CRON_TRIGGERS | Čuva CronTrigger, uključujući Cron izraz i informacije o vremenskoj zoni |
| QRTZ_FIRED_TRIGGERS | Čuva informacije o stanju vezanom za već okinute Trigger-e, kao i informacije o izvršenju povezanih Job-ova |
| QRTZ_PAUSED_TRIGGER_GRPS | Čuva informacije o pauziranim grupama Trigger-a |
| QRTZ_SCHEDULER_STATE | Čuva malu količinu informacija o stanju Scheduler-a i o ostalim instancama Scheduler-a |
| QRTZ_LOCKS | Čuva informacije o pesimističkim bravama programa |
| QRTZ_JOB_DETAILS | Čuva detaljne informacije o svakom konfigurisanom Job-u |
| QRTZ_JOB_LISTENERS | Čuva informacije o konfigurisanim JobListener-ima |
| QRTZ_SIMPLE_TRIGGERS | Čuva jednostavne Trigger-e, uključujući broj ponavljanja, interval i broj već okinutih puta |
| QRTZ_BLOG_TRIGGERS | Trigger-e čuva kao Blob tip |
| QRTZ_TRIGGER_LISTENERS | Čuva informacije o konfigurisanim TriggerListener-ima |
| QRTZ_TRIGGERS | Čuva informacije o konfigurisanim Trigger-ima |
Preostalo je još konfiguracija scheduler-a, jobStore-a i threadPool-a.
Treći korak: kreiramo interfejs za zakazivanje zadataka IScheduleService, sa tri metoda - zakazivanje preko Cron izraza, zakazivanje na zadato vreme i otkazivanje zadatka.
public interface IScheduleService {
/**
* Zakazuje zadatak preko Cron izraza
*/
String scheduleJob(Class<? extends Job> jobBeanClass, String cron, String data);
/**
* Zakazuje zadatak na zadato vreme
*/
String scheduleFixTimeJob(Class<? extends Job> jobBeanClass, Date startTime, String data);
/**
* Otkazuje zakazani zadatak
*/
Boolean cancelScheduleJob(String jobName);
}Četvrti korak: kreiramo klasu za implementaciju posla zakazivanja ScheduleServiceImpl, koja implementira odgovarajuće metode kroz API Scheduler-a, CronTrigger-a i JobDetail-a.
@Slf4j
@Service
public class ScheduleServiceImpl implements IScheduleService {
private String defaultGroup = "default_group";
@Autowired
private Scheduler scheduler;
@Override
public String scheduleJob(Class<? extends Job> jobBeanClass, String cron, String data) {
String jobName = UUID.fastUUID().toString();
JobDetail jobDetail = JobBuilder.newJob(jobBeanClass)
.withIdentity(jobName, defaultGroup)
.usingJobData("data", data)
.build();
//kreira okidač, navodi vreme izvršenja zadatka
CronTrigger cronTrigger = TriggerBuilder.newTrigger()
.withIdentity(jobName, defaultGroup)
.withSchedule(CronScheduleBuilder.cronSchedule(cron))
.build();
// raspoređivač vrši raspoređivanje zadatka
try {
scheduler.scheduleJob(jobDetail, cronTrigger);
} catch (SchedulerException e) {
log.error("Izvršenje zakazivanja zadatka nije uspelo{}", e.getMessage());
}
return jobName;
}
@Override
public String scheduleFixTimeJob(Class<? extends Job> jobBeanClass, Date startTime, String data) {
//datum u CRON izraz
String startCron = String.format("%d %d %d %d %d ? %d",
DateUtil.second(startTime),
DateUtil.minute(startTime),
DateUtil.hour(startTime, true),
DateUtil.dayOfMonth(startTime),
DateUtil.month(startTime) + 1,
DateUtil.year(startTime));
return scheduleJob(jobBeanClass, startCron, data);
}
@Override
public Boolean cancelScheduleJob(String jobName) {
boolean success = false;
try {
// pauzira okidač
scheduler.pauseTrigger(new TriggerKey(jobName, defaultGroup));
// uklanja zadatak iz okidača
scheduler.unscheduleJob(new TriggerKey(jobName, defaultGroup));
// briše zadatak
scheduler.deleteJob(new JobKey(jobName, defaultGroup));
success = true;
} catch (SchedulerException e) {
log.error("Otkazivanje zadatka nije uspelo{}", e.getMessage());
}
return success;
}
}Peti korak: definišemo zadatak koji treba izvršiti, nasleđujemo klasu QuartzJobBean i implementiramo metod executeInternal; ovde definišemo samo jedan zadatak - zakazano objavljivanje članka.
@Slf4j
@Component
public class PublishPostJob extends QuartzJobBean {
@Autowired
private IScheduleService scheduleService;
@Autowired
private IPostsService postsService;
@Override
protected void executeInternal(JobExecutionContext jobExecutionContext) throws JobExecutionException {
Trigger trigger = jobExecutionContext.getTrigger();
JobDetail jobDetail = jobExecutionContext.getJobDetail();
JobDataMap jobDataMap = jobDetail.getJobDataMap();
Long data = jobDataMap.getLong("data");
log.debug("Operacija zakazanog objavljivanja članka: {}",data);
// nakon dohvatanja ID-a članka, dohvata članak, ažurira status članka na objavljen i postavlja vreme objave
boolean success = postsService.updatePostByScheduler(data);
// nakon završetka briše okidač i zadatak
if (success) {
log.debug("Zakazani zadatak uspešno izvršen, započinjem uklanjanje zakazanog zadatka");
scheduleService.cancelScheduleJob(trigger.getKey().getName());
}
}
}Šesti korak: u interfejsu PostsServiceImpl za objavu članaka dodajemo metod zakazivanja radi zakazanog objavljivanja.
@Service
public class PostsServiceImpl extends ServiceImpl<PostsMapper, Posts> implements IPostsService {
private void handleScheduledAfter(Posts posts) {
// članak je već sačuvan kao nacrt, i dohvatili smo ID članka
// poziv zakazanog zadatka
String jobName = scheduleService.scheduleFixTimeJob(PublishPostJob.class, posts.getPostDate(), posts.getPostsId().toString());
LOGGER.debug("Zakazani zadatak {} kreće sa izvršenjem", jobName);
}
}Dobro, sada pokrećemo servis i testiramo pomoću Swagger-a; obrati pažnju na to da podesiš vreme zakazane objave članka.

Kada pogledamo tabelu Quartz-a qrtz_cron_triggers, vidimo da je zadatak već dodat.

U tabeli qrtz_job_details takođe se mogu videti konkretni detalji zadatka.

Nakon što vreme zakazane objave članka nastupi, u logovima se takođe može videti Quartz-ov log izvršenja.

Kada ponovo pregledamo Quartz tabele qrtz_cron_triggers i qrtz_job_details, videćemo da je zakazani zadatak već uklonjen.
Sveukupno gledano, integracija Spring Boot-a i Quartz-a prolazi vrlo glatko: konfiguracije je malo, koraci su jasni, a moćniji je od Spring Task-a; podržava i memoriju i trajno pamćenje, pa kad naiđeš na zakazane zadatke svakako isprobaj.
Kompletna funkcionalnost implementirana je u praktičnom projektu Codingmore, pa možeš uvesti Codingmore u lokalno okruženje i isprobati.
Pregled poslovne logike
Ukratko, da pregledamo poslovnu logiku zakazanog objavljivanja članaka u Codingmore-u.
Prilikom objavljivanja članka korisnik može izabrati zakazano objavljivanje; ako ga izabere, mora podesiti vreme zakazane objave - privremeno je propisano da zakazivanje može najranije deset minuta unapred.
Kada korisnik na admin strani izabere zakazano objavljivanje, pri čuvanju članka njegov status prvo treba podesiti na status nacrta, što je za korisnike na frontendu nevidljivo stanje.
Putanja do izvornog koda
- Codingmore: https://github.com/itwanger/coding-more
- codingmore-quartz: https://github.com/itwanger/codingmore-learning
