「アプリが起動したら、一度だけ何か実行したい」という場面はよくありますよね。初期データを入れたい、起動時に外部サービスへの疎通を確認したい、あるいはバッチを一発キックしたい。そんなときに候補になるのが @PostConstructCommandLineRunnerApplicationRunnerApplicationReadyEvent の4つです。

ただ、いざ書こうとすると「どれを使えばいいの?」で手が止まりがちです。この記事では CommandLineRunnerApplicationRunner を軸に実装方法を整理して、最後に4つのフックの使い分けまで一気に押さえます。読み終えたら、迷わず適切なフックを選べるようになるはずです。

CommandLineRunner・ApplicationRunnerとは

どちらも、アプリの起動が完了した直後に run メソッドが自動で呼ばれるコールバックインターフェースです。もう少し正確に言うと、SpringApplication.run() が呼び出し元に返る直前、つまり全Beanの初期化が終わってアプリケーションコンテキストが立ち上がりきったタイミングで実行されます。

この「起動が全部終わってから一度だけ」という性質が、初期データ投入・起動時チェック・バッチのキックといった処理にぴったりなんですね。

2つの違いは、実は 引数の受け取り方だけ です。CommandLineRunnerString[] で生の起動引数を受け取り、ApplicationRunnerApplicationArguments という構造化されたオブジェクトで受け取ります。実行タイミングも失敗時の挙動も同じです。

CommandLineRunnerの最小実装

まずは一番シンプルな形から。@Component を付けて CommandLineRunner を実装すれば、それだけで起動時に呼ばれます。

import org.springframework.boot.CommandLineRunner;
import org.springframework.stereotype.Component;

@Component
public class StartupRunner implements CommandLineRunner {

    @Override
    public void run(String... args) {
        System.out.println("アプリが起動しました。起動引数の数: " + args.length);
    }
}

クラスを増やしたくない場合は、@Configuration クラスの中で @Bean としてラムダで登録する手もあります。短い処理ならこちらのほうが見通しがいいですね。

import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
public class StartupConfig {

    @Bean
    CommandLineRunner initRunner(UserRepository repository) {
        return args -> {
            repository.save(new User("admin"));
            System.out.println("初期ユーザーを投入しました");
        };
    }
}

CommandLineRunner を実装したBeanは、複数あっても全部実行されます。ロジックごとにRunnerを分けておくと役割がはっきりして読みやすくなります。

ApplicationRunnerとの違い

ApplicationRunner は引数を ApplicationArguments で受け取ります。--server.port=8081 のような --key=value 形式のオプション引数を扱いたいときは、自前でパースしなくて済むぶんこちらが便利です。

import org.springframework.boot.ApplicationArguments;
import org.springframework.boot.ApplicationRunner;
import org.springframework.stereotype.Component;

@Component
public class ArgsRunner implements ApplicationRunner {

    @Override
    public void run(ApplicationArguments args) {
        if (args.containsOption("init")) {
            System.out.println("--init が指定されました");
        }
        args.getOptionValues("mode")
           .forEach(value -> System.out.println("mode=" + value));
    }
}

CommandLineRunner なら同じことを String[] から自力で切り出す必要があります。生の引数をそのまま欲しいだけなら CommandLineRunner、オプションとして構造的に扱いたいなら ApplicationRunner、というのが素直な選び方です。

起動引数の受け取り方

ApplicationArguments の主要メソッドを押さえておきましょう。java -jar app.jar --mode=fast sample.txt のように起動したケースを例にすると、こんな具合に取り出せます。

@Override
public void run(ApplicationArguments args) {
    // オプション名の一覧 → ["mode"]
    args.getOptionNames();
    // --mode の値 → ["fast"]
    args.getOptionValues("mode");
    // オプション以外の引数 → ["sample.txt"]
    args.getNonOptionArgs();
    // フラグの有無 → false
    args.containsOption("debug");
}

getOptionValues は同じオプションが複数回渡されることも想定してリストを返す点だけ覚えておくといいです。単純なフラグの有無だけ見たいなら containsOption で十分です。

@Orderで複数Runnerの実行順序を制御する

Runnerが複数あるとき、デフォルトの実行順序は保証されません。順序に意味がある場合は @Order を付けて明示しましょう。値が小さいほど先に実行されます。

@Component
@Order(1)
class SchemaRunner implements CommandLineRunner {
    public void run(String... args) {
        System.out.println("1: スキーマ準備");
    }
}

@Component
@Order(2)
class SeedRunner implements CommandLineRunner {
    public void run(String... args) {
        System.out.println("2: 初期データ投入");
    }
}

