C#でCSVをネスト構造クラスに取り込む方法|外部ライブラリなしでActcode可変列に対応する実装パターン

C#でCSVを読み込んでネストしたクラス構造にきれいにマッピングしたい。外部ライブラリは禁止だけれど、Actcode列は何個あるか分からない――そんな現場あるあるの要件に対して、.NET標準ライブラリだけで実装する具体的な手順とサンプルコードを、簡易版と堅牢版の両方から詳しく解説します。

目次

CSVをC#のネスト構造クラスにマッピングしたいシナリオ

この記事で扱うシナリオは次のようなものです。

  • やりたいこと:CSVを読み込んで、ネスト構造のクラスにマッピングしたい
  • クラス構造:
クラス役割主なプロパティ
main11レコード全体を表すルートクラスname, val, Party (PartyDetails), acctlist (List<ACModel>)
PartyDetails住所情報のまとまりAddress1, City, State, Zip
ACModelアカウントコードの1件ActCode

入力となるCSVは、先頭に固定列があり、その後ろに Actcode で始まる列が任意個続くという少しクセのある形式です。

Name,Val,Address1,City,State,Zip,Actcode1,Actcode2,Actcode3,...
山田太郎,100,Tokyo 1-1,Chiyoda,Tokyo,1000001,101,102,
鈴木花子,200,Osaka 2-2,Kita,Osaka,5300001,201,,203
  • Actcode 列の数はCSVによって増えたり減ったりする
  • 外部のCSVパーサーライブラリは使わず、System.IO などの標準機能で完結させたい

こういった要件に対して、列の追加・削除・並び替えに強い「ヘッダー駆動」のマッピングを行うのが、実務上もっとも扱いやすい解決策です。

前提となるCSV形式とクラス構造

CSVの列構成とActcode可変列

今回想定するCSVのヘッダーは次のようなイメージです。

列名説明例
Name顧客名など、レコードの名前山田太郎
Val何らかの数値や文字列100
Address1住所1行目Tokyo 1-1
City市区町村Chiyoda
State都道府県Tokyo
Zip郵便番号1000001
Actcode*任意個続くアカウントコード列(Actcode1, Actcode2…)101, 102, 103…

Actcode 列は、列名が Actcode で始まればすべて対象にします。たとえば ActcodeA や Actcode_01 といった名前でも拾えるようにしておくと、後から列名が微妙に変わっても柔軟に対応できます。

クラス定義の例(main1 / PartyDetails / ACModel)

まずは、CSVをマッピングする対象となるクラス群を定義しておきます。質問の前提に合わせてクラス名はそのまま使いつつ、C#の慣習に合わせてパスカルケースのプロパティ名にしています。

using System;
using System.Collections.Generic;

public class main1
{
    public string name { get; set; } = string.Empty;
    public string val  { get; set; } = string.Empty;
    public PartyDetails Party { get; set; } = new PartyDetails();
    public List&lt;ACModel&gt; acctlist { get; set; } = new List&lt;ACModel&gt;();
}

public class PartyDetails
{
    public string Address1 { get; set; } = string.Empty;
    public string City     { get; set; } = string.Empty;
    public string State    { get; set; } = string.Empty;
    public string Zip      { get; set; } = string.Empty;
}

public class ACModel
{
    public int ActCode { get; set; }
}

クラスとCSV列の対応は次の表のようになります。

CSV列クラスプロパティ
Namemain1name
Valmain1val
Address1PartyDetailsAddress1
CityPartyDetailsCity
StatePartyDetailsState
ZipPartyDetailsZip
Actcode*ACModelActCode(acctlistに複数入る)

最終的には List<main1> としてすべての行を取得できるように実装していきます。

ヘッダー駆動で列インデックスを取得する設計

列数が可変で、さらに列順もCSVによって変わる可能性がある場合、「何列目がどの項目か」を固定で決め打ちするのは非常に危険です。

そこで、次のようなヘッダー駆動のマッピングを行います。

