例外処理

【C#】EndOfStreamExceptionが発生する原因と例外を防ぐ具体的なコード例

EndOfStreamExceptionSystem.IOの例外で、ストリーム末尾を超えて読み込もうとした瞬間に発生します。

原因はデシリアライズ前にPositionを戻し忘れる、ループ中にEndOfStreamLengthを確認しないなどです。

対策は末尾チェック、Seekで先頭に戻す、catchでログして安全に終了することです。

目次から探す
  1. EndOfStreamExceptionを引き起こす基礎構造
  2. 主な発生パターン
  3. 原因特定のポイント
  4. 具体的な対策
  5. コード例:良くない実装
  6. コード例:改善実装
  7. ユニットテストによる再発防止
  8. ログ設計と運用
  9. パフォーマンスとメモリ考慮
  10. よくあるQ&A
  11. まとめ

EndOfStreamExceptionを引き起こす基礎構造

ストリームAPIの仕組み

C#のストリームAPIは、ファイルやメモリ、ネットワークなどのデータソースから連続したバイト列を読み書きするための仕組みです。

ストリームは基本的に「読み取り位置(Position)」を持ち、そこからデータを読み込んだり書き込んだりします。

ストリームの終端に達すると、それ以上の読み取りはできません。

この仕組みを理解することが、EndOfStreamExceptionの発生原因を把握する第一歩です。

基本プロパティ(PositionとLength)

ストリームの重要なプロパティとして、PositionLengthがあります。

  • Positionは現在の読み書き位置を示すlong型の値です。ストリームの先頭は0で、読み込みや書き込みを行うたびにこの値は進みます
  • Lengthはストリーム全体の長さ(バイト数)を示します。読み取り可能なデータの総量を表すため、PositionLengthに達するとストリームの終端に到達したことになります

例えば、ファイルストリームの場合、Lengthはファイルのサイズに相当します。

PositionLengthを超えることは通常ありませんが、プログラムの誤りでPositionを不適切に操作すると、読み取り時に例外が発生する原因になります。

Read系メソッドの挙動

ストリームからデータを読み取るメソッドには、ReadReadByteReadLine(StreamReaderの場合)などがあります。

これらのメソッドは、現在のPositionから指定されたバイト数や文字数を読み込み、Positionを進めます。

  • Readメソッドは、指定したバッファに読み込めるだけのデータを格納し、実際に読み込んだバイト数を返します。読み込めるデータがない場合は0を返します
  • ReadByteは1バイトだけ読み込み、読み込めなければ-1を返します
  • StreamReaderReadLineは、改行までの文字列を読み込みます。終端に達するとnullを返します

これらのメソッドは、ストリームの終端に達した場合に読み込みを停止する設計ですが、BinaryReaderのようにバイナリデータを読み込む際に、期待したサイズのデータが存在しないとEndOfStreamExceptionが発生します。

これは、読み込み要求がストリームの終端を超えているためです。

.NETランタイムが例外を投げる条件

EndOfStreamExceptionは、System.IO名前空間に属し、IOExceptionを継承した例外クラスです。

この例外は、ストリームの終端を超えて読み込みを試みた場合にスローされます。

具体的には以下のような条件で発生します。

  • 読み込み要求がストリームの残りデータ量を超えた場合

例えば、BinaryReader.ReadInt32()は4バイトの読み込みを要求しますが、ストリームの残りが3バイト以下の場合、EndOfStreamExceptionが発生します。

  • シリアライズやデシリアライズ処理でストリームの位置が不適切な場合

デシリアライズ時にストリームのPositionが終端に近い、または終端を超えていると、読み込みができず例外が発生します。

  • ストリームの終端を正しく検出せずに読み込みを続けた場合

例えば、StreamReaderEndOfStreamプロパティを使わずにReadLineを繰り返すと、終端を超えて読み込もうとして例外が起きることがあります。

  • 非同期読み込みでキャンセルや切断が発生した場合

ネットワークストリームなどで接続が切れた際に、読み込みが途中で終わると例外が発生することがあります。

このように、EndOfStreamExceptionはストリームの終端を超えた読み込み操作が原因で発生します。

したがって、ストリームのPositionLengthを適切に管理し、終端を検出してから読み込みを行うことが重要です。

これにより、例外の発生を未然に防ぐことができます。

主な発生パターン

ファイルI/Oでのケース

固定長データの読み取りミス

ファイルから固定長のバイナリデータを読み取る際に、読み込みサイズの誤りやファイルの破損により、期待したバイト数が取得できない場合があります。

たとえば、4バイトの整数を読み込むつもりが、ファイルの終端に近くて3バイトしか残っていないと、BinaryReader.ReadInt32()EndOfStreamExceptionをスローします。

この問題は、ファイルのサイズや残りバイト数を事前にチェックせずに読み込みを行うことが原因です。

ファイルの破損や不完全な書き込みがある場合も同様に発生します。

連続ReadLineでEOFを無視

StreamReaderReadLineメソッドを使ってテキストファイルを読み込む際、EndOfStreamプロパティを確認せずに無限ループや条件判定を行うと、ファイルの終端を超えて読み込みを試みてEndOfStreamExceptionが発生することがあります。

たとえば、以下のようにwhile(true)ReadLineを繰り返し、nullチェックを怠るケースです。

StreamReader reader = new StreamReader("sample.txt");
while (true)
{
    string line = reader.ReadLine();
    // lineがnullの可能性を無視して処理を続ける
}

このようなコードは、ファイル終端に達したときに例外を引き起こします。

EndOfStreamReadLineの戻り値nullを適切にチェックすることが重要です。

ネットワークストリームでのケース

TCP接続切断時の読み取り

