XsvReaderクラスに読み取り用メソッドを定義する

連載C#でCSVファイル操作ライブラリ の2回目の記事です。

おさらい

前回(文字区切りデータ(CSV,TSV)をパースする)では、XsvReaderクラスにParse()静的メソッドを作成しました。

class XsvReader
{
    public static IEnumerable<string> Parse(string line,
        ICollection<string> delimiters, Func<string> followingLineSelector);
}

ファイルから読み取る場合は、以下のようにStreamReaderなりを使用して1行ずつパースしていました。

using(var reader = new StreamReader("filename.csv"))
{
    while(!reader.EndOfStream)
    {
        var row = XsvReader.Parse(reader.ReadLine(), new[]{","} ,
            () => reader.ReadLine());
        Console.WriteLine(string.Join(",", row));
    }
}

今回は、このような読み取り処理をXsvReaderに組み込んで、各種読み取り用メソッドを作成してみたいと思います。

XsvReaderの実装

TextReaderをラップする

まずは、内部でのテキストデータの読み取りに使用する内部リーダを準備します。
内部リーダにはSystem.IO.StreamReaderSystem.IO.StringReaderの基底クラスSystem.IO.TextReaderを使用します。

class XsvReader : IDisposable
{
    //文字データ読み取り用内部リーダ
    protected TextReader BaseReader{ get; set; }
    //Dispose済みの場合にTrueを返す
    public bool Disposed{ get; protected set; }
    //データの終端に達している場合にTrueを返す
    public bool EndOfData{ get; }

    //内部リーダを指定するコンストラクタ
    public XsvReader(TextReader baseReader)
    {
        this.BaseReader = baseReader;
    }
    //内部リーダをDisposeする
    public void Dispose()
    {
        if(Disposed) return;
        BaseReader.Dispose();
        Disposed = true;
    }
}
  • コンストラクタにはSystem.IO.TextReaderを継承した具象クラスのインスタンスを渡します。
  • IDisposableインターフェースを実装し、Dispose()メソッドで内部リーダのDispose()を呼ぶようにします。

これで、XsvReaderの外郭ができました。以下のようにusingを使った一連の読み取り処理が記述できます。

//StreamReaderを使う場合
using(var reader = XsvReader(new StreamReader("fileName.cs")))
{
    reader.ReadXsvLine();
}

//StringReader を使う場合
string data= "a,b,c,d,e...";
using(var reader = XsvReader(new StringReader(data)))
{
    reader.ReadXsvLine();
}

読み取り処理

それでは、テキストデータを読み取るためのメソッドを実装しましょう。

通常のテキストとしての1行を読み取るReadLine()メソッド

内部リーダのReadLine()で読み取った1行をそのまま返します。
文字区切りテキストでない行や、不要な行を読み飛ばしたい場合に使用することを想定しています。

public string ReadLine()
{
    return BaseReader.ReadLine();    
}

文字区切りテキストの1行(レコード)分を読み取るReadXsvLine()メソッド

1レコードに含まれるフィールドのシーケンス(IEnumerable<string>)を返します。

内部リーダの現在の位置からReadLine()メソッドで取得した1行を渡してParse()メソッドを実行し、その結果を返却します。 フィールド内に改行を含む場合、第3引数に渡したデリゲート内が実行され、ReadLine()メソッドが追加で呼ばれます。

public IEnumerable<string> ReadXsvLine(ICollection<string> delimiters)
{
    return Parse(ReadLine(), delimiters, () => ReadLine());
}

文字区切りテキストを末尾まで読み取るReadXsvToEnd()メソッド

内部リーダの現在の位置から、末尾に達するまでReadXsvLine()メソッドを繰り返します。

public IEnumerable<string[]> ReadXsvToEnd(ICollection<string> delimiters)
{
    while (!EndOfData)
    {
        yield return ReadXsvLine(delimiters).ToArray();
    }
}

EndOfDataプロパティ

StreamReaderクラスにはEndOfStreamプロパティがあり、これで読み取り位置が末尾に達したか否かを調べることができるのですが 基底クラスのTextReaderには同様のプロパティが存在しません。
そこで、読み取り位置が末尾に達したか否かを確認するプロパティEndOfDataを新たに追加します。

末尾に達したことを調べる方法ですが、ここではTextReader.Peek()メソッドを使用しています。 MSDN によると

Peek メソッドは、ファイルの末尾に達したのか、または別のエラーが発生したのかを判断するために、整数値を返します。
これにより、ユーザーは戻り値が -1 かどうかをチェックしてから Char 型にキャストできます。TextReader の現在位置が、この操作によって変更されることはありません。 使用できる文字がない場合、戻り値は -1 になります。 既定の実装では、-1 が返されます。

とあります。
今回の実装では、Peek()が-1を返したら末尾と判断してEndOfData がtrueを返すようにしています。

ただ、内部リーダの実装によっては、Peek()メソッドをサポートせず常に-1を返してくる可能性があります。この場合EndOfDataプロパティは常にtrueを返すことになり、意図した動作になりません。
TextReaderを継承した自作のクラスを内部リーダとして使用する場合にはPeek()メソッドをオーバーライドして適切な実装を行う必要があります。

