C#

CsvReader

// CsvReader.cs
// Copyright (C) 2020, Kaigo
// hesperus.net

using System;
using System.Collections.Specialized;
using System.IO;
using System.Text;

/// <summary>
/// CSVの読み取りを実装します。
/// </summary>
public class CsvReader : IDisposable
{
    //
    //  定数
    //

    /// <summary>
    /// ReadBufferのサイズ
    /// </summary>
    private const int ReadBufferSize = 256;


    //
    //  フィールド
    //

    /// <summary>
    /// TextReader.ReadBlock()メソッドで使用するバッファ
    /// </summary>
    private char[] ReadBuffer;

    /// <summary>
    /// TextReader.ReadBlock()メソッドで読み取った文字数
    /// </summary>
    private int ReadCount;

    /// <summary>
    /// 読み取りバッファ内の、次に処理する文字のインデックス
    /// </summary>
    private int ReadBufferIndex;

    /// <summary>
    /// フィールド読み取り用のバッファ
    /// </summary>
    private StringBuilder FieldBuffer;

    /// <summary>
    /// 直前の文字
    /// </summary>
    private char PrevChar;

    /// <summary>
    /// 読み取り元のTextReader
    /// </summary>
    private TextReader SrcReader;


    //
    //  プロパティ
    //

    /// <summary>
    /// 読み取ったレコードに含まれるフィールドのコレクションを取得します。
    /// </summary>
    public StringCollection Fields { get; private set; }

    /// <summary>
    /// 読み取ったレコードのインデックスを取得します。
    /// </summary>
    public long RecordIndex { get; private set; }


    //
    //  コンストラクタ
    //

    /// <summary>
    /// CsvReaderクラスの新しいインスタンスを初期化します。
    /// </summary>
    /// <param name="Reader">
    /// 読み取り元となるTextReaderクラスのインスタンスを指定します。
    /// </param>
    public CsvReader(TextReader Reader)
    {
        if (Reader == null)
        {
            throw new ArgumentNullException("Reader");
        }

        ReadBuffer = new char[ReadBufferSize];
        ReadCount = 0;
        ReadBufferIndex = 0;

        FieldBuffer = new StringBuilder();
        PrevChar = '\0';

        Fields = new StringCollection();
        RecordIndex = -1;

        SrcReader = Reader;
    }


    //
    //  メソッド
    //

    /// <summary>
    /// このCsvReaderオブジェクトによって使用されているリソースを解放します。
    /// </summary>
    public void Dispose()
    {
        if (ReadBuffer != null)
        {
            ReadBuffer = null;
        }

        ReadCount = 0;
        ReadBufferIndex = 0;

        if (FieldBuffer != null)
        {
            FieldBuffer.Length = 0;
            FieldBuffer = null;
        }

        PrevChar = '\0';

        if (Fields != null)
        {
            Fields.Clear();
            Fields = null;
        }

        RecordIndex = -1;

        if (SrcReader != null)
        {
            SrcReader.Dispose();
            SrcReader = null;
        }
    }