TCPソケットのネットワークストリームでデータを読み込む際、接続が相手側で切断されると、読み込み操作が途中で終了し、EndOfStreamExceptionが発生することがあります。

特に、NetworkStreamBinaryReaderを使って固定長のデータを読み込む場合、切断により期待したバイト数が得られず、例外がスローされます。

接続の状態を監視し、切断時には読み込みを中断する処理が必要です。

WebSocketメッセージ処理

WebSocket通信でメッセージを受信する際、メッセージの終端を正しく検出しないと、ストリームの終端を超えて読み込みを試みてEndOfStreamExceptionが発生します。

WebSocketはフレーム単位でデータを送受信しますが、フレームの終わりを判定せずに連続して読み込むと、バッファの終端を超える読み込みが起きやすいです。

フレームの長さやメッセージの終端を正確に把握し、読み込み範囲を制御することが重要です。

圧縮ストリーム使用時

GZipStreamのサイズ超過

GZipStreamなどの圧縮ストリームを使って圧縮データを展開する際、圧縮データの終端を超えて読み込みを行うとEndOfStreamExceptionが発生します。

圧縮ストリームは元のデータサイズと圧縮後のサイズが異なるため、展開時に読み込みサイズを誤ると、ストリームの終端を超えて読み込もうとすることがあります。

圧縮データの正確なサイズ管理や、読み込みループでの終端チェックが必要です。

シリアライズ・デシリアライズ

BinaryFormatterのバージョン不整合

BinaryFormatterを使ったシリアライズ・デシリアライズ処理で、シリアライズ時とデシリアライズ時のクラス定義やバージョンが異なる場合、ストリームの読み込み位置がずれてEndOfStreamExceptionが発生することがあります。

たとえば、シリアライズしたオブジェクトのフィールドが増減したり、型が変更された場合、デシリアライズ時に期待するデータ構造と実際のストリーム内容が合わず、読み込みが終端を超えてしまいます。

この問題を防ぐには、バージョン管理を徹底し、互換性のあるシリアライズ設計を行うことが重要です。

メモリストリーム使用時

書き込み忘れによるサイズ不足

MemoryStreamにデータを書き込んだ後、書き込みが完了していなかったり、FlushPositionのリセットを忘れると、読み込み時に期待したデータが存在せずEndOfStreamExceptionが発生します。

たとえば、書き込み後にPositionを先頭に戻さずに読み込みを開始すると、ストリームの終端に位置しているため、読み込みができません。

また、書き込み途中でストリームを読み込もうとすると、データが不完全で例外が起きることがあります。

書き込み完了後は必ずPosition0に戻し、読み込み前にデータの整合性を確認してください。

サードパーティライブラリ内での例

サードパーティ製のライブラリやフレームワークでストリームを扱う際、内部処理の不具合や使い方の誤りによりEndOfStreamExceptionが発生することがあります。

たとえば、独自のシリアライズ形式や圧縮形式を扱うライブラリで、ストリームの終端チェックが不十分な場合や、APIの仕様に合わない読み込みを行うと例外が起きます。

このような場合は、ライブラリのドキュメントをよく確認し、正しい使い方を守ることが重要です。

また、例外発生時のスタックトレースを解析し、どの処理で終端超過が起きているかを特定することがトラブルシューティングの鍵となります。

原因特定のポイント

コールスタック解析

EndOfStreamExceptionが発生した際、まずは例外のコールスタックを詳細に確認することが重要です。

コールスタックは、例外がスローされたメソッドの呼び出し履歴を示しており、どの処理でストリームの終端を超えた読み込みが行われたかを特定できます。

Visual StudioなどのIDEでは、例外発生時にコールスタックウィンドウでスタックトレースを確認できます。

特に、BinaryReaderStreamReaderの読み込みメソッドがどの行で呼ばれているかを注視してください。

自作のメソッドやライブラリの呼び出し階層も含めて解析することで、問題の根本箇所を絞り込めます。

また、非同期処理やイベント駆動のコードでは、コールスタックが複雑になることがあるため、例外発生時のスレッドコンテキストも確認するとよいでしょう。

デバッガでPosition確認

ストリームのPositionプロパティは、現在の読み書き位置を示します。

EndOfStreamExceptionが発生する場合、PositionLengthを超えているか、終端付近で不正な読み込みが行われている可能性が高いです。

デバッガを使って例外発生直前のPositionをウォッチ式やローカル変数ウィンドウで確認してください。

読み込みループの中でPositionがどのように変化しているかを追跡すると、どのタイミングで終端を超えたかがわかります。

また、Positionを手動で変更しているコードがあれば、その操作が正しいかどうかも検証してください。

特に、SeekメソッドやPositionの直接代入は誤った位置に設定されやすいため注意が必要です。

Stream.Length比較

Stream.Lengthはストリーム全体のバイト数を示します。

PositionLengthの比較は、読み込み可能な残りデータ量を把握するうえで欠かせません。

読み込み前にPositionLength以上になっていないかをチェックすることで、終端超過の読み込みを防げます。

たとえば、BinaryReaderで固定長のデータを読み込む場合は、残りのバイト数が読み込みサイズ以上あるかを確認してから読み込みを行うのが安全です。

以下のような条件チェックが有効です。

if (stream.Length - stream.Position >= expectedReadSize)
{
    // 読み込み処理
}
else
{
    // 終端に達しているため読み込みを中止
}

このようにLengthPositionを比較して読み込み制御を行うことで、EndOfStreamExceptionの発生を未然に防げます。

ログ出力の配置

例外の原因を特定するためには、ストリームの読み込み処理周辺に適切なログ出力を配置することが効果的です。

ログには、読み込み前後のPositionや読み込んだバイト数、読み込み要求サイズなどを記録します。

