☰
C#操作XML文件实战:读写、属性修改与节点增删全攻略
2026/9/26 7:32:18 网站建设 项目流程

直接就进入正题。C#里操作XML文件,是绝大多数上位机、桌面应用、配置管理项目都绕不开的活儿。最近我自己在做一个跨平台工控配置工具时,又跟XML文件打了不少交道,顺手把读写、属性修改、节点动刀的完整套路整理出来。这篇博文不是文档翻译,是我实际项目里跑过的源码,加上踩过的坑,往下翻可以直接抄作业。

先交代一下背景。手头这套系统需要把设备参数、通信地址、数据类型这些配置写到XML文件里,供上位机启动时加载;运行中还要根据操作动态修改某些属性,比如把enable从false拨到true、把timeout从3000改成5000。类似的活儿相信不少朋友都干过:C#读取XML、分析节点、改属性值、再存回去。看起来简单,真做起来,编码、命名空间、空值判断、XPath匹配,每个环节都能给你整出点幺蛾子。

这篇文章适合谁?正在写C#上位机、桌面工具、配置管理程序的人,尤其是刚接触XML处理、想绕过那些"坑"的开发者。我会把核心源码拆开讲清楚,并把XmlDocument、XDocument、XmlReader三种主流方式的使用边界说明白,这样你做技术选型的时候心里有数。

1. 内容整体设计与思路拆解

1.1 XML在C#项目里到底扮演什么角色

很多人觉得XML是"过气"的配置格式,但真实工程里它依然活跃:工控系统里的OPC配置、PLC通信参数表、仪器仪表的通道定义、老系统的数据交换接口,几乎都依赖XML。原因很简单,它自带层级结构,既是人能读的文本,又能被程序解析成一棵节点树,比INI文件表达复杂关系能力更强,比JSON在"带命名空间、带属性、带注释"的场景下更成熟。

C#处理XML和JSON不太一样。JSON用JsonSerializer一把梭,一个类映射一个对象;XML则有两条路:一条是把XML映射成对象(XmlSerializer),另一条是直接把XML当"文档树"操作(XmlDocument/XDocument)。真正到了要动态修改节点属性、增删节点这种精细活,文档树方式更顺手。本文的源码就是围绕文档树方式展开的。

1.2 三种API的选型逻辑:不用纠结,看你干什么活

C#里XML操作涉及三套常驻API:老的XmlDocument、后来更现代的XDocument(LINQ to XML)、以及面向读取优化的XmlReader。我做了个对比表,方便按需选择。

API内存模型适合场景缺点
XmlDocumentDOM树,全部加载进内存小文件、需要XPath、老项目维护代码偏冗,内存占用偏高
XDocumentDOM树 + LINQ中小文件、快速查询、函数式修改老同事可能不熟,部分工程还没迁移
XmlReader游标式流读取超大文件、只读遍历不能回退,修改节点不方便

实际项目里我的经验法则是:文件小于5MB、结构不复杂、需要频繁增删改,用XDocument;团队老代码全是XmlDocument,比如维护工控项目的时候,就顺着老代码用XmlDocument,风格统一,坑也少;大日志或者超大配置文件需要扫描,用XmlReader,内存占用能差出好几个量级。

本文主要用XmlDocument和XDocument两种各来一遍,因为这两种最能覆盖日常开发里"改属性值"的需求。

2. 环境准备与核心API梳理

2.1 建项目和引用命名空间

动手前先把工程准备利索。这里以一个普通的.NET项目为例,控制台、WinForms、WPF都可以,核心逻辑通用。

using System; using System.Xml; // XmlDocument 专用 using System.Xml.Linq; // XDocument 专用 using System.IO; // 文件读写 using System.Text; // 编码处理

这三个命名空间是基础。其中System.Xml.Linq是LINQ to XML的核心,如果你用的是.NET Core 3.0以上或者.NET 5+,直接默认就有,不需要额外安装包。老Framework项目也是.NET 3.5+就内置了。

注意:System.Xml.XPath也不能忘,某些老代码里会用到SelectSingleNode配合XPath的写法,这个命名空间负责提供XPathNavigator的能力。引用不全会报"命名空间不存在",新手经常在这卡一下。

