Spring Bootのschedulingで定期実行を変幻自在にしてみた

## はじめに

システム開発に関わっていると、定期実行しているバッチを目にすることが多いと思います。
JP1で定期実行の設定をしたり、ECSでコンテナ化して定期実行の設定を入れるなど、様々な方法があります。

今回は、私がSpring FrameworkのTaskScheduler機能を採用して定期実行を実現した方法について紹介します。

具体的には以下のポイントを取り上げます。

- TaskSchedulerを採用した理由
- java.util.concurrent.ScheduledExecutorServiceを使わなかった理由
- どのように実現したのか


これらのポイントを通して、TaskSchedulerを使うことでどのようなメリットがあるのか、具体的な実装方法について解説していきます。

## 開発環境

- 言語：Java21
- フレームワーク：Spring Boot v3.1.4, Spring v6.0.12
- ビルドツール：gradle 8.5
- IDE：IntelliJ IDEA 2024.1.2

## 問題

### 定期実行を停止・再開の操作をしたい

`@Scheduled`アノテーションを使ったタスクスケジューリングを使用しているバッチですが、いくつかの不満がありました。
それは、システムリリースのタイミングが縛られるということです。`@Scheduled`アノテーションでは、停止や再開、実行状況の確認といった操作はデフォルトではサポートされていません。

私の所属している現場では、10分に一度の間隔で定期実行しているタスクがあり、大体2～3分くらいの実行時間になります。
その影響でリリースの時間が制約されてしまうのは少々ストレスになるので、この問題を解決するために定期実行を停止・再開できる方法を調査しました。

## 解決方法

### TaskSchedulerを採用

結論から言うと、今回は`org.springframework.scheduling.TaskScheduler`を用いて解決に至りました。
一時停止や再開、ステータス確認などの機能を実装するには、`TaskScheduler`や`ScheduledExecutorService`を使用することが一般的ですが、Spring Bootを使用しているため、TaskSchedulerを採用しました。その理由を以下に記載します。

- **スケジューリング設定の管理が容易**
    - スケジュールの設定がソース内に散らばっていると管理が難しい
    - Spring Bootの`application.properties`やYAMLファイルを使用して一元管理が可能
- **スケジューリング機能の柔軟性**
    - Cron式をサポートしているので、複雑なスケジューリングが簡単に実装できる
- **タスクの一時停止や再開が容易**
    - `TaskScheduler`を用いることで、容易に一時停止や再開が可能
- **DI（Dependency Injection）との統合**
    - TaskSchedulerを@Beanで定義することで、Springのコンテナによって管理され、他のBeanから簡単にアクセスできる
    - テスト時にモックのTaskSchedulerを注入できるため、依存するコンポーネントの単体テストが簡単になる
    - TaskSchedulerの設定（例：スレッドプールのサイズ）を一箇所で集中管理できる

DIの一例

```java
@Configuration
public class SchedulerConfig {

    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(10);
        taskScheduler.setThreadNamePrefix("TaskScheduler-");
        return taskScheduler;
    }
}
```

```java
@Service
public class MyService {

    private final ThreadPoolTaskScheduler taskScheduler;

    public MyService(ThreadPoolTaskScheduler taskScheduler) {
        this.taskScheduler = taskScheduler;
    }

    public void scheduleTask(Runnable task, long delay) {
        taskScheduler.scheduleWithFixedDelay(task, delay);
    }
}
```

### java.util.concurrent.ScheduledExecutorServiceを使わなかった理由

`java.util.concurrent.ScheduledExecutorService`を使わなかった理由について一言でいうならば、Springを使用していたからです。以下に詳細な理由を記載します。

- **タスクの開始と停止**
    - タスクのスケジュールと停止を手動で行う必要がある
- **リソースリーク防止**
    - アプリケーションシャットダウン時にリソースリークを防ぐための適切な終了処理が必要
    - リソースが適切に解放されずに保持され続けることで、システムのリソースが枯渇し、最終的にはプログラムやシステム全体のパフォーマンスや安定性に悪影響を与える

## どのように実現するか

