ファイル

【C#】CSVを配列に変換する基本テクニックとCsvHelper・TextFieldParserの活用法

CSV文字列やファイルをC#で配列に変換するには、もっとも簡単なのは文字列をSplit(',')で区切る方法ですが、セル内にカンマや改行が含まれる場合はTextFieldParserやCsvHelperなどの専用ライブラリを使うと安全です。

行単位でFile.ReadAllLinesを使い、各行をフィールド配列へ変換すればデータを扱いやすくなります。

目次から探す
  1. CSVを配列に変換する全体像
  2. String.Splitによるシンプルな変換
  3. TextFieldParserでの堅牢な読み取り
  4. CsvHelperライブラリの活用
  5. ケース別実装パターン
  6. パフォーマンス最適化のコツ
  7. エンコーディングとロケールの注意点
  8. テストとデバッグ
  9. セキュリティ対策
  10. 便利な拡張メソッド集
  11. よくあるエラーと解決策
  12. 実装チェックリスト
  13. まとめ

CSVを配列に変換する全体像

CSV(Comma-Separated Values)は、データをカンマで区切って表現するシンプルなテキストフォーマットです。

C#でCSVを配列に変換する際には、CSVの仕様を理解し、適切な方法で読み込むことが重要です。

ここでは、CSVフォーマットの基本から配列化の流れまでを解説します。

CSVフォーマットの基本

CSVは表形式のデータをテキストで表現するためのフォーマットで、各行がレコード、カンマで区切られた各要素がフィールドに対応します。

ただし、単純にカンマで分割するだけでは対応できないケースも多いため、仕様の理解が欠かせません。

RFC 4180との違い

CSVの標準仕様としてよく参照されるのがRFC 4180です。

これはCSVの基本的なルールを定めた文書で、以下のようなポイントが含まれています。

  • 各レコードは改行(CRLF)で区切る
  • フィールドはカンマで区切る
  • フィールド内にカンマや改行、ダブルクォートが含まれる場合は、フィールド全体をダブルクォートで囲む
  • ダブルクォートをフィールド内に含める場合は、2つ連続で記述する(例:”He said “”Hello”””)
  • 先頭行はヘッダーとして扱うことが多いが必須ではない

ただし、実際のCSVファイルはこの仕様に完全に準拠していないことも多く、例えば改行コードがLFのみだったり、区切り文字がカンマ以外(タブやセミコロン)だったりする場合もあります。

よくある落とし穴

CSVを配列に変換する際に注意したいポイントは以下の通りです。

  • フィールド内のカンマ

フィールド内にカンマが含まれている場合、単純にstring.Split(',')で分割すると誤った分割結果になります。

例: "東京,新宿",100"東京新宿"100 に分割されてしまう。

  • ダブルクォートの扱い

フィールドを囲むダブルクォートの有無や、フィールド内のダブルクォートのエスケープ処理が必要です。

  • 改行を含むフィールド

フィールド内に改行が含まれている場合、1行ずつ読み込むだけでは正しく処理できません。

  • 空フィールドの扱い

連続したカンマによる空フィールドの有無や、空白文字のトリム処理なども考慮が必要です。

  • エンコーディングの違い

UTF-8、Shift_JISなどファイルの文字コードによって読み込み方法が異なります。

これらの落とし穴を理解した上で、適切な方法を選択することが大切です。

配列化の流れ

CSVを配列に変換する際は、文字列ソースかファイルソースかによって処理の流れが異なります。

ここではそれぞれのケースについて説明します。

文字列ソースを処理する場合

文字列ソースとは、すでにメモリ上にCSV形式の文字列が存在している場合を指します。

例えば、Web APIのレスポンスやデータベースから取得したCSV形式のテキストなどです。

この場合の基本的な流れは以下の通りです。

  1. 改行で行単位に分割

文字列全体を改行コード\r\n\nで分割し、各行を取得します。

  1. 各行を区切り文字で分割

各行をカンマやタブなどの区切り文字で分割し、フィールドの配列を作成します。

  1. 必要に応じてトリムやエスケープ解除

フィールドの前後の空白を削除したり、ダブルクォートで囲まれたフィールドのクォートを外したりします。

  1. 配列として格納

各行のフィールド配列をまとめて二次元配列やリストに格納します。

ただし、単純にstring.Splitを使うだけでは、前述の落とし穴に対応できません。

複雑なCSVの場合は正規表現や専用のパーサーを使うことが望ましいです。

ファイルソースを処理する場合

ファイルソースは、CSVファイルをディスクから読み込む場合です。

ファイルの読み込みにはStreamReaderFile.ReadAllLinesなどを使います。

基本的な流れは以下の通りです。

  1. ファイルを開く

StreamReaderFile.ReadAllLinesでファイルを読み込みます。

  1. 1行ずつ読み込む

ファイルの内容を1行ずつ読み込みます。

File.ReadAllLinesは全行を一括で読み込みますが、大きなファイルの場合はStreamReader.ReadLineで逐次処理する方がメモリ効率が良いです。

  1. 行を区切り文字で分割

各行をカンマなどの区切り文字で分割し、フィールド配列を作成します。

  1. フィールドの後処理

ダブルクォートの除去や空白のトリム、エスケープ文字の処理を行います。

  1. 配列やリストに格納

各行の配列をまとめて管理します。

ファイルからの読み込みは、文字コードの指定や例外処理も重要です。

また、フィールド内に改行が含まれる場合は、単純に1行ずつ読み込むだけでは正しく処理できません。

そうしたケースにはTextFieldParserCsvHelperなどの専用ライブラリを使うことが推奨されます。

これらの基本を押さえた上で、次のセクションでは具体的な実装方法やライブラリの活用法について詳しく解説していきます。

String.Splitによるシンプルな変換

C#でCSVを配列に変換する最も基本的な方法は、String.Splitメソッドを使うことです。

ここでは、String.Splitの基本的な使い方から、空要素の扱い、前後処理のテクニック、そしてこの方法でよく起こる課題について詳しく説明します。

基本構文と使い方

String.Splitは文字列を指定した区切り文字で分割し、文字列配列を返します。

CSVのカンマ区切りに使う場合は、カンマ,を区切り文字として指定します。

using System;
class Program
{
    static void Main()
    {
        string csv = "札幌,東京,名古屋,福岡";
        string[] fields = csv.Split(',');
        foreach (var field in fields)
        {
            Console.WriteLine(field);
        }
    }
}
札幌
東京
名古屋
福岡

このコードでは、csv文字列をカンマで分割し、fields配列に格納しています。

単純なCSVであればこれで十分です。

オプション引数の設定

String.Splitには、区切り文字の配列とともにStringSplitOptionsを指定できます。

主に使うのは以下の2つです。

  • StringSplitOptions.None(デフォルト): 空の要素も配列に含める
  • StringSplitOptions.RemoveEmptyEntries: 空の要素を除外する

例えば、空のフィールドがあるCSVを分割する場合の違いを見てみましょう。

using System;
class Program
{
    static void Main()
    {
        string csv = "札幌,,名古屋,,福岡";
        // 空要素を含む
        string[] fieldsWithEmpty = csv.Split(',');
        // 空要素を除外
        string[] fieldsWithoutEmpty = csv.Split(new char[] { ',' }, StringSplitOptions.RemoveEmptyEntries);
        Console.WriteLine("空要素を含む場合:");
        foreach (var field in fieldsWithEmpty)
        {
            Console.WriteLine($"'{field}'");
        }
        Console.WriteLine("\n空要素を除外した場合:");
        foreach (var field in fieldsWithoutEmpty)
        {
            Console.WriteLine($"'{field}'");
        }
    }
}
空要素を含む場合:
'札幌'
''
'名古屋'
''
'福岡'
空要素を除外した場合:
'札幌'
'名古屋'
'福岡'

