宝塔拨测博客拨测技术博客
宝塔拨测

XML 转 JSON 后属性和 CDATA 去哪了?_attributes、_text 与「单个节点不是数组」一次讲清

把一段 XML 粘进转换工具,点「转换为 JSON」,第一眼通常会觉得结构怎么这么怪:属性跑到 _attributes 里,文本跑到 _text 里,同一个标签只出现一次时是对象、出现两次就变数组。这既不是转换出错,也不是工具偷懒,而是 XML 和 JSON 两套数据模型对不上时,必须有人做取舍。下面所有输入输出,都来自 宝塔 XML/JSON 互转工具 的一次真实运行,截图和内容取自同一次操作。

一段带属性的 XML,转出来是什么样

输入:

<?xml version="1.0" encoding="UTF-8"?>
<users>
  <user id="1" role="admin">张三</user>
  <user id="2">李四</user>
</users>

输出:

{
  "users": {
    "user": [
      {
        "_attributes": {
          "id": "1",
          "role": "admin"
        },
        "_text": "张三"
      },
      {
        "_attributes": {
          "id": "2"
        },
        "_text": "李四"
      }
    ]
  }
}

四条规则一次看全:根元素变成 JSON 唯一的顶层键;元素上的属性收进 _attributes;元素里的文本收进 _text;同名子元素有两个,所以 user 是数组。

在宝塔 XML/JSON 互转工具里把一段带 id、role 属性的 XML 转成 JSON,属性进 _attributes、文本进 _text,两个 user 变成数组

属性为什么要单独装一层,不能直接当键

<user id="1"> 里的 id 在 XML 里是属性,JSON 没有属性的概念,只能变成键值对。但直接平铺会撞车:<user id="1"><id>2</id></user> 里属性和子元素都叫 id,平铺之后一个会盖掉另一个。所以工具把所有属性统一收进名为 _attributes 的对象,正文内容收进 _text,两者和子元素各占一层。

代价是取值路径变长:原来是 user.id,转成 JSON 后要写 user._attributes.id。更要紧的是这个前缀只是本工具的约定——它不属于 XML 规范,也不属于 JSON 规范(RFC 8259)。换一个解析库,键名可能变成别的形式,同一个 XML 出来的结构就不一样了。所以拿在线工具看一眼结构很合适,真要接进程序,还是用固定版本的库并把这层映射写进代码里。

另外注意:属性值一律是字符串。<rss version="2.0"> 转出来是 "version": "2.0",不会被当成数字,这一点和 YAML 转 JSON 的口径相反,不会出现「2.0 变 2」这类改写。

一个子元素是对象、两个子元素是数组

同一份数据,多一条记录,类型就变。实测两种输入:

<list><item>a</item></list>

输出 {"list": {"item": {"_text": "a"}}} —— item 是对象。

<list><item>a</item><item>b</item></list>

输出 {"list": {"item": [{"_text": "a"}, {"_text": "b"}]}} —— item 变成数组。

XML 里没有「数组」这个东西,解析器只能靠同名标签出现几次来猜。出现一次时不敢当成数组,因为绝大多数元素本来就是单例,全部包成数组反而没人用。取这个折中的结果是:消费端必须自己归一化。

Node.js 里常见写法:

const items = [].concat(data.list.item ?? []);

Python:

items = data["list"]["item"]
if isinstance(items, dict):
    items = [items]

写采集脚本时,第一版基本都在这里翻车:接口今天返回一条记录是对象,明天返回两条就是数组,for (const it of data.list.item) 直接把对象当可迭代对象用,报错还看不出原因。

这四类内容,转过去就没了

  1. 注释。<!-- 部署配置 --> 不会出现在 JSON 里。JSON 标准(RFC 8259)没有注释语法,浏览器 DOM 里注释也是独立节点类型,转换只处理元素节点和文本节点。
  2. CDATA。<name><![CDATA[a & b]]></name> 实测输出 "name": {},整段内容消失。CDATA 是 XML 里「这段字符原样保留、不解析实体」的写法,在 DOM 里是独立的 CDATA 段节点,不是文本节点,所以被跳过。
  3. 混排文本的顺序和空格。<desc>价格 <b>100</b> 元</desc> 转成 {"desc": {"b": {"_text": "100"}, "_text": "价格元"}}:两段文本被拼在一起,「价格」和「元」中间的空格没了,而「元」原本在 <b> 后面,这个位置信息已经丢了。
  4. 空白与空元素。<empty/> 转成 {};只含空白字符的文本节点被丢弃。