### 依存関係の追加

`spring-boot-starter-scheduling`の依存関係を`build.gradle`に追加

```gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter-scheduling'
}

```

### タスクスケジューラーのBean定義

`TaskScheduler`を利用するために、スケジューラーのBeanを設定

```java
@Configuration
public class SchedulerConfig {

    @Bean
    public ThreadPoolTaskScheduler taskScheduler() {
        ThreadPoolTaskScheduler taskScheduler = new ThreadPoolTaskScheduler();
        taskScheduler.setPoolSize(xxx);
        taskScheduler.setThreadNamePrefix("xxx");
        return taskScheduler;
    }
}
```

### タスク管理サービスの作成

タスクの停止・再開・ステータス管理を行うためのクラスを作成

```java
@Component
@RequiredArgsConstructor
@Slf4j
public class TaskService {

    private final ThreadPoolTaskScheduler taskScheduler;

    private ScheduledFuture<?> scheduledFuture;

    public boolean startTask(Runnable task, String cron) {
        try {
            if (!isTaskRunning()) {
                scheduledFuture = taskScheduler.schedule(task, new CronTrigger(cron));
                return true;
            }
        } catch (RejectedExecutionException e) {
            log.error("Error has occurred. message={}.", e.getMessage());
        }
        return false;
    }

    /**
     * 実行中のタスクを中断せずにキャンセル
     */
    public boolean safeCancelTask() {
        if (scheduledFuture != null) {
            return scheduledFuture.cancel(false);
        }
        return false;
    }

    /**
     * 実行中のタスクを強制的に中断してキャンセル
     */
    public boolean forceCancelTask() {
        if (scheduledFuture != null) {
            return scheduledFuture.cancel(true);
        }
        return false;
    }

    /**
     * タスクが現在実行中かどうかを判定
     *
     * @return タスクが実行中の場合はtrue、それ以外の場合はfalse
     */
    public boolean isTaskRunning() {
        return scheduledFuture != null && !scheduledFuture.isCancelled();
    }
}
```

### エンドポイントの作成

タスクの一時停止、再開、ステータス確認を行うエンドポイントを作成

```java
@Component
@Endpoint(id="task")
@RequiredArgsConstructor
@Slf4j
public class TaskEndpoint {

    private final TaskService taskService

    private final Task task;

    private final SchedulerConfig config;

    @WriteOperation
    public void start() {
        boolean taskStarted = taskService.startTask(task::execute, schedulerConfiguration.getCron());
        log.info("Test task {}", taskStarted ? "started" : "not started");
    }
    
    // 他は省略
}
```

### プロパティファイルの設定

アクチュエータエンドポイントを有効にし、Cron形式の設定値も記載

```groovy
management:
  endpoints:
    web:
      exposure:
        include: "*"
scheduler:
  task:
    scheduling:
      # cron: "0 0/10 * * * *" # 10分ごとのCron式
```

### エンドポイントの使用方法

- タスクの開始: `POST /actuator/task/start`
- タスクの停止: `POST /actuator/task/stop`　（エンドポイントに追加すればOK）
- タスクのステータス確認: `GET /actuator/task` 　（エンドポイントに追加すればOK）

## おわりに

今回は、定期実行しているタスクに停止・再開・ステータス確認を行う機能を追加しました。Spring Frameworkのschedulingを使用したおかげで、比較的容易に実装することができました。

特に、`ThreadPoolTaskScheduler`クラスの停止や再開を行うメソッドを使用することで、数行のコードで実装することができました。

今回はタスクの停止・再開・ステータス確認に観点を絞って説明しましたので、定期実行の設定やエンドポイントに関する詳細な記載は省略しました。

今後は、Springに関する記事や、さらに複雑なスケジューリングタスクの管理方法や、異なるスケジューリングライブラリとの比較についても取り上げたいと思います。

読者の皆さんも、この記事を参考にして、ぜひご自身のプロジェクトでタスクのスケジューリング機能を実装してみてください。ご質問やフィードバックがありましたら、ぜひコメントでお知らせください。