たとえば、読み込みループの開始時と終了時にPositionをログに出力し、どの時点で終端を超えたかを追跡できるようにします。

また、例外発生時にはスタックトレースとともにPositionLengthの値をログに残すと、後から原因解析がしやすくなります。

ログ出力はSerilogNLogなどのロギングフレームワークを使うと便利です。

JSON形式で出力すれば、ログ解析ツールでの検索やフィルタリングも容易になります。

以下はログ出力例のイメージです。

[INFO] Read operation started. Position=1024, Length=2048, RequestedBytes=4
[INFO] Read operation completed. Position=1028
[ERROR] EndOfStreamException caught. Position=2047, Length=2048, RequestedBytes=4

このように詳細なログを残すことで、どの読み込み操作が終端超過を引き起こしたかを特定しやすくなります。

具体的な対策

読み込み前のEndOfStreamチェック

StreamReader.EndOfStream利用

StreamReaderを使ってテキストデータを読み込む場合、EndOfStreamプロパティを活用してストリームの終端に達しているかを事前に確認することが重要です。

これにより、終端を超えた読み込みを防ぎ、EndOfStreamExceptionの発生を抑制できます。

以下はStreamReader.EndOfStreamを使った安全な読み込み例です。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        using (var stream = new FileStream("sample.txt", FileMode.Open))
        using (var reader = new StreamReader(stream))
        {
            while (!reader.EndOfStream)
            {
                string line = reader.ReadLine();
                Console.WriteLine(line);
            }
        }
    }
}
ファイルの各行が順に表示される

このコードでは、EndOfStreamtrueになるまでReadLineを繰り返すため、終端を超えた読み込みが起きません。

ReadLineの戻り値がnullになるケースもありますが、EndOfStreamを使うことでより明示的に終端を検出できます。

BinaryReader.BaseStream.Position確認

バイナリデータをBinaryReaderで読み込む場合は、BaseStream.PositionBaseStream.Lengthを比較して、読み込み前に残りバイト数を確認することが効果的です。

これにより、読み込みサイズが残りデータを超えないように制御できます。

以下はBinaryReaderでのチェック例です。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        using (var stream = new FileStream("data.bin", FileMode.Open))
        using (var reader = new BinaryReader(stream))
        {
            while (reader.BaseStream.Position < reader.BaseStream.Length)
            {
                if (reader.BaseStream.Length - reader.BaseStream.Position >= 4)
                {
                    int value = reader.ReadInt32();
                    Console.WriteLine($"読み込んだ値: {value}");
                }
                else
                {
                    Console.WriteLine("残りデータが不足しているため読み込みを中止します。");
                    break;
                }
            }
        }
    }
}
読み込んだ値: 12345
読み込んだ値: 67890
残りデータが不足しているため読み込みを中止します。

このように、読み込み前に残りバイト数をチェックすることで、EndOfStreamExceptionを防げます。

期待サイズでループ制御

ストリームから複数のデータを連続して読み込む場合、期待するデータサイズに基づいてループを制御することが重要です。

例えば、固定長のレコードを読み込む場合は、残りのバイト数がレコードサイズ以上あるかを確認しながら読み込みを行います。

以下は固定長レコードを安全に読み込む例です。

using System;
using System.IO;
class Program
{
    const int RecordSize = 8; // 例: 8バイトのレコード
    static void Main()
    {
        using (var stream = new FileStream("records.bin", FileMode.Open))
        using (var reader = new BinaryReader(stream))
        {
            while (reader.BaseStream.Length - reader.BaseStream.Position >= RecordSize)
            {
                byte[] record = reader.ReadBytes(RecordSize);
                Console.WriteLine($"レコード読み込みです: {BitConverter.ToString(record)}");
            }
            Console.WriteLine("すべてのレコードを読み込みました。");
        }
    }
}
レコード読み込みです: 01-02-03-04-05-06-07-08
レコード読み込みです: 09-0A-0B-0C-0D-0E-0F-10
すべてのレコードを読み込みました。

この方法で、終端を超えた読み込みを防ぎつつ、正確にデータを処理できます。

Seekでストリーム先頭へ戻す

デシリアライズや複数回の読み込み処理を行う際、ストリームのPositionが終端に近い場合は、先頭に戻すことで読み込みエラーを防げます。

SeekメソッドやPositionプロパティを使ってストリームの位置をリセットします。

以下はBinaryFormatterでデシリアライズ前にストリーム位置を先頭に戻す例です。

using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
[Serializable]
class SampleData
{
    public int Id;
    public string Name;
}
class Program
{
    static void Main()
    {
        var data = new SampleData { Id = 1, Name = "テスト" };
        using (var stream = new MemoryStream())
        {
            var formatter = new BinaryFormatter();
            formatter.Serialize(stream, data);
            // 読み込み前にPositionを先頭に戻す
            stream.Seek(0, SeekOrigin.Begin);
            var deserialized = (SampleData)formatter.Deserialize(stream);
            Console.WriteLine($"Id: {deserialized.Id}, Name: {deserialized.Name}");
        }
    }
}
Id: 1, Name: テスト

最新のC#ではBinaryFormatterはセキュリティ上の問題から非推奨となっており、使用しないことが推奨されています(設定によってはコンパイルが通りません)。代わりに、例えばSystem.Text.Jsonなどのシリアライズ方法を使うのが良いです。

Positionを戻さずにデシリアライズを試みると、EndOfStreamExceptionが発生することがあります。

usingブロックでリソースを確実に閉じる

ストリームを使い終わったら、usingブロックを使って確実にリソースを解放することが重要です。

リソースが解放されないと、ストリームの状態が不安定になり、読み込み位置の管理が乱れることがあります。