ステップ処理内容ポイント
11行目(ヘッダー)を読み込む区切り文字(カンマ or タブ)もこのタイミングで判定
2ヘッダー名から列インデックスを求めるName, Val, Address1 など
3Actcode で始まるすべての列を収集可変列に自然対応。StartsWith("Actcode") で判定
42行目以降を1行ずつ読み込み、ヘッダー情報をもとに取り出すヘッダー再出現行、空行などはスキップする
5Actcode列の値を int.TryParse で ACModel に変換変換できない値は無視 or ログ出力

この方式にしておくと、CSV側に列が追加された場合でも、Actcode 以外の列は無視されますし、列の順番が多少入れ替わってもコードの修正なしに動作し続けてくれます。

簡易版実装:File.ReadAllLines+SplitでCSVを取り込む

まずは前提のとおり、外部ライブラリを使わず、単純なCSV(引用なし、カンマ or タブ区切り)を処理する簡易版から見ていきます。

簡易版のサンプルコード

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

public static class CsvImporter
{
    public static List&lt;main1&gt; Parse(string filePath)
    {
        // 小さめのファイルを想定して一括読み込み
        var lines = File.ReadAllLines(filePath);
        if (lines.Length == 0) return new List&lt;main1&gt;();

        // 区切り文字を自動判定(タブがあればタブ優先、なければカンマ)
        char delim = lines[0].Contains('\t') ? '\t' : ',';

        // ヘッダー行を解析
        var headers = lines[0]
            .Split(delim)
            .Select(h =&gt; h.Trim())
            .ToArray();

        // 列名からインデックスを取得するローカル関数
        int GetIndex(string name) =&gt;
            Array.FindIndex(
                headers,
                h =&gt; string.Equals(h, name, StringComparison.OrdinalIgnoreCase)
            );

        int nameIdx  = GetIndex("Name");
        int valIdx   = GetIndex("Val");
        int addrIdx  = GetIndex("Address1");
        int cityIdx  = GetIndex("City");
        int stateIdx = GetIndex("State");
        int zipIdx   = GetIndex("Zip");

        // Actcode で始まる列のインデックスをすべて収集
        var actIdxs = headers
            .Select((h, i) =&gt; new { Header = h, Index = i })
            .Where(x =&gt; x.Header.StartsWith("Actcode", StringComparison.OrdinalIgnoreCase))
            .Select(x =&gt; x.Index)
            .ToArray();

        var result = new List&lt;main1&gt;();

        // データ行を処理
        for (int i = 1; i &lt; lines.Length; i++)
        {
            var line = lines[i];
            if (string.IsNullOrWhiteSpace(line)) continue;

            var cols = line.Split(delim);

            // 表全体をコピペしたときなどに、途中でヘッダーが再出現するケースをスキップ
            if (cols.Length &gt; 0 &amp;&amp;
                string.Equals(cols[0].Trim(), "Name", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            // 安全に列を取り出すローカル関数
            string Get(int idx) =&gt;
                (idx &gt;= 0 &amp;&amp; idx &lt; cols.Length) ? cols[idx].Trim() : string.Empty;

            var item = new main1
            {
                name = Get(nameIdx),
                val  = Get(valIdx),
                Party = new PartyDetails
                {
                    Address1 = Get(addrIdx),
                    City     = Get(cityIdx),
                    State    = Get(stateIdx),
                    Zip      = Get(zipIdx)
                },
                acctlist = new List&lt;ACModel&gt;()
            };

            // Actcode 列をすべて ACModel に変換
            foreach (var idx in actIdxs)
            {
                if (idx &lt; 0 || idx &gt;= cols.Length) continue;

                var s = cols[idx].Trim();
                if (string.IsNullOrEmpty(s)) continue;

                if (int.TryParse(s, out var code))
                {
                    item.acctlist.Add(new ACModel { ActCode = code });
                }
                // TryParse に失敗した値は無視。必要ならログを取る
            }

            result.Add(item);
        }

        return result;
    }
}

処理フローのポイント解説

  • 区切り文字の自動判定
    1行目にタブが含まれていればタブ区切り、そうでなければカンマ区切りとみなしています。Excelからの貼り付けを前提にする場合、この程度のゆるい判定でも意外と役に立ちます。
  • ヘッダーからのインデックス取得
    GetIndex ローカル関数で、列名からインデックスを引けるようにしています。StringComparison.OrdinalIgnoreCase を使うことで、name や NAME といった大文字小文字の揺れも吸収しています。
  • Actcode列の一括検出
    StartsWith("Actcode") を使うことで、Actcode1, Actcode2, Actcode_Extra のような列名をすべて拾えます。将来列が増えてもコードの修正は不要です。
  • ヘッダー再出現行のスキップ
    CSVが複数表のコピペだったり、途中でヘッダー行がもう一度出てきたりするケースを避けるため、1列目が Name の行はデータとして扱わないようにしています。
  • 列欠損へのフォールバック
    Get 関数では、インデックスが範囲外なら空文字列を返すようにしており、「列が足りない」ケースでも例外で落ちないようにしています。

TextFieldParserを使った堅牢版実装(引用・カンマ入りにも対応)

上記の簡易版は、「引用符で囲まれていない」「カンマを含むフィールドがない」など、シンプルなCSVを前提にしています。しかし実務では次のような行がよく登場します。

"山田, 太郎",100,"Tokyo 1-1, Chiyoda","Chiyoda","Tokyo",1000001,101,102,
  • 名前にカンマが含まれている
  • 住所にカンマが含まれている
  • これらをダブルクォートで囲っている

string.Split(',') ではこうしたケースを正しく扱えません。
そこで、.NET 標準に含まれる Microsoft.VisualBasic.FileIO.TextFieldParser を使うと、外部ライブラリなしで引用/カンマ入りのCSVにもかなり強くなります。

TextFieldParserのメリット

項目Splitとの違いメリット
引用符対応Splitは自前実装が必要HasFieldsEnclosedInQuotes = true で自動でいい感じに処理してくれる
カンマを含むフィールドSplitだとフィールドが分裂する引用で囲まれた範囲内のカンマを1フィールドとして扱う
ストリーミングFile.ReadAllLinesはメモリ一括ReadFields() で1行ずつ処理でき、大きなファイルでもメモリ効率が良い
標準ライブラリ外部ライブラリを追加する必要ありMicrosoft.VisualBasic アセンブリ内のクラスなので、NuGet追加なしで利用可能

TextFieldParser版のサンプルコード

先ほどと同じクラス構造に対して、TextFieldParserでCSVを読み込む例です。

using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.VisualBasic.FileIO;

public static class CsvImporterRobust
{
    public static List&lt;main1&gt; Parse(string filePath, char? delimiter = null)
    {
        var result = new List&lt;main1&gt;();

        using var parser = new TextFieldParser(filePath);
        parser.TextFieldType = FieldType.Delimited;

        // 区切り文字の指定(明示指定があればそれを優先)
        if (delimiter.HasValue)
        {
            parser.SetDelimiters(delimiter.Value.ToString());
        }
        else
        {
            // カンマ/タブ両対応にしておく
            parser.SetDelimiters(",", "\t");
        }

        parser.HasFieldsEnclosedInQuotes = true;

        // ヘッダー行の読み込み
        var headerFields = parser.ReadFields();
        if (headerFields is null || headerFields.Length == 0)
        {
            return result;
        }

        var headers = headerFields
            .Select(h =&gt; h.Trim())
            .ToArray();

        int GetIndex(string name) =&gt;
            Array.FindIndex(
                headers,
                h =&gt; string.Equals(h, name, StringComparison.OrdinalIgnoreCase)
            );

        int nameIdx  = GetIndex("Name");
        int valIdx   = GetIndex("Val");
        int addrIdx  = GetIndex("Address1");
        int cityIdx  = GetIndex("City");
        int stateIdx = GetIndex("State");
        int zipIdx   = GetIndex("Zip");

        var actIdxs = headers
            .Select((h, i) =&gt; new { Header = h, Index = i })
            .Where(x =&gt; x.Header.StartsWith("Actcode", StringComparison.OrdinalIgnoreCase))
            .Select(x =&gt; x.Index)
            .ToArray();

        while (!parser.EndOfData)
        {
            var cols = parser.ReadFields();
            if (cols is null || cols.Length == 0) continue;

            // ヘッダー再出現行をスキップ
            if (string.Equals(cols[0]?.Trim(), "Name", StringComparison.OrdinalIgnoreCase))
            {
                continue;
            }

            string Get(int idx) =&gt;
                (idx &gt;= 0 &amp;&amp; idx &lt; cols.Length) ? (cols[idx]?.Trim() ?? string.Empty) : string.Empty;

            var item = new main1
            {
                name = Get(nameIdx),
                val  = Get(valIdx),
                Party = new PartyDetails
                {
                    Address1 = Get(addrIdx),
                    City     = Get(cityIdx),
                    State    = Get(stateIdx),
                    Zip      = Get(zipIdx)
                },
                acctlist = new List&lt;ACModel&gt;()
            };

            foreach (var idx in actIdxs)
            {
                if (idx &lt; 0 || idx &gt;= cols.Length) continue;

                var s = cols[idx]?.Trim();
                if (string.IsNullOrEmpty(s)) continue;

                if (int.TryParse(s, out var code))
                {
                    item.acctlist.Add(new ACModel { ActCode = code });
                }
            }

            result.Add(item);
        }

        return result;
    }
}

この実装であれば、一般的なExcel由来のCSVに対してかなり頑強に動作してくれます。

実務でハマりやすいポイントとその回避策

ここからは、実装して実際に運用するときにハマりがちなポイントと、その回避策をまとめます。

空行・ヘッダー再出現行の扱い

パターン例推奨対応
空行行の内容が空、または空白のみstring.IsNullOrWhiteSpace でスキップ
ヘッダー再出現途中に Name,Val,Address1,... がもう一度出てくる1列目が Name の行をスキップする簡易対策が有効
サマリ行「合計」などの集計行1列目が数値以外など、ルールを決めてフィルタリング

列欠損・列追加への備え

CSV仕様は、意外とあっさり変わります。列が1つ増えたり、使っていない列が挟まれたり。そんなときに壊れないコードにしておくのが重要です。

  • 列欠損:ヘッダーに存在しない列名はインデックスが -1 になります。
    Get 関数内でインデックスチェックをしておけば、その列がなくても空文字で処理が続行できます。
  • 列追加:ヘッダー駆動であれば、新しい列が増えても既存処理はその列を単に無視します。
    Actcode のような特別扱いが必要な列は、StartsWith でまとめて拾っておくのが安全です。

数値変換の失敗とログ出力

Actcode に数値以外の値(「N/A」など)が紛れ込むこともあります。int.Parse を使うと例外で落ちてしまうので、必ず int.TryParse を使いましょう。

さらに、TryParseに失敗した値を無視して終わりにするのではなく、ログに吐いておくと後からデータクレンジングしやすくなります。

if (int.TryParse(s, out var code))
{
    item.acctlist.Add(new ACModel { ActCode = code });
}
else
{
    // 例:ログフレームワークがあれば warn で吐いておく
    // logger.LogWarning("Invalid Actcode value: {Value}", s);
}

日本語・文字コードの扱い

日本語環境では「文字化け」が地味にハマりどころです。特にWindows-31J(Shift_JIS系)で保存されたCSVを扱う場合は、エンコーディングを明示したほうが安全です。

  • File.ReadAllLines(filePath, Encoding.GetEncoding("shift_jis"))
  • new TextFieldParser(filePath, Encoding.GetEncoding("shift_jis"))

エンドユーザーがExcelで開いて保存し直す運用が想定されるなら、文字コードの想定をドキュメント化しておくと、トラブルを減らせます。

パフォーマンスと拡張性の工夫

大きなCSVファイルへの対応

数十MB〜数百MBクラスのCSVを扱う場合、File.ReadAllLines で一括読み込みするとメモリを圧迫する可能性があります。その場合は次のような工夫が考えられます。

  • File.ReadLines を使って1行ずつ読み込む(簡易版の場合)
  • TextFieldParser を使って1行ずつ ReadFields する(堅牢版の場合)
  • 1レコード処理ごとに途中結果をDBや別ファイルに書き出し、List<main1> を巨大にしすぎない

単純に List<main1> にどんどん詰め込むと、それ自体がメモリを食いつぶしてしまうことがあるため、「インポートしながらDBに流す」ようなストリーミング処理に切り替える設計も検討しましょう。

バリデーションルールを分離する

CSVインポートのコードに、すべての業務ルール(必須チェック、桁数チェック、桁あふれ、桁埋め…)をベタ書きし始めると、あっという間に保守不能になります。

おすすめは、次のように責務を分けることです。

層役割主な処理
CSVパース層文字列からクラスへマッピング区切り文字処理、ヘッダー解析、型変換(int.TryParse など)
バリデーション層業務ルールのチェック必須項目チェック、範囲チェック、パターンマッチ
アプリケーション層永続化や後続処理DB登録、ログ出力、エラー通知

この記事のサンプルは「CSVパース層」に相当する部分だけを書いていますが、実際のアプリケーションではこの上にバリデーション層を載せると、長期的にメンテナンスしやすくなります。

テストしやすい構造にしておく

最後に、CSVインポートはテストしておかないと、気づかないうちに仕様変更で壊れている……というパターンが多い処理です。テストしやすくするコツは、「文字列の行列」を渡して結果を返す純粋な関数にしておくことです。

たとえば、次のような形で責務を分けます。

// I/O を含まない純粋なパース関数
public static List&lt;main1&gt; ParseLines(IEnumerable&lt;string&gt; lines)
{
    // ここに、File.ReadAllLines で読み込んだ配列を前提としたロジックを書く
}

// 実運用では File.ReadAllLines や TextFieldParser で読み込んだものを渡す
public static List&lt;main1&gt; ParseFile(string filePath)
{
    var lines = File.ReadAllLines(filePath);
    return ParseLines(lines);
}

こうしておけば、ユニットテストでは string[] の配列を直接渡すだけで検証できるため、テストデータのバリエーションも増やしやすくなります。

まとめ:外部ライブラリなしでもCSVをネスト構造クラスにきれいに取り込める

ここまで、次のようなポイントで「外部ライブラリなしのCSV取り込み」を整理してきました。

  • ヘッダー行を解析し、列インデックスを動的に決める「ヘッダー駆動」の設計にする
  • Actcode のような可変列は、列名の StartsWith 判定でまとめて扱う
  • 簡易版としては File.ReadAllLines+Split でも十分動くが、引用・カンマ入りのCSVには弱い
  • より堅牢にするなら、標準ライブラリに含まれる TextFieldParser を活用する
  • 空行・ヘッダー再出現・列欠損・数値変換失敗など、実務でよくあるトラブルをあらかじめハンドリングしておくと運用が安定する
  • パフォーマンスやバリデーション、テストのしやすさも考えて責務を分割しておくと、長期運用に耐える実装になる

List<main1> のようなネスト構造のクラスにCSVをマッピングする処理は、一度しっかり設計しておけば別プロジェクトでも再利用しやすい「資産」になります。この記事のコードと考え方をベースに、現場の要件に合わせたCSVインポート基盤を組み立ててみてください。

この記事を書いた人

実務の現場で詰まりがちなポイントを地図にするITブログ「IT trip」を運営。Windows/Office(Teams・Excel)からSQL、サーバ運用、ガジェットまで、再現性のある手順と“なぜそうなるか”を丁寧に解説します。読んだらすぐ試せること、そして迷った人の次の一歩が見えることを大切にしています。

コメント

コメントする

目次