概要

バッチジョブが増えるたびに JAR を分けていると、ビルド成果物の管理やデプロイ手順が煩雑になります。ジョブごとに main クラスを用意して起動スクリプトを書き分ける運用は、10 本を超えたあたりから保守コストが無視できなくなります。この記事では、1 つの JAR ファイルに全ジョブをまとめ、起動引数で渡す Properties ファイルの中身だけで実行対象を切り替える BatchDispatcher を実装します。Properties の `job.class` キーに書かれた完全修飾クラス名を `Class.forName` で読み込み、リフレクションで BatchJob のインスタンスを生成し、SimpleBatchRunner に渡して実行します。新しいジョブを追加するときは BatchJob 実装クラスを書いて Properties ファイルを追加するだけで済み、Dispatcher 本体も Runner も触る必要がありません。JP1 や cron から呼ぶ場合も、引数の Properties ファイルパスを差し替えるだけです。

使いどころ

10 本以上のバッチジョブを 1 つの JAR にまとめ、JP1 のジョブネットから Properties ファイル指定で起動する

開発環境で複数ジョブの動作確認を 1 つの成果物で行い、ビルド・配布の手間を減らす

ジョブ追加時に既存コードを一切変更せず、新規クラスと Properties ファイルの追加だけでリリースする

コード例

BatchDispatcher — 1 JAR 複数ジョブのディスパッチャー
import java.io.FileInputStream;
import java.io.IOException;
import java.util.HashMap;
import java.util.Map;
import java.util.Properties;
import java.util.logging.Level;
import java.util.logging.Logger;

interface BatchJob {
    void initialize(JobContext context) throws Exception;
    ExitCode execute(JobContext context) throws Exception;
    void terminate(JobContext context);
}

enum ExitCode {
    SUCCESS(0), WARNING(1), ERROR(2);
    private final int code;
    ExitCode(int code) { this.code = code; }
    public int getCode() { return code; }
}

class JobContext {
    private final String jobName;
    private final Properties properties;
    private final Map<String, Object> attributes = new HashMap<String, Object>();
    private long startTimeMillis;

    public JobContext(String jobName, Properties properties) {
        this.jobName = jobName;
        this.properties = properties;
    }
    public String getJobName() { return jobName; }
    public String getProperty(String key, String defaultValue) {
        return properties.getProperty(key, defaultValue);
    }
    public void setAttribute(String key, Object value) { attributes.put(key, value); }
    @SuppressWarnings("unchecked")
    public <T> T getAttribute(String key, Class<T> type) {
        Object v = attributes.get(key);
        return (v != null && type.isInstance(v)) ? (T) v : null;
    }
    public long getStartTimeMillis() { return startTimeMillis; }
    public void setStartTimeMillis(long v) { this.startTimeMillis = v; }
}

class SimpleBatchRunner {
    private static final Logger LOGGER = Logger.getLogger(SimpleBatchRunner.class.getName());

    public ExitCode run(BatchJob job, JobContext context) {
        ExitCode exitCode = ExitCode.ERROR;
        context.setStartTimeMillis(System.currentTimeMillis());
        LOGGER.info("[" + context.getJobName() + "] ジョブ開始");
        try {
            job.initialize(context);
            exitCode = job.execute(context);
        } catch (Exception e) {
            LOGGER.log(Level.SEVERE, "[" + context.getJobName() + "] エラー", e);
        } finally {
            try { job.terminate(context); } catch (Exception e) {
                LOGGER.log(Level.WARNING, "後処理エラー", e);
            }
            long elapsed = System.currentTimeMillis() - context.getStartTimeMillis();
            LOGGER.info("[" + context.getJobName() + "] 終了 ("
                + elapsed + "ms) コード: " + exitCode.getCode());
        }
        return exitCode;
    }
}

// ===== サンプルジョブ 1: CSV取込 =====
class CsvImportJob implements BatchJob {
    @Override
    public void initialize(JobContext ctx) throws Exception {
        String inputFile = ctx.getProperty("input.file", "");
        if (inputFile.isEmpty()) {
            throw new IllegalArgumentException("input.file が設定されていません");
        }
        ctx.setAttribute("inputFile", inputFile);
    }

