Spring-Boot Args4jを使ったサブコマンド(SubCommands)の実装案


Spring-Boot で、SubCommands を作れないのかと検索していたら、Designing a CL App with SpringBoot CommandLineRunner Interface · Mert Kara
という記事が見つかりました。

Commons CLI と、CommandLineRunner を使っての実装だったので、Args4j を使って実装できないか、試してみた結果を記載します。


参考


必要なライブラリ

gradledependencies 定義です。

dependencies {
    // https://mvnrepository.com/artifact/org.springframework.boot/spring-boot-starter
    compile group: 'org.springframework.boot', name: 'spring-boot-starter', version: '1.5.3.RELEASE'
    // https://mvnrepository.com/artifact/args4j/args4j
    compile group: 'args4j', name: 'args4j', version: '2.33'
    compile group: 'com.google.api-client', name: 'google-api-client', version: '1.22.0'
    compile 'com.google.apis:google-api-services-webmasters:v3-rev22-1.22.0'
}

作ったもの

いきなりですが、作ったものを貼り付けます。1
[1].Google Search Console API のコマンドラインツールを作っていて、作っているの途中ですが、今回の記事内容としてはまあいいかなと..

CliApplication.java

Applicationクラスです。
取得結果を、コンソール出力する際に、BANNERの出力はいらないので、
OFFにしています。

package xyz.monotalk.google.webmaster.cli;

import org.springframework.boot.Banner;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;

@SpringBootApplication
public class CliApplication {
    public static void main(String[] args) {
        SpringApplication springApplication = new SpringApplication(CliApplication.class);
        springApplication.setBannerMode(Banner.Mode.OFF);
        springApplication.run(args);
    }
}

Command.java

args4jSubCommandHandler で使用する interface になります。

package xyz.monotalk.google.webmaster.cli;

/**
 * interface Command
 */
public interface Command {
    void execute();
}

WebmastersCommandRunner.java

CommandLineRunner の実装クラスです。
説明が長いので、プログラムの下部に説明を記載します。

package xyz.monotalk.google.webmaster.cli;

import org.kohsuke.args4j.Argument;
import org.kohsuke.args4j.CmdLineParser;
import org.kohsuke.args4j.spi.SubCommand;
import org.kohsuke.args4j.spi.SubCommandHandler;
import org.kohsuke.args4j.spi.SubCommands;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsDeleteCommand;
import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsGetCommand;
import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsListCommand;
import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsSubmitCommand;

import java.util.ArrayList;
import java.util.List;

/**
 * WebmastersCommandRunner
 */
@Component
public class WebmastersCommandRunner implements CommandLineRunner {

    @Autowired
    private ApplicationContext context;

    /**
     * 引数によって実行するオブジェクトを切り替える
     */
    @Argument(handler = SubCommandHandler.class)
    @SubCommands(
            {
                @SubCommand(name = "sitemaps.list", impl = SiteMapsListCommand.class),
                @SubCommand(name = "sitemaps.delete", impl = SiteMapsDeleteCommand.class),
                @SubCommand(name = "sitemaps.get", impl = SiteMapsGetCommand.class),
                @SubCommand(name = "sitemaps.submit", impl = SiteMapsSubmitCommand.class)
            })
    private Command command;

