XMLファイルが33個あり、しかもファイルごとにタグ構造や項目名が違う――この状態で「一括でCSV化」はほぼ失敗します。鍵は、共通の列(共通フォーマット)を決め、各XMLをそこへマッピングして正規化すること。本記事では設計の考え方からC#実装例、欠損値・型変換の注意点、Excel/Power Queryでの代替案までまとめます。
なぜ「XML→CSVの一括変換」がうまくいかないのか
XMLは「タグでデータを表現する」という点では共通ですが、実務で受け取るXMLはベンダーやシステムごとに設計が違います。とくに、次の差分が混ざると単純な変換ツールでは同じCSVヘッダーに揃えられません。
- 同じ意味でも項目名が違う(例:UserID / RecordID / CustomerId)
- 階層が違う(同じ値が別階層にある、親子関係が逆、属性で持つ/要素で持つ)
- 繰り返し要素(明細行)がある/ない(1ファイル=1レコードとは限らない)
- 名前空間(namespace)が付いていて、単純なElement検索で取れない
- 日付・数値・通貨などの表現がバラバラ(YYYY/MM/DD、ISO 8601、カンマ小数など)
結論として、スキーマがバラバラなXMLを「一発で綺麗なCSVにする」ワンステップの解決策はほぼありません。代わりに、共通フォーマットを決めて正規化する設計にすると、多少の個別対応があっても、再現性のある統合処理にできます。
まず決めるべきはCSVの「共通フォーマット」:列設計のコツ
最初にやるべきは、CSVの列(ヘッダー)を決めることです。ポイントは「XMLに列を合わせる」のではなく、業務で使う意味(セマンティクス)に合わせて列を設計すること。全XMLに共通する項目だけに絞ると安定しますが、後工程で必要な項目が欠けることもあるため、次の3層で考えるのが現実的です。
| 層 | 内容 | 例 | メリット | 注意点 |
|---|---|---|---|---|
| 必須列 | 必ず出したい・欠けると困る | ID、日付、取引種別 | 分析・突合が安定 | 欠損時の扱いを必ず決める |
| 推奨列 | あれば便利、なくても処理は止めない | 氏名、部署、金額、通貨 | 利用価値が高い | スキーマ差が出やすい |
| 拡張列 | ソース固有だが残したい | VendorCode、CustomField* | 後から要求が来ても対応しやすい | 列が膨らみやすい |
33個のXMLを「とにかく全部列にする(全項目ユニオン)」は、後で扱いにくいCSVになりがちです。まずは必須列・推奨列を固め、拡張列は「必要になったら足す」ほうが運用しやすいです。
列名の揺れを吸収する:項目名マッピングの作り方
スキーマ差でよくあるのが「同じ意味だが名前が違う」です。ここはif文を増やすより、意味→候補名の対応表(マッピング)として管理すると保守が楽になります。
| CSVの共通列 | 意味 | XML側で出てきがちな項目名(例) |
|---|---|---|
| ID | レコードの識別子 | UserID / RecordID / CustomerId / Id |
| Name | 人名・顧客名など | Name / FullName / CustomerName / Person |
| Date | 発生日・登録日 | Date / RegistDate / CreatedAt / Timestamp |
| Amount | 金額 | Amount / Price / Total / Charge |
| Currency | 通貨 | Currency / Curr / CurrencyCode |
注意点は、名前が似ていても意味が違う場合があることです。最初の数件は目視で突合し、「このスキーマではIDはここ」まで決めてから自動化すると事故が減ります。
欠損項目・複数値・不正値をどう扱うかを先に決める
統合CSVは「後工程が読みやすい」ことが大切です。そのため、実装に入る前に欠損や例外の扱いをルール化しておきます。おすすめは“止める/埋める/退避する”の3択で整理することです。
| 状況 | 推奨対応 | CSVへの出力 | ログ・別出力 |
|---|---|---|---|
| 必須列が欠損 | 処理は継続しつつ、後で調査できる形にする | 空欄または特定値(例:N/A) | エラー一覧CSVに記録(SourceFileと行キー必須) |
| 推奨列が欠損 | 空欄でOK | 空欄 | 必要に応じて警告 |
| 複数値(繰り返し要素) | 要件に合わせて「行を増やす」か「1行に畳む」 | 明細は複数行、メモは結合など | 変換ルールをドキュメント化 |
| 型変換できない | 元文字列を残して、正規化列は空欄にする | 数値・日付列は空欄 | Raw列またはエラー一覧に元値を保存 |
現場では「C#で一旦統合して、最後はExcelで微修正」という運用も珍しくありません。ただし、その手作業の量は、ここでルールを固めておくほど減らせます。
スキーマが違うなら、変換ロジックも分ける:設計の現実解
「全部同じコードで読む」発想を捨て、スキーマ単位で読み取り・正規化を用意します。おすすめの手順は次の通りです。
- 33個のXMLを眺め、ルート要素・名前空間・主要タグでグルーピングする
- グループ(=スキーマ)ごとに「取りたい意味(ID/日付/金額…)」がどこにあるか決める
- スキーマごとにコンバーター(変換クラス)を作り、共通モデルへ変換する
- 共通モデルの一覧をCSVへ書き出す
33個すべてが完全に別物とは限らず、実際には「似たXMLが数種類ある」ケースが多いです。最初のグルーピングを丁寧にやるほど、以降の実装が速くなります。
C#で実装する場合の定番パターン
ここでは「スキーマごとに抽出 → 共通モデルへ正規化 → CSVへ出力」という流れを、保守しやすい形で組み立てます。サンプルは概念を示すための骨格なので、実際のタグ名や階層に合わせて調整してください。
共通モデル(CSVの1行)をクラスで表す
後で原因調査できるように、SourceFile(元ファイル名)やSchema(スキーマ種別)を持たせるのがポイントです。さらに、ソース固有の値を残したい場合は拡張領域(Extra)を用意します。
public sealed class CommonRecord
{
public string Id { get; set; } = "";
public string Name { get; set; } = "";
public DateTime? Date { get; set; }
public decimal? Amount { get; set; }
public string Currency { get; set; } = "";
// 追跡用(後で原因調査しやすい)
public string SourceFile { get; set; } = "";
public string Schema { get; set; } = "";
// ソース固有の値を残したい場合(拡張列)
public Dictionary<string, string> Extra { get; } = new();
}
スキーマごとの変換を分離する(Strategyパターン)
スキーマ差をif文で増やし続けると破綻しやすいので、スキーマごとに変換クラスを分けます。判定(CanHandle)と変換(Convert)をセットにすると見通しが良くなります。
using System.Xml.Linq;
public interface IXmlConverter
{
string SchemaName { get; }
bool CanHandle(XElement root);
IEnumerable<CommonRecord> Convert(XDocument doc, string sourceFile);
}
コンバーターの選択とマージは次のようなイメージです。
using System.Globalization;
using System.Text;
using System.Xml.Linq;
var converters = new IXmlConverter[]
{
new SchemaAConverter(),
new SchemaBConverter(),
// ...必要なだけ追加
};
var records = new List<CommonRecord>();
foreach (var file in Directory.EnumerateFiles(inputDir, "*.xml"))
{
XDocument doc;
try
{
doc = XDocument.Load(file);
}
catch (Exception ex)
{
Log($"Parse failed: {Path.GetFileName(file)} - {ex.Message}");
continue;
}
var root = doc.Root;
if (root is null)
{
Log($"No root: {Path.GetFileName(file)}");
continue;
}
var conv = converters.FirstOrDefault(c => c.CanHandle(root));
if (conv is null)
{
Log($"Unknown schema: {Path.GetFileName(file)} / root={root.Name}");
continue;
}
records.AddRange(conv.Convert(doc, Path.GetFileName(file)));
}
スキーマAの例:要素名が素直なケース
要素の階層がシンプルな場合はLINQ to XML(XDocument)で十分です。
using System.Globalization;
using System.Xml.Linq;
public sealed class SchemaAConverter : IXmlConverter
{
public string SchemaName => "SchemaA";
public bool CanHandle(XElement root)
=> root.Name.LocalName == "Users";
public IEnumerable<CommonRecord> Convert(XDocument doc, string sourceFile)
{
var root = doc.Root!;
foreach (var user in root.Elements().Where(e => e.Name.LocalName == "User"))
{
yield return new CommonRecord
{
Id = (string?)user.Element("UserID") ?? "",
Name = (string?)user.Element("Name") ?? "",
Date = TryParseDate((string?)user.Element("CreatedAt")),
Amount = TryParseDecimal((string?)user.Element("Amount")),
Currency = (string?)user.Element("Currency") ?? "",
SourceFile = sourceFile,
Schema = SchemaName
};
}
}
private static DateTime? TryParseDate(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
return DateTime.TryParse(s, out var dt) ? dt : null;
}
private static decimal? TryParseDecimal(string? s)
{
if (string.IsNullOrWhiteSpace(s)) return null;
// まずはInvariantCultureで試す(小数点が.のケースに強い)
if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.InvariantCulture, out var d))
return d;
// 次にCurrentCulture(カンマ小数など)を試す
if (decimal.TryParse(s, NumberStyles.Any, CultureInfo.CurrentCulture, out d))
return d;
return null;
}
}
名前空間(namespace)があるXMLの読み方
ハマりやすいのが名前空間付きXMLです。要素名が同じでも、名前空間が違うとElement("UserID")では取れません。基本は、XMLのルートから名前空間を取得してXNamespaceで結合します。
var root = doc.Root!;
XNamespace ns = root.Name.Namespace;
var id = (string?)root.Element(ns + "UserID");
「同じスキーマでも名前空間が揺れる」ような場合は、性能と安全性のバランスを見て、LocalNameで探索する方法もあります(ただし大きいXMLでは遅くなりやすい)。
var id = doc.Descendants()
.FirstOrDefault(e => e.Name.LocalName == "UserID")
?.Value;
明細(繰り返し要素)をCSVに落とす設計:1行を何にするか
XMLには「ヘッダー情報+明細の繰り返し」という構造が多くあります。この場合、CSVの1行を「明細1行」にすると扱いやすいことが多いです。ヘッダー項目は明細行に繰り返して持たせ、明細番号(LineNo)を追加します。
| 設計 | CSVの1行 | 向いている用途 | 注意点 |
|---|---|---|---|
| 明細行ベース | 注文(ヘッダー)+商品(明細) | 集計、BI、突合 | ヘッダーが繰り返しで冗長になる |
| ヘッダーベース | 注文1件=1行(明細は結合) | 一覧表、簡易閲覧 | 明細が長いと列/セルが壊れやすい |
「何を1行にするか」が決まると、コンバーターの実装も迷いにくくなります。要件が曖昧なときは、まず明細行ベースで作り、必要ならヘッダーベースを別CSVとして追加するのが安全です。
CSV出力でハマりやすいポイント
- 文字コード:Excelで開く前提ならUTF-8(BOMあり)を検討。文字化け対策になります。
- 改行・カンマ・ダブルクォート:フィールドに改行やカンマが入るとCSVが崩れます。手書き実装よりライブラリ利用が安全です。
- 日付・数値のフォーマット:CSVは文字列なので、後工程で安定する表記に統一します(例:日付は
yyyy-MM-dd、日時はISO 8601)。 - 列順:列を動的に増やす場合でも、列順を固定すると差分比較や再処理が楽になります。
C#なら、CSV周りはCsvHelperなどの実績あるライブラリを使うと安心です。固定列だけ出すならWriteRecordsで簡単ですが、拡張列(Extra)も出したい場合はヘッダーを自前で組むのが分かりやすいです。
using CsvHelper;
using System.Globalization;
using System.Text;
var baseHeader = new[] { "ID", "Name", "Date", "Amount", "Currency", "SourceFile", "Schema" };
var extraKeys = records.SelectMany(r => r.Extra.Keys)
.Distinct()
.OrderBy(k => k)
.ToList();
using var writer = new StreamWriter(outputPath, false, new UTF8Encoding(encoderShouldEmitUTF8Identifier: true));
using var csv = new CsvWriter(writer, CultureInfo.InvariantCulture);
// ヘッダー
foreach (var h in baseHeader) csv.WriteField(h);
foreach (var k in extraKeys) csv.WriteField(k);
csv.NextRecord();
// 本文
foreach (var r in records)
{
csv.WriteField(r.Id);
csv.WriteField(r.Name);
csv.WriteField(r.Date?.ToString("yyyy-MM-dd") ?? "");
csv.WriteField(r.Amount?.ToString(CultureInfo.InvariantCulture) ?? "");
csv.WriteField(r.Currency);
csv.WriteField(r.SourceFile);
csv.WriteField(r.Schema);
foreach (var k in extraKeys)
csv.WriteField(r.Extra.TryGetValue(k, out var v) ? v : "");
csv.NextRecord();
}
大きなXMLでも落ちないようにする:XmlReaderでストリーミング
33ファイルでも、1ファイルが大きいとXDocument.Loadでメモリが厳しくなることがあります。その場合はXmlReaderでストリーミング処理にすると、読みながらCSVへ書けます(「全レコードをListに溜めない」構成にできるのが強みです)。
using System.Xml;
using var reader = XmlReader.Create(file);
while (reader.Read())
{
// 例:<Item> 開始を見つけたら、その要素だけ読み込む
if (reader.NodeType == XmlNodeType.Element && reader.Name == "Item")
{
var item = XElement.ReadFrom(reader) as XElement;
if (item is null) continue;
var record = new CommonRecord
{
Id = (string?)item.Element("RecordID") ?? "",
// ...必要な項目を抽出
};
// ここで即CSVに書き出す(recordsに溜めない)
}
}
ストリーミングは実装難度が少し上がりますが、「落ちない」「速い」「途中失敗でも再開しやすい」メリットがあります。運用で何度も回す処理なら、早めに検討する価値があります。
Excel / Power Queryでやる場合の現実的な落としどころ
プログラムを書かずに済ませたい場合、ExcelのPower Query(取得と変換)でXMLを読み込んで整形し、クエリを結合(Append)して1つのテーブルにする方法もあります。ただし、スキーマ差が大きいと結局は「スキーマごとにクエリを作る」ことになり、運用負荷が増えます。
| やり方 | 向いているケース | 弱いケース | ポイント |
|---|---|---|---|
| Excel / Power Query | スキーマ差が小さい、ファイル数が少ない、まず試したい | 階層が深い、明細が複雑、名前空間が混在、繰り返し運用 | 最終的に「共通列だけ」に揃えてから結合すると安定 |
| C#(専用変換) | スキーマ差が大きい、定期的に回す、ログや例外処理が必要 | 初期実装の時間が取れない | コンバーター分離+共通モデルで保守しやすくする |
時間がない場合は「Power Queryで叩き台を作り、足りない部分はC#で埋める」「C#で統合し、最後の微修正だけExcel」など、併用が最も現実的です。
運用を楽にする:マッピングを設定ファイル化する
スキーマが増えたり、項目名が変わったりする環境では、マッピングをコードに埋め込むと改修が大変です。そこで、マッピングをJSONなどの設定に寄せると変更に強くなります。階層が深い場合はXPathで指定すると管理しやすいです。
{
"SchemaA": {
"root": "Users",
"recordPath": "/Users/User",
"fields": {
"ID": "UserID",
"Name": "Name",
"Date": "CreatedAt",
"Amount": "Amount",
"Currency": "Currency"
}
},
"SchemaB": {
"root": "Records",
"recordPath": "/Records/Record",
"fields": {
"ID": "RecordID",
"Name": "CustomerName",
"Date": "Timestamp",
"Amount": "Total"
}
}
}
もちろん、すべてを設定化できるわけではありません(複雑な条件分岐、明細展開、値の計算などはコードが必要です)。それでも、「項目の場所」だけでも設定に逃がすと、保守コストが大きく下がります。
品質を担保するためのチェックリスト
- スキーマ判定が誤判定しないか(root名だけでなくnamespaceや必須要素も見る)
- 必須列の欠損がどれくらい発生しているか(変換後に集計して把握)
- 日付・数値の正規化ルールが決まっているか(出力フォーマットを固定)
- 明細の扱い(行を増やす/畳む)が要件に合っているか
- 例外時のログ(SourceFile、スキーマ名、キー、エラー内容)が残るか
- 再実行のしやすさ(途中失敗時にどこまで出たか分かる、追記か作り直しかを決める)
まとめ:ワンステップ解はないが、設計すれば再現性は作れる
異なるスキーマのXMLを1つのCSVに統合する本質は、「共通フォーマットを定義し、各XMLをそこへマッピングして正規化する」ことです。スキーマがバラバラな以上、多少の個別対応は避けられません。しかし、スキーマ判定と変換ロジックを分離し、欠損や型変換のルールを先に決めておけば、手作業を最小化しながら安定運用できます。最初は小さく作り、必要な列とスキーマを順に増やしていくのが最短ルートです。

コメント