Spring-Boot で、SubCommands を作れないのかと検索していたら、Designing a CL App with SpringBoot CommandLineRunner Interface · Mert Kara
という記事が見つかりました。
Commons CLI と、CommandLineRunner を使っての実装だったので、Args4j を使って実装できないか、試してみた結果を記載します。
参考
-
Designing a CL App with SpringBoot CommandLineRunner Interface · Mert Kara
-
java - How do I manually autowire a bean with Spring? - Stack Overflow
-
args4j/SubCommandHandler.java at b1b2d131c348c3da771046b209c0f39e1204357d · kohsuke/args4j
必要なライブラリ
gradle
の dependencies
定義です。
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
args4j
の SubCommandHandler
で使用する 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 する
で、インスタンス化したcommandクラスに手動でinject できます。AutowireCapableBeanFactory autowireCapableBeanFactory = context.getAutowireCapableBeanFactory(); autowireCapableBeanFactory.autowireBean(this.command);
-
WebmastersFactory.java
AutowireCapableBeanFactory autowireCapableBeanFactory = context.getAutowireCapableBeanFactory();
autowireCapableBeanFactory.autowireBean(this.command);
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; } } }
-
説明
CmdLineParser
をtry - 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 の実装はこれで終わりです。
以上です。
コメント