2.2 准备一份会反复用到的XML样例

写代码之前,先搞一份有代表性的XML文件。以工控场景里的设备配置为例:

<?xml version="1.0" encoding="utf-8"?> <DeviceConfig version="2.0"> <Device name="PLC_01" enable="true" timeout="3000"> <Communication> <Protocol type="ModbusTCP" /> <Server ip="192.168.1.10" port="502" /> </Communication> <DataPoints> <Point id="1001" name="Temperature" address="40001" dataType="float" /> <Point id="1002" name="Pressure" address="40003" dataType="float" enable="false" /> </DataPoints> </Device> <Device name="PLC_02" enable="false"> <Communication> <Protocol type="S7" /> </Communication> </Device> </DeviceConfig>

这份XML涵盖了最常见的三种操作目标:

  • 根节点的属性:version="2.0"
  • 多层节点的属性:Device的enable、timeout、Point的address
  • 节点本身的内容:虽然这里没有纯文本内容,但后面增删节点时会用到类似<Description>主站</Description>的结构

3. 源码实战:加载与读取XML文件

3.1 用XmlDocument加载XML文件

老工程里最常见的加载方式,一段代码直接读取文件:

XmlDocument doc = new XmlDocument(); doc.Load("deviceConfig.xml"); XmlElement root = doc.DocumentElement; Console.WriteLine(root.GetAttribute("version")); // 2.0

这里doc.Load不仅能加载文件路径,还能重载一个接受Stream、TextReader、XmlReader的版本。我测试过,同样一个5MB的XML,直接用Load(string)最简单,但如果你要控制编码、去掉非法字符,最好自己构造一个XmlReaderSettings再传入。

doc.DocumentElement拿到的就是根节点DeviceConfig。之后再想往下访问子节点,最直观的是逐层GetElementsByTagName,但这样代码会很啰嗦。我更推荐组合XPath。

XmlNode plc1 = doc.SelectSingleNode("/DeviceConfig/Device[@name='PLC_01']"); if (plc1 != null) { string enable = plc1.Attributes["enable"].Value; string timeout = plc1.Attributes["timeout"].Value; Console.WriteLine($"PLC_01 enable={enable}, timeout={timeout}"); }

这里有几个坑必须说:

SelectSingleNode返回的是XmlNode?,找不到时是null,直接Attributes会空引用。项目里出现过好几次运行到一半崩溃,就是因为没判空。

属性名的访问要区分两种情况:Attributes["enable"]本身也可能是null,如果节点里根本没写enable属性,取.Value照样崩。所以稳妥写法是:

XmlAttribute enableAttr = plc1.Attributes?["enable"]; if (enableAttr != null) { /* use enableAttr.Value */ }

3.2 用XDocument加载并读取属性

XDocument的代码风格更紧凑,LINQ写起来舒服得多。

XDocument xdoc = XDocument.Load("deviceConfig.xml"); var plc1 = xdoc.Descendants("Device") .FirstOrDefault(d => (string)d.Attribute("name") == "PLC_01"); if (plc1 != null) { bool enable = (bool?)plc1.Attribute("enable") ?? false; int timeout = (int?)plc1.Attribute("timeout") ?? 5000; Console.WriteLine($"XDocument -> enable={enable}, timeout={timeout}"); }

这段代码里的精髓在(string)d.Attribute("name")和(bool?)plc1.Attribute("enable")。XDocument的XAttribute有隐式转换运算符,直接把这个attribute转成字符串、整数、布尔。转不成功就给你null,配合??给默认值,代码既安全又短。

在我实际经验里,读取操作用XDocument比XmlDocument舒服太多。特别是要取某个节点集合再筛选的时候,Descendants()横扫所有层级,不用像XPath那样写长串定位表达式。

3.3 读取属性值的三种方式对比

经常有朋友问,Attributes["xx"].Value、.GetAttribute("xx")、.Attribute("xx").Value到底啥区别。我列个表:

方式所属API不存在时行为推荐指数
XmlElement.GetAttribute("xx")XmlDocument返回空字符串,不报错高
XmlNode.Attributes?["xx"].ValueXmlDocument需要判空,否则崩中
XElement.Attribute("xx")?.ValueXDocument返回null,不报错高