空のフィールドを保持したい場合はStringSplitOptions.Noneを使い、不要な空要素を除外したい場合はRemoveEmptyEntriesを使います。

空要素の扱い

CSVでは連続したカンマが空のフィールドを表します。

空要素を除外すると、元のフィールド数と異なり、データの位置がずれる可能性があるため注意が必要です。

例えば、住所や名前のフィールドが空の場合、空要素を除外するとどのフィールドが空だったのか判別できなくなります。

そのため、空要素を除外するのは、空フィールドが意味を持たない場合や、単純なリスト形式のCSVに限定した方が安全です。

前後処理のテクニック

String.Splitで分割しただけでは、フィールドの前後に不要な空白や改行が残っていることがあります。

これらを取り除くためのテクニックを紹介します。

トリムとクリーンアップ

フィールドの前後に空白やタブ、改行が含まれている場合は、Trim()メソッドで除去します。

using System;
class Program
{
    static void Main()
    {
        string csv = " 札幌 , 東京 , 名古屋 , 福岡 \n";
        string[] fields = csv.Split(',');
        for (int i = 0; i < fields.Length; i++)
        {
            fields[i] = fields[i].Trim();
        }
        foreach (var field in fields)
        {
            Console.WriteLine($"'{field}'");
        }
    }
}
'札幌'
'東京'
'名古屋'
'福岡'

このようにTrim()を使うことで、余計な空白や改行を取り除けます。

特にCSVのフィールドがダブルクォートで囲まれている場合は、Trim()だけでは不十分なので、後述のダブルクォート処理も必要です。

Nullや空文字の正規化

CSVの空フィールドは空文字列として扱われますが、場合によってはnullに変換したいこともあります。

例えば、データベースに格納する際にnullを使いたい場合です。

以下は空文字をnullに変換する例です。

using System;
class Program
{
    static void Main()
    {
        string csv = "札幌,,名古屋,,福岡";
        string[] fields = csv.Split(',');
        for (int i = 0; i < fields.Length; i++)
        {
            fields[i] = string.IsNullOrWhiteSpace(fields[i]) ? null : fields[i];
        }
        foreach (var field in fields)
        {
            Console.WriteLine(field == null ? "null" : $"'{field}'");
        }
    }
}
'札幌'
null
'名古屋'
null
'福岡'

このように空文字や空白だけのフィールドをnullに置き換えることで、後続の処理で空フィールドを判別しやすくなります。

String.Splitで発生する課題

String.Splitはシンプルで使いやすい反面、CSVの複雑な仕様には対応できないことが多いです。

ここでは代表的な課題を挙げます。

ダブルクォート付きフィールド

CSVではフィールド内にカンマや改行が含まれる場合、フィールド全体をダブルクォートで囲みます。

例えば、

"東京,新宿",100 この場合、単純にカンマで分割すると3つのフィールドに分かれてしまいます。

string csv = "\"東京,新宿\",100";
string[] fields = csv.Split(',');
foreach (var field in fields)
{
    Console.WriteLine(field);
}

出力は以下のようになります。

"東京
新宿"
100

これは意図しない分割です。

String.Splitは区切り文字を単純に分割するだけなので、ダブルクォートで囲まれたフィールドの中のカンマを無視できません。

この問題を解決するには、正規表現や専用のCSVパーサーを使う必要があります。

改行を含むセル

CSVのフィールド内に改行が含まれることもあります。

例えば、

"住所1
住所2",100

この場合、1行ずつ読み込んでSplitすると、改行を含むフィールドが複数行に分かれてしまい、正しく配列化できません。

String.Splitは文字列の分割に特化しているため、改行を含むフィールドの処理はできません。

ファイルから読み込む場合は、複数行をまとめて処理できる専用のパーサーを使うことが望ましいです。

マルチバイト文字とエンコーディング

日本語などのマルチバイト文字を含むCSVを扱う場合、文字コードの指定が重要です。

String.Split自体は文字列の分割なのでエンコーディングの影響は受けませんが、ファイルから読み込む際に文字コードが合っていないと文字化けが発生します。

例えば、Shift_JISで保存されたCSVをUTF-8として読み込むと、文字化けや不正な文字列が発生します。

File.ReadAllTextStreamReaderで読み込む際に正しいエンコーディングを指定してください。

using System;
using System.IO;
using System.Text;
class Program
{
    static void Main()
    {
        // Shift_JISで保存されたCSVを正しく読み込む例
        string path = "data_sjis.csv";
        string csv = File.ReadAllText(path, Encoding.GetEncoding("Shift_JIS"));
        string[] fields = csv.Split(',');
        foreach (var field in fields)
        {
            Console.WriteLine(field);
        }
    }
}

このように、String.Splitは単純な分割には便利ですが、CSVの複雑な仕様やファイルの文字コードには注意が必要です。

より堅牢な処理が必要な場合は、TextFieldParserCsvHelperなどの専用ライブラリを検討してください。

TextFieldParserでの堅牢な読み取り

TextFieldParserは、Microsoft.VisualBasic名前空間に含まれるクラスで、CSVや固定長ファイルの読み取りに特化しています。

C#でも利用可能で、フィールド内のカンマや改行、ダブルクォートの扱いを自動で処理してくれるため、堅牢なCSV読み込みが可能です。

特徴とメリット

  • フィールド内の区切り文字や改行を正しく処理

ダブルクォートで囲まれたフィールド内のカンマや改行を区切り文字として誤認しません。

  • 区切り文字の柔軟な設定

カンマ以外にもタブやセミコロンなど任意の区切り文字を指定できます。

  • 固定長ファイルの読み込みも可能

CSV以外のフォーマットにも対応しています。

  • 簡単なAPIで使いやすい

ループで1行ずつ読み込み、配列としてフィールドを取得できるため、実装がシンプルです。

  • 例外処理やエラーハンドリングが組み込み

不正な行やフォーマットエラーを検出しやすいです。

これらの特徴により、TextFieldParserは単純なString.Splitよりも信頼性の高いCSV処理を実現します。

実装ステップ

インスタンス生成とプロパティ

TextFieldParserはファイルパスやStreamをコンストラクタに渡してインスタンスを生成します。

使用後はDisposeが必要なので、using文で囲むのが一般的です。

using Microsoft.VisualBasic.FileIO;
using System;
class Program
{
    static void Main()
    {
        using (TextFieldParser parser = new TextFieldParser("data.csv"))
        {
            // ここに処理を記述
        }
    }
}

SetDelimitersの設定

CSVの区切り文字を指定します。

複数の区切り文字も設定可能ですが、通常はカンマ1つを指定します。

parser.SetDelimiters(",");

タブ区切りの場合は"\t"を指定します。

TextFieldTypeの選択

TextFieldTypeプロパティでファイルの形式を指定します。

CSVの場合はFieldType.Delimitedを設定します。

parser.TextFieldType = FieldType.Delimited;

固定長ファイルの場合はFieldType.FixedWidthを指定し、SetFieldWidthsで各フィールドの幅を設定します。

レコード読み取りループ

EndOfDataプロパティでファイルの終端を判定し、ReadFields()メソッドで1行分のフィールド配列を取得します。

while (!parser.EndOfData)
{
    string[] fields = parser.ReadFields();
    // fieldsを使った処理
}

このループで1行ずつ安全に読み込めます。

データ検証と例外処理

型変換のポイント

ReadFields()はすべて文字列配列を返すため、数値や日付などの型変換は別途行います。

int.TryParseDateTime.TryParseを使い、変換失敗時の処理を実装しましょう。

int number;
if (int.TryParse(fields[1], out number))
{
    // 数値として利用
}
else
{
    // 変換失敗時の処理
}

