かんがるーさんの日記

最近自分が興味をもったものを調べた時の手順等を書いています。今は Spring Boot をいじっています。

Grooy スクリプトをそのまま渡して実行する Spring Boot+Picocli ベースのコマンドラインアプリを作成する ( その7 )( @SpringBootApplication アノテーションを付与した Groovy スクリプトで SFTP クライアントを作成する )

概要

記事一覧はこちらです。

Grooy スクリプトをそのまま渡して実行する Spring Boot+Picocli ベースのコマンドラインアプリを作成する ( その6 )( Groovy スクリプトからログをコンソールやファイルに出力する ) の続きです。

今回から @SpringBootApplication アノテーションを付与した Groovy スクリプトを作成してみます。

  • 今回の手順で確認できるのは以下の内容です。
    • Spring Integration の SFTP Adapters を利用して簡単な SFTP クライアントを作成します。

参照したサイト・書籍

  1. atmoz/sftp
    https://hub.docker.com/r/atmoz/sftp

  2. SFTP Adapters
    https://docs.spring.io/spring-integration/docs/current/reference/html/sftp.html#sftp

  3. @Unmatched annotation
    https://picocli.info/#unmatched-annotation

  4. Concatenate Two Arrays in Java
    https://www.baeldung.com/java-concatenate-arrays

  5. spring-integration/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpRemoteFileTemplateTests.java
    https://github.com/spring-projects/spring-integration/blob/main/spring-integration-sftp/src/test/java/org/springframework/integration/sftp/session/SftpRemoteFileTemplateTests.java

目次

  1. SFTP クライアントの仕様をまとめる
  2. docker-compose.yml に SFTP サーバを起動するための設定を追加する
  3. build.gradle に Spring Integration の SFTP Adapter を依存関係に追加する
  4. Groovy スクリプト側で Picocli の @Option アノテーションが使用できるよう groovy-script-executor.jar を変更する
  5. Groovy スクリプトを配置する package を sample → ksby.cmdapp.groovyscriptexecutor.script に変更する
  6. groovy-script-executor.jar 内に SFTP のアップロード・ダウンロード処理のための helper クラスを作成する
  7. Groovy スクリプトで SFTP クライアントを作成する
  8. groovy-script-executor.jar と Groovy スクリプトが出力するログを調整する
  9. 動作確認
  10. メモ書き
    1. groovy-script-executor.jar 内の Application クラス(@SpringBootApplication を付与したクラス)が ComponentScan の対象に含まれると Groovy スクリプトは実行されるが help が表示される

手順

SFTP クライアントの仕様をまとめる

  • ファイルのアップロード・ダウンロードを以下のコマンドで実行できるようにします。
    • gse SftpClient.groovy --host=<ホスト名> --port=<ポート番号> --user=<ユーザ名> --password=<パスワード> --upload-dir=<アップロード先ディレクトリ> --upload-file=<アップロードするファイル>
    • gse SftpClient.groovy --host=<ホスト名> --port=<ポート番号> --user=<ユーザ名> --password=<パスワード> --download-src=<ダウンロード元ファイル> --download-dst=<ダウンロード先ファイル>
  • これまで Groovy スクリプトは sample package の下に作成していましたが、groovy-script-executor.jar 内に作成する Spring の Component のメソッドを呼び出せるようにするために ksby.cmdapp.groovyscriptexecutor.script package の下に作成します(これまで作成した Groovy スクリプトもこの下に移動します)。
  • SFTP のアップロード・ダウンロード処理は groovy-script-executor.jar 内に Spring の Component として作成します。Groovy スクリプトはこの Component のメソッドを呼び出します。またこの Component は ComponentScan の記述を省略するため ksby.cmdapp.groovyscriptexecutor.script.helper package の下に作成します。

docker-compose.yml に SFTP サーバを起動するための設定を追加する

atmoz/sftp を利用して SFTP サーバを構築します。Spring Boot + Spring Integration でいろいろ試してみる ( その29 )( Docker Compose でサーバを構築する、FTP+SFTPサーバ編 ) も参考にしてください。