GetAttribute这个方法挺良心,属性不存在返回空字符串,对读取来说很安全。不过也带来个小坑:你分不清"属性不存在"和"属性值是空字符串"这两种情况。如果业务逻辑上确实要区分,还是得用Attributes["xx"] == null来判断。

XDocument这边,我习惯先拿到XAttribute对象再判空,用?.Value或者直接强转,把空值风险挡在外面。

3.4 遍历多个同名节点并提取属性

读取往往不是拿一个节点,而是要遍历整个列表。比如把DataPoints下面所有Point节点拉出来:

XElement dataPoints = xdoc.Descendants("DataPoints").FirstOrDefault(); if (dataPoints == null) return; foreach (XElement point in dataPoints.Elements("Point")) { string id = (string)point.Attribute("id"); string name = (string)point.Attribute("name"); string address = (string)point.Attribute("address"); string dataType = (string)point.Attribute("dataType"); bool enable = (bool?)point.Attribute("enable") ?? true; Console.WriteLine($"Point {id}: {name}, address={address}, type={dataType}, enable={enable}"); }

这里用Elements("Point")而不是Descendants("Point"),是有讲究的。Elements只查子层级,Descendants查所有后代。DataPoints下面如果还有子分组,用Descendants会把脏数据也捞出来。我建议:能用Elements就不用Descendants,避免匹配到意外的同名节点。

4. 源码实战:属性值修改与节点操作

4.1 修改已有属性值

需求来了:运行中发现PLC_01的timeout要改成5000,enable要设成false。XDocument实现:

XDocument xdoc = XDocument.Load("deviceConfig.xml"); XElement plc1 = xdoc.Descendants("Device") .FirstOrDefault(d => (string)d.Attribute("name") == "PLC_01"); if (plc1 == null) return; plc1.SetAttributeValue("timeout", "5000"); plc1.SetAttributeValue("enable", false); xdoc.Save("deviceConfig.xml");

SetAttributeValue是修改属性的一把好手:属性存在,覆盖该属性;属性不存在,自动追加新属性。这个行为非常符合"配置更新"的语义。比如有时候上游配置漏了enable属性,你直接SetAttributeValue("enable", true),它就会帮你在正确位置加上,不需要先Add再判断。

用XmlDocument写会麻烦一点,但老项目里避不开:

XmlDocument doc = new XmlDocument(); doc.Load("deviceConfig.xml"); XmlElement plc1 = doc.SelectSingleNode("/DeviceConfig/Device[@name='PLC_01']") as XmlElement; if (plc1 == null) return; plc1.SetAttribute("timeout", "5000"); plc1.SetAttribute("enable", "false"); doc.Save("deviceConfig.xml");

SetAttribute的语义和SetAttributeValue类似,存在就覆盖,不存在就新建。两个API行为对齐,学起来不冲突。

4.2 批量修改场景:循环内更新属性

更现实的场景是批量把DataPoints下所有Point的enable批量改成需要的状态。比如停用所有地址大于40010的点:

XDocument xdoc = XDocument.Load("deviceConfig.xml"); XElement dataPoints = xdoc.Descendants("DataPoints").FirstOrDefault(); if (dataPoints == null) return; int count = 0; foreach (XElement point in dataPoints.Elements("Point")) { int address = (int?)point.Attribute("address") ?? 0; if (address > 40010) { point.SetAttributeValue("enable", false); count++; } } Console.WriteLine($"共更新 {count} 个节点"); xdoc.Save("deviceConfig.xml");

这里有个实战经验:Save操作不要放在循环里面。文件反复打开关闭,性能差不说,万一中途抛异常,XML文件可能处于半写状态,直接把配置搞损坏。我习惯先在内存里把所有修改都做完,最后一次Save。如果担心写一半崩溃,就采用"写临时文件+替换"的策略。

4.3 新增节点与新增属性

不只是改属性,结构上也要能"无中生有"。比如要给PLC_01新增一个Alarm节点,带上level和description属性:

XElement plc1 = xdoc.Descendants("Device") .FirstOrDefault(d => (string)d.Attribute("name") == "PLC_01"); if (plc1 == null) return; XElement alarm = new XElement("Alarm", new XAttribute("level", "1"), new XAttribute("description", "High temperature alarm")); plc1.Add(alarm); xdoc.Save("deviceConfig.xml");

多参数构造是XDocument最让我舒服的地方。一个节点连同它的所有属性、子节点,都可以在构造函数里一次性搭完。Add方法把新节点挂到plc1下面,新节点自动附到末尾。如果需要指定位置插入,可以用AddFirst或者AddAfterSelf。

XmlDocument版本就得三步走:

XmlElement plc1 = doc.SelectSingleNode("/DeviceConfig/Device[@name='PLC_01']") as XmlElement; if (plc1 == null) return; XmlElement alarm = doc.CreateElement("Alarm"); alarm.SetAttribute("level", "1"); alarm.SetAttribute("description", "High temperature alarm"); plc1.AppendChild(alarm); doc.Save("deviceConfig.xml");

XmlDocument的套路是"先Create,再SetAttribute,最后AppendChild",每一件事都要单独调一个方法。代码长,但也直观。想插入到指定位置,用InsertAfter或InsertBefore,配合参考节点来定位。

4.4 删除节点与删除属性

有增就有删。把enable="false"的Point全删掉:

xdoc.Descendants("Point") .Where(p => (bool?)p.Attribute("enable") == false) .Remove(); xdoc.Save("deviceConfig.xml");

Remove()是XElement的扩展方法,直接把匹配到的所有节点从父节点摘除。一句话完成"查询+删除",这在老XmlDocument里要写好几行。

只删属性不删节点,用SetAttributeValue("enable", null),传null就会移除这个属性。这个技巧挺冷门但很实用,比如上线前要清掉某些调试属性,一行搞定。

XmlDocument删除节点:

XmlNodeList points = doc.SelectNodes("/DeviceConfig/Device/DataPoints/Point[@enable='false']"); foreach (XmlNode point in new ArrayList(points)) { point.ParentNode.RemoveChild(point); }

注意这里我用new ArrayList(points)包了一下,这是个老坑:遍历XmlNodeList时直接删节点,集合长度动态变化,会漏删。先拷贝一份再遍历,就没这个问题了。

4.5 保存文件时的编码与声明问题

Save不是简单完事。XDocument默认保存时会带上<?xml version="1.0" encoding="utf-8"?>,这没问题。但有一个很常见的情况:原始XML声明是encoding="gb2312"或encoding="utf-16",你加载、修改、保存后,编码处理不当,整个文件会乱掉。

我的做法是保存时明确指定编码:

xdoc.Save("deviceConfig.xml", SaveOptions.DisableFormatting);

如果希望输出有缩进让文件好看,用默认就行。要指定UTF-8无BOM,这样跨平台兼容性最好:

using (var writer = new StreamWriter("deviceConfig.xml", false, new UTF8Encoding(false))) { xdoc.Save(writer); }

UTF8Encoding(false)的意思是"无BOM"。很多老设备不认带BOM的UTF-8文件,上位机下发配置时容易把第一个字节当成非法字符。这个细节我印象很深,之前在一台国产PLC上调试,对方怎么都解析不了配置,最后把BOM去掉,立竿见影。

5. 命名空间问题:最容易翻车的隐藏关卡

5.1 带xmlns属性时XPath全部失效

真实工程里XML文件经常带命名空间,比如:

<DeviceConfig xmlns="http://www.example.com/schema/device"> <Device name="PLC_01" /> </DeviceConfig>

