PAYATHON 2026

如何用 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="..."、注释和其他元素中的相似文本都可能干扰简单搜索。
  • 无法正确处理 &quot;、&amp;、&lt;、&#xA; 等引用。
  • 返回空字符串时,无法判断属性不存在,还是属性存在但值为空。
  • 错误数据可能已经写入 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() 返回解析后的属性值。示例中的引用会转换为:

  • &lt; 变为 <
  • &quot; 变为 "
  • &amp; 变为 &
  • &#xA; 变为换行字符

因此,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 和存储格式往往会成为主要开销。

备注:内容仅供参考。