本格派エンジニアの工具箱

第19回Javaプログラムから外部プロセスを起動するための「Apache Commons Exec」

Apache Commons Execとは

Javaプログラムから外部プロセスを実行する一般的な方法としては、標準ライブラリに用意されているjava.lang.ProcessBuilderクラスや、java.lang.Runtime.exec()メソッドがあります。しかしこれらのクラス/メソッドによるサポートは限定的であり、あまり使い勝手が良くないことでも知られています。Apache Commons Exec⁠以下、Commons Exec)は、そのような標準的な方法に変わる外部プロセスの起動手段を提供してくれるオープンソースのライブラリです。特にプロセスに対する適切な入出力処理が、比較的簡単に記述できるようになっている点が大きなメリットです。

Commons Execはこのページよりダウンロードできます。本稿執筆時点での最新版はバージョン1.1です。ダウンロードしたファイルを解凍し、中に含まれている「commons-exec-1.1.jar」をクラスパスに追加して利用します。

Commons Execによる外部プロセスの実行

Commons Execを使って外部プロセスを実行する手順は次のようになります。

  1. CommandLineクラスで実行したいコマンドを作成する
  2. Executorインスタンスを生成し、タイムアウトなどの設定を行う
  3. Executorのexecute()メソッドでコマンドを実行する

CommandLineは実行コマンドを表すクラスで、実行したいコマンドや実行ファイル名をコンストラクタに渡してインスタンスを生成します。コマンドに渡す実行時引数はaddArgument()メソッドやaddArguments()メソッドで設定します。Executorはプロセスの実行や環境変数の設定などを行うための機能が定義されたインターフェースで、実装クラスとしてはDefaultExecutorクラスが用意されています。次のコードは、Commons Execを使ってWindows上でpingコマンドを実行するプログラムの例です。

package jp.gihyo.toolbox.commonsexec;

import java.io.IOException;
import org.apache.commons.exec.*;

public class CommonsExecSample1 {
  public static void main(String[] args) {
    // コマンドを作成
    CommandLine commandLine = new CommandLine("ping");
    commandLine.addArgument("/n");
    commandLine.addArgument("5");
    commandLine.addArguments("/w 1000");
    commandLine.addArgument("127.0.0.1");
    
    // Executorを作成
    DefaultExecutor executor = new DefaultExecutor();
    try {
      executor.setExitValue(0);    // 正常終了の場合に返される値
      // 実行
      int exitValue = executor.execute(commandLine);
    } catch (ExecuteException ex) {
      ex.printStackTrace();
    } catch (IOException ex) {
      ex.printStackTrace();
    }
  }
}

実行したいコマンドは次のようなものです。\nはパケットの送信回数を、\wはタイムアウト時間を指定する引数です。

> ping /n 5 /w 1000 127.10.0.1

このコマンドをCommandLineのインスタンスとして生成しています。引数をひとつ追加する場合にはaddArgument()を使います。2つ以上追加する場合には、addArguments()にスペースで区切って渡します。

このCommandLineインスタンスを、Executorのexecute()メソッドに渡して呼び出せば、対象のコマンドがサブプロセスとして実行されます。デフォルトでは、execute()メソッドはプロセスが終了するまで処理をブロックします。execute()の戻り値は、プロセスが終了時に返す終了コードです。setExitValue()は、どの終了コードが返された場合に正常終了とみなすかを設定するためのメソッドになっています。ここで設定した値以外が返される場合には、ExecutorはExecuteExceptionを発生させます。

このプログラムを実行すると、次のようにpingコマンドで5回のパケットを送信し、その結果が標準出力に出力されることが確認できます。

127.0.0.1 に ping を送信しています 32 バイトのデータ:
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128

127.0.0.1 の ping 統計:
    パケット数: 送信 = 5、受信 = 5、損失 = 0 (0% の損失)、