    @Override
    public void run(String... args) throws Exception {
        // ------------------------------------------------
        // "--application" を含むコマンドライン引数を除外
        // ----------------
        List<String> cmdArgs = new ArrayList<>();
        for (String arg : args) {
            if (!arg.contains("--application")) {
                cmdArgs.add(arg);
            }
        }
        new CmdLineParser(this).parseArgument(cmdArgs);
        AutowireCapableBeanFactory autowireCapableBeanFactory = context.getAutowireCapableBeanFactory();
        autowireCapableBeanFactory.autowireBean(this.command);
        this.command.execute();
    }
}
  • 説明

    • Application クラスと同一パッケージに作成
      @Component アノテーションを付与しているので、Application クラスの同一パッケージ以下に存在すれば、
      Application クラス 起動後に run メソッドを実行してくれます。

    • @Argument アノテーションと、 @SubCommands アノテーションを使用して、SubCommand を定義
      指定される引数に対応する、Command の実装クラスをアノテーションで定義しています。

    • run メソッドの引数 args から、--application を含むオプション引数を除外
      後述するJava クラスで、application.keyFileLocation を使っています。
      args4j は、調べた限りだと、知らない引数があると、エラーを吐くので、既知の args4j が知らない引数を、
      除外しています。

    • インスタンス化した、command クラスにinject する

      AutowireCapableBeanFactory autowireCapableBeanFactory = context.getAutowireCapableBeanFactory();
      autowireCapableBeanFactory.autowireBean(this.command);
      
      で、インスタンス化したcommandクラスに手動でinject できます。

WebmastersFactory.java

AutowireCapableBeanFactory autowireCapableBeanFactory = context.getAutowireCapableBeanFactory();
autowireCapableBeanFactory.autowireBean(this.command);
で、command に inject されるクラスです。
inject 時に、keyFileLocation に、コマンドライン引数で指定した --application.keyFileLocation=@@@@
@@@@ が値としてinject されます。
@@@@ には、Google Search Console API の キーファイルを指定しています。

package xyz.monotalk.google.webmaster.cli;

import com.google.api.client.googleapis.auth.oauth2.GoogleCredential;
import com.google.api.client.googleapis.javanet.GoogleNetHttpTransport;
import com.google.api.client.http.HttpTransport;
import com.google.api.client.json.JsonFactory;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.services.webmasters.Webmasters;
import com.google.api.services.webmasters.WebmastersScopes;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;

import java.io.FileInputStream;
import java.io.IOException;
import java.security.GeneralSecurityException;
import java.util.Collections;

/**
 * WebmastersFactory
 */
@Component
public class WebmastersFactory {

    @Value("${application.keyFileLocation}")
    private String keyFileLocation;

