CLOVER🍀

That was when it all began.

Spring BootでQuartzを使ってみる

これは、なにをしたくて書いたもの?

Spring Boot(とSpring Framework)に、Quartzとの連携機能があるので、試してみようということで。

Spring Boot、Spring FrameworkQuartz

Spring BootおよびSpring Frameworkの、Quartzとの連携については以下に記載があります。

またSpring Bootのプロパティとして、spring.quartz.〜で設定ができます。

Common Application Properties / Core Properties

もう少し見てみましょう。

Spring Bootでは以下をBean定義することで、QuartzSchedulerに関連付けます。

  • JobDetail
  • Calendar
  • Trigger

IO / Quartz Scheduler

JobStoreは、デフォルトではインメモリです。spring.quartz.job-store-typeプロパティをjdbcとすることで、JobStoreをデータベースに
することができます。
JobStoreをデータベースにした場合は、spring.quartz.jdbc.initialize-schemaプロパティでQuartzが使うテーブルの初期化方法を
指定できます。

データソースは、デフォルトではSpring Bootのアプリケーション向けのものを使います。それ以外のデータソースを使うには、
別のデータソースを@Bean@QuartzDataSourceを使って定義することになります。

JobDetailTriggerは直接Springのコンポーネントとして定義してもよいですが、JobDetailFactoryBeanSimpleTriggerFactoryBean
CronTriggerFactoryBeanで定義することもできます。

また、SchedulerSchedulerFactoryBeanで定義することもできます。

先にSpring BootでのQuartzに関するプロパティを紹介しましたが、SchedulerFactoryBeanで定義で設定する内容はQuartzの設定と
バッティングするので、SchedulerFactoryBeanquartz.propertiesでそれぞれ設定しない方が良いとされています。

たとえば、Quartzが使用するデータソースをSpring Frameworkのものとする場合は、org.quartz.jobStore.classを指定してはいけません。

Please note that many SchedulerFactoryBean settings interact with common Quartz settings in the properties file; it is therefore not recommended to specify values at both levels. For example, do not set an "org.quartz.jobStore.class" property if you mean to rely on a Spring-provided DataSource, or specify an org.springframework.scheduling.quartz.LocalDataSourceJobStore variant which is a full-fledged replacement for the standard org.quartz.impl.jdbcjobstore.JobStoreTX.

ところで、Spring Frameworkのデータソースを使う場合は、LocalDataSourceJobStoreが使われるようです。これ、JobStoreCMT
サブクラスみたいですね。

public class LocalDataSourceJobStore extends JobStoreCMT {

https://github.com/spring-projects/spring-framework/blob/v5.3.23/spring-context-support/src/main/java/org/springframework/scheduling/quartz/LocalDataSourceJobStore.java

Jobは、QuartzJobBeanを継承したクラスを作成して定義します。

public class ExampleJob extends QuartzJobBean {

    private int timeout;

    /**
     * Setter called after the ExampleJob is instantiated
     * with the value from the JobDetailFactoryBean (5)
     */
    public void setTimeout(int timeout) {
        this.timeout = timeout;
    }

    protected void executeInternal(JobExecutionContext ctx) throws JobExecutionException {
        // do the actual work
    }
}

Jobとしての処理は、executeInternalメソッドをオーバーライドして実装します。また、JobDataで設定した値をプロパティとして
設定することもできます。

ドキュメントを見るのはこれくらいにして、実際に使ってみましょう。

お題

今回のお題は、以下とします。

