CLOVER🍀

That was when it all began.

Database Riderを試してみる

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

以前、DbUnitを使ってエントリーを書いてみました。

DbUnitを試してみる - CLOVER🍀

この時にも少し触れていたのですが、今回はDatabase Riderというものを試してみたいと思います。

Database Rider

Database Riderは、DbUnitとJUnitを統合してデータベースのテストをしやすくするものです。

Database Rider integrates DBUnit and JUnit in order to make database testing a breeze!

Database Rider

機能としては、以下のようですね。

ドキュメントは、以下から最新のものを参照します。

Document

現時点で公開されている最新のドキュメントは、1.35.0のようです(一覧にはありませんでしたが、URLからバージョン指定すると参照
できました)。

Database Rider Documentation

Getting Startedはこちら。

Database Rider Getting Started

GitHubリポジトリはこちら。

GitHub - database-rider/database-rider: Database testing made easy!

サンプルは、GitHubリポジトリに含まれています。

https://github.com/database-rider/database-rider/tree/1.35.0/rider-examples

以下のようなサンプルがあります。

  • dbunit-tomee-appcomposer-sample
  • jOOQ-DBUnit-flyway-example
  • jpa-productivity-boosters
  • quarkus-dbunit-sample
  • quarkus-postgres-sample
  • rider-kotlin
  • spring-boot-dbunit-sample

とりあえずこれくらいにして、使っていってみましょう。

お題

今回のお題は、以下のようにします。

  • Database Riderを使ったテストを書く
  • ファイルで用意するデータセットには、YAMLを使用する
  • データベースアクセスにはDoma 2を使用する
  • テスティングフレームワークには、JUnit 5を使用する
  • データベースにはMySQL 8.0を使用する

参考にするドキュメントは、まずはGetting Startedにしたいと思います。

Database Rider Getting Started

ドキュメント本体の方も、必要に応じて参照します。

Database Rider Documentation

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.4 2022-07-19
OpenJDK Runtime Environment (build 17.0.4+8-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.4+8-Ubuntu-120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.4, 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で動作しているものとします。

$ 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.00 sec)

データベースはpractice、アカウントはkazuhira/passwordで作成しているものとします。

準備

Maven依存関係など。

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>com.mysql</groupId>
            <artifactId>mysql-connector-j</artifactId>
            <version>8.0.31</version>
            <scope>runtime</scope>
        </dependency>
        <dependency>
            <groupId>org.seasar.doma</groupId>
            <artifactId>doma-core</artifactId>
            <version>2.53.1</version>
        </dependency>

        <dependency>
            <groupId>org.seasar.doma</groupId>
            <artifactId>doma-processor</artifactId>
            <version>2.53.1</version>
            <optional>true</optional>
        </dependency>

        <dependency>
            <groupId>com.github.database-rider</groupId>
            <artifactId>rider-junit5</artifactId>
            <version>1.35.0</version>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.jupiter</groupId>
                    <artifactId>*</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.junit.platform</groupId>
                    <artifactId>*</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

        <dependency>
            <groupId>org.junit.jupiter</groupId>
            <artifactId>junit-jupiter</artifactId>
            <version>5.9.1</version>
            <scope>test</scope>
        </dependency>
        <dependency>
            <groupId>org.assertj</groupId>
            <artifactId>assertj-core</artifactId>
            <version>3.23.1</version>
            <scope>test</scope>
        </dependency>
    </dependencies>

    <build>
        <plugins>
            <plugin>
                <artifactId>maven-surefire-plugin</artifactId>
                <version>2.22.2</version>
            </plugin>
        </plugins>
    </build>

ポイントは、こちらですね。

        <dependency>
            <groupId>com.github.database-rider</groupId>
            <artifactId>rider-junit5</artifactId>
            <version>1.35.0</version>
            <scope>test</scope>
            <exclusions>
                <exclusion>
                    <groupId>org.junit.jupiter</groupId>
                    <artifactId>*</artifactId>
                </exclusion>
                <exclusion>
                    <groupId>org.junit.platform</groupId>
                    <artifactId>*</artifactId>
                </exclusion>
            </exclusions>
        </dependency>

Database Riderのアーティファクトのうち、JUnit 5向けのrider-junit5を使っているところですね。