    @Override
    public ExitCode execute(JobContext ctx) throws Exception {
        String inputFile = ctx.getAttribute("inputFile", String.class);
        int count = 0;
        java.io.BufferedReader reader = new java.io.BufferedReader(
            new java.io.FileReader(inputFile));
        try {
            while (reader.readLine() != null) { count++; }
        } finally { reader.close(); }
        ctx.setAttribute("processedCount", Integer.valueOf(count));
        return ExitCode.SUCCESS;
    }

    @Override
    public void terminate(JobContext ctx) {
        Integer count = ctx.getAttribute("processedCount", Integer.class);
        System.out.println("CSV取込完了: " + (count != null ? count : 0) + " 件");
    }
}

// ===== サンプルジョブ 2: レポート出力 =====
class ReportExportJob implements BatchJob {
    @Override
    public void initialize(JobContext ctx) throws Exception {
        ctx.setAttribute("outputDir", ctx.getProperty("output.dir", "."));
    }

    @Override
    public ExitCode execute(JobContext ctx) throws Exception {
        String outputDir = ctx.getAttribute("outputDir", String.class);
        String path = outputDir + "/report_" + System.currentTimeMillis() + ".txt";
        java.io.FileWriter writer = new java.io.FileWriter(path);
        try {
            writer.write("日次売上レポート\n");
            writer.write("生成日時: " + new java.util.Date() + "\n");
        } finally { writer.close(); }
        ctx.setAttribute("reportPath", path);
        return ExitCode.SUCCESS;
    }

    @Override
    public void terminate(JobContext ctx) {
        String path = ctx.getAttribute("reportPath", String.class);
        System.out.println("レポート出力完了: " + (path != null ? path : "未生成"));
    }
}

// ===== ディスパッチャー本体 =====
public class BatchDispatcher {
    private static final Logger LOGGER = Logger.getLogger(BatchDispatcher.class.getName());

    public static void main(String[] args) {
        if (args.length < 1) {
            System.err.println("使い方: java -jar batch.jar <properties-file-path>");
            System.err.println("例: java -jar batch.jar config/csv-import.properties");
            System.exit(ExitCode.ERROR.getCode());
            return;
        }

        // 1. Properties ファイル読み込み
        String propsPath = args[0];
        Properties props = new Properties();
        FileInputStream fis = null;
        try {
            fis = new FileInputStream(propsPath);
            props.load(fis);
        } catch (IOException e) {
            System.err.println("設定ファイル読込失敗: " + propsPath);
            e.printStackTrace();
            System.exit(ExitCode.ERROR.getCode());
            return;
        } finally {
            if (fis != null) { try { fis.close(); } catch (IOException ignored) {} }
        }

        // 2. ジョブクラス名と表示名を取得
        String jobClassName = props.getProperty("job.class");
        String jobName = props.getProperty("job.name", "unknown");

        if (jobClassName == null || jobClassName.trim().isEmpty()) {
            System.err.println("job.class が未定義: " + propsPath);
            System.exit(ExitCode.ERROR.getCode());
            return;
        }

        LOGGER.info("ディスパッチ: job.class=" + jobClassName + ", job.name=" + jobName);

        // 3. リフレクションで BatchJob インスタンスを生成
        BatchJob job;
        try {
            Class<?> clazz = Class.forName(jobClassName.trim());
            Object instance = clazz.getDeclaredConstructor().newInstance();
            if (!(instance instanceof BatchJob)) {
                System.err.println(jobClassName + " は BatchJob を実装していません");
                System.exit(ExitCode.ERROR.getCode());
                return;
            }
            job = (BatchJob) instance;
        } catch (ClassNotFoundException e) {
            System.err.println("クラスが見つかりません: " + jobClassName);
            System.exit(ExitCode.ERROR.getCode());
            return;
        } catch (NoSuchMethodException e) {
            System.err.println("引数なしコンストラクタがありません: " + jobClassName);
            System.exit(ExitCode.ERROR.getCode());
            return;
        } catch (Exception e) {
            System.err.println("インスタンス化失敗: " + jobClassName);
            e.printStackTrace();
            System.exit(ExitCode.ERROR.getCode());
            return;
        }

        // 4. ジョブ実行
        JobContext context = new JobContext(jobName, props);
        SimpleBatchRunner runner = new SimpleBatchRunner();
        ExitCode exitCode = runner.run(job, context);

        LOGGER.info("ディスパッチ完了: " + jobName + " -> " + exitCode.name());
        System.exit(exitCode.getCode());
    }
}

