
简介一套基于 Visual C 的纯原生 XML 解析与读写示例工程面向需要在 Windows 平台上处理可扩展标记语言又不想额外安装第三方库的 C/C 开发者。工程代码覆盖文档对象模型的节点遍历、节点增删改查、基于事件的流式解析、XML 序列化输出、特殊字符转义以及常见解析错误处理等关键知识点并配有 ForDelNode、CreateXml 等示例文档用来对照理解动态创建节点、按条件删除节点和把数据写回 XML 文件的完整思路。压缩包共 18 个文件以 7 个 cpp 源文件和 5 个头文件作为主体另含 3 个 XML 样例、工程配置文件与说明文本整体大小仅 46KB结构紧凑适合逐行阅读、修改实验和直接移植。已有 189 人学习下载对于希望从底层理解 XML 语法和对象模型、同时巩固文件输入输出与异常处理能力的开发者来说是一份轻量而完整的小型参考工程。1. 纯原生Visual C解析XML这套源代码能把读写闭环全部拉通Visual C解析XML这个需求在很多老项目里不是“选哪个XML库”的问题而是“客户机环境允不允许动”。引xerces-c要管DLL搜索路径引pugixml虽然只有头文件但也要跟现有编译选项磨合。这套纯原生源代码把XML的读取和写入全部落在Windows系统自带的MSXML组件上不装第三方库不往工程里塞额外运行时用VC的ATL智能指针加上msxml6接口就可以完成加载、遍历、取值、建树、保存的完整闭环。适合维护MFC老项目的场景也适合想要轻量级配置管理的工程。跟着下面的步骤走从COM初始化到最后封装每一段都能直接编进现有项目。2. MSXML COM组件选型为什么原生方案不自己写解析器2.1 自写解析器与MSXML的差距手写一个XML解析器看起来很酷但真要处理DTD、实体引用、注释节点、CDATA、命名空间和字符编码工程量比想象中大得多。C标准库本身不提供XML解析能力用正则表达式提取标签在简单场景下可行遇到嵌套结构、自闭合标签、属性值转义、CDATA段之后就会变成灾难。MSXML是Windows系统自带的XML解析器以COM组件形式暴露Visual C解析XML时直接创建DOMDocument对象就是把解析这层黑匣子交给系统组件工程自身只负责调用。在可靠性、代码体积、后续维护三个维度上都比自写解析器划算。MSXML有几个并存的版本老系统上常见MSXML 3.0新系统标配MSXML 6.0。3.0对应CLSID_DOMDocument30和msxml3.dll兼容XP到Win106.0对应CLSID_DOMDocument60和msxml6.dll安全策略更严格默认拒绝加载外部DTD。二者接口基本一致切换版本只需要改一个CLSID。在做选型时我一般直接锁6.0新系统一定带msxml6.dll安全争议小语法校验严格排错信号清晰只有目标机器还是Windows XP SP2这种古董才降级到3.0。下表把常用版本的关键参数列出来版本类IDDLL文件典型场景MSXML 3.0CLSID_DOMDocument30 / __uuidof(DOMDocument30)msxml3.dll老系统兼容、XP/Win7MSXML 6.0CLSID_DOMDocument60 / __uuidof(DOMDocument60)msxml6.dllWin7及以上、安全性要求较高2.2 CoInitializeEx与CoCreateInstance实例化有了版本和CLSID下一步是让COM对象能被创建。很多新手在VC中跳过COM初始化直接调CoCreateInstance返回CO_E_NOTINITIALIZED才回来查资料。MSXML是COM组件使用前必须先初始化线程的COM库这是第一个容易翻车的地方。#include windows.h #include msxml6.h // MSXML 6.0 接口与类ID定义 #include atlbase.h // CComPtr 智能指针 #include atlcomcli.h // CComBSTR/CComVariant HRESULT hr CoInitializeEx(NULL, COINIT_MULTITHREADED); if (FAILED(hr) hr ! RPC_E_CHANGED_MODE) { return -1; } CComPtrIXMLDOMDocument2 spDoc; hr spDoc.CoCreateInstance(__uuidof(DOMDocument60)); if (FAILED(hr)) { return -2; }代码前两行初始化当前线程的COM库COINIT_MULTITHREADED让COM对象跑在多线程套间里后续如果从工作线程解析XML不会因为套间模型不一致再次报错。注意返回值RPC_E_CHANGED_MODE如果主线程已经用另一种模式初始化过COM第二次调用会返回这个值代表“改变模式失败”但不是一个致命错误代码里要容忍它。后半段是核心__uuidof(DOMDocument60)从msxml6.h取出MSXML 6.0的CLSIDCComPtr的CoCreateInstance负责创建实例并完成引用计数管理。这里用CComPtr而不是裸指针是为了让指针的AddRef/Release被自动管理。后面几乎所有接口调用都基于它省去一多半手工释放的麻烦。MSXML的接口大量使用输出参数返回值HRESULT参数是指针CComPtr对这套模型支持得最好。用#import msxml6.dll可以省去手写接口声明的麻烦但是import会生成.tlh和.tli包装文件build输出目录里多出一堆文件对“纯原生源代码”这个目标不太友好。直接包含msxml6.h则干净很多头文件、库都在系统SDK里编译器直接解析不生成额外文件。我后面所有示例都按msxml6.h的方式写。2.3 为什么用IXMLDOMDocument2而不是IXMLDOMDocument选IXMLDOMDocument2而不是IXMLDOMDocument是因为MSXML 6.0把schema、validateOnParse等实用属性放在2代接口上。IXMLDOMDocument2继承自IXMLDOMDocument所以load、get_documentElement、createElement这些基础方法完全保留。MSXML接口方法几乎都返回HRESULT输出参数是指针。使用CComPtr时不能直接把spDoc传给方法因为CComPtr的operator在某些编译选项下被重载成返回内部成员地址编译不过。我习惯的写法是声明裸指针接收再Attach给CComPtrIXMLDOMElement* pElem NULL; hr spDoc-get_documentElement(pElem); if (SUCCEEDED(hr) pElem ! NULL) { CComPtrIXMLDOMElement spElem; spElem.Attach(pElem); // 直接接管引用计数 }get_documentElement返回的接口指针已经带引用计数所以这里必须用Attach而不是赋值赋值会增加一次引用导致该节点永远释放不掉。这个“裸指针接收 Attach接住”的习惯会贯穿整个读取和写入流程。后面所有代码都按这个节奏写能避开一整类CComPtr与API输出参数的编译错误和引用计数泄漏问题。3. 读取XML文件加载、节点遍历、属性与文本提取3.1 load文件与loadXML字符串两种加载路径拿到IXMLDOMDocument2对象第一步是往里面填XML。load和loadXML是两条不同的路径load从文件路径、URL或IStream载入XMLloadXML直接从内存字符串载入。二者都通过VARIANT_BOOL输出是否成功。VARIANT_BOOL bLoaded VARIANT_FALSE; hr spDoc-load(CComVariant(Lconfig.xml), bLoaded); if (bLoaded VARIANT_FALSE) { CComPtrIXMLDOMParseError spErr; if (SUCCEEDED(spDoc-get_parseError(spErr))) { CComBSTR bstrReason; spErr-get_reason(bstrReason); // 输出错误原因常见的是路径找不到或语法错误 } return -3; }load的第一个参数是VARIANT直接传文件路径字符串即可。最容易翻车的是相对路径相对路径是相对于进程当前工作目录而不是exe所在目录。用Visual Studio调试时当前目录通常是工程目录双击exe运行时是exe目录从服务或计划任务启动时又可能是system32目录。我的习惯是把相对路径统一转成绝对路径再传给load避免不同启动方式下同一份配置读出不同结果。提示相对路径随启动方式变化建议把XML路径统一转成绝对路径可以用GetFullPathName或_wfullpath转换后再传给load。loadXML的写法更直接CComBSTR bstrXml(Lconfigip192.168.1.10/ip/config); hr spDoc-loadXML(bstrXml, bLoaded); if (bLoaded VARIANT_FALSE) { // 同样用 get_parseError 拿错误原因 }loadXML适合测试单节点、构造临时报文省去临时文件读写。要留意的是loadXML对字符串编码的判定BSTR在MSXML看来是UTF-16如果XML声明写的是gb2312而字符串内容不是UTF-16编码会触发编码冲突错误。这个细节放到第5章避坑里单独讲。3.2 遍历DOM树get_childNodes与get_item的下标XML加载成功后从文档根部往下爬。get_documentElement拿根节点get_childNodes拿子节点列表get_item按下标取节点。CComPtrIXMLDOMElement spRoot; { IXMLDOMElement* pElem NULL; hr spDoc-get_documentElement(pElem); if (FAILED(hr) || pElem NULL) return -4; spRoot.Attach(pElem); } CComPtrIXMLDOMNodeList spNodes; { IXMLDOMNodeList* pList NULL; hr spRoot-get_childNodes(pList); if (FAILED(hr) || pList NULL) return -5; spNodes.Attach(pList); } long nCount 0; spNodes-get_length(nCount); for (long i 0; i nCount; i) { IXMLDOMNode* pNode NULL; hr spNodes-get_item(i, pNode); if (FAILED(hr) || pNode NULL) continue; CComPtrIXMLDOMNode spNode; spNode.Attach(pNode); CComBSTR bstrName; spNode-get_nodeName(bstrName); // 根据 bstrName 做分支处理 }get_childNodes返回的NodeList是实时视图。如果遍历过程中修改DOM树列表可能同步变化所以不要在循环里删节点。get_item越界时返回NULL指针代码里做了空指针判断这对结构不规范的XML文件是保命的。nodeName对元素节点返回标签名对文本节点返回#text对注释节点返回#comment。做遍历时如果只按名字判断很容易把文本节点误当成元素节点需要在分支里用get_nodeType判断节点类型。NODE_TYPE枚举里NODE_ELEMENT是1NODE_TEXT是3NODE_ATTRIBUTE是2做遍历逻辑先过滤掉非元素节点后续代码会干净很多。3.3 读取元素文本与属性get_text和getAttribute节点遍历结合看最核心的两个需求是读元素文本和读属性值。CComPtrIXMLDOMElement spRoot; { IXMLDOMElement* pElem NULL; spDoc-get_documentElement(pElem); spRoot.Attach(pElem); } CComPtrIXMLDOMNode spIpNode; { IXMLDOMNode* pNode NULL; hr spRoot-selectSingleNode(CComBSTR(Lip), pNode); if (SUCCEEDED(hr) pNode ! NULL) { spIpNode.Attach(pNode); } } CComBSTR bstrIp; if (spIpNode ! NULL) { spIpNode-get_text(bstrIp); // get_text 返回子树拼接文本 } CComVariant varVersion; hr spRoot-getAttribute(CComBSTR(Lversion), varVersion); if (SUCCEEDED(hr) varVersion.vt VT_BSTR) { CComBSTR bstrVersion(varVersion.bstrVal); }selectSingleNode传XPath表达式这里传ip表示当前节点的直接子节点ip。层级深时可以写server/ip或server[port8080]/ipMSXML的XPath支持很完整。get_text返回节点及其所有后代拼接的文本这是和get_nodeValue最大的区别get_nodeValue对元素节点返回空值只有对文本节点才返回内容。所以读元素内容优先get_text读精确的文本节点才用get_nodeValue。属性读取用getAttribute返回VARIANT。先判断vt是不是VT_BSTR因为整数型、布尔型属性MSXML会按类型返回。VARIANT用完要交给CComVariant管理生命周期显式做VariantClear也可以。从bstrVal构造CComBSTR时它只拷贝第一个字符串所以原始VARIANT里如果有共享引用必须保证释放顺序这一点在循环读属性时尤其重要。4. 写入XML文件构造DOM树、增删改节点、序列化保存4.1 createElement与appendChild构造DOM树读解决之后写是另一条腿。写XML从创建DOM树开始createElement创建元素appendChild把元素挂到父节点下。CComPtrIXMLDOMDocument2 spDoc; spDoc.CoCreateInstance(__uuidof(DOMDocument60)); CComPtrIXMLDOMElement spRoot; { IXMLDOMElement* pElem NULL; spDoc-createElement(CComBSTR(Lconfig), pElem); spRoot.Attach(pElem); } IXMLDOMNode* pOut NULL; hr spDoc-appendChild(spRoot, pOut); if (pOut ! NULL) pOut-Release();创建元素之后元素本身不在文档中必须appendChild到文档或某个节点否则save出来的文件是空的。appendChild返回的pOut是新挂接后的节点引用不需要时立即ReleasespRoot仍然持有创建时的引用计数这部分交给CComPtr管理。用CComPtr接住创建结果后全程不需要手写Release函数结束自动释放这是最不容易漏的写法。如果整个流程都使用裸指针每创建一个元素就要记一次释放稍一疏忽就会泄漏。4.2 创建子节点、设置文本与属性根节点挂好之后往里面添加子节点并给子节点填充文本和属性CComPtrIXMLDOMElement spServer; { IXMLDOMElement* pElem NULL; spDoc-createElement(CComBSTR(Lserver), pElem); spServer.Attach(pElem); } CComPtrIXMLDOMElement spIp; { IXMLDOMElement* pElem NULL; spDoc-createElement(CComBSTR(Lip), pElem); spIp.Attach(pElem); } spIp-put_text(CComBSTR(L192.168.1.10)); spServer-appendChild(spIp, NULL); // 不需要输出的新节点引用 VARIANT varPort; VariantInit(varPort); varPort.vt VT_I4; varPort.lVal 8080; hr spServer-setAttribute(CComBSTR(Lport), varPort); VariantClear(varPort); IXMLDOMNode* pOut NULL; hr spRoot-appendChild(spServer, pOut); if (pOut ! NULL) pOut-Release();put_text在元素节点上设置文本内容它等价于先清除现有子节点再添加一个文本节点所以不要在已有文本的节点上反复put_text否则上一次的内容会被整体覆盖。setAttribute接受VARIANT传入VT_I4序列化出来是数字传入VT_BSTR序列化出来是字符串。设置属性之前要VariantInit用完VariantClear释放否则遇到BSTR类型的属性值会内存泄漏。如果需要复用属性节点可以用createAttribute配合setAttributeNode但常规配置文件用setAttribute就够了。如果目标XML带命名空间createElement这种无命名空间创建方式不够用要用createNode(NODE_ELEMENT, Lprefix:localName, Lhttp://schema.example.com/xxx)先创建带命名空间的节点再appendChild。命名空间URI会被MSXML自动处理成xmlns:prefix声明。绝大多数配置文件用不到这个特性但数据交换类的XML经常用到写进封装类时需要把这个参数暴露出来。4.3 save保存文件与XML声明的编码控制树建完最后是序列化成文件。save接受VARIANT目标可以是文件路径、IStream或ASP对象。最常见的是直接传路径hr spDoc-save(CComVariant(LC:\\work\\output.xml)); if (FAILED(hr)) { // 常见原因目标目录不存在、目录无写权限、文件被占用 }保存时编码怎么控制是大多数人都会踩的地方。MSXML保存文件时如果不显式声明默认按UTF-8写入但文件头部的XML声明是它自己推断出来的。更可控的做法是创建文档后先插入一条处理指令CComPtrIXMLDOMProcessingInstruction spPi; { IXMLDOMProcessingInstruction* pPi NULL; hr spDoc-createProcessingInstruction( CComBSTR(Lxml), CComBSTR(Lversion\1.0\ encoding\UTF-8\), pPi); if (SUCCEEDED(hr) pPi ! NULL) { spPi.Attach(pPi); } } CComVariant varNull; varNull.vt VT_NULL; IXMLDOMNode* pInserted NULL; spDoc-insertBefore(spPi, varNull, pInserted); if (pInserted ! NULL) pInserted-Release(); // 此时文档只有处理指令再挂根节点 spDoc-appendChild(spRoot, NULL);insertBefore的refChild参数传VT_NULL表示追加到文档末尾。由于此时文档里还没有根节点处理指令成为唯一节点之后再appendChild根节点处理指令就保持在根节点之前。如果反过来先挂根节点再插处理指令处理指令会跑到根节点后面那就不再是合法的XML文档结构。注意保存编码由处理指令里的encoding声明决定不要指望save去自动检测原文件编码。把encodingUTF-8改成encodinggb2312保存时MSXML会按目标编码输出字节流。MSXML内部处理媒介是UTF-16转换过程依赖系统代码页在精简版Windows镜像上缺了GB2312代码页时保存会失败。跨平台交换统一用UTF-8国内老系统对接才考虑gb2312。5. 避坑与常见问题排查编码乱码、内存泄漏、路径与线程5.1 现象XML加载后中文乱码保存再打开时编码又变了原因XML声明里写的是encodingUTF-8但文件实际字节是GB2312MSXML严格按声明去解释字节流。声明和真实编码不一致读出来必然乱码反过来保存时声明决定输出编码声明不对写出来的文件换个环境看就是乱码。解决先确认文件真实编码用十六进制看文件头部UTF-8的ASCII范围字节与GB2312共存时特征很明显。如果文件是GB2312要么转成UTF-8再交给XML处理要么把xml声明改成gb2312并保存为ANSI字节流。我一般在生成侧直接定死UTF-8读取侧不做自动探测避免两边各自猜编码。乱码这类问题排查最花时间先把编码定死能省掉一半排查工作。5.2 现象程序退出时崩溃怀疑COM对象没释放干净原因MSXML接口返回的指针大多带引用计数裸指针接收后忘了Release或反复把新指针Attach到同一个CComPtr上旧指针的引用计数泄漏。更隐蔽的是释放顺序错乱子节点还没释放就把父节点释放了析构时COM组件内部状态不一致直接崩溃。解决统一用CComPtr接收接口不碰裸指针循环里复用同一个CComPtr时先Release再Attach。在程序退出路径上所有CComPtr离开作用域后再调CoUninitialize。MFC工程里如果对话框类持有CComPtr成员变量析构顺序由类成员声明顺序决定把文档对象声明在最后让它先析构能减少一批偶发崩溃。5.3 现象load返回VARIANT_FALSE但浏览器打开XML文件正常原因load失败的绝大多数原因不是XML语法错误而是路径找不到、文件被占用或权限不足。浏览器做容错解析缺闭合标签也能渲染MSXML的DOM加载严格一个标签不闭合就整体失败。解决先把相对路径转成绝对路径用GetFullPathName或PathCombine处理再用CreateFile以共享读方式打开一次文件验证路径和权限。如果路径没问题把parseError的reason输出到日志里它会把具体行号和错误码带出来直接定位到问题标签。有次遇到“系统找不到指定的资源”最后发现是工作目录不对路径里的反斜杠没转义。5.4 现象多线程并发解析XML一个线程报错另一个线程正常原因MSXML的DOMDocument与线程套间绑定。每个线程自己CoInitializeEx并创建独立DOMDocument一般没问题如果多个线程共享同一个DOMDocument实例非创建线程的调用会走COM代理轻则性能下降重则返回RPC_E_WRONG_THREAD。解决多线程场景给每个线程创建独立的DOMDocument实例不共享。CoInitializeEx(COINIT_MULTITHREADED)之后各线程独立创建、独立释放自己的实例。封装类里把m_spDoc设计成实例成员而不是静态成员隐含要求就是“一个对象对应一个线程”从根上杜绝共享。5.5 现象加载XML报告DTD解析错误开发环境里却正常原因MSXML 6.0默认禁止解析外部DTD和外部实体是对XXE这类攻击的安全防御。如果XML里带了DOCTYPE声明并引用外部DTD而目标机器上的MSXML是6.0load就会失败。解决把XML重构为不依赖外部DTD的结构去掉DOCTYPE声明内部固定格式的配置文件本来就不需要DTD。如果业务上确实要读外部实体可以显式设置validateOnParse为false、resolveExternals为true但面对不可信XML时保持默认禁止更稳妥别为了过报错就放开解析策略。6. 进阶把原生MSXML读写封装成工具类老项目直接替换文件操作前面几节的代码已经把XML读写链路打通但每个模块复制粘贴一遍会乱。我习惯把这些操作封装成一个轻量类对外只暴露Load、Save、GetNodeText、SetNodeText四个方法。class CXmlDoc { public: bool Load(const CString strFilePath) { m_spDoc.CoCreateInstance(__uuidof(DOMDocument60)); CComVariant varPath(strFilePath); VARIANT_BOOL bLoaded VARIANT_FALSE; m_spDoc-load(varPath, bLoaded); return bLoaded VARIANT_TRUE; } CString GetNodeText(const CString strXPath) { CComPtrIXMLDOMNode spNode; IXMLDOMNode* pNode NULL; m_spDoc-selectSingleNode(CComBSTR(strXPath), pNode); if (pNode NULL) return CString(); spNode.Attach(pNode); CComBSTR bstrText; spNode-get_text(bstrText); return CString(bstrText); } private: CComPtrIXMLDOMDocument2 m_spDoc; };Load内部每次都重新创建DOMDocument实例保证一个实例只对应一个文档避免复用旧状态。GetNodeText用selectSingleNode定位XPath直接写在调用方读取逻辑和业务逻辑剥离。SetNodeText的核心是先selectSingleNode找不到时按路径逐级创建节点再put_text。这个封装让业务代码从“操作COM接口”变成“操作业务数据”XML的细节全部收在类里。这段封装实际落地后原有INI解析代码一千多行替换成类加XPath映射表几百个键值就能读完。从那以后我每次写XML处理模块都强制自己走一遍避坑清单编码是不是定死了UTF-8、路径是不是转成了绝对路径、每个线程是不是有独立的实例、每个COM指针是不是都被智能指针管着。这份纯原生Visual C解析读写XML的源代码就是把这几条全部跑通之后沉淀下来的。希望帮到你。本文还有配套的精品资源点击获取