YAML から XML への変換

YAML を XML へ、すべてブラウザー内で変換します。

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

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

上に YAML を貼り付けると、隣に字下げされた整形式の XML が現れます。宣言も付き、特殊文字はすべてエスケープされます。YAML が解析できないときは、空白のペインではなくパーサー自身の 1 行目の苦情が出ますし、正当な XML 要素名にするためにキーを改名した場合は、何が変わったかを一覧にします。

古いものが新しいものを読まねばならないとき、この向きが必要になります。XML しか受け取らない連携、SOAP のエンドポイント、XSD で検証される交換形式、YAML で設定されたサービスのためのフィクスチャ。とがった角が驚くほど多く、そのほとんどは XML ではなく YAML から来ます。

解析は js-yaml が行い、サイトの全ページではなく、このページを開いたときにだけ読み込まれます。すべてがこのタブで動き、何もアップロードされません。これは大事なことです。YAML は設定が住む場所であり、設定は資格情報が住む場所だからです。

YAML の 3 種類のノードはどう写るか

YAML のノードはちょうど 3 種類で、それぞれに XML の相手が 1 つあります。写像はキーごとに 1 つずつの子要素の集まりになり、キーが要素名になります。並びは、包む入れ物なしに、親の要素名を成員ごとに 1 回ずつ繰り返します。XML が一覧を表す方法が繰り返しだからです。スカラーはその要素のテキスト内容になります。

その上にルートの問題が乗ります。YAML は文書の最上位にどのノードでも許しますが、XML はちょうど 1 つのルートを求めます。キーが 1 つだけの写像にはすでに自然なルートがあるので、そのキーがルート要素になります。キーが 2 つ以上の写像、最上位の並び、裸のスカラーは、既定で root という名の 1 つの要素に包まれます。この名前は操作列で変えられます。

並びの規則から来る帰結を 2 つ知っておく値打ちがあります。空の並びは何も生みません。ですからキーは消えます。要素を 0 回繰り返すことは、要素が 0 個だということです。そして、ほかの並びの中に直接入れ子になった並びは平らになります。内側の並びには使える自分の名前がないからです。

order:
  id: '00042'
  line:
    - Widget
    - Gasket
  note: null
  tags: []

<?xml version="1.0" encoding="UTF-8"?>
<order>
  <id>00042</id>
  <line>Widget</line>
  <line>Gasket</line>
  <note/>
</order>
写像、並び、null、そして空の並び。

XML が見る前に、YAML はもう型を決めている

これがこのページでいちばん大事なことで、しかもこのコンバーターの性質ではありません。YAML は素のスカラーの型を、その綴りかたをもとに、パーサーの内側で決めます。値が XML の書き手に届くころには、それはすでに数か論理値か日付か文字列であり、XML にはその区別を取り戻せる型体系がありません。

js-yaml は YAML 1.2 の中核スキーマにタイムスタンプ型を足したものを実装しており、結果は次のとおりです。値を貼り付ければ 1 行ずつ確かめられます。

  • true と false は論理値で、テキスト true と false として書かれます。yes と no はここでは文字列のままですが、PyYAML や Ansible のような YAML 1.1 のパーサーは no を false と読むので、同じファイルでも道具が違えば違う XML が出てきます。
  • 先頭のゼロは変換の前に消えています。01730 は数の 1730 になり、XML の書き手にできることは何もありません。'01730' と書いてください。
  • 別の基数も解決されるので、0x1F は 31 になり <hex>31</hex> と書かれます。16 進の色コードやハードウェアの識別子には引用符が要ります。
  • YAML の整数はブラウザーでは倍精度になるので、19 桁の識別子は書き手が関わる前にすでに下の桁を失っています。識別子は必ず引用符でくくってください。
  • 日付はタイムスタンプに解決され、タイムスタンプには書き手が作れるテキスト表現がないので、2024-01-05 は空の <when/> として出てきます。引用符でくくればテキストとして書かれます。

YAML のキーは、しばしば正当な XML 名ではない

XML 1.0 の 2.3 節は、要素名は文字・アンダースコア・コロンで始まり、それらに数字・ハイフン・ピリオドを加えたもので続くと述べています。YAML のキーにそんな制限はありません。「2024 total」「user@email」、そして空文字列はどれもふつうのキーであり、どれも要素名にはなれません。