まず docker/sftp-server/config ディレクトリを作成し、この下に users.conf を新規作成して以下の内容を記述します。このファイルは改行コードを LF にします。

user01:pass01:::upload,download

.gitattributes を以下のように変更します。

#
# https://help.github.com/articles/dealing-with-line-endings/
#
# These are explicitly windows files and should use crlf
*.bat                                   text eol=crlf
docker/sftp-server/config/users.conf    text eol=lf
  • docker/sftp-server/config/users.conf text eol=lf を追加します。

docker-compose.yml の一番下に sftp-server の設定を追加します。

  #############################################################################
  # sftp-server
  #
  sftp-server:
    image: atmoz/sftp:alpine-3.7
    container_name: sftp-server
    ports:
      - "22:22"
    volumes:
      - ./docker/sftp-server/config/users.conf:/etc/sftp/users.conf:ro

docker-compose up -d コマンドを実行して atmoz/sftp の Docker Image を pull してサーバを起動します。

f:id:ksby:20211124210744p:plain

WinSCP で接続できることを確認します。

f:id:ksby:20211124211242p:plain f:id:ksby:20211124211342p:plain f:id:ksby:20211124211421p:plain

build.gradle に Spring Integration の SFTP Adapter を依存関係に追加する

groovy-script-executor/build.gradle を以下のように変更します。