以下はusingを使った安全なストリーム操作例です。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        using (var stream = new FileStream("sample.txt", FileMode.Open))
        using (var reader = new StreamReader(stream))
        {
            while (!reader.EndOfStream)
            {
                string line = reader.ReadLine();
                Console.WriteLine(line);
            }
        }
    }
}

usingを使うことで、例外が発生してもストリームが確実に閉じられ、次回の読み込み時に不整合が起きにくくなります。

try-catchで例外を補足し再試行

EndOfStreamExceptionが発生する可能性がある処理は、try-catchで囲み、例外を補足して適切に対処することができます。

場合によっては、読み込みを再試行したり、エラーメッセージをログに記録して処理を継続することも可能です。

以下は例外を補足して再試行する例です。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        using (var stream = new FileStream("data.bin", FileMode.Open))
        using (var reader = new BinaryReader(stream))
        {
            bool success = false;
            int retryCount = 0;
            while (!success && retryCount < 3)
            {
                try
                {
                    int value = reader.ReadInt32();
                    Console.WriteLine($"読み込んだ値: {value}");
                    success = true;
                }
                catch (EndOfStreamException)
                {
                    retryCount++;
                    Console.WriteLine($"読み込み失敗。リトライ {retryCount} 回目");
                    stream.Seek(0, SeekOrigin.Begin);
                }
            }
            if (!success)
            {
                Console.WriteLine("読み込みに失敗しました。");
            }
        }
    }
}
読み込んだ値: 12345

この例では、例外発生時にストリームの位置を先頭に戻して再試行しています。

データフォーマット管理

ReadAsyncのキャンセル活用

非同期でストリームを読み込む場合、ReadAsyncメソッドにCancellationTokenを渡して読み込みをキャンセルできるように設計すると、途中で読み込みが長時間停止したり、終端を超えた読み込みを防止できます。

以下はReadAsyncでキャンセルトークンを使う例です。

using System;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
class Program
{
    static async Task Main()
    {
        using (var stream = new FileStream("data.bin", FileMode.Open))
        {
            byte[] buffer = new byte[1024];
            var cts = new CancellationTokenSource(TimeSpan.FromSeconds(5));
            try
            {
                int bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length, cts.Token);
                Console.WriteLine($"読み込んだバイト数: {bytesRead}");
            }
            catch (OperationCanceledException)
            {
                Console.WriteLine("読み込みがキャンセルされました。");
            }
        }
    }
}

キャンセルを活用することで、読み込みが長時間ブロックされることを防ぎ、例外発生のリスクを減らせます。

設計段階での契約定義

ストリームでやり取りするデータのフォーマットやサイズ、構造を設計段階で明確に定義し、契約(プロトコル)として文書化しておくことも重要です。

これにより、読み込み側は期待するデータサイズや構造を正確に把握でき、終端超過の読み込みを防止できます。

たとえば、ヘッダーにデータ長を含める、固定長レコードを使う、バージョン番号を付与するなどの設計が考えられます。

これらの契約に基づいて読み込み処理を実装すれば、EndOfStreamExceptionの発生を抑えられます。

リトライポリシー設定

ストリーム読み込み時に一時的な問題でEndOfStreamExceptionが発生する場合は、リトライポリシーを設定して再試行を行うことが有効です。

特にネットワークストリームや非同期処理で効果的です。

リトライ回数や待機時間を制御し、一定回数失敗したら処理を中断するようにします。

これにより、一時的な読み込み失敗を回避しつつ、無限ループを防止できます。

例外処理の共通化

EndOfStreamExceptionを含むストリーム関連の例外処理は、共通のハンドラやユーティリティメソッドにまとめると保守性が向上します。

共通化することで、例外発生時のログ出力やリトライ処理、リソース解放などを一元管理でき、コードの重複を減らせます。

たとえば、以下のような共通メソッドを用意します。

bool TryReadInt32(BinaryReader reader, out int value)
{
    try
    {
        value = reader.ReadInt32();
        return true;
    }
    catch (EndOfStreamException)
    {
        value = default;
        // ログ出力やリトライ処理をここに記述
        return false;
    }
}

このように例外処理を共通化することで、コードの品質と信頼性を高められます。

コード例:良くない実装

ファイル全文読み込みでのミス

ファイルの全文を読み込む際に、終端チェックを怠るとEndOfStreamExceptionが発生しやすくなります。

以下のコードは、StreamReaderでファイルを読み込む際にEndOfStreamを確認せずにReadLineを繰り返しているため、ファイル終端を超えて読み込みを試みてしまいます。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        using (var stream = new FileStream("sample.txt", FileMode.Open))
        using (var reader = new StreamReader(stream))
        {
            while (true)
            {
                string line = reader.ReadLine();
                // lineがnullかどうかのチェックをしていないため、
                // ファイル終端を超えて読み込もうとして例外が発生する可能性がある
                Console.WriteLine(line);
            }
        }
    }
}

このコードは、ファイルの終端に達してReadLinenullを返してもループを抜けず、nullConsole.WriteLineに渡すことで例外や予期しない動作を引き起こします。

EndOfStreamReadLineの戻り値を必ずチェックしないと、EndOfStreamExceptionが発生するリスクが高まります。

BinaryFormatter逆シリアライズ

BinaryFormatterを使ったデシリアライズ時に、ストリームのPositionを先頭に戻さずに読み込みを開始すると、ストリームの終端を超えて読み込もうとしてEndOfStreamExceptionが発生します。

以下のコードはその典型例です。

