モダンJavaバッチ開発:JobOperatorの主要機能でバッチジョブを自在に操るハンズオン

はじめに

Spring Batchは強力なバッチ処理フレームワークですが、作ったジョブを「どのように動かし、管理するか」は多くの開発者が悩むポイントです。

特に、Webアプリケーションの管理画面からジョブを起動したり、長時間実行されるジョブを安全に停止したり、失敗したジョブを特定の時点から再開したりといった運用管理は、堅牢なシステムを構築する上で欠かせません。

この記事では、Spring Batchが提供する強力な運用管理ツールJobOperatorに焦点を当てます。

その基本的な使い方から、Web UI経由でジョブを動的にコントロールする実践的なサンプルアプリケーションの開発、さらには開発過程で遭遇しがちな「ハマりどころ」とその解決策までを、APIの視点も交えて詳細に解説します。


Spring Batchのパラメータ駆動とジョブの起動・停止の詳細については、以下の記事で詳細に解説していますので、是非ご覧ください。


目次

  • 1. アプリケーションの仕様
    • 1-1. 目的と主要な学習項目
    • 1-2. 概要
    • 1-3. 主要なAPIエンドポイント
    • 1-4. フォルダ構成
  • 2. JobOperatorとは? – 機能と有効化
    • 2-1. JobOperatorが提供する主な機能
      • JobOperatorの有効化(Spring Boot 3.2以降)
  • 3. 実装の詳細
    • 3-1. 準備:長時間実行されるサンプルジョブ
      • StoppableTasklet.java
      • StoppableJobConfig.java
    • 3-2. APIとUIの作成
      • JobOperationController.java
      • job-operator.html
  • 4. 実行方法
    • 4-1. 準備
    • 4-2. Web UIからの実行
    • 4-3. APIでの直接実行
      • 手順1: ジョブの起動
      • 手順2: ジョブの停止
      • 手順3: ジョブの再開
      • 補足: 情報取得API
  • 5. 実行と結果解説
    • 5-1. ジョブの起動
    • 5-2. ジョブの停止
    • 5-3. ジョブの再開
  • 6. 応用シナリオ
    • 6-1. 応用的な操作シナリオ
      • シナリオA:複数ジョブの並行実行と、特定のジョブのみを停止する
      • シナリオB:完了済みジョブの操作とJobInstanceの理解
  • 7. まとめ

対象読者

  • Spring Batchジョブの運用管理に興味のあるJava開発者。
  • JobOperatorの機能(起動、停止、再開など)を実践的に学びたい方。
  • Web UIやAPI経由でバッチジョブをコントロールする方法を習得したい方。
  • Spring BatchのJobLauncherJobOperatorJobExplorerの使い分けを理解したい方。

1. アプリケーションの仕様

1-1. 目的と主要な学習項目

本ハンズオンは、Spring Batchが提供するJobOperatorインターフェースの役割と機能を実践的に学ぶことを目的としています。

Webアプリケーション上からバッチジョブを動的に起動、停止、再開する一連の運用フローを実装し、その過程で発生する技術的な課題と解決策を通じて、堅牢なバッチ管理システムの構築方法を習得します。

  • JobOperatorの主要な機能(startNextInstance, stop, restartなど)の利用方法。
  • JobExplorerを使ったジョブ実行状態の監視。
  • JobLauncherと連携したジョブの起動。
  • ジョブの安全な停止(Graceful Shutdown)の実装パターン。
  • Spring Bootの自動設定と手動設定の相互作用に関する理解。
  • 開発時によく遭遇するエラー(Bean定義の重複、JSONシリアライズエラー)の原因特定とデバッグ手法。

1-2. 概要

  • 使用技術: Spring Boot, Spring Batch, JPA(Hibernate), H2 Database, Java。
  • Job構成: 意図的に30秒間実行され続けるstoppableJobを定義します。
  • UI/API:
    • Web UI (job-operator.html) から、ジョブの起動、停止、再開を操作できます。
    • Web UIは、ジョブの実行履歴と現在のステータスを5秒ごとに自動でポーリングして表示します。
    • すべての操作は、バックエンドのJobOperationControllerが提供するREST APIを介して行われます。

1-3. 主要なAPIエンドポイント

本ハンズオンで実装する主要なAPIエンドポイントは以下の通りです。

HTTPメソッドパス役割
POST/job/operator/launchstoppableJobを起動します。
POST/job/operator/stop/{id}指定されたjobExecutionIdのジョブを停止します。
POST/job/operator/restart/{id}指定されたjobExecutionIdのジョブを再開します。
GET/job/operator/names登録されている全てのジョブ名を取得します。
GET/job/operator/executions/recent最近のジョブ実行履歴(最大20件)を取得します。

1-4. フォルダ構成

このハンズオンで作成・変更される主要なファイルは以下の通りです。

src/main/java/com/example/springbatchh2crud/
├── config/
   ├── StoppableJobConfig.java  // 停止可能なジョブの定義
   └── JacksonConfig.java       // JSON変換設定(トラブルシューティングで作成)
├── controller/
   └── JobOperationController.java // ジョブ操作用APIコントローラー
└── tasklet/
    └── StoppableTasklet.java    // 長時間実行タスクレット
src/main/resources/
└── static/
    └── job-operator.html        // 操作用Web UI

Spring Batchのサンプルコードのベースを、以下の記事で提供していますので、本記事の実装をする際に、是非ご活用ください。


2. JobOperatorとは? – 機能と有効化

JobOperatorは、実行中のアプリケーションの外部からSpring Batchのジョブを操作するための中心的なインターフェースです。


2-1. JobOperatorが提供する主な機能

JobLauncherがジョブを「起動する」ことに特化しているのに対し、JobOperatorはより広範な運用タスクをカバーします。

  • start(String jobName, String parameters):
    • 新しいジョブインスタンスを起動します。
  • startNextInstance(String jobName):
    • JobParametersIncrementer を使って新しいパラメータでジョブインスタンスを起動します。
  • stop(long jobExecutionId):
    • 実行中のジョブを安全に停止します。
  • restart(long jobExecutionId):
    • 失敗、または停止したジョブを再実行します。
  • abandon(long jobExecutionId):
    • 失敗したジョブを「放棄」し、再実行できないようにします。
  • getSummary(long jobExecutionId):
    • ジョブ実行の概要を取得します。
  • getRunningExecutions(String jobName):
    • 指定されたジョブ名で現在実行中のジョブ実行IDを取得します。

