XML から JSON への変換

JSON へ変換。対応づけの判断はすべて手元にあります。

入力
出力
待機中文書を貼り付けると検査します。入力中にそのまま検証されます。

すべてこのタブ内で実行されます。貼り付けた内容がアップロード・記録・送信されることはありません。 ネットワークパネルを開いて確認する.

上に XML を貼り付けると、入力しながら JSON が現れます。まず整形式かどうかを走査します。壊れたマークアップを推測で読み進める変換器は、明らかに間違った JSON ではなく、静かに間違った JSON を吐くからです。構文が通らなければ、中途半端なオブジェクトではなく、行・列・直し方が返ります。

これが要るのは、話し相手のサービスが XML を話し、自分から下流はすべて JSON を話すときです。テストでアサーションを書きたい SOAP レスポンス、スクリプトに取り込みたい取引先のフィード、インポーターを書く前に中を見たい ONIX ファイル。アップロードは一切ありません。エンベロープにベアラートークンが載っている場合、これは重要です。

ここが違うのは、対応づけを隠していないことです。XML を JSON にする唯一の正解はなく、どの変換器もあなたの代わりに半ダースの判断を下していますが、その中身を言うものはほとんどありません。このページは判断のひとつひとつに名前を付け、切り替えを見せ、変換で何が失われたかを出力の隣のノート欄に報告します。

なぜ「正解」はなく「選んだ答え」しかないのか

XML のデータモデルは JSON のそれより厳密に豊かです。XML には順序を持つ子、属性、要素と交ざったテキスト、名前空間、コメント、CDATA があります。JSON にあるのは順序のないオブジェクト、配列、文字列、数値、真偽値、null です。前者から後者への写像は、必ず何かを捨てるか何かを作り出すことになります。

Michael Kay は Balisage のスキーマ対応変換に関する論文でこう述べています。汎用の変換器は「字句的な XML の背後にあるオブジェクトモデルの意味を推測しており、しかも推測を外している」。推測しなければならないのは、次の 7 か所です。

  • 属性か子要素か。<user id="7"/> と <user><id>7</id></user> は別の文書ですが、多くの人は同じ JSON になってほしいと考えます。統合すると <user id="7"><id>8</id></user> はキーの重複を生み、RFC 8259 はそれを予測不能と呼んでいます。
  • 出現は 1 回か複数回か。<items><item>a</item></items> のどこにも item が繰り返しうるとは書かれていないので、変換器は数を数えて推測します。
  • 混在内容。<p>Some text <b>is important</b>.</p> には順序のある子が 3 つあり、JSON オブジェクトはその順序を表現できません。
  • 空白だけのテキスト。整形済み XML で要素のあいだにある改行とインデントは本物のテキストノードであり、残せば忠実ですが使い物になりません。
  • 名前空間。プレフィックスは名前ではなく、名前は URI のほうです。JSON には名前空間の概念そのものがありません。
  • コメントと処理命令。どちらも JSON には存在しません。
  • 空要素。<e/> は null、""、{}、空のテキストキーのどれにも妥当に対応づけられますし、<e/> と <e></e> は同じ文書です。

ここで使う規約と、それを変えるスイッチ

既定は Stefan Goessner が 2006 年に示した属性プレフィックス規約で、fast-xml-parser も xml2js も AWS SDK もその変種を使っています。属性には @_ を付け、同名の子要素と衝突しないようにします。属性や子と同じ要素にあるテキストは #text の下に入ります。1 回だけ現れる要素は値、2 回現れれば配列になります。テキストだけの要素はただの文字列に潰れるので、<name>Alice</name> は "Alice" です。

ルート要素は最も外側のキーとして残り、コメントは捨て、空白は取り除き、実体参照は解決するので、"A &amp; B" と書かれた属性は "A & B" として届きます。エディター上部のコントロールで、属性プレフィックス、テキストキー、「常に配列」にする要素名の一覧、そして 3 つのチェックボックス(名前空間プレフィックスを削除、属性を破棄、型を変換)を設定できます。3 つともオフです。