それぞれは拒むのではなく改名し、改名はすべて報告します。不正な文字は 1 つずつアンダースコアに置き換え、それでも数字で始まる名前には前にアンダースコアを付けます。取り除くのではなく置き換えるのは意図的です。取り除けば「2024 total」と「2024total」が同じ要素になり、別々の 2 つのフィールドが統合されてしまいます。ですから「2024 total」は _2024_total に、「2024-total」はハイフンがすでに正当なので _2024-total に、「user@email」は user_email になります。完璧ではありません。「first name」と「first_name」はどちらも first_name に着地するので、句読点だけが違うキーは名前を変えてください。

YAML は文字列でないキーも許します。2024 は整数、true は論理値、明示キーの構文では並びまるごとをキーにできます。いずれも要素名になる前に文字列化されます。癖が 1 つ。配列の添字のように見えるキーは先に、しかも数として昇順に並べられるので、2 と 10 と name が混ざった写像は、あなたが書いた順に要素を出しません。

複数文書のストリーム、アンカー、マージキー

YAML のストリームはハイフン 3 つで区切られた複数の文書を持てますし、Kubernetes のマニフェストは日常的にそうしています。XML のルートはちょうど 1 つなので、すべてを読んで包みます。1 つの <documents> 要素の中に、YAML の文書ごとに 1 つの <document> 子要素、そして何件見つかったかの注記。多くのコンバーターは黙って先頭だけに切り詰め、それに気づくのはマニフェストの 3 分の 2 が静かに消えた本番でのことです。

アンカー、エイリアス、マージキーはパーサーが解決し、XML が書かれるころには消えています。得られるのは完全に展開された結果で、これは正しく、そして入力よりかなり大きくなりえます。1 つの土台ブロックを 40 のサービスにエイリアスすれば、40 の写しができます。その展開はこのタブのメモリで起きるので、エイリアスが重いと遅くなりますし、サイズの上限は元だけでなく出力にも当てはまります。

コメントは保たれません。YAML のデータモデルの一部ではなく、パーサーが引き渡してこないからです。空のストリームや、コメントだけのストリームは、空のルート要素に変換されるのではなく「空」として報告されます。

コードで行う

どの言語でも手順は 2 つ。安全なローダーで YAML を読み込み、それからきちんとエスケープするもので XML を書きます。ここでは安全のためのフラグは XML 側ではなく YAML 側にあります。いくつかの YAML ライブラリは、既定で、あるいは文書の中のタグ 1 つで、ファイルから任意のクラスを実体化します。それは設定を装った遠隔コード実行です。

import yaml from 'js-yaml';
import { XMLBuilder } from 'fast-xml-parser';

// load() uses the default schema, which constructs no JavaScript types.
// Do not swap in js-yaml's extended schema for untrusted input.
const docs = [];
yaml.loadAll(yamlSource, (d) => docs.push(d));

if (docs.length === 0) throw new Error('The YAML document is empty.');
// XML has one root; a multi-document stream needs wrapping, not truncating.
let data = docs.length > 1 ? { documents: { document: docs } } : docs[0];

if (data === null || typeof data !== 'object' || Array.isArray(data)
    || Object.keys(data).length !== 1) {
  data = { root: data };
}

const builder = new XMLBuilder({
  ignoreAttributes: false,
  attributeNamePrefix: '@_',
  textNodeName: '#text',
  format: true,
  indentBy: '  ',
  suppressEmptyNode: true,
});

console.log('<?xml version="1.0" encoding="UTF-8"?>');
console.log(builder.build(data));

// XMLBuilder does not sanitise names. A YAML key of "2024 total" is written
// verbatim and the result will not parse, so validate before you ship it.
import re
import yaml
import xmltodict


def legal_name(key):
    """Replace illegal characters rather than stripping them, so distinct
    keys stay distinct. Prefix a leading digit."""
    name = re.sub(r'[^\w.\-:]', '_', str(key), flags=re.UNICODE)
    return name if re.match(r'^[A-Za-z_:]', name) else '_' + name


def sanitise(node):
    if isinstance(node, dict):
        return {legal_name(k): sanitise(v) for k, v in node.items()}
    if isinstance(node, list):
        return [sanitise(v) for v in node]
    if isinstance(node, bool):
        return 'true' if node else 'false'
    return node if node is None else str(node)


# safe_load, never load: yaml.load with the default Loader will construct
# arbitrary Python objects from !!python tags in the document.
docs = [d for d in yaml.safe_load_all(yaml_source) if d is not None]
if not docs:
    raise SystemExit('The YAML document is empty.')