JobOperatorの有効化(Spring Boot 3.2以降)

かつてはJobOperatorを利用するためにいくつかの手動設定が必要でしたが、Spring Boot 3.2以降では、spring-boot-starter-batchの依存関係があれば、JobOperator自動的に設定され、すぐにインジェクションして使用できます。

この自動設定では、JobRegistry(ジョブの台帳)やJobRegistrySmartInitializingSingleton(ジョブの自動登録部品)といったコンポーネントがあり、これらもSpring Bootが自動で設定してくれます。


3. 実装の詳細

ここからは、実際にJobOperatorを使ってジョブを管理するアプリケーションを構築していきます。

3-1. 準備:長時間実行されるサンプルジョブ

StoppableTasklet.java

ジョブの「停止」操作の効果を分かりやすく確認するため、意図的に30秒間かかり続ける単純なジョブを作成します。

package com.example.springbatchh2crud.tasklet;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.StepContribution;
import org.springframework.batch.core.scope.context.ChunkContext;
import org.springframework.batch.core.step.tasklet.Tasklet;
import org.springframework.batch.repeat.RepeatStatus;

public class StoppableTasklet implements Tasklet {
    // ロギングのためのLoggerインスタンス
    private static final Logger log = LoggerFactory.getLogger(StoppableTasklet.class);
    // タスクの実行時間(秒)
    private static final int DURATION_SECONDS = 30;
    // スリープ間隔(ミリ秒)
    private static final int SLEEP_INTERVAL_MS = 1000;

    @Override
    public RepeatStatus execute(StepContribution contribution, ChunkContext chunkContext) throws Exception {
        // 現在のジョブ実行IDを取得
        long jobExecutionId = chunkContext.getStepContext().getStepExecution().getJobExecutionId();
        log.info("JobExecutionId: [{}]. Starting stoppable task for {} seconds.", jobExecutionId, DURATION_SECONDS);

        // 指定された時間だけループを実行
        for (int i = 0; i < DURATION_SECONDS; i++) {
            try {
                log.info("JobExecutionId: [{}]. Running... {}/{} seconds.", jobExecutionId, i + 1, DURATION_SECONDS);
                // 指定された間隔でスリープ
                Thread.sleep(SLEEP_INTERVAL_MS);
            } catch (InterruptedException e) {
                // タスクが中断された場合の処理
                log.warn("JobExecutionId: [{}]. Task was interrupted. Finishing gracefully.", jobExecutionId);
                // スレッドの中断状態を復元
                Thread.currentThread().interrupt();
                // ジョブを停止済みとして終了
                return RepeatStatus.FINISHED;
            }
        }

        log.info("JobExecutionId: [{}]. Stoppable task finished.", jobExecutionId);
        // タスクが正常に完了
        return RepeatStatus.FINISHED;
    }
}

ポイント解説:Graceful Shutdown(安全な停止)
JobOperator.stop()が呼び出されると、実行中のスレッドに「割り込み(Interrupt)」が発生します。Thread.sleep()のようなブロッキング処理はInterruptedExceptionをスローするため、これをcatchブロックで捕捉します。ここでリソースのクリーンアップなどを行い、処理を中途半端な状態で終わらせないようにすることが「Graceful Shutdown」の基本です。


StoppableJobConfig.java

上記のTaskletを実行するJobStepを定義します。

package com.example.springbatchh2crud.config;

import org.springframework.batch.core.Job;
import org.springframework.batch.core.Step;
import org.springframework.batch.core.job.builder.JobBuilder;
import org.springframework.batch.core.launch.support.RunIdIncrementer;
import org.springframework.batch.core.repository.JobRepository;
import org.springframework.batch.core.step.builder.StepBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.transaction.PlatformTransactionManager;

import com.example.springbatchh2crud.listener.LoggingStepExecutionListener;
import com.example.springbatchh2crud.tasklet.StoppableTasklet;

@Configuration // Springの設定クラスであることを示すアノテーション
public class StoppableJobConfig {

    // Stepの実行リスナー(ログ出力用)
    private final LoggingStepExecutionListener loggingStepExecutionListener;

    // コンストラクタインジェクションでリスナーを注入
    public StoppableJobConfig(LoggingStepExecutionListener loggingStepExecutionListener) {
        this.loggingStepExecutionListener = loggingStepExecutionListener;
    }

    @Bean // SpringコンテナにStoppableTaskletのBeanを登録
    public StoppableTasklet stoppableTasklet() {
        return new StoppableTasklet(); // StoppableTaskletのインスタンスを生成
    }

    @Bean // SpringコンテナにStepのBeanを登録
    public Step stoppableStep(JobRepository jobRepository, PlatformTransactionManager transactionManager,
            StoppableTasklet stoppableTasklet) {
        // StepBuilderを使ってStepを構築
        return new StepBuilder("stoppableStep", jobRepository) // Step名を指定し、JobRepositoryを使用
                .tasklet(stoppableTasklet, transactionManager) // 実行するTaskletとトランザクションマネージャーを指定
                .listener(loggingStepExecutionListener) // Step実行リスナーを登録
                .build(); // Stepをビルド
    }

    @Bean // SpringコンテナにJobのBeanを登録
    public Job stoppableJob(JobRepository jobRepository, Step stoppableStep) {
        // JobBuilderを使ってJobを構築
        return new JobBuilder("stoppableJob", jobRepository) // Job名を指定し、JobRepositoryを使用
                .incrementer(new RunIdIncrementer()) // ジョブパラメータを自動でインクリメントする設定
                .start(stoppableStep) // 最初に実行するStepを指定
                .build(); // Jobをビルド
    }
}

3-2. APIとUIの作成

