読者です 読者をやめる 読者になる 読者になる

M12i.

学術書・マンガ・アニメ・映画の消費活動とプログラミングについて

Apache Commons Execで外部コマンド実行するサンプル

Java

とある事情でProcessBuilderオブジェクトを使用して外部コマンドを実行したものの*1その標準出力を取得しようとしたらロックしてしまった…という経験*2を踏まえ、別の方法としてApache Commons Execを利用することを考えました。

Apache Commons ExecはJavaコードから外部コマンドを実行するためのライブラリです。このライブラリが提供するAPIをざっと見ておくと:

クラス 説明
CommandLine コマンド文字列を表わすオブジェクト(引数も含む)です。
Executor コマンドの実行を担当するオブジェクトで標準入力・出力・エラーのハンドリングや終了コードのチェック、タイムアウト時間の設定などなどの窓口ともなります。
Watchdog タイムアウトを制御するためのオブジェクトです。このオブジェクトを初期化してExecutorに設定することで実行打ち切り時間を設定できます。
PumpStreamHandler ExecuteStreamHandlerインターフェースの実装クラス。このオブジェクトを介してコマンド実行時の標準入力をInputStreamで、標準出力・エラーをOutputStreamで指定して、それぞれ設定することができます。

そしてサンプルコード(プロジェクト全体はこちら):

public final class Main {
  public static void main(final String[] args) throws Exception {
    executeAndPrintLines("ls -la");
    executeAndPrintLines("ls", "-la");
    executeAndPrintLines("ls", "-la", "no_such_file.txt");
    executeAndPrintLines("ping -c 2 localhost");
    executeAndPrintLines("ping -c 5 localhost");
  }
  
  private static void executeAndPrintLines(final String... commandAndArgs) {
    System.out.println("Execute: " + Arrays.asList(commandAndArgs));
    // コマンドをパースしてオブジェクト化
    final ExternalCommand cmd = ExternalCommand.parse(commandAndArgs);
    // タイムアウト時間に3000ミリ秒を指定しつつ実行
    final Result res = cmd.execute(3000);
    // 終了コードを確認する
    System.out.println("ExitCode: " + res.getExitCode());
    // コマンドの標準出力の内容から入力ストリームを生成してそこから再度内容を読み取る
    System.out.println("Stdout: ");
    for (final String line : res.getStdoutLines()) {
      System.out.println("1>  " + line);
    }
    // コマンドの標準エラーの内容から入力ストリームを生成してそこから再度内容を読み取る
    System.out.println("Stderr: ");
    for (final String line : res.getStderrLines()) {
      System.out.println("2>  " + line);
    }
    System.out.println();
  }
}

実行結果はこんな感じ:

Execute: [ls -la]
ExitCode: 0
Stdout: 
1>  total 48
1>  drwxr-xr-x  12 foo  staff   408 Apr 23 02:32 .
1>  drwxr-xr-x  21 foo  staff   714 Apr 22 23:22 ..
1>  -rw-r--r--   1 foo  staff  1397 Apr 22 23:24 .classpath
1>  drwxr-xr-x  15 foo  staff   510 Apr 23 08:50 .git
1>  -rw-r--r--   1 foo  staff    53 Apr 23 02:32 .gitignore
1>  -rw-r--r--   1 foo  staff   549 Apr 22 23:22 .project
1>  drwxr-xr-x   5 foo  staff   170 Apr 22 23:24 .settings
1>  -rw-r--r--   1 foo  staff  1083 Apr 23 02:31 LICENSE
1>  -rw-r--r--   1 foo  staff   105 Apr 23 02:31 README.md
1>  -rw-r--r--   1 foo  staff  1131 Apr 23 02:21 pom.xml
1>  drwxr-xr-x   4 foo  staff   136 Apr 22 23:22 src
1>  drwxr-xr-x   4 foo  staff   136 Apr 22 23:22 target
Stderr: 