data = {'documents': {'document': docs}} if len(docs) > 1 else docs[0]
if not isinstance(data, dict) or len(data) != 1:
    data = {'root': data}

print(xmltodict.unparse(sanitise(data), pretty=True, indent='  ',
                        full_document=True))

# PyYAML applies the YAML 1.1 resolver, so an unquoted no is False here and
# a string in js-yaml. Quote anything whose type you care about.
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.SerializationFeature;
import com.fasterxml.jackson.dataformat.xml.XmlMapper;
import com.fasterxml.jackson.dataformat.yaml.YAMLMapper;

// Jackson's YAML module wraps SnakeYAML but binds only to JsonNode and to
// classes you name, so the SnakeYAML deserialisation gadget problem
// (CVE-2022-1471, the default Constructor instantiating arbitrary types)
// is not reachable through this API. Using SnakeYAML directly, construct it
// as: new Yaml(new SafeConstructor(new LoaderOptions()))
JsonNode tree = new YAMLMapper().readTree(yamlSource);

XmlMapper xml = new XmlMapper();
xml.enable(SerializationFeature.INDENT_OUTPUT);

String out = "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
    + xml.writer().withRootName("root").writeValueAsString(tree);

// readTree reads the first document only. For a multi-document stream use
// new YAMLMapper().readerFor(JsonNode.class).readValues(yamlSource)
// and wrap the results yourself.
using Newtonsoft.Json;
using YamlDotNet.Serialization;

// YamlDotNet's Deserializer binds only to types you name and does not
// resolve arbitrary .NET types from tags in the document.
var yaml = new DeserializerBuilder().Build();
object? tree = yaml.Deserialize<object>(new StringReader(yamlSource));

if (tree is null) throw new InvalidOperationException("The YAML is empty.");

// Round-trip through JSON so Json.NET can do the XML writing, including the
// escaping. The second argument names the root, which YAML does not supply
// and XML requires.
string json = JsonConvert.SerializeObject(tree);
var document = JsonConvert.DeserializeXmlNode(json, "root")
    ?? throw new InvalidOperationException("Nothing to write.");

var settings = new System.Xml.XmlWriterSettings { Indent = true, IndentChars = "  " };
using var writer = System.Xml.XmlWriter.Create(Console.Out, settings);
document.Save(writer);

// Json.NET will not sanitise names: a YAML key of "2024 total" throws
// XmlException when the node is created. Rewrite keys before this point.
<?php
use Symfony\Component\Yaml\Yaml;

// Symfony's parser never instantiates PHP objects unless you pass
// PARSE_OBJECT or PARSE_OBJECT_FOR_MAP. Do not pass either for input you did
// not write. The ext-yaml alternative, yaml_parse(), is governed by the
// yaml.decode_php ini setting, which is off by default; check it.
$data = Yaml::parse($source, Yaml::PARSE_EXCEPTION_ON_INVALID_TYPE);

function legal_name(string $key): string {
    $name = preg_replace('/[^\w.\-:]/u', '_', $key);
    return preg_match('/^[A-Za-z_:]/', $name) ? $name : '_' . $name;
}

function write_node(XMLWriter $w, string $name, mixed $value): void {
    if (is_array($value) && array_is_list($value)) {
        foreach ($value as $v) write_node($w, $name, $v);   // repeat, no wrapper
        return;
    }
    $w->startElement(legal_name($name));
    if (is_array($value)) {
        foreach ($value as $k => $v) write_node($w, (string) $k, $v);
    } elseif (is_bool($value)) {
        $w->text($value ? 'true' : 'false');
    } elseif ($value !== null) {
        $w->text((string) $value);
    }
    $w->endElement();
}

$single = count($data) === 1;
$w = new XMLWriter();
$w->openMemory();
$w->setIndent(true);
$w->setIndentString('  ');
$w->startDocument('1.0', 'UTF-8');
write_node($w, $single ? (string) array_key_first($data) : 'root',
               $single ? reset($data) : $data);
$w->endDocument();
echo $w->outputMemory();
# yq v4 (Mike Farah) converts directly.
yq -p=yaml -o=xml '.' config.yaml

# yq writes no XML declaration and no wrapper, so a multi-key document
# produces several roots. Wrap it first:
yq -p=yaml -o=xml '{"root": .}' config.yaml