うれしいのは、CommandLineRunnerApplicationRunner が混在していても @Order で一元的に順序を付けられることです。Ordered インターフェースを実装しても同じことができます。「スキーマを整えてからデータを入れる」のように依存関係がある処理では、順序を明示しておくのが安全です。

Runnerが例外を投げたときの挙動

ここは意外とハマりやすいポイントです。Runnerの run メソッドが例外を投げると、その例外は SpringApplication.run まで伝播し、アプリの起動そのものが失敗して終了します

一見こわい挙動ですが、「起動時に前提が満たされていなければ即座に落とす」というヘルスチェックには、むしろ好都合です。中途半端に動き続けるより、起動段階で気づけたほうが安全ですからね。

@Component
class HealthCheckRunner implements CommandLineRunner {
    private final ExternalApiClient client;

    HealthCheckRunner(ExternalApiClient client) {
        this.client = client;
    }

    @Override
    public void run(String... args) {
        if (!client.ping()) {
            // 前提未達なら起動を止める
            throw new IllegalStateException("外部APIに接続できません");
        }
    }
}

逆に「失敗しても起動は続けたい」処理なら、Runnerの中で try-catch して例外を握りつぶし、ログだけ残すようにします。

@Override
public void run(String... args) {
    try {
        cacheWarmer.warmUp();
    } catch (Exception e) {
        log.warn("キャッシュのウォームアップに失敗しました。処理は続行します", e);
    }
}

なお、終了コードまで制御したい場合は ExitCodeGenerator を併用できます。バッチ的な用途で「異常終了なら終了コード1で落としたい」ときに役立ちます。

@PostConstruct・Runner・ApplicationReadyEventの使い分け

起動時フックが複数あるのは、それぞれ担当する「範囲」と「タイミング」が違うからです。実行順序はおおまかに次のとおりです。

  1. @PostConstruct … 各Beanの初期化時(そのBeanが組み上がった直後)
  2. CommandLineRunner / ApplicationRunner … コンテキスト起動完了後
  3. ApplicationReadyEvent … アプリが完全に起動可能になった通知

表にすると違いがはっきりします。

フック対象範囲引数失敗時の挙動向くユースケース
@PostConstructそのBean単位なしBean生成に失敗し起動失敗Bean自身の初期化
CommandLineRunner / ApplicationRunnerアプリ全体で一度きり起動引数を受け取れる起動が失敗し終了初期データ投入・起動時チェック
ApplicationReadyEventアプリ全体で一度きりイベントオブジェクト起動継続(リスナー内で完結)疎結合なイベント駆動処理

@PostConstruct はあくまでBean単位の初期化フックです。そのBeanが使う内部状態を整える用途に向きます(詳しくは BeanライフサイクルとPostConstruct/PreDestroyの記事 を参照してください)。アプリ全体で一度だけ走らせたい、起動引数も見たい、という要件ならRunnerが素直です。ApplicationReadyEvent は「起動完了を他のコンポーネントに知らせる」イベント駆動の作りにしたいときに向いています。

典型ユースケースとフックの選び方

最後に、よくある3つのケースでどれを選ぶかを整理します。

初期データ投入 は、Javaコードで柔軟に組み立てたいならRunnerが手軽です。SQLで管理したいなら data.sql / schema.sql に任せるほうが素直で、こちらは data.sql・schema.sqlによる初期化の記事 にまとめています。

起動時ヘルスチェック は、前提が満たされないときに例外で起動を止めたいので、Runnerがぴったりです。先ほどの例のように、疎通できなければ落とす作りにしておくと安心です。

バッチのキック は、起動時に一度だけ動かすならRunnerで十分です。ここで「定期的に動かしたい」なら話が変わります。Runnerは起動時の一度きりなので、定期実行が必要なら @Scheduledの記事 の仕組みを使いましょう。

判断軸はシンプルで、「一度きり」か「定期」か「Bean単位」かで選べばだいたい迷いません。起動が重くて困っている場合は 起動パフォーマンスチューニングの記事 も合わせてどうぞ。

まとめ

CommandLineRunnerApplicationRunner は、起動完了直後に一度だけ処理を走らせたいときの定番です。2つの違いは引数の受け取り方だけで、構造化された引数が欲しいなら ApplicationRunner、生の引数でよければ CommandLineRunner を選べばOKです。

順序が要るなら @Order、起動引数は ApplicationArguments、そしてRunnerが例外を投げると起動が失敗して終了する。この3点を押さえておけば実装で困ることはほぼありません。Bean単位の初期化なら @PostConstruct、疎結合なイベント駆動にしたいなら ApplicationReadyEvent と、目的に合わせて使い分けていきましょう。