<order id="00042">
  <total currency="GBP">19.90</total>
  <line sku="0071">Widget</line>
  <line sku="0072">Gasket</line>
  <note/>
</order>

{
  "order": {
    "@_id": "00042",
    "total": { "@_currency": "GBP", "#text": "19.90" },
    "line": [
      { "@_sku": "0071", "#text": "Widget" },
      { "@_sku": "0072", "#text": "Gasket" }
    ],
    "note": ""
  }
}
既定の設定で、厄介な 4 つのケースをすべて含む文書を変換した結果。

なぜ型変換が既定でオフなのか

スキーマのない XML は、どこまで行ってもテキストです。"123" を数値にするのは便利です。ただし識別子を壊すその瞬間までは。そして XML 連携を流れるものの大半は識別子です。

型変換をオンにしたときも、その適用範囲は意図的に狭くしてあります。値が数値になるのは、厳密な JSON 数値の文法に合致し、さらに往復に耐えたときだけです。解析結果をもう一度直列化し、元の文字列と 1 文字ずつ比較します。この検査が、他の変換器が抱えたまま出荷している不具合を止めます。変換をオンにしても、次のものは文字列のままです。

  • 先頭のゼロ。"00042" や "01730" はそもそも文法に合いません。JSON の数値はゼロのあとに数字を続けられないからです。郵便番号も銀行コードも SKU も生き残ります。
  • 小数点以下の末尾のゼロ。"19.90" は 19.9 になり、直列化すると "19.9" なので文字列を保ちます。"1.10" が 1.1 になることはありません。
  • double に収まらない整数。"9007199254740993" は末尾が 992 の値になり、往復に失敗するので文字列のままです。よそで 19 桁の注文番号が壊れるのはこれです。
  • 正規形でない指数表記。"1e5" は 100000 になり、それは "1e5" ではないのでテキストのままです。
  • ちょうど true、false、null でないもの。"TRUE"、"yes"、"Y" は文字列のままです。

失われるものと、名前のある代替案

3 つは残りません。名前の違う兄弟どうしの文書順は失われるので、<line> と <discount> が交互に現れていても、JSON はその並びについて何も言いません。混在内容は平坦化され、テキスト断片は連結され、その間の要素は自分のキーに移ります。<root>35<nested>34</nested>46</root> のテキスト値は "3546" になります。コメントは破棄されます。

名前空間は解決せずそのまま残すので、soap:Body はキー "soap:Body" になります。プレフィックスを削除すれば "Body" ですが、そうすると異なる名前空間の同じローカル名を持つ 2 つの要素が 1 つのキーで衝突します。JSON に第 3 の選択肢はありません。

この規約があなたの利用側の期待と違うなら、代替案には名前があります。BadgerFish はテキストを $、属性を @name の下に置き、スコープ内のすべての名前空間を持ち回ります。往復性は良く、可読性はほぼ皆無です。Parker は属性を捨ててルートを吸収し、最も簡潔で最も一方通行な出力になります。JsonML は各要素を [名前, 属性, 子] と書き、混在内容と子の順序を保つ唯一の一般的な規約です。

コードで同じことをする

実際に XML を扱う言語での同じ変換です。どのサンプルも実体解決を無効にしています。これらのスタックのいくつかは既定で DOCTYPE に書かれた URL を取得しに行き、それが XXE 脆弱性だからです。各サンプルには、そのライブラリが代わりに下している対応づけの判断も書いてあります。

import { XMLParser } from 'fast-xml-parser';

const parser = new XMLParser({
  ignoreAttributes: false,       // default is true: attributes are DROPPED
  attributeNamePrefix: '@_',
  textNodeName: '#text',
  trimValues: true,
  parseTagValue: false,          // keep values as strings
  parseAttributeValue: false,
  processEntities: false,        // do not expand DOCTYPE-declared entities
  // FXP cannot know whether a tag repeats, so tell it which ones are lists.
  isArray: (name) => ['line', 'item', 'entry'].includes(name),
});