# A multi-document stream needs collecting into one root explicitly, or yq
# emits one XML fragment per document:
yq ea -p=yaml -o=xml '{"documents": {"document": [.]}}' manifests.yaml

# Always check the result. yq does not sanitise element names, so a key with
# a space in it produces XML that will not parse:
yq -p=yaml -o=xml '{"root": .}' config.yaml | xmllint --noout --nonet -

これらのどれでも正しく設定すべきなのは、書き手ではなくローダーです。Python の yaml.load、Java の SnakeYAML の既定 Constructor、そして yaml.decode_php を有効にした yaml_parse は、いずれも文書のタグから任意のオブジェクトを作ります。YAML ファイルはデータです。それが別の何かになれるローダーを使うまでは。

よくある質問

私の YAML はどこかにアップロードされますか。

いいえ。YAML のパーサーも XML の書き手も、このタブで動く JavaScript であり、届く先のサーバー側の仕組みもありません。開発者ツールのネットワークタブを開いて文書を貼り付ければ、ページ自身のアセットが一度読み込まれ、そのあとは何もないのが見えます。

ここではとくに確かめる値打ちがあります。YAML は設定が住む場所だからです。Kubernetes のシークレット、CI パイプラインの変数、ホスト名とユーザー名の入った Ansible のインベントリー、データベースのパスワードが書かれた compose ファイル。

郵便番号やバージョン番号、ID が変わってしまったのはなぜですか。

XML の書き手ではなく YAML が変えたからです。YAML はスカラーの型を綴りかたから推論するので、01730 は数の 1730、1.10 は浮動小数の 1.1 になり、19 桁の識別子は倍精度に収まりません。それはすべて YAML のパーサーの内側で、XML に関わる何かが動くより前に起きます。

直し方は YAML の側にあります。値を引用符でくくってください。'01730'、'1.10'、'9007199254740993' はいずれも文字列として届き、打ったとおりに書かれます。その YAML を生成器が作ったのなら、生成器が引用符を付けるべきでした。

--- で区切られた複数の文書を持つ YAML ファイルはどうなりますか。

すべて読んで包みます。1 つの <documents> 要素の中に YAML の文書ごとに 1 つの <document> 子要素が入り、注記のパネルが何件見つかったかを述べます。

もう一方の道、多くのコンバーターが選ぶ道は、最初の文書だけを変換して残りを黙って無視することです。Kubernetes のマニフェストにとってそれはひどい既定です。1 つのファイルが Deployment と Service と ConfigMap を抱えるのは日常で、3 つのうち 2 つを失っても、配備が失敗するまで気づかないからです。

アンカー、エイリアス、マージキーはどう扱われますか。

パーサーが解決し、出力では完全に展開されます。アンカーはノードに印を付け、エイリアスはそれを指し返し、マージキーは 1 つの写像をもう 1 つに当てはめます。3 つとも XML には存在せず、どれも生き残りません。

得られるものは正しいのですが、入力よりずっと大きくなりえます。1 つの土台ブロックを 40 のサービスにエイリアスすれば、完全な写しが 40 できます。それが YAML の意味していたことで、ただ YAML は 1 回書けば済むようにしてくれていただけです。展開はこのタブのメモリで起きるので、エイリアスが重いと遅くなります。

値を子要素ではなく XML の属性として出せますか。

はい。YAML のキーの頭に、操作列に表示されている属性接頭辞(既定では @_)を付けてください。値 7 を持つ '@_id' というキーは、<id> という子ではなく、囲んでいる要素の id 属性になります。

そのキーは引用符でくくらなければなりません。素のスカラーは YAML が予約している @ で始められないので、引用符のない @_id は解析エラーになり、しかもメッセージは文字ではなく字下げについて文句を言います。そこに置くのはスカラーだけにしてください。属性値は構造を含められないので、@_ のキーの下に写像を置くと、入れ子の XML ではなく文字列化された混乱ができあがります。

作られる XML は妥当ですか。

整形式です。それは別の、そしてより弱い主張です。すべての要素は閉じられ、ルートはちょうど 1 つ、テキスト中のアンパサンドと小なり記号と ]]> の並びはエスケープされ、属性値ではさらに二重引用符と空白文字がエスケープされ、先頭に UTF-8 の宣言が書かれます。

妥当性とはスキーマに合うことであり、YAML にはそれを導けるスキーマがありません。その XML が検証を行う先へ向かうなら、その相手が公開しているスキーマとともに XSD バリデーターへ持っていってください。

関連ツール

関連する解説