エラーレコードのスキップ

不正な行やフィールド数が異なる行があった場合、例外が発生することがあります。

try-catchで囲み、エラー行をログに記録してスキップする方法が一般的です。

try
{
    string[] fields = parser.ReadFields();
    // 処理
}
catch (MalformedLineException ex)
{
    Console.WriteLine($"不正な行をスキップ: {ex.Message}");
}

これにより、処理を中断せずに読み込みを続けられます。

パフォーマンス考察

大容量CSVでの検証

TextFieldParserは堅牢ですが、大容量ファイルの読み込みではパフォーマンスに注意が必要です。

1行ずつ読み込むためメモリ効率は良いですが、処理速度は専用の高速ライブラリに劣る場合があります。

大量データを高速に処理したい場合は、パフォーマンスを計測し、必要に応じてCsvHelperなどのライブラリを検討してください。

バッファサイズ調整

TextFieldParserは内部でバッファを使って読み込みますが、バッファサイズの調整は直接できません。

ファイル読み込みのパフォーマンスを改善したい場合は、ファイルの読み込み前に圧縮や分割を検討するか、別の高速パーサーを利用するのが効果的です。

以下にTextFieldParserを使ったCSV読み込みのサンプルコードを示します。

using System;
using Microsoft.VisualBasic.FileIO;
class Program
{
    static void Main()
    {
        string path = "data.csv";
        using (TextFieldParser parser = new TextFieldParser(path))
        {
            parser.TextFieldType = FieldType.Delimited;
            parser.SetDelimiters(",");
            while (!parser.EndOfData)
            {
                try
                {
                    string[] fields = parser.ReadFields();
                    // フィールド数のチェック
                    if (fields.Length != 3)
                    {
                        Console.WriteLine("フィールド数が異なる行をスキップ");
                        continue;
                    }
                    // 型変換例
                    if (!int.TryParse(fields[1], out int age))
                    {
                        Console.WriteLine("年齢の変換に失敗した行をスキップ");
                        continue;
                    }
                    Console.WriteLine($"名前: {fields[0]}, 年齢: {age}, 出身地: {fields[2]}");
                }
                catch (MalformedLineException ex)
                {
                    Console.WriteLine($"不正な行をスキップ: {ex.Message}");
                }
            }
        }
    }
}
名前: 山田太郎, 年齢: 30, 出身地: 東京
名前: 佐藤花子, 年齢: 25, 出身地: 大阪
不正な行をスキップ: 行の形式が不正です。
名前: 鈴木一郎, 年齢: 40, 出身地: 名古屋

このコードは、CSVファイルを1行ずつ読み込み、フィールド数や型変換の検証を行いながら処理しています。

不正な行は例外処理でスキップし、処理を継続しています。

CsvHelperライブラリの活用

CsvHelperはC#でCSVファイルの読み書きを簡単かつ柔軟に行える人気のライブラリです。

複雑なCSVの仕様にも対応し、マッピングや例外処理も充実しています。

ここではインストールから基本設定、読み込み・書き込みの具体的な使い方まで詳しく説明します。

インストールと基本設定

CsvHelperはNuGetパッケージとして提供されています。

Visual Studioのパッケージマネージャーコンソールで以下のコマンドを実行してインストールします。

Install-Package CsvHelper
Nugetパッケージマネージャーのタブから追加することも可能

または、.NET CLIを使う場合は以下のコマンドです。

dotnet add package CsvHelper

インストール後、名前空間CsvHelperをusingディレクティブで追加します。

using CsvHelper;
using System.Globalization;

CSVの読み書き時にはCultureInfoを指定することが推奨されます。

特に数値や日付のフォーマットに影響するため、CultureInfo.InvariantCultureを使うことが多いです。

CsvReaderでの読み込み

CsvReaderクラスを使うと、CSVファイルを簡単に読み込めます。

基本的にはStreamReaderと組み合わせて使用します。

using (var reader = new StreamReader("data.csv"))
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
{
    var records = csv.GetRecords<dynamic>();
    foreach (var record in records)
    {
        Console.WriteLine(record);
    }
}

自動マッピング

CsvHelperはCSVのヘッダー行とクラスのプロパティ名を自動でマッピングします。

例えば以下のようなクラスがある場合、

public class Person
{
    public string Name { get; set; }
    public int Age { get; set; }
    public string City { get; set; }
}

CSVのヘッダーがName,Age,Cityであれば、自動的に対応するプロパティに値がセットされます。

using (var reader = new StreamReader("data.csv"))
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
{
    var records = csv.GetRecords<Person>();
    foreach (var person in records)
    {
        Console.WriteLine($"{person.Name} ({person.Age}) - {person.City}");
    }
}

ClassMapによるカスタムマッピング

CSVのヘッダー名とクラスのプロパティ名が異なる場合や、変換処理を加えたい場合はClassMapを使います。

ClassMapを継承したクラスを作成し、マッピングを定義します。

using CsvHelper.Configuration;
public class PersonMap : ClassMap<Person>
{
    public PersonMap()
    {
        Map(m => m.Name).Name("氏名");
        Map(m => m.Age).Name("年齢");
        Map(m => m.City).Name("出身地");
    }
}

読み込み時にマップを登録します。

using (var reader = new StreamReader("data.csv"))
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
{
    csv.Context.RegisterClassMap<PersonMap>();
    var records = csv.GetRecords<Person>();
    foreach (var person in records)
    {
        Console.WriteLine($"{person.Name} ({person.Age}) - {person.City}");
    }
}

属性ベースマッピング

クラスのプロパティに属性を付けてマッピングを指定する方法もあります。

CsvHelper.Configuration.Attributes名前空間のName属性を使います。

using CsvHelper.Configuration.Attributes;
public class Person
{
    [Name("氏名")]
    public string Name { get; set; }
    [Name("年齢")]
    public int Age { get; set; }
    [Name("出身地")]
    public string City { get; set; }
}

この場合、ClassMapの登録は不要で、GetRecords<Person>()で自動的に属性を参照してマッピングされます。

Fluentマッピング

ClassMapはFluent APIスタイルでマッピングを定義でき、条件付きマッピングや変換も可能です。

public class PersonMap : ClassMap<Person>
{
    public PersonMap()
    {
        Map(m => m.Name).Name("氏名");
        Map(m => m.Age).Name("年齢").Convert(row => int.Parse(row.Row.GetField("年齢")));
        Map(m => m.City).Name("出身地");
    }
}

レコードを配列に変換する実装

dynamic型利用

CsvHelperdynamic型でレコードを取得でき、フィールド名を気にせずに柔軟に扱えます。

using (var reader = new StreamReader("data.csv"))
using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
{
    var records = csv.GetRecords<dynamic>();
    foreach (var record in records)
    {
        Console.WriteLine(record.Name);
        Console.WriteLine(record.Age);
        Console.WriteLine(record.City);
    }
}

ただし、dynamicは型安全ではないため、フィールド名の誤りや存在しないフィールドへのアクセスに注意が必要です。

DTOクラス利用

型安全に扱いたい場合は、前述のようにDTO(Data Transfer Object)クラスを定義し、GetRecords<T>()で取得します。

これにより、コンパイル時に型チェックが行われ、コードの保守性が向上します。

例外ハンドリング

MissingFieldFoundの制御

CSVに期待するフィールドが存在しない場合、CsvHelperはデフォルトで例外をスローします。

これを無効化するには、MissingFieldFoundプロパティにnullを設定します。

csv.Configuration.MissingFieldFound = null;

これにより、フィールドが欠落していても例外が発生せず、処理を継続できます。

BadDataFoundの制御

不正なデータ行を検出した際の動作も制御可能です。

BadDataFoundnullを設定すると、エラーを無視します。