using System;
using System.IO;
using System.Runtime.Serialization.Formatters.Binary;
[Serializable]
class SampleData
{
    public int Id;
    public string Name;
}
class Program
{
    static void Main()
    {
        var data = new SampleData { Id = 1, Name = "テスト" };
        using (var stream = new MemoryStream())
        {
            var formatter = new BinaryFormatter();
            formatter.Serialize(stream, data);
            // Positionを先頭に戻していないため、Deserializeで例外が発生する
            var deserialized = (SampleData)formatter.Deserialize(stream);
            Console.WriteLine($"Id: {deserialized.Id}, Name: {deserialized.Name}");
        }
    }
}

このコードは、Serialize後のstream.Positionがストリームの終端にあるため、Deserializeが読み込みを開始した時点で終端を超えてしまい、EndOfStreamExceptionがスローされます。

デシリアライズ前に必ずstream.Position = 0;stream.Seek(0, SeekOrigin.Begin);で位置をリセットする必要があります。

受信バッファ固定長読み込み

ネットワークやファイルから固定長のデータを読み込む際に、残りのデータ量を確認せずに読み込みを行うと、終端を超えて読み込もうとしてEndOfStreamExceptionが発生します。

以下のコードは、BinaryReaderで4バイトの整数を読み込む際に残りバイト数をチェックしていない例です。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        using (var stream = new FileStream("data.bin", FileMode.Open))
        using (var reader = new BinaryReader(stream))
        {
            while (true)
            {
                // 残りのバイト数を確認せずにReadInt32を呼び出しているため、
                // ファイル終端に達するとEndOfStreamExceptionが発生する
                int value = reader.ReadInt32();
                Console.WriteLine($"読み込んだ値: {value}");
            }
        }
    }
}

このコードは、ファイルの終端に達してもループを抜けずにReadInt32を呼び続けるため、終端を超えた読み込みが発生し例外がスローされます。

読み込み前にreader.BaseStream.Lengthreader.BaseStream.Positionを比較して、残りバイト数が4バイト以上あるかを確認しないと安全に読み込めません。

コード例:改善実装

whileループとEndOfStream組み合わせ

StreamReaderを使ったテキストファイルの読み込みでは、EndOfStreamプロパティを活用してファイル終端を正しく検出しながらwhileループで読み込む方法が安全です。

これにより、終端を超えた読み込みを防ぎ、EndOfStreamExceptionの発生を回避できます。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        using (var stream = new FileStream("sample.txt", FileMode.Open))
        using (var reader = new StreamReader(stream))
        {
            while (!reader.EndOfStream)
            {
                string line = reader.ReadLine();
                Console.WriteLine(line);
            }
        }
    }
}
ファイルの各行が順に表示される

このコードは、EndOfStreamtrueになるまでReadLineを繰り返すため、ファイル終端を超えた読み込みを防止します。

ReadLineの戻り値がnullになるケースもありますが、EndOfStreamを使うことでより明示的に終端を検出できます。

async/awaitによる非同期読み込み

非同期処理でストリームを読み込む場合は、async/awaitを使い、StreamReader.ReadLineAsyncStream.ReadAsyncを活用して効率的かつ安全に読み込みを行います。

非同期読み込みでも終端チェックを怠らないことが重要です。

以下はStreamReader.ReadLineAsyncを使った非同期読み込みの例です。

using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
    static async Task Main()
    {
        using (var stream = new FileStream("sample.txt", FileMode.Open))
        using (var reader = new StreamReader(stream))
        {
            while (!reader.EndOfStream)
            {
                string line = await reader.ReadLineAsync();
                Console.WriteLine(line);
            }
        }
    }
}
ファイルの各行が順に表示される

非同期読み込みはUIスレッドのブロックを防ぎ、パフォーマンス向上に寄与します。

EndOfStreamを使って終端を検出し、例外の発生を防いでいます。

Span<byte>を用いた安全な処理

Span<byte>はスタック上に確保される軽量なバッファで、メモリ効率が高く安全にバイト列を扱えます。

ストリームからの読み込み時にSpan<byte>を使うことで、バッファオーバーランや終端超過のリスクを減らせます。

以下はFileStream.ReadのオーバーロードでSpan<byte>を使った例です。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        using (var stream = new FileStream("data.bin", FileMode.Open))
        {
            Span<byte> buffer = stackalloc byte[1024];
            int bytesRead;
            while ((bytesRead = stream.Read(buffer)) > 0)
            {
                Console.WriteLine($"読み込んだバイト数: {bytesRead}");
                // buffer.Slice(0, bytesRead)で有効データを扱う
            }
        }
    }
}
読み込んだバイト数: 1024
読み込んだバイト数: 512

Span<byte>を使うことで、バッファの境界を明確に管理でき、終端を超えた読み込みを防止しやすくなります。

PipeReader採用時の例

System.IO.PipelinesPipeReaderは、高性能なストリーム読み込みAPIで、非同期かつ効率的にデータを処理できます。

PipeReaderはバッファの終端を意識しながら読み込みを行うため、EndOfStreamExceptionの発生を抑制できます。

以下はPipeReaderを使った非同期読み込みの例です。

using System;
using System.IO;
using System.IO.Pipelines;
using System.Threading.Tasks;
class Program
{
    static async Task Main()
    {
        using (var stream = new FileStream("data.bin", FileMode.Open))
        {
            var reader = PipeReader.Create(stream);
            while (true)
            {
                ReadResult result = await reader.ReadAsync();
                ReadOnlySequence<byte> buffer = result.Buffer;
                if (buffer.Length == 0 && result.IsCompleted)
                {
                    break; // 終端に到達
                }
                // バッファ内のデータを処理(例: 全部読み捨て)
                reader.AdvanceTo(buffer.End);
                if (result.IsCompleted)
                {
                    break;
                }
            }
            await reader.CompleteAsync();
            Console.WriteLine("読み込み完了");
        }
    }
}
読み込み完了

PipeReaderはバッファの終端を明示的に管理し、読み込み範囲を制御できるため、終端超過の読み込みによる例外を防ぎやすいです。

