如何用 C++ 分块解析 XML 并按列式键值存储
结论
这类文件适合用 libxml2 的 xmlTextReader 处理。它以 pull 模式顺序读取 XML,遇到 row 开始元素时处理属性,不需要构建完整的 DOM。内存主要由解析器缓冲区、当前节点和单个 row 的大小决定,不会随着总行数不断增加。
getline() 加字符串搜索不适合解析 XML。一个元素可能跨越多行,属性顺序也不固定,属性值还可能包含 entity、换行和经过编码的引号。XML 解析器会处理这些情况,并能报告 XML 不完整、引号未闭合和标签不匹配等错误。
如果项目采用事件回调,也可以使用 Expat 或 libxml2 的 SAX 接口。这里只需依次读取 row,用 xmlTextReader 实现通常更简单。
为什么按行搜索不可靠
原来的代码有这些问题:
- XML 元素可以跨越多行,物理行不是 XML 的语义边界。
while (line[end] != '"')没有检查end < line.size(),缺少结束引号时会越界。Id2="..."、注释和其他元素中的相似文本都可能干扰简单搜索。- 无法正确处理
"、&、<、
等引用。 - 返回空字符串时,无法判断属性不存在,还是属性存在但值为空。
- 错误数据可能已经写入
users,调用方却不知道 XML 已经损坏。
XML 的 well-formed 检查只能发现语法错误。Id 是否必填、是否为整数、是否重复都属于业务规则,需要程序另行验证。如果有正式的 XSD,还可以增加 schema 验证。
使用 xmlTextReader 逐个处理 row
下面的示例不会保留所有记录。解析器遇到根元素的直接子元素 row 时,会复制其属性,并立即交给 consumeRow() 输出或写入存储系统。
#include <libxml/xmlreader.h>
#include <iostream>
#include <memory>
#include <stdexcept>
#include <string>
#include <string_view>
#include <utility>
#include <vector>
using Attribute = std::pair<std::string, std::string>;
using Row = std::vector<Attribute>;
struct ReaderDeleter {
void operator()(xmlTextReaderPtr reader) const noexcept {
if (reader != nullptr) {
xmlFreeTextReader(reader);
}
}
};
using ReaderPtr =
std::unique_ptr<_xmlTextReader, ReaderDeleter>;
struct XmlError {
std::string message;
int line = 0;
};
void onXmlError(void* context,
const char* message,
xmlParserSeverities severity,
xmlTextReaderLocatorPtr locator) {
if (severity == XML_PARSER_SEVERITY_WARNING ||
severity == XML_PARSER_SEVERITY_VALIDITY_WARNING) {
return;
}
auto* error = static_cast<XmlError*>(context);
error->message = message != nullptr ? message : "unknown XML error";
error->line =
locator != nullptr ? xmlTextReaderLocatorLineNumber(locator) : 0;
}
void writeQuoted(std::ostream& out, std::string_view value) {
out.put('"');
for (char ch : value) {
switch (ch) {
case '\\':
out << "\\\\";
break;
case '"':
out << "\\\"";
break;
case '\n':
out << "\\n";
break;
case '\r':
out << "\\r";
break;
case '\t':
out << "\\t";
break;
default:
out.put(ch);
break;
}
}
out.put('"');
}
void consumeRow(const Row& row) {
bool first = true;
for (const auto& [key, value] : row) {
if (!first) {
std::cout << ", ";
}
std::cout << key << ':';
writeQuoted(std::cout, value);
first = false;
}
std::cout << '\n';
}
void parseRows(const std::string& filename) {
constexpr int options = XML_PARSE_NONET | XML_PARSE_COMPACT;
ReaderPtr reader(
xmlReaderForFile(filename.c_str(), nullptr, options));
if (!reader) {
throw std::runtime_error("cannot open XML file: " + filename);
}
XmlError error;
xmlTextReaderSetErrorHandler(reader.get(), onXmlError, &error);
int result = 0;
while ((result = xmlTextReaderRead(reader.get())) == 1) {
if (xmlTextReaderNodeType(reader.get()) !=
XML_READER_TYPE_ELEMENT) {
continue;
}
const xmlChar* localName =
xmlTextReaderConstLocalName(reader.get());
if (!xmlStrEqual(localName, BAD_CAST "row")) {
continue;
}
// posts/users 是深度 0,直接子元素 row 是深度 1。
if (xmlTextReaderDepth(reader.get()) != 1) {
continue;
}
Row row;
int attrResult = xmlTextReaderMoveToFirstAttribute(reader.get());
while (attrResult == 1) {
const xmlChar* name = xmlTextReaderConstName(reader.get());
const xmlChar* value = xmlTextReaderConstValue(reader.get());
if (name == nullptr || value == nullptr) {
throw std::runtime_error("invalid XML attribute");
}
// 这些指针只在 reader 保持当前状态时有效,因此必须复制。
row.emplace_back(
reinterpret_cast<const char*>(name),
reinterpret_cast<const char*>(value));
attrResult = xmlTextReaderMoveToNextAttribute(reader.get());
}
if (attrResult < 0) {
throw std::runtime_error("failed to read XML attributes");
}
if (xmlTextReaderMoveToElement(reader.get()) != 1) {
throw std::runtime_error("failed to return to row element");
}
consumeRow(row);
}
if (result < 0) {
std::string message = "malformed XML";
if (error.line > 0) {
message += " at line " + std::to_string(error.line);
}
if (!error.message.empty()) {
message += ": " + error.message;
}
throw std::runtime_error(message);
}
}
int main(int argc, char* argv[]) {
if (argc != 2) {
std::cerr << "usage: xml_rows <file.xml>\n";
return 2;
}
try {
parseRows(argv[1]);
return 0;
} catch (const std::exception& ex) {
std::cerr << ex.what() << '\n';
return 1;
}
}
安装 libxml2 开发包并确保环境中可以使用 pkg-config 后,可按下面的方式编译:
c++ -std=c++17 -O2 xml_rows.cpp \
$(pkg-config --cflags --libs libxml-2.0) \
-o xml_rows
输出形式如下:
Id:"1", Reputation:"4220", DisplayName:"Jeff Atwood"
示例为属性值加了引号和转义,因为值中可能包含逗号、冒号或换行。如果直接输出 key:value, key:value,后续将无法可靠拆分。需要供程序读取时,更适合使用 JSON Lines、CSV 或带长度前缀的二进制格式。
XML entity 如何处理
xmlTextReaderConstValue() 返回解析后的属性值。示例中的引用会转换为:
<变为<"变为"&变为&
变为换行字符
因此,Body 和 AboutMe 中得到的是经过编码的 HTML 文本。这些内容是属性值,不是当前 XML 文档的子元素,不能当作外层 XML 标签处理。
示例没有启用 XML_PARSE_NOENT。处理不受信任的输入时,不应为展开自定义实体而随意启用这个选项。XML_PARSE_NONET 可以阻止网络访问。如果输入可能包含 DTD 或外部实体,还要根据威胁模型限制 DTD,并使用经过安全审查的解析配置。XML_PARSE_HUGE 会放宽部分解析限制,也不应轻易启用。
写入真正的列式存储
key:value 只是单条记录的键值表示。按列保存时,可以让 consumeRow() 把同名属性送入对应列:
void consumeRow(const Row& row) {
for (const auto& [key, value] : row) {
columnWriter(key).append(value);
}
finishCurrentRow();
}
具体实现可以使用独立的列文件,也可以接入支持列式布局的存储库。属性缺失时需要补位。例如,某个 row 没有 Title,就要在该列写入 null 标记。直接跳过会导致各列的行号错位。
如果只需要 Id、DisplayName 等少数列,可以在遍历属性时筛选,避免复制很大的 Body:
if (xmlStrEqual(name, BAD_CAST "Id") ||
xmlStrEqual(name, BAD_CAST "DisplayName")) {
row.emplace_back(
reinterpret_cast<const char*>(name),
reinterpret_cast<const char*>(value));
}
如果把所有记录都放进全局 std::map,即使 XML 采用流式解析,业务容器仍会持续增长,最终可能耗尽内存。记录应当逐行写出,也可以采用有上限的批次,例如积累固定数量后批量落盘。
错误处理上的两个细节
格式错误有时要到文件后部才能发现。如果此前的记录已经写入最终存储,程序会留下部分结果。若要求整个文件解析成功后才生效,应先写入临时文件或事务,确认解析完成后再提交。
问题中的片段没有展示 </posts> 或 </users>。如果真实文件也缺少结束标签,解析器会在文件末尾报告 malformed XML。在此之前遇到的 row 可能已经交给 consumeRow(),因此输出端需要支持事务式提交。
如何评估实现
测试时至少要准备这些输入:
- 正常文件,其中包含空属性、entity、UTF-8 字符和很长的
Body。 row跨越多行的文件。- 缺少引号、标签不匹配或缺少根结束标签的文件。
- 缺少
Id、Id为空或Id重复的文件。 - 属性中包含逗号、冒号、引号和换行的文件。
- 用于观察峰值内存和吞吐量的大文件。
比较性能时,要使用相同的输入、输出目标和 release 构建,并分别记录总耗时、每秒处理的字节数或记录数,以及峰值常驻内存。“只扫描但不写出”和“解析并落盘”不是可直接比较的测试,因为 I/O 和存储格式往往会成为主要开销。
备注:内容仅供参考。
