XMHell:使用铁锈处理38 GB的UTF-16XML

2020-10-23 07:18:17

几个周末前,我发现自己很想从新墨西哥州石油保护部(OCD)那里获取新墨西哥州某个县的石油和天然气产量数据。

幸运的是,OCD通过FTP服务器提供对历史油井生产的访问。我计算出我需要下载/public/ocd/ocd Interface v1.1/Volumes/wcProduction/wcProduction.zip…。然后我的财产就花光了。

我首先注意到的是文件大小。OCD似乎没有提供一种方法来查询有限的基于时间或区域的生产历史数据子集,所以我们只能使用一个zip文件来表示“自时间黎明以来的所有新墨西哥州”1。结果是一个巨大的712MB ZIP文件。

这就是我知道我有麻烦的地方:里面唯一的东西是一个名为wcProduction.xml的38 GB文件。

XML-“可扩展标记语言”。与Java的AbstractBeanFactoryTemplateFactorys和整个GeoCities一起,XML是90年代末和21世纪初过度计算文化的缩影。

似乎没有两个解析器能在细节上达成一致,而且冗长的商数是这样的,试图为大约2001年左右的普通“XHTML”网站“打印源代码”很可能会完全破坏亚马逊的森林。可以说对它有利的一件事是,它确实可以用来在某种程度上忠实地表示树(内存中的层次结构,而不是我们刚刚杀死数百万人的东西)。

不过,抱怨已经够多了:“XML Everywhere”的浪潮早已平息。我们得想办法处理它留下的东西。虽然这是解析XML的最常用方法,但很明显,将整个文件加载到内存中不太可能奏效:我在其上编写这篇文章的工作站只有32 GB的RAM。此外,当我们只对一小部分数据感兴趣时,为整个文档构建语法树是没有意义的。XML世界将这种方法(在这里称为“DOM解析器”)称为“DOM解析器”,但这只是解析任何形式语言的常见情况-我们递增地构造整个文档的内存表示(即语法树)。当我们需要整个结果并且内存中的表示形式符合我们的资源预算时,它工作得很好:例如,当从源代码编译计算机程序时,或者从HTML呈现网页时。

取而代之的是,我们将使用流方法(XML人员称其为“SAX”,原因是我没有对此进行足够的研究)。在这种方法中,我们将增量地解析文档,并通过基于事件的API接收结果。这将使我们只处理我们关心的数据,并将一次需要保存在内存中的数量降至最低。

为了在这个“巨大的”数据集上获得良好的性能,并利用易于访问的库生态系统(包括流式ZIP存档处理和高性能流式XML解析器),我们将用Rust编程语言编写一个数据提取程序。

我不打算在这里大谈铁锈令人兴奋的原因。它是一种静态类型的编译语言,将性能和控制、表现力和安全性完美地结合在一起。我发现对于我可能也使用C或C++的那种“系统编程”任务来说,它是一个很好的选择,但是构建工具和包管理系统(Cargo)也使它成为这类“类脚本”程序的可行选择,特别是在性能可能有问题的情况下。

让我们从使用货物创建一个新的铁锈项目开始。我们需要的是程序(二进制),而不是库:

这将创建一个包含Rust项目的新目录OCD_PRODUCTION。在内部,我们在src/main.rs中有一个“hello world”程序,在Cargo.toml中有该项目的规范。该目录也是一个git存储库,这很方便。

当我们可以使用zip库将XML文件流出ZIP文件时,解压ZIP文件就没有意义了。让我们先从预览XML文件的文本开始。

在我们开始之前有一个警告:这将是“脚本”质量代码。我的目标是快速可靠地处理一个文件,而不是构建可重用的库或生成“生产就绪”代码。因此,这段代码将跳过一些“最佳实践”:所有内容都将放在一个很长的main.rs文件中,并且错误处理将面向捕获错误条件和中止程序,而不是面向错误恢复或友好的错误消息。

考虑到这一点,让我们在src/main.rs中编写一个短程序,将ZIP文件路径作为命令行参数,并从其中包含的文件一次流式传输几KB:

我们将在“发布模式”下编译(即启用优化),并针对ZIP文件运行:

与许多“现代”语言一样,Rust的标准字符串类型是UTF-8编码的Unicode字符串。这是一个正确的答案,有很多充分的理由,但它只是在最近几年才变得流行起来。

在本例中,我们显然遇到了一些不是以UTF-8编码的文本。事实上,我们找到了一个无效字节作为第一个字节。让我们看一下原始字节,而不是Rust字符串,这样我们就可以看到发生了什么。我们将编辑src/main.rs:

文件为wcProduction.xml,大小为38535541060字节读取块:[255254,60,0114,0,111,0,111,0,116,0,32,0,120,0,109,0,108,0,110,0,115,0,58,0,120,0,115,0,105,0,61,0,34,0,104,0,116,0,116,0,112,0,58,0,47,0,47,0,119,0,119,0,46,0,119,119,0,119,0,46,0,119,0,51,0,46,0,111,0,114,0,103,0,47,0,50,0,48,0,48,0,49,0,47,0,88,0,77,0,76,0,83,0,99,0,104,0,101,0,109,0,97,0,45,0,0,105,0,110,0,115,0,116,0,97,0,110,0,99,0,101,0,34,0,62,0,60,0,120,0,115,0,100,0,58,0,115,0,99,0,104,0,101,0,109,0,97,0,32,0,116,0,97,0,114,0,103,0,101,0,116,0,78,0,97,0,...。

在这一点上,正在恢复的Windows程序员和Java程序员同情地退缩了。有两件事立即凸显出来:第一,我们有一系列字节在可打印的ASCII范围和零之间交替,第二,我们以0xFF 0xFE开始-这是一个BOM。我们正在以小端字节顺序处理UTF-16。

如果你想知道我们是如何走到这一步的,我建议你读一读“创世纪”第11章。

稍长一点的说法是这样的:在80年代末,一些有远见的人开始认真思考非美国人正在使用计算机的事实。事实上,世界上的一些计算机用户甚至不是欧洲人,他们通常可以将一些有趣的带口音的拉丁字符走私到ASCII的“未使用”的第八位中,从而逍遥法外。当然,非拉丁字母的人靠自己做得很好:他们有自己的字符集和编码。

但是,如果我们都可以共享一个通用字符集,那不是很好吗?某种…。“Uni”版本的“代码”?当然,他们认为,中文/日文/韩文(CJK)字符集、拉丁字母、西里尔字母和任何其他感兴趣的东西都可以编码成一个共享的16位范围。对于世界上所有的语言来说,65,536个字符应该足够了!我们刚刚决定新的“字符类型”是16位整数,而不是8位整数;字符串仍然可以是这些字符的数组,将现有ASCII文本所需的存储空间增加一倍是可以接受的全球和谐代价。大牌致力于这一新的“通用字符集”(UCS-2):Microsoft重新构建了Windows的字符串处理,以使用双字节UCS-2“宽字符”字符串,热门的新Java编程语言也做出了同样的决定。

可以想象,65,536个字符对世界上所有的语言来说都远远不够-更不用说还没有出现的奇怪的👩‍👩‍👦‍👦字符💯Now to🔜。Unicode最终采用了32位地址空间,支持超过40亿个字符。即使考虑到表情符号创造的惊人速度,我们也不太可能在短期内填补这一空缺。

然而,承诺使用固定宽度的32位字符编码似乎也没有吸引力。编码和解码将很简单,但是对于纯ASCII文本(仍然很常见),每4个字节中就有3个字节被浪费。解决方案是UTF-16编码形式的“补丁”,它使用与UCS-2类似的16位“字符”,但提供了一种转义机制,将基本多语言平面之外的字符编码为两个字符的“代理对”。

这让Windows和Java人员修补了他们的系统,但这有点不令人满意:一旦我们接受了可变宽度编码的需要,为什么即使是拉丁字母,我们还在每个字符上浪费两个字节呢?这一推理直接导致了UTF-8 2,它使用可变宽度编码方案中的普通单字节字符来忠实地表示所有Unicode,同时保持与ASCII的兼容性。

从离题到计算性的Babel返回后,我们发现了XML文件中直接遇到的“宽字符”的另一个问题。我们的现代计算机体系结构是“面向字节的”:我们已经达成了一种粗略的共识,即一切都应该能够在8位的原子单元上工作。但是,几乎所有感兴趣的量都更大:我们使用64位(8字节)地址、16位(2字节)UCS-2字符,依此类推。我们应该如何在顺序寻址字节的世界中表示这些内容呢?我们有两个主要选择:最低有效字节优先(“小端”)或最高有效字节优先(“大端”)。不同的CPU架构做出了不同的选择,因此当我们共享UCS-2或UTF-16文本时,我们需要一种方法来辨别文本的编码字节顺序。这就是我们在文件开头看到的BOM的用途:由于Unicode强制使用的值0xFEFF在我们的文件中显示为0xFF 0xFE,我们知道该文件是用小端字节顺序编码的。

对我们来说非常幸运的是,我们可以使用图书馆为我们处理所有这些事情。ENCODING_rs和ENCODING_RS_IO库将允许我们动态地从UTF-16转换为UTF-8,这将允许我们将普通的Rust字符串发送到我们的XML解析器。

[软件包]名称=";ocd_Production";版本=";0.1.0";作者=[";Derrick W.Turk<;[email protected]>;";]edition=";2018年";[依赖项]编码_rs=";0.8.22";编码_rs_io=";0.1.7";zip=";0.5.5";

文件为wcProduction.xml,大小为38535541060字节读取块:<;根xmlns:xsi=";http://www.w3.org/2001/XMLSchema-instance";>;<;xsd:schema targetNamespace=";urn:schemas-microsoft-com:sql:SqlRowSet1";xmlns:schema=";urn:schemas-microsoft-com:sql:SqlRowSet1";xmlns:xsd=";http://www.w3.org/2001/XMLSchema";xmlns:sqltype=";Http://schemas.microsoft.com/sqlserver/2004/sqltypes";elementFormDefault=";qualified";>;<;xsd:import namespace=";http://schemas.microsoft.com/sqlserver/2004/sqltypes";schemaLocation=";http://schemas.microsoft.com/sqlserver/2004/sqltypes/sqltypes.xsd";/>;<;xsd:element name=";wcproduction";>;<;xsd:complexType>;<;xsd:sequence>;<;xsd:元素名称=";api_st_cde";type=";nillable=";1";/>;<;<;xsd:元素名称=";api_cnty_cde";type=";sqltype:mall int";nillable=";1";/>;<;xsd:元素名称=";api_well_idn";type=";nillable=#34;sqltype:int";nillable=";/>;<;xsd:元素名称=";api_well_idn";type=";nillable=";sqltype:int";nillable=";1";/>;<;xsd:元素名称=";pool_idn";type=";sqltype:int";nillable=";/>;<;xsd:元素名称=";prodn_mth";type=";sqltype:mall int";nillable=";/>;<;xsd:元素名称=";prodn_yr";type=";sqltype:int";nillable";/>;<;xsd:元素名称=";prodn_yr";type=#34;sqltype:int";nillable";sqltype:int";nillable";1";/>;<;xsd:元素名称=";OGRID_cde";type=";sqltype:int";nillable=";/>;<;xsd:元素名称=";prd_knd_cde";nillable=";1";>;<;xsd:simpleType>;<;xsd:restriction BASE=";sqltype:char";sqltype:localeId=";1033";sqltype:sqlCompareOptions=";IgnoreCase IgnoreKanaType IgnoreWidth";sqltypes:sqlSortId=";52";>;<;xsd:maxLength value=";2";/>;<;/xsd:restriction>;<;/xsd:simpleType>;<;/xsd:element>;<;xsd:element Name=";Eff_DTE";TYPE=";sqltype:DateTime";nillable=";1";/>;<;xsd:元素名称=";Nillable=";1";>;<;xsd:simpleType>;<;xsd:restriction base=";sqltype:char";sqltype:localeId=";1033sqltype:sqlCompareOptions=";IgnoreCase IgnoreKanaType IgnoreWidth";sqltypes:sqlSortId=";52";>;<;xsd:maxLength value=";1";/>;<;/xsd:restriction>;<;/xsd:simpleType>;<;/xsd:Element>;<;xsd:Element Name=";c115_WC_STAT_CDE";nillable=";1";>;<;xsd:simpleType>;<;xsd:restriction BASE=";sqltype:char";sqltype:localeId=";1033";sqltype:sqlCompareOptions=";IgnoreCase IgnoreKanaType IgnoreWidth";sqltype:s

[软件包]名称=";ocd_Production";Version=";0.1.0";Authors=[";Derrick W.Turk<;[email protected]>;";]edition=";2018年";[依赖项]编码_rs=";0.8.22";coding_rs_io=";0.1.7";Quick-xml=";0.18.1";zip=";0.5.5和#34;

在src/main.rs中,我们将每次分块的println替换为快速XML解析器生成的事件的模式匹配:

文件为wcProduction.xml,大小为38535541060字节...。开始wcProduction文本:start api_st_cde文本:30 end api_st_cde文本:start api_cnty_cde文本:5 end api_cnty_cde文本:start api_well_idn文本:20178 end api_well_idn文本:start pool_idn文本:10540 end pool_idn文本:start prodn_mth文本:7 end prodn_mth text:start prodn_yr text:1973 end prodn_yr text:start oggrid_cde text:12437 end oggrid_cde text:start prd_knd_。Cde text:g end prd_knd_cde text:START Eff_DTE Text:1973-07-31T00:00:00 END Eff_DTE Text:START ADMAND_IND TEXT:N END ADMAND_IND TEXT:START C115_WC_STAT_CDE TEXT:F END C115_WC_STAT_CDE Text:START PROD_AMT TEXT:53612 END PROD_AMT TEXT:START PROD_DAY_NUM TEXT:99 END PRODY_DAY_NUM TEXT:99 END PRODY_DAY_NUM TEXT:START MOD_DTE TEXT:2015-04-07T07:31:00.173。结束MOD_DTE文本:结束wcProduction...。

在这一点上,我们可以清楚地看到每个生产记录的结构。要将这些增量事件转换为包含我们关心的生产数据的内存结构,我们将构建一个简单的状态机。

考虑一下每个<;wcProduction>;元素的“形状”。在文件中的任何位置,我们的解析器都可能处于以下任何一种状态:

在Rust中,我们将此状态编码为枚举类型,并将状态机解析器编码为struct类型:

最终结果被累积到Production成员中,作为一个嵌套的散列映射,它允许我们首先按油井API编号查找生产,然后按日期查找生产。解析器可以选择性地保留谓词函数(作为特征对象),它将使用该函数根据API编号决定是处理还是跳过每条记录(例如,过滤到单个县)。

让我们从绘制状态机的“转换图”开始。我们将使用箭头指示可能的状态转换,并用触发它们的事件标记它们:

在Rust中,我们可以将其作为WellProductionParser结构上的函数来实现,该结构使用来自XML解析器的事件,针对当前状态采取适当的操作,并更新状态。以下是一个简略版本:

启动并运行基于状态机的解析器后,最后一步只是为收集的数据提供表格输出。我们将编写制表符分隔的文本,这些文本可以很容易地复制和粘贴到电子表格中,或由其他程序处理。我们还将按日期对每个油井的产量进行排序-XML不一定是有序的。

我们最后的Main如下所示,现在应用一个谓词,该谓词使用API编号进行过滤,只选择Eddy县的井:

在我的“工作站”上,这个程序在几分钟内运行,并从ZIP归档文件中提取一个92MB的制表符分隔文件。还不错!。

这个项目的全部源代码可以在giHub上找到,网址是https://github.com/derrickturk/ocd_production.。请随意根据您自己的目的进行修改。

时间的曙光似乎已经过去了一段时间。

.