依存しているJUnit 5が古かったので、excludeしてJUnit 5は自分で追加しました。

テーブルは、以下の定義で作成。

create table book(
  isbn varchar(14),
  title varchar(100),
  price int,
  publish_date date,
  category varchar(20),
  primary key(isbn)
);

続いて、Doma 2を使ったプログラムを書いていきます。

エンティティ。

src/main/java/org/littlewings/databaserider/Book.java

package org.littlewings.databaserider;

import java.time.LocalDate;

import org.seasar.doma.Entity;
import org.seasar.doma.Id;

@Entity
public class Book {
    @Id
    String isbn;
    String title;
    Integer price;
    LocalDate publishDate;
    String category;

    public static Book create(String isbn, String title, Integer price, LocalDate publishDate, String category) {
        Book book = new Book();
        book.setIsbn(isbn);
        book.setTitle(title);
        book.setPrice(price);
        book.setPublishDate(publishDate);
        book.setCategory(category);

        return book;
    }

    // getter/setterは省略
}

Dao。

src/main/java/org/littlewings/databaserider/BookDao.java

package org.littlewings.databaserider;

import java.util.List;

import org.seasar.doma.Dao;
import org.seasar.doma.Delete;
import org.seasar.doma.Insert;
import org.seasar.doma.Select;
import org.seasar.doma.Sql;

@Dao
public interface BookDao {
    @Insert
    int insert(Book book);

    @Sql("select /*%expand*/* from book where isbn = /* isbn */'dummy'")
    @Select
    Book selectByIsbn(String isbn);

    @Sql("select /*%expand*/* from book order by price desc")
    @Select
    List<Book> selectAllOrderByPriceDesc();

    @Delete
    int delete(Book book);
}

Config。

src/main/java/org/littlewings/databaserider/DomaConfig.java

package org.littlewings.databaserider;

import javax.sql.DataSource;

import org.seasar.doma.jdbc.Config;
import org.seasar.doma.jdbc.Naming;
import org.seasar.doma.jdbc.dialect.Dialect;
import org.seasar.doma.jdbc.dialect.MysqlDialect;
import org.seasar.doma.jdbc.tx.LocalTransactionDataSource;
import org.seasar.doma.jdbc.tx.LocalTransactionManager;
import org.seasar.doma.jdbc.tx.TransactionManager;

public class DomaConfig implements Config {
    private static final DomaConfig CONFIG = new DomaConfig();

    Dialect dialect;

    LocalTransactionDataSource dataSource;

    TransactionManager transactionManager;

    public static DomaConfig singleton() {
        return CONFIG;
    }

    private DomaConfig() {
        dialect = new MysqlDialect();
        dataSource = new LocalTransactionDataSource(
                "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin",
                "kazuhira",
                "password"
        );
        transactionManager = new LocalTransactionManager(dataSource.getLocalTransaction(getJdbcLogger()));
    }

    @Override
    public DataSource getDataSource() {
        return dataSource;
    }

    @Override
    public Dialect getDialect() {
        return dialect;
    }

    @Override
    public TransactionManager getTransactionManager() {
        return transactionManager;
    }

    @Override
    public Naming getNaming() {
        return Naming.SNAKE_LOWER_CASE;
    }
}

あとは、これらを使ってテストコードを書いていきます。

Database Riderを使ってみる

では、Database Riderを使ってみましょう。

まずは、テストコードの雛形を用意。

src/test/java/org/littlewings/databaserider/DatabaseRiderGettingStartedTest.java

package org.littlewings.databaserider;

import java.sql.DriverManager;
import java.time.LocalDate;
import java.util.List;

import com.github.database.rider.core.api.connection.ConnectionHolder;
import com.github.database.rider.core.api.dataset.DataSet;
import com.github.database.rider.core.api.dataset.DataSetFormat;
import com.github.database.rider.core.api.dataset.ExpectedDataSet;
import com.github.database.rider.core.api.exporter.ExportDataSet;
import com.github.database.rider.junit5.DBUnitExtension;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;

import static org.assertj.core.api.Assertions.assertThat;

@ExtendWith(DBUnitExtension.class)
public class DatabaseRiderGettingStartedTest {

    // ここに、テストを書く!!
}

