XML から JSON への変換:規約・落とし穴・コード例
SOAP エンドポイント、RSS フィード、あるいは sitemap.xml からレスポンスを取ってくると、それが XML だった。だがこちらのスタックは JSON が前提だ。フロントエンドは JavaScript、その間は REST、底にはドキュメントストアが据わっている。そこで XML を JSON に変換する必要が出てくるわけだが、パーサーに渡せば一行で済むだろうと高をくくる。
たいていは実際にそうなる。出力に足をすくわれるまでは。配列だと思っていたものが単一のオブジェクトになっている。id 属性が消えている。01234 のような郵便番号が数値の 1234 で返ってくる。これらはどれもパーサーのバグではない。かみ合わない 2 つのデータモデルをマッピングした結果であり、XML を確実に JSON へ変換する唯一の道は、その溝を埋める規約を理解することだ。
本ガイドでは、なぜそうした規約が存在するのか、変換の 4 つの方法(ブラウザ、JavaScript、Python、CLI)、主要なライブラリすべてが共有する @_ と #text のルール、サイレントにデータを失わせる 5 つの落とし穴、そしてきれいなラウンドトリップのために JSON を XML へ戻す方法を扱う。例はすべて実物で実行可能だ。Node、Python、シェルに貼り付ければ、コメントに示したとおりの出力が得られる。
XML から JSON への変換に規約が要る理由(単なる再整形ではない)
XML と JSON は表面的には似ている。どちらも名前付きの入れ子データからなるツリーだ。しかし根底にあるモデルは、無視できないところで分かれている。XML の要素は属性を持て、混在コンテンツ(テキストと子要素が交互に現れるもの)を保持でき、名前空間の下に置ける。JSON にはそうした概念がひとつもない。あるのはオブジェクト、配列、そして 4 つのスカラー型だけだ。だから一方をもう一方に変換する作業は、片方にしか綴れない単語を持つ 2 つの文法を、あいだに立って翻訳することに近い。
何かを変換する前に、ソースが本当に妥当かどうかを確認しておくのが得策だ。エスケープし忘れた & ひとつ、あるいはタグの不整合があれば、パーサーの段階ではじかれる。そこで、まず入力を XMLフォーマッター に通して整形式(well-formed)かを確かめておけば、わけのわからないエラーをひと巡り見ずに済む。
ここが、2 つのモデルが引き裂かれる地点だ。
| 観点 | XML | JSON |
|---|---|---|
| ノード型 | 要素、属性、テキスト、混在コンテンツ | オブジェクト、配列、文字列、数値、真偽値、null |
| ルートの制約 | ルート要素がちょうど 1 つ必須 | ルートの制約なし |
| 属性 | あり(id="P01") | なし(@_ 規約が必要) |
| 繰り返し要素 | 同名の兄弟要素が合法 | オブジェクトのキーは重複不可(配列規約が必要) |
| 型システム | テキストは型なし — すべて文字列 | ネイティブ型 |
| 名前空間 | あり(xmlns) | なし |
モデルが一致しないので、XML から JSON への変換はどうしても規約に頼ることになる。とはいえ、その規約は恣意的なものではない。fast-xml-parser(Node.js)、xmltodict(Python)、JAXB(Java)はいずれも同じ 2 つのマーカーに落ち着いた。属性には @_、混在コンテンツのテキストには #text だ。一度覚えてしまえば、ランタイムをまたいで通用する。データの形状の不一致は他の変換でも起きる問題で、たとえばCSV と JSON の相互変換ガイドで扱った型推論の話も根は同じだ。
XML を JSON に変換する方法:4 つの手段
自分の状況に合う方法を選ぼう。一度きりの素早い貼り付けか、Node サービスか、Python のパイプラインか、CI のシェルスクリプトか。
方法 1 — ブラウザベースのツール(セットアップ不要・プライバシー優先)
一度きりの変換、あるいはどこの馬の骨ともしれないサイトに貼り付けたくない XML には、ブラウザ内変換ツールが最速の道だ。XML を XML to JSON 変換ツール に貼り付ければ、JSON が即座に現れる。インストールもアカウントもアップロードも要らない。すべてはブラウザの JavaScript エンジンの中で動くので、データはマシンから出ていかない。
この最後の点は、聞こえる以上に重要だ。SOAP エンベロープは WS-Security トークンを運び、内部の設定ファイルは接続文字列を運び、エクスポートデータは顧客レコードを運ぶ。何も送信されないからこそ、このツールは資格情報や機微なペイロードを含む XML にも安全だ。自分で確かめることもできる。Network タブを開いて、変換するあいだリクエストがひとつも飛ばないのを見ればいい。
方法 2 — JavaScript / Node.js(fast-xml-parser)
Node では fast-xml-parser が定番の選択肢だ。とはいえデフォルトには驚かされる。属性は無視され、値は型変換されてしまう。だから以下のオプションが、忠実な変換のために本当に欲しい設定になる。
// Convert XML to JSON in Node.js using fast-xml-parser
import { XMLParser } from 'fast-xml-parser';
const xml = `<catalog>
<product id="P01">
<name>Wireless Headphones</name>
<price currency="USD">79.99</price>
</product>
</catalog>`;
const parser = new XMLParser({
ignoreAttributes: false, // keep attributes (default drops them!)
attributeNamePrefix: '@_', // attributes become @_-prefixed keys
textNodeName: '#text', // mixed-content text goes under #text
parseAttributeValue: false, // no type coercion on attributes
parseTagValue: false, // no type coercion on element text
});
const result = parser.parse(xml);
console.log(JSON.stringify(result, null, 2));
// {
// "catalog": {
// "product": {
// "@_id": "P01",
// "name": "Wireless Headphones",
// "price": {
// "@_currency": "USD",
// "#text": "79.99"
// }
// }
// }
// }
人がよく忘れる 2 つの設定が、ignoreAttributes: false と parseTagValue: false だ。前者は id と currency の属性を残す。後者はパーサーが "79.99" を浮動小数点数に、"01234" を 1234 に変えてしまうのを止める。文字列の保持がなぜ安全なデフォルトなのかは、落とし穴の節で改めて取り上げる。
ブラウザで依存ゼロにしたいなら、ネイティブの DOMParser がパースを肩代わりしてくれる。あとは自分で DOM を歩いていく。
// Zero-dependency XML to JSON in the browser using DOMParser
function xmlToJson(node) {
// Text-only element → string value
const children = Array.from(node.children);
if (children.length === 0 && node.attributes.length === 0) {
return node.textContent.trim();
}
const obj = {};
// Attributes → @_ prefix
for (const attr of node.attributes) {
obj['@_' + attr.name] = attr.value;
}
// Element with attributes AND text → #text
if (children.length === 0) {
obj['#text'] = node.textContent.trim();
return obj;
}
// Recurse into children, collecting same-named siblings into arrays
for (const child of children) {
const value = xmlToJson(child);
if (obj[child.tagName] === undefined) {
obj[child.tagName] = value;
} else {
if (!Array.isArray(obj[child.tagName])) obj[child.tagName] = [obj[child.tagName]];
obj[child.tagName].push(value);
}
}
return obj;
}
const doc = new DOMParser().parseFromString(
'<catalog><product id="P01"><name>Wireless Headphones</name></product></catalog>',
'text/xml'
);
const json = { [doc.documentElement.tagName]: xmlToJson(doc.documentElement) };
console.log(JSON.stringify(json, null, 2));
// { "catalog": { "product": { "@_id": "P01", "name": "Wireless Headphones" } } }
DOMParser は XML 1.0 準拠で、CDATA とエンティティ参照を処理し、整形式エラーを報告する。しかもパッケージのインストールは一切不要だ。引き換えに、上で示した配列収集のルールを含め、走査ロジックは自分の責任になる。
方法 3 — Python(xmltodict)
Python では、xmltodict が作業全体を短いパイプラインへ畳み込んでくれる。デフォルトでは属性の接頭辞に @、混在コンテンツに #text を使う。
# Convert XML to JSON in Python using xmltodict
import json
import xmltodict
xml = """<catalog>
<product id="P01">
<name>Wireless Headphones</name>
<price currency="USD">79.99</price>
</product>
</catalog>"""
data = xmltodict.parse(xml)
print(json.dumps(data, indent=2))
# {
# "catalog": {
# "product": {
# "@id": "P01",
# "name": "Wireless Headphones",
# "price": {
# "@currency": "USD",
# "#text": "79.99"
# }
# }
# }
# }
デフォルトで xmltodict はすべての値を文字列のまま保つ。これこそ欲しい挙動だ。先に知っておく価値のある唯一のオプションが force_list で、これは「単一か複数か」の配列問題を、コードに届く前に解決してくれる。
# force_list guarantees <product> is always a list, even when there is one
data = xmltodict.parse(xml, force_list={'product'})
products = data['catalog']['product'] # always a list now
for p in products:
print(p['name'])
force_list がなければ、<product> が 1 つだと辞書(dict)になり、2 つだとリストになる。そして単一要素のケースでループが落ちる。これが落とし穴その 1 で、下で取り上げる。
方法 4 — CLI(yq / Python のワンライナー)
シェルスクリプトや CI パイプラインには、2 つのワンライナーがたいていの場合をカバーする。Mike Farah の yq は XML を読んで JSON を直接出力する。
# Using yq (Mike Farah's Go version)
yq -p=xml -o=json '.' input.xml
# Pipe from stdin
cat sitemap.xml | yq -p=xml -o=json '.'
環境にすでに xmltodict が入っているなら、Python のワンライナーは追加のバイナリを必要としない。
python3 -c "import sys, xmltodict, json; print(json.dumps(xmltodict.parse(sys.stdin.read()), indent=2))" < input.xml
どちらも stdin からストリームするので、そのままパイプラインに落とし込める。スクリプトの途中で API レスポンスを変換したり、ビルド工程でファイル群を一括正規化したりするのに役立つ。
@_ 属性規約と #text 規約の解説
たいていの変換ツールのページは、いちばん肝心な部分を飛ばしている。あの妙な @_ と #text のキーが何を意味し、なぜ存在するのか、という部分だ。ここをはっきりさせれば、出力が恣意的に見えなくなる。
属性は @_ を接頭辞とするキーへマップされる。 属性には JSON 上の等価物がない。オブジェクトの中には、子とは別物の「このオブジェクトに関するメタデータ」を収める場所がないのだ。そこでの規約が、属性に @_ を接頭辞とするキーを与えることだ。
<user id="42" role="admin"/>
→ { "user": { "@_id": "42", "@_role": "admin" } }
なぜ @_ なのか。妥当な XML の要素名は @ で始められないので、この接頭辞が本物の子要素キーとぶつかることがないからだ。いわば衝突しないことが保証された予約済みの接頭辞である。(xmltodict は裸の @ を、fast-xml-parser はデフォルトで @_ を使うが、原理は同じだ。)
混在コンテンツは #text へマップされる。 要素が属性とテキスト値の両方を持つとき、テキストは属性キーと並んで居場所を必要とする。それが #text だ。
<price currency="USD">29.99</price>
→ { "price": { "@_currency": "USD", "#text": "29.99" } }
プレーンテキストの要素は、そのまま文字列値になる。 属性なし、子なし、テキストだけ。だから #text を経由させる必要はない。<name>Alice</name> は "name": "Alice" になる。#text キーが現れるのは、属性によって要素値がオブジェクトにならざるをえないときだけだ。
この非対称性が、ある厄介なバグの源になる。同じ要素名が、ある文書ではプレーンな文字列を、別の文書では @_/#text のオブジェクトを生み出すことがあるのだ。その個別のインスタンスがたまたま属性を持っていたかどうかで決まる。currency 属性のない <price> は文字列 "29.99" だが、同じものでも <price currency="USD"> は { "@_currency": "USD", "#text": "29.99" } になる。node.price を直接読むコードは一方の形では動き、もう一方ではサイレントに壊れる。防御的なアクセサは型を確認することだ。const amount = typeof node.price === 'object' ? node.price['#text'] : node.price;。
CDATA はプレーンなテキストコンテンツになる。 <![CDATA[if (a < b) return;]]> セクションは単なるエスケープの仕組みなので、区切り子は剥がされ、内側のテキストが保たれる。"if (a < b) return;" だ。特別なものは何ひとつ JSON に残らない。
出力を得たら、それを JSON整形ツール に貼り付けて JSON 出力を検証し、コードに組み込む前に、構造が消費側の期待と一致しているかを確かめよう。
XML から JSON への 5 つの落とし穴と回避法
これらは、コードレビューをすり抜けて本番で表面化する失敗だ。どれもが、本ガイド冒頭のモデルの不一致にさかのぼる。
1. 配列のあいまいさ(単一か複数か)。 <item> が 1 つならオブジェクトに、2 つ以上なら配列になる。JSON の形状は、その個別の文書にたまたま兄弟要素が何個あったかに依存する。result.items.item.forEach(...) のような消費側コードは、フィクスチャに 3 件のアイテムがあるテスト時には動き、レコードがちょうど 1 件しかない本番で TypeError: not a function を投げる。
// Two <book> siblings → array
// <library><book>A</book><book>B</book></library>
// → { "library": { "book": ["A", "B"] } }
// One <book> → object, NOT an array
// <library><book>A</book></library>
// → { "library": { "book": "A" } }
// Normalize so both cases behave identically
const books = [].concat(result.library?.book ?? []);
books.forEach(b => console.log(b)); // safe for 0, 1, or many
[].concat(x ?? []) というイディオムは覚えておく価値がある。値が欠けていれば [] に、単一のオブジェクトなら [object] に、既存の配列ならそのまま素通りする。Python なら xmltodict.parse() に force_list={'book'} を渡せば値は常にリストになり、正規化を丸ごと省ける。
2. 属性がサイレントに落ちる。 いくつかのライブラリは属性の無視をデフォルトにしている。fast-xml-parser がまさにそうで、ignoreAttributes: false を設定するまでそうなる。変換は成功したように見え、JSON も問題なくパースでき、そして id、currency、status の値がただ消えている。デフォルトを信用せず、必ずフラグを明示的に設定しよう。
3. 名前空間のフラット化。 xmlns 宣言はただの @_xmlns キーになり、<soap:Body> の接頭辞は文字列キー "soap:Body" の一部としてしか生き残らない。意味 — 2 つの接頭辞が同じ URI に束縛されうるということ — は失われる。
<soap:Envelope xmlns:soap="http://schemas.xmlsoap.org/soap/envelope/">
<soap:Body>...</soap:Body>
</soap:Envelope>
→ {
"soap:Envelope": {
"@_xmlns:soap": "http://schemas.xmlsoap.org/soap/envelope/",
"soap:Body": "..."
}
}
接頭辞 soap: はいまやキー名の中のただのテキストで、それが名前空間だと知るものは何もない。異なる名前空間に属する 2 つの要素がローカル名を共有していれば、衝突しうる。正確な名前空間の扱いが要件の一部であるなら、データは名前空間対応のパーサーの中に留めておき、そもそも JSON にフラット化しないことだ。
4. 型変換をしない — そしてそれが正しい。 <zip>01234</zip> を 1234 にしてはならない。口座コード、郵便番号、ゼロ埋めの識別子、精度に敏感な小数は、いずれもサイレントな型変換のもとで壊れる。よい変換ツールはすべてを文字列のまま保ち、意図的に型変換させてくれる。
// Don't rely on implicit coercion
if (config.timeout > 25) { /* fragile: "30" > 25 happens to work */ }
// Coerce explicitly, only where you know the type
if (parseInt(config.timeout, 10) > 25) { /* safe */ }
5. 損失:コメント、処理命令、混在コンテンツの順序。 XML のコメント(<!-- ... -->)と処理命令(<?xml-stylesheet ?>)には JSON 上の居場所がなく、破棄される。子要素と交互に並んだテキストの相対的な順序は、ラウンドトリップで保たれないことがある。すべてのバイトを保持する必要があるなら — 元の文書をそっくり再出力するためなど — そもそも変換しないことだ。データモデルに手を触れず再整形・最小化するには XMLフォーマッター を使おう。
JSON を XML へ戻す(ラウンドトリップ)
逆方向にはそれ独自のひねりがある。JSON にはルート要素のルールがなく、XML はちょうど 1 つを要求するからだ。対になる JSON to XML 変換ツール は同じ @_/#text 規約を逆向きに適用するので、JSON → XML → JSON の往復で属性、テキスト、構造が保たれる。
面白いのはルートの正規化の部分だ。この変換ツールは単一ルートの要件を 4 つのルールで解決する。
- 単一キーのオブジェクト → そのキーがルートになる:
{ "config": {...} }→<config>...</config>。 - 複数キーのオブジェクト →
<root>で包む:{ "a": 1, "b": 2 }→<root><a>1</a><b>2</b></root>。 - トップレベルの配列 →
<root><item>...</item></root>として包み、<item>を固定のフォールバック名とする。 - プリミティブ値 →
<root>value</root>。
それ以外はすべて順方向を映している。@_ キーは属性になり、#text はテキストコンテンツになり、キーの下の JSON 配列は同名の兄弟要素の繰り返しを生む。キー名は再利用され、決して単数形化されない。
// Convert JSON to XML in Node.js using fast-xml-parser
import { XMLBuilder } from 'fast-xml-parser';
const data = {
catalog: {
product: {
'@_id': 'P01',
name: 'Wireless Headphones',
price: { '@_currency': 'USD', '#text': '79.99' },
},
},
};
const builder = new XMLBuilder({
attributeNamePrefix: '@_', // @_ keys become attributes
textNodeName: '#text', // #text key becomes text content
ignoreAttributes: false, // process @_ keys
format: true, // pretty-print
});
console.log(builder.build(data));
// <catalog>
// <product id="P01">
// <name>Wireless Headphones</name>
// <price currency="USD">79.99</price>
// </product>
// </catalog>
ビルダーが肩代わりしてくれる細部がひとつある。テキストと属性値の中の特殊文字(<、>、&、")は対応するエンティティ参照へエスケープされるので、出力は整形式のまま保たれる。
FAQ
XML の属性は JSON にどうマップされる?
属性は @_ を接頭辞とするキーになり、id="42" は "@_id": "42" に変わる。これは fast-xml-parser と xmltodict が共有する規約であり、妥当な要素名が @ で始まることはないので、この接頭辞が要素名と衝突することはない。
XML から JSON への変換で、なぜ数値が文字列のまま残る?
変換ツールが型変換を一切しないからだ。01234 を無理に 1234 にすれば、郵便番号、口座番号、ゼロ埋め ID から意味のある先頭のゼロが落ちてしまう。すべての値を文字列のまま保つのが安全なデフォルトであり、型がわかっている下流で意図的に型変換すればよい。
XML から JSON への変換は無損失?
いいえ。コメントと処理命令は破棄され、名前空間の意味は部分的にしか保たれず、混在コンテンツの順序はラウンドトリップで保たれないことがある。すべてのバイトを保持する必要があるときは、XML を JSON に変換するのではなく、XMLフォーマッターを使って XML を再整形しよう。
繰り返しの XML 要素は JSON でどう扱われる?
同名の子が 1 つならオブジェクトに、2 つ以上なら配列になる。形状が兄弟要素の数に依存するので、消費側のコードは常に配列へ正規化し、単一要素のケースと複数要素のケースの両方を落とさず扱えるようにすべきだ。
XML の名前空間は JSON への変換でどうなる?
xmlns 宣言はただの @_xmlns キーになり、接頭辞は "soap:Body" のように要素名の文字列の中に留まる。接頭辞と URI の意味的な束縛は解釈されないので、別個の名前空間がひとつにフラット化されうる。
JSON を XML に戻すにはどうすればいい?
対になる JSON to XML 変換ツールを使う。同じ @_ と #text の規約を逆向きに適用するので、属性、テキストコンテンツ、配列が対称的にマップし戻される。その対称性こそ、きれいな JSON → XML → JSON のラウンドトリップを可能にするものだ。
複数のルート要素を持つ XML を変換できる?
いいえ。複数のトップレベル要素は整形式の XML ではないので、パーサーが入力をはじく。まずフラグメントを単一のルート要素で包み — <a/><b/> を <root><a/><b/></root> にして — それから変換しよう。
まとめ
XML から JSON への変換は、形を整え直すだけの作業ではなく、規約に沿って 2 つのモデルを橋渡しする作業だ。そのルールはランタイムをまたいで一貫している。属性は @_ キーへ、混在コンテンツのテキストは #text へ、繰り返しの兄弟要素は配列へマップされ、先頭のゼロや精度が生き残るよう値は文字列のまま保たれる。気をつけるべき罠は、単一か配列かという形状の入れ替わり、サイレントに落ちる属性、コメントと名前空間の意味の喪失。どれもバグではなく、背後のモデルの食い違いを知っていれば前もって避けられるものだ。
素早く、プライベートに変換したいときは、XML to JSON 変換ツール に貼り付けよう。これは完全にブラウザの中で動く。ソースはまず XMLフォーマッター で検証し、ラウンドトリップ用の XML が必要なら JSON to XML 変換ツール で逆方向へ進もう。データフォーマットのモデルが変換の挙動をどう形づくるかについては、YAML と JSON の違いの解説も参照してほしい。