非同期処理との相性も良く、高速なストリーム処理に適しています。

ユニットテストによる再発防止

MSTestによる例外テスト

EndOfStreamExceptionの発生を防ぐためには、ユニットテストで例外が正しく発生するか、または発生しないかを検証することが重要です。

MSTestを使った例外テストでは、Assert.ThrowsException<T>メソッドを利用して、特定の処理が例外をスローするかどうかを確認できます。

以下は、BinaryReaderで不正な読み込みを行いEndOfStreamExceptionが発生することをテストする例です。

using System;
using System.IO;
using Microsoft.VisualStudio.TestTools.UnitTesting;
[TestClass]
public class StreamTests
{
    [TestMethod]
    public void ReadBeyondEnd_ThrowsEndOfStreamException()
    {
        using (var stream = new MemoryStream(new byte[] { 0x01, 0x02 })) // 2バイトのみ
        using (var reader = new BinaryReader(stream))
        {
            // 4バイト読み込もうとして例外が発生することを期待
            Assert.ThrowsException<EndOfStreamException>(() =>
            {
                int value = reader.ReadInt32();
            });
        }
    }
}

このテストは、2バイトしかないメモリストリームから4バイトの整数を読み込もうとした際にEndOfStreamExceptionが発生することを検証しています。

例外が発生しなければテストは失敗し、発生すれば成功となります。

このように例外発生を明示的にテストすることで、コードの安全性を高め、再発防止につなげられます。

NUnitとモックストリーム

NUnitを使う場合も同様に例外テストが可能です。

さらに、モックストリームを作成してストリームの動作を制御し、さまざまなシナリオをテストできます。

以下は、NUnitでEndOfStreamExceptionが発生するケースをモックストリームで再現し、例外を検証する例です。

using System;
using System.IO;
using NUnit.Framework;
public class MockStream : Stream
{
    private readonly byte[] _data;
    private long _position;
    public MockStream(byte[] data)
    {
        _data = data;
        _position = 0;
    }
    public override int Read(byte[] buffer, int offset, int count)
    {
        if (_position >= _data.Length)
        {
            // ストリーム終端を超えた読み込みで例外をスロー
            throw new EndOfStreamException("ストリームの終わりを超えて読み取ろうとしました。");
        }
        int bytesToRead = (int)Math.Min(count, _data.Length - _position);
        Array.Copy(_data, _position, buffer, offset, bytesToRead);
        _position += bytesToRead;
        return bytesToRead;
    }
    // 必要な抽象メンバを実装(簡略化)
    public override bool CanRead => true;
    public override bool CanSeek => false;
    public override bool CanWrite => false;
    public override long Length => _data.Length;
    public override long Position { get => _position; set => throw new NotSupportedException(); }
    public override void Flush() { }
    public override long Seek(long offset, SeekOrigin origin) => throw new NotSupportedException();
    public override void SetLength(long value) => throw new NotSupportedException();
    public override void Write(byte[] buffer, int offset, int count) => throw new NotSupportedException();
}
[TestFixture]
public class StreamTests
{
    [Test]
    public void MockStream_ReadBeyondEnd_ThrowsEndOfStreamException()
    {
        var data = new byte[] { 0x01, 0x02 };
        using (var stream = new MockStream(data))
        using (var reader = new BinaryReader(stream))
        {
            // 4バイト読み込みを試みて例外が発生することを検証
            Assert.Throws<EndOfStreamException>(() =>
            {
                int value = reader.ReadInt32();
            });
        }
    }
}

この例では、MockStreamを使って意図的に終端を超えた読み込みでEndOfStreamExceptionをスローするようにしています。

NUnitのAssert.Throwsで例外発生を検証し、テストの信頼性を高めています。

モックストリームを活用することで、実際のファイルやネットワークに依存せずに多様な読み込みシナリオを再現でき、例外処理の堅牢性を検証できます。

ログ設計と運用

Serilogで位置情報を記録

ストリーム操作で発生するEndOfStreamExceptionの原因特定には、読み込み位置やストリームの状態をログに記録することが非常に有効です。

Serilogは柔軟な構成が可能なロギングライブラリで、ストリームのPositionLengthなどの情報をログに含めることで、問題発生時の状況を詳細に把握できます。

以下は、Serilogを使ってストリームの位置情報をログに記録する例です。

using System;
using System.IO;
using Serilog;
class Program
{
    static void Main()
    {
        Log.Logger = new LoggerConfiguration()
            .WriteTo.Console()
            .CreateLogger();
        using (var stream = new FileStream("data.bin", FileMode.Open))
        using (var reader = new BinaryReader(stream))
        {
            try
            {
                while (reader.BaseStream.Position < reader.BaseStream.Length)
                {
                    Log.Information("読み込み開始 Position={Position} Length={Length}", reader.BaseStream.Position, reader.BaseStream.Length);
                    int value = reader.ReadInt32();
                    Log.Information("読み込み成功 Position={Position} Value={Value}", reader.BaseStream.Position, value);
                }
            }
            catch (EndOfStreamException ex)
            {
                Log.Error(ex, "EndOfStreamException発生 Position={Position} Length={Length}", reader.BaseStream.Position, reader.BaseStream.Length);
            }
        }
        Log.CloseAndFlush();
    }
}

このコードでは、読み込み開始時と成功時にPositionLengthをログに出力し、例外発生時には例外情報とともに位置情報を記録しています。

これにより、どの位置で終端超過が起きたかを後から解析しやすくなります。

例外フィルタリング

大量のログが出力される環境では、EndOfStreamExceptionのような特定の例外だけを抽出して監視したい場合があります。

Serilogではフィルタリング機能を使い、特定の例外やメッセージをログレベルや条件で絞り込むことが可能です。