JobOperatorの機能をWebから呼び出すための@RestControllerと、ブラウザで操作するためのHTMLファイルを作成します。

JobOperationController.java

JobOperatorJobLauncherJobExplorerをインジェクションして、各操作に対応するエンドポイントを実装します。

package com.example.springbatchh2crud.controller;

import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.stream.Collectors;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.batch.core.Job;
import org.springframework.batch.core.JobExecution;
import org.springframework.batch.core.JobParameters;
import org.springframework.batch.core.JobParametersBuilder;
import org.springframework.batch.core.explore.JobExplorer;
import org.springframework.batch.core.launch.JobLauncher;
import org.springframework.batch.core.launch.JobOperator;
import org.springframework.batch.core.launch.NoSuchJobException;
import org.springframework.batch.core.launch.NoSuchJobExecutionException;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;

@RestController // このクラスがRESTコントローラーであることを示すアノテーション
@RequestMapping("/job/operator") // このコントローラーのベースパスを設定
public class JobOperationController {

    // ロギングのためのLoggerインスタンス
    private static final Logger log = LoggerFactory.getLogger(
        JobOperationController.class
    );

    // Spring Batchのジョブを起動するためのインターフェース
    private final JobLauncher jobLauncher;
    // Spring Batchのジョブ操作(停止、再起動など)のためのインターフェース
    private final JobOperator jobOperator;
    // Spring Batchのジョブ実行情報を探索するためのインターフェース
    private final JobExplorer jobExplorer;
    // 操作対象のジョブ(stoppableJobという名前のBeanを注入)
    private final Job stoppableJob;

    // コンストラクタインジェクションで必要な依存関係を注入
    public JobOperationController(
        JobLauncher jobLauncher,
        JobOperator jobOperator,
        JobExplorer jobExplorer,
        @Qualifier("stoppableJob") Job stoppableJob // stoppableJobという名前のBeanを特定して注入
    ) {
        this.jobLauncher = jobLauncher;
        this.jobOperator = jobOperator;
        this.jobExplorer = jobExplorer;
        this.stoppableJob = stoppableJob;
    }

    @PostMapping("/launch") // POSTリクエストを/job/operator/launchにマッピング
    public ResponseEntity<Map<String, Object>> launchJob() {
        log.info("Request received to launch the stoppableJob.");
        try {
            // ジョブパラメータを構築(現在時刻をパラメータとして追加)
            JobParameters jobParameters = new JobParametersBuilder()
                .addLocalDateTime("launchDate", LocalDateTime.now())
                .toJobParameters();
            // ジョブを起動
            JobExecution jobExecution = jobLauncher.run(
                stoppableJob,
                jobParameters
            );
            // 成功レスポンスを構築
            Map<String, Object> response = new HashMap<>();
            response.put("message", "Job launched successfully.");
            response.put("jobExecutionId", jobExecution.getId());
            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("Error launching job", e);
            // エラーレスポンスを構築
            Map<String, Object> response = new HashMap<>();
            response.put("message", "Error launching job: " + e.getMessage());
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
                response
            );
        }
    }

    @PostMapping("/stop/{jobExecutionId}") // POSTリクエストを/job/operator/stop/{jobExecutionId}にマッピング
    public ResponseEntity<Map<String, String>> stopJob(
        @PathVariable long jobExecutionId // パス変数からjobExecutionIdを取得
    ) {
        log.info(
            "Request received to stop job with execution ID: {}",
            jobExecutionId
        );
        Map<String, String> response = new HashMap<>();
        try {
            // 指定されたjobExecutionIdのジョブに停止シグナルを送信
            boolean stopped = jobOperator.stop(jobExecutionId);
            if (stopped) {
                response.put(
                    "message",
                    "Job " + jobExecutionId + " stop signal sent."
                );
                return ResponseEntity.ok(response);
            } else {
                response.put(
                    "message",
                    "Failed to send stop signal to job " +
                        jobExecutionId +
                        ". It may have already completed or been stopped."
                );
                return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
                    response
                );
            }
        } catch (NoSuchJobExecutionException e) {
            log.error("Job execution not found: {}", jobExecutionId, e);
            response.put(
                "message",
                "Job execution with ID " + jobExecutionId + " not found."
            );
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(response);
        } catch (Exception e) {
            log.error("Error stopping job {}", jobExecutionId, e);
            response.put("message", "Error stopping job: " + e.getMessage());
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
                response
            );
        }
    }

    @PostMapping("/restart/{jobExecutionId}") // POSTリクエストを/job/operator/restart/{jobExecutionId}にマッピング
    public ResponseEntity<Map<String, Object>> restartJob(
        @PathVariable long jobExecutionId // パス変数からjobExecutionIdを取得
    ) {
        log.info(
            "Request received to restart job with execution ID: {}",
            jobExecutionId
        );
        Map<String, Object> response = new HashMap<>();
        try {
            // 指定されたjobExecutionIdのジョブを再起動
            Long newJobExecutionId = jobOperator.restart(jobExecutionId);
            response.put(
                "message",
                "Job " + jobExecutionId + " restarted successfully."
            );
            response.put("newJobExecutionId", newJobExecutionId);
            return ResponseEntity.ok(response);
        } catch (Exception e) {
            log.error("Error restarting job {}", jobExecutionId, e);
            response.put("message", "Error restarting job: " + e.getMessage());
            return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(
                response
            );
        }
    }

    @GetMapping("/names") // GETリクエストを/job/operator/namesにマッピング
    public ResponseEntity<Set<String>> getJobNames() {
        log.info("Request received to get all job names.");
        // 登録されている全てのジョブ名を取得して返却
        return ResponseEntity.ok(jobOperator.getJobNames());
    }

    @GetMapping("/running/{jobName}") // GETリクエストを/job/operator/running/{jobName}にマッピング
    public ResponseEntity<Set<Long>> getRunningExecutions(
        @PathVariable String jobName // パス変数からjobNameを取得
    ) {
        log.info(
            "Request received to get running executions for job: {}",
            jobName
        );
        try {
            // 指定されたジョブ名の実行中のジョブ実行IDを取得して返却
            return ResponseEntity.ok(jobOperator.getRunningExecutions(jobName));
        } catch (NoSuchJobException e) {
            log.warn("Job with name {} not found.", jobName, e);
            // ジョブが見つからない場合は404を返却
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(Set.of());
        }
    }

    @GetMapping("/summary/{jobExecutionId}") // GETリクエストを/job/operator/summary/{jobExecutionId}にマッピング
    public ResponseEntity<String> getSummary(
        @PathVariable long jobExecutionId // パス変数からjobExecutionIdを取得
    ) {
        log.info(
            "Request received to get summary for job execution: {}",
            jobExecutionId
        );
        try {
            // 指定されたジョブ実行IDのサマリー情報を取得して返却
            return ResponseEntity.ok(jobOperator.getSummary(jobExecutionId));
        } catch (NoSuchJobExecutionException e) {
            log.warn("Job execution with ID {} not found.", jobExecutionId, e);
            // ジョブ実行が見つからない場合は404を返却
            return ResponseEntity.status(HttpStatus.NOT_FOUND).body(
                "Job execution not found."
            );
        }
    }

    @GetMapping("/executions/recent") // GETリクエストを/job/operator/executions/recentにマッピング
    public ResponseEntity<List<Map<String, Object>>> getRecentExecutions() {
        log.info("Request received to get recent job executions.");
        List<Map<String, Object>> executionSummaries = new ArrayList<>();

        Set<String> jobNames = jobOperator.getJobNames(); // 全てのジョブ名を取得
        if (jobNames.isEmpty()) {
            return ResponseEntity.ok(executionSummaries); // ジョブがない場合は空のリストを返却
        }

        for (String jobName : jobNames) { // 各ジョブ名について処理
            try {
                // ジョブ名からジョブインスタンスを取得し、それに関連するジョブ実行を取得
                List<JobExecution> executions = jobExplorer
                    .findJobInstancesByJobName(jobName, 0, 10) // 最新10件のジョブインスタンスを取得
                    .stream()
                    .flatMap(instance ->
                        jobExplorer.getJobExecutions(instance).stream() // 各インスタンスのジョブ実行を取得
                    )
                    .collect(Collectors.toList());

                for (JobExecution exec : executions) { // 各ジョブ実行についてサマリーを作成
                    Map<String, Object> summary = new HashMap<>();
                    summary.put("jobExecutionId", exec.getId());
                    summary.put(
                        "jobInstanceId",
                        exec.getJobInstance().getInstanceId()
                    );
                    summary.put("jobName", exec.getJobInstance().getJobName());
                    summary.put("status", exec.getStatus());
                    summary.put("exitCode", exec.getExitStatus().getExitCode());
                    summary.put("startTime", exec.getStartTime());
                    summary.put("endTime", exec.getEndTime());
                    summary.put("createTime", exec.getCreateTime());
                    summary.put("lastUpdated", exec.getLastUpdated());
                    executionSummaries.add(summary);
                }
            } catch (Exception e) {
                log.error("Error fetching executions for job: {}", jobName, e);
            }
        }

        // 実行サマリーを最新の作成時刻でソート
        executionSummaries.sort((m1, m2) -> {
            LocalDateTime t1 = (LocalDateTime) m1.get("createTime");
            LocalDateTime t2 = (LocalDateTime) m2.get("createTime");
            if (t1 == null && t2 == null) return 0;
            if (t1 == null) return 1;
            if (t2 == null) return -1;
            return t2.compareTo(t1);
        });

        // 最新20件の実行サマリーを返却
        return ResponseEntity.ok(
            executionSummaries.stream().limit(20).collect(Collectors.toList())
        );
    }
}