こちらを見ながら進めていきます。

Database Rider Getting Started

先に、JUnit 5向けの設定から。

@ExtendWith(DBUnitExtension.class)
public class DatabaseRiderGettingStartedTest {

Database Rider Getting Started / Riding database in JUnit 5 tests

Getting Startedなどを見ていると@RunWith(JUnitPlatform.class)が要ると書いていましたが、こちらはなくても良さそうですけどね。

データの例。

Database Rider Getting Started / Example

今回は、こんなデータを用意しました。

src/test/resources/dataset/books.yml

book:
  - isbn: "978-4295008477"
    title: "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"
    price: 2860
    publish_date: "2020-03-13"
    category: "java"
  - isbn: "978-4621303252"
    title: "Effective Java 第3版"
    price: 4400
    publish_date: "2018-10-30"
    category: "java"
  - isbn: "978-4774189093"
    title: "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで"
    price: 3278
    publish_date: "2017-04-18"
    category: "java"
  - isbn: "978-4798161488"
    title: "MySQL徹底入門 第4版 MySQL 8.0対応"
    price: "4180"
    publish_date: "2020-07-06"
    category: "mysql"
  - isbn: "978-4798147406"
    title: "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド"
    price: 3960
    publish_date: "2016-08-26"
    category: "mysql"

こちらを、@DataSetアノテーションで指定します。クラスパス上のファイルを指定する、で良さそうです。

    @Test
    @DataSet("dataset/books.yml")
    public void gettingStarted() {
        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());

        DomaConfig
                .singleton()
                .getTransactionManager()
                .required(() -> {
                    Book mysqlBook = bookDao.selectByIsbn("978-4798161488");
                    assertThat(mysqlBook).isNotNull();
                    assertThat(mysqlBook.getTitle()).isEqualTo("MySQL徹底入門 第4版 MySQL 8.0対応");
                    assertThat(mysqlBook.getPrice()).isEqualTo(4180);
                    assertThat(mysqlBook.getPublishDate()).isEqualTo(LocalDate.of(2020, 7, 6));
                    assertThat(mysqlBook.getCategory()).isEqualTo("mysql");

                    List<Book> books = bookDao.selectAllOrderByPriceDesc();
                    assertThat(books).hasSize(5);
                    assertThat(books.stream().map(Book::getPrice).toList()).containsExactly(4400, 4180, 3960, 3278, 2860);
                });
    }

@DataSetアノテーションは、データをセットアップするアノテーションです。

Database Rider Getting Started / Configuration

デフォルトでは、指定したDataSetの投入先のテーブルを1度全件削除してからデータを投入する動きになるようです。これはstrategyで
指定します。

deleteの順を指定したり、テストの前後にデータベース内のデータを削除したり、テストの前後にSQLやスクリプトを実行することも
できるようです。

今回は、@DataSetアノテーションで登録されたデータをアサーションするソースコードになっています。

        DomaConfig
                .singleton()
                .getTransactionManager()
                .required(() -> {
                    Book mysqlBook = bookDao.selectByIsbn("978-4798161488");
                    assertThat(mysqlBook).isNotNull();
                    assertThat(mysqlBook.getTitle()).isEqualTo("MySQL徹底入門 第4版 MySQL 8.0対応");
                    assertThat(mysqlBook.getPrice()).isEqualTo(4180);
                    assertThat(mysqlBook.getPublishDate()).isEqualTo(LocalDate.of(2020, 7, 6));
                    assertThat(mysqlBook.getCategory()).isEqualTo("mysql");

                    List<Book> books = bookDao.selectAllOrderByPriceDesc();
                    assertThat(books).hasSize(5);
                    assertThat(books.stream().map(Book::getPrice).toList()).containsExactly(4400, 4180, 3960, 3278, 2860);
                });

ところで、接続先に関する情報がまだ出てきていません。データベース接続は、ConnectionHolderというものを使って定義するようです。

