C#でCSVを読み込んでネストしたクラス構造にきれいにマッピングしたい。外部ライブラリは禁止だけれど、Actcode列は何個あるか分からない――そんな現場あるあるの要件に対して、.NET標準ライブラリだけで実装する具体的な手順とサンプルコードを、簡易版と堅牢版の両方から詳しく解説します。
CSVをC#のネスト構造クラスにマッピングしたいシナリオ
この記事で扱うシナリオは次のようなものです。
- やりたいこと:CSVを読み込んで、ネスト構造のクラスにマッピングしたい
- クラス構造:
| クラス | 役割 | 主なプロパティ |
|---|---|---|
| main1 | 1レコード全体を表すルートクラス | 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<ACModel> acctlist { get; set; } = new List<ACModel>();
}
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列 | クラス | プロパティ |
|---|---|---|
| Name | main1 | name |
| Val | main1 | val |
| Address1 | PartyDetails | Address1 |
| City | PartyDetails | City |
| State | PartyDetails | State |
| Zip | PartyDetails | Zip |
| Actcode* | ACModel | ActCode(acctlistに複数入る) |
最終的には List<main1> としてすべての行を取得できるように実装していきます。
ヘッダー駆動で列インデックスを取得する設計
列数が可変で、さらに列順もCSVによって変わる可能性がある場合、「何列目がどの項目か」を固定で決め打ちするのは非常に危険です。
そこで、次のようなヘッダー駆動のマッピングを行います。
| ステップ | 処理内容 | ポイント |
|---|---|---|
| 1 | 1行目(ヘッダー)を読み込む | 区切り文字(カンマ or タブ)もこのタイミングで判定 |
| 2 | ヘッダー名から列インデックスを求める | Name, Val, Address1 など |
| 3 | Actcode で始まるすべての列を収集 | 可変列に自然対応。StartsWith("Actcode") で判定 |
| 4 | 2行目以降を1行ずつ読み込み、ヘッダー情報をもとに取り出す | ヘッダー再出現行、空行などはスキップする |
| 5 | Actcode列の値を 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<main1> Parse(string filePath)
{
// 小さめのファイルを想定して一括読み込み
var lines = File.ReadAllLines(filePath);
if (lines.Length == 0) return new List<main1>();
// 区切り文字を自動判定(タブがあればタブ優先、なければカンマ)
char delim = lines[0].Contains('\t') ? '\t' : ',';
// ヘッダー行を解析
var headers = lines[0]
.Split(delim)
.Select(h => h.Trim())
.ToArray();
// 列名からインデックスを取得するローカル関数
int GetIndex(string name) =>
Array.FindIndex(
headers,
h => 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) => new { Header = h, Index = i })
.Where(x => x.Header.StartsWith("Actcode", StringComparison.OrdinalIgnoreCase))
.Select(x => x.Index)
.ToArray();
var result = new List<main1>();
// データ行を処理
for (int i = 1; i < lines.Length; i++)
{
var line = lines[i];
if (string.IsNullOrWhiteSpace(line)) continue;
var cols = line.Split(delim);
// 表全体をコピペしたときなどに、途中でヘッダーが再出現するケースをスキップ
if (cols.Length > 0 &&
string.Equals(cols[0].Trim(), "Name", StringComparison.OrdinalIgnoreCase))
{
continue;
}
// 安全に列を取り出すローカル関数
string Get(int idx) =>
(idx >= 0 && idx < 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<ACModel>()
};
// Actcode 列をすべて ACModel に変換
foreach (var idx in actIdxs)
{
if (idx < 0 || idx >= 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<main1> Parse(string filePath, char? delimiter = null)
{
var result = new List<main1>();
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 => h.Trim())
.ToArray();
int GetIndex(string name) =>
Array.FindIndex(
headers,
h => 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) => new { Header = h, Index = i })
.Where(x => x.Header.StartsWith("Actcode", StringComparison.OrdinalIgnoreCase))
.Select(x => 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) =>
(idx >= 0 && idx < 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<ACModel>()
};
foreach (var idx in actIdxs)
{
if (idx < 0 || idx >= 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<main1> ParseLines(IEnumerable<string> lines)
{
// ここに、File.ReadAllLines で読み込んだ配列を前提としたロジックを書く
}
// 実運用では File.ReadAllLines や TextFieldParser で読み込んだものを渡す
public static List<main1> 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インポート基盤を組み立ててみてください。

コメント