読者です 読者をやめる 読者になる 読者になる

チリペヂィア

リンクフリー。サンプルコードなどは関連記事内でライセンスについて明示されない限り商用利用なども自由に行って構いませんが、自己責任でお願いします。またこれら日記内容の著作権自体は放棄していません。引用部分については引用元の権利に従ってください。

C#でCSV読む

DOBONをうろついていたら久しぶりに目から鱗が落ちたですよ

CSV形式のファイルをDataTableや配列等として取得する: .NET Tips: C#, VB.NET
「改行混じり」CSV、行中のダブルクオートが偶数になるまで行結合を繰り返すだけで読めるってよ

…。

はえ?

…。

ほ、本当だーッ!?

CSVって基本的にはセルをカンマで区切り、ROWはそのまんま改行で表現する書式。なんですが、意外と面倒くさいのはダブルクオーテーションで囲むと、区切り文字のカンマのみならず改行文字までセル内に含ませることが出来る仕様。どうしたものか。しかもダブルクオーテーション自体のエスケープにも対応しなくちゃいけない。しかし、しかしである。

ミソは

  • 制御文字がカンマとダブルクオートしかない
  • カンマはダブルクオーテーションで囲ってエスケープ
  • ダブルクオーテーションは2連続でエスケープ

だからダブルクオートが偶数になるまで行結合するだけ*1、妙案…。ちなみに2連続ダブルクオートにも対応したスプリット用の正規表現が次のパターンだそう。

("(?:[^"]|"")*"|[^,]*),

(ただし行末にカンマを一つ追加しないと最後の要素が取得できない*2

しょえー。読込だけならライブラリとか持ってくる必要もないのか。では早速、CSV形式のテキストストリームをシーケンシャルリードしちゃうクラス書いてみます。

using System;
using System.Collections.Generic;

public class CSVReader
{
    /// <summary>先頭末尾のダブルクオーテーションを切除します。標準の動作です。</summary>
    public bool trimDQuot;

    /// <summary>エスケープされているダブルクオーテーション(二重になっているダブルクーテーション)を1つに戻します。標準の動作です。</summary>
    public bool formatEscapedDQuot;
        
    /// <summary>コンストラクタ</summary>
    /// <param name="reader">読込むCSVデータ。StringReaderかStreamReaderを想定。</param>
    public CSVReader(System.IO.TextReader reader)
    {
        this.reader = reader;
        trimDQuot = true;
        formatEscapedDQuot = true;
    }

    /// <summary>CSVから一行を読んで返します。</summary>
    /// <returns>行にデータがあれば、1行分のセルの配列をstring[]として返します。セルが一つもない空行の場合は空の配列が返ります。EOFに達するとNULLが返ります。ダブルクオーテーションの処理はtrimDQuotメンバとformatEscapedDQuotメンバを参照してください。</returns>
    public string[] ReadLine()
    {
        int ctQuot = 0;
        string mergedLine = null;
        while (-1 < reader.Peek())
        {
            // ダブルクオーテーションの数が偶数になるまでReadLineをマージしていけば改行交じりを処理できる。
            string getLine = reader.ReadLine();
            
            // 連結
            if (mergedLine == null) mergedLine = getLine;
            else mergedLine += (System.Environment.NewLine + getLine);

            // クオーテーションカウント
            ctQuot += (getLine.Length - getLine.Replace("\"", string.Empty).Length); 
            if ((ctQuot % 2) == 0) // 偶数ブレイク判定
            {
                ctQuot = 0; // カウンタ初期化(しなくても良いっちゃ良い)
                break;
            }
        }
            
        if (mergedLine == null) return null;
        else if (mergedLine.Length == 0) return new string[0];

        List<string> split = new List<string>();
        foreach (System.Text.RegularExpressions.Match match in regexSplitter.Matches(mergedLine + ','))
        {
            string item = match.Value;
            split.Add(item.Substring(0, item.Length - 1)); // remove comma
        }

        if (trimDQuot)
        {
            for (int index = 0; index < split.Count; index++)
            {
                string item = split[index];
                if ((0 < item.Length) && (item[0] == '"') && (item[item.Length - 1] == '"'))
                {
                    split[index] = item.Substring(1, Math.Max(0, item.Length - 2));
                }
            }
        }

        if (formatEscapedDQuot)
        {
            for (int index = 0; index < split.Count; index++)
            {
                split[index] = split[index].Replace("\"\"", "\"");
            }
        }

        return split.ToArray();
    }

    private static readonly string splitterPattern = "(\"(?:[^\"]|\"\")*\"|[^,]*),";
    private static readonly System.Text.RegularExpressions.Regex regexSplitter =
        new System.Text.RegularExpressions.Regex(splitterPattern, System.Text.RegularExpressions.RegexOptions.Multiline);

    private System.IO.TextReader reader;
}

テスト用のCSVをつくりーの(できるだけえげつないの)
f:id:tiri_tomato:20141228014720p:plain

テストのコンソールプロジェクト作ってリソース追加しーの*3
f:id:tiri_tomato:20141228014724p:plain

書きーの

static void Main(string[] args)
{
    CSVReader reader = new CSVReader(new System.IO.StringReader(Resources.CSVImportTest));
    string[] getLine = null;
    while ((getLine = reader.ReadLine()) != null)
    {
        for (int index = 0; index < getLine.Length; index++)
        {
            // 改行分かりにくいから<br>に置換する。
            getLine[index] = getLine[index].Replace(System.Environment.NewLine, "<br>");
        }
        // アイテム同士をスラッシュで連結して表示する。
        Console.WriteLine(string.Join("/", getLine));
    }
}

走らせる。
f:id:tiri_tomato:20141228014727p:plain

動いてるっぽい?改行コードとか、ちょっと不安なので使う際はお手元でも一通りテストしてくださいな*4

それでは皆様、良いお年を。

12/28 19:00追記
ソースコードの偶数カウンタctQuotですが、偶数達成時にゼロ初期化するのを忘れてました。「カウントアップした状態が偶数かどうか」だから理論的には初期化せずとも動くんですが気持ち悪いのでなおしました。

2015/04/14 追記
ダブルクオーテーションのトリム処理のところ、空のセルおよび最終セルがダブルクオーテーション一つだけでEOFしてる場合のチェックを追加…うーむgdgdで申し訳ないですハイ。

*1:「1ROW分のデータ=全てのカンマが閉じた状態で最初に改行が現れるところ迄」なので、改行単位で読み込んでみてカンマ数が偶数になるところまで結合していけばよい、てわけで、言われてみれば簡単な話なんだけど…ふーむ。

*2:アイテムの末尾サインとしてカンマを使う発想/C言語文字列のNULL終端記号とかEOFマークみたいなもん。

*3:FileTypeは単なるTextで

*4:って、当たり前か…Win8の2010で書いてますが、とくにSystem.Text.RegularExpressions.Regexクラスとか、改行のあたりの処理が他環境でどの程度安定するかわからんのですよねコレ。