@ExtendWith(DBUnitExtension.class)
public class DatabaseRiderGettingStartedTest {
    ConnectionHolder connectionHolder = () ->
            DriverManager.getConnection(
                    "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin",
                    "kazuhira",
                    "password"
            );

ただ、今回はこちらを使わずにDatabase Riderの設定ファイルで定義することにしました。

Database Rider Getting Started / Configuration

dbunit.ymlというファイルですね(@DBUnitアノテーションでもよいみたいですが)。

src/test/resources/dbunit.yml

properties:
  caseSensitiveTableNames: true
connectionConfig:
  url: "jdbc:mysql://172.17.0.2:3306/practice?characterEncoding=utf-8&connectionCollation=utf8mb4_0900_bin"
  user: "kazuhira"
  password: "password"

デフォルトだとcaseSensitiveTableNamesがfalseなので、データセット内のテーブル名やカラム名の大文字小文字が一致しないと
ダメなのですが、今回はtrueにしました。

次は、アサーションもDatabase Riderに任せる方法を試してみます。@ExpectedDataSetアノテーションを使うようです。

Database Rider Getting Started / Database assertion with ExpectedDataSet

@ExpectedDataSetアノテーションを使うと、テスト実行後のテーブルの状態と@ExpectedDataSetアノテーションで指定したデータセットの
内容をアサーションしてくれます。

    @Test
    @DataSet("dataset/books.yml")
    @ExpectedDataSet(value = "dataset/expectedBooks.yml", orderBy = "isbn", ignoreCols = "price")
    public void expectedDataSet() {
        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());

        DomaConfig
                .singleton()
                .getTransactionManager()
                .required(() -> {
                    assertThat(
                            bookDao
                                    .delete(
                                            Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, LocalDate.of(2017, 4, 18), "java")
                                    )
                    ).isEqualTo(1);

                    bookDao.insert(Book.create("978-1492080510", "High Performance MySQL: Proven Strategies for Operating at Scale", 6312, LocalDate.of(2021, 12, 24), "mysql"));

                    List<Book> books = bookDao.selectAllOrderByPriceDesc();
                    assertThat(books).hasSize(5);
                });
    }

@DataSetアノテーションでデータをロードし、テスト内でデータの削除・追加をして、@ExpectedDataSetでアサーションします。

用意したファイルは、こんな感じです。

src/test/resources/dataset/expectedBooks.yml

book:
  - isbn: "978-4295008477"
    title: "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"
    price: 2860
    publish_date: "2020-03-13"
    category: "java"
  - isbn: "978-4621303252"
    title: "Effective Java 第3版"
    price: 4400
    publish_date: "2018-10-30"
    category: "java"
  - isbn: "978-4798161488"
    title: "MySQL徹底入門 第4版 MySQL 8.0対応"
    price: 4180
    publish_date: "2020-07-06"
    category: "mysql"
  - isbn: "978-4798147406"
    title: "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド"
    price: 3960
    publish_date: "2016-08-26"
    category: "mysql"
  - isbn: "978-1492080510"
    title: "High Performance MySQL: Proven Strategies for Operating at Scale"
    price: 1006312
    publish_date: "2021-12-24"
    category: "mysql"

データのアサーションの際には、orderByでソート順を指定した方が良さそうですね。

    @ExpectedDataSet(value = "dataset/expectedBooks.yml", orderBy = "isbn", ignoreCols = "price")

今回はisbnカラムでソートしました。これを指定していなくて、最初アサーションに失敗しましたね…。

また、比較対象にしないカラムをignoreColsで指定しています。これを確認するために、登録するデータと

                    bookDao.insert(Book.create("978-1492080510", "High Performance MySQL: Proven Strategies for Operating at Scale", 6312, LocalDate.of(2021, 12, 24), "mysql"));

アサーションするデータを微妙にずらしています。priceカラムですね。

  - isbn: "978-1492080510"
    title: "High Performance MySQL: Proven Strategies for Operating at Scale"
    price: 1006312
    publish_date: "2021-12-24"
    category: "mysql"

