テキストファイルの末尾からｎ行を読み込むには（C#）


テキストファイルの末尾から指定した行数のテキストを効率的に取り出したいです。  
Linux の tail
コマンドのようなものです。([こちらの質問](https://ja.stackoverflow.com/questions/4654/%e3%83%86%e3%82%ad%e3%82%b9%e3%83%88%e3%83%95%e3%82%a1%e3%82%a4%e3%83%ab%e3%81%ae%e6%9c%ab%e5%b0%be%e3%81%8b%e3%82%89%ef%bd%8e%e8%a1%8c%e3%82%92%e8%aa%ad%e3%81%bf%e8%be%bc%e3%82%80%e3%81%ab%e3%81%af)
のC#版です)

簡単な実装方法として、

[code]

    var path = "test.txt";
    var n = 10;
    
    var lines = File.ReadAllLines(path);
    
    foreach (var line in lines.Skip(lines.Length - n).Take(n))
    {
        System.Console.WriteLine(line);
    }
    
[/code]

このようにファイルを先頭から全て読み込んで末尾のｎ行を取り出すということは出来るのですが、この方法だとファイルサイズが大きい時に処理時間が長くなってしまいます。ディスクI/Oやメモリ消費という面からも好ましくないと思います。

ファイルをランダムアクセスして末尾から１行ずつ取り出すにはどのような方法があるでしょうか？

対象のテキストファイルは以下の想定です。

  * 文字コードは UTF-8
  * 改行はCRLF、LFが混在
  * １行の文字数に上限なし




以下の手順でどうでしょうか。

  1. `FileStream`を作成する
  2. `FileStream.Seek(-バッファーサイズ、SeekOrigin.End)`で末尾から一定の位置に移動する
  3. `FileStream.Read`で`byte[]`に値を読み込む(1回で完了しない可能性があるため繰り返し処理が必要です)
  4. バッファーの末尾からLF=`0x0A`を探す(UTF-8であればマルチバイト文字は必ず`0x80`以上です)
  5. 見つかったら`Seek`でLFの次のバイトに移動し、`StreamReader`で文字列にする。
  6. 見つからなかったら位置を変えて2からやり直す

コードを追加しました。

[code]

    static void Main(string[] args)
    {
        int BUFFER_SIZE = 32; // バッファーサイズ(あえて小さく設定)
        int lineCountToWrite = 10; // 探索行数
        var buffer = new byte[BUFFER_SIZE];
        var foundCount = 0;
        using (var fs = new FileStream(@"..\..\Program.cs", FileMode.Open))
        {
            // 検索ブロック位置の繰り返し
            for (var i = 0; ; i++)
            {
                if (fs.Length <= i * BUFFER_SIZE)
                {
                    // ファイルの先頭まで達した場合
                    Console.WriteLine("NOT FOUND");
                    Console.ReadKey();
                    return;
                }
    
                // ブロック開始位置に移動
                var offset = Math.Min((int)fs.Length, (i + 1) * BUFFER_SIZE);
                fs.Seek(-offset, SeekOrigin.End);
    
                // ブロックの読み込み
                var readLength = offset - BUFFER_SIZE * i;
                for (var j = 0; j < readLength; j += fs.Read(buffer, j, readLength - j)) ;
    
                // ブロック内の改行コードの検索
                for (var k = readLength - 1; k >= 0; k--)
                {
                    if (buffer[k] == 0x0A)
                    {
                        foundCount++;
                        if (foundCount == lineCountToWrite)
                        {
                            // 所定の行数が見つかった場合
    
                            fs.Seek(-offset + k + 1, SeekOrigin.End);
    
                            using (var sr = new StreamReader(fs, Encoding.UTF8))
                            {
                                Console.WriteLine(sr.ReadToEnd());
                            }
                            Console.ReadKey();
                            return;
                        }
                    }
                }
            }
        }
    }
    
[/code]




以下の手順でどうでしょうか。

  1. `FileStream`を作成する
  2. `FileStream.Seek(-バッファーサイズ、SeekOrigin.End)`で末尾から一定の位置に移動する
  3. `FileStream.Read`で`byte[]`に値を読み込む(1回で完了しない可能性があるため繰り返し処理が必要です)
  4. バッファーの末尾からLF=`0x0A`を探す(UTF-8であればマルチバイト文字は必ず`0x80`以上です)
  5. 見つかったら`Seek`でLFの次のバイトに移動し、`StreamReader`で文字列にする。
  6. 見つからなかったら位置を変えて2からやり直す

コードを追加しました。

[code]

    static void Main(string[] args)
    {
        int BUFFER_SIZE = 32; // バッファーサイズ(あえて小さく設定)
        int lineCountToWrite = 10; // 探索行数
        var buffer = new byte[BUFFER_SIZE];
        var foundCount = 0;
        using (var fs = new FileStream(@"..\..\Program.cs", FileMode.Open))
        {
            // 検索ブロック位置の繰り返し
            for (var i = 0; ; i++)
            {
                if (fs.Length <= i * BUFFER_SIZE)
                {
                    // ファイルの先頭まで達した場合
                    Console.WriteLine("NOT FOUND");
                    Console.ReadKey();
                    return;
                }
    
                // ブロック開始位置に移動
                var offset = Math.Min((int)fs.Length, (i + 1) * BUFFER_SIZE);
                fs.Seek(-offset, SeekOrigin.End);
    
                // ブロックの読み込み
                var readLength = offset - BUFFER_SIZE * i;
                for (var j = 0; j < readLength; j += fs.Read(buffer, j, readLength - j)) ;
    
                // ブロック内の改行コードの検索
                for (var k = readLength - 1; k >= 0; k--)
                {
                    if (buffer[k] == 0x0A)
                    {
                        foundCount++;
                        if (foundCount == lineCountToWrite)
                        {
                            // 所定の行数が見つかった場合
    
                            fs.Seek(-offset + k + 1, SeekOrigin.End);
    
                            using (var sr = new StreamReader(fs, Encoding.UTF8))
                            {
                                Console.WriteLine(sr.ReadToEnd());
                            }
                            Console.ReadKey();
                            return;
                        }
                    }
                }
            }
        }
    }
    
[/code]