  • Spring BootとQuartzを連携させる
  • QuartzのJobStoreはMySQLとする
  • JobにはJobDataを設定して、Jobのプロパティとして設定する

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.5 2022-10-18
OpenJDK Runtime Environment (build 17.0.5+8-Ubuntu-2ubuntu120.04)
OpenJDK 64-Bit Server VM (build 17.0.5+8-Ubuntu-2ubuntu120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.5, vendor: Private Build, runtime: /usr/lib/jvm/java-17-openjdk-amd64
Default locale: ja_JP, platform encoding: UTF-8
OS name: "linux", version: "5.4.0-131-generic", arch: "amd64", family: "unix"

MySQLは172.17.0.2で動作しているものとし、データベースはpractice、ユーザー/パスワードはkazuhirapasswordで作成済みとします。

$ mysql --version
mysql  Ver 8.0.31 for Linux on x86_64 (MySQL Community Server - GPL)


mysql> select version();
+-----------+
| version() |
+-----------+
| 8.0.31    |
+-----------+
1 row in set (0.01 sec)

準備

Quartzのテーブルは、事前に作成しておくことにします。

$ curl -sL https://raw.githubusercontent.com/quartz-scheduler/quartz/v2.3.2/quartz-core/src/main/resources/org/quartz/impl/jdbcjobstore/tables_mysql_innodb.sql | mysql -ukazuhira -p practice

Spring Bootアプリケーションを作成する

では、Spring Bootアプリケーションを作成していきます。依存関係には、quartzjdbcmysqlを指定。

$ curl -s https://start.spring.io/starter.tgz \
  -d bootVersion=2.7.5 \
  -d javaVersion=17 \
  -d type=maven-project \
  -d name=quartz-example \
  -d groupId=org.littlewings \
  -d artifactId=quartz-examples \
  -d version=0.0.1-SNAPSHOT \
  -d packageName=org.littlewings.spring.quartz \
  -d dependencies=quartz,jdbc,mysql \
  -d baseDir=quartz-example | tar zxvf -

jdbcmysqlがあるのは、JobStoreをデータベースにするからですね。

プロジェクト内に移動。

$ cd quartz-example

生成されたソースコードは、削除しておきます。

$ rm src/main/java/org/littlewings/spring/quartz/QuartzExampleApplication.java src/test/java/org/littlewings/spring/quartz/QuartzExampleApplicationTests.java

Maven依存関係やプラグインの設定など。

        <properties>
                <java.version>17</java.version>
        </properties>
        <dependencies>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-jdbc</artifactId>
                </dependency>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-quartz</artifactId>
                </dependency>

                <dependency>
                        <groupId>com.mysql</groupId>
                        <artifactId>mysql-connector-j</artifactId>
                        <scope>runtime</scope>
                </dependency>
                <dependency>
                        <groupId>org.springframework.boot</groupId>
                        <artifactId>spring-boot-starter-test</artifactId>
                        <scope>test</scope>
                </dependency>
        </dependencies>

        <build>
                <plugins>
                        <plugin>
                                <groupId>org.springframework.boot</groupId>
                                <artifactId>spring-boot-maven-plugin</artifactId>
                        </plugin>
                </plugins>
        </build>

Jobの定義。プロパティとして、wordをJobDataとして受け取るように想定して作成。

src/main/java/org/littlewings/spring/quartz/PrintMessageJob.java

package org.littlewings.spring.quartz;

import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.springframework.scheduling.quartz.QuartzJobBean;

public class PrintMessageJob extends QuartzJobBean {
    LoggingService loggingService;

    private String word;

    public PrintMessageJob(LoggingService loggingService) {
        this.loggingService = loggingService;
    }

    @Override
    protected void executeInternal(JobExecutionContext context) throws JobExecutionException {
        loggingService.log(word);
    }

    public String getWord() {
        return word;
    }

    public void setWord(String word) {
        this.word = word;
    }
}

設定されたプロパティをログ出力するだけのJobです。

        loggingService.log(word);

ログ出力の部分は、DIできることを示すためにSpringのコンポーネントにすることにしました。

src/main/java/org/littlewings/spring/quartz/LoggingService.java

package org.littlewings.spring.quartz;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class LoggingService {
    Logger logger = LoggerFactory.getLogger(LoggingService.class);

    public void log(String word) {
        logger.info("Hello, {}!!!", word);
    }
}

JobDetailTriggerの定義。今回は、JobDetailFactoryBeanSimpleTriggerFactoryBeanを使って定義しました。

src/main/java/org/littlewings/spring/quartz/JobConfig.java

package org.littlewings.spring.quartz;

import java.sql.Date;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.Map;

import org.quartz.JobDetail;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.quartz.JobDetailFactoryBean;
import org.springframework.scheduling.quartz.SimpleTriggerFactoryBean;

@Configuration
public class JobConfig {
    @Bean
    public JobDetailFactoryBean jobDetailFactoryBean() {
        JobDetailFactoryBean jobDetailFactoryBean = new JobDetailFactoryBean();
        jobDetailFactoryBean.setName("print-message-job");
        jobDetailFactoryBean.setJobClass(PrintMessageJob.class);
        jobDetailFactoryBean.setJobDataAsMap(Map.of("word", "Quartz"));
        jobDetailFactoryBean.setGroup("job-group1");
        jobDetailFactoryBean.setDurability(true); // Triggerを紐付けないJobとして登録

        return jobDetailFactoryBean;
    }

    @Bean
    public SimpleTriggerFactoryBean simpleTriggerFactoryBean(JobDetail jobDetail) {
        SimpleTriggerFactoryBean simpleTriggerFactoryBean = new SimpleTriggerFactoryBean();
        simpleTriggerFactoryBean.setName("trigger1");
        simpleTriggerFactoryBean.setJobDetail(jobDetail);
        simpleTriggerFactoryBean.setGroup("trigger-group1");
        simpleTriggerFactoryBean.setStartTime(
                Date.from(LocalDateTime.now().plusSeconds(15L).atZone(ZoneId.of("Asia/Tokyo")).toInstant())
        );
        simpleTriggerFactoryBean.setRepeatInterval(5 * 1000L);

        return simpleTriggerFactoryBean;
    }
}

実行スケジュールは、15秒後に開始して、5秒おきに実行するようにTrigger設定しました。

        simpleTriggerFactoryBean.setStartTime(
                Date.from(LocalDateTime.now().plusSeconds(15L).atZone(ZoneId.of("Asia/Tokyo")).toInstant())
        );
        simpleTriggerFactoryBean.setRepeatInterval(5 * 1000L);

このソースコードでは、以下の設定を行う必要があります。

        jobDetailFactoryBean.setDurability(true); // Triggerを紐付けないJobとして登録

Durabilityとは永続性で、これがfalseの時はTriggerに紐づいていないJobSchedulerから削除されます。

Durability - if a job is non-durable, it is automatically deleted from the scheduler once there are no longer any active triggers associated with it. In other words, non-durable jobs have a life span bounded by the existence of its triggers.

Lesson 3: More About Jobs and Job Details

これをtrueにしていない場合は、以下のように起動時に例外になります。

org.springframework.beans.factory.BeanCreationException: Error creating bean with name 'quartzScheduler' defined in class path resource [org/springframework/boot/autoconfigure/quartz/QuartzAutoConfiguration.class]: Invocation of init method failed; nested exception is org.quartz.SchedulerException: Jobs added with no trigger must be durable.


Caused by: org.quartz.SchedulerException: Jobs added with no trigger must be durable.

メッセージに書かれているように、Triggerに紐付かないJobはDurabilityをtrueにしておく必要があります。
JobDetailTriggerSchedulerへの登録タイミングが異なるからでしょうね。

mainクラス。

src/main/java/org/littlewings/spring/quartz/App.java

package org.littlewings.spring.quartz;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class App {
    public static void main(String... args) {
        SpringApplication.run(App.class, args);
    }
}

Spring Bootの設定。

src/main/resources/application.properties

spring.datasource.url=jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin
spring.datasource.username=kazuhira
spring.datasource.password=password

spring.quartz.job-store-type=jdbc
# Jobの上書き設定を許容する
spring.quartz.overwrite-existing-jobs=true
spring.quartz.properties.org.quartz.jobStore.isClustered=true
spring.quartz.properties.org.quartz.jobStore.useProperties=true
spring.quartz.wait-for-jobs-to-complete-on-shutdown=true
spring.quartz.jdbc.initialize-schema=never

spring.datasource.〜はデータソースの設定なので良いとして。

こちらで、JobStoreをデータベースにします。これで使われるJobStoreは、Spring Frameworkの提供するLocalDataSourceJobStoreです。

spring.quartz.job-store-type=jdbc

Quartzのテーブル定義は先に作成したので、以下はneverを設定。

spring.quartz.jdbc.initialize-schema=never

アプリケーションの終了時に、実行中のJobがある場合は待機するように設定。

spring.quartz.wait-for-jobs-to-complete-on-shutdown=true

spring.quartz.properties.〜は、Quartzのプロパティをそのまま指定するようになりますね。

spring.quartz.properties.org.quartz.jobStore.isClustered=true
spring.quartz.properties.org.quartz.jobStore.useProperties=true

あと、今回はスケジューリングをしょっちゅう変更していたので、以下のように既存のJobを上書きできるようにしました。

# Jobの上書き設定を許容する
spring.quartz.overwrite-existing-jobs=true

こちらに書かれている内容ですね。

By default, jobs created by configuration will not overwrite already registered jobs that have been read from a persistent job store. To enable overwriting existing job definitions set the spring.quartz.overwrite-existing-jobs property.

IO / Quartz Scheduler

通常は、デフォルトのfalseで良いと思います。

確認する

では、動作確認してみましょう。

パッケージング。

$ mvn package

起動。

$ java -jar target/quartz-examples-0.0.1-SNAPSHOT.jar

起動ログ。

2022-11-14 00:49:19.086  INFO 39395 --- [           main] o.s.s.quartz.LocalDataSourceJobStore     : JobStoreCMT initialized.
2022-11-14 00:49:19.087  INFO 39395 --- [           main] org.quartz.core.QuartzScheduler          : Scheduler meta-data: Quartz Scheduler (v2.3.2) 'quartzScheduler' with instanceId 'NON_CLUSTERED'
  Scheduler class: 'org.quartz.core.QuartzScheduler' - running locally.
  NOT STARTED.
  Currently in standby mode.
  Number of jobs executed: 0
  Using thread pool 'org.quartz.simpl.SimpleThreadPool' - with 10 threads.
  Using job-store 'org.springframework.scheduling.quartz.LocalDataSourceJobStore' - which supports persistence. and is clustered.

2022-11-14 00:49:19.087  INFO 39395 --- [           main] org.quartz.impl.StdSchedulerFactory      : Quartz scheduler 'quartzScheduler' initialized from an externally provided properties instance.
2022-11-14 00:49:19.087  INFO 39395 --- [           main] org.quartz.impl.StdSchedulerFactory      : Quartz scheduler version: 2.3.2
2022-11-14 00:49:19.087  INFO 39395 --- [           main] org.quartz.core.QuartzScheduler          : JobFactory set to: org.springframework.scheduling.quartz.SpringBeanJobFactory@340da44c
2022-11-14 00:49:19.305  INFO 39395 --- [           main] o.s.s.quartz.SchedulerFactoryBean        : Starting Quartz Scheduler now
2022-11-14 00:49:19.313  INFO 39395 --- [           main] o.s.s.quartz.LocalDataSourceJobStore     : ClusterManager: detected 1 failed or restarted instances.
2022-11-14 00:49:19.313  INFO 39395 --- [           main] o.s.s.quartz.LocalDataSourceJobStore     : ClusterManager: Scanning for instance "NON_CLUSTERED"'s failed in-progress jobs.
2022-11-14 00:49:19.339  INFO 39395 --- [           main] org.quartz.core.QuartzScheduler          : Scheduler quartzScheduler_$_NON_CLUSTERED started.
2022-11-14 00:49:19.360  INFO 39395 --- [           main] org.littlewings.spring.quartz.App        : Started App in 2.305 seconds (JVM running for 2.711)

クラスタリングが有効になっていますね。

そして、起動してからほぼ15秒後にJobが開始し、5秒おきに実行されます。

2022-11-14 00:49:19.360  INFO 39395 --- [           main] org.littlewings.spring.quartz.App        : Started App in 2.305 seconds (JVM running for 2.711)
2022-11-14 00:49:33.560  INFO 39395 --- [eduler_Worker-1] o.l.spring.quartz.LoggingService         : Hello, Quartz!!!
2022-11-14 00:49:38.523  INFO 39395 --- [eduler_Worker-2] o.l.spring.quartz.LoggingService         : Hello, Quartz!!!
2022-11-14 00:49:43.521  INFO 39395 --- [eduler_Worker-3] o.l.spring.quartz.LoggingService         : Hello, Quartz!!!
2022-11-14 00:49:48.529  INFO 39395 --- [eduler_Worker-4] o.l.spring.quartz.LoggingService         : Hello, Quartz!!!

JobDataとして渡した値も設定できていることが確認できますね。

クラスタリングが有効になっているので、このあと同じアプリケーションを起動すると、クラスターに参加してそれぞれのノードでJobが
実行されるのを確認できます。

$ java -jar target/quartz-examples-0.0.1-SNAPSHOT.jar

OKですね。

あと、今回はJobDetailTriggerをBeanとして定義しましたが、Scheduler自体はDI可能なので、こちらを使って動的にJob登録を行うなど
するのもよいでしょう。

src/main/java/org/littlewings/spring/quartz/JobService.java

package org.littlewings.spring.quartz;

import org.quartz.Scheduler;
import org.springframework.stereotype.Service;

@Service
public class JobService {
    Scheduler scheduler;

    public JobService(Scheduler scheduler) {
        this.scheduler = scheduler;
    }

    public void registerJobs() {
        // ...
    }
}

補足

QuartzJobBeanクラスを継承して作成したクラスはSpringのコンポーネントがDI可能なのですが、そうするとインスタンスはどのように
扱われているのか気になりますね。

結論を見ると、Jobの実行都度インスタンスが作成されるようです。

       Object job = (this.applicationContext != null ?
                this.applicationContext.getAutowireCapableBeanFactory().createBean(
                        bundle.getJobDetail().getJobClass(), AutowireCapableBeanFactory.AUTOWIRE_CONSTRUCTOR, false) :
                super.createJobInstance(bundle));

https://github.com/spring-projects/spring-framework/blob/v5.3.23/spring-context-support/src/main/java/org/springframework/scheduling/quartz/SpringBeanJobFactory.java#L89-L92

まとめ

Spring BootおよびSpring Frameworkの、Quartzとの連携機能を使ってみました。

最初、設定方法がちょっとわからなかったのですが、いろいろ調べたり動かしたりしてわかるようになりました。

というか、慣れるとかなりわかりやすい気がしますね。