csv.Configuration.BadDataFound = null;

ログを出したい場合は、デリゲートを設定してカスタム処理も可能です。

書き込み時の配列展開

WriteRecordsメソッド

CsvHelperCsvWriterクラスでCSVの書き込みができます。

WriteRecordsメソッドにリストや配列を渡すと、自動的にヘッダーとレコードを書き込みます。

using (var writer = new StreamWriter("output.csv"))
using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
{
    var records = new List<Person>
    {
        new Person { Name = "山田太郎", Age = 30, City = "東京" },
        new Person { Name = "佐藤花子", Age = 25, City = "大阪" }
    };
    csv.WriteRecords(records);
}

区切り文字変更

デフォルトの区切り文字はカンマですが、Configuration.Delimiterプロパティで変更可能です。

例えばタブ区切りにする場合は以下のように設定します。

csv.Configuration.Delimiter = "\t";

これにより、タブ区切りのTSVファイルとして書き出せます。

以下に、CsvHelperを使った読み込みと書き込みのサンプルコードを示します。

using System;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using CsvHelper;
using CsvHelper.Configuration.Attributes;
public class Person
{
    [Name("氏名")]
    public string Name { get; set; }
    [Name("年齢")]
    public int Age { get; set; }
    [Name("出身地")]
    public string City { get; set; }
}
class Program
{
    static void Main()
    {
        string inputPath = "input.csv";
        string outputPath = "output.csv";
        // 読み込み
        using (var reader = new StreamReader(inputPath))
        using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
        {
            var records = csv.GetRecords<Person>();
            foreach (var person in records)
            {
                Console.WriteLine($"{person.Name} ({person.Age}) - {person.City}");
            }
        }
        // 書き込み
        var people = new List<Person>
        {
            new Person { Name = "山田太郎", Age = 30, City = "東京" },
            new Person { Name = "佐藤花子", Age = 25, City = "大阪" }
        };
        using (var writer = new StreamWriter(outputPath))
        using (var csv = new CsvWriter(writer, CultureInfo.InvariantCulture))
        {
            csv.WriteRecords(people);
        }
    }
}
山田太郎 (30) - 東京
佐藤花子 (25) - 大阪

このコードは、属性ベースマッピングでCSVを読み込み、リストのデータをCSVに書き出しています。

CsvHelperを使うことで、複雑なCSV処理も簡潔に実装できます。

ケース別実装パターン

CSVファイルは単純なカンマ区切りだけでなく、様々な形式や特殊なケースがあります。

ここでは代表的なケースごとにC#での実装例や注意点を解説します。

区切り文字がタブのTSV

TSV(Tab-Separated Values)は、フィールドの区切り文字がタブ\tの形式です。

CSVとほぼ同様ですが、区切り文字をカンマからタブに変更する必要があります。

String.Splitでの処理例

using System;
class Program
{
    static void Main()
    {
        string tsv = "札幌\t東京\t名古屋\t福岡";
        string[] fields = tsv.Split('\t');
        foreach (var field in fields)
        {
            Console.WriteLine(field);
        }
    }
}
札幌
東京
名古屋
福岡

TextFieldParserでの処理例

using System;
using Microsoft.VisualBasic.FileIO;
class Program
{
    static void Main()
    {
        using (var parser = new TextFieldParser("data.tsv"))
        {
            parser.TextFieldType = FieldType.Delimited;
            parser.SetDelimiters("\t");
            while (!parser.EndOfData)
            {
                string[] fields = parser.ReadFields();
                foreach (var field in fields)
                {
                    Console.Write($"{field} ");
                }
                Console.WriteLine();
            }
        }
    }
}

CsvHelperでの処理例

using System;
using System.Globalization;
using System.IO;
using CsvHelper;
class Program
{
    static void Main()
    {
        using (var reader = new StreamReader("data.tsv"))
        using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
        {
            csv.Configuration.Delimiter = "\t";
            var records = csv.GetRecords<dynamic>();
            foreach (var record in records)
            {
                Console.WriteLine(record);
            }
        }
    }
}

タブ区切りの場合は、区切り文字を明示的にタブに設定することがポイントです。

ダブルクォートが二重エスケープされたCSV

CSVの仕様では、フィールド内のダブルクォートは2つ連続で表現しますが、二重にエスケープされている場合があります。

例えば、"He said """"Hello""""."のように4つ連続しているケースです。

このような場合、単純なパーサーでは正しく処理できません。

CsvHelperTextFieldParserは標準的なエスケープに対応していますが、二重エスケープはカスタム処理が必要です。

CsvHelperでのカスタム変換例

using System;
using System.Globalization;
using System.IO;
using CsvHelper;
using CsvHelper.Configuration;
using CsvHelper.TypeConversion;
public class CustomStringConverter : StringConverter
{
    public override object ConvertFromString(string text, IReaderRow row, MemberMapData memberMapData)
    {
        // 二重エスケープされたダブルクォートを単一に置換
        if (text != null)
        {
            text = text.Replace("\"\"", "\"");
        }
        return base.ConvertFromString(text, row, memberMapData);
    }
}
public class Record
{
    public string Comment { get; set; }
}
public class RecordMap : ClassMap<Record>
{
    public RecordMap()
    {
        Map(m => m.Comment).TypeConverter<CustomStringConverter>();
    }
}
class Program
{
    static void Main()
    {
        using (var reader = new StringReader("\"He said \"\"\"\"Hello\"\"\"\".\""))
        using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
        {
            csv.Context.RegisterClassMap<RecordMap>();
            var records = csv.GetRecords<Record>();
            foreach (var record in records)
            {
                Console.WriteLine(record.Comment);
            }
        }
    }
}
He said ""Hello"".

この例では、CustomStringConverterで二重にエスケープされたダブルクォートを単一に置換し、正しい文字列として読み込んでいます。

先頭行がヘッダーではないファイル

CSVファイルの先頭行がヘッダーでない場合、CsvHelperTextFieldParserのデフォルト設定では正しく読み込めません。

ヘッダーなしのファイルを扱う方法を紹介します。

CsvHelperでヘッダーなしを指定

using System;
using System.Globalization;
using System.IO;
using CsvHelper;
using CsvHelper.Configuration;
class Program
{
    static void Main()
    {
        var config = new CsvConfiguration(CultureInfo.InvariantCulture)
        {
            HasHeaderRecord = false,
        };
        using (var reader = new StreamReader("noheader.csv"))
        using (var csv = new CsvReader(reader, config))
        {
            while (csv.Read())
            {
                for (int i = 0; csv.TryGetField<string>(i, out var field); i++)
                {
                    Console.Write($"{field} ");
                }
                Console.WriteLine();
            }
        }
    }
}

この設定で、1行目もデータとして読み込みます。

フィールドはインデックスでアクセスします。

TextFieldParserでの対応

TextFieldParserはヘッダーの概念がないため、単純に1行目から読み込みます。

ヘッダー行をスキップしたい場合は、ReadLine()で1行読み飛ばす処理を追加します。

using System;
using Microsoft.VisualBasic.FileIO;
class Program
{
    static void Main()
    {
        using (var parser = new TextFieldParser("noheader.csv"))
        {
            parser.TextFieldType = FieldType.Delimited;
            parser.SetDelimiters(",");
            // ヘッダーがないのでスキップしない
            while (!parser.EndOfData)
            {
                string[] fields = parser.ReadFields();
                foreach (var field in fields)
                {
                    Console.Write($"{field} ");
                }
                Console.WriteLine();
            }
        }
    }
}

列数が可変のデータ

CSVファイルの中には、行ごとに列数が異なる可変長のデータもあります。

これを扱う場合は、固定のクラスマッピングは使いづらいため、動的に配列やリストで処理する方法が適しています。