    /// <summary>
    /// 次のレコードを読み取ります。次のレコードを読み取れた場合、RecordIndex
    /// とFieldsに値を設定し、trueを返します。ファイル終端に達していて読み取れ
    /// なかった場合は、falseを返します。
    /// </summary>
    /// <returns></returns>
    public bool ReadNext()
    {
        // ファイル終端に達しているかどうかを示すフラグ
        bool IsFileEnd = false;
        
        // レコードが終了したかどうかを示すフラグ
        bool IsRecordEnd = false;

        // レコードが何かの値を持っているかどうかを示すフラグ
        bool HasAnyValue = false;

        Fields.Clear();


        while (!IsRecordEnd)
        {
            // フィールドが終了したかどうかを示すフラグ
            bool IsFieldEnd = false;

            // 二重引用符モードかどうかを示すフラグ
            bool IsQuoted = false;

            // 二重引用符で囲まれているフィールドかどうかを示すフラグ
            bool IsQuotedField = false;

            FieldBuffer.Length = 0;


            while (!IsFieldEnd)
            {
                if (ReadBufferIndex >= ReadCount)
                {
                    // ReadBufferに処理する文字がなくなった場合、新たにSrcReaderから
                    // ReadBufferに読み込む。

                    ReadCount = SrcReader.ReadBlock(ReadBuffer, 0, ReadBufferSize);
                    ReadBufferIndex = 0;

                    if (ReadCount == 0)
                    {
                        // 1文字も読み取れない場合は、ファイル終端に達しているので、フィールドと
                        // レコードとファイルを終了させる。

                        IsFieldEnd = true;
                        IsRecordEnd = true;
                        IsFileEnd = true;

                        if (IsQuoted)
                        {
                            // 二重引用符モードのままファイル終端に達してしまった場合は、二重引用符に
                            // 囲まれていないフィールドと解釈する。


                            // 二重引用符を元に戻す。

                            FieldBuffer.Replace("\"", "\"\"");
                            FieldBuffer.Insert(0, '\"');
                        }

                        break;
                    }
                }


                char CurChar = ReadBuffer[ReadBufferIndex++];

                switch (CurChar)
                {
                    case '\r': // 復帰コード
                        if (IsQuoted)
                        {
                            // 二重引用符モードの場合、通常の文字として処理する。

                            FieldBuffer.Append(CurChar);
                        }
                        else
                        {
                            // 二重引用符モードでなければ、フィールドとレコードを終了させる。

                            IsFieldEnd = true;
                            IsRecordEnd = true;
                        }

                        break;

                    case '\n': // 改行コード
                        if (IsQuoted)
                        {
                            // 二重引用符モードの場合、通常の文字として処理する。

                            FieldBuffer.Append(CurChar);
                        }
                        else if (PrevChar != '\r')
                        {
                            // 二重引用符モードでなく、直前の文字が復帰コードでないなら、フィールドと
                            // レコードを終了させる。

                            IsFieldEnd = true;
                            IsRecordEnd = true;
                        }
                        //else
                        //{
                        //    // 二重引用符モードでなく、直前の文字が復帰コードであるなら、すでにフィー
                        //    // ルドとレコードは終了されているので、無視する。
                        //}

                        break;

                    case '\"': // 二重引用符
                        HasAnyValue = true;

                        if (IsQuoted)
                        {
                            // 二重引用符モードで二重引用符がある場合、二重引用符モードを終了する。

                            IsQuoted = false;
                        }
                        else if (IsQuotedField)
                        {
                            // 二重引用符モードでなく、二重引用符で囲まれているフィールドである場合、
                            // 二重引用符を追加して二重引用符モードに再突入する。

                            FieldBuffer.Append(CurChar);
                            IsQuoted = true;

                            // 二重引用符の直前の文字が二重引用符でない場合、直前の文字は他のcaseで処
                            // 理される。'\r', '\n', ','ではフィールドが終了するし、defaultでは
                            // IsQuotedFieldがfalseになるので、このif文に該当するのは直前の文字が二重
                            // 引用符の場合だけ。
                        }
                        else if (FieldBuffer.Length == 0)
                        {
                            // フィールドの1字目が二重引用符の場合、二重引用符モードにして、二重引用
                            // 符で囲まれているフィールドとする。

                            IsQuoted = true;
                            IsQuotedField = true;
                        }
                        else
                        {
                            // 二重引用符で囲まれているフィールドではなく、フィールドの途中に二重引用
                            // 符が現れた場合、通常の文字として扱う。二重引用符モードにしない。

                            FieldBuffer.Append(CurChar);
                        }

                        break;

                    case ',': // カンマ
                        HasAnyValue = true;

                        if (IsQuoted)
                        {
                            // 二重引用符モードの場合、通常の文字として処理する。

                            FieldBuffer.Append(CurChar);
                        }
                        else
                        {
                            // 二重引用符モードでなければ、フィールドを終了させる。

                            IsFieldEnd = true;
                        }

                        break;

                    default:
                        HasAnyValue = true;

                        if (!IsQuoted && IsQuotedField)
                        {
                            // 二重引用符で囲まれているフィールドのはずなのに、二重引用符モードを終え
                            // たあとに通常の文字がある場合は、二重引用符で囲まれていないフィールドと
                            // して扱う。

                            IsQuotedField = false;


                            // 二重引用符を元に戻す。

                            FieldBuffer.Replace("\"", "\"\"");
                            FieldBuffer.Insert(0, '\"');
                            FieldBuffer.Append('\"');
                        }

                        FieldBuffer.Append(CurChar);
                        break;
                }

                PrevChar = CurChar;
            }

            Fields.Add(FieldBuffer.ToString());
        }

        if (!IsFileEnd || HasAnyValue)
        {
            // ファイル終端でなければtrueを返す。レコードに何か値があればtrueを返す。

            RecordIndex++;
            return true;
        }

        // ファイル終端で、レコードに何も値がないならfalseを返す。

        PrevChar = '\0';
        Fields.Clear();
        RecordIndex = -1;
        return false;
    }
}