たとえば、EndOfStreamExceptionのみをエラーレベルでファイルに記録し、それ以外はコンソールに出力する設定例です。

Log.Logger = new LoggerConfiguration()
    .WriteTo.Console()
    .WriteTo.File("errors.log", restrictedToMinimumLevel: Serilog.Events.LogEventLevel.Error,
        outputTemplate: "{Timestamp:yyyy-MM-dd HH:mm:ss} [{Level}] {Message}{NewLine}{Exception}",
        filter: e => e.Exception is EndOfStreamException)
    .CreateLogger();

この設定により、EndOfStreamExceptionだけがerrors.logに記録され、他のログはコンソールに流れます。

これで例外の監視が効率化されます。

JSONログフォーマット

ログを機械的に解析・集約する場合、JSON形式でログを出力すると便利です。

SerilogはJSONフォーマットのシンクを標準でサポートしており、ログ解析ツールやクラウドサービスと連携しやすくなります。

以下はJSON形式でログを出力する例です。

Log.Logger = new LoggerConfiguration()
    .WriteTo.File(new Serilog.Formatting.Json.JsonFormatter(), "log.json")
    .CreateLogger();

JSONログにはPositionLengthなどのプロパティも含めて出力できるため、後から検索やフィルタリングが容易になります。

たとえば、EndOfStreamExceptionが発生した位置情報をキーにして問題の傾向分析が可能です。

アラート設定と監視

EndOfStreamExceptionはストリーム操作の異常を示す重要なシグナルなので、発生時に即座に対応できるようアラート設定を行うことが望ましいです。

ログを監視するツールやクラウドサービス(例:Azure Monitor、AWS CloudWatch、Elastic Stackなど)を使い、特定の例外が検出されたら通知を送る仕組みを構築します。

たとえば、SerilogでJSONログを出力し、Elastic Stack(Elasticsearch + Kibana + Logstash)に連携して、EndOfStreamExceptionのログが一定回数を超えたらメールやSlackに通知するルールを設定できます。

また、ログのリアルタイム監視ツールを使い、例外発生時にダッシュボードでアラートを表示したり、運用チームに自動通知することで迅速な対応が可能です。

このように、ログ設計と運用においては、単にログを残すだけでなく、例外のフィルタリングやフォーマット、監視・アラート体制を整えることが、EndOfStreamExceptionの早期発見と再発防止に役立ちます。

パフォーマンスとメモリ考慮

バッファリング最適化

ストリームからの読み込み処理において、バッファリングの最適化はパフォーマンス向上とメモリ効率の改善に直結します。

適切なバッファサイズを設定することで、I/O回数を減らし、CPU負荷やディスクアクセスのオーバーヘッドを抑えられます。

.NETのFileStreamBufferedStreamはバッファリング機能を持っており、デフォルトのバッファサイズは一般的に4KBや8KB程度ですが、用途に応じて調整が可能です。

大きすぎるバッファはメモリ消費を増やし、小さすぎるバッファはI/O回数が増えてパフォーマンス低下を招きます。

以下はFileStreamのバッファサイズを指定して読み込む例です。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        const int bufferSize = 64 * 1024; // 64KBのバッファサイズを指定
        using (var stream = new FileStream("largefile.bin", FileMode.Open, FileAccess.Read, FileShare.Read, bufferSize))
        {
            byte[] buffer = new byte[bufferSize];
            int bytesRead;
            while ((bytesRead = stream.Read(buffer, 0, buffer.Length)) > 0)
            {
                // 読み込んだデータを処理
                Console.WriteLine($"読み込んだバイト数: {bytesRead}");
            }
        }
    }
}
読み込んだバイト数: 65536
読み込んだバイト数: 65536
...

この例では64KBのバッファを使い、I/O回数を減らして効率的に読み込んでいます。

バッファサイズはファイルサイズやシステムのメモリ状況に応じて調整してください。

また、BufferedStreamを使うことで、既存のストリームにバッファリング機能を追加できます。

特にネットワークストリームや圧縮ストリームなど、バッファリングがないストリームに対して有効です。

using (var fileStream = new FileStream("data.bin", FileMode.Open))
using (var bufferedStream = new BufferedStream(fileStream, bufferSize))
{
    // bufferedStreamを使って読み込み処理
}

バッファリングの最適化は、EndOfStreamExceptionの防止には直接関係しませんが、読み込み処理の安定性と効率を高めるために重要なポイントです。

大容量ファイルの分割読み込み

大容量ファイルを一度に読み込むとメモリ消費が増大し、パフォーマンス低下やシステムの不安定化を招く恐れがあります。

これを防ぐために、ファイルを分割して部分的に読み込む方法が推奨されます。

分割読み込みでは、ファイルのサイズやレコード単位で読み込み範囲を制御し、必要な部分だけを処理します。

これによりメモリ使用量を抑えつつ、効率的にデータを扱えます。

以下は大容量ファイルをチャンク単位(例:1MB)で分割読み込みする例です。

using System;
using System.IO;
class Program
{
    static void Main()
    {
        const int chunkSize = 1024 * 1024; // 1MB
        byte[] buffer = new byte[chunkSize];
        using (var stream = new FileStream("largefile.bin", FileMode.Open))
        {
            long totalSize = stream.Length;
            long offset = 0;
            while (offset < totalSize)
            {
                int bytesToRead = (int)Math.Min(chunkSize, totalSize - offset);
                int bytesRead = stream.Read(buffer, 0, bytesToRead);
                if (bytesRead == 0)
                {
                    break; // ファイル終端に到達
                }
                // 読み込んだチャンクを処理
                Console.WriteLine($"チャンク読み込みです: {bytesRead} バイト, オフセット: {offset}");
                offset += bytesRead;
            }
        }
    }
}
チャンク読み込みです: 1048576 バイト, オフセット: 0
チャンク読み込みです: 1048576 バイト, オフセット: 1048576
...