これで、アサーションに失敗するとどうなるか確認してみます。データの削除・登録するコードは残しつつ、アサーションで使用するファイルを
入力と同じにしてみます。

    @Test
    @DataSet("dataset/books.yml")
    @ExpectedDataSet(value = "dataset/books.yml", orderBy = "isbn", ignoreCols = "price")
    public void expectedDataSetFailure() {
        BookDao bookDao = new BookDaoImpl(DomaConfig.singleton());

        DomaConfig
                .singleton()
                .getTransactionManager()
                .required(() -> {
                    assertThat(
                            bookDao
                                    .delete(
                                            Book.create("978-4774189093", "Java本格入門 〜モダンスタイルによる基礎からオブジェクト指向・実用ライブラリまで", 3278, LocalDate.of(2017, 4, 18), "java")
                                    )
                    ).isEqualTo(1);

                    bookDao.insert(Book.create("978-1492080510", "High Performance MySQL: Proven Strategies for Operating at Scale", 6312, LocalDate.of(2021, 12, 24), "mysql"));

                    List<Book> books = bookDao.selectAllOrderByPriceDesc();
                    assertThat(books).hasSize(5);
                });
    }

結果。

org.dbunit.assertion.DbComparisonFailure: value (table=book, row=0, col=category) expected:<java> but was:<mysql>

ちょっとわかりにくい気もしますが…row(行番号)をヒントに探す感じでしょうね…。

最後に、データのエクスポートを行ってみます。プログラムで出力する方法と、@ExportDataSetアノテーションを使う方法があるようです。

Database Rider Getting Started / Exporting DataSets

今回は、@ExportDataSetアノテーションを使ってみます。

以下のテストメソッド実行時の全テーブル(といっても、テーブルはひとつだけですが)のデータをエクスポートします。

    @Test
    @ExportDataSet(format = DataSetFormat.YML, outputName = "target/exported/dataset/exportedAllTables.yml")
    public void exportAllTables() {

    }

フォーマットは、JSON、YAML、XML、XLS、CSVからの指定ですね。デフォルトはYAML(DataSetFormat.YML)です。

出力結果はこちら。

target/exported/dataset/exportedAllTables.yml

book:
  - isbn: "978-1492080510"
    title: "High Performance MySQL: Proven Strategies for Operating at Scale"
    price: 6312
    publish_date: "2021-12-24"
    category: "mysql"
  - isbn: "978-4295008477"
    title: "新世代Javaプログラミングガイド[Java SE 10/11/12/13と言語拡張プロジェクト]"
    price: 2860
    publish_date: "2020-03-13"
    category: "java"
  - isbn: "978-4621303252"
    title: "Effective Java 第3版"
    price: 4400
    publish_date: "2018-10-30"
    category: "java"
  - isbn: "978-4798147406"
    title: "詳解MySQL 5.7 止まらぬ進化に乗り遅れないためのテクニカルガイド"
    price: 3960
    publish_date: "2016-08-26"
    category: "mysql"
  - isbn: "978-4798161488"
    title: "MySQL徹底入門 第4版 MySQL 8.0対応"
    price: 4180
    publish_date: "2020-07-06"
    category: "mysql"

今回は、こんなところでしょうか。

Database Riderの実装的には、こちらのクラスを見ると良さそうな感じでした。

https://github.com/database-rider/database-rider/blob/1.35.0/rider-core/src/main/java/com/github/database/rider/core/dataset/DataSetExecutorImpl.java

まとめ

Database Riderを使ってみました。

背後にDbUnitがあるのですが、かなり簡単に使えるのではないかなと思います。DbUnitを直接使うよりは、こちらの方が良さそうです。

これからは、Database Riderを使ってデータベース関係のテストを行うのも十分ありですね。

Quartzで、指定した時間に1回だけ起動するJobを作成する

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

Quartzを使っていて、指定した時間に1度だけ起動するJobを定義したいなと思いまして。

指定した間隔で起動し続けるJobばかり扱っていたので、どうしたらいいんだろうと思ったのですが、割と単純でした。

1回だけ起動するJobを定義する

TriggerBuilder#withScheduleでどう表現したらいいんだろうとか思っていたのですが、Cookbookにそのまま書いてありました。
正確には、Triggerでコントロールするわけですが。

以下が登録したらすぐに起動し、繰り返し実行は行わないTriggerです。

// Define a Trigger that will fire "now", and not repeat
Trigger trigger = newTrigger()
    .withIdentity("trigger1", "group1")
    .startNow()
    .build();

How-To: Scheduling a Job

TriggerBuilder#withScheduleで起動スケジュールを指定しなければ良いのです。

あとは、TutorialのTriggerに関する記述を見ると、startTimeで最初にTriggerが有効になるタイミングを説明しています。