CSVの一般的なルール

CSVに規格のようなものはなかったが、いつの間にかRFC 4180にまとめられていた。ただ、仕様ではないらしい。CSVには方言しかないので、それらに共通するルールをまとめたものだと思う。

簡単に書くと、だいたいこんな感じ。

  • ファイルは、改行でレコードに区切られる。
  • レコードは、カンマでフィールドに区切られる。
  • レコードに含まれるフィールドの数は、すべてのレコードで同じでなければならない。
  • フィールドを二重引用符で囲んでも良い。
    二重引用符で囲まないフィールドに、二重引用符を含めてはいけない。
    二重引用符で囲むフィールドには、二重引用符、カンマ、改行を含めても良く、その場合、含める二重引用符の直前にひとつの二重引用符を挿入する。
  • 先頭のレコードをヘッダとすることがある。

実装の留意事項

  • CR, LF, 二重引用符、カンマ以外の文字を通常の文字として扱う。空白も制御文字も例外ではない。
  • 改行については、CR, LF, CRLFのいずれでも良く、それらが混在していても良い。
  • ファイル末尾の空行を無視し、それ以外の空行を無視しない。
    仮にフィールドがひとつしかなく、空文字列のフィールドを含む数個のレコードがファイル末尾にあるならば、そのような形式をCSVと呼んで良いものか不安だが、最後の空行を無視し、それ以外の空行をレコードとして処理する。
  • フィールド数の検証を行わない。フィールド数は、すべてのレコードにおいて同じでなくても良い。ただし、CSVを扱うアプリケーションでは、フィールド数の検証をせざるを得ないと思う。
  • 二重引用符で囲まれたフィールドについて、先頭と末尾の二重引用符を取り除き、その他の連続するふたつの二重引用符をひとつの二重引用符にする。
    "She said ""Hello World!""" → She said "Hello World!"
    CSVを出力する側から考えると、CSVとして出力するために必要だから二重引用符で囲むのであって、元の文字列は二重引用符で囲まれていない。二重引用符を外した文字列を取得できるほうが便利なはず。
    二重引用符で始まっていないフィールドに含まれる二重引用符を、通常の文字として処理する。
    AB"C
    二重引用符で始まり、二重引用符で閉じられ、そのうしろに通常の文字があるような場合も同様で、たとえ最初と最後に二重引用符があっても、二重引用符で囲まれていると判断しない。
    "AB"C"
    二重引用符で始まるフィールドが、ファイル終端まで二重引用符で閉じられていない場合、二重引用符に囲まれていないひとつのフィールドとして処理する。
    これらのようなことがあるとすれば、おそらくCSV出力時のミスだと思うので、エラーにしたほうが良いかもしれない。
  • ヘッダがあるとしても、区別せずにレコードとして扱う。
  • アルゴリズム的にIsFileEndの扱いが難解になってしまっていたので、IsFileEndをクラスのフィールドからローカル変数に変更した。ファイルから読み取るときに、ファイルポインタがファイル終端まで達した後で、ファイルポインタを先頭に移動させ、再び読み取ることができていた。意図していなかった動作だが、それが可能であることをよりわかりやすくした。ただし、ファイルの先頭にBOMがある場合、BOMを文字として読み取ってしまうため、2回目以降の読み取りはうまくいかない。
  • CsvReaderをDispose()するときに、もとのTextReaderをDispose()するように変更した。StreamReaderもDispose()されるときにBaseStreamをDispose()するようだ。そのような作法らしい。