TextFieldParserでの処理例

using System;
using Microsoft.VisualBasic.FileIO;
class Program
{
    static void Main()
    {
        using (var parser = new TextFieldParser("variable_columns.csv"))
        {
            parser.TextFieldType = FieldType.Delimited;
            parser.SetDelimiters(",");
            while (!parser.EndOfData)
            {
                string[] fields = parser.ReadFields();
                Console.WriteLine($"列数: {fields.Length}");
                foreach (var field in fields)
                {
                    Console.Write($"{field} ");
                }
                Console.WriteLine();
            }
        }
    }
}

CsvHelperでの動的読み込み

using System;
using System.Globalization;
using System.IO;
using CsvHelper;
class Program
{
    static void Main()
    {
        using (var reader = new StreamReader("variable_columns.csv"))
        using (var csv = new CsvReader(reader, CultureInfo.InvariantCulture))
        {
            while (csv.Read())
            {
                for (int i = 0; csv.TryGetField<string>(i, out var field); i++)
                {
                    Console.Write($"{field} ");
                }
                Console.WriteLine();
            }
        }
    }
}

この方法で、行ごとに異なる列数のデータも柔軟に処理できます。

これらのケースに応じて適切な設定や処理を行うことで、様々なCSV形式に対応した配列変換が可能になります。

パフォーマンス最適化のコツ

CSVファイルの読み込みや配列変換は、データ量が増えると処理時間やメモリ使用量が問題になることがあります。

ここではC#でのパフォーマンスを向上させるための具体的なテクニックを紹介します。

Span<char>とReadOnlySpan<char>の活用

Span<char>およびReadOnlySpan<char>は、.NET Core以降で利用可能なメモリ効率の良い文字列操作用の構造体です。

文字列のコピーを伴わずに部分文字列を扱えるため、分割やトリムなどの処理を高速化できます。

例えば、string.Splitは新しい文字列配列を生成しますが、ReadOnlySpan<char>を使うと元の文字列のメモリを共有しつつ部分文字列を参照できます。

using System;
class Program
{
    static void Main()
    {
        ReadOnlySpan<char> csv = "札幌,東京,名古屋,福岡".AsSpan();
        int start = 0;
        for (int i = 0; i <= csv.Length; i++)
        {
            if (i == csv.Length || csv[i] == ',')
            {
                ReadOnlySpan<char> field = csv.Slice(start, i - start);
                Console.WriteLine(field.ToString());
                start = i + 1;
            }
        }
    }
}
札幌
東京
名古屋
福岡

この方法は文字列のコピーを最小限に抑え、GC負荷を軽減します。

大量データの処理やリアルタイム性が求められる場面で効果的です。

StreamReaderのEncoding指定

CSVファイルの読み込み時に文字コードを正しく指定しないと、文字化けや不正なデータが発生し、再処理が必要になることがあります。

StreamReaderのコンストラクタでEncodingを明示的に指定することで、正確かつ高速に読み込めます。

using System;
using System.IO;
using System.Text;
class Program
{
    static void Main()
    {
        string path = "data.csv";
        // UTF-8 BOM付きファイルを正しく読み込む例
        using (var reader = new StreamReader(path, Encoding.UTF8))
        {
            string line;
            while ((line = reader.ReadLine()) != null)
            {
                Console.WriteLine(line);
            }
        }
    }
}

適切なエンコーディング指定は、読み込みエラーや文字化けによる再処理を防ぎ、結果的にパフォーマンス向上につながります。

並列処理での高速化

大量のCSVデータを処理する際、CPUのマルチコアを活用して並列処理を行うことで高速化が可能です。

ここではParallel.ForEachと非同期I/Oを使ったTaskの活用例を示します。

Parallel.ForEach

ファイルの各行や複数ファイルを並列で処理する場合に有効です。

例えば、複数のCSVファイルを同時に読み込み、配列変換を行うケースです。

using System;
using System.Collections.Concurrent;
using System.IO;
using System.Threading.Tasks;
class Program
{
    static void Main()
    {
        string[] files = Directory.GetFiles("csv_folder", "*.csv");
        var results = new ConcurrentBag<string[]>();
        Parallel.ForEach(files, file =>
        {
            string[] lines = File.ReadAllLines(file);
            foreach (var line in lines)
            {
                string[] fields = line.Split(',');
                results.Add(fields);
            }
        });
        Console.WriteLine($"処理したレコード数: {results.Count}");
    }
}

ConcurrentBagを使うことでスレッドセーフに結果を収集できます。

ただし、I/O待ちが多い場合は並列化の効果が限定的になることもあります。

非同期I/OとTask

async/awaitを使った非同期I/Oでファイル読み込みを行い、CPU負荷を分散させる方法です。

特にネットワーク越しのファイルアクセスや大容量ファイルの読み込みに有効です。

using System;
using System.IO;
using System.Threading.Tasks;
class Program
{
    static async Task Main()
    {
        string path = "data.csv";
        using (var reader = new StreamReader(path))
        {
            string line;
            while ((line = await reader.ReadLineAsync()) != null)
            {
                string[] fields = line.Split(',');
                // 非同期処理中にCPU負荷の高い処理を入れることも可能
                Console.WriteLine(fields[0]);
            }
        }
    }
}

非同期処理はCPUバウンドではなくI/Oバウンドの処理に適しており、UIスレッドのブロック回避やスケーラビリティ向上に役立ちます。

メモリプールの利用

大量の文字列やバイト配列を頻繁に生成すると、GC(ガベージコレクション)の負荷が高まりパフォーマンスが低下します。

ArrayPool<T>MemoryPool<T>を使うと、メモリの再利用が可能になり、GC負荷を軽減できます。

using System;
using System.Buffers;
class Program
{
    static void Main()
    {
        var pool = ArrayPool<char>.Shared;
        char[] buffer = pool.Rent(1024);
        try
        {
            string csv = "札幌,東京,名古屋,福岡";
            int length = csv.Length > buffer.Length ? buffer.Length : csv.Length;
            csv.AsSpan(0, length).CopyTo(buffer);
            // bufferを使った処理
            Console.WriteLine(new string(buffer, 0, length));
        }
        finally
        {
            pool.Return(buffer);
        }
    }
}

メモリプールを使うことで、頻繁な配列の確保と解放を避け、パフォーマンスの安定化が期待できます。

特に大量データのバッチ処理やリアルタイム処理で効果的です。

エンコーディングとロケールの注意点

CSVファイルを扱う際、文字コード(エンコーディング)やロケール(地域設定)による違いに注意しないと、文字化けや数値・日時の誤認識が発生します。

ここでは代表的な注意点を解説します。

UTF-8 BOM付きファイル

UTF-8エンコーディングのCSVファイルには、ファイルの先頭にBOM(Byte Order Mark)が付いている場合があります。

BOMはファイルのエンコーディングを示す特殊なバイト列で、WindowsのExcelなどが付加することが多いです。

BOM付きUTF-8ファイルをStreamReaderで読み込む際、BOMを正しく認識しないと先頭の文字列に不正な文字が混入することがあります。

using System;
using System.IO;
using System.Text;
class Program
{
    static void Main()
    {
        string path = "utf8bom.csv";
        // BOM付きUTF-8を正しく読み込む例
        using (var reader = new StreamReader(path, Encoding.UTF8, detectEncodingFromByteOrderMarks: true))
        {
            string line = reader.ReadLine();
            Console.WriteLine(line);
        }
    }
}

StreamReaderdetectEncodingFromByteOrderMarkstrueに設定すると、BOMを検出して自動的に正しいエンコーディングで読み込みます。

これにより、先頭行の文字化けを防げます。

Shift_JISファイルの読み込み