The “startTime” property indicates when the trigger’s schedule first comes into affect. The value is a java.util.Date object that defines a moment in time on a given calendar date. For some trigger types, the trigger will actually fire at the start time, for others it simply marks the time that the schedule should start being followed. This means you can store a trigger with a schedule such as “every 5th day of the month” during January, and if the startTime property is set to April 1st, it will be a few months before the first firing.

Lesson 4: More About Triggers

CookbookのドキュメントではTriggerBuilder#startNowを使っているので、開始時間が「即時」ですね。ちなみにTriggerBuilder#startNowを
使う場合は、明示的に書かなくてもデフォルト値がそもそも同じ意味になっています。

TriggerBuilder#startAtを使うことで、指定の時間から有効になるTriggerを定義することができます。今回は、こちらが焦点です。

それから、Triggerの期限も指定できるようですね。

The “endTime” property indicates when the trigger’s schedule should no longer be in effect. In other words, a trigger with a schedule of “every 5th day of the month” and with an end time of July 1st will fire for it’s last time on June 5th.

では、試していきたいと思います。

環境

今回の環境は、こちら。

$ java --version
openjdk 17.0.4 2022-07-19
OpenJDK Runtime Environment (build 17.0.4+8-Ubuntu-120.04)
OpenJDK 64-Bit Server VM (build 17.0.4+8-Ubuntu-120.04, mixed mode, sharing)


$ mvn --version
Apache Maven 3.8.6 (84538c9988a25aec085021c365c560670ad80f63)
Maven home: $HOME/.sdkman/candidates/maven/current
Java version: 17.0.4, 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"

準備

Maven依存関係など。

    <properties>
        <maven.compiler.source>17</maven.compiler.source>
        <maven.compiler.target>17</maven.compiler.target>
        <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
        <project.reporting.outputEncoding>UTF-8</project.reporting.outputEncoding>
    </properties>

    <dependencies>
        <dependency>
            <groupId>org.quartz-scheduler</groupId>
            <artifactId>quartz</artifactId>
            <version>2.3.2</version>
        </dependency>
        <dependency>
            <groupId>ch.qos.logback</groupId>
            <artifactId>logback-classic</artifactId>
            <version>1.2.11</version>
        </dependency>
    </dependencies>

ログ出力にはLogbackを使用するので、設定ファイルも用意しておきます。

src/main/resources/logback.xml

<?xml version="1.0" encoding="utf-8"?>
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%d{yyyy-MM-dd HH:mm:ss.SSS} [%thread] %-5level %logger{36} - %msg%n</pattern>
        </encoder>
    </appender>

    <root level="INFO">
        <appender-ref ref="STDOUT"/>
    </root>
</configuration>

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

まずは、Jobを定義します。

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

package org.littlewings.quartz.oneshot;

import org.quartz.Job;
import org.quartz.JobExecutionContext;
import org.quartz.JobExecutionException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

public class PrintMessageJob implements Job {
    Logger logger = LoggerFactory.getLogger(PrintMessageJob.class);

    @Override
    public void execute(JobExecutionContext context) throws JobExecutionException {
        logger.info("[{} / {}] Hello Job!!", context.getJobDetail().getKey(), context.getTrigger().getKey());
    }
}

実行したらメッセージを表示するJobですが、この時にJobDetailのキーとTriggerのキーを出力するようにしました。

mainクラス。

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

package org.littlewings.quartz.oneshot;

import java.time.Duration;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.time.ZoneOffset;
import java.time.temporal.ChronoUnit;
import java.util.Date;
import java.util.Set;
import java.util.concurrent.TimeUnit;

import org.quartz.JobBuilder;
import org.quartz.JobDetail;
import org.quartz.Scheduler;
import org.quartz.SchedulerException;
import org.quartz.Trigger;
import org.quartz.TriggerBuilder;
import org.quartz.impl.StdSchedulerFactory;