这四条都属于格式表达能力的差异,不是 bug,但决定了 XML 转 JSON 只能当「取值」用,不能当「备份」。带注释的配置文件、把富文本存在 CDATA 里的接口、正文夹标签的 RSS 描述,转成 JSON 都会缺内容。要校验格式本身对不对,用 XML 格式化工具 的校验更直接。

JSON 转 XML:只认第一个顶层键

反向转换的第一个坑不在写法上,在结构上。输入:

{"server":{"host":"api.example.com","port":80,"tls":false,"tags":["a","b"],"limit":null},"client":{"timeout":5}}

输出:

<?xml version="1.0" encoding="UTF-8"?>
<server>
  <host>api.example.com</host>
  <port>80</port>
  <tls>false</tls>
  <tags>a</tags>
  <tags>b</tags>
  <limit />
</server>

client 整段不见了,也没有任何提示。因为 XML 只能有一个根元素,工具取 JSON 的第一个顶层键当根标签,其余顶层键直接不参与输出。贴 JSON 之前先确认它只有一个顶层键,否则手动包一层 {"root": {…}}。

同一工具切到 JSON → XML,输入里 server 和 client 两个顶层键,输出只剩 server,client 整段没有出现

这段输出同时演示了三条类型规则:

  • 数字 80 写成 <port>80</port>,布尔 false 写成 <tls>false</tls>——XML 里没有类型,落到标签里都是文本,程序读回来要自己转。
  • null 写成自闭合的 <limit />。
  • 数组写成同名标签重复出现:["a","b"] 变成两个 <tags>。

还有两个更容易忽略的:

  • _text 和子节点并存时,_text 被丢弃。{"name": {"_text": "内联", "child": "x"}} 输出的是 <name><child>x</child></name>,「内联」两个字没了。往回转的时候不要同时给 _text 和子元素。
  • 不合法的标签名照样输出,不报错。"bad key"、"3rd" 会生成 <bad key>、<3rd>,而 XML 规定名字不能含空格、不能以数字开头。工具只管拼字符串,不校验名称合法性,等这份 XML 被别的程序读取时才报错——而且那时已经很难想到是键名的问题。

转回去能还原到什么程度

把第一段 JSON 输出原样贴回 JSON → XML,得到:

<?xml version="1.0" encoding="UTF-8"?>
<users>
  <user id="1" role="admin">张三</user>
  <user id="2">李四</user>
</users>

属性、文本、中文、顺序都对得上,实体也被正确转义:输入 JSON 里 note 的值是 a & b "q"、文本是 x < y(JSON 字符串里就是裸的 & 和 <),转成 XML 后变成 note="a &amp; b &quot;q&quot;"、<v>x &lt; y</v>,可以正常解析。这一圈属于可信的部分。

回不去的是数组标记:{"list": {"item": [{"_text": "a"}]}} 只有一个元素,转成 <list><item>a</item></list>,再转回 JSON 就变成 {"list": {"item": {"_text": "a"}}} —— 数组没了。所以「XML → JSON → XML → JSON」不能保证结构一致,往返转换适合人工核对字段,不适合当数据管线。

报错里的行列号,藏在英文长句中间

输入一份标签没闭合的 XML:

<root>
  <a>1</a>
  <b>2</root>

点转换后,工具在下方输出:

This page contains the following errors:error on line 3 at column 13: Unexpected closing tag: root != bBelow is a rendering of the page up to the first error.

这段是浏览器内置的 XML 解析器(DOMParser)的原文,工具只是把它原样转出来,所以是英文、前后还夹着两句样板句。有用的只有中间那截 error on line 3 at column 13: Unexpected closing tag: root != b:第 3 行第 13 列,遇到了结束标签 root,可当时还开着的是 b —— 也就是 <b> 没闭合就写了 </root>。行列号从 1 开始数,不是下标。

XML 输入标签不闭合时报错:红色提示给出 line 3 at column 13,右侧 JSON 输出框仍保留着上一次成功转换的结果

这张图里还有一个容易误判的细节:报错时右侧输出框不会被清空,还留着上一次成功的结果。图里左边是刚输入的 <root>,右边却是上一条的 config,看着像「转换成功了」,实际是上一次的残留。看到红色报错就以报错为准,别拿右边的旧内容当结果。

JSON 方向的报错来自 JSON.parse,同样是英文但格式不同,例如 Expected double-quoted property name in JSON at position 7 (line 1 column 8):position 是字符下标(从 0 开始),后面括号里才是行列号。多一个逗号、少一个引号,基本都在这条报错里指出来了。