public bool EndOfData
{
    get
    {
        if (!_endOfData)
        {
            _endOfData = BaseReader.Peek() == -1;
        }
        return _endOfData;
    }
}

まとめ

ようやく、XsvReaderとして形になってきました。ここまでのソースコードをこのページの最後に載せておきます。 そのままコピペで使えると思います(.Net Framework 3.5以降)。

なお、今回の記事では挙げませんでしたが、System.IO.Streamを引数に取るコンストラクタが追加されています。
コンストラクタ内で、引数で渡されたStreamからStreamReaderを生成して内部リーダに設定しています。

次回

⇒ 次回は、今回作成した読み取り用メソッドの非同期版を作ってみたいと思います。

using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;

namespace HandyUtil.Text.Xsv
{
    public class XsvReader : IDisposable
    {
        protected bool _endOfData = false;
        protected TextReader BaseReader { set; get; }

        public bool Disposed { get; protected set; }

        public bool EndOfData
        {
            get
            {
                if (!_endOfData)
                {
                    _endOfData = BaseReader.Peek() == -1;
                }
                return _endOfData;
            }
        }

        public XsvReader(Stream stream, Encoding encoding)
        {
            if (stream == null)
            { throw new ArgumentNullException("stream"); }

            BaseReader = new StreamReader(stream, encoding);
        }

        public XsvReader(Stream stream)
        {
            if (stream == null)
            { throw new ArgumentNullException("stream"); }
            BaseReader = new StreamReader(stream);
        }

        public XsvReader(TextReader reader)
        {
            if (reader == null)
            { throw new ArgumentNullException("reader"); }
            BaseReader = reader;
        }

        public void Dispose()
        {
            if (BaseReader != null)
            {
                BaseReader.Dispose();
                Disposed = true;
            }
        }

        public string ReadLine()
        {
            return BaseReader.ReadLine();
        }

        public IEnumerable<string> ReadXsvLine(ICollection<string> delimiters)
        {
            return Parse(ReadLine(), delimiters, () => ReadLine());
        }

        public IEnumerable<string[]> ReadXsvToEnd(ICollection<string> delimiters)
        {
            while (!EndOfData)
            {
                yield return ReadXsvLine(delimiters).ToArray();
            }
        }

        public static IEnumerable<string> Parse(string line, ICollection<string> delimiters, Func<string> followingLineSelector)
        {
            if (string.IsNullOrEmpty(line))
            { yield break; }

            var state = TokenState.Empty;
            string token = "";

            foreach (var c in line)
            {
                switch (state)
                {
                    case TokenState.Empty:
                    case TokenState.AfterSeparator:
                        state = TokenState.Empty;
                        if (char.IsWhiteSpace(c) && !delimiters.Any(s => s.StartsWith(c.ToString())))
                        { break; }

                        if (c == '"')
                        {
                            state = TokenState.QuotedField;
                            token += c;
                            break;
                        }
                        state = TokenState.NormalField;
                        goto case TokenState.NormalField;

                    case TokenState.NormalField:
                        {
                            token += c;
                            var delimiter = delimiters.FirstOrDefault(s => token.EndsWith(s));
                            if (delimiter != null)
                            {
                                yield return token.Substring(0, token.Length - delimiter.Length).TrimEnd();
                                state = TokenState.AfterSeparator;
                                token = "";
                            }
                            break;
                        }

                    case TokenState.QuotedField:
                        token += c;
                        if (c == '"')
                        {
                            state = TokenState.EndQuote;
                        }
                        break;

                    case TokenState.EndQuote:
                        if (c == '"')
                        {
                            token += c;
                            state = TokenState.QuotedField;
                            break;
                        }

                        yield return token.Substring(1, token.Length - 2).Replace("\"\"", "\"");
                        token = "";
                        state = TokenState.AfterQuote;
                        goto case TokenState.AfterQuote;

                    case TokenState.AfterQuote:
                        {
                            token += c;
                            var delimiter = delimiters.FirstOrDefault(s => token.EndsWith(s));
                            if (delimiter != null)
                            {
                                state = TokenState.AfterSeparator;
                                token = "";
                            }
                            break;
                        }
                }
            }

            if (state == TokenState.AfterQuote)
            { yield break; }

            if (state == TokenState.QuotedField)
            {
                var next = Parse(token + Environment.NewLine + followingLineSelector(),
                    delimiters, followingLineSelector);
                foreach (var s in next)
                {
                    yield return s;
                }
            }
            else if (token != string.Empty || state == TokenState.AfterSeparator)
            {

                yield return (state == TokenState.EndQuote)
                    ? token.TrimEnd().Substring(1, token.Length - 2).Replace("\"\"", "\"")
                    : token.TrimEnd();
            }
        }

        private enum TokenState
        {
            Empty,
            AfterSeparator,
            NormalField,
            QuotedField,
            EndQuote,
            AfterQuote
        }

    }
}