CSVは現役

CSVは相当古い形式だし、XMLなど新しいフォーマットもあるので、とっくにすたれてるだろうと思いきや、いまでもときどき使うことがある。

比較的小さなデータならXMLのほうが便利だが、XMLはファイルをすべて読み取ってからでないと次の処理ができないため、大きなデータを扱うには向かない。

CSVなら1レコードずつデータを読み取りながら処理できるので、大きなデータを扱うときにはCSVを選択することが多い。誰でも知っている一般的な形式であることも理由のひとつ。

例えばデータ移行のとき。データベース同士を直接つなげられれば簡単だが、そのようなことはセキュリティ的に許されないだろう。データベースからテーブルの内容をCSVで取り出し、別のデータベースに登録するのが普通だと思うが、そのファイルはMB単位とか、あるいはそれ以上の大きさになり、テキストエディタで開くのさえ難しいことがある。

その他

  • CSVを読み取るプログラムは、使いまわせるし、役に立つので、ひとつ作っておいたほうが良い。
  • ファイルの内容を一括で読み取ってから、改行でレコードに分割し、さらにカンマでフィールドに分割するようなやり方では、CSVを正しく読み取るのは難しいと思う。予めそうできるように取り決めておかない限り、うまくいかない。
  • 二重引用符で囲まれたフィールドは文字列であり、そうでないフィールドはintなどの値であるといったルールもある。それは独自のルール、方言のひとつであって、「1フィールド目はintに変換してください」というようなことと同じだと思う。
    CSVはテキストなので、すべてのフィールドは文字列であると考えたほうが理解しやすい。

サンプルコード

/// <summary>
/// CSVの各フィールドの長さを計測します。
/// </summary>
/// <param name="CsvFilePath">CSVファイルのパスを指定します。</param>
/// <param name="CharSet">CSVファイルの文字セットを指定します。</param>
/// <returns></returns>
public static int[] MeasureCsvFieldLengths(string CsvFilePath, Encoding CharSet)
{
    int[] FieldLengths = null;

    using (FileStream InputStream = File.Open(CsvFilePath, FileMode.Open, FileAccess.Read, FileShare.Read))
    {
        using (StreamReader SrcReader = new StreamReader(InputStream, CharSet))
        {
            using (CsvReader Reader = new CsvReader(SrcReader))
            {
                if (Reader.ReadNext())
                {
                    FieldLengths = new int[Reader.Fields.Count];

                    for (int i = 0; i < Reader.Fields.Count; i++)
                    {
                        FieldLengths[i] = Reader.Fields[i].Length;
                    }

                    while (Reader.ReadNext())
                    {
                        if (Reader.Fields.Count != FieldLengths.Length)
                        {
                            throw new Exception("フィールド数が異なります。レコード=" + Reader.RecordIndex.ToString());
                        }

                        for (int i = 0; i < Reader.Fields.Count; i++)
                        {
                            if (FieldLengths[i] < Reader.Fields[i].Length)
                            {
                                FieldLengths[i] = Reader.Fields[i].Length;
                            }
                        }
                    }
                }
            }
        }
    }

    return FieldLengths;
}