Execute: [ls, -la]
ExitCode: 0
Stdout: 
1>  total 48
1>  drwxr-xr-x  12 foo  staff   408 Apr 23 02:32 .
1>  drwxr-xr-x  21 foo  staff   714 Apr 22 23:22 ..
1>  -rw-r--r--   1 foo  staff  1397 Apr 22 23:24 .classpath
1>  drwxr-xr-x  15 foo  staff   510 Apr 23 08:50 .git
1>  -rw-r--r--   1 foo  staff    53 Apr 23 02:32 .gitignore
1>  -rw-r--r--   1 foo  staff   549 Apr 22 23:22 .project
1>  drwxr-xr-x   5 foo  staff   170 Apr 22 23:24 .settings
1>  -rw-r--r--   1 foo  staff  1083 Apr 23 02:31 LICENSE
1>  -rw-r--r--   1 foo  staff   105 Apr 23 02:31 README.md
1>  -rw-r--r--   1 foo  staff  1131 Apr 23 02:21 pom.xml
1>  drwxr-xr-x   4 foo  staff   136 Apr 22 23:22 src
1>  drwxr-xr-x   4 foo  staff   136 Apr 22 23:22 target
Stderr: 

Execute: [ls, -la, no_such_file.txt]
ExitCode: 1
Stdout: 
Stderr: 
2>  ls: no_such_file.txt: No such file or directory

Execute: [ping -c 2 localhost]
ExitCode: 0
Stdout: 
1>  PING localhost (127.0.0.1): 56 data bytes
1>  64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.053 ms
1>  64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.143 ms
1>  
1>  --- localhost ping statistics ---
1>  2 packets transmitted, 2 packets received, 0.0% packet loss
1>  round-trip min/avg/max/stddev = 0.053/0.098/0.143/0.045 ms
Stderr: 

Execute: [ping -c 5 localhost]
ExitCode: 143
Stdout: 
1>  PING localhost (127.0.0.1): 56 data bytes
1>  64 bytes from 127.0.0.1: icmp_seq=0 ttl=64 time=0.086 ms
1>  64 bytes from 127.0.0.1: icmp_seq=1 ttl=64 time=0.132 ms
1>  64 bytes from 127.0.0.1: icmp_seq=2 ttl=64 time=0.061 ms
1>  64 bytes from 127.0.0.1: icmp_seq=3 ttl=64 time=0.057 ms
Stderr: 

サンプルで利用しているExternalCommandのコードは以下のとおり:

/**
 * 外部コマンドを表わすオブジェクト.
 * 同期もしくは非同期で当該コマンドを実行するためのメソッドを提供する。
 */