const json = parser.parse(xmlSource);

// fast-xml-parser never fetches anything over the network, so XXE is not
// reachable. It does expand entities declared in an internal DTD unless you
// set processEntities: false, so leave that off for untrusted input and cap
// the input size before parsing.
import json
import xmltodict

# disable_entities=True is the default in current xmltodict and blocks the
# expat entity-expansion attacks. Pass it explicitly so a downgrade of the
# dependency cannot silently re-enable them.
doc = xmltodict.parse(
    xml_source,
    disable_entities=True,
    attr_prefix='@_',
    cdata_key='#text',
    force_list=('line', 'item', 'entry'),   # the singleton fix
)

print(json.dumps(doc, indent=2, ensure_ascii=False))

# xmltodict returns dicts in document order, but that ordering has no meaning
# once serialised: JSON objects are unordered. Values are always strings.
# There is no coercion, which is the right default.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.dataformat.xml.XmlFactory;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import javax.xml.stream.XMLInputFactory;

XMLInputFactory input = XMLInputFactory.newFactory();
// Neither of these is off by default. Both must be, for untrusted XML.
input.setProperty(XMLInputFactory.SUPPORT_DTD, false);
input.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false);

XmlMapper xml = new XmlMapper(new XmlFactory(input));
JsonNode tree = xml.readTree(xmlSource);

String json = new ObjectMapper()
    .writerWithDefaultPrettyPrinter()
    .writeValueAsString(tree);

// Jackson's XML module merges attributes in with child elements: there is no
// prefix, so <user id="7"><id>8</id></user> loses one of the two. If your
// documents put data on attributes, bind to a class annotated with
// @JacksonXmlProperty(isAttribute = true) instead of reading a tree.
using System.Xml;
using Newtonsoft.Json;

var settings = new XmlReaderSettings
{
    DtdProcessing = DtdProcessing.Prohibit,
    XmlResolver = null,
    MaxCharactersFromEntities = 1024 * 1024,
    MaxCharactersInDocument = 20L * 1024 * 1024,
};

using var reader = XmlReader.Create(new StringReader(xmlSource), settings);
var document = new XmlDocument { XmlResolver = null };
document.Load(reader);

// omitRootObject: false keeps the root element as the outer key.
string json = JsonConvert.SerializeXmlNode(
    document, Newtonsoft.Json.Formatting.Indented, omitRootObject: false);

// Json.NET prefixes attributes with "@" and uses "#text" for text, close to
// the convention on this page. It has the singleton problem and no isArray
// hook: the only fix is a json:Array="true" attribute in the source XML,
// which you usually do not control.
<?php
libxml_use_internal_errors(true);

// LIBXML_NONET blocks network access for any DTD the document references.
// LIBXML_NOENT is deliberately NOT passed: it would substitute entities.
$xml = simplexml_load_string($source, 'SimpleXMLElement', LIBXML_NONET);

if ($xml === false) {
    foreach (libxml_get_errors() as $e) {
        fprintf(STDERR, "line %d col %d: %s\n", $e->line, $e->column, trim($e->message));
    }
    exit(1);
}

echo json_encode($xml, JSON_PRETTY_PRINT | JSON_UNESCAPED_SLASHES), "\n";

// Two things to know first. json_encode() puts attributes under an
// "@attributes" object, not a prefix. And when an element has both text and
// child elements, SimpleXML drops the text entirely: mixed content does not
// survive this route at all.
# yq v4 (Mike Farah) reads XML and writes JSON with no Python dependency.
yq -p=xml -o=json '.' document.xml

# Attributes are prefixed with + by default; match this page's convention:
yq -p=xml -o=json --xml-attribute-prefix='@_' '.' document.xml

# yq resolves nothing over the network. It also has the singleton problem and
# no per-tag array option, so a list of one comes back as a scalar. Normalise
# on the way into jq:
yq -p=xml -o=json '.' document.xml \
  | jq '.order.line |= (if type == "array" then . else [.] end)'

