XML 转 JSON 的 TypeScript 简易实现.

为什么重复写脚本?

最近在参与的一个 TypeScript 项目上出现了一个功能,即通过读取 xml 文件的属性信息来检查特定的动作是否执行完成。当然作为一名合格的模块战士,最好的方式就是用第三方库来解析 xml 文件,但作为一个折腾玩家,自己实现的功能才更加具有可控性。

json 相较于 xml 更加通用和方便,虽然 npm 上已经有 sax 这个开源库提供现成的 xml 解析方案了,但这些方案由于需要兼容很多“阴间” xml 样例,不得已会存在很多匹配上的的冗余代码。因此,考虑到项目中的 xml 并不存在特殊格式,所以可以简化很多步骤,在执行速度以及体积大小上会存在一定的优势。

方案简要

这一次的算法实现,我并没有过多地考虑性能优化——这并不只是因为我偷懒——主要还是先实现一个可用的 Demo 为主。xml 的内容解析类似于文本分析,所以在实现时很自然地就往递归遍历想了,简单粗暴,效果拔群。具体思路就是正确找出标签的开头位置和结尾位置进行解析,然后中间的内容递归到下一层进行匹配解析。

从这个角度出发,要解决的问题就分为了三个:标签文本拆分json 对象构建内容解析

标签文本拆分

xml 始终以 <> 为标签进行内容分割,夹着子文本和子标签。所以如果要正确进行后续的匹配,只需要按照 <> 标记进行内容上的分割即可,然后将生成的字符串列表交给下一层进行处理。最终的实现效果是这样的:

源文本:
  "   </param>   Hello World!<a>test</a>Cest la vie   </param>"    // 使用 .trim() 去除两边多余的空格回车
目标字符串列表:
  [
    "</param>",
    "Hello World!",
    "<a>",
    "test",
    "</a>",
    "Cest la vie",
    "</param>"
  ]

这样便可以方便得按照标签为单位进行处理了,这一部分在实现上没有考虑太多,直接新开辟了一个容器(再次偷懒),然后双指针遍历一遍匹配 <> 存入内存即可。后续再根据该列表的内容以及对应的规则进行 json 对象构建和参数匹配。

json 对象构建

内容解析部分需要根据 xml 的文本内容来构建 json 结构。首先是捣鼓这个结构到底长啥样,不仅需要具有通用性,而且不能够丢失原文本的信息(能够反向还原回去)。

首先是确定类型,根据解析功能的需求,内容类型一共分为三种,一种是纯文本内容,一种是标签,一种是注释(未完成)。内容的类型则使用 nodeType 参数进行标记,不同的内容有着不同的对象属性;然后是确定结构,不同的内容类型对应的不同的对象结构,解析 json 的时候直接通过 nodeType 识别后使用对应的方法解析子内容即可。

在确定了具体结构之后,就开始往框架上填内容了。这里我最早想使用的方法是使用一个指针去遍历内容,然后用另一个变量存储当前的状态(在第几层,标签信息),最后根据当前状态去生成对应结构里面的信息,复杂度为 O(n)。在 TypeScript 中,我使用了稍微简单的解法来实现,即用数字作为栈标志,外加双指针递归遍历,该方法抛去递归栈开销也比较接近 O(n) 了,实测效果也挺不错。

内容解析

由于前面标签文本拆分部分已经做了处理,所以在这儿可直接通过前缀来对解析流程进行区分。带 </ 的为闭合标签,转入递归子流程;带 < 的即为开始标签,转入标签内容匹配流程;不带 < 的即为文本内容,转入文本内容处理流程。但转入流程不能仅仅依靠前缀进行判断,正所谓“门当户对”,</ 对应的有可能是另一个开始标签。所以还需要一个 stack 作为层级的 token,遇到开始标签则 stack++, 遇到闭合标签则 stack--;只有当 stack = 0</ 为正确匹配,此时才可进入递归。

此外还有个单标签的情况,本实现直接额外进行了 if 判断匹配末尾的 />。这里由于是在完成整体算法后才实现的,所以写的比较丑,额外增加了匹配的时间。

正确匹配结构之后,最终要根据标签内的文本内容匹配出标签参数。这里大量使用了正则表达式来提取,同时也测试了大量的字符串匹配去保证这一块匹配的正确性,最终效果也挺不错。

问题和部分优化

当然,这个方案从设计之初就存在了设计问题。

首先是这种分割再分析的方式,注定了与大型 xml 数据无缘。大文件测试中,40MB 需要 600+ms 到 1+s(复杂结构),110+MB 下当场报废,nodejs 的进程直接卡住没反应。如果使用字符串流 + 缓存状态信息的方式的话,大文件读取和解析上会好使很多,但由于时间问题我就没有继续进行效率改进。

这波啊,这波是汪汪队丢大脸。

第二,这个方案扩展性不够,如果要匹配额外的一些情况(注释,或者其他奇奇怪怪的标签),就必须要重写部分代码。这一块我做了结构上的优化,可以通过叠 if 的方式去匹配额外的情况。这个优化虽然不能说是毫无裨益吧,但至少也是一无是处了。

第三,本实现虽然鲁棒性高,能够忽略掉绝大部分的格式问题,但残缺的结构是没有办法正常解析的。后来我又返回看了 sax 的源码,好像它也是遇到困难睡大觉,直接在 README 上写了 It does a little validation when in strict mode, but not much

从匹配算法上考虑,主要的耗时点就是各种情况的 if 匹配上,30MB 体量的 xml 文件解析中,每减少一个 if 的匹配可以减少近 100ms 的时间。一般来说 xml 文件前面会有 <?xml><!DOCTYPE> 之类的特殊标签,而这在后面的匹配中是完全不会再碰见的。所以在完成了整体的实现后,我又把这一部分的匹配规则给抽离出来单独匹配,尽量去减少额外的条件判断。

我不太了解 nodejs 的解释器优化,在 Python 中进行字符串切片会额外新生成一个字符串对象,相比于双指针会更加耗时。TS 中我直接使用了 .slice() 进行切片,可能相应的效率也会差一些。

最后,其实源码也是比较丑的,有着明显的赶工痕迹,结构上也不够完善。同时这是我第一次用 TypeScript 来丢人完整实现一个功能,所以有些小马拉大车,遇上了很多坑。

源码

由于一些小问题,暂时不作公开。