C#
VisualBasic

【.NET】テキストファイルの末尾からn行を読み込む

はじめに

これはVisual Basic Advent Calendar 2017の23日の記事となります。

お仕事で外部アプリケーションが出力するログを監視し、ある条件なら警告画面を前面に表示させる要望がありました。
【.NET】外部アプリケーションの画面上すべてのタイトル(キャプション)を取得する

ログなので最終行を読み込む処理を作る必要があるなと先走って作成してみたところ、そのログは先頭に行を追加されるようになっていたため、作り損になってしまいました。
まー先頭行を取得する処理はすごく簡単なんですが、最終行を取得する処理は結構面倒なんです。

しかし、先頭に行を追加するってログって初めて見た。ログサイズが小さいからいいけど、全行をを読み込んで、先頭行に追加した上で全行を書き込む必要があるので処理コストが高いよね。何か理由があってそうしているんだろうけどさ。
Hey, Scripting Guy! テキスト ファイルの先頭に行を追加する方法はありますか

説明

Tail関数には引数として、ファイル名、読込行数(デフォルト=1)、文字コード(デフォルト=UTF-8)を指定します。

何か大きめなサイズのログがないかと検索してみたところ、以前インストールしたOracle Database 11g Express Edition(XE) のlistener.log が21MByteありましたので動かしてみました。
listener.log はUTF-8と思ったらShift-JISで作られてました。念のためサクラエディタでUTF-8に変換したのでも読み込みテストを行いました。

sample.vb
Dim path As String = "C:\oraclexe\app\oracle\diag\tnslsnr\LaVie_PC\listener\trace\listener.log"
Dim line As String = Tail(path, 6, "Shift-JIS")
MessageBox.Show(line)
sample.cs
string path = @"C:\oraclexe\app\oracle\diag\tnslsnr\LaVie_PC\listener\trace\listener.log"
string line = Tail(path, 6, "Shift-JIS");
MessageBox.Show(line);

出力結果

サクラエディタで「listener.log」を読み込み末尾を表示。
TailLog2.png

Tail関数で「listener.log」の末尾6行を読み込んだ。
TailLog.png

ソースコード

VisualBasic版となります。

Utility.vb
Public Shared Function Tail(ByVal path As String, ByVal Optional lines As Integer = 1, Optional ByVal encoding As String = "UTF-8") As String
    Dim BUFFER_SIZE As Integer = 32
    Dim offset As Integer = 0
    Dim loc As Integer = 0
    Dim foundCount As Integer = 0
    Dim buffer = New Byte(BUFFER_SIZE - 1) {}
    Dim isFirst As Boolean = True
    Dim isFound As Boolean = False
    Dim i As Integer = 0

    Using fs = New FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)
        While True
            offset = Math.Min(CInt(fs.Length), (i + 1) * BUFFER_SIZE)
            loc = 0
            If fs.Length <= i * BUFFER_SIZE Then
                If foundCount > 0 OrElse fs.Length > 0 Then Exit While
                Throw New ArgumentOutOfRangeException("NOT FOUND DATA")
            End If

            fs.Seek(-offset, SeekOrigin.[End])
            Dim readLength As Integer = offset - BUFFER_SIZE * i
            For j As Integer = 0 To readLength - 1
                j += fs.Read(buffer, j, readLength - j)
            Next

            For k As Integer = readLength - 1 To 0 Step -1
                If buffer(k) = 10 Then
                    If isFirst AndAlso k = readLength - 1 Then Continue For
                    foundCount += 1
                    If foundCount = lines Then
                        loc = k + 1
                        isFound = True
                        Exit For
                    End If
                End If
            Next

            isFirst = False
            If isFound Then Exit While
            i += 1
        End While

        fs.Seek(-offset + loc, SeekOrigin.[End])
        Using sr = New StreamReader(fs, Text.Encoding.GetEncoding(encoding))
            Return sr.ReadToEnd()
        End Using
    End Using
End Function

C#版となります。

Utility.cs
public static string Tail(string path, int lines = 1, string encoding = "UTF-8")
{
    int BUFFER_SIZE = 32;       // バッファーサイズ(あえて小さく設定)
    int offset = 0;
    int loc = 0;
    int foundCount = 0;
    var buffer = new byte[BUFFER_SIZE];
    bool isFirst = true;
    bool isFound = false;

    // ファイル共有モードで開く
    using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read, FileShare.ReadWrite))
    {
        // 検索ブロック位置の繰り返し
        for (int i = 0; ; i++)
        {
            // ブロック開始位置に移動
            offset = Math.Min((int)fs.Length, (i + 1) * BUFFER_SIZE);
            loc = 0;
            if (fs.Length <= i * BUFFER_SIZE)
            {
                // ファイルの先頭まで達した場合
                if (foundCount > 0 || fs.Length > 0) break;

                // 行が未存在
                throw new ArgumentOutOfRangeException("NOT FOUND DATA");
            }

            fs.Seek(-offset, SeekOrigin.End);

            // ブロックの読み込み
            int readLength = offset - BUFFER_SIZE * i;
            for (int j = 0; j < readLength; j += fs.Read(buffer, j, readLength - j)) ;

            // ブロック内の改行コードの検索
            for (int k = readLength - 1; k >= 0; k--)
            {
                if (buffer[k] == 0x0A)
                {
                    if (isFirst && k == readLength - 1) continue;
                    if (++foundCount == lines)
                    {
                        // 所定の行数が見つかった場合
                        loc = k + 1;
                        isFound = true;
                        break;
                    }
                }
            }
            isFirst = false;
            if (isFound) break;
        }

        // 見つかった場合
        fs.Seek(-offset + loc, SeekOrigin.End);

        using (var sr = new StreamReader(fs, Encoding.GetEncoding(encoding)))
        {
            return sr.ReadToEnd();
        }
    }
}

ライセンスっぽいこと

コード改変や配布は自由です。
このツールによる義務/責任を何ら負いません。

最後に

もともとstackoverflowの記事を参考にC#で作成したのを、この記事用にVisualBasicで書き直しました。
汎用っぽく末尾からn行にしたけど、最終行だけだったらシンプルに作れるような気もします。

あと、外部アプリケーション側で「別のプロセスで使用されているため、プロセスはファイル 'xxxxxx.xxx' にアクセスできません。」のエラーダイアログ画面がまれに表示されたため、別名ファイルにコピーした上で読む込むようにしました。

参照