public class App {
    public static void main(String... args) {
        Scheduler scheduler = null;
        try {
            scheduler = StdSchedulerFactory.getDefaultScheduler();

            scheduler.start();

            JobDetail printMessageJob =
                    JobBuilder
                            .newJob(PrintMessageJob.class)
                            .withIdentity("printMessageJob1", "job-group1")
                            .build();

            Trigger triggerStartNow1 =
                    TriggerBuilder
                            .newTrigger()
                            .withIdentity("triggerStartNow1", "trigger-group1")
                            .build();

            Trigger triggerStartNow2 =
                    TriggerBuilder
                            .newTrigger()
                            .withIdentity("triggerStartNow2", "trigger-group1")
                            .startNow()
                            .build();

            Trigger triggerStartDelay1 =
                    TriggerBuilder
                            .newTrigger()
                            .withIdentity("triggerStartDelay1", "trigger-group1")
                            .startAt(Date.from(LocalDateTime.now().plusSeconds(5L).atZone(ZoneId.of("Asia/Tokyo")).toInstant()))
                            .build();

            Trigger triggerStartDelay2 =
                    TriggerBuilder
                            .newTrigger()
                            .withIdentity("triggerStartDelay2", "trigger-group1")
                            .startAt(Date.from(LocalDateTime.now().plusSeconds(10L).atZone(ZoneId.of("Asia/Tokyo")).toInstant()))
                            .build();

            Trigger triggerStartBeforeTime =
                    TriggerBuilder
                            .newTrigger()
                            .withIdentity("triggerStartBeforeTime", "trigger-group1")
                            .startAt(Date.from(LocalDateTime.now().minusSeconds(60L).atZone(ZoneId.of("Asia/Tokyo")).toInstant()))
                            .build();

            scheduler.scheduleJob(
                    printMessageJob,
                    Set.of(
                            triggerStartNow1,
                            triggerStartNow2,
                            triggerStartDelay1,
                            triggerStartDelay2,
                            triggerStartBeforeTime
                    ),
                    true
            );

            waitFor(Duration.ofSeconds(20L));
        } catch (SchedulerException e) {
            e.printStackTrace();
        } finally {
            if (scheduler != null) {
                try {
                    scheduler.shutdown(true);
                } catch (SchedulerException e) {
                    e.printStackTrace();
                }
            }
        }
    }

    static void waitFor(Duration waitTime) {
        LocalDateTime startTime = LocalDateTime.now();
        while (ChronoUnit.SECONDS.between(startTime, LocalDateTime.now()) < waitTime.toSeconds()) {
            try {
                TimeUnit.SECONDS.sleep(3L);
            } catch (InterruptedException e) {
                // no-op
            }
        }
    }
}

とりあえず、Schedulerを開始します。

            scheduler = StdSchedulerFactory.getDefaultScheduler();

            scheduler.start();

JobDetailを作成。

            JobDetail printMessageJob =
                    JobBuilder
                            .newJob(PrintMessageJob.class)
                            .withIdentity("printMessageJob1", "job-group1")
                            .build();

このJobDetailに関連付けるTriggerを作成していきます。

まずは、即時起動するTrigger。

            Trigger triggerStartNow1 =
                    TriggerBuilder
                            .newTrigger()
                            .withIdentity("triggerStartNow1", "trigger-group1")
                            .build();

            Trigger triggerStartNow2 =
                    TriggerBuilder
                            .newTrigger()
                            .withIdentity("triggerStartNow2", "trigger-group1")
                            .startNow()
                            .build();

違いはTriggerBuilder#startNowを呼んでいるかどうかですが、startTimeのデフォルト値はnew Date()なので、開始タイミングという意味では
両者は等価です。

Schedulerに登録次第、すぐに開始します。

続いて、TriggerBuilder#startAtで指定した時間に起動するTrigger。今回は、5秒後と10秒後に起動するTriggerを用意。

            Trigger triggerStartDelay1 =
                    TriggerBuilder
                            .newTrigger()
                            .withIdentity("triggerStartDelay1", "trigger-group1")
                            .startAt(Date.from(LocalDateTime.now().plusSeconds(5L).atZone(ZoneId.of("Asia/Tokyo")).toInstant()))
                            .build();

            Trigger triggerStartDelay2 =
                    TriggerBuilder
                            .newTrigger()
                            .withIdentity("triggerStartDelay2", "trigger-group1")
                            .startAt(Date.from(LocalDateTime.now().plusSeconds(10L).atZone(ZoneId.of("Asia/Tokyo")).toInstant()))
                            .build();