日本語環境で多く使われるShift_JIS(CP932)エンコーディングのCSVファイルは、StreamReaderで明示的に指定しないと文字化けします。

using System;
using System.IO;
using System.Text;
class Program
{
    static void Main()
    {
        string path = "shiftjis.csv";
        // Shift_JISで読み込む例
        using (var reader = new StreamReader(path, Encoding.GetEncoding("Shift_JIS")))
        {
            string line;
            while ((line = reader.ReadLine()) != null)
            {
                Console.WriteLine(line);
            }
        }
    }
}

Shift_JISは日本語の全角文字を含むため、UTF-8で読み込むと文字化けが発生します。

ファイルのエンコーディングを事前に確認し、適切に指定してください。

CultureInfoと数値・日時

CSVの数値や日時はロケールによって表記が異なり、読み込み時に誤認識されることがあります。

CsvHelperなどのライブラリではCultureInfoを指定して正しくパースすることが重要です。

小数点とカンマ区切り

欧米圏では小数点にピリオド.を使い、千の区切りにカンマ,を使います。

一方、日本や多くのヨーロッパ諸国では小数点にカンマを使う場合があります。

例えば、1,234.56(英語圏)と1.234,56(ドイツ語圏)は同じ数値を表しますが、区切り文字の意味が逆です。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string valueEn = "1,234.56";
        string valueDe = "1.234,56";
        double numberEn = double.Parse(valueEn, CultureInfo.GetCultureInfo("en-US"));
        double numberDe = double.Parse(valueDe, CultureInfo.GetCultureInfo("de-DE"));
        Console.WriteLine(numberEn); // 1234.56
        Console.WriteLine(numberDe); // 1234.56
    }
}

CSV読み込み時に適切なCultureInfoを指定しないと、数値のパースに失敗したり誤った値になることがあります。

日付フォーマットの差異

日付の表記もロケールによって異なります。

例えば、MM/dd/yyyy(米国)とdd/MM/yyyy(英国)では同じ数字でも意味が変わります。

using System;
using System.Globalization;
class Program
{
    static void Main()
    {
        string dateUs = "12/31/2023";
        string dateUk = "31/12/2023";
        DateTime dtUs = DateTime.ParseExact(dateUs, "MM/dd/yyyy", CultureInfo.GetCultureInfo("en-US"));
        DateTime dtUk = DateTime.ParseExact(dateUk, "dd/MM/yyyy", CultureInfo.GetCultureInfo("en-GB"));
        Console.WriteLine(dtUs.ToString("yyyy-MM-dd")); // 2023-12-31
        Console.WriteLine(dtUk.ToString("yyyy-MM-dd")); // 2023-12-31
    }
}

CSVの日時フィールドを正しく読み込むには、CultureInfoの指定やDateTime.ParseExactでフォーマットを明示的に指定することが推奨されます。

これらのエンコーディングとロケールの違いを理解し、適切に設定することで、CSVの読み込み時の文字化けやデータ誤認識を防ぎ、正確な配列変換が可能になります。

テストとデバッグ

CSVを配列に変換する処理は、データの多様性やファイルの形式によって動作が変わるため、テストとデバッグが非常に重要です。

ここではテスト用のサンプルCSVの準備から、ユニットテストの設計、そしてロギングとトレースの活用方法を解説します。

サンプルCSVの準備

テストを効果的に行うためには、様々なケースをカバーしたサンプルCSVファイルを用意することが重要です。

正常系と異常系のデータを用意し、想定されるあらゆるパターンを検証します。

正常系データ

正常系データは、仕様通りのフォーマットで問題なく処理できることを確認するためのデータです。

例えば、以下のようなCSVを用意します。

Name,Age,City
山田太郎,30,東京
佐藤花子,25,大阪
鈴木一郎,40,名古屋

このデータはヘッダーがあり、各フィールドが正しい形式で記述されています。

ユニットテストでは、このデータを読み込んで正しく配列に変換できるかを検証します。

異常系データ

異常系データは、エラーや例外が発生する可能性のあるケースを含みます。

以下のようなパターンを用意します。

  • フィールド数が異なる行
Name,Age,City
山田太郎,30,東京
佐藤花子,25
鈴木一郎,40,名古屋,ExtraField
  • 空のフィールドや空行
Name,Age,City
山田太郎,,東京
鈴木一郎,40,名古屋
  • 不正な文字コードやエンコーディングの違い
  • ダブルクォートの不整合
Name,Age,City
"山田太郎,30,東京
佐藤花子",25,大阪

これらの異常系データを使って、例外処理やエラーハンドリングが正しく機能しているかをテストします。

ユニットテスト設計

C#ではNUnitやxUnitなどのテストフレームワークを使ってユニットテストを実装します。

CSVの配列変換処理は、入力に対して期待される配列が返るか、例外が適切に発生するかを検証します。

NUnit

NUnitは歴史が長く、豊富な機能を持つテストフレームワークです。

以下はNUnitでCSVの配列変換をテストする例です。

using NUnit.Framework;
using System.IO;
[TestFixture]
public class CsvParserTests
{
    [Test]
    public void Parse_ValidCsv_ReturnsCorrectArray()
    {
        string csv = "Name,Age,City\n山田太郎,30,東京\n佐藤花子,25,大阪";
        var parser = new CsvParser();
        var result = parser.Parse(csv);
        Assert.AreEqual(2, result.Length);
        Assert.AreEqual("山田太郎", result[0][0]);
        Assert.AreEqual("30", result[0][1]);
        Assert.AreEqual("東京", result[0][2]);
    }
    [Test]
    public void Parse_InvalidCsv_ThrowsException()
    {
        string csv = "Name,Age,City\n山田太郎,30\n佐藤花子,25,大阪";
        var parser = new CsvParser();
        Assert.Throws<CsvParseException>(() => parser.Parse(csv));
    }
}

この例では、正常系と異常系のテストを分けて実装しています。

xUnit

xUnitは.NET Coreでよく使われる軽量なテストフレームワークです。

NUnitと似た構文ですが、属性名やアサーションの書き方が若干異なります。

using Xunit;
public class CsvParserTests
{
    [Fact]
    public void Parse_ValidCsv_ReturnsCorrectArray()
    {
        string csv = "Name,Age,City\n山田太郎,30,東京\n佐藤花子,25,大阪";
        var parser = new CsvParser();
        var result = parser.Parse(csv);
        Assert.Equal(2, result.Length);
        Assert.Equal("山田太郎", result[0][0]);
        Assert.Equal("30", result[0][1]);
        Assert.Equal("東京", result[0][2]);
    }
    [Fact]
    public void Parse_InvalidCsv_ThrowsException()
    {
        string csv = "Name,Age,City\n山田太郎,30\n佐藤花子,25,大阪";
        var parser = new CsvParser();
        Assert.Throws<CsvParseException>(() => parser.Parse(csv));
    }
}

xUnitは[Fact]属性でテストメソッドを示し、NUnitの[Test]に相当します。

ロギングとトレース

テストだけでなく、実際の運用環境でもログを活用して問題の原因を特定しやすくすることが重要です。

C#ではILoggerインターフェースを使ったロギングが標準的です。

ILoggerインターフェース

ILoggerはMicrosoft.Extensions.Logging名前空間にあり、ログレベルや出力先を柔軟に設定できます。

CSV処理の例外や警告をログに記録することで、デバッグや運用監視が容易になります。

using Microsoft.Extensions.Logging;
public class CsvProcessor
{
    private readonly ILogger<CsvProcessor> _logger;
    public CsvProcessor(ILogger<CsvProcessor> logger)
    {
        _logger = logger;
    }
    public void Process(string csv)
    {
        try
        {
            // CSV解析処理
        }
        catch (Exception ex)
        {
            _logger.LogError(ex, "CSV処理中にエラーが発生しました。");
            throw;
        }
    }
}

