1. nogitsune413

    No comment

    nogitsune413
Changes in body
Source | HTML | Preview

Javaから外部プログラムを呼び出す事ができる。
ここで簡単なサンプルを用いて、外部プログラムの呼び出しを試してみる事にする。

・やること

圧縮ファイルを解凍する。

・方法

Javaから圧縮ソフトを呼び出して、解凍処理を行わせる。

・使用するソフト

圧縮・解凍ソフト 7-zip

課題

 1. どうやって外部プログラムを呼ぶか?

これには、ProcessBuilderクラスを使う。
ProcessBuilderクラスのインスタンス生成時に引数としてString配列を渡すと、配列が外部プログラムと引数を表しているものとして解釈される。

1
// googleにpingを打ってみる。
ProcessBuilder pb = new ProcessBuilder("ping","google.com","-n","5");

このビルダーからプロセスを実行すれば良い。

2
pb.start();

自分はこの方法を以下のサイトで知ったのだが、この方法には問題があるらしい。
Java外部プロセス起動メモ(Hishidama's Java Process Memo)

しかし、別プロセスからの出力量が多いときは、このプログラムは止まってしまう。
(試してみた感じでは、出力側(起動したプロセス)が標準出力or標準エラーのどちらかに512文字(たぶんUNICODE(UTF16)なので、1024バイト)を超えて出力するとNG)

外部プロセスは標準出力(や標準エラー)にがんがん書き込みたいのだが、その受け側であるProcessのInputStreamはバッファーサイズに限りがある。
したがって、データ量がそのバッファー内に収まっている間はいいのだが、ストリームから読み出してやらないとバッファーが足りなくなって、それ以上読み込めなくなる。
InputStreamが読み込めないと、書き込み側(外部プロセス)がブロッキング(一時停止)される。したがって、そのプロセスは終了できないことになる。


 2. 外部コマンドの標準出力が多すぎると、プログラムがハングする

7-zipの圧縮コマンドは、試してみると分かるが、解凍したファイルを全て標準出力に列挙し、最後に解凍結果のサマリを表示する。

標準出力
7-Zip [64] 9.38 beta  Copyright (c) 1999-2014 Igor Pavlov  2015-01-03

Processing archive: C:\temp\zip\sample.zip

Extracting  sample
Extracting  sample\h1
Extracting  sample\h1\sample1.txt
Extracting  sample\sample2.txt
Extracting  sample\sample3.txt

Everything is Ok

Folders: 2
Files: 3
Size:       21
Compressed: 751

Kernel  Time =     0.000 =    0%
User    Time =     0.015 =   27%
Process Time =     0.015 =   27%    Virtual  Memory =      1 MB
Global  Time =     0.057 =  100%    Physical Memory =      4 MB

このぐらいのログ出力ならいいものの、圧縮するぐらいだから、ファイル数は多いのが普通である。そうすると、上記に引用した理由により、外部コマンドが実行中に停止してしまう。Java8のAPIリファレンスにも、以下の記載がある。
Java(tm) Platform, Standard Edition 8 API仕様
クラスProcess

デフォルトでは、作成されたサブプロセスは、自身の端末またはコンソールを持ちません。その標準入出力(つまり標準入力、標準出力、標準エラー)の処理はすべて親プロセスにリダイレクトされますが、それらの情報にアクセスするには、メソッドgetOutputStream()、getInputStream()、およびgetErrorStream()を使って取得されるストリームを使用します。親プロセスはこれらのストリームを使って、サブプロセスに入力を送ったり、サブプロセスからの出力を取得したりします。ネイティブなプラットフォームには標準入出力ストリームに使うバッファのサイズが限られるものもあるので、サブプロセスの入力ストリームの書き込みあるいはストリーム出力の読込みが失敗した場合、サブプロセスはブロックされるか、デッドロック状態になる可能性があります。

この問題を回避するためには、どうしたらいいだろうか?
実は、上記のAPIレファレンスの記述には続きがあり、次のように書かれている。

必要に応じて、ProcessBuilderクラスのメソッドを使ってサブプロセスの入出力をリダイレクトすることもできます。

この仕組みを使えば、問題が解決できそうである。標準出力をバッファに書き込むのではなく、ファイルにリダイレクトする事にしよう。ファイルに書き込んでしまえば、バッファサイズが一杯になってしまう問題は発生しないからである。

3
ProcessBuilder pb = new ProcessBuilder(command); 
pb.redirectErrorStream(true);      // 標準出力と標準エラー出力の出力先を同じにする。
pb.redirectOutput(log.toFile());   // 標準出力と標準エラー出力をリダイレクトするファイルを設定。

2行目で行っているのは、標準出力と標準エラー出力を同じファイルに書き込む設定である。

※ 外部プログラムの入出力をJavaに統合する事もできるようだ。やり方は以下の補足を参照。(2015/06/13 追記)
【補足】外部プログラムの入出力をJavaプロセスに統合する


 3. 外部コマンドはいつ終わるのか?

外部コマンドが別プロセスとして起動するという事は、Javaのプロセスと外部コマンドのプロセスが平行して実行されるという事である。それで良い場合もあるだろうが、あたかも一つのプロセスであるかのように、逐次実行できると都合が良い。これは割と簡単に実現できる。
Processクラスのメソッド「waitFor()」を使えばいいのだ。

4
Process proc = pb.start();
proc.waitFor();                // コマンドの終了を待機する。

 4. 外部コマンドの実行結果をどうやって取得するか?

外部コマンドの実行が成功したのか、失敗したのか、呼び出し元のプログラムとしては、興味がある所だ。これも至極簡単に取得できる。

5
Process proc = pb.start();     // コマンドを実行       
int exitCode = proc.waitFor(); // コマンドの終了コードを取得する。

コマンドの実行に成功したら、終了コード「0」が返ってくる。


課題が解決したので、実際にプログラムを書いてみる。
下記のプログラムは完結しているので、Javaのバージョン8で実際に実行できる。

サンプルプログラム

・フォルダ構成

C:
├Program Files
│└─7-Zip
│    7z.exe(7zipのコマンドライン用実行プログラム)
└temp
 ├─log(7zipの実行ログ出力先フォルダ)
 ├─unZip(ZIPファイルの解凍先フォルダ)
 └─zip
     sample.zip(解凍する圧縮ファイル)

・ソースコード

Sample.Java
package p1;

import static java.lang.System.*;

import java.io.IOException;
import java.nio.file.Path;
import java.nio.file.Paths;
import java.util.ArrayList;
import java.util.List;

public class Sample
{
    /** エントリポイント(テスト用)
     *
     * @param args 未使用
     */
    public static void main(String[] args)
    {
        final Path targetZipFile = Paths.get("C:\\temp\\zip\\sample.zip");        // 解凍する圧縮ファイル
        final Path unZipFolder   = Paths.get("C:\\temp\\unZip");                  // 解凍先フォルダ
        final Path sevenZipTool  = Paths.get("C:\\Program Files\\7-Zip\\7z.exe"); // 7-zipのコマンドラインツール
        final Path sevenZipLog   = Paths.get("C:\\temp\\log\\7zipStdout.log");    // 7-zipの実行時ログ

        // 圧縮コマンドを取得する。
        List<String> unZipCmd = getUnZipCommand(targetZipFile,unZipFolder,sevenZipTool);

        // 外部コマンドを実行する。
        runExternalCommand(unZipCmd,sevenZipLog);
    }

    /** 外部コマンドを呼び出す。
     *
     * @param command 呼び出すコマンド及びコマンド引数が格納されたリスト
     * @param log     コマンド実行時の標準出力及び標準エラー出力を書き出すログファイル
     */
    private static void runExternalCommand(List<String> command,Path log)
    {
        ProcessBuilder pb = new ProcessBuilder(command); // コマンド実行用のプロセスを準備する。
        pb.redirectErrorStream(true);                    // 標準出力と標準エラー出力の出力先を同じにする。
        pb.redirectOutput(log.toFile());                 // 標準出力と標準エラー出力の出力先ファイルを設定。

        try
        {
            Process proc = pb.start();     // コマンドを実行
            int exitCode = proc.waitFor(); // コマンドの終了を待機する。

            out.println(getResultMessage(command,exitCode)); // コマンドの実行結果を表示
        }
        catch(IOException | InterruptedException e)
        {
            e.printStackTrace();
        }
    }

    /** 解凍するための外部コマンドを取得する。
     *  解凍に使用するのは、7-zipのコマンドラインツール「7z.exe」である。
     *
     * @param targetZipFile 解凍する圧縮ファイル
     * @param dstFolder     解凍先フォルダ
     * @param sevenZipExe   コマンドラインツール「7z.exe」
     * @return              解凍コマンドリスト
     */
    private static List<String> getUnZipCommand(Path targetZipFile,Path dstFolder,Path sevenZipExe)
    {
        final String EXTRACT = "x";  // パラメータ:圧縮ファイル内のフォルダ構造を保持したまま展開する。
        final String OUTPUT  = "-o"; // パラメータ:出力先フォルダを指定する。

        List<String> cmd = new ArrayList<>();

        cmd.add(sevenZipExe.toString());             // 7-zipのコマンドラインツール
        cmd.add(EXTRACT);                            // 展開する。
        cmd.add(new StringBuilder().append(OUTPUT)    // 解凍先フォルダの指定
                                   .append(dstFolder).toString());
        cmd.add(targetZipFile.toString());           // 解凍する圧縮ファイル

        return cmd;
    }

    /** コマンドの実行結果を表す文字列を取得する。
     *
     * @param command 実行したコマンドを表す配列
     * @param exitCode 実行コマンドの終了コード
     * @return コマンドの実行結果を表す文字列
     */
    private static String getResultMessage(List<String> command, int exitCode)
    {
        final String SPACE = " "; // コマンドリストを区切る空白文字

        return new StringBuilder().append("外部コマンド「")
                                  .append(String.join(SPACE,command))
                                  .append("」を実行しました。")
                                  .append("終了コード:")
                                  .append(exitCode).toString();
    }
}

・実行結果

このプログラムを実行すると、以下の結果が得られる。

Javaプログラムの標準出力
外部コマンド「C:\Program Files\7-Zip\7z.exe x -oC:\temp\unZip C:\temp\zip\sample.zip」を実行しました。終了コード:0
実行後のフォルダ
C:
├Program Files
│└─7-Zip
│    7z.exe(7zipのコマンドライン用実行プログラム)
└temp
 ├─log
 │   7zipStdout.log(7zipの実行ログ)
 │
 ├─unZip
 │ └─sample(解凍されたZIPファイル)
 │   │ sample2.txt
 │   │ sample3.txt
 │   │
 │   └─h1
 │       sample1.txt
 │
 └─zip
     sample.zip(解凍する圧縮ファイル)
ログファイル「7zipStdout.log」に出力された「7z.exe」の実行結果
7-Zip [64] 9.38 beta  Copyright (c) 1999-2014 Igor Pavlov  2015-01-03

Processing archive: C:\temp\zip\sample.zip

Extracting  sample
Extracting  sample\h1
Extracting  sample\h1\sample1.txt
Extracting  sample\sample2.txt
Extracting  sample\sample3.txt

Everything is Ok

Folders: 2
Files: 3
Size:       21
Compressed: 751

Kernel  Time =     0.015 =   26%
User    Time =     0.000 =    0%
Process Time =     0.015 =   26%    Virtual  Memory =      1 MB
Global  Time =     0.058 =  100%    Physical Memory =      4 MB

【補足】外部プログラムの入出力をJavaプロセスに統合する

・概要

ProcessBuilderクラスのメソッドinheritIOを使うと、Javaから呼び出した外部プログラムの入出力をJavaの標準入出力に統合できる。

・発端

下記の本を読んで知ったのだが、Java1.7で、ProcessBuilderクラスにinheritIOというメソッドが追加されている。

Javaプログラマーなら習得しておきたい Java SE 8 実践プログラミング [Kindle版]

このinheritIOメソッドの仕様は、Java(tm) Platform, Standard Edition 8 API仕様 によると、以下である。

public ProcessBuilder inheritIO()

サブプロセスの標準入出力の入力元と出力先を、現在のJavaプロセスと同じものに設定します。
これは、簡易メソッドです。次の形式の呼出しは、
pb.inheritIO()
次の呼び出しと正確に同じ動作になります。
pb.redirectInput(Redirect.INHERIT)
.redirectOutput(Redirect.INHERIT)
.redirectError(Redirect.INHERIT)
この動作は、ほとんどのオペレーティング・システム・コマンド・インタプリタや標準Cライブラリ関数system()と同等のものになります。

つまり、Javaの標準入出力に、サブプロセスである外部プログラムの入出力を統合できる。

・実験

今回の作ったサンプルプログラムを修正し、inheritIOメソッドを試してみよう。
修正したのは、ProcessBuilderを生成して、出力先を設定する箇所だ。

    /** 外部コマンドを呼び出す。
     *
     * @param command 呼び出すコマンド及びコマンド引数が格納されたリスト
     */
    private static void runExternalCommand(List<String> command) // ログファイルを表す引数[log]は不要なので削除
    {
        ProcessBuilder pb = new ProcessBuilder(command);
        pb.inheritIO(); // ← ここで、inheritIO()を呼び出し、外部プログラムの入出力をJavaプロセスに統合する。

        try
        {
            Process proc = pb.start();
            int exitCode = proc.waitFor();

            out.println(getResultMessage(command,exitCode));
        }
        catch(IOException | InterruptedException e)
        {
            e.printStackTrace();
        }
    }

結果は次の通り。Javaのコンソールに7zipの実行結果が表示される。

Javaのコンソール
7-Zip [64] 9.38 beta  Copyright (c) 1999-2014 Igor Pavlov  2015-01-03

Processing archive: C:\temp\zip\sample.zip

Extracting  sample
Extracting  sample\h1
Extracting  sample\h1\sample1.txt
Extracting  sample\sample2.txt
Extracting  sample\sample3.txt

Everything is Ok

Folders: 2
Files: 3
Size:       21
Compressed: 751

Kernel  Time =     0.031 =   12%
User    Time =     0.000 =    0%
Process Time =     0.031 =   12%    Virtual  Memory =      1 MB
Global  Time =     0.253 =  100%    Physical Memory =      4 MB
外部コマンド「C:\Program Files\7-Zip\7z.exe x -oC:\temp\unZip C:\temp\zip\sample.zip」を実行しました。終了コード:0

・考察

単にログが出てくれれば良い場合、inheritIOメソッドは優れた方法だと思う。自分はほとんどこの方法で用が足りる。
特にいいのは、Javaプロセスが出しているログの中に外部プログラムのログが入る事で、プログラムの実行中、どこで実行した外部プログラムなのか、容易に特定できる事。それに、わざわざ外部プログラム用にログファイルを用意する必要がなくなる所も良い。
ただ、外部プログラムの出力を単なるログとしてではなく、意味のある結果としてJavaプログラムから参照したい場合、この方法では駄目だと思う。その時は、最初に挙げたファイル出力のやり方で実装し、外部プログラムの標準出力が出力されたファイルをJavaプログラムから読み込み、内容を解析すればいいだろう。

・終わりに

inheritIO()を知りこの記事を書き直そうと思ったが、ファイル出力の方が合う場面もあると思い直し、補足の形で追記した。inheritIO()を知りinheritIO()を使ったやり方でこの記事を書き直そうと思ったが、ファイル出力の方が合う場面もあると思い直し、補足の形で追記した。

(2015/06/13 追記)