この方法では、メモリに負荷をかけずに大容量ファイルを安全に処理できます。

分割読み込みは、EndOfStreamExceptionの発生を防ぐためにも有効で、読み込み範囲を明確に制御できるため、終端超過の読み込みを回避しやすくなります。

また、分割読み込み時は、チャンクの境界でデータの切れ目がないか注意が必要です。

レコード単位のファイルの場合は、チャンクの最後で不完全なレコードが切れていないか検証し、必要に応じて次のチャンクと結合して処理する工夫が求められます。

よくあるQ&A

非同期メソッドでの挙動

非同期メソッドを使ってストリームからデータを読み込む場合、EndOfStreamExceptionの発生は同期処理と基本的に同じ原因で起こります。

つまり、ストリームの終端を超えて読み込みを試みたときに例外がスローされます。

ただし、非同期処理特有の注意点もあります。

非同期読み込みでは、ReadAsyncReadLineAsyncなどのメソッドを使い、awaitで結果を待ちます。

読み込み完了までの待機中にキャンセルやタイムアウトが発生すると、OperationCanceledExceptionTimeoutExceptionがスローされることがありますが、EndOfStreamExceptionは読み込み範囲の超過が原因で発生します。

非同期メソッドでのEndOfStreamExceptionを防ぐには、読み込み前にStream.LengthPositionを確認し、残りのデータ量を把握してから読み込みを行うことが重要です。

また、ReadAsyncの戻り値である読み込んだバイト数が0の場合は終端に達したことを示すため、これを検出して読み込みループを終了させる設計が推奨されます。

以下は非同期読み込みで終端を正しく検出する例です。

using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
    static async Task Main()
    {
        using (var stream = new FileStream("data.bin", FileMode.Open))
        {
            byte[] buffer = new byte[1024];
            int bytesRead;
            while ((bytesRead = await stream.ReadAsync(buffer, 0, buffer.Length)) > 0)
            {
                Console.WriteLine($"読み込んだバイト数: {bytesRead}");
            }
            Console.WriteLine("ファイル終端に到達しました。");
        }
    }
}

このように、非同期でも読み込みバイト数のチェックを行い、終端を超えた読み込みを防ぐことが重要です。

Unity環境での注意点

Unity環境でEndOfStreamExceptionが発生する場合、特に注意すべきポイントがいくつかあります。

Unityは独自のランタイムやAPIを持ち、マルチプラットフォーム対応のためにストリームの挙動が標準の.NET環境と異なることがあります。

まず、UnityのFileStreamBinaryReaderは基本的に.NETの実装に準じていますが、プラットフォームによってはファイルアクセスの制限や非同期処理のサポート状況が異なります。

特にモバイルやWebGL環境ではファイルシステムの制約が厳しく、ストリームの終端検出が不安定になることがあります。

また、Unityのコルーチンやasync/awaitを使った非同期処理では、ストリームの読み込み中にフレーム更新が行われるため、読み込み状態の管理が複雑になります。

読み込み途中でストリームが閉じられたり、Positionが不正に変更されるとEndOfStreamExceptionが発生しやすくなります。

対策としては、以下の点に注意してください。

  • ファイルアクセスは可能な限り同期的に行い、読み込み完了後に処理を進める
  • 非同期処理を使う場合は、読み込み状態を明確に管理し、PositionLengthを適切にチェックします
  • プラットフォーム固有のファイルアクセス制限を理解し、必要に応じてUnityのStreamingAssetsResourcesフォルダを活用します
  • ファイルの存在チェックやサイズ確認を事前に行い、不完全なファイル読み込みを防ぐ

これらの注意点を守ることで、Unity環境でのEndOfStreamExceptionの発生を抑制できます。

.NET Frameworkとの違い

EndOfStreamExceptionの挙動自体は、.NET Frameworkと.NET Core/.NET 5以降で大きな違いはありませんが、ストリームAPIの内部実装やパフォーマンス、非同期処理のサポート状況に差異があります。

.NET Frameworkでは、非同期ストリーム読み込みのAPIが限定的であり、ReadAsyncReadLineAsyncの実装も古いバージョンでは最適化が不十分な場合があります。

そのため、非同期処理中に例外が発生しやすいケースや、終端検出のタイミングが微妙に異なることがあります。

一方、.NET Coreや.NET 5以降では、ストリームAPIが大幅に改善され、非同期処理のパフォーマンスや安定性が向上しています。

PipeReaderなどの新しいAPIも利用可能で、終端検出やバッファ管理がより厳密に行われるため、EndOfStreamExceptionの発生を抑制しやすくなっています。

また、.NET FrameworkではBinaryFormatterが標準的に使われていましたが、セキュリティ上の理由から.NET Core以降では推奨されておらず、代替のシリアライズ手法が推奨されています。

これに伴い、シリアライズ・デシリアライズ時のEndOfStreamExceptionの発生パターンも変わる可能性があります。

まとめると、.NET Frameworkと最新の.NET環境ではAPIの進化により、ストリーム操作の安定性や例外発生の抑制に差があるため、開発環境に応じた対策やAPI選択が重要です。

まとめ

この記事では、C#で発生するEndOfStreamExceptionの原因や典型的な発生パターン、具体的な対策方法を詳しく解説しました。

ストリームの位置管理や終端検出の重要性、適切な読み込み制御、非同期処理での注意点などを理解できます。

また、ユニットテストやログ設計による再発防止策も紹介し、安定したストリーム操作の実現に役立つ知識を提供しています。

関連記事

Back to top button
目次へ