ログレベルはLogTraceLogDebugLogInformationLogWarningLogErrorLogCriticalがあり、用途に応じて使い分けます。

ロギングを組み込むことで、問題発生時に詳細な情報を取得でき、迅速な原因特定と対応が可能になります。

セキュリティ対策

CSVファイルを扱う際には、単にデータを読み書きするだけでなく、セキュリティ上のリスクにも注意を払う必要があります。

ここでは特に注意すべき「Excel数式インジェクション防止」「パストラバーサルの回避」「入力検証とサニタイズ」について解説します。

Excel数式インジェクション防止

Excel数式インジェクションは、CSVファイルのセルに悪意のある数式が埋め込まれ、ユーザーがそのCSVをExcelで開いた際に不正なコマンドが実行される攻撃手法です。

たとえば、セルの先頭に「=」「+」「-」「@」などの文字があると、Excelはそれを数式として解釈します。

対策方法

  • セルの先頭文字をチェックし、数式として解釈される可能性のある文字をエスケープまたは削除する

具体的には、セルの先頭が=, +, -, @のいずれかで始まる場合、先頭にシングルクォート'を付加するか、先頭文字を空白に置き換えます。

string SanitizeForExcel(string input)
{
    if (string.IsNullOrEmpty(input))
        return input;
    char firstChar = input[0];
    if (firstChar == '=' || firstChar == '+' || firstChar == '-' || firstChar == '@')
    {
        return "'" + input;
    }
    return input;
}
  • CSV出力時に全セルに対してこの処理を適用する

これにより、Excelで開いた際に数式として実行されるリスクを低減できます。

  • ユーザーに対してCSVファイルの取り扱い注意を促す

特に外部から受け取ったCSVを開く際は注意喚起を行うことも重要です。

パストラバーサルの回避

パストラバーサル攻撃は、ファイルパスの入力に不正な文字列(例:../..\)を含めることで、意図しないディレクトリのファイルにアクセスさせる攻撃です。

CSVファイルの読み込みや保存時にユーザーからのパス入力を受け付ける場合は特に注意が必要です。

対策方法

  • ファイルパスの正規化と検証

入力されたパスをPath.GetFullPathで絶対パスに変換し、許可されたディレクトリの範囲内にあるかをチェックします。

using System.IO;
bool IsSafePath(string baseDir, string inputPath)
{
    string fullPath = Path.GetFullPath(inputPath);
    return fullPath.StartsWith(Path.GetFullPath(baseDir));
}
  • ユーザー入力を直接ファイルパスとして使用しない

可能な限り、ファイル名のみを受け取り、ベースディレクトリと結合してパスを生成します。

  • ホワイトリスト方式のファイル名検証

許可されたファイル名や拡張子のみを受け付けるようにします。

入力検証とサニタイズ

CSVの内容は外部からの入力であることが多く、不正なデータが混入するリスクがあります。

SQLインジェクションやクロスサイトスクリプティング(XSS)などの攻撃を防ぐために、入力検証とサニタイズを行うことが重要です。

対策方法

  • 入力値の長さや形式を検証する

例えば、数値フィールドには数値以外が入っていないか、文字列フィールドの長さが適切かをチェックします。

  • 特殊文字のエスケープ

HTMLやJavaScriptに埋め込む場合は、XSS対策としてエスケープ処理を行います。

  • SQLクエリに直接埋め込まない

データベースに保存する際は、必ずパラメータ化クエリを使い、SQLインジェクションを防ぎます。

  • CSV出力時のサニタイズ

CSVの仕様に従い、フィールド内にカンマや改行、ダブルクォートが含まれる場合は適切にエスケープ(ダブルクォートで囲み、内部のダブルクォートは2つにする)します。

string EscapeCsvField(string field)
{
    if (field.Contains(",") || field.Contains("\"") || field.Contains("\n") || field.Contains("\r"))
    {
        field = field.Replace("\"", "\"\"");
        return $"\"{field}\"";
    }
    return field;
}

これにより、CSVのフォーマット破壊や意図しないデータ解釈を防止できます。

これらのセキュリティ対策を実装することで、CSVファイルを安全に扱い、悪意ある攻撃からシステムを守ることができます。

特にExcel数式インジェクションは見落とされがちなので、CSV出力時には必ず対策を行うことを推奨します。

便利な拡張メソッド集

C#でCSVを扱う際に役立つ拡張メソッドを用意しておくと、コードの可読性や再利用性が向上します。

ここでは、IEnumerable<string>からstring[]への変換、遅延評価で読み込むReadLines拡張、配列からCSV行を生成するメソッドを紹介します。

IEnumerable<string>からstring[]への変換

CSVの各行をIEnumerable<string>として扱うことがありますが、配列string[]に変換したい場合があります。

拡張メソッドを使うと簡潔に変換できます。

using System.Collections.Generic;
using System.Linq;
public static class EnumerableExtensions
{
    public static string[] ToArraySafe(this IEnumerable<string> source)
    {
        if (source == null)
        {
            return new string[0];
        }
        return source.ToArray();
    }
}

使用例

using System;
using System.Collections.Generic;
class Program
{
    static void Main()
    {
        IEnumerable<string> lines = new List<string> { "札幌", "東京", "名古屋" };
        string[] array = lines.ToArraySafe();
        foreach (var item in array)
        {
            Console.WriteLine(item);
        }
    }
}
札幌
東京
名古屋

この拡張メソッドはnullチェックも含むため、nullが渡された場合でも空配列を返し、例外を防ぎます。

遅延評価で読み込むReadLines拡張

大量のCSVファイルを扱う場合、一度に全行をメモリに読み込むのは非効率です。

IEnumerable<string>で遅延評価しながら1行ずつ読み込む拡張メソッドを作成すると便利です。

using System;
using System.Collections.Generic;
using System.IO;
public static class FileExtensions
{
    public static IEnumerable<string> ReadLinesLazy(this string filePath)
    {
        using (var reader = new StreamReader(filePath))
        {
            string line;
            while ((line = reader.ReadLine()) != null)
            {
                yield return line;
            }
        }
    }
}

使用例

using System;
class Program
{
    static void Main()
    {
        foreach (var line in "data.csv".ReadLinesLazy())
        {
            Console.WriteLine(line);
        }
    }
}

この方法はファイルのサイズに関係なく、必要な分だけ読み込むためメモリ効率が良く、大きなCSVファイルの処理に適しています。

配列からCSV行を生成

配列の文字列をCSV形式の1行に変換する拡張メソッドです。

フィールド内にカンマやダブルクォート、改行が含まれる場合は適切にエスケープします。

using System;
using System.Text;
public static class CsvExtensions
{
    public static string ToCsvLine(this string[] fields)
    {
        if (fields == null || fields.Length == 0)
            return string.Empty;
        var sb = new StringBuilder();
        for (int i = 0; i < fields.Length; i++)
        {
            string field = fields[i] ?? string.Empty;
            bool mustQuote = field.Contains(",") || field.Contains("\"") || field.Contains("\n") || field.Contains("\r");
            if (mustQuote)
            {
                field = field.Replace("\"", "\"\"");
                field = $"\"{field}\"";
            }
            sb.Append(field);
            if (i < fields.Length - 1)
                sb.Append(",");
        }
        return sb.ToString();
    }
}

使用例

using System;
class Program
{
    static void Main()
    {
        string[] fields = { "山田太郎", "30", "東京,新宿" };
        string csvLine = fields.ToCsvLine();
        Console.WriteLine(csvLine);
    }
}
山田太郎,30,"東京,新宿"

この拡張メソッドを使うと、配列の内容を正しいCSVフォーマットの1行に変換でき、CSVファイルへの書き込みが簡単になります。