命名空间和编码:能过,但只是当字符串过

带前缀的 RSS 片段:

<rss xmlns:dc="http://purl.org/dc/elements/1.1/" version="2.0">…<dc:title>T</dc:title></rss>

转出来 xmlns:dc 和 version 都在 _attributes 里,dc:title 原样成为键名 —— 前缀被当成名字的一部分,工具不做命名空间解析。也就是说,改掉前缀、或者让同一个前缀在不同位置指向不同 URI,转出来的 JSON 完全看不出区别。靠命名空间区分来源的跨系统交换(SOAP、多规范混用的订阅源),别指望这层语义在 JSON 里还留着。

编码方面,输出的 XML 声明固定写 encoding="UTF-8"。原文件如果是 GBK,把结果复制回去要按 UTF-8 保存,否则字节是 GBK、声明写 UTF-8,别的程序打开就乱码。

什么时候别用在线工具

  • 看一眼结构、贴工单、确认字段落在哪一层:这类场景用工具更直接。页面写明基于浏览器原生 DOMParser 实现、所有解析与序列化均在浏览器本地完成,据此内容不会上传到服务器。
  • 批量转换、进 CI、要固定映射规则:用命令或库,比如 Python 的 xmltodict,属性前缀、文本键名都能自己指定。关键是规则可复现,不会因为换了个页面就变一套键名。
  • 要保留注释、CDATA、混合内容顺序:别转 JSON,JSON 没有这些概念。要么继续用 XML,要么在转换前把关键内容挪进普通元素并转义好实体。
  • 含账号、密钥、内网地址的配置:先脱敏。在浏览器本地跑,不等于可以随手粘。

一句话总结:属性进 _attributes、文本进 _text、单节点不是数组、注释和 CDATA 一定丢、JSON 转 XML 只认第一个顶层键。看结构够用,接程序就换规则可控的解析库。

常见问题

XML 转 JSON 后属性变成 _attributes,这个前缀是哪来的?

是工具的约定,不是 XML 也不是 JSON 的标准。属性在 JSON 里没有对应结构,只能变成键值对,但又不能直接和子元素平铺——<user id="1"><id>2</id></user> 里属性和子元素同名就撞车了。所以工具把属性统一收进 _attributes 对象、正文文本收进 _text。换成别的解析库,这两个键名会不同(常见的是 @ 前缀),代码要跟着改。

为什么只有一个 item 时是对象,两个就变成数组?

XML 本身没有数组概念,解析器只能按同名标签出现的次数推断。实测 <list><item>a</item></list> 输出 "item": {"_text": "a"},而两个 item 输出的是数组。出现一次不敢当数组,因为绝大多数元素本来就是单例。消费端要先归一化:Node.js 用 [].concat(data.list.item ?? []),Python 用 isinstance 判断,否则遍历时会报错。

CDATA 里的内容转 JSON 后变成空对象,能救回来吗?

不能靠转换救。CDATA 段在浏览器 DOM 里是独立的节点类型,转换只处理元素节点和文本节点,所以 <name><![CDATA[a & b]]></name> 实测输出 "name": {},整段内容被跳过。要保留内容,只能把 CDATA 改写成普通文本并手动转义实体(& 写成 &amp;、< 写成 &lt;),或者干脆不转 JSON。

JSON 转 XML 后少了几个字段,是工具吃数据了吗?

是只取了第一个顶层键。XML 只能有一个根元素,工具用 JSON 的第一个顶层键当根,其余顶层键静默丢弃。实测 {"server":{…},"client":{"timeout":5}} 转出来只有 server,client 整段不见且没有提示。贴之前先确认只有一个顶层键,或者手动包一层 {"root": {…}}。

转出来的 XML 和原文不完全一样,是工具改坏了吗?

多数是格式上的正常差异:属性顺序、空白、注释、编码声明都可能变,XML 规范里属性顺序本身没有语义,注释也不会进 JSON。真正要注意的是四类内容丢失:注释、CDATA、混排文本的顺序与空格、纯空白节点。要逐字复原原始文件,别用 JSON 当中转,直接保留 XML。

报错里的 line 3 at column 13 是从 0 开始数的吗?

从 1 开始。实测 <root> 下 <b>2</root> 这种标签没闭合的输入,报错是 error on line 3 at column 13: Unexpected closing tag: root != b,指第 3 行第 13 列遇到结束标签 root,但当时打开的是 b。这句报错来自浏览器内置的 XML 解析器,是英文原文,前后还夹着两句样板文字,看中间那截就够。