あと、過去の時間にするとどうなるかというのも確認してみましょう。

            Trigger triggerStartBeforeTime =
                    TriggerBuilder
                            .newTrigger()
                            .withIdentity("triggerStartBeforeTime", "trigger-group1")
                            .startAt(Date.from(LocalDateTime.now().minusSeconds(60L).atZone(ZoneId.of("Asia/Tokyo")).toInstant()))
                            .build();

(たぶんMisfire扱いになっているからだと思うのですが)答えとしては、即時起動になります。

これらのTriggerを、一括でJobDetailに関連付けてスケジューリング。

            scheduler.scheduleJob(
                    printMessageJob,
                    Set.of(
                            triggerStartNow1,
                            triggerStartNow2,
                            triggerStartDelay1,
                            triggerStartDelay2,
                            triggerStartBeforeTime
                    ),
                    true
            );

終了まで、20秒待ちます。

            waitFor(Duration.ofSeconds(20L));

確認する

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

$ mvn compile exec:java -Dexec.mainClass=org.littlewings.quartz.oneshot.App

結果はこちら。

2022-11-03 17:03:06.440 [DefaultQuartzScheduler_Worker-3] INFO  o.l.quartz.oneshot.PrintMessageJob - [job-group1.printMessageJob1 / trigger-group1.triggerStartBeforeTime] Hello Job!!
2022-11-03 17:03:06.440 [DefaultQuartzScheduler_Worker-1] INFO  o.l.quartz.oneshot.PrintMessageJob - [job-group1.printMessageJob1 / trigger-group1.triggerStartNow1] Hello Job!!
2022-11-03 17:03:06.440 [DefaultQuartzScheduler_Worker-2] INFO  o.l.quartz.oneshot.PrintMessageJob - [job-group1.printMessageJob1 / trigger-group1.triggerStartNow2] Hello Job!!
2022-11-03 17:03:11.435 [DefaultQuartzScheduler_Worker-4] INFO  o.l.quartz.oneshot.PrintMessageJob - [job-group1.printMessageJob1 / trigger-group1.triggerStartDelay1] Hello Job!!
2022-11-03 17:03:16.434 [DefaultQuartzScheduler_Worker-5] INFO  o.l.quartz.oneshot.PrintMessageJob - [job-group1.printMessageJob1 / trigger-group1.triggerStartDelay2] Hello Job!!

TriggerBuilder#startAtで過去時間を指定したものは即時起動、

2022-11-03 17:03:06.440 [DefaultQuartzScheduler_Worker-3] INFO  o.l.quartz.oneshot.PrintMessageJob - [job-group1.printMessageJob1 / trigger-group1.triggerStartBeforeTime] Hello Job!!

未指定、またはTriggerBuilder#startNowを指定したものも即時起動、

2022-11-03 17:03:06.440 [DefaultQuartzScheduler_Worker-1] INFO  o.l.quartz.oneshot.PrintMessageJob - [job-group1.printMessageJob1 / trigger-group1.triggerStartNow1] Hello Job!!
2022-11-03 17:03:06.440 [DefaultQuartzScheduler_Worker-2] INFO  o.l.quartz.oneshot.PrintMessageJob - [job-group1.printMessageJob1 / trigger-group1.triggerStartNow2] Hello Job!!

TriggerBuilder#startAtで5秒、10秒遅らせて起動させるようにしたもの。

2022-11-03 17:03:11.435 [DefaultQuartzScheduler_Worker-4] INFO  o.l.quartz.oneshot.PrintMessageJob - [job-group1.printMessageJob1 / trigger-group1.triggerStartDelay1] Hello Job!!
2022-11-03 17:03:16.434 [DefaultQuartzScheduler_Worker-5] INFO  o.l.quartz.oneshot.PrintMessageJob - [job-group1.printMessageJob1 / trigger-group1.triggerStartDelay2] Hello Job!!

わかりやすい結果になりました。

これで、今回確認したいことはOKです。

まとめ

Quartzで、指定した時間に1回だけ起動するJobを作成してみました。

割とSimpleTriggerやCronTriggerなどに目が行きがちだったので、盲点といえば盲点でしたね。

使い方として覚えておきましょう。