Javaでtail -fならcommons-ioが楽

Java で tail -f のようなファイルの監視・追跡をしたい時に0から実装するのもなかなか面倒くさいです.Apache commons-io というライブラリにそれを簡単に実現できる機能があったのでメモしておきます.

Commons IO

IO 系をよしなに扱うためのオープンソースライブラリです.今回はその中のファイル監視機能を使って tail -f を簡単に実現します.
ライブラリは以下からダウンロード可能です.

https://commons.apache.org/proper/commons-io/download_io.cgi

勿論 Maven Repo にもあります.

https://mvnrepository.com/artifact/commons-io/commons-io

Tailerを使う

tail -f を実現するために,commons-io の中の Tailer というクラスを使います.
以下が基本的な Tailer の使い方です.

public class Test {
    public static void main(String args[]){
        File targetFile = new File("log.txt");  // 監視対象のファイル
        TailerListener listener = new MyListener();  // 対象ファイルに追記があったときの動作を指定
        int checkDelay = 100;  // 追記の確認間隔
        boolean tailFromEOF = true;  // trueにするとファイルの最終行から読み取る. falseだと最初の行から.

        Tailer tailer = Tailer.create(targetFile, listener, checkDelay, tailFromEOF);

        // その他の処理
    }
}

public class MyListener extends TailerListenerAdapter {
    @Override
    public void handle(String line) {
        System.out.println(line);
    }
}

ファイルに追記があった場合の動作を規定したリスナークラスを自分で作成して,それを元に Tailer インスタンスを作ってあげる形です.追記が発生すると,追記された文字列を引数にリスナークラスの handle() が呼び出されるので,ここでは追記された行を単に標準出力することになります.

また,上では create() というファクトリメソッドで tailer インスタンスを作成しています.で,最終的にたどり着く create() の中を見ると以下のようになっています.

public static Tailer create(File file, TailerListener listener, long delayMillis, boolean end, boolean reOpen, int bufSize) {
    Tailer tailer = new Tailer(file, listener, delayMillis, end, reOpen, bufSize);
    Thread thread = new Thread(tailer);
    thread.setDaemon(true);
    thread.start();
    return tailer;
}

注意したいのは,create() インスタンスの中でスレッドをスタートさせているところです.したがって,create() を実行するとバックグラウンドで tail -f が走る形になります.なので,メインロジックが終了すると tail 処理も止まります.つまり,先のプログラムで「その他の処理」と書いた部分に何も書かなければ,tail 機能は動き始めた瞬間に止まることになります.

もしバックグラウンドではなくメイン処理としてファイルの監視ループをしたいのであれば,以下のように Tailer を使います.

public class Test {
    public static void main(String args[]){
        ...省略...
        Tailer tailer = new Tailer(targetFile, listener, checkDelay, tailFromEOF);
        tailer.run();  // ここでループが走りmain処理が止まる
    }
}

こちらでは,Tailer のコンストラクタを使ってインスタンスを生成し,その後でインスタンスの run() を呼び出しています.

run() ではスレッドを起動せず,run という private メンバを終了条件として while によるフラグループをします.run は private なので,外から状態を変えるには stop() を使います.stop() は run を false にするだけのメソッドです.なので,ループを止めたい状況では他のスレッドなどから tailer.stop() を呼んであげれば大丈夫です.

Tailerの動作の大枠

以下 run() の中身です.簡単のため,メイン処理部分だけ抽出して後は省いています.

public void run() {
    // ファイルオープンと初期化
    RandomAccessFile reader = null;
    try {
        long position = 0;

        while (run && reader == null) {
            reader = new RandomAccessFile(file, RAF_MODE);
            
            // ポイント位置の初期化
            position = end ? file.length() : 0;
            reader.seek(position);
        }
    }

    // 監視ループ
    while (run) {
        long length = file.length();

        if (length > position) {
            // 追記あり position の更新とリスナの呼び出し
            position = readLines(reader);
        }
        
        try {
            Thread.sleep(delayMillis);  // 意図的なディレイによる待機
        }
    }
}

やっていることは,ループして現在のファイルのポイント位置とファイルの長さを比較して,追記行の存在判定をしているだけです.ここでは,RandomAccessFile というクラスを使ってファイルのポインタ位置の細かい制御とシーク処理を実現しています.自分で tail を実装しようとする場合にも,お世話になりそうなクラスです.以下は概要記事.

http://www.javaroad.jp/java_io9.htm

で,追記行があれば readLines() を呼び出して追記行を読み込み,リスナに処理させています.以下 readLines() の中身です.

private long readLines(RandomAccessFile reader) throws IOException {
    StringBuilder sb = new StringBuilder();

    long pos = reader.getFilePointer();
    long rePos = pos; // position to re-read
    int num;

    // 追記行を読んでリスナーのhandleを呼び出す
    while (run && ((num = reader.read(inbuf)) != -1)) { 
        for (int i = 0; i < num; i++) {
            byte ch = inbuf[i];
            switch (ch) {
            case '\n':
                listener.handle(sb.toString());
                sb.setLength(0);
                rePos = pos + i + 1;
                break;
            default:
                sb.append((char) ch);
            }
        }

        pos = reader.getFilePointer();
    }

    reader.seek(rePos);
    return rePos;
}

やっていることは単純に行を読み込んでリスナーの handle() に渡しているだけです.が,改行文字の扱いなどがあるため,少しだけ複雑になります.で,最後 rePos を return する前に RandomAccessFile の reader をシークして,ファイルのポイント位置を更新しています.あとは run() に戻り,これの繰り返しです.