Преобразование XML в JSON: соглашения, подводные камни и примеры кода
Вы получаете ответ от SOAP-эндпоинта, RSS-ленты или из sitemap.xml — и это XML. Весь ваш стек живёт на JSON: JavaScript на фронтенде, REST посередине, документоориентированное хранилище снизу. Значит, XML нужно преобразовать в JSON, и вы тянетесь к парсеру, рассчитывая уложиться в одну строку.
Обычно так и выходит — пока результат не подведёт. Массив, которого вы ждали, оказывается одиночным объектом. Атрибут id исчезает. Почтовый индекс вроде 01234 возвращается числом 1234. Ни одна из этих проблем не баг парсера. Это следствие сопоставления двух моделей данных, которые не совпадают, и единственный надёжный способ преобразовать XML в JSON — разобраться в соглашениях, которые закрывают этот разрыв.
В этом руководстве разобрано, почему такие соглашения существуют, четыре способа выполнить преобразование (браузер, JavaScript, Python, CLI), правила @_ и #text, общие для всех крупных библиотек, пять подводных камней, которые приводят к тихой потере данных, и как преобразовать JSON обратно в XML для чистого кругового обхода. Примеры настоящие и запускаемые — вставьте их в Node, Python или в shell, и они выдадут результат, показанный в комментариях.
Почему преобразованию XML в JSON нужны соглашения (а не просто переформатирование)
XML и JSON на первый взгляд похожи — оба представляют собой деревья именованных вложенных данных, — но их базовые модели расходятся в важных деталях. Элементы XML могут нести атрибуты, содержать смешанный контент (текст, перемежающийся с дочерними элементами) и принадлежать пространствам имён. В JSON ни одного из этих понятий нет. Есть объекты, массивы и четыре скалярных типа. Преобразование одного в другое — это не переформатирование, а перевод между двумя грамматиками, в одной из которых есть слова, которые другая не способна записать.
Прежде чем что-либо преобразовывать, стоит убедиться, что исходник действительно валиден. Случайный неэкранированный & или несовпадающий тег будут отклонены парсером, поэтому если сначала прогнать вход через XML Форматировщик для проверки правильной оформленности, это избавит от целого круга непонятных ошибок.
Вот где две модели расходятся:
| Измерение | XML | JSON |
|---|---|---|
| Типы узлов | элементы, атрибуты, текст, смешанный контент | объекты, массивы, string, number, boolean, null |
| Ограничение на корень | требуется ровно один корневой элемент | ограничений на корень нет |
| Атрибуты | да (id="P01") | нет (нужно соглашение @_) |
| Повторяющиеся элементы | одноимённые соседи допустимы | ключи объекта не могут повторяться (нужно соглашение о массиве) |
| Система типов | текст не типизирован — всё является строкой | нативные типы |
| Пространства имён | да (xmlns) | нет |
Поскольку модели не совпадают, каждое преобразование XML в JSON управляется соглашениями, а не является переформатированием без потерь. Хорошая новость в том, что соглашения не произвольны: fast-xml-parser (Node.js), xmltodict (Python) и JAXB (Java) сошлись на одних и тех же двух маркерах — @_ для атрибутов, #text для текста смешанного контента. Выучите их один раз — и они перенесутся между средами исполнения. По той же причине несоответствия формы данных всплывают и в других преобразованиях, например в вопросах о выводе типов из руководства по преобразованию CSV в JSON.
Как преобразовать XML в JSON: 4 способа
Выберите способ под свой контекст: быстрая разовая вставка, сервис на Node, конвейер на Python или shell-скрипт в CI.
Способ 1 — браузерный инструмент (без настройки, приватность прежде всего)
Для разового преобразования или для XML, который вы предпочли бы не вставлять на случайный сайт, конвертер в браузере — самый быстрый путь. Вставьте XML в Конвертер XML в JSON, и JSON появится мгновенно — без установки, без аккаунта, без загрузки на сервер. Всё работает в JavaScript-движке вашего браузера, поэтому данные никогда не покидают машину.
У последней детали есть практическое следствие. SOAP-конверты содержат токены WS-Security, внутренние конфигурации — строки подключения, а экспорты — записи клиентов. Поскольку ничего не передаётся, инструмент безопасен для XML, содержащего учётные данные или чувствительный payload. Можно убедиться в этом самостоятельно: откройте вкладку 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"
// }
// }
// }
// }
Две настройки, о которых забывают, — это 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, который разбирается ниже.
Способ 4 — CLI (yq / однострочник на Python)
Для shell-скриптов и CI-конвейеров два однострочника покрывают большинство случаев. 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 в другом — в зависимости от того, нёс ли этот конкретный экземпляр атрибут. <price> без атрибута currency — это строка "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 и убедиться, что структура соответствует ожиданиям потребителя, прежде чем встраивать её в код.
5 подводных камней преобразования XML в JSON и как их избежать
Это те сбои, которые проскакивают код-ревью и всплывают в продакшене. Каждый из них восходит к несоответствию моделей из начала этого руководства.
1. Неоднозначность массива (один против многих). Один <item> становится объектом; два и более — массивом. Форма JSON зависит от того, сколько соседей случайно оказалось именно в этом документе. Код потребителя вроде result.items.item.forEach(...) работает при тестировании — где в вашей фикстуре три элемента — и выбрасывает 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 передайте force_list={'book'} в xmltodict.parse(), и значение всегда будет списком, так что нормализация вовсе не понадобится.
2. Атрибуты тихо отбрасываются. Некоторые библиотеки по умолчанию игнорируют атрибуты — fast-xml-parser делает именно так, пока вы не зададите ignoreAttributes: false. Преобразование выглядит успешным, JSON разбирается без ошибок, а ваши значения id, currency и status просто исчезли. Всегда выставляйте флаг явно, а не доверяйте значению по умолчанию.
3. Уплощение пространств имён. Объявление xmlns становится обычным ключом @_xmlns, а префикс в <soap:Body> выживает лишь как часть строкового ключа "soap:Body". Семантика — то, что два префикса могут привязываться к одному 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: теперь просто текст в имени ключа; ничто не знает, что это пространство имён. Если два элемента из разных пространств имён делят локальное имя, они могут столкнуться. Когда точная обработка пространств имён является частью требований, держите данные в парсере с поддержкой пространств имён и вовсе не уплощайте их в 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 требует ровно одного. Сопутствующий Конвертер JSON в XML применяет те же соглашения @_/#text в обратную сторону, так что обход JSON → XML → JSON сохраняет атрибуты, текст и структуру.
Интересная часть — нормализация корня. Конвертер решает требование единственного корня по четырём правилам:
- Объект с одним ключом → этот ключ становится корнем:
{ "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 отбросило бы значимый ведущий ноль из почтовых индексов, номеров счетов и дополненных нулями идентификаторов. Сохранение каждого значения строкой — безопасное значение по умолчанию; приводите тип осознанно ниже по потоку, где вы знаете тип.
Является ли преобразование XML в JSON обратимым без потерь?
Нет. Комментарии и инструкции обработки отбрасываются, семантика пространств имён сохраняется лишь частично, а порядок смешанного контента может не пережить круговой обход. Когда нужно сохранить каждый байт, используйте XML Форматировщик, чтобы переформатировать XML, а не преобразовывать его в JSON.
Как повторяющиеся элементы XML обрабатываются в JSON?
Один одноимённый дочерний узел становится объектом; два и более — массивом. Поскольку форма зависит от числа соседей, код потребителя всегда должен нормализовать к массиву, чтобы обрабатывать и случай с одним элементом, и случай с многими без сбоя.
Что происходит с пространствами имён XML при преобразовании в JSON?
Объявление xmlns становится обычным ключом @_xmlns, а префикс остаётся внутри строки имени элемента, как в "soap:Body". Семантическая привязка префикса к URI не интерпретируется, поэтому различные пространства имён могут уплощаться вместе.
Как преобразовать JSON обратно в XML?
Используйте сопутствующий Конвертер JSON в XML. Он применяет те же соглашения @_ и #text в обратную сторону, так что атрибуты, текстовый контент и массивы сопоставляются обратно симметрично. Именно эта симметрия делает возможным чистый круговой обход JSON → XML → JSON.
Можно ли преобразовать XML с несколькими корневыми элементами?
Нет. Несколько элементов верхнего уровня не являются хорошо оформленным XML, поэтому парсер отклоняет вход. Сначала оберните фрагменты в один корневой элемент — превратите <a/><b/> в <root><a/><b/></root> — и затем преобразуйте.
Заключение
Преобразование XML в JSON управляется соглашениями, а не является переформатированием. Правила одинаковы во всех средах исполнения: атрибуты сопоставляются с ключами @_, текст смешанного контента — с #text, повторяющиеся соседи — с массивами, а значения остаются строками, чтобы ведущие нули и точность выживали. Ловушки, которые стоит запомнить, — это смена формы «один против массива», тихо отброшенные атрибуты и потеря комментариев и семантики пространств имён. Ни одна из них не баг: зная несоответствие моделей, стоящее за ними, их легко предусмотреть заранее.
Когда нужно быстрое и приватное преобразование, вставьте данные в Конвертер XML в JSON — он работает целиком в вашем браузере. Сначала проверьте исходник с помощью XML Форматировщика, а в обратную сторону идите через Конвертер JSON в XML, когда нужен круговой обход XML. Подробнее о том, как модели форматов данных формируют поведение преобразования, смотрите в заметках о различиях YAML и JSON.