これらの拡張メソッドを活用することで、CSVの読み書き処理がよりシンプルかつ安全に実装できます。

用途に応じてカスタマイズし、プロジェクトに組み込んでみてください。

よくあるエラーと解決策

CSVを配列に変換する際には、さまざまなエラーが発生することがあります。

ここでは代表的なエラーとその原因、解決策を具体的に解説します。

IndexOutOfRangeException

原因

IndexOutOfRangeExceptionは、配列やリストの範囲外のインデックスにアクセスした場合に発生します。

CSV処理でよくあるケースは、行ごとのフィールド数が異なり、想定したインデックスにフィールドが存在しない場合です。

例えば、3列のCSVを想定してfields[2]にアクセスしたが、実際にはその行が2列しかなかった場合に発生します。

解決策

  • フィールド数のチェックを行う

配列アクセス前にfields.Lengthを確認し、アクセス可能か判定します。

if (fields.Length > 2)
{
    var value = fields[2];
    // 処理
}
else
{
    // 不足している場合の処理(例: スキップ、デフォルト値設定)
}
  • 例外処理で安全に対応する

例外をキャッチしてログを残し、処理を継続する方法もあります。

  • CSVのフォーマットを事前に検証する

フィールド数が一定であることを保証できる場合は、読み込み前にチェックを行うと良いです。

MalformedLineException

原因

MalformedLineExceptionは、TextFieldParserCsvHelperなどのCSVパーサーで、行の形式が不正な場合に発生します。

例えば、ダブルクォートの閉じ忘れや、区切り文字の不整合が原因です。

解決策

  • CSVファイルの整合性を確認する

テキストエディタや専用ツールで不正な行を特定し修正します。

  • 例外処理で不正行をスキップする

読み込みループ内でtry-catchを使い、不正な行をログに記録してスキップします。

try
{
    string[] fields = parser.ReadFields();
    // 処理
}
catch (MalformedLineException ex)
{
    Console.WriteLine($"不正な行をスキップ: {ex.Message}");
}
  • 専用のCSV検証ツールを利用する

事前にCSVの妥当性をチェックするツールを使うのも有効です。

HeaderValidationException

原因

HeaderValidationExceptionは、CsvHelperでCSVのヘッダー行が期待した列名と一致しない場合に発生します。

例えば、マッピングしたクラスのプロパティ名とCSVのヘッダー名が異なる場合です。

解決策

  • マッピングの見直し

ClassMapや属性で正しいヘッダー名を指定します。

public class PersonMap : ClassMap<Person>
{
    public PersonMap()
    {
        Map(m => m.Name).Name("氏名");
        Map(m => m.Age).Name("年齢");
        Map(m => m.City).Name("出身地");
    }
}
  • ヘッダー検証を無効化する

ヘッダーの検証を無効にして読み込みを続行することも可能です。

csv.Configuration.HeaderValidated = null;
csv.Configuration.MissingFieldFound = null;
  • CSVファイルのヘッダーを修正する

CSVファイルのヘッダー行を期待する列名に合わせる方法もあります。

エンコーディング不一致

原因

CSVファイルの文字コードと読み込み時のエンコーディング指定が異なると、文字化けや読み込みエラーが発生します。

特に日本語を含むファイルで多い問題です。

解決策

  • 正しいエンコーディングを指定して読み込む

StreamReaderCsvReaderのコンストラクタで適切なEncodingを指定します。

using (var reader = new StreamReader("data.csv", Encoding.GetEncoding("Shift_JIS")))
{
    // 読み込み処理
}
  • BOM付きUTF-8ファイルの自動検出を有効にする

StreamReaderdetectEncodingFromByteOrderMarkstrueに設定します。

  • ファイルのエンコーディングを事前に確認する

テキストエディタやツールでエンコーディングを確認し、読み込み時に合わせます。

  • エンコーディング変換ツールを利用する

必要に応じてファイルのエンコーディングを変換してから処理する方法もあります。

これらのエラーはCSV処理で頻繁に遭遇しますが、原因を理解し適切に対処することで安定した処理が可能になります。

エラーハンドリングやログ出力を充実させ、問題発生時に迅速に対応できる体制を整えましょう。

実装チェックリスト

CSVを配列に変換する処理を実装する際に押さえておきたいポイントをチェックリスト形式でまとめました。

これらを順に確認することで、堅牢で効率的な実装が可能になります。

CSVフォーマットの仕様確認

  • [ ] 区切り文字(カンマ、タブ、セミコロンなど)を正しく把握している
  • [ ] ヘッダー行の有無を確認している
  • [ ] フィールド内のダブルクォートや改行の扱いを理解している

文字コード(エンコーディング)の指定

  • [ ] ファイルのエンコーディングを正しく指定して読み込んでいる
  • [ ] UTF-8 BOM付きファイルのBOM検出を有効にしている
  • [ ] Shift_JISやその他のエンコーディングに対応している

CSV読み込み方法の選択

  • [ ] 単純なString.Splitで十分か、専用ライブラリTextFieldParserCsvHelperを使うべきか判断している
  • [ ] フィールド内のカンマや改行を正しく処理できる方法を選択している

データの前後処理

  • [ ] フィールドのトリム(空白除去)を行っている
  • [ ] ダブルクォートの除去やエスケープ解除を適切に実装している
  • [ ] 空文字やnullの扱いを明確にしている

例外処理とエラーハンドリング

  • [ ] 不正な行やフィールド数の異常を検出し、適切にスキップまたはログ出力している
  • [ ] 例外発生時に処理が中断しないようにしている
  • [ ] 例外の種類に応じたハンドリングを実装している(例:MalformedLineExceptionIndexOutOfRangeExceptionなど)

パフォーマンス対策

  • [ ] 大容量ファイルに対応できるよう、遅延読み込みやストリーム処理を採用している
  • [ ] Span<char>ReadOnlySpan<char>を活用してメモリ効率を高めている
  • [ ] 並列処理や非同期I/Oを検討している
  • [ ] メモリプールを利用してGC負荷を軽減している

セキュリティ対策

  • [ ] Excel数式インジェクションを防ぐため、セルの先頭文字をサニタイズしている
  • [ ] ファイルパスのパストラバーサルを防止する検証を行っている
  • [ ] 入力値の検証とサニタイズを適切に実装している

ロケールと文化依存の考慮

  • [ ] 数値や日時のフォーマットに対応するため、CultureInfoを適切に指定している
  • [ ] 小数点やカンマ区切りの違いを考慮している
  • [ ] 日付フォーマットの差異に対応している

テストとデバッグ

  • [ ] 正常系・異常系のサンプルCSVを用意している
  • [ ] ユニットテストを実装し、主要な処理を網羅している
  • [ ] ロギングやトレースを組み込み、問題発生時に原因を特定しやすくしている

拡張性とメンテナンス性

  • [ ] 拡張メソッドやヘルパークラスを活用してコードの再利用性を高めている
  • [ ] コメントやドキュメントを充実させている
  • [ ] 将来的な仕様変更に対応しやすい設計にしている

このチェックリストを活用して、CSVの配列変換処理を段階的に実装・検証していくことで、トラブルを未然に防ぎ、安定したシステムを構築できます。

まとめ

この記事では、C#でCSVを配列に変換する基本的な方法から、String.SplitTextFieldParserCsvHelperの活用法まで幅広く解説しました。

エンコーディングやロケールの注意点、パフォーマンス最適化、セキュリティ対策、よくあるエラーの対処法も網羅しています。

これらを踏まえた実装チェックリストを活用することで、堅牢で効率的なCSV処理が実現できます。

初心者から実務者まで役立つ内容です。

関連記事

Back to top button