这种情况下依然用/DeviceConfig/Device去SelectSingleNode,一定返回null。很多朋友第一次遇到会怀疑是不是XML文件没读到,其实是命名空间在作怪。原理是:XML默认命名空间下,节点全名不是DeviceConfig,而是{http://www.example.com/schema/device}DeviceConfig。XPath如果不注册前缀,根本匹配不到。

解决思路是创建XmlNamespaceManager:

XmlDocument doc = new XmlDocument(); doc.Load("deviceConfig_withns.xml"); XmlNamespaceManager nsmgr = new XmlNamespaceManager(doc.NameTable); nsmgr.AddNamespace("d", "http://www.example.com/schema/device"); XmlNode plc1 = doc.SelectSingleNode("/d:DeviceConfig/d:Device[@name='PLC_01']", nsmgr);

这里的d前缀只是个别名,只要和XML里的命名空间URI一致就行。关键技术点在于:AddNamespace里的URI必须和文档中xmlns的值一字不差,多一个空格都匹配不上,很容易排查半天。

5.2 XDocument与命名空间的交互方式

XDocument处理命名空间要换一套思路。节点名不是单纯的Device,而是XName,由命名空间和本地名共同组成:

XDocument xdoc = XDocument.Load("deviceConfig_withns.xml"); XNamespace ns = "http://www.example.com/schema/device"; var plc1 = xdoc.Descendants(ns + "Device") .FirstOrDefault(d => (string)d.Attribute("name") == "PLC_01"); if (plc1 != null) { Console.WriteLine((string)plc1.Attribute("name")); }

ns + "Device"看着像字符串拼接,实际上是XNamespace和本地名合成的XName。记住一个原则:不带命名空间的节点,Descendants("Device")找得到;带默认命名空间的节点,一定要用ns + "Device"。两个写法混用,结果就是找不到节点。

写XML的时候,要主动给新增节点指定命名空间:

XElement alarm = new XElement(ns + "Alarm", new XAttribute("level", "1")); plc1.Add(alarm);

如果不指定命名空间,新节点会变成不带命名空间的"孤魂野鬼",序列化出来文件名会变成<Alarm xmlns="">,破坏整个XML的结构一致性。

6. 常见问题与排查技巧实录

6.1 问题速查表

这节整理一下我在实际开发和工控联调中遇到的高频问题,做成速查表:

现象常见原因解决办法
总是报空引用节点或属性不存在却没判空先判断节点是否为null,再取属性
中文变成乱码保存时未指定正确编码用UTF8Encoding(false)写文件
XPath查不到节点文档带默认命名空间用XmlNamespaceManager注册对应前缀
修改后文件排版变乱保存方式不当使用默认Save或SaveOptions搭配缩进
读取超大文件内存爆掉用DOM方式全载入改用XmlReader流式读取
大量节点删除漏删遍历XmlNodeList同时删除先拷贝列表再遍历删除
保存时文件被占用其他进程占用文件句柄using包裹保存流程,避免句柄泄漏

这张表是我压箱底的排错索引,很多次都是靠"根据现象反查原因"省下大把时间。

6.2 排查思路实录:一次典型的“属性没改上去”问题

分享一个真实案例。朋友负责的通信程序,运行时要根据远程指令把设备的enable改成false,但重启后配置又变成true了。代码逻辑看起来没问题:

plc1.SetAttributeValue("enable", false); xdoc.Save("deviceConfig.xml");

问题出在:程序启动时加载了一份XML到内存,运行中SetAttributeValue改的是内存对象,Save虽然写文件了,但有些配置项在退出时会被另一段代码重新覆盖。排查过程是这样的:

  1. 先在Save后面立刻重新加载文件,确认属性值确实写进去了
  2. 再搜代码里所有对enable属性做写入的地方,发现有另外一处代码在程序退出前初始化了配置对象
  3. 把退出时覆盖的逻辑加个条件判断,只允许"用户手动确认过"的修改生效

这个案例给我们的经验是:XML操作本身没错,错在业务逻辑多个写入点互相打架。遇到"改不上"的问题,第一反应不应该是怀疑API,而是要检查是不是有第二处写入。

6.3 实战心得:什么是"足够好"的XML操作姿势

总结我多年下来觉得最顺手的操作组合:

  • 读配置、改属性、改节点数不多,直接上XDocument,代码量最少,可读性最强
  • 老项目兼容、团队统一用XmlDocument,就别硬改成XDocument,风格不一致的代码比API旧更让人头疼
  • 超大批量遍历,用XmlReader,内存常驻值基本不变
  • 写文件前先备份原文件,改坏了还能回滚

举个例子,我一般在保存前做个备份:

string path = "deviceConfig.xml"; if (File.Exists(path)) { File.Copy(path, "deviceConfig.xml.bak", true); } xdoc.Save(path);

这个习惯在调试配置问题时救过我很多次。就算写崩了,deviceConfig.xml.bak还在,一条命令恢复现场。

6.4 大文件与XmlReader的补充场景

有些朋友的业务会到"几十MB的XML扫描"这一步,比如分析历史数据文件。XDocument.Load会一次性把整棵树搬进内存,实测一个50MB的XML文件就能把程序内存拉到几百MB,机器差点的直接卡死。这时候用XmlReader游标式扫描:

using (XmlReader reader = XmlReader.Create("largeData.xml")) { while (reader.Read()) { if (reader.NodeType == XmlNodeType.Element && reader.Name == "Point") { string id = reader.GetAttribute("id"); string address = reader.GetAttribute("address"); // 只处理单个节点,不保留整棵树 } } }

XmlReader像一条单向管道,从头读到尾,内存占用小而稳定。缺点是只能按顺序读,不能回头,没办法做随机访问;想改属性值也很别扭。所以我的定位是:大文件只读用XmlReader,小文件又读又改用XDocument。

7. 从属性修改到完整工具类:日常开发模板

7.1 封装一个通用XmlHelper

为了避免每个项目都要重写一遍读写XML的代码,我习惯维护一个轻量的XmlHelper静态类,专门处理常见的读取和修改动作:

public static class XmlHelper { public static XDocument Load(string path) { return XDocument.Load(path, LoadOptions.PreserveWhitespace | LoadOptions.SetLineInfo); } public static string GetAttribute(XElement node, string name, string defaultValue = "") { return (string)node.Attribute(name) ?? defaultValue; } public static void SetAttribute(XElement node, string name, object value) { node.SetAttributeValue(name, value); } public static void SaveWithBomOptions(string path, XDocument doc) { var utf8NoBom = new UTF8Encoding(false); using (var writer = new StreamWriter(path, false, utf8NoBom)) { doc.Save(writer); } } }

这个helper我觉得最重要的不是代码多高深,而是两个细节:

LoadOptions.PreserveWhitespace保留原始空白,防止保存时整个文件格式被重排,导致版本控制平台diff出一大片无意义变更;LoadOptions.SetLineInfo会在LINQ查询报错时告诉你出错行号,排查构建工具生成XML的格式问题很有用。

7.2 一个极小但完整的命令行动手例子

为了让你能立刻跑起来,我给一个完整的控制台例子,代码可直接复制到.NET 6+环境里执行:

using System; using System.Xml.Linq; using System.Text; using System.IO; string path = "demo.xml"; XDocument xdoc = XDocument.Load(path); XElement plc1 = xdoc.Descendants("Device") .FirstOrDefault(d => (string)d.Attribute("name") == "PLC_01"); if (plc1 != null) { plc1.SetAttributeValue("timeout", "5000"); plc1.SetAttributeValue("enable", "false"); Console.WriteLine("修改成功"); } else { Console.WriteLine("未找到PLC_01"); } using (var writer = new StreamWriter(path, false, new UTF8Encoding(false))) { xdoc.Save(writer); }

这套流程可以直接用在按键触发"应用设置"、定时保存配置、联动修改设备参数等场景。你需要跑起来时,读入一份模板XML,改你关心的属性,再保存回去,一个最小可用的配置管理模块就出来了。

8. 最后的经验体会

我个人在建这套配置管理模块时最大的体感是:C#操作XML,本质上就是"找到目标节点、改目标属性、按正确编码存回去"这三板斧,但90%的问题都出在看似无关的细节上——编码是否带BOM、XPath是否被命名空间阻断、遍历时是否直接删了集合、保存时文件句柄是否被占。如果你能把这几关都迈过去,日常的XML读写和属性修改就真正变成了"有手就行"的事。

再分享一个我最近在用的习惯:所有涉及XML修改的地方,都预留一份"修改前备份"。不是每个人都记得备份,但一旦线上环境配置被改坏,这份备份就是救命稻草。XML的操作API本身是可靠的,风险和收益的差距恰恰在工程习惯上。祝你的配置读得顺、改得准、存得稳。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询