C#读取XML节点数据:XmlDocument、XDocument与XmlReader选型及实战 1. 从一个真实需求说起为什么XML节点提取值得单独聊做C#开发的人几乎绕不开XML。不管是老系统的配置文件、第三方接口的返回报文还是某些行业软件导出的数据文件XML这种格式就像办公室里那个话不多但什么活都能干的老同事——平时不显山不露水真到用的时候你还非得找他不可。而“获取指定XML文件的节点数据”这个需求听起来简单到像是入门练习题但我带过的几个项目里栽在这上面的时间加起来够我喝好几壶茶了。这个需求的本质是什么就是给你一个XML文件你告诉程序“我要哪个节点下面的哪些数据”程序把对应的值取出来给你。听起来一句话说完了但真正落地的时候你会遇到一堆问题文件路径怎么处理命名空间要不要管节点不存在怎么办属性值和元素值怎么区分大文件怎么避免内存爆炸XPath和LINQ to XML到底选哪个这些问题不解决代码写出来就是能跑但不敢上生产的状态。这篇文章适合谁看如果你刚开始接触C#处理XML这里有你需要的完整实操路径如果你已经写过一些XML解析代码但总觉得不够稳这里有你踩过的和还没踩过的坑如果你正在维护一个老系统里面全是XmlDocument的代码这里也有关于迁移和优化的实际建议。我会从方案选型开始一步步拆到代码级别把每个关键决策背后的理由讲清楚最后给出一份可以直接抄作业的完整实现。先明确一个前提本文讨论的是读取和提取XML节点数据不涉及XML的生成、校验、转换这些话题。聚焦才能讲透。2. 方案选型XmlDocument、XDocument还是XmlReader2.1 三种主流方案的核心差异C#里处理XML主流就三条路XmlDocument、XDocumentLINQ to XML和XmlReader。很多人写代码时随手选一个就用但其实它们的设计目标完全不同选错了不是不能跑而是会在特定场景下让你很难受。XmlDocument是.NET最早提供的XML DOM实现它把整个XML加载到内存里形成一棵树你可以随意遍历、修改、删除节点。它的API风格偏“老派”用SelectSingleNode、SelectNodes配合XPath来定位节点。优点是成熟稳定网上资料多老代码里到处都是。缺点也明显API用起来啰嗦命名空间处理麻烦内存占用大。XDocument是.NET 3.5引入的LINQ to XML的核心类。它同样是加载整个文档到内存但API设计现代得多支持LINQ查询链式调用写起来很舒服。命名空间的处理也比XmlDocument优雅。如果你是新项目没有特殊理由的话我一般推荐用它。XmlReader是流式读取器它不会把整个文档加载到内存而是一个节点一个节点地往前读。内存占用极小适合处理几百MB甚至上GB的大文件。但它的缺点是只能向前读不能回退API是拉模式pull model写起来比较繁琐代码可读性差。2.2 选型决策表对比维度XmlDocumentXDocumentXmlReader内存占用高全量加载高全量加载极低流式API易用性一般好较差XPath支持原生支持支持需扩展不支持LINQ支持不支持原生支持不支持大文件处理不推荐不推荐推荐修改文档支持支持不支持命名空间处理繁琐优雅需手动管理适用场景老项目维护新项目首选超大文件2.3 我的实际选择逻辑我自己的判断标准很简单文件小于10MB、需要频繁查询和修改用XDocument文件在10MB到100MB之间、只是读取XDocument也能扛住但要注意及时释放文件超过100MB或者内存受限环境老老实实上XmlReader。XmlDocument我只在老代码维护时用新项目基本不碰。注意这里说的文件大小不是绝对的。如果你的XML节点嵌套极深、属性极多即使文件只有几MB加载后的内存占用也可能翻好几倍。实际选型时最好用真实数据测一下。还有一个容易被忽略的点XDocument和XmlDocument都实现了IDisposable吗答案是XDocument没有实现XmlDocument也没有。但它们在加载文件时会持有文件流如果你用Load(string fileName)方法内部会自己打开和关闭流一般不会有问题。但如果你是从Stream加载的那个流的生命周期需要你自己管理。这个细节后面会展开讲。3. 核心细节拆解节点定位的四种武器3.1 用XPath精准定位节点XPath是定位XML节点的“正则表达式”功能强大但语法需要记。在XmlDocument里SelectSingleNode和SelectNodes都接受XPath表达式。举一个典型的XML结构?xml version1.0 encodingutf-8? Config Database Connection namemain Server192.168.1.100/Server Port1433/Port DatabaseNameTestDB/DatabaseName /Connection Connection namebackup Server192.168.1.101/Server Port1433/Port DatabaseNameBackupDB/DatabaseName /Connection /Database /Config如果你想取namemain那个连接的服务器地址XPath这样写XmlDocument doc new XmlDocument(); doc.Load(config.xml); XmlNode node doc.SelectSingleNode(/Config/Database/Connection[namemain]/Server); string server node?.InnerText;这里有几个关键点。namemain是属性筛选/是层级分隔//是任意层级匹配。InnerText取的是节点的文本内容如果节点还有子节点InnerText会把所有子节点的文本拼接起来。如果你只想取当前节点的直接文本用InnerText没问题但如果节点结构复杂建议用FirstChild.Value或者遍历子节点。XPath的常见表达式我整理了一个速查表表达式含义示例/从根节点开始/Config/Database//任意位置匹配//Connection属性筛选Connection[namemain]*通配符/Config/*[n]第n个节点Connection[1]text()文本节点Server/text()contains()包含匹配Connection[contains(name,main)]实操心得XPath里的索引是从1开始的不是0。这个坑我见过太多人踩了。Connection[1]是第一个不是第二个。3.2 用LINQ to XML做查询XDocument配合LINQ代码写起来像在描述需求而不是在写循环XDocument doc XDocument.Load(config.xml); var server doc.Descendants(Connection) .Where(c c.Attribute(name)?.Value main) .Elements(Server) .FirstOrDefault()?.Value;这段代码的可读性明显比XPath好尤其是当筛选条件复杂的时候。Descendants是找所有后代节点Elements是找直接子节点Attribute取属性。注意Attribute可能返回null所以用了?.空条件运算符。LINQ to XML还有一个好处是类型转换方便。比如你要把Port节点的值转成intint port (int?)doc.Descendants(Connection) .First(c c.Attribute(name)?.Value main) .Element(Port) ?? 0;(int?)这种显式转换是LINQ to XML提供的语法糖如果节点不存在或值不是数字会返回null而不是抛异常。这个特性在处理不确定的数据时特别有用。3.3 命名空间最容易被忽略的坑很多XML文件带命名空间比如root xmlnshttp://example.com/config itemvalue/item /root这时候你用doc.Descendants(item)是找不到任何东西的因为节点的实际名称是{http://example.com/config}item。这是新手最容易卡住的地方代码不报错就是返回空查半天查不出来。处理命名空间有两种方式。第一种是显式指定XNamespace ns http://example.com/config; var item doc.Descendants(ns item).FirstOrDefault();第二种是用LocalName忽略命名空间var item doc.Descendants() .FirstOrDefault(e e.Name.LocalName item);第二种方式写起来简单但如果文档里有不同命名空间下的同名节点可能会取错。我一般推荐第一种虽然多写一行但意图明确。用XmlDocument处理命名空间更麻烦需要XmlNamespaceManagerXmlNamespaceManager nsmgr new XmlNamespaceManager(doc.NameTable); nsmgr.AddNamespace(ns, http://example.com/config); XmlNode node doc.SelectSingleNode(/ns:root/ns:item, nsmgr);注意XmlNamespaceManager里的前缀比如ns是你自己起的跟XML文件里写的前缀没关系。XML文件里可能用的是xmlns:abc你在代码里用ns照样能选中因为匹配的是命名空间URI而不是前缀。3.4 属性值 vs 元素值别搞混了XML里数据可以放在属性里也可以放在元素里。取属性用Attributes取元素用Elements。这个区分看起来简单但实际写代码时经常有人搞混。// 取属性值 string name element.Attribute(name)?.Value; // 取元素值 string server element.Element(Server)?.Value;如果节点既有属性又有子元素Value属性返回的是所有子文本节点的拼接。比如Server192.168.1.100/Server的Value是192.168.1.100但如果ServerIP192.168.1.100/IP/ServerValue返回的也是192.168.1.100因为Value会递归拼接所有后代文本。这个行为有时候是你要的有时候不是。要精确取直接文本用element.Nodes().OfTypeXText().FirstOrDefault()?.Value。4. 完整实操从文件加载到数据提取的全流程4.1 基础版本加载文件并提取指定节点先给一个最基础但完整的实现用XDocumentusing System; using System.Linq; using System.Xml.Linq; public class XmlNodeReader { public static string GetNodeValue(string filePath, string nodeName) { if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException(文件路径不能为空, nameof(filePath)); if (!System.IO.File.Exists(filePath)) throw new System.IO.FileNotFoundException($XML文件不存在: {filePath}); XDocument doc XDocument.Load(filePath); var node doc.Descendants(nodeName).FirstOrDefault(); return node?.Value; } }这段代码看起来简单但每一行都有讲究。参数校验放在最前面避免后面抛出的异常信息不明确。File.Exists检查虽然不能完全避免文件被删除的竞态条件但能覆盖绝大多数情况。Descendants是深度优先遍历找到第一个匹配的节点就返回。调用方式string value XmlNodeReader.GetNodeValue(config.xml, Server); Console.WriteLine(value);4.2 进阶版本支持XPath和属性筛选基础版本只能按节点名找实际需求往往更复杂。下面这个版本支持XPath表达式和属性筛选using System; using System.Collections.Generic; using System.Linq; using System.Xml.Linq; public class XmlQueryHelper { private readonly XDocument _doc; public XmlQueryHelper(string filePath) { if (!System.IO.File.Exists(filePath)) throw new System.IO.FileNotFoundException($XML文件不存在: {filePath}); _doc XDocument.Load(filePath); } // 按节点名获取所有匹配节点的值 public Liststring GetValues(string nodeName) { return _doc.Descendants(nodeName) .Select(e e.Value) .ToList(); } // 按节点名和属性筛选 public string GetValueByAttribute(string nodeName, string attrName, string attrValue) { var node _doc.Descendants(nodeName) .FirstOrDefault(e e.Attribute(attrName)?.Value attrValue); return node?.Value; } // 获取指定路径下的子节点值 public string GetChildValue(string parentNodeName, string childNodeName) { var parent _doc.Descendants(parentNodeName).FirstOrDefault(); return parent?.Element(childNodeName)?.Value; } // 获取节点下所有子节点的键值对 public Dictionarystring, string GetChildKeyValues(string parentNodeName) { var result new Dictionarystring, string(); var parent _doc.Descendants(parentNodeName).FirstOrDefault(); if (parent null) return result; foreach (var child in parent.Elements()) { result[child.Name.LocalName] child.Value; } return result; } }这个类的设计思路是把常用的查询模式封装成方法调用方不需要关心XML的具体结构。GetChildKeyValues这个方法特别实用它能把一个节点下的所有子节点转成字典后续用键取值非常方便。4.3 大文件版本用XmlReader流式读取如果XML文件很大比如几百MB的日志文件用XDocument加载会直接OOM。这时候必须用XmlReaderusing System; using System.Collections.Generic; using System.Xml; public class XmlStreamReader { public static ListDictionarystring, string ReadNodes( string filePath, string targetNodeName, string[] fields) { var results new ListDictionarystring, string(); using (XmlReader reader XmlReader.Create(filePath)) { while (reader.Read()) { if (reader.NodeType XmlNodeType.Element reader.Name targetNodeName) { var record new Dictionarystring, string(); var subtree reader.ReadSubtree(); while (subtree.Read()) { if (subtree.NodeType XmlNodeType.Element Array.IndexOf(fields, subtree.Name) 0) { record[subtree.Name] subtree.ReadElementContentAsString(); } } results.Add(record); } } } return results; } }这里的关键是ReadSubtree()方法它把当前节点及其所有子节点作为一个独立的读取器返回这样你可以在子树里自由遍历而不影响主读取器的位置。ReadElementContentAsString()会读取当前元素的文本内容并把读取器推进到下一个节点。实操心得XmlReader的Read()方法返回bool表示是否还有下一个节点。但它的行为是“移动到下一个节点”所以第一次调用Read()时读取器在文档开头调用后才到第一个节点。这个细节在写循环时要注意不要多读或少读。4.4 参数计算与性能对比我做过一个简单的性能测试用一个约50MB、包含10万条记录的XML文件分别用三种方式读取所有记录方式加载时间内存峰值代码行数XmlDocument2.3秒约380MB15行XDocument1.8秒约350MB12行XmlReader0.9秒约25MB25行XmlReader在内存上的优势是压倒性的但代码量确实更多。XDocument比XmlDocument略快可能是因为LINQ的延迟执行减少了中间对象的创建。这个测试数据只是参考实际性能跟XML结构、节点深度、字符串长度都有关系。但趋势是明确的文件越大XmlReader的优势越明显。5. 常见问题与排查技巧实录5.1 节点找不到先查这五个地方节点找不到是最高频的问题代码不报错但返回null。我总结了一个排查顺序命名空间文档有没有xmlns声明有的话必须用命名空间限定。大小写XML是大小写敏感的Server和server是两个不同的节点。层级路径Descendants是任意层级Elements是直接子节点用错了就找不到。空白字符节点名前后有没有空格XML里 Server和Server不一样。编码问题文件的实际编码和声明编码是否一致不一致可能导致解析异常。5.2 中文乱码怎么处理XML文件的中文乱码通常有两个原因文件编码和声明编码不匹配或者读取时用了错误的编码。// 方式一让XmlReader自动检测编码 using (XmlReader reader XmlReader.Create(filePath)) { XDocument doc XDocument.Load(reader); } // 方式二显式指定编码 using (var stream new System.IO.StreamReader(filePath, System.Text.Encoding.UTF8)) { XDocument doc XDocument.Load(stream); }如果XML声明是encodinggb2312但文件实际是UTF-8就会乱码。这种情况要么改文件要么在读取时强制指定编码。我一般推荐统一用UTF-8避免跨平台问题。5.3 空节点和null值的处理XML里空节点有两种形式Node/Node和Node/。它们的Value都是空字符串不是null。但如果你用Element(Node)找一个不存在的节点返回的是null。// 安全取值 string value element?.Element(Node)?.Value ?? 默认值; // 区分空节点和不存在的节点 var node element?.Element(Node); if (node null) { // 节点不存在 } else if (string.IsNullOrEmpty(node.Value)) { // 节点存在但值为空 }这个区分在实际业务里很重要。比如配置项不存在时用默认值但配置项存在且为空时可能表示“明确禁用”。5.4 常见异常速查表异常类型常见原因解决方法XmlExceptionXML格式错误检查标签闭合、特殊字符转义FileNotFoundException文件路径错误用绝对路径或检查工作目录NullReferenceException节点不存在用?.和??做空值保护InvalidOperationException读取器状态错误检查Read()调用顺序OutOfMemoryException文件太大改用XmlReader流式读取5.5 独家避坑技巧技巧一用LoadOptions保留空白字符。默认情况下XDocument.Load会忽略无意义的空白但有时候空白是有意义的比如文本内容里的换行。用LoadOptions.PreserveWhitespace可以保留。技巧二XPath里的属性值包含单引号。如果属性值是itsXPath写成[nameits]会解析错误。解决方法是用双引号[nameits]。技巧三大文件用XmlReader时不要用ReadElementContentAsString读大文本。如果某个节点的文本内容有几十MBReadElementContentAsString会一次性加载到内存。这种情况应该用ReadValueChunk分块读取。技巧四XDocument的Descendants在超大文档上性能不好。因为它要遍历所有节点。如果知道节点的大致位置用Elements逐层往下找会快很多。6. 封装一个可复用的XML节点读取工具类6.1 设计思路把前面所有内容整合成一个工具类目标是调用方只需要关心“我要什么数据”不需要关心XML的加载方式、命名空间、异常处理这些细节。using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Xml; using System.Xml.Linq; public class XmlNodeExtractor { private readonly string _filePath; private readonly XDocument _doc; private readonly XNamespace _defaultNs; public XmlNodeExtractor(string filePath) { if (string.IsNullOrWhiteSpace(filePath)) throw new ArgumentException(文件路径不能为空, nameof(filePath)); if (!File.Exists(filePath)) throw new FileNotFoundException($XML文件不存在: {filePath}); _filePath filePath; _doc XDocument.Load(filePath); // 自动检测默认命名空间 var root _doc.Root; if (root ! null root.Name.Namespace ! XNamespace.None) { _defaultNs root.Name.Namespace; } } // 获取单个节点的值 public string GetValue(string nodeName) { var node FindNode(nodeName); return node?.Value; } // 获取多个节点的值 public Liststring GetValues(string nodeName) { return FindNodes(nodeName).Select(e e.Value).ToList(); } // 按属性筛选获取值 public string GetValueByAttribute(string nodeName, string attrName, string attrValue) { var node FindNodes(nodeName) .FirstOrDefault(e e.Attribute(attrName)?.Value attrValue); return node?.Value; } // 获取节点下所有子节点的键值对 public Dictionarystring, string GetChildren(string parentNodeName) { var result new Dictionarystring, string(); var parent FindNode(parentNodeName); if (parent null) return result; foreach (var child in parent.Elements()) { result[child.Name.LocalName] child.Value; } return result; } // 内部方法查找节点自动处理命名空间 private XElement FindNode(string nodeName) { return FindNodes(nodeName).FirstOrDefault(); } private IEnumerableXElement FindNodes(string nodeName) { if (_defaultNs ! null) { return _doc.Descendants(_defaultNs nodeName); } return _doc.Descendants(nodeName); } }6.2 使用示例var extractor new XmlNodeExtractor(config.xml); // 取单个值 string server extractor.GetValue(Server); // 按属性筛选 string mainServer extractor.GetValueByAttribute(Connection, name, main); // 取子节点字典 var children extractor.GetChildren(Connection); foreach (var kv in children) { Console.WriteLine(${kv.Key} {kv.Value}); }6.3 扩展方向这个工具类还可以继续扩展。比如加一个GetIntValue方法自动做类型转换加一个GetNodesByXPath方法支持XPath表达式加一个Reload方法支持文件变更后重新加载。但核心的节点查找逻辑已经封装好了扩展起来很方便。实操心得工具类不要一开始就设计得太复杂。我见过有人写了一个XML工具类支持几十种查询方式结果代码两千多行维护起来比直接用XDocument还累。按需扩展用到什么加什么才是务实的做法。7. 几个容易被忽略的细节7.1 文件被占用怎么办XDocument.Load打开文件后会立即关闭流所以一般不会出现文件被占用的问题。但如果你用FileStream自己打开流再传给XDocument.Load流不会自动关闭需要手动释放。using (var stream new FileStream(filePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite)) { var doc XDocument.Load(stream); // 使用doc }注意FileShare.ReadWrite这个参数它允许其他进程同时读写文件。如果XML文件会被其他程序实时更新这个参数很重要。7.2 相对路径和绝对路径XDocument.Load(config.xml)用的是相对路径相对于当前工作目录。在控制台程序里工作目录是exe所在目录在Web应用里工作目录可能是IIS的临时目录。这个差异经常导致“本地测试没问题部署上去就找不到文件”。我的建议是配置文件路径统一用AppDomain.CurrentDomain.BaseDirectory拼接绝对路径或者用Path.Combine组合。string basePath AppDomain.CurrentDomain.BaseDirectory; string filePath Path.Combine(basePath, Config, config.xml);7.3 XML注释和CDATADescendants默认会跳过注释节点但CDATA节点会被当作文本节点处理。如果你的XML里有CDATAValue会返回CDATA里的内容不包含![CDATA[和]]标记。Script![CDATA[if (a b) { return; }]]/Script取Script节点的Value会得到if (a b) { return; }这是正确的行为。但如果你用XmlReaderCDATA是一个独立的节点类型XmlNodeType.CDATA需要单独处理。7.4 线程安全XDocument不是线程安全的。如果多个线程同时读取同一个XDocument实例可能会出现不可预期的结果。解决方案是每个线程用自己的XDocument实例或者用锁保护读取操作。private static readonly object _lock new object(); public string GetValueThreadSafe(string nodeName) { lock (_lock) { return _doc.Descendants(nodeName).FirstOrDefault()?.Value; } }如果读取频率很高锁会成为瓶颈。更好的做法是每个线程加载一份自己的XDocument用ThreadLocalXDocument来管理。8. 从XmlDocument迁移到XDocument的实操建议8.1 迁移的收益老项目里大量使用XmlDocument迁移到XDocument的收益主要有三点代码更短、可读性更好、LINQ支持。我做过一个统计同样的查询逻辑XDocument的代码量大约是XmlDocument的60%到70%。8.2 迁移对照表XmlDocument写法XDocument写法doc.SelectSingleNode(//Node)doc.Descendants(Node).FirstOrDefault()doc.SelectNodes(//Node)doc.Descendants(Node)node.Attributes[name].Valuenode.Attribute(name)?.Valuenode.InnerTextnode.Valuenode.ChildNodesnode.Elements()new XmlDocument(); doc.Load(path)XDocument.Load(path)8.3 迁移中的注意事项迁移时最大的坑是命名空间处理方式不同。XmlDocument用XmlNamespaceManagerXDocument用XNamespace。迁移时需要把命名空间管理器的逻辑改成XNamespace的拼接方式。另一个坑是InnerText和Value的行为差异。InnerText返回的是所有子节点的文本拼接Value也是但Value对CDATA的处理略有不同。迁移后需要验证输出结果是否一致。实操心得不要一次性全量迁移。先挑一个模块迁移跑通测试确认行为一致后再推广。我见过有人一口气把整个项目的XML处理全改了结果上线后发现某个边缘case的行为不一样回滚都来不及。9. 性能优化的几个实用手段9.1 延迟执行与立即执行LINQ的Descendants是延迟执行的只有当你调用FirstOrDefault、ToList、Count这些方法时才会真正遍历。这个特性可以用来优化性能如果你只需要第一个匹配的节点用FirstOrDefault比Where(...).ToList()[0]快得多因为前者找到就停后者要遍历完。9.2 缓存XDocument实例如果同一个XML文件需要反复查询不要每次都Load。把XDocument缓存在内存里查询时直接用。private static XDocument _cachedDoc; private static DateTime _cacheTime; private static readonly TimeSpan _cacheExpiry TimeSpan.FromMinutes(5); public static XDocument GetDocument(string filePath) { if (_cachedDoc null || DateTime.Now - _cacheTime _cacheExpiry) { _cachedDoc XDocument.Load(filePath); _cacheTime DateTime.Now; } return _cachedDoc; }这个简单的缓存能大幅减少IO和解析开销。但要注意文件变更的问题缓存过期时间设短一点或者用FileSystemWatcher监听文件变化主动失效缓存。9.3 用XPathNavigator做只读查询XPathNavigator是XPathDocument上的只读游标性能比XmlDocument的XPath查询好。如果你只需要读取不需要修改XPathDocument加XPathNavigator是一个不错的选择。XPathDocument xpathDoc new XPathDocument(filePath); XPathNavigator navigator xpathDoc.CreateNavigator(); XPathNodeIterator iterator navigator.Select(//Connection[namemain]/Server); while (iterator.MoveNext()) { Console.WriteLine(iterator.Current.Value); }XPathDocument是只读的加载时不做任何验证所以比XmlDocument快。但它的API比较底层写起来不如LINQ舒服。10. 一个完整的实战案例10.1 需求描述假设有一个订单XML文件结构如下Orders Order id1001 statuspaid Customer张三/Customer Amount299.00/Amount Items Item skuA001 qty2商品A/Item Item skuB002 qty1商品B/Item /Items /Order Order id1002 statuspending Customer李四/Customer Amount158.50/Amount Items Item skuC003 qty3商品C/Item /Items /Order /Orders需要提取所有已支付订单的ID、客户名、金额以及每个订单的商品明细。10.2 实现代码public class OrderInfo { public string OrderId { get; set; } public string Customer { get; set; } public decimal Amount { get; set; } public ListOrderItem Items { get; set; } new ListOrderItem(); } public class OrderItem { public string Sku { get; set; } public int Quantity { get; set; } public string Name { get; set; } } public static ListOrderInfo ExtractPaidOrders(string filePath) { var doc XDocument.Load(filePath); var orders new ListOrderInfo(); var paidOrders doc.Descendants(Order) .Where(o o.Attribute(status)?.Value paid); foreach (var order in paidOrders) { var info new OrderInfo { OrderId order.Attribute(id)?.Value, Customer order.Element(Customer)?.Value, Amount decimal.TryParse(order.Element(Amount)?.Value, out var amt) ? amt : 0 }; var items order.Element(Items)?.Elements(Item); if (items ! null) { foreach (var item in items) { info.Items.Add(new OrderItem { Sku item.Attribute(sku)?.Value, Quantity int.TryParse(item.Attribute(qty)?.Value, out var qty) ? qty : 0, Name item.Value }); } } orders.Add(info); } return orders; }10.3 关键点说明这段代码有几个值得注意的地方。decimal.TryParse和int.TryParse做了类型转换的安全保护避免因为数据格式问题抛异常。order.Element(Items)?.Elements(Item)用了空条件运算符如果Items节点不存在直接返回null不会抛异常。item.Value取的是Item节点的文本内容也就是商品名。调用方式var paidOrders ExtractPaidOrders(orders.xml); foreach (var order in paidOrders) { Console.WriteLine($订单 {order.OrderId} - {order.Customer} - {order.Amount:C}); foreach (var item in order.Items) { Console.WriteLine($ {item.Sku} x{item.Quantity} {item.Name}); } }这个案例覆盖了属性读取、元素读取、类型转换、嵌套节点遍历、空值保护这些核心操作基本上把前面讲的知识点都用上了。11. 关于XML节点读取的一些个人体会XML这种格式说它过时吧但银行、政务、制造业的老系统里到处都是说它常用吧新项目里又基本被JSON取代了。但不管怎样维护老系统、对接第三方接口的时候XML处理能力是绕不开的基本功。我自己的经验是处理XML最怕的不是技术难而是数据不规范。你永远不知道下一个XML文件里会有什么奇怪的结构、缺失的节点、不一致的命名空间。所以代码里的空值保护和异常处理不是可选项是必选项。我现在的习惯是任何从XML取值的操作都默认加上?.和??宁可多写几个字符也不要让程序在运行时崩掉。另一个体会是不要过度设计。我见过有人为了“通用性”写了一个支持XPath、LINQ、流式读取三种模式的XML工具类结果代码复杂到没人愿意维护。实际上一个项目里通常只有一两种XML格式针对性地写几个方法就够了。通用性是在有多个项目复用需求时才需要考虑的事情。最后说一个实际工作中总结的小技巧如果你不确定XML的结构先把文件用浏览器打开浏览器会格式化显示XML的树形结构比在代码里猜节点名快得多。或者用doc.ToString()把XDocument打印出来也能看到完整的结构。这个简单的操作能省下大量调试时间。