Java で ssh や scp を呼び出す(3)

id:n_shuyo:20060706:1152162198 と id:n_shuyo:20060707:1152290107 の続き。

pure Java での SSH 実装である Ganymed SSH-2 を使って、scp や単発のコマンド実行を行ってみたが、今回は SSH を通じて複数のコマンドを実行してみる。


基本は openSession() でセッションを都度開きながら、execCommand() を呼び出すという流れになるのだが、いくつか落とし穴がある。

まず session.getStdout() を read して標準出力を取得してやらないと、そもそもコマンドが実行されない。
これは touch のような何も出力しないコマンドであってもそうなってしまう。

それではということで、ループで session.getStdout() を read して標準出力をせっせと読み込んであげるわけだが、接続先のサーバ上でコマンドの実行が終了しない限り EOF に達せず、ブロックされてしまうのだ。

コマンドの実行が終了してから次の処理に移るという流れであれば不都合なさそうに聞こえるが、間違って入力待ちプロンプトの状態になってしまったら、ハングしてしまう。


これらを解決するためには、スレッドを起こして標準出力(標準エラーも同様)を処理してやる必要がある。
Ganymed SSH-2 にはそのための StreamGobbler というクラスが用意されてはいるのだが、主として標準出力を読み捨てるためのもので、取得したい場合にはあまり向いていないようだった(というか、うまく使えなかった……)。


ということで同種のスレッドを自前で実装しつつ、ssh でのコマンド実行のラッパーも書いてみたサンプルが以下。
前回まではパスワード認証だったが、今回はキーペアを使ったパスフレーズ認証にしてみた。

import java.io.*;
import ch.ethz.ssh2.*;

public class SshTest2 {

    private static final long TIMEOUT = 1000;

    public static void main(String[] arg) {
        try {
            SshTest2 test = new SshTest2();
            test.doProc();
        } catch (InterruptedException ex) { ex.printStackTrace();
        } catch (IOException ex) { ex.printStackTrace();
        }
    }

    public void doProc() throws IOException, InterruptedException {

        // login
        Connection conn = new Connection("ホスト", ポート);
        ConnectionInfo info = conn.connect();   //IO
        File pemfile = new File("秘密キーファイル名");
        boolean result = conn.authenticateWithPublicKey("ユーザID", pemfile, "パスフレーズ");  //IO
        if (result) {
            SshCommandExecute ssh1 = new SshCommandExecute(conn.openSession());
            ssh1.exec("ls -l");

            SshCommandExecute ssh2 = new SshCommandExecute(conn.openSession());
            ssh2.exec("cat /etc/hosts", TIMEOUT);
            System.out.println(ssh2.getStdout());
            ssh2.close();

            ssh1.join(TIMEOUT);
            System.out.println(ssh1.getStdout());
            ssh1.close();

            conn.close();
        }
    }


    class SshCommandExecute {
        private Session session;
        private InputStreamBuffering stdout;
        private InputStreamBuffering stderr;
        private OutputStreamWriter stdin;

        public SshCommandExecute(Session _session) {
            session = _session;
        }

        public void exec(String command) throws IOException {
            session.execCommand(command);

            stdout = new InputStreamBuffering(session.getStdout());
            stderr = new InputStreamBuffering(session.getStderr());
            stdin = new OutputStreamWriter(session.getStdin());
        }

        public void exec(String command, long timeout) throws IOException, InterruptedException {
            exec(command);
            stdout.join(timeout);
        }

        public void join(long timeout) throws InterruptedException {
            stdout.join(timeout);
        }

        public OutputStreamWriter getStdin() {
            return stdin;
        }

        public InputStreamBuffering getStdout() {
            return stdout;
        }

        public InputStreamBuffering getStderr() {
            return stderr;
        }

        public int getExitStatus() {
            return session.getExitStatus().intValue();
        }

        public void close() {
            session.close();
        }
    }

    class InputStreamBuffering extends Thread {
        private InputStream input;
        private ByteArrayOutputStream output = new ByteArrayOutputStream();
        private Exception exception = null;
        private static final int BUFFER_SIZE = 4096;

        public InputStreamBuffering(InputStream _input) {
            input = _input;
            start();
        }
        
        public void run() {
            try {
                byte[] buffer = new byte[BUFFER_SIZE];
                int length;
                while ((length = input.read(buffer)) > 0) synchronized (output) {
                    output.write(buffer, 0, length);
                }
            } catch (Exception ex) {
                exception = ex;
            }
        }
        
        public Exception getException() {
            return exception;
        }
        
        public byte[] getByteArray() {
            return output.toByteArray();
        }

        public String toString() {
            synchronized(output) { try{return output.toString();} finally {output.reset();} }
        }

        public String toString(String encoding) throws UnsupportedEncodingException {
            synchronized(output) { try{return output.toString(encoding);} finally {output.reset();} }
        }
    }

}

SshCommandExecute がラッパークラスで、それを使っているのが以下の部分。

    SshCommandExecute ssh2 = new SshCommandExecute(conn.openSession());
    ssh2.exec("cat /etc/hosts", TIMEOUT);
    System.out.println(ssh2.getStdout());
    ssh2.close();

SshCommandExecute#exec() でコマンドを実行するが、タイムアウト(ms) を指定すると、基本は同期(コマンド実行終了待ち)ながら指定時間でタイムアウトするのでハングを防止できる。
タイムアウトに0を指定すれば、永遠に待ち続ける。

一方、タイムアウトを指定しなければ、実行だけさせて即帰ってくる(非同期)。
後から join() で終了待ちすることができる。

上記サンプルではわざと先に実行したコマンド(非同期)を追い抜いて、後から実行したコマンド(同期)の結果を得るように書いてみている。
まあ、1つめのコマンドが実行時間のかかるものではないので、あまり意味がないのだが……。


以下は余談。

複数のコマンドを実行する方法として、Ganymed の FAQ http://www.ganymed.ethz.ch/ssh2/FAQ.html#sessioncommands には「Session.startShell() で shell を起こして実行する方法もあるよん」とか書いてあって、まあチャレンジしてはみた(コマンドごとにセッション張りまくるよりパフォーマンス的に有利にも見えたので)。

結論から言うと、色々と難しいことが多すぎて、とてもお勧めできない。
まずコマンドの実行終了を知る確実な方法がない。
標準出力を解析してシェルのプロンプトが表示されたら実行完了、ってプロンプトなんか設定でいくらでも変わるし……
コマンドの終了コードを得るには、"echo $?" して、やっぱり標準出力を解析?

ということで素直にコマンドごとにセッションを張りましょう……