ラウンド トリップの概算時間 (ミリ秒):
    最小 = 0ms、最大 = 0ms、平均 = 0ms

CommandLineに対する引数の追加は、次の例のように配列を使って行うこともできます。

CommandLine commandLine = new CommandLine("ping");
String[] options = {"/n", "5", "/w", "1000"};
commandLine.addArguments(options);
commandLine.addArgument("127.0.0.1");

また次に示すようにparse()メソッドを使って、コマンド文字列からCommandLineインスタンスを生成することも可能です。

// コマンドを作成
CommandLine commandLine = 
        CommandLine.parse("ping /n 5 /w 1000 127.0.0.1");

タイムアウト時間の設定

プロセスの実行にはタイムアウトを設定することもできます。その場合、設定された時間が経過すると、強制的にプロセスは終了されます。タイムアウトは、プロセスの実行時間を管理するExecuteWatchdogクラスを使って設定します。このクラスのコンストラクタにタイムアウト時間を指定してインスタンスを生成し、それを次のようにsetWatchdog()メソッドでExecutorにセットします。

<import type="text/sourcecode" ref="sources/CommonsExecSample2.txt" caption="リスト3.1" encoding="UTF-8">

pingコマンドの引数に/tを指定した場合、プロセスが停止されるまでパケットを送り続けることになります。実行すると、2秒経過した時点でプロセスが強制的に終了されるため、次のような結果になります。

127.0.0.1 に ping を送信しています 32 バイトのデータ:
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
org.apache.commons.exec.ExecuteException: Process exited with an error: 1 (Exitvalue: 1)
  at org.apache.commons.exec.DefaultExecutor.executeInternal(DefaultExecutor.java:377)
  at org.apache.commons.exec.DefaultExecutor.execute(DefaultExecutor.java:160)
  at org.apache.commons.exec.DefaultExecutor.execute(DefaultExecutor.java:147)
  at jp.gihyo.toolbox.commonsexec.CommonsExecSample2.main(CommonsExecSample2.java:22)

ExecuteExceptionが発生しているのは、プロセスの終了コードとして、正常終了として設定した0ではなく1が返されたためです。ExecuteWatchdogによるタイムアウトで強制終了されたので、正常時とは異なる終了コードになったわけです。

標準出力の内容をファイルに記録する

特に指定しなければ、実行したコマンドからの出力は、親プロセス(つまり元になっているJavaプログラム)の標準出力や標準エラー出力に書き込まれます。この出力先を変更したい場合にはExecuteStreamHandlerインターフェースや、その実行クラスであるPumpStreamHandlerクラスを使います。たとえば、次のようにPumpStreamHandlerのコンストラクタにFileOutputStreamを渡してインスタンスを生成し、それをsetStreamHandler()メソッドでExecutorにセットします。すると出力先が標準出力からFileOutputStreamに変更されます。したがって、この例ではresult.logというファイルに出力内容が書き込まれるということになります。

// 標準出力の内容をファイルに記録する設定
PumpStreamHandler streamHandler = 
    new PumpStreamHandler(new FileOutputStream("result.log"));
executor.setStreamHandler(streamHandler);

出力ストリームだけでなく、コマンドに対する入力ストリームを指定することもできます。次の例は、cmd.exe(コマンドプロンプトを使うためのコマンド)を実行し、そこに標準入力からの入力を反映させるプログラムの例です。

package jp.gihyo.toolbox.commonsexec;

import java.io.*;
import org.apache.commons.exec.*;