/// <summary>
/// CSVファイルの内容をデータベースに登録します。
/// </summary>
/// <param name="CsvFilePath">CSVファイルのパスを指定します。</param>
/// <param name="CharSet">CSVファイルの文字セットを指定します。</param>
/// <param name="ConnectionString">SQLサーバの接続文字列を指定します。</param>
/// <param name="NewTableName">データベースに作成する新しいテーブルの名前を指定します。</param>
public static void CsvToDatabase(string CsvFilePath, Encoding CharSet, string ConnectionString, string NewTableName)
{
    int[] FieldLengths = MeasureCsvFieldLengths(CsvFilePath, CharSet);

    if (FieldLengths == null)
    {
        return;
    }


    int FieldCount = FieldLengths.Length;

    string[] ColumnNames = new string[FieldCount];
    SqlParameter[] Parameters = new SqlParameter[FieldCount + 1];


    StringBuilder Builder = new StringBuilder();
    Builder.Append("CREATE TABLE ");
    Builder.Append(NewTableName);

    Builder.AppendLine(" (");
    Builder.AppendLine(" N BIGINT NOT NULL,");
    Parameters[0] = new SqlParameter("@N", SqlDbType.BigInt);

    for (int i = 0; i < FieldCount; i++)
    {
        ColumnNames[i] = "C" + i.ToString();
        Builder.Append(" ");
        Builder.Append(ColumnNames[i]);
        Builder.Append(" NVARCHAR(");
        Builder.Append(FieldLengths[i]);
        Builder.AppendLine(") NOT NULL,");
        Parameters[i + 1] = new SqlParameter("@" + ColumnNames[i], SqlDbType.NVarChar, FieldLengths[i]);
    }

    Builder.AppendLine(" PRIMARY KEY (N))");

    string CreateCommandText = Builder.ToString();


    Builder.Length = 0;
    Builder.Append("INSERT INTO ");
    Builder.Append(NewTableName);
    Builder.Append(" (N");

    for (int i = 0; i < FieldCount; i++)
    {
        Builder.Append(", ");
        Builder.Append(ColumnNames[i]);
    }

    Builder.Append(") VALUES (");
    Builder.Append(Parameters[0].ParameterName);

    for (int i = 1; i <= FieldCount; i++)
    {
        Builder.Append(", ");
        Builder.Append(Parameters[i].ParameterName);
    }

    Builder.Append(")");

    string InsertCommandText = Builder.ToString();


    using (SqlConnection Connection = new SqlConnection(ConnectionString))
    {
        Connection.Open();

        using (SqlTransaction Transaction = Connection.BeginTransaction())
        {
            using (SqlCommand Command = new SqlCommand(CreateCommandText, Connection, Transaction))
            {
                Command.ExecuteNonQuery();
            }

            using (FileStream InputStream = File.Open(CsvFilePath, FileMode.Open, FileAccess.Read, FileShare.Read))
            {
                using (StreamReader SrcReader = new StreamReader(InputStream, CharSet))
                {
                    using (CsvReader Reader = new CsvReader(SrcReader))
                    {
                        while (Reader.ReadNext())
                        {
                            Parameters[0].Value = Reader.RecordIndex;

                            for (int i = 0; i < FieldCount; i++)
                            {
                                Parameters[i + 1].Value = Reader.Fields[i];
                            }

                            using (SqlCommand Command = new SqlCommand(InsertCommandText, Connection, Transaction))
                            {
                                Command.Parameters.AddRange(Parameters);
                                Command.ExecuteNonQuery();
                                Command.Parameters.Clear();
                            }
                        }
                    }
                }
            }

            Transaction.Commit();
        }
    }
}
(2020/12/21 初稿)
(2020/12/22 更新)
(2021/01/15 更新)