ポイント解説:JobOperatorJobExplorerの使い分け

  • JobOperator: ジョブのライフサイクルを変更する操作(起動、停止、再開)に使います。
  • JobExplorer: ジョブの実行履歴や状態を参照する操作に使います。
    このように役割が明確に分かれているため、コードの意図が分かりやすくなります。

job-operator.html

このファイルをsrc/main/resources/static/に配置します。APIを簡単にテストするためのシンプルなUIです。

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Spring Batch Job Operator</title>
    <style>
        /* スタイリングは省略 */
        body { font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif; margin: 2em; background-color: #f4f4f9; color: #333; }
        h1, h2 { color: #4a4a4a; border-bottom: 2px solid #007bff; padding-bottom: 0.3em; }
        .container { max-width: 1200px; margin: 0 auto; background: #fff; padding: 2em; border-radius: 8px; box-shadow: 0 2px 4px rgba(0,0,0,0.1); }
        .section { margin-bottom: 2.5em; }
        button { background-color: #007bff; color: white; border: none; padding: 10px 15px; border-radius: 4px; cursor: pointer; font-size: 1em; transition: background-color 0.2s; }
        button:hover { background-color: #0056b3; }
        button:disabled { background-color: #cccccc; cursor: not-allowed; }
        input[type="text"] { padding: 8px; border-radius: 4px; border: 1px solid #ccc; font-size: 1em; margin-right: 0.5em; }
        table { width: 100%; border-collapse: collapse; margin-top: 1em; box-shadow: 0 1px 3px rgba(0,0,0,0.05); }
        th, td { border: 1px solid #ddd; padding: 12px; text-align: left; }
        th { background-color: #f8f9fa; }
        tr:nth-child(even) { background-color: #fdfdfd; }
        tr:hover { background-color: #f1f1f1; }
        .status-COMPLETED { color: #28a745; font-weight: bold; }
        .status-STARTED, .status-STARTING { color: #007bff; font-weight: bold; }
        .status-STOPPING { color: #ffc107; font-weight: bold; }
        .status-STOPPED { color: #fd7e14; font-weight: bold; }
        .status-FAILED { color: #dc3545; font-weight: bold; }
        .status-UNKNOWN { color: #6c757d; }
        #toast { position: fixed; top: 20px; right: 20px; background-color: #333; color: white; padding: 15px; border-radius: 5px; z-index: 1000; display: none; box-shadow: 0 2px 10px rgba(0,0,0,0.2); }
        ul { list-style-type: none; padding-left: 0; }
        li { background: #e9ecef; margin-bottom: 5px; padding: 8px 12px; border-radius: 4px; }
    </style>
</head>
<body>

<div class="container">
    <h1>Spring Batch Job Operator</h1>

    <!-- ジョブの起動、停止、再起動を制御するセクション -->
    <div class="section">
        <h2>Job Control</h2>
        <!-- ジョブを起動するボタン -->
        <button id="launchJobBtn">Launch 'stoppableJob'</button>
        <hr>
        <!-- 停止・再起動対象のJobExecutionIdを入力するテキストボックス -->
        <input type="text" id="jobExecutionIdInput" placeholder="Enter JobExecutionId">
        <!-- ジョブを停止するボタン -->
        <button id="stopJobBtn">Stop Job</button>
        <!-- ジョブを再起動するボタン -->
        <button id="restartJobBtn">Restart Job</button>
    </div>

    <!-- 登録されているジョブ名を表示するセクション -->
    <div class="section">
        <h2>Registered Job Names</h2>
        <!-- ジョブ名を更新するボタン -->
        <button id="refreshJobNamesBtn">Refresh</button>
        <!-- ジョブ名がリスト表示される場所 -->
        <ul id="jobNamesList"></ul>
    </div>

    <!-- 最近実行されたジョブの情報を表示するセクション -->
    <div class="section">
        <h2>Recent Job Executions</h2>
        <!-- 実行履歴を更新するボタン -->
        <button id="refreshExecutionsBtn">Refresh</button>
        <!-- 実行履歴が表示されるテーブル -->
        <table id="executionsTable">
            <thead>
                <tr>
                    <th>Exec ID</th>
                    <th>Instance ID</th>
                    <th>Job Name</th>
                    <th>Status</th>
                    <th>Start Time</th>
                    <th>End Time</th>
                    <th>Exit Code</th>
                </tr>
            </thead>
            <tbody>
                <!-- JavaScriptによってデータがここに挿入されます -->
            </tbody>
        </table>
    </div>
</div>

<!-- ユーザーへの通知メッセージを表示するトースト(ポップアップ)領域 -->
<div id="toast"></div>

<script>
    // DOMContentLoadedイベントは、HTMLの読み込みと解析が完了した後に実行されます。
    document.addEventListener('DOMContentLoaded', () => {
        // HTML要素への参照を取得
        const launchJobBtn = document.getElementById('launchJobBtn');
        const stopJobBtn = document.getElementById('stopJobBtn');
        const restartJobBtn = document.getElementById('restartJobBtn');
        const refreshJobNamesBtn = document.getElementById('refreshJobNamesBtn');
        const refreshExecutionsBtn = document.getElementById('refreshExecutionsBtn');
        const jobExecutionIdInput = document.getElementById('jobExecutionIdInput');
        const jobNamesList = document.getElementById('jobNamesList');
        const executionsTableBody = document.querySelector('#executionsTable tbody');
        const toast = document.getElementById('toast');

        // APIのベースURLを定義
        const API_BASE_URL = '/job/operator';

        /**
         * ユーザーに通知メッセージを表示する関数
         * @param {string} message - 表示するメッセージ
         * @param {boolean} isError - エラーメッセージかどうか (trueなら赤色、falseなら緑色)
         */
        function showToast(message, isError = false) {
            toast.textContent = message; // メッセージを設定
            toast.style.backgroundColor = isError ? '#d9534f' : '#5cb85c'; // 背景色を設定
            toast.style.display = 'block'; // 表示
            // 3秒後に非表示にする
            setTimeout(() => {
                toast.style.display = 'none';
            }, 3000);
        }

        /**
         * APIエンドポイントを呼び出す汎用関数
         * @param {string} endpoint - APIのエンドポイントパス
         * @param {object} options - fetch APIに渡すオプション (method, headers, bodyなど)
         * @param {boolean} showSuccessToast - 成功時にトーストを表示するかどうか
         * @returns {Promise<object>} APIからのレスポンスデータ
         */
        async function apiCall(endpoint, options = {}, showSuccessToast = true) {
            try {
                // APIを呼び出し
                const response = await fetch(`${API_BASE_URL}${endpoint}`, options);
                // レスポンスをJSONとして解析
                const data = await response.json();
                // HTTPステータスコードが200番台以外の場合はエラーとして処理
                if (!response.ok) {
                    throw new Error(data.message || `HTTP error! status: ${response.status}`);
                }
                // 成功トーストの表示
                if (showSuccessToast) {
                    showToast(data.message || 'Operation successful!');
                }
                return data; // データを返す
            } catch (error) {
                console.error('API Error:', error); // コンソールにエラーを出力
                showToast(error.message, true); // エラートーストを表示
                throw error; // エラーを再スロー
            }
        }

        /**
         * ジョブを起動する関数
         */
        async function launchJob() {
            launchJobBtn.disabled = true; // ボタンを無効化して二重クリック防止
            try {
                // /launch エンドポイントをPOSTメソッドで呼び出し
                const data = await apiCall('/launch', { method: 'POST' });
                jobExecutionIdInput.value = data.jobExecutionId; // 起動したジョブのIDを入力欄に表示
                await refreshExecutions(); // 実行履歴を更新
            } finally {
                launchJobBtn.disabled = false; // 処理完了後にボタンを有効化
            }
        }

        /**
         * ジョブを停止する関数
         */
        async function stopJob() {
            const jobExecutionId = jobExecutionIdInput.value; // 入力欄からJobExecutionIdを取得
            // IDが入力されていない場合はエラーメッセージを表示して終了
            if (!jobExecutionId) {
                showToast('Please enter a JobExecutionId.', true);
                return;
            }
            stopJobBtn.disabled = true; // ボタンを無効化
            try {
                // /stop/{jobExecutionId} エンドポイントをPOSTメソッドで呼び出し
                await apiCall(`/stop/${jobExecutionId}`, { method: 'POST' });
                // ステータス更新のために少し待ってから実行履歴を更新
                setTimeout(refreshExecutions, 1000);
            } finally {
                stopJobBtn.disabled = false; // 処理完了後にボタンを有効化
            }
        }

        /**
         * ジョブを再起動する関数
         */
        async function restartJob() {
            const jobExecutionId = jobExecutionIdInput.value; // 入力欄からJobExecutionIdを取得
            // IDが入力されていない場合はエラーメッセージを表示して終了
            if (!jobExecutionId) {
                showToast('Please enter a JobExecutionId.', true);
                return;
            }
            restartJobBtn.disabled = true; // ボタンを無効化
            try {
                // /restart/{jobExecutionId} エンドポイントをPOSTメソッドで呼び出し
                const data = await apiCall(`/restart/${jobExecutionId}`, { method: 'POST' });
                // 新しいJobExecutionIdがあれば入力欄を更新
                if (data.newJobExecutionId) {
                    jobExecutionIdInput.value = data.newJobExecutionId;
                }
                await refreshExecutions(); // 実行履歴を更新
            } finally {
                restartJobBtn.disabled = false; // 処理完了後にボタンを有効化
            }
        }

        /**
         * 登録されているジョブ名の一覧を更新する関数
         */
        async function refreshJobNames() {
            try {
                // /names エンドポイントを呼び出し、ジョブ名一覧を取得 (成功トーストは表示しない)
                const names = await apiCall('/names', {}, false);
                jobNamesList.innerHTML = ''; // 現在のリストをクリア
                // ジョブが登録されていない場合
                if (names.length === 0) {
                    jobNamesList.innerHTML = '<li>No jobs registered.</li>';
                } else {
                    // 取得したジョブ名をリストに追加
                    names.forEach(name => {
                        const li = document.createElement('li');
                        li.textContent = name;
                        jobNamesList.appendChild(li);
                    });
                }
            } catch (error) {
                jobNamesList.innerHTML = '<li>Error loading job names.</li>'; // エラー表示
            }
        }

        /**
         * 最近のジョブ実行履歴を更新する関数
         */
        async function refreshExecutions() {
            try {
                // /executions/recent エンドポイントを呼び出し、実行履歴を取得 (成功トーストは表示しない)
                const executions = await apiCall('/executions/recent', {}, false);
                executionsTableBody.innerHTML = ''; // 現在のテーブル内容をクリア
                // 実行履歴がない場合
                if (executions.length === 0) {
                    executionsTableBody.innerHTML = '<tr><td colspan="7">No recent executions found.</td></tr>';
                } else {
                    // 取得した実行履歴をテーブルに追加
                    executions.forEach(exec => {
                        const row = document.createElement('tr');
                        row.style.cursor = 'pointer'; // クリック可能であることを示すカーソル
                        row.innerHTML = `
                            <td>${exec.jobExecutionId}</td>
                            <td>${exec.jobInstanceId}</td>
                            <td>${exec.jobName}</td>
                            <td><span class="status-${exec.status}">${exec.status}</span></td>
                            <td>${formatDate(exec.startTime)}</td>
                            <td>${formatDate(exec.endTime)}</td>
                            <td>${exec.exitCode}</td>
                        `;
                        // 行をクリックするとJobExecutionIdを入力欄にセット
                        row.addEventListener('click', () => {
                            jobExecutionIdInput.value = exec.jobExecutionId;
                        });
                        executionsTableBody.appendChild(row);
                    });
                }
            } catch (error) {
                executionsTableBody.innerHTML = '<tr><td colspan="7">Error loading executions.</td></tr>'; // エラー表示
            }
        }

        /**
         * 日付文字列を整形して表示する関数
         * @param {string} dateString - ISO形式の日付文字列
         * @returns {string} 整形された日付文字列、または'N/A'
         */
        function formatDate(dateString) {
            if (!dateString) return 'N/A'; // 日付がない場合は'N/A'を返す
            const date = new Date(dateString); // Dateオブジェクトに変換
            return date.toLocaleString('ja-JP'); // 日本のロケール形式で整形して返す
        }

        // 各ボタンにクリックイベントリスナーを設定
        launchJobBtn.addEventListener('click', launchJob);
        stopJobBtn.addEventListener('click', stopJob);
        restartJobBtn.addEventListener('click', restartJob);
        refreshJobNamesBtn.addEventListener('click', refreshJobNames);
        refreshExecutionsBtn.addEventListener('click', refreshExecutions);

        // ページ読み込み時にジョブ名と実行履歴を初期表示
        refreshJobNames();
        refreshExecutions();
        // 5秒ごとに実行履歴を自動更新
        setInterval(refreshExecutions, 5000);
    });
</script>

</body>
</html>

4. 実行方法

4-1. 準備

まず、アプリケーションを起動します。プロジェクトのルートディレクトリで以下のコマンドを実行してください。

./mvnw spring-boot:run

アプリケーションが起動したら、Webブラウザで http://localhost:8080/job-operator.html にアクセスします。


4-2. Web UIからの実行

表示されたコントロールパネル上で、以下の操作を順に試します。

  1. ジョブの起動: [Launch ‘stoppableJob’] ボタンをクリックします。
  2. ジョブの停止: 実行中のジョブのIDが入力欄にセットされるので、[Stop Job] ボタンをクリックします。
  3. ジョブの再開: 停止したジョブのIDが入力されたまま、[Restart Job] ボタンをクリックします。

4-3. APIでの直接実行

curlコマンドを使って、バックエンドAPIを直接操作することもできます。

手順1: ジョブの起動

# ジョブを起動し、レスポンスからjobExecutionIdを取得
curl -X POST -H "Content-Type: application/json" http://localhost:8080/job/operator/launch

手順2: ジョブの停止

ジョブが実行中(30秒以内)に、先ほど取得したIDを使って停止リクエストを送ります。

# {id}の部分を実際のjobExecutionIdに置き換える
curl -X POST http://localhost:8080/job/operator/stop/1

手順3: ジョブの再開

停止したジョブを再開します。

# {id}の部分を停止したjobExecutionIdに置き換える
curl -X POST http://localhost:8080/job/operator/restart/1

補足: 情報取得API

# 登録されているジョブ名の一覧を取得
curl http://localhost:8080/job/operator/names

# 最近の実行履歴を取得
curl http://localhost:8080/job/operator/executions/recent

5. 実行と結果解説

5-1. ジョブの起動

まず、launch APIを呼び出してstoppableJobを起動します。

クライアント側 (curl)

curl -X POST -H "Content-Type: application/json" http://localhost:8080/job/operator/launch

サーバーは即座に以下のようなJSONレスポンスを返します。jobExecutionIdとして 1 が採番されたことがわかります。

{"jobExecutionId":1,"message":"Job launched successfully."}

サーバー側ログ
サーバーでは、JobOperationControllerがリクエストを受け付け、TaskExecutorJobLauncherがジョブを起動します。その後、stoppableStepが実行され、StoppableTaskletが30秒間のループ処理を開始します。

2025-11-20T10:39:26.046+09:00  INFO 34641 --- [nio-8080-exec-1] c.e.s.controller.JobOperationController  : Request received to launch the stoppableJob.
2025-11-20T10:39:26.054+09:00  INFO 34641 --- [nio-8080-exec-1] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=stoppableJob]] launched with the following parameters: [{'launchDate':'{value=2025-11-20T10:39:26.046861, type=class java.time.LocalDateTime, identifying=true}'}]
2025-11-20T10:39:26.061+09:00  INFO 34641 --- [nio-8080-exec-1] o.s.batch.core.job.SimpleStepHandler     : Executing step: [stoppableStep]
2025-11-20T10:39:26.063+09:00  INFO 34641 --- [nio-8080-exec-1] c.e.s.tasklet.StoppableTasklet           : JobExecutionId: [1]. Starting stoppable task for 30 seconds.
2025-11-20T10:39:26.063+09:00  INFO 34641 --- [nio-8080-exec-1] c.e.s.tasklet.StoppableTasklet           : JobExecutionId: [1]. Running... 1/30 seconds.
...

5-2. ジョブの停止

ジョブが実行中に、stop APIを呼び出します。

クライアント側 (curl)

curl -X POST http://localhost:8080/job/operator/stop/1

サーバーは停止シグナルが送信されたことを示すレスポンスを返します。

{"message":"Job 1 stop signal sent."}

サーバー側ログ
JobOperationControllerが停止リクエストを受け付け、JobOperatorが実行中のジョブのスレッドに割り込みをかけます。StoppableTasklet内のThread.sleep()InterruptedExceptionをスローし、catchブロックが実行されます。Task was interruptedという警告ログが出力され、Taskletは安全に終了します。最終的に、Spring BatchフレームワークがジョブのステータスをSTOPPEDとして記録します。

2025-11-20T10:39:51.473+09:00  INFO 34641 --- [nio-8080-exec-2] c.e.s.controller.JobOperationController  : Request received to stop job with execution ID: 1
2025-11-20T10:39:51.474+09:00  INFO 34641 --- [nio-8080-exec-2] o.s.b.c.l.support.SimpleJobOperator      : Stopping job execution with id=1
...
2025-11-20T10:39:51.475+09:00  WARN 34641 --- [nio-8080-exec-1] c.e.s.tasklet.StoppableTasklet           : JobExecutionId: [1]. Task was interrupted. Finishing gracefully.
2025-11-20T10:39:51.478+09:00  INFO 34641 --- [nio-8080-exec-1] o.s.batch.core.step.AbstractStep         : Step: [stoppableStep] executed in 25s415ms
2025-11-20T10:39:51.481+09:00  INFO 34641 --- [nio-8080-exec-1] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=stoppableJob]] completed with the following parameters: [...] and the following status: [STOPPED] in 25s435ms

5-3. ジョブの再開

停止したジョブ(jobExecutionId: 1)を再開します。

クライアント側 (curl)

curl -X POST http://localhost:8080/job/operator/restart/1

レスポンスには、新しいjobExecutionIdとして 2 が含まれています。これは、同じジョブインスタンスに対して新しい実行が開始されたことを意味します。

{"newJobExecutionId":2,"message":"Job 1 restarted successfully."}

サーバー側ログ
JobOperatorは再開リクエストを受け、stoppableJobの新しい実行を開始します。ログから、JobExecutionId: [2]としてStoppableTaskletが最初から実行されていることが確認できます。今回は途中で停止されないため、30秒間の処理を完遂し、ジョブのステータスはCOMPLETEDになります。

2025-11-20T10:40:07.643+09:00  INFO 34641 --- [nio-8080-exec-5] c.e.s.controller.JobOperationController  : Request received to restart job with execution ID: 1
2025-11-20T10:40:07.646+09:00  INFO 34641 --- [nio-8080-exec-5] o.s.b.c.l.support.SimpleJobOperator      : Attempting to resume job with name=stoppableJob and parameters={...}
2025-11-20T10:40:07.650+09:00  INFO 34641 --- [nio-8080-exec-5] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=stoppableJob]] launched with the following parameters: [{...}]
2025-11-20T10:40:07.654+09:00  INFO 34641 --- [nio-8080-exec-5] c.e.s.tasklet.StoppableTasklet           : JobExecutionId: [2]. Starting stoppable task for 30 seconds.
...
2025-11-20T10:40:37.761+09:00  INFO 34641 --- [nio-8080-exec-5] c.e.s.tasklet.StoppableTasklet           : JobExecutionId: [2]. Stoppable task finished.
2025-11-20T10:40:37.763+09:00  INFO 34641 --- [nio-8080-exec-5] o.s.batch.core.step.AbstractStep         : Step: [stoppableStep] executed in 30s110ms
2025-11-20T10:40:37.768+09:00  INFO 34641 --- [nio-8080-exec-5] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=stoppableJob]] completed with the following parameters: [{...}] and the following status: [COMPLETED] in 30s116ms

この一連の流れを通じて、executions/recent APIを叩くと、jobExecutionId: 1STOPPEDjobExecutionId: 2COMPLETEDという履歴を確認できます。


6. 応用シナリオ

6-1. 応用的な操作シナリオ

基本フローに慣れたら、実際の運用で遭遇しがちな、より複雑なシナリオを試してみましょう。


シナリオA:複数ジョブの並行実行と、特定のジョブのみを停止する

  1. 操作:
    Web UIの “Launch ‘stoppableJob'” ボタンを、間隔を空けずに2〜3回素早くクリックします。
  2. 期待結果の確認:
  • サーバーログ: 複数のStoppableTaskletが並行して実行され、それぞれのJobExecutionIdを持つログが混ざって出力されます。
2025-11-20T11:05:10.100+09:00  INFO 35111 --- [nio-8080-exec-1] c.e.s.tasklet.StoppableTasklet : JobExecutionId: [3]. Running... 3/30 seconds.
2025-11-20T11:05:10.500+09:00  INFO 35111 --- [nio-8080-exec-2] c.e.s.tasklet.StoppableTasklet : JobExecutionId: [4]. Running... 2/30 seconds.
2025-11-20T11:05:11.100+09:00  INFO 35111 --- [nio-8080-exec-1] c.e.s.tasklet.StoppableTasklet : JobExecutionId: [3]. Running... 4/30 seconds.
  • API確認: executions/recent APIを呼び出すと、複数のジョブがSTARTED状態でリストアップされます。
curl http://localhost:8080/job/operator/executions/recent
[
  {"jobExecutionId":4, "jobName":"stoppableJob", "status":"STARTED", ...},
  {"jobExecutionId":3, "jobName":"stoppableJob", "status":"STARTED", ...},
  ...
]
  1. 操作:
    実行中のジョブの中から、中間のジョブ(例:jobExecutionId3のジョブ)をstop APIで停止します。
curl -X POST http://localhost:8080/job/operator/stop/3
{"message":"Job 3 stop signal sent."}
  1. 最終結果の確認:
  • サーバーログ: JobExecutionId: [3]のジョブだけがSTOPPEDになったログが出力されます。他のジョブ(例:4)は実行を継続します。
2025-11-20T11:05:15.200+09:00  INFO 35111 --- [nio-8080-exec-3] c.e.s.controller.JobOperationController  : Request received to stop job with execution ID: 3
...
2025-11-20T11:05:15.800+09:00  INFO 35111 --- [nio-8080-exec-1] o.s.b.c.l.s.TaskExecutorJobLauncher      : Job: [SimpleJob: [name=stoppableJob]] completed with the following parameters: [...] and the following status: [STOPPED]
...
2025-11-20T11:05:15.500+09:00  INFO 35111 --- [nio-8080-exec-2] c.e.s.tasklet.StoppableTasklet : JobExecutionId: [4]. Running... 7/30 seconds.
  • API確認: 再度executions/recent APIを呼び出すと、jobExecutionId: 3のステータスがSTOPPEDに変わっていることが確認できます。
[
  {"jobExecutionId":4, "jobName":"stoppableJob", "status":"STARTED", ...},
  {"jobExecutionId":3, "jobName":"stoppableJob", "status":"STOPPED", ...},
  ...
]
  • これにより、JobOperatorjobExecutionIdに基づいて、個別のジョブ実行を正確に識別し、操作できることがわかります。

シナリオB:完了済みジョブの操作とJobInstanceの理解

  1. 操作:

ジョブを1つ起動し、30秒待ってステータスがCOMPLETEDになることを確認します。executions/recent APIで以下のように表示されます。

[
  {"jobExecutionId":5, "jobName":"stoppableJob", "status":"COMPLETED", "endTime":"2025-11-20T11:10:30.123456", ...}
]
  1. 操作(失敗ケース):

その完了したジョブのjobExecutionId(この例では5)を指定して、stop APIを呼び出します。

curl -X POST http://localhost:8080/job/operator/stop/5

期待結果:
JobExecutionNotRunningExceptionが発生し、サーバーはエラーレスポンスを返します。これは、stop操作は実行中のジョブに対してのみ有効であるためです。

{
  "message": "Failed to send stop signal to job 5. It may have already completed or been stopped."
}
  1. 操作(成功ケース):
    今度は、同じ完了済みのjobExecutionId5)で、restart APIを呼び出します。
curl -X POST http://localhost:8080/job/operator/restart/5

期待結果:
エラーにはならず、新しいjobExecutionId(例:6)を含む成功レスポンスが返ってきます。

{"newJobExecutionId":6,"message":"Job 5 restarted successfully."}

executions/recentを確認すると、新しいジョブ実行6STARTED状態でリストに追加されていることがわかります。

ポイント解説:なぜ完了したジョブをRestartできるのか?
本来、COMPLETEDステータスのジョブインスタンスは再実行できません。再実行しようとするとJobInstanceAlreadyCompleteExceptionというエラーが発生するのが通常です。

しかし今回のstoppableJobは、JobParametersIncrementer(具体的にはRunIdIncrementer)を持つように設定されています。これはジョブ実行のたびにrun.idという新しいパラメータを自動的に付与する機能です。

Spring Batchでは、Job名とJobパラメータの組み合わせが同じであるものを「同一のJobInstance」と見なします。restart操作時にRunIdIncrementerが新しいパラメータを生成するため、Spring Batchはそれを「過去の実行とは異なる、新しいJobInstance」と判断し、実行を許可します。これが、完了済みのジョブでもrestartが成功する理由です。


7. まとめ

JobOperatorは、Spring Batchアプリケーションの運用を劇的に改善する強力なツールです。Spring Boot 3.2以降では、その利用が非常に簡単になりました。

しかし、その裏側にある自動設定の仕組みを理解していないと、今回経験したような予期せぬエラーに繋がることがあります。
エラーが発生した際は、メッセージをよく読み、Spring Bootがどのような設定を自動で行おうとしているのか、そして自分のコードがそれを妨げていないかを考えることが、問題解決への近道となります。


免責事項

本記事の内容は、執筆時点での情報に基づいています。技術情報は常に変化するため、記載された情報が常に最新かつ正確であることを保証するものではありません。本記事の情報を利用したことにより生じるいかなる損害についても、筆者は一切の責任を負いません。ご自身の判断と責任においてご利用ください。


SNSでもご購読できます。

コメントを残す

*