dependencies {
    ..........

    // dependency-management-plugin によりバージョン番号が自動で設定されるもの
    // Dependency Versions ( https://docs.spring.io/spring-boot/docs/current/reference/html/dependency-versions.html#dependency-versions ) 参照
    implementation("org.springframework.boot:spring-boot-starter")
    implementation("org.springframework.integration:spring-integration-sftp")
    ..........
  • implementation("org.springframework.integration:spring-integration-sftp") を追加します。

Gradle Tool Window の左上にある「Refresh all Gradle projects」ボタンをクリックして更新します。

Groovy スクリプト側で Picocli の @Option アノテーションが使用できるよう groovy-script-executor.jar を変更する

今の groovy-script-executor.jar の実装ではコマンドライン引数を @Parameters アノテーションで Groovy スクリプト側で受け取ることは出来るのですが、@Option アノテーションで受け取ることが出来ません。

Groovy スクリプト側で @Option アノテーションを付与したフィールドを定義しても、groovy-script-executor.jar の GroovyScriptExecutorCommand クラスで先に @Option アノテーションで定義されているかがチェックされて、定義されていないとエラーになります。

GroovyScriptExecutorCommand クラスに @Unmatched アノテーション を付与したフィールドを定義しておくと @Option アノテーションで定義されていなくてもエラーにならず @Unmatched アノテーションを付与したフィールドにセットされますので、この動作を利用します。

groovy-script-executor/src/main/java/ksby/cmdapp/groovyscriptexecutor/command/GroovyScriptExecutorCommand.java を以下のように変更します。

package ksby.cmdapp.groovyscriptexecutor.command;

import groovy.lang.Binding;
import groovy.lang.GroovyShell;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.ArrayUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.info.BuildProperties;
import org.springframework.stereotype.Component;

import java.io.File;
import java.io.IOException;
import java.util.concurrent.Callable;

import static picocli.CommandLine.*;

@Slf4j
@Component
@Command(name = "groovy-script-executor", mixinStandardHelpOptions = true,
        versionProvider = GroovyScriptExecutorCommand.class,
        description = "Groovyスクリプトを実行するコマンド")
public class GroovyScriptExecutorCommand
        implements Callable<Integer>, IExitCodeExceptionMapper, IVersionProvider {

    @Autowired
    private BuildProperties buildProperties;

    @Parameters(index = "0", paramLabel = "Groovyスクリプト",
            description = "実行する Groovyスクリプトを指定する")
    private File groovyScript;

    @Parameters(index = "1..*", paramLabel = "引数",
            description = "Groovyスクリプトに渡す引数を指定する")
    private String[] args;

    @Unmatched
    private String[] unmatched;

    @Override
    public Integer call() throws IOException {
        try {
            Binding binding = new Binding();
            GroovyShell shell = new GroovyShell(binding);
            shell.run(groovyScript, ArrayUtils.addAll(args, unmatched));
        } catch (Exception e) {
            log.error("Groovyスクリプトでエラーが発生しました。", e);
        }

        return ExitCode.OK;
    }

    ..........
  • @Unmatched private String[] unmatched; を追加します。
  • call メソッド内の以下の点を変更します。
    • shell.run(groovyScript, args);shell.run(groovyScript, ArrayUtils.addAll(args, unmatched)); に変更します。

Groovy スクリプトを配置する package を sample → ksby.cmdapp.groovyscriptexecutor.script に変更する

groovy-script-executor/src/main/groovy の下に ksby.cmdapp.groovyscriptexecutor.script package を新規作成し、sample package の下の Groovy スクリプトをこの下に移動します。移動後、sample package を削除します。

ksby.cmdapp.groovyscriptexecutor.script package の下の Groovy スクリプトが groovy-script-executor.jar に含まれないようにするために groovy-script-executor/build.gradle を以下のように変更します。

[compileJava, compileTestGroovy, compileTestJava]*.options*.encoding = "UTF-8"
[compileJava, compileTestGroovy, compileTestJava]*.options*.compilerArgs = ["-Xlint:all,-options,-processing,-path"]
sourceSets {
    main {
        compileGroovy {
            exclude "ksby/cmdapp/groovyscriptexecutor/script/*.groovy"
        }
    }
}
..........
  • SourceSets 内で exclude "sample/*.groovy"exclude "ksby/cmdapp/groovyscriptexecutor/script/*.groovy" に変更します。

また groovy-script-executor/src/main/java/ksby/cmdapp/groovyscriptexecutor の下にも script package を新規作成します。

groovy-script-executor.jar 内に SFTP のアップロード・ダウンロード処理のための helper クラスを作成する

groovy-script-executor/src/main/java/ksby/cmdapp/groovyscriptexecutor/script の下に helper.sftp package を新規作成した後、この下に SftpHelper.java を新規作成し以下のコードを記述します。

package ksby.cmdapp.groovyscriptexecutor.script.helper.sftp;

import com.jcraft.jsch.ChannelSftp;
import com.jcraft.jsch.SftpException;
import org.springframework.expression.common.LiteralExpression;
import org.springframework.integration.file.remote.ClientCallbackWithoutResult;
import org.springframework.integration.file.support.FileExistsMode;
import org.springframework.integration.sftp.session.DefaultSftpSessionFactory;
import org.springframework.integration.sftp.session.SftpRemoteFileTemplate;
import org.springframework.integration.support.MessageBuilder;
import org.springframework.messaging.Message;
import org.springframework.stereotype.Component;

import java.io.File;

@Component
public class SftpHelper {

    public SftpRemoteFileTemplate createSftpRemoteFileTemplate(
            String host,
            int port,
            String user,
            String password) {
        DefaultSftpSessionFactory factory = new DefaultSftpSessionFactory(false);
        factory.setHost(host);
        factory.setPort(port);
        factory.setUser(user);
        factory.setPassword(password);
        factory.setAllowUnknownKeys(true);

        return new SftpRemoteFileTemplate(factory);
    }

    public void upload(SftpRemoteFileTemplate sftpRemoteFileTemplate,
                       String uploadDir, File uploadFile) {
        sftpRemoteFileTemplate.setRemoteDirectoryExpression(new LiteralExpression(uploadDir));
        Message<File> message = MessageBuilder.withPayload(uploadFile).build();
        sftpRemoteFileTemplate.send(message, FileExistsMode.REPLACE);
    }

    public void download(SftpRemoteFileTemplate sftpRemoteFileTemplate,
                         String downlaodSrc, String downloadDst) {
        sftpRemoteFileTemplate.executeWithClient(
                (ClientCallbackWithoutResult<ChannelSftp>) client -> {
                    try {
                        client.get(downlaodSrc, downloadDst);
                    } catch (SftpException e) {
                        throw new RuntimeException(e);
                    }
                });
    }
}

Groovy スクリプトで SFTP クライアントを作成する

groovy-script-executor/src/main/groovy/ksby/cmdapp/groovyscriptexecutor/script の下に SftpClient.groovy を新規作成し、以下のコードを記述します。

package ksby.cmdapp.groovyscriptexecutor.script

import groovy.util.logging.Slf4j
import ksby.cmdapp.groovyscriptexecutor.script.helper.sftp.SftpHelper
import org.springframework.boot.CommandLineRunner
import org.springframework.boot.ExitCodeGenerator
import org.springframework.boot.SpringApplication
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.integration.sftp.session.SftpRemoteFileTemplate
import org.springframework.stereotype.Component
import picocli.CommandLine
import picocli.CommandLine.ArgGroup
import picocli.CommandLine.Command
import picocli.CommandLine.ExitCode
import picocli.CommandLine.IExitCodeExceptionMapper
import picocli.CommandLine.IFactory
import picocli.CommandLine.Option

import java.util.concurrent.Callable

@Slf4j
@SpringBootApplication
class SftpClient implements CommandLineRunner, ExitCodeGenerator {

    private int exitCode

    private final SftpClientCommand sftpClientCommand

    private final IFactory factory

    SftpClient(SftpClientCommand sftpClientCommand, IFactory factory) {
        this.sftpClientCommand = sftpClientCommand
        this.factory = factory
    }

    static void main(String[] args) {
        System.exit(SpringApplication.exit(SpringApplication.run(SftpClient.class, args)));
    }

    @Override
    void run(String... args) throws Exception {
        exitCode = new CommandLine(sftpClientCommand, factory)
                .setExitCodeExceptionMapper(sftpClientCommand)
                .execute(args)
    }

    @Override
    int getExitCode() {
        return exitCode
    }

    @Component
    @Command(name = "SftpClient",
            mixinStandardHelpOptions = true,
            description = "SFTPサーバにファイルをアップロード・ダウンロードするコマンド")
    static class SftpClientCommand
            implements Callable<Integer>, IExitCodeExceptionMapper {

        @Option(names = "--host", required = false, description = "ホスト名")
        String host = "localhost"

        @Option(names = "--port", required = false, description = "ポート番号")
        int port = 22

        @Option(names = ["-u", "--user"], description = "ユーザ名")
        String user

        @Option(names = ["-p", "--password"], description = "パスワード")
        String password

        @ArgGroup(exclusive = true, multiplicity = "1")
        SftpClientOperation sftpClientOperation

        static class SftpClientOperation {

            @ArgGroup(exclusive = false, multiplicity = "1")
            UploadOption uploadOption

            @ArgGroup(exclusive = false, multiplicity = "1")
            DownloadOption downloadOption

        }

        static class UploadOption {

            @Option(names = "--upload-dir", required = true, description = "アップロード先ディレクトリ")
            String uploadDir

            @Option(names = "--upload-file", required = true, description = "アップロードするファイル")
            File uploadFile

        }

        static class DownloadOption {

            @Option(names = "--download-src", required = true, description = "ダウンロード元ファイル")
            String downlaodSrc

            @Option(names = "--download-dst", required = true, description = "ダウンロード先ファイル")
            String downloadDst

        }

        private final SftpHelper sftpHelper

        SftpClientCommand(SftpHelper sftpHelper) {
            this.sftpHelper = sftpHelper
        }

        @Override
        Integer call() throws Exception {
            SftpRemoteFileTemplate sftpRemoteFileTemplate =
                    sftpHelper.createSftpRemoteFileTemplate(host, port, user, password)

            if (sftpClientOperation.uploadOption != null) {
                log.info("{} へ {} をアップロードします",
                        sftpClientOperation.uploadOption.uploadDir,
                        sftpClientOperation.uploadOption.uploadFile)
                sftpHelper.upload(sftpRemoteFileTemplate,
                        sftpClientOperation.uploadOption.uploadDir,
                        sftpClientOperation.uploadOption.uploadFile)
            } else {
                log.info("{} を {} へダウンロードします",
                        sftpClientOperation.downloadOption.downlaodSrc,
                        sftpClientOperation.downloadOption.downloadDst)
                sftpHelper.download(sftpRemoteFileTemplate,
                        sftpClientOperation.downloadOption.downlaodSrc,
                        sftpClientOperation.downloadOption.downloadDst)
            }

            return ExitCode.OK
        }

        @Override
        int getExitCode(Throwable exception) {
            if (exception instanceof RuntimeException) {
                return 101
            }

            return ExitCode.OK
        }
    }

}

groovy-script-executor.jar と Groovy スクリプトが出力するログを調整する

groovy-script-executor/src/main/resources/application.properties を以下のように変更します。

spring.main.banner-mode=off

logging.level.root=ERROR
logging.level.com.jcraft.jsch=ERROR
logging.level.ksby.cmdapp.groovyscriptexecutor.script=INFO
logging.level.org.springframework.integration.expression.ExpressionUtils=ERROR
  • logging.level.root=OFFlogging.level.root=ERROR に変更します。
  • 以下の行を追加します。
    • logging.level.com.jcraft.jsch=ERROR
      • SFTP のライブラリのログです。SFTP の処理の詳細を知りたい時にはこのログのレベルを変更します。
    • logging.level.ksby.cmdapp.groovyscriptexecutor.script=INFO
    • logging.level.org.springframework.integration.expression.ExpressionUtils=ERROR
      • あまり意味のない WARN ログが出力されるのでログレベルを ERROR に変更して出力されないようにします。

ログレベルは groovy-script-executor.jar で設定したので D:\tmp\application.properties を削除します。

コマンドラインで Groovy スクリプトを実行する際には JSON フォーマットのログは不便なので CONSOLE に戻します。groovy-script-executor/src/main/resources/logback-spring.xml を以下のように変更します。

    ..........

    <if condition='isDefined("LOG_FILE")'>
        <then>
            <root>
                <appender-ref ref="${LOGGING_APPENDER}"/>
            </root>
        </then>
        <else>
            <root>
                <appender-ref ref="CONSOLE"/>
                <!--<appender-ref ref="JSON"/>-->
            </root>
        </else>
    </if>
</configuration>

build タスクを実行し、生成した groovy-script-executor.jar を D:\tmp にコピーします。

動作確認

SFTP サーバの upload ディレクトリに何もファイルがないことを確認してから、

f:id:ksby:20211127162239p:plain

gse SftpClient.groovy --user=user01 --password=pass01 --upload-dir=upload --upload-file=publications.csv コマンドを実行するとログが出力されて、

f:id:ksby:20211127162513p:plain

upload ディレクトリに publications.csv がアップロードされます。

f:id:ksby:20211127162629p:plain

次に gse SftpClient.groovy --user=user01 --password=pass01 --download-src=upload/publications.csv --download-dst=D:\tmp\sample.csv コマンドを実行すると、

f:id:ksby:20211127162926p:plain

upload/publications.csv が D:\tmp の下に sample.csv としてダウンロードされます。

f:id:ksby:20211127163013p:plain

メモ書き

groovy-script-executor.jar 内の Application クラス(@SpringBootApplication を付与したクラス)が ComponentScan の対象に含まれると Groovy スクリプトは実行されるが help が表示される

SftpClient.groovy の @SpringBootApplication アノテーションscanBasePackages = "ksby.cmdapp.groovyscriptexecutor" を指定して ComponentScan の対象に groovy-script-executor.jar 内の Application クラスが含まれるようにしてから、

@Slf4j
@SpringBootApplication(scanBasePackages = "ksby.cmdapp.groovyscriptexecutor")
class SftpClient implements CommandLineRunner, ExitCodeGenerator {

gse SftpClient.groovy --user=user01 --password=pass01 --upload-dir=upload --upload-file=publications.csv コマンドを実行すると Missing required parameter: 'Groovyスクリプト のメッセージと help が表示されます。

f:id:ksby:20211127165407p:plain

ただし Groovy スクリプト自体は実行されており、ファイルはアップロードされていました。

メッセージと help が表示されるのは紛らわしいので、groovy-script-executor.jar 内の Application クラス(@SpringBootApplication を付与したクラス)が ComponentScan の対象に含まれないようにした方がよいでしょう。

履歴

2021/11/27
初版発行。