SpringBootを利用してCLIアプリケーションの起動ができるまで


概要

Springを利用したWEBアプリケーションの開発をしていましたが、Spring Webを利用しないバッチアプリの開発をする事になり、バッチアプリでのDIコンテナの扱い方がなかなか見つからなかったので覚え書きとして今回記事を書くことにしました。

この記事のゴール

Spring Initializrを利用して新規作成したプロジェクトに、以下のような機能を持つコマンドラインアプリケーションを実装する。

  • 実行時に引数を受け取り、指定の機能を実行することができる。
  • CLIアプリケーションでSpringのBeanを利用する。

環境

  • Java 11
  • Spring Boot 2.4.3
  • Pleiades All in One Eclipse 2020-12

依存関係の一部

Mavenプロジェクトの場合、pom.xmlの記述は

pom.xml
<dependencies>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter</artifactId>
    </dependency>
    <dependency>
        <groupId>org.springframework.boot</groupId>
        <artifactId>spring-boot-starter-test</artifactId>
        <scope>test</scope>
    </dependency>
</dependencies>
<build>
    <plugins>
        <plugin>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-maven-plugin</artifactId>
        </plugin>
    </plugins>
</build>

Gradleプロジェクトの場合、build.gradleの記述は

build.gradle
dependencies {
    implementation 'org.springframework.boot:spring-boot-starter'
    testImplementation 'org.springframework.boot:spring-boot-starter-test'
}

test {
    useJUnitPlatform()
}

どちらもSpring Initializrを利用して作成したものになります。
以降はこのプロジェクトをEclipseにインポートして作成していきます。

今回作成をするものについて

作成するアプリは、起動時の引数に名前を受け取り、受け取った名前に対してコンソール上で挨拶をするものとなります。
引数を受け取らなかった場合は、「Hello World!」と出すようにします。
動作させる上で、起動時に出力されるSpring Bootのバナーが邪魔になってしまうので、application.propertiesでバナーを表示させないようにします。
また、今回コンソールへの出力はLogbackを利用しています。
各設定は以下のようになります。

application.properties
spring.main.banner-mode = off
logback.xml
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE logback>
<configuration>
    <appender name="STDOUT" class="ch.qos.logback.core.ConsoleAppender">
        <encoder>
            <pattern>%msg%n</pattern>
        </encoder>
    </appender>

    <logger name="console-logger">
        <appender-ref ref="STDOUT" />
    </logger>
</configuration>

ファイル階層

CliApplication
├ src/main/java
│ └ com.example
│   ├ controller
│   │ └ CliController.java
│   ├ service
│   │ ├ CliService.java
│   │ └ CliServiceImpl.java
│   └ CliApplication.java
└ src/main/resources
  ├ application.properties
  └ logback.xml

実装

長い前置きとなってしまいましたが、まずメインメソッドのあるクラスは以下のようになります。

CliApplication.java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.context.ConfigurableApplicationContext;

import com.example.controller.CliController;

@SpringBootApplication
public class CliApplication {

    private final static Logger logger = LoggerFactory.getLogger("console-logger");

    public static void main(String[] args) {
        logger.info("処理開始!");

        try (ConfigurableApplicationContext ctx = SpringApplication.run(CliApplication.class, args)) {
            CliController controller = ctx.getBean(CliController.class);
            controller.process(args);
        } catch(Exception e) {
            e.printStackTrace();
        }

        logger.info("処理終了!");
    }
}

とても重要なのが ConfigurableApplicationContext ctx = SpringApplication.run(CliApplication.class, args) とした部分で、ここでSpringのDIコンテナを作成しています。
Webアプリケーションの場合は、サーバーを起動した際にDIコンテナにBeanが作成されて、それをいつでも利用できるように待受状態になります。
ところが今回のように、起動をしてから待受状態とならない場合は、呼び出そうとしてもNullPointerExceptionとなってしまうので、自分でDIコンテナを使う準備をする必要があります。
これでCliApplication.javaのあるパッケージ下で定義されたBeanを使用することが出来るようになり、ctx.getBean(CliController.class)とすることでコントローラーを呼び出しています。
try-with-resource文で書かれているのは、メインクラスが実行されたらDIコンテナを使う準備をして、処理が終わったあとに閉じて欲しいのが理由です。

次に各コントローラークラスと、サービスクラスは以下のようになります。

CliController.java
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Controller;

import com.example.service.CliService;

@Controller
public class CliController {

    @Autowired
    private CliService service;

    public void process(String[] args) {
        if (args.length == 0) {
            service.greeting();
        } else {
            service.greeting(args[0]);
        }
    }
}
CliService.java
public interface CliService {

    public void greeting();

    public void greeting(String arg);
}

CliServiceImpl.java
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Service;

@Service
public class CliServiceImpl implements CliService {

    private final static Logger logger = LoggerFactory.getLogger("console-logger");

    public void greeting() {
        logger.info("Hello! World!");
    }
    public void greeting(String arg) {
        logger.info(String.format("Hello! %s!", arg));
    }
}

コントローラークラスで、メインクラスと同じように ConfigurableApplicationContext ctx = SpringApplication.run(CliController.class, args) と呼び出さない理由は、この記述ではDIコンテナを作成することになります。既に上のパッケージで作成済みのDIコンテナがあるのに、新たに重複してDIコンテナを作成してしまうことになります。
メインクラスでDIコンテナを利用する準備をして、そのスコープ内で呼び出されている間はいわゆる待受状態になっています。よって、ここでは @Autowired でBeanを利用することが出来るという事のようです。

動作確認

上記プロジェクトを、引数なしで起動した場合の出力は以下のようになります。

処理開始!
Hello! World!
処理終了!

また、引数に「Nobunaga」と渡した場合の出力は以下のようになります。

処理開始!
Hello! Nobunaga!
処理終了!

とても単純な処理になりますので、ここに関しては特に説明することがありません。
今回は起動オプションに関する処理を行っていませんので、Eclipseで実行をする際は実行の構成でANSIコンソール出力のチェックボックスを外さないと、Hello! --spring.output.ansi.enabled=always! となってしまうので注意してください。