public class CommonsExecSample4 {
  public static void main(String[] args) {
    // コマンドを作成
    CommandLine commandLine = new CommandLine("cmd.exe");
    
    // Executorを作成
    DefaultExecutor executor = new DefaultExecutor();
    // タイムアウト時間を設定
    ExecuteWatchdog watchdog = new ExecuteWatchdog(30000);
    executor.setWatchdog(watchdog);

    try {
      // 標準入力からの入力をプロセスで受け取る
      PumpStreamHandler streamHandler = 
              new PumpStreamHandler(System.out, System.err, System.in);
      executor.setStreamHandler(streamHandler);
      
      executor.setExitValue(0);    // 正常終了の場合に返される値
      // 実行
      executor.execute(commandLine);
      System.out.println("Process Exit.");
    } catch (ExecuteException ex) {
      ex.printStackTrace();
    } catch (IOException ex) {
      ex.printStackTrace();
    }
  }
}

PumpStreamHandlerのコンストラクタの第一引数には標準出力、第二引数には標準エラー出力、第三引数には標準入力となるストリームをそれぞれ渡します。ここでは、標準入力としてSystem.inを渡しているので、Javaプログラムの標準入力ストリームが、そのまま起動したプロセスにつながることを意味しています。

タイムアウト時間を30秒にしてあるので、このプログラムを実行して30秒の間は、プロンプトからの入力はそのままcmd.exeのコマンドに流し込まれることになります。そしてその結果はSystem.outすなわちJavaプログラム側の標準出力に出力されます。

プロセスを非同期で実行する

通常であればexecute()コマンドはプロセスが終了するまでプログラムの処理をブロックするので、その間に他の処理を行うことはできません。ブロックを行わないようにするにはExecuteResultHandlerを利用します。このインスタンスをexecute()の第二引数に指定することで、プロセスの実行中にもプログラムはブロックされず、別の処理を継続することができるようになります。以下に例を示します。

package jp.gihyo.toolbox.commonsexec;

import java.io.IOException;
import org.apache.commons.exec.*;

public class CommonsExecSample5 {
  public static void main(String[] args) {
    // コマンドを作成
    CommandLine commandLine = 
            CommandLine.parse("ping /n 5 127.0.0.1");
    
    // Executorを作成
    DefaultExecutor executor = new DefaultExecutor();

    try {
      executor.setExitValue(0);    // 正常終了の場合に返される値
      // 非同期モードで実行
      DefaultExecuteResultHandler resultHandler = 
              new DefaultExecuteResultHandler();
      executor.execute(commandLine, resultHandler);
      
      // 並行して実行する処理
      System.out.println("Hello!!!!!");
      
      // プロセスの終了待ち
      resultHandler.waitFor();
      System.out.println("Prcess Exit.");
    } catch (InterruptedException ex) {
      ex.printStackTrace();
    } catch (ExecuteException ex) {
      ex.printStackTrace();
    } catch (IOException ex) {
      ex.printStackTrace();
    }
  }
}

実行すると次のような出力結果になります。

Hello!!!!!

127.0.0.1 に ping を送信しています 32 バイトのデータ:
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128
127.0.0.1 からの応答: バイト数 =32 時間 <1ms TTL=128

127.0.0.1 の ping 統計:
    パケット数: 送信 = 5、受信 = 5、損失 = 0 (0% の損失)、
ラウンド トリップの概算時間 (ミリ秒):
    最小 = 0ms、最大 = 0ms、平均 = 0ms
Prcess Exit.

DefaultExecuteResultHandlerはExecuteResultHandlerの実装クラスです。本来であれば「System.out.println("Hello!!!!!");」の処理はpingコマンドが終了するまで実行されませんが、今回はExecuteResultHandlerを設定しているので、処理がブロックされず、すぐに「Hello!!!!!」と出力されているのがわかります。

プロセスの終了の終了を待ちたい場所ではExecuteResultHandlerのwaitFor()メソッドを実行します。これによって、プロセスが終了されるまではその場所でプログラムがブロックされることになります。

外部プロセスの実行はJavaプログラムの中でも問題を発生させやすい部分として知られています。コードそのものはシンプルですが、いくつかの構造的な欠陥を含んでいるからです。Commons Execを使うことでその煩わしさを幾分かは解消することができるでしょう。

おすすめ記事

記事・ニュース一覧