public final class ExternalCommand {
  /**
   * Apache Commons Execのコマンドライン・オブジェクト.
   */
  private final CommandLine commandLine;
  /**
   * コンストラクタ.
   * 静的メソッドを介した初期化のみ許可する。
   * @param args コマンドとその引数を表わす文字列配列
   */
  private ExternalCommand(final String... args) {
    if (args.length == 0) {
      throw new IllegalArgumentException();
    }
    this.commandLine = CommandLine.parse(args[0]);
    for (final String arg : Arrays.copyOfRange(args, 1, args.length)) {
      this.commandLine.addArgument(arg);
    }
  }
  /**
   * Apache Commons Execのコマンドライン・オブジェクトを返す.
   * @return コマンドライン・オブジェクト
   */
  public CommandLine getCommandLine() {
    return commandLine;
  }
  /**
   * タイムアウト指定なしで同期実行する.
   * @return 実行結果
   */
  public Result execute() {
    return execute(0);
  }
  /**
   * タイムアウト指定ありで同期実行する.
   * @param timeoutMillis 実行を打ち切るまでのミリ秒
   * @return 実行結果
   */
  public Result execute(final long timeoutMillis) {
    // 標準出力を受け取るためのストリームを初期化
    final PipeOutputStream out = new PipeOutputStream();
    // 標準エラーを受け取るためのストリームを初期化
    final PipeOutputStream err = new PipeOutputStream();
    // ストリームを引数にしてストリームハンドラを初期化
    final PumpStreamHandler streamHandler = new PumpStreamHandler(out, err);
    // エグゼキュータを初期化
    final Executor exec = new DefaultExecutor();
    // タイムアウト指定の引数を確認
    if (timeoutMillis > 0) {
      // 1以上の場合のみ実際にエグゼキュータに対して設定を行う
      exec.setWatchdog(new ExecuteWatchdog(timeoutMillis));
    }
    // 終了コードによるエラー判定をスキップするよう指定
    exec.setExitValues(null);
    // ストリームハンドラを設定
    exec.setStreamHandler(streamHandler);
    
    try {
      // 実行して終了コードを受け取る(同期実行する)
      final int exitCode = exec.execute(commandLine);
      // 実行結果を呼び出し元に返す
      return new Result(exitCode, out.getInputStream(), err.getInputStream());
    } catch (final ExecuteException e) {
      // 終了コード判定はスキップされるためこの例外がスローされるのは予期せぬ事態のみ
      // よって非チェック例外でラップして再スローする
      throw new RuntimeException(e);
    } catch (final IOException e) {
      // IOエラーの発生は予期せぬ事態
      // よって非チェック例外でラップして再スローする
      throw new RuntimeException(e);
    }
  }
  /**
   * タイムアウト指定なしで非同期実行する.
   * @return 実行結果にアクセスするためのFutureオブジェクト
   */
  public Future<Result> executeAsynchronously() {
    return executeAsynchronously(0);
  }
  /**
   * タイムアウト指定ありで非同期実行する.
   * @param timeoutMillis 実行を打ち切るまでのミリ秒
   * @return 実行結果にアクセスするためのFutureオブジェクト
   */
  public Future<Result> executeAsynchronously(final long timeoutMillis) {
    final ExecutorService service = Executors.newSingleThreadExecutor();
    return service.submit(new Callable<Result>() {
      @Override
      public Result call() throws Exception {
        return ExternalCommand.this.execute(timeoutMillis);
      }
    });
  }
  /**
   * 外部コマンド文字列を受け取りオブジェクトを初期化する.
   * @param commandLine 外部コマンド文字列
   * @return オブジェクト
   */
  public static ExternalCommand parse(final String commandLine) {
    return new ExternalCommand(commandLine);
  }
  /**
   * 外部コマンドとその引数を表わす文字列配列を受け取りオブジェクトを初期化する.
   * @param commandLine 外部コマンドとその引数の配列
   * @return オブジェクト
   */
  public static ExternalCommand parse(final String... commandAndArgs) {
    return new ExternalCommand(commandAndArgs);
  }
  /**
   * 実行結果を表わすオブジェクト.
   */
  public static final class Result {
    /**
     * 終了コード.
     */
    private final int exitCode;
    /**
     * 標準出力の内容にアクセスするための{@link InputStream}.
     */
    private final InputStream inputFromStdout;
    /**
     * 標準エラーの内容にアクセスするための{@link InputStream}.
     */
    private final InputStream inputFromStderr;
    /**
     * コンストラクタ.
     * @param exitCode 終了コード
     * @param inputFromStdout 標準出力の内容にアクセスするための{@link InputStream}
     * @param inputFromStderr 標準エラーの内容にアクセスするための{@link InputStream}
     */
    private Result(final int exitCode, final InputStream inputFromStdout, final InputStream inputFromStderr) {
      this.exitCode = exitCode;
      this.inputFromStdout = inputFromStdout;
      this.inputFromStderr = inputFromStderr;
    }
    /**
     * 終了コードを返す.
     * @return 終了コード
     */
    public int getExitCode() {
      return exitCode;
    }
    /**
     * 標準出力の内容にアクセスするための{@link InputStream}を返す.
     * @return {@link InputStream}
     */
    public InputStream getStdout() {
      return inputFromStdout;
    }
    /**
     * 標準エラーの内容にアクセスするための{@link InputStream}を返す.
     * @return {@link InputStream}
     */
    public InputStream getStderr() {
      return inputFromStderr;
    }
    /**
     * 標準出力の内容に行ごとにアクセスするための{@link Iterable}を返す.
     * @return {@link Iterable}
     */
    public Iterable<String> getStdoutLines() {
      return readLines(inputFromStdout);
    }
    /**
     * 標準エラーの内容に行ごとにアクセスするための{@link Iterable}を返す.
     * @return {@link Iterable}
     */
    public Iterable<String> getStderrLines() {
      return readLines(inputFromStderr);
    }
    /**
     * ストリームから文字列を読み出し行ごとのリストに変換する.
     * @param in ストリーム
     * @return リスト
     */
    private List<String> readLines(final InputStream in) {
      final BufferedReader br = new BufferedReader(new InputStreamReader(in, Charset.defaultCharset()));
      final List<String> result = new ArrayList<String>();
      String line = null;
      try {
        while ((line = br.readLine()) != null) {
          result.add(line);
        }
      } catch (final IOException e) {
        // 読み取り対象のストリームはByteArrayInputStreamである前提のため
        // IOエラーが起きることは実際上あり得ないこと
        // 万一エラーが起きた場合でも非チェック例外で包んで再スローする
        throw new RuntimeException(e);
      }
      return result;
    }
  }
}

*1:そもそもJavaで外部コマンドを実行するというのがあまりよい作法でないことは明らかなのですがそうは言っても現実的にそうせざるを得ない場面はあるわけです。

*2:この事象についてはこちらの記事が詳しいです。