Qt 中的 XML 处理首先要区分顺序读取和文档修改。QXmlStreamReader 逐个读取标记,适合扫描或提取字段;QDomDocument 在内存中保留整棵树,便于查找、创建和修改节点。
本篇保留原笔记的流式读取、DOM、.ui 检查、配置创建与修改、Python 解析,以及频谱任务模板示例。C++ 程序采用 C++17;DOM 的 ParseResult 接口要求 Qt 6.5 或更新版本。
选择接口与版本
跳转到“选择接口与版本”| 需求 | 当前接口 | 边界 |
|---|---|---|
| 顺序检查开始/结束元素、属性和文本 | QXmlStreamReader,属于 Qt Core | 流式读取不代表程序积累的结果也没有内存开销 |
| 随机访问或修改小型文档 | QDomDocument,链接 Qt6::Xml | 整棵 DOM 占用内存,复制句柄也不等于深复制树 |
| 创建顺序输出文档 | QXmlStreamWriter | 适合按既定结构写出,仍要检查设备错误 |
| 维护旧 SAX 代码 | 旧 QXmlSimpleReader 等 | 已从 Qt 6 的 Qt XML 移除;过渡代码可使用 Core5Compat |
不要将旧 SAX 类作为新 Qt 6 项目的默认选择。官方迁移建议使用 QXmlStreamReader;兼容模块只是帮助维护旧代码。Qt 6 XML 变化
流式阅读的基本循环是反复调用 readNext():isStartElement() 时读名称和属性,isEndElement() 时处理闭合,isCharacters() 时读取文本。文本可能被分成多个标记;trimmed() 只适合你已经决定忽略首尾空白的字段,不能对任意 XML 文本都这样处理。QXmlStreamReader
任务模板:名字相同还不够
跳转到“任务模板:名字相同还不够”原示例中的默认命名空间是 http://www.xxxx.com/cm,Python 却查找 http://www.pruftechnik.com/cm,因此找不到对应元素。URI 不一致就是不同的命名空间;它在这里是标识符,不是要求解析器访问的网页。
下面继续使用原 XML 的示例 URI,并明确把它当作教学格式,不声称这是厂商实际文件的完整规范。真实文件应按它自己的 URI、层级和版本约定解析。
| 层级 | 保留的字段与示例值 |
|---|---|
task_template | version=4.0.0、task_id 末尾为 003、name=Spec (a) 10Hz - 12800Hz、source=factory |
meas_setup | kind=trending_spectrum_amplitude |
spectrum | name=Spectrum Velocity、res_id 末尾为 002、quant=acc、lines=51200、window=hanning |
time_waveform_config | name=Time Waveform Velocity、res_id 末尾为 001、quant=acc |
average | num=3、overlap=60、factor=0.5、type=lin |
freq_spectrum | max=12800、max_type=time_based、min=10 |
旧 C++ 代码只比较 xml.name(),会把错误命名空间或错误层级中的同名元素也当成目标。本例同时检查 URI 和完整元素路径,要求六个节点各出现一次、必需属性非空。它只验证这个明确的教学子集,尚未验证字段间的全部业务关系。
完整例子一:按命名空间与层级读取
跳转到“完整例子一:按命名空间与层级读取”此程序只需 Qt6::Core。它拒绝超过 1 MiB 的输入、超过 16 层的结构、DTD、未知层级和非空文本;这些是本示例格式的边界,不是 XML 标准的一般限制。用 Debug 配置运行断言。
#include <QByteArray>#include <QDebug>#include <QMap>#include <QStringList>#include <QXmlStreamReader>#include <cassert>#include <stdexcept>
using Attributes = QMap<QString, QString>;using TaskFields = QMap<QString, Attributes>;const QString taskNamespace = QStringLiteral("http://www.xxxx.com/cm");
TaskFields readTask(const QByteArray& bytes) { if (bytes.size() > 1024 * 1024) throw std::runtime_error("Task input exceeds 1 MiB"); const QString root = QStringLiteral("task_template"); const QString setup = root + "/meas_setup"; const QString spectrum = setup + "/spectrum"; const QMap<QString, QStringList> required{ {root, {"version", "task_id", "name", "source"}}, {setup, {"kind"}}, {spectrum, {"name", "res_id", "quant", "lines", "window"}}, {spectrum + "/time_waveform_config", {"name", "res_id", "quant"}}, {spectrum + "/average", {"num", "overlap", "factor", "type"}}, {spectrum + "/freq_spectrum", {"max", "max_type", "min"}} }; QXmlStreamReader xml(bytes); QStringList stack; TaskFields fields; while (!xml.atEnd()) { xml.readNext(); if (xml.isDTD()) { xml.raiseError("DTD is not part of this task format"); } else if (xml.isStartElement()) { stack.append(xml.name().toString()); const QString key = stack.join('/'); if (stack.size() > 16 || xml.namespaceUri() != taskNamespace || !required.contains(key) || fields.contains(key)) { xml.raiseError("Unexpected namespace, path, depth or duplicate element"); break; } Attributes values; for (const QString& attribute : required.value(key)) { // 这些属性不带命名空间,按本格式的明确约定读取。 const QString value = xml.attributes().value(QString{}, attribute).toString(); if (value.isEmpty()) { xml.raiseError("Missing required attribute"); break; } values.insert(attribute, value); } if (xml.hasError()) break; fields.insert(key, values); } else if (xml.isEndElement()) { if (!stack.isEmpty()) stack.removeLast(); } else if (xml.isCharacters() && !xml.isWhitespace()) { xml.raiseError("This task format stores values in attributes"); } } if (xml.hasError()) { const QString message = QStringLiteral("%1 at %2:%3") .arg(xml.errorString()).arg(xml.lineNumber()).arg(xml.columnNumber()); throw std::runtime_error(message.toUtf8().constData()); } if (fields.size() != required.size()) throw std::runtime_error("Missing required task element"); bool ok = false; const int lines = fields.value(spectrum).value("lines").toInt(&ok); if (!ok || lines <= 0) throw std::runtime_error("lines must be a positive int"); return fields;}
bool rejected(const QByteArray& bytes) { try { (void)readTask(bytes); } catch (const std::runtime_error&) { return true; } return false;}
int main() { const QByteArray sample = R"xml(<?xml version="1.0" encoding="UTF-8"?><task_template xmlns="http://www.xxxx.com/cm" version="4.0.0" task_id="00000000-0000-0000-0000-000000000003" name="Spec (a) 10Hz - 12800Hz" source="factory"> <meas_setup kind="trending_spectrum_amplitude"> <spectrum name="Spectrum Velocity" res_id="00000000-0000-0000-0000-000000000002" quant="acc" lines="51200" window="hanning"> <time_waveform_config name="Time Waveform Velocity" res_id="00000000-0000-0000-0000-000000000001" quant="acc"/> <average num="3" overlap="60" factor="0.5" type="lin"/> <freq_spectrum max="12800" max_type="time_based" min="10"/> </spectrum> </meas_setup></task_template>)xml"; const TaskFields fields = readTask(sample); assert(fields.size() == 6); const QString spectrum = QStringLiteral("task_template/meas_setup/spectrum"); assert(fields.value(spectrum).value("lines") == "51200"); assert(fields.value(spectrum + "/freq_spectrum").value("max") == "12800"); for (auto node = fields.cbegin(); node != fields.cend(); ++node) qDebug().noquote() << node.key() << node.value();
QByteArray wrongNamespace = sample; wrongNamespace.replace("http://www.xxxx.com/cm", "urn:wrong"); assert(rejected(wrongNamespace)); QByteArray missingAttribute = sample; missingAttribute.replace(" lines=\"51200\"", ""); assert(rejected(missingAttribute)); QByteArray wrongType = sample; wrongType.replace("lines=\"51200\"", "lines=\"abc\""); assert(rejected(wrongType)); assert(rejected(sample.left(sample.size() - 16)));}读取实际文件时,先检查 QFile::open(),按上限读取并检查 QFile::error(),再交给解析器;不要先对任意文件 readAll(),然后才认为内存上限已受到保护。无事件的同步解析程序完成后直接退出,不能因为创建了 QCoreApplication 就无条件进入 exec()。
DOM:每个节点只访问一次
跳转到“DOM:每个节点只访问一次”原遍历函数内部会走完整条兄弟链,外部又逐个兄弟调用它,造成重复。以下约定递归函数只负责当前节点及其子树,兄弟节点只由父循环推进一次。
DOM 的节点是共享句柄;要独立修改一份文档,应使用深度 cloneNode(true) 等方式,而不是假定普通复制隔离了内容。setContent 的结果还应保留错误信息及行列号。QDomDocument
下例中的 QtSettings 和 .ui XML 都没有命名空间。setContent(bytes) 默认关闭命名空间处理;解析带命名空间的文档时,应传入 QDomDocument::ParseOption::UseNamespaceProcessing,再按 URI 与本地名称检查节点,不能把这里按 tagName() 查找的代码直接当成前半篇任务模板的校验器。DOM 解析选项
完整例子二:创建、修改、保存和检查 UI XML
跳转到“完整例子二:创建、修改、保存和检查 UI XML”程序需要 Qt6::Core 与 Qt6::Xml。它创建 QtSettings、800×600 的 MainWindow 和 btnOK,把文本改为 New Text,添加 btnCancel,随后保存并重新解析。setText 能处理空 Text 元素,不假设它一定已经有第一个文本子节点。
#include <QByteArray>#include <QDomDocument>#include <QFile>#include <QMap>#include <QSaveFile>#include <QStringList>#include <QTemporaryDir>#include <cassert>#include <stdexcept>
void require(bool success, const QString& message) { if (!success) throw std::runtime_error(message.toUtf8().constData());}
QDomDocument parse(const QByteArray& bytes) { QDomDocument document; const auto result = document.setContent(bytes); require(static_cast<bool>(result), QStringLiteral("%1 at %2:%3") .arg(result.errorMessage).arg(result.errorLine).arg(result.errorColumn)); return document;}
QDomElement add(QDomDocument& doc, QDomNode parent, const QString& tag, const QString& text = {}) { QDomElement element = doc.createElement(tag); parent.appendChild(element); if (!text.isNull()) element.appendChild(doc.createTextNode(text)); return element;}
void setText(QDomDocument& doc, QDomElement element, const QString& text) { while (!element.firstChild().isNull()) element.removeChild(element.firstChild()); element.appendChild(doc.createTextNode(text));}
void visit(const QDomNode& node, QMap<QString, int>& counts) { if (node.isElement()) ++counts[node.toElement().tagName()]; for (QDomNode child = node.firstChild(); !child.isNull(); child = child.nextSibling()) visit(child, counts);}
int main() { QDomDocument doc; doc.appendChild(doc.createProcessingInstruction("xml", "version=\"1.0\" encoding=\"UTF-8\"")); QDomElement root = add(doc, doc, "QtSettings"); root.setAttribute("version", "1.0"); QDomElement window = add(doc, root, "Window"); window.setAttribute("name", "MainWindow"); add(doc, window, "Width", "800"); add(doc, window, "Height", "600"); QDomElement ok = add(doc, root, "Button"); ok.setAttribute("name", "btnOK"); QDomElement okText = add(doc, ok, "Text", "OK"); setText(doc, okText, "New Text"); QDomElement cancel = add(doc, root, "Button"); cancel.setAttribute("name", "btnCancel"); QDomElement cancelText = add(doc, cancel, "Text"); setText(doc, cancelText, "Cancel");
QMap<QString, int> counts; visit(doc.documentElement(), counts); assert(counts.value("Button") == 2 && counts.value("Text") == 2); assert(okText.text() == "New Text" && cancelText.text() == "Cancel");
QTemporaryDir temporary; require(temporary.isValid(), QStringLiteral("Cannot create temporary directory")); const QString filename = temporary.filePath("qt_config_modified.xml"); QSaveFile output(filename); const bool opened = output.open(QIODevice::WriteOnly); require(opened, output.errorString()); const QByteArray bytes = doc.toByteArray(2); const qint64 written = output.write(bytes); require(written == bytes.size(), output.errorString()); const bool committed = output.commit(); require(committed, output.errorString()); QFile input(filename); const bool inputOpened = input.open(QIODevice::ReadOnly); require(inputOpened, input.errorString()); const QByteArray loaded = input.readAll(); // 文件由本程序刚生成,尺寸已知很小 require(input.error() == QFileDevice::NoError, input.errorString()); const QDomDocument roundTrip = parse(loaded); assert(roundTrip.documentElement().firstChildElement("Button") .firstChildElement("Text").text() == "New Text");
const QDomDocument ui = parse(R"xml(<ui version="4.0"><class>Window</class><widget class="QWidget" name="Window"><widget class="QPushButton" name="btnOK"/></widget><connections><connection><sender>btnOK</sender><signal>clicked()</signal><receiver>Window</receiver><slot>close()</slot></connection></connections></ui>)xml"); const QDomNodeList widgets = ui.documentElement().elementsByTagName("widget"); assert(widgets.size() == 2); QStringList widgetDescriptions; for (int i = 0; i < widgets.size(); ++i) { const QDomElement element = widgets.at(i).toElement(); widgetDescriptions.append(element.attribute("name") + ":" + element.attribute("class")); } assert(widgetDescriptions.contains("btnOK:QPushButton")); const QDomNodeList connections = ui.documentElement().elementsByTagName("connection"); assert(connections.size() == 1); const QDomElement connection = connections.at(0).toElement(); assert(connection.firstChildElement("sender").text() == "btnOK"); assert(connection.firstChildElement("signal").text() == "clicked()"); assert(connection.firstChildElement("receiver").text() == "Window"); assert(connection.firstChildElement("slot").text() == "close()");}示例对 .ui 只读取控件和连接声明,没有据此创建窗口或执行连接。真正的 Designer UI 加载见 .ui 文件与所有权。QSaveFile 在提交前使用临时文件写入,仍须检查 write() 与 commit(),不能把“打开成功”当成“保存成功”。QSaveFile
Python 解析同一份任务模板
跳转到“Python 解析同一份任务模板”原 PyQt5 版本的 QFile/QXmlStreamReader 循环与前面的流式思路相同;维护旧 PyQt5 项目时按对应绑定版本使用这些类,不要将它的包名混入 Qt 6 C++ 构建要求。仅需离线处理这类小文件时,Python 标准库的 ElementTree 就能按完整命名空间查找元素。ElementTree 命名空间
下面是独立脚本,运行时传入 XML 路径。它提取上表中的全部字段,并明确处理文件错误、XML 语法错误以及缺失字段。它与前面的 C++ 格式检查不是完整等价校验器:此脚本只查必需的直接子节点,其他扩展节点没有被一律拒绝。
import argparseimport xml.etree.ElementTree as ET
NAMESPACE = "http://www.xxxx.com/cm"
def read_task(filename): with open(filename, "rb") as source: data = source.read(1024 * 1024 + 1) if len(data) > 1024 * 1024: raise ValueError("task input exceeds 1 MiB") root = ET.fromstring(data) if root.tag != f"{{{NAMESPACE}}}task_template": raise ValueError("unexpected root or namespace")
def child(parent, name): matches = parent.findall(f"{{{NAMESPACE}}}{name}") if len(matches) != 1: raise ValueError(f"expected exactly one {name}") return matches[0]
setup = child(root, "meas_setup") spectrum = child(setup, "spectrum") nodes = { "task_template": (root, ("version", "task_id", "name", "source")), "meas_setup": (setup, ("kind",)), "spectrum": (spectrum, ("name", "res_id", "quant", "lines", "window")), "time_waveform_config": (child(spectrum, "time_waveform_config"), ("name", "res_id", "quant")), "average": (child(spectrum, "average"), ("num", "overlap", "factor", "type")), "freq_spectrum": (child(spectrum, "freq_spectrum"), ("max", "max_type", "min")), } result = {} for name, (element, attributes) in nodes.items(): result[name] = {} for attribute in attributes: value = element.get(attribute) if value is None or value == "": raise ValueError(f"missing {name}.{attribute}") result[name][attribute] = value if int(result["spectrum"]["lines"]) <= 0: raise ValueError("lines must be positive") return result
if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("filename") arguments = parser.parse_args() try: for name, fields in read_task(arguments.filename).items(): print(name, fields) except (OSError, ET.ParseError, ValueError) as error: parser.exit(1, f"Cannot read task template: {error}\n")历史示例使用的 D:/shawei/temp/jpbalance/temp/TaskTemplates/Shock Pulse.xml 只是当时的本机路径。脚本通过参数接收路径,避免把这一路径写死为其他人的运行前提。真实文件的频率单位、有效范围、量值代码和兼容版本,需要按实际格式另行验证。