    /**
     * Create Webmasters instance.
     *
     * @return
     */
    public Webmasters create() {
        HttpTransport httpTransport;
        try {
            httpTransport = GoogleNetHttpTransport.newTrustedTransport();
        } catch (GeneralSecurityException e) {
            throw new IllegalStateException(e);
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
        JsonFactory jsonFactory = JacksonFactory.getDefaultInstance();
        GoogleCredential credential;
        try {
            credential = GoogleCredential.fromStream(new FileInputStream(keyFileLocation)).createScoped(Collections.singleton(WebmastersScopes.WEBMASTERS));
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
        // Create a new authorized API client
        Webmasters service = new Webmasters.Builder(httpTransport, jsonFactory, credential).setApplicationName("Search Console Cli").build();
        return service;
    }
}

SiteMapsListCommand.java

Command の実装クラスになります。サブコマンドです。
@Autowired 指定した WebmastersFactory が、WebmastersCommandRunner 内で inject され、execute メソッドで、WebmastersFactory#create()が呼び出されています。

サブコマンド内で、args4j のアノテーションを付与されたフィールド変数には、CLI で指定した引数が設定されます。

package xyz.monotalk.google.webmaster.cli.subcommands.sitemaps;

import com.google.api.services.webmasters.Webmasters;
import com.google.api.services.webmasters.model.SitemapsListResponse;
import org.kohsuke.args4j.Option;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import xyz.monotalk.google.webmaster.cli.Command;
import xyz.monotalk.google.webmaster.cli.WebmastersFactory;

import java.io.IOException;

/**
 * SiteMapsListCommand
 */
@Component
public class SiteMapsListCommand implements Command {

    @Autowired
    private WebmastersFactory factory;

    @Option(name = "-siteUrl", usage = "Url of site", required = true)
    private String siteUrl = null;

    @Override
    public void execute() {
        Webmasters webmasters = factory.create();
        Webmasters.Sitemaps.List siteMaps;
        try {
            siteMaps = webmasters.sitemaps().list(siteUrl);
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
        SitemapsListResponse response;
        try {
            response = siteMaps.execute();
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
        try {
            System.out.println(response.toPrettyString());
        } catch (IOException e) {
            throw new IllegalStateException(e);
        }
    }
}

Args4j の Usage を出力する

SubCommand に required = true, metaVar = "<command name>", usage = "command name" を追加して、usage の出力ができるようにします。 3属性ともに設定しないと、usage は出力されませんでした。

  • WebmastersCommandRunner.java

    package xyz.monotalk.google.webmaster.cli;
    
    import org.kohsuke.args4j.Argument;
    import org.kohsuke.args4j.CmdLineException;
    import org.kohsuke.args4j.CmdLineParser;
    import org.kohsuke.args4j.spi.SubCommand;
    import org.kohsuke.args4j.spi.SubCommandHandler;
    import org.kohsuke.args4j.spi.SubCommands;
    import org.springframework.beans.factory.annotation.Autowired;
    import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
    import org.springframework.boot.CommandLineRunner;
    import org.springframework.context.ApplicationContext;
    import org.springframework.stereotype.Component;
    import xyz.monotalk.google.webmaster.cli.subcommands.SearchAnalyticsCommand;
    import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsDeleteCommand;
    import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsGetCommand;
    import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsListCommand;
    import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsSubmitCommand;
    
    import java.lang.reflect.Field;
    import java.util.ArrayList;
    import java.util.Arrays;
    import java.util.List;
    import java.util.StringJoiner;
    
    /**
     * WebmastersCommandRunner
     */
    @Component
    public class WebmastersCommandRunner implements CommandLineRunner {
    
        @Autowired private ApplicationContext context;
    
        /**
         * 引数によって実行するオブジェクトを切り替える
         */
        @Argument(handler = SubCommandHandler.class, required = true, metaVar = "<command name>", usage = "command name")
        @SubCommands({@SubCommand(name = "webmasters.sitemaps.list", impl = SiteMapsListCommand.class), @SubCommand(
                name = "webmasters.sitemaps.delete", impl = SiteMapsDeleteCommand.class), @SubCommand(
                name = "webmasters.sitemaps.get", impl = SiteMapsGetCommand.class), @SubCommand(
                name = "webmasters.sitemaps.submit", impl = SiteMapsSubmitCommand.class), @SubCommand(
                name = "webmasters.searchanalytics.query", impl = SearchAnalyticsCommand.class),})
        private Command command;
    
        @Override
        public void run(String... args) throws Exception {
            // ------------------------------------------------
            // "--application" を含むコマンドライン引数を除外
            // ----------------
            List<String> cmdArgs = new ArrayList<>();
            for (String arg : args) {
                if (!arg.contains("--application")) {
                    cmdArgs.add(arg);
                }
            }
            CmdLineParser parser = new CmdLineParser(this);
            try {
                parser.parseArgument(cmdArgs);
                AutowireCapableBeanFactory autowireCapableBeanFactory = context.getAutowireCapableBeanFactory();
                autowireCapableBeanFactory.autowireBean(this.command);
                this.command.execute();
            } catch (CmdLineException e) {
                System.err.println(e.getMessage());
                parser.printUsage(System.err);
                return;
            }
        }
    }
    

  • 説明
    CmdLineParsertry - catch して、CmdLineException が出力された場合、CmdLineParser#printUsage() でusage を出力します。
    java -jar xyz.monotalk.google.webmaster.cli-0.0.1.jar --application.keyFileLocation=@@@@@ のように、subcommand 名 指定せずに コマンドを実行すると、以下のようなusage が出力されます。

  • OUTPUT
    Argument "<command name>" is required の部分が、System.err.println(e.getMessage()); で出力されており、それ以外の部分は、parser.printUsage(System.err); で出力されています。

Argument "<command name>" is required
 [webmasters.sitemaps.list |            : command name
 webmasters.sitemaps.delete |              
 webmasters.sitemaps.get |                 
 webmasters.sitemaps.submit |              
 webmasters.searchanalytics.query]         

Flask風 の Usage を出力する

apache/incubator-superset: Apache Superset (incubating) is a modern, enterprise-ready business intelligence web application の usage が 良い感じに出力されるので、その雰囲気で コンソール出力されるように実装してみました。
usage() メソッドで、SubCommands アノテーションの定義値を取得し、それを元に、コマンドの usage を出力します。
* WebmastersCommandRunner.java

package xyz.monotalk.google.webmaster.cli;

import org.apache.commons.lang3.tuple.Pair;
import org.apache.commons.text.StrBuilder;
import org.kohsuke.args4j.Argument;
import org.kohsuke.args4j.CmdLineException;
import org.kohsuke.args4j.CmdLineParser;
import org.kohsuke.args4j.Option;
import org.kohsuke.args4j.spi.SubCommand;
import org.kohsuke.args4j.spi.SubCommandHandler;
import org.kohsuke.args4j.spi.SubCommands;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.AutowireCapableBeanFactory;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.ApplicationContext;
import org.springframework.stereotype.Component;
import xyz.monotalk.google.webmaster.cli.subcommands.SearchAnalyticsCommand;
import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsDeleteCommand;
import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsGetCommand;
import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsListCommand;
import xyz.monotalk.google.webmaster.cli.subcommands.sitemaps.SiteMapsSubmitCommand;

import java.lang.reflect.Field;
import java.util.ArrayList;
import java.util.Arrays;
import java.util.List;
import java.util.StringJoiner;

import static java.lang.System.out;

/**
 * WebmastersCommandRunner
 */
@Component
public class WebmastersCommandRunner implements CommandLineRunner {

    @Autowired private ApplicationContext context;

    /**
     * 引数によって実行するオブジェクトを切り替える
     */
    @Argument(handler = SubCommandHandler.class)
    @SubCommands({@SubCommand(name = "webmasters.sitemaps.list", impl = SiteMapsListCommand.class), @SubCommand(
            name = "webmasters.sitemaps.delete", impl = SiteMapsDeleteCommand.class), @SubCommand(
            name = "webmasters.sitemaps.get", impl = SiteMapsGetCommand.class), @SubCommand(
            name = "webmasters.sitemaps.submit", impl = SiteMapsSubmitCommand.class), @SubCommand(
            name = "webmasters.searchanalytics.query", impl = SearchAnalyticsCommand.class),})
    private Command command;

    @Option(name = "-?", aliases = "--help", usage = "show this help message and exit") private boolean usageFlag;

    @Override
    public void run(String... args) throws Exception {
        // ------------------------------------------------
        // "--application" を含むコマンドライン引数を除外
        // ----------------
        List<String> cmdArgs = new ArrayList<>();
        for (String arg : args) {
            if (!arg.contains("--application")) {
                cmdArgs.add(arg);
            }
        }
        if (cmdArgs.isEmpty()) {
            CmdLineParser parser = new CmdLineParser(this);
            parser.parseArgument(cmdArgs);
            out.println("--------------------------------------------------------------------------");
            out.println(usage());
            parser.printUsage(out);
            out.println("------------");
            return;
        }
        CmdLineParser parser = new CmdLineParser(this);
        try {
            parser.parseArgument(cmdArgs);
            // Output help
            if (usageFlag) {
                out.println("--------------------------------------------------------------------------");
                out.println(usage());
                parser.printUsage(out);
                out.println("---------------------------");
                return;
            }
            AutowireCapableBeanFactory autowireCapableBeanFactory = context.getAutowireCapableBeanFactory();
            autowireCapableBeanFactory.autowireBean(this.command);
            this.command.execute();
        } catch (CmdLineException e) {
            out.println("error occurred: " + e.getMessage());
            out.println("--------------------------------------------------------------------------");
            out.println(usage());
            parser.printUsage(out);
            out.println("------------");
            return;
        }
    }

    /**
     * usage
     */
    private String usage() {
        StrBuilder sb = new StrBuilder();
        sb.appendln("usage: xyz.monotalk.google.webmaster.cli.CliApplication");
        sb.appendNewLine();
        Field field;
        try {
            field = this.getClass().getDeclaredField("command");
        } catch (NoSuchFieldException e) {
            throw new IllegalStateException(e);
        }
        SubCommands subCommands = field.getAnnotation(SubCommands.class);
        StringJoiner joiner = new StringJoiner(",", "{", "}");
        Arrays.stream(subCommands.value()).forEach(e -> joiner.add(e.name()));
        sb.appendln("  " + joiner.toString());
        sb.appendln("  " + "...");
        sb.appendNewLine();
        sb.appendln("positional arguments:");
        sb.appendNewLine();
        sb.appendln("  " + joiner.toString());
        Arrays.stream(subCommands.value()).map(e -> {
            try {
                return Pair.of(e.name(), (Command) e.impl().newInstance());
            } catch (InstantiationException | IllegalAccessException ex) {
                throw new IllegalStateException(ex);
            }
        }).forEach(e -> {
            sb.appendln("    " + e.getLeft() + "    " + e.getRight().usage());
        });
        sb.appendNewLine();
        sb.append("optional arguments:");
        sb.appendNewLine();
        return sb.toString();
    }
}

  • Command interface に method を追加
    Commannd.java に、説明文取得用にメソッドを追加し、各Command クラスから、説明文を返すようにしました。

    package xyz.monotalk.google.webmaster.cli;
    
    /**
     * Command interface
     */
    public interface Command {
        /**
         * Main Method
         */
        void execute();
    
        /**
         * Usage
         * @return
         */
        String usage();
    
    }
    

  • superset の usage
    superset の usage は以下のように出力されます。

    % superset -?
    usage: superset [-?]
    
                    {flower,version,worker,db,runserver,refresh_druid,init,load_examples,shell,update_datasources_cache}
                    ...
    
    positional arguments:
      {flower,version,worker,db,runserver,refresh_druid,init,load_examples,shell,update_datasources_cache}
        flower              Runs a Celery Flower web server Celery Flower is a UI
                            to monitor the Celery operation on a given broker
        load_examples       Loads a set of Slices and Dashboards and a supporting
                            dataset
        worker              Starts a Superset worker for async SQL query
                            execution.
        db                  Perform database migrations
        runserver           Starts a Superset web server.
        refresh_druid       Refresh druid datasources
        init                Inits the Superset application
        version             Prints the current version number
        shell               Runs a Python shell inside Flask application context.
        update_datasources_cache
                            Refresh sqllab datasources cache
    
    optional arguments:
      -?, --help            show this help message and exit
    

  • 作成したCommandLineRunner のusage
    以下のような出力になります。

    % java -jar xyz.monotalk.google.webmaster.cli-0.0.1.jar --application.keyFileLocation=@@@ -? 
    --------------------------------------------------------------------------
    usage: xyz.monotalk.google.webmaster.cli.CliApplication
    
      {webmasters.sitemaps.list,webmasters.sitemaps.delete,webmasters.sitemaps.get,webmasters.sitemaps.submit,webmasters.searchanalytics.query}
      ...
    
    positional arguments:
    
      {webmasters.sitemaps.list,webmasters.sitemaps.delete,webmasters.sitemaps.get,webmasters.sitemaps.submit,webmasters.searchanalytics.query}
        webmasters.sitemaps.list      webmasters.sitemaps.list's description
        webmasters.sitemaps.delete    webmasters.sitemaps.delete's description
        webmasters.sitemaps.get    webmasters.sitemaps.get's descriptioin
        webmasters.sitemaps.submit    Submits a sitemap for a site.
        webmasters.searchanalytics.query    webmasters.searchanalytics.query's descriptioin
    
    optional arguments:
    
     -? (--help) : show this help message and exit (default: true)
    
    なかなか無理矢理な感じはあります。
    SubCommand の実装はこれで終わりです。
    以上です。

コメント