上のライブラリはどれも、少なくとも 1 つの対応づけを黙って決めています。Jackson は属性を子と統合し、SimpleXML は混在内容のテキストを捨て、Json.NET と yq はどの要素がリストかを指定できません。これらはバグではなく、曖昧さが表に出ているだけです。どれを選ぶにせよ、1 件のレスポンスと複数件のレスポンスを同じコード経路に通すテストを書いてください。

よくある質問

XML はサーバーに送られますか。

いいえ。スキャナーもマッパーも JSON シリアライザーも、すべてこのタブで動く JavaScript です。変換が到達できるバックエンドは存在しません。

開発者ツールを開いてネットワークタブに切り替え、貼り付けて見てください。このページ自身のアセットが一度読み込まれ、その後は何も続きません。ここではそれが多くの変換器以上に重要です。人が変換する XML はたいてい連携のペイロードであり、WS-Security ヘッダーや API キーがそのまま入っているからです。

<item> が 1 つだとオブジェクトで、2 つだと配列になるのはなぜですか。

スキーマがなければ XML に多重度の情報がないからです。文書のどこにも item が繰り返しうるとは書かれていないので、変換器は数を数えて推測します。その結果、JSON の形が契約ではなくデータに左右されます。XML 連携が壊れる最も一般的な形がこれです。3 件のテストレスポンスに対して書いたコードが items.item.map() を呼び、注文が 1 件だけの顧客が現れるまでは動きます。

対策はエディター上部の「常に配列」欄です。コードでも同じことをしてください。fast-xml-parser には isArray、xmltodict には force_list があり、xml2js はまさにこの理由で explicitArray を既定で true にしています。

JSON に変換するとき XML の属性を残すには。

既定で残ります。@_ を前に付けたキーの下に入るので、<user id="7"/> は {"user": {"@_id": "7"}} になります。このプレフィックスは、同名の属性と子要素が互いを上書きしないために存在します。

プレフィックスを変えることも、空にして属性を子のあいだに混ぜることもできます。混ぜた出力は読みやすい代わりに、<user id="7"><id>8</id></user> のように名前が衝突すると値が黙って失われます。属性を完全に捨てるチェックボックスもあり、これは Parker 規約と同じ挙動です。一方向の抽出には十分ですが、元に戻す用途には向きません。

soap: のような名前空間とプレフィックスはどうなりますか。

プレフィックス付きの名前をそのまま使うので、soap:Body はキー "soap:Body" になります。何も解決しません。JSON は名前空間 URI を運べないからです。宣言された名前空間の数はノート欄が伝えます。

「名前空間プレフィックスを削除」にすると "Body" になり、SOAP レスポンスから値を 1 つ取り出すときはたいていこれが望みでしょう。リスクは現実的です。文書に soap:Header と wsse:Header の両方があると、削除によって 1 つのキーに統合され、片方が勝ちます。

「数値と真偽値を変換」はオンにすべきですか。

データに識別子が含まれていないと分かっているときだけです。ここでの変換は多くの実装より厳しく、厳密な数値文法に加えて往復検査を求めるので、"00042"、"19.90"、"1.10"、そして double に収まらない整数は壊れずに文字列のまま残ります。

それでも扱えないのは、サンプルでは "1" で、来週の火曜日には "N/A" になる項目です。妥当な折衷案は、オフのままにして、本当に必要な 2 つ 3 つの項目だけを自分のコードで変換することです。そうすれば判断が書き残ります。

コメント、CDATA、空要素はどう扱われますか。

コメントと処理命令は捨てられます。JSON には置き場所がありませんし、定義からしてデータではないものにキーを与えると、出力が扱いにくくなるだけです。

CDATA はテキストとして扱います。<![CDATA[a < b]]> と a &lt; b は同じ内容を 2 通りに書いたものです。空要素は空文字列になるので、<note/> も <note></note> も "note": "" になります。null を選ばなかったのは、それが「存在して空」ではなく「値が不明」と読めてしまうからです。

関連ツール

関連する解説