/*
 * === Properties ファイル例 ===
 *
 * --- config/csv-import.properties ---
 * job.class=CsvImportJob
 * job.name=日次CSV取込
 * input.file=/data/import/daily_sales.csv
 *
 * --- config/report-export.properties ---
 * job.class=ReportExportJob
 * job.name=日次レポート出力
 * output.dir=/data/reports
 *
 * === 起動例 ===
 * java -jar batch.jar config/csv-import.properties
 * java -jar batch.jar config/report-export.properties
 *
 * === cron 設定例 ===
 * 0 2 * * * cd /opt/batch && java -jar batch.jar config/csv-import.properties
 * 0 6 * * * cd /opt/batch && java -jar batch.jar config/report-export.properties
 */

Java 8 / 17 / 21 の完全なサンプルコードは GitHub リポジトリ で確認できます。

Version Coverage

ServiceLoader を使えばリフレクションなしでジョブを発見できる。モジュール境界を超える場合は module-info.java に opens が必要。

Java 17
// Java 17: ServiceLoader でリフレクション不要
ServiceLoader<BatchJob> loader = ServiceLoader.load(BatchJob.class);
var job = loader.stream()
    .filter(p -> p.type().getName().equals(className))
    .findFirst()
    .orElseThrow(() -> new IllegalArgumentException("未登録: " + className))
    .get();

Library Comparison

Pure Java(BatchDispatcher)ジョブ数が数十本程度で、起動方法を統一したい場合。Properties + リフレクションで十分。ジョブ間の依存解決や起動順序の制御は自前で実装する必要がある。
Spring Batch + CommandLineRunnerSpring Boot ベースでジョブ選択・パラメータ管理・実行履歴を一元管理したい場合。Spring Boot の起動オーバーヘッドと依存が増える。
picocli + 自前ディスパッチコマンドライン引数の解析を型安全に行い、サブコマンドでジョブを切り替えたい場合。picocli 依存が追加。ジョブ追加時に main クラスの修正が必要。

注意点

Class.forName はクラスパス上にクラスが存在しなければ ClassNotFoundException を投げる。fat JAR のビルド時に依存クラスが含まれているか確認すること

リフレクションで生成するクラスには引数なしの public コンストラクタが必要。private や引数付きだと InstantiationException になる

Properties の job.class に任意のクラス名を書けるため、BatchJob を実装していないクラスが指定される可能性がある。instanceof チェックを必ず入れること

本番環境では Properties ファイルのパスに絶対パスを使うか、実行ディレクトリを固定する運用ルールを設けること

Java 17 以降のモジュールシステムでは、対象クラスのパッケージが opens されている必要がある

FAQ

fat JAR にまとめる方法は何が適切ですか。

maven-shade-plugin や gradle の shadowJar が定番です。Main-Class に BatchDispatcher を指定すれば java -jar で起動できます。

リフレクションを使わずにジョブを切り替える方法はありますか。

Map にジョブ名とインスタンスを登録する方法が最も単純です。ただしジョブ追加のたびに Map の修正が必要になります。

Properties ファイルではなく引数でクラス名を直接渡す設計はどうですか。

可能ですが、ジョブ固有のパラメータも引数で管理することになり煩雑です。Properties にまとめる方が運用しやすいです。

関連書籍

この記事のテーマをさらに深く学びたい方へ。

※ Amazon アソシエイトリンクを含みます