C#でプログラミングの練習がてらCSVリーダを書いた話


はじめに

TextFieldParserCSVHelperを追加で入れるのが色々面倒な環境で作業をしている際に、テスト時のデータチェック用 兼 勉強用 兼 練習 兼 遊び目的でCSVファイルのパース処理を組んだ記録です。
初投稿なので、さらに投稿の練習も兼ねて。

環境

.Net Framework 4.5 (C# 5)

CSVの仕様

Comma-Separated Values - ウィキペディア

要約すると、

  • カンマ(,)でデータが区切られている
  • データ内に以下のどれかがあれば、データをダブルクォートで囲む
    • カンマ
    • 改行
    • ダブルクォート
  • データ内にダブルクォートがあれば、それを二重にする
  • 以上に関係なく、各データはダブルクォートで囲んでもいい

使うシステムで仕様が違う場合があるとのことですが、おそらくここまでが基本仕様とのこと。

読み取りの指針

以下の処理ができれば読み取りできるはず、という点を書く前に大まかに整理。

  • データの先頭がダブルクォートだったら、それ以降でペアになっていないダブルクォートを探して、そこまでを1データとする
  • データ自体にダブルクォートがあれば、二重になっているのを1つに変換する
  • データの先頭がダブルクォートでなければ、次のカンマまでを1データとする
  • 行の先頭、末尾にカンマがあれば、その前後に空文字データがあると考える
  • 2回以上連続でカンマがあれば、そこに空文字データがあると考える
  • 空行は、空文字データが1つある行と考える

実際のメソッド


// File.ReadLines(string path)辺りを引数で渡すつもりで作成
public IEnumerable<List<string>> ReadCsvData(IEnumerable<string> data)
{
    // 返却用リスト
    List<string> retLine = new List<string>();
    // データの改行有無フラグ
    bool hasNewLine = false;

    foreach(string line in data)
    {
        int iStart = 0;

        // 前の読み取り行に改行付きデータがあれば、ダブルクォートの終点探し
        if (hasNewLine)
        {
            retLine[retLine.Count - 1] += Environment.NewLine;
            int iEnd = SearchCloseQuot(line, iStart);
            if (iEnd == -1)
            {
                retLine[retLine.Count - 1] += line.Replace("\"\"", "\"");
                continue;
            }
            retLine[retLine.Count - 1] += line.Substring(iStart, iEnd).Replace("\"\"", "\""); 
            hasNewLine = false;

            iStart = line.IndexOf(',', ++iEnd);

            if(iStart == -1)
            {
                yield return retLine;
                retLine = new List<string>();
                continue;
            }

            iStart++;
        }

        // 行始まりか、前行からの改行データ終わりから読み取り開始
        for (; iStart < line.Length; iStart++)
        {
            //始点がダブルクォートなら、終点のダブルクォートの探索
            if(line[iStart] == '"')
            {
                int iEnd = SearchCloseQuot(line, iStart + 1);
                if (iEnd == -1)
                {
                    retLine.Add(line.Substring(iStart));
                    hasNewLine = true;
                    break;
                }
                retLine.Add(line.Substring(iStart + 1, iEnd - iStart - 1).Replace("\"\"", "\""));
                if(iEnd == line.Length - 1)
                {
                    break;
                }
                else
                {
                    iStart = line.IndexOf(',', iEnd + 1);
                    continue;
                }
            }
            // 始点がカンマなら、空文字データがあるとして処理
            else if (line[iStart] == ',')
            {
                retLine.Add("");
                continue;
            }
            // 始点がそれ以外なら、次のカンマを探索
            else
            {
                int iEnd = line.IndexOf(',', iStart);
                if(iEnd == -1)
                {
                    retLine.Add(line.Substring(iStart).Trim());
                    break;
                }
                else
                {
                    retLine.Add(line.Substring(iStart, iEnd - iStart));
                    iStart = iEnd;
                    continue;
                }
            }
        }

        // 行内のデータに改行がなければ、リストを返却してリセット
        if (!hasNewLine)
        {
            if (line.Length == 0 || line[line.Length - 1] == ',')
            {
                retLine.Add("");
            }
            yield return retLine;
            retLine = new List<string>();
        }
    }

    // 読み取りが終わった時に改行があれば異常処理
    if (hasNewLine)
        throw new InvalidDataException();
}

// データ終端のダブルクォートを探すサブ関数
private int SearchCloseQuot(string data, int startIndex)
{
    for (int i = startIndex; i < data.Length; i += 2)
    {
        i = data.IndexOf('"', i);
        if (i == data.Length - 1 || data[i + 1] != '"' || i == -1)
        {
            return i;
        }
    }
    return -1;
}

使ってみた

テストデータ:

a,b,cd,e
,f,,g,h,

"i,",j,k
"lm"",n",op,"qrs",
"tu","vw""
""x",yz

実行結果:
(データのない箇所はハイフン)

0 1 2 3 4 5
a b cd e - -
f g h
- - - - -
i, j k - - -
lm",n op qrs - -
tu vw"
"x
yz - - -

補記

いったん例は割愛しますが、前述のTextFieldParserクラスでの読み取りには

  • データの先頭、末尾にスペースがあると、ダブルクォートで囲っていてもTrimされる
  • 空白行(サンプルデータの3行目)はスキップして読み取る

などの特徴があって、使っていたデータだと不都合がありました。結果として組んでおいてよかったのかもしれない。