大家可能都觉得,给 Excel 写代码就是往单元格里塞数据,真正做过企业文件交付的人才会意识到,文件信息栏里的标题、作者、关键词、备注这些属性,比单元格内容还容易被人忽视,却又特别要命。我在自动化出单、报告归档、审计留痕这几个场景里,没少在文档属性上栽跟头。
这篇文章不聊怎么操作单元格,就讲怎么用 C# 代码给 Excel 文件添加文档属性,包括内置属性(标题、作者、关键词、注释这些)和自定义扩展属性(比如项目编号、审批人、批次号),用 Open XML SDK 直接在文件包里改,不依赖 Excel 程序。
适合这些情况:你要在服务器上批量生成报价单,需要给每个文件写清楚客户编号;你在做资料归档,希望所有 Excel 文件都带上统一的标签;又或者你已经用 C# 生成报表,但文件管理员的检索系统只认文档属性。把这些场景一次讲透,你照着抄就能用。
1. 文档属性是 Excel 的“身份证号码”,先搞清楚它的存放位置
1.1 你在 Excel 里看到的属性其实是两部分
Excel 文件在界面上看到的文档属性,打开“文件 → 信息 → 属性”就能看到。但底层看,一个 xlsx 文件就是一个 zip 压缩包,里面有几份独立的 XML 描述文件。文档属性分散在两个地方:
- 核心属性部分:通常对应压缩包里的
docProps/core.xml,记录标题、主题、作者、关键词、备注、创建时间、修改时间这些通用信息。 - 扩展属性部分:对应
docProps/app.xml,记录应用级别的信息,比如公司名、管理器、类别、版本号等。Excel 界面里很多属性栏都能在这里找到。 - 自定义属性部分:对应
docProps/custom.xml,保存用户自定义字段,比如“合同编号”“批次”“审核状态”,每个属性都有名称和值。
所以你想通过代码设置属性,本质上就是在改这几个 XML 文件。知道这一点,后面很多奇怪的坑都能想明白:为什么修改了 Title 但文件管理器不显示?因为文件管理器默认显示的是文件名,不是属性里的 Title。为什么属性设置后 Excel 打不开?多半是 XML 节点没写合法。
1.2 为什么业务系统里经常需要写文档属性
在我接触过的项目里,写文档属性不是业务导向,而是管理导向。最常见的三个场景:
批量出单。比如系统按模板生成上百份报价单,每份文件除了内容不同,还必须在属性里写入对应的客户编号、销售人、创建日期。这样下游同事即使把文件下载到本地,也能通过右键属性快速识别文件属于哪个订单,不用打开 Excel 看内容。
归档检索。有些内部知识库靠文档属性做索引,尤其是 Keywords 和 Category。如果生成工具不写属性,归档时就要人工一个个补,成本很高。我有次帮一个资料室做批量导入,发现他们有几千个 Excel 文件,标题、关键词大量为空,最后只能用一个脚本去读正文生成关键词,再回填属性。如果平时生成的时候就写进去,会省很多事。
审计留痕。合规文件要求记录作者和最后修改者,修改时间也是强制字段。用代码生成的文件如果属性里是空的,审计就会提出整改意见。所以生成流程里必须同步把 Author 和 Modified 写好。
一句话总结:单元格是给人看的内容,文档属性是给系统和归档工具看的元数据。要是系统之间只认元数据,那这一步就跑不了。
2. 工具选型:与其硬刚 COM 组件,不如用 Open XML SDK 直接改包
2.1 三种主流操作 Excel 属性的方式对比
在 .NET 生态里,给 Excel 写文档属性有三条路:
第一条,用 Excel COM 组件。直接在 C# 里启动 Excel 进程,操作 Workbook 对象的 BuiltinDocumentProperties 和 CustomDocumentProperties。优点是很直观,能用 Excel 自身的校验逻辑,如果属性类型不对它会提醒。缺点是必须在装了 Excel 的 Windows 机器上跑,服务器上装 Office 既贵又麻烦,而且容易留下后台进程,比如生成 10 个文件可能导致 10 个 EXCEL.EXE 在内存里不退出。这种方式现在只适合开发工具在本机用,不适合批量服务。
第二条,用 Open XML SDK。xlsx 本质是 zip 包,Open XML SDK 提供了一套强类型 API 让你读写包内的 XML,不需要安装 Excel。优点是无桌面依赖、跨平台、性能稳定,适合 ASP.NET Core、Linux 容器、命令行程序。缺点是 API 偏底层,操作属性时要清楚节点位置和命名空间。
第三条,用第三方库。比如 NPOI、EPPlus 这类开源库,通常也封装了文档属性接口。优点是封装友好,你跳过文档属性直接读单元格也行。缺点是如果要改的字段特别冷门,可能得自己操作 XML,另外不同库对自定义属性的支持程度不一样。
我平时做生产环境的功能,首选 Open XML SDK,最稳。
2.2 为什么我选了 Open XML SDK(以及准备 NuGet 包)
具体原因有三个:
一是不用依赖 Excel 安装。服务器上能跑 .NET 就行,这个条件绝大多数公司都满足。为了给文件写个属性去装 Office,运维不会同意的。
二是对现有代码侵入小。Open XML SDK 读取 xlsx 后,SpreadsheetDocument 对象里直接暴露 PackageProperties,核心属性一行一个赋值,比解析 XML 省事多了。
三是文档格式完全公开。因为改的是包里的 XML 切片,我们可以直接对照 OOXML 规范查看问题。第三方库出 bug 的时候你只能等更新,但这个 SDK 你能自己检查。
在 Visual Studio 里给项目添加 NuGet 包DocumentFormat.OpenXml即可。版本选当前稳定版就好。如果程序是 .NET Core / .NET 5 以上,SDK 包是跨平台的。如果程序要跑在只读目录里,注意给文件本身开写权限。
提示:NuGet 包名和命名空间都不包含任何厂商名,直接搜 DocumentFormat.OpenXml 就能找到。不要用一个叫“Office Common”的老包,很可能不是你需要的。
这里也提醒一句:如果你想设置的是 xls(97-2003 格式),Open XML SDK 操作不了,必须转成 xlsx 或走其他方式。现在新系统基本都用 xlsx,老文件批量处理前先转换。
3. 实操:给 Excel 文件写入内置核心属性,算是最简单的一档
3.1 一个最小示例:打开文件、写入属性、保存
下面这个代码是最小可用的版本:
using DocumentFormat.OpenXml.Packaging; string filePath = @"C:\temp\report.xlsx"; using (SpreadsheetDocument doc = SpreadsheetDocument.Open(filePath, true)) { PackageProperties props = doc.PackageProperties; props.Title = "2025年二季度销售分析"; props.Subject = "销售数据报表"; props.Author = "数据组小张"; props.Keywords = "销售, 季度, 分析"; props.Category = "经营报表"; props.Status = "已审核"; props.Comments = "由C#批处理脚本生成,请在三日内归档。"; props.Created = DateTime.UtcNow; props.Modified = DateTime.UtcNow; doc.Save(); }核心流程就三步:打开文件、改 PackageProperties、保存。PackageProperties 对应的是docProps/core.xml里的节点,Title、Subject、Author 这些都是文本类型,直接赋字符串即可。
有一点要特别注意:Created、Modified 这类日期属性的赋值方式。Open XML SDK 在底层存储的是 ISO 8601 字符串,虽然 SDK 大部分版本能自动转换,但如果你用的版本比较旧,可能会遇到类型不匹配的问题。为了少踩坑,建议统一赋 UTC 时间。Excel 打开时会根据本地时区自动转换回本地时间,显示效果没问题。如果你直接赋DateTime.Now,文件拿到别的时区看就会差 8 小时,存档时很尴尬。
写完属性后还有一个隐藏动作:doc.Save()。这里 Save() 是保存整个包,不是只保存属性。如果在 using 块末尾再执行 Dispose(),流程也是安全的。如果你的属性没生效,先检查是不是少调了 Save()。
3.2 判断文件是否可写:别让文件被占用
用SpreadsheetDocument.Open(filePath, true)时,第二个参数 true 表示可写。如果文件已经被 Excel 打开,进程会报 IOException,说文件正由另一进程使用。这个问题很常见,尤其是测试的时候,你开着 Excel 看效果,再跑程序就报错。
建议在打开文件前做一次占用检测,或者干脆把处理逻辑包在 try 里:
try { using (SpreadsheetDocument doc = SpreadsheetDocument.Open(filePath, true)) { // 处理属性 } } catch (IOException ex) { Console.WriteLine($"文件被占用:{ex.Message}"); }如果批量任务量很大,建议先在临时目录复制一份副本,对副本设置属性,再替换原文件。这样可以避免原文件被锁,也能降低处理中断导致原文件损坏的风险。我处理大批量文件时,都是“复制—修改—替换”三部曲。
3.3 保存过程中容易踩的坑
第一个坑是“属性写进去了但一保存又没了”。如果你用 Open XML SDK 打开一个 xlsx,写入属性后调 doc.Save(),一般情况下没问题。但如果你同时操作了工作簿中的单元格或样式,某些异常 Save 可能导致属性节点没有真正 flush。稳妥方法是确保整个 using 块没有异常,并在结束时保存一次。
第二个坑是“时间属性时区不对”。Created 属性在 XML 里存储的是全球标准时间格式,带 Z 后缀。你用本地时间直接赋给 Created,打开 Excel 会看到时间往前推了 8 小时。建议赋值前先把本地时间转为 UTC,DateTime.UtcNow即可。Excel 显示时会自动转回来,系统之间传文件也不会乱。
第三个坑是“文件扩展名与内容不匹配”。如果你把 xls 改成 xlsx,但内部结构还是旧格式,SDK 打开时会报包错误。遇到这种情况,需要先重新保存为真正的 xlsx。同理,加密、带宏的 xlsm 文件也可以操作,但要注意是否受保护,如果密码保护可能打不开。
注意:如果你要操作的文件被第三方软件占用,即使文件本身没被 Excel 打开,也可能因为权限问题打不开。原因通常是打开了网络驱动器上的文件,或者同步盘正在上传。最好先拷到本地临时目录。
4. 进阶:自定义属性的写入与读取
4.1 自定义属性解决的问题
内置属性字段是固定的,能表达的信息有限。很多时候业务需要“客户编号”“合同金额”“审批状态”这类字段,内置属性里没有。比如你生成了一百份投标文件,每个文件需要标注对应的投标编号,那就要用自定义属性。
自定义属性存放在docProps/custom.xml里。它的数据结构比核心属性复杂,每个属性有:
- name:属性名称。
- pid:属性编号,从 2 开始递增。
- fmtid:固定格式标识,一组固定的 GUID,通常是
{D5CDD505-2E9C-101B-9397-08002B2CF9AE}。 - type:值的类型,有 lpwstr、i4、r8、date、bool 等。
- value:具体的值。
如果不理解 XML 结构,直接用 SDK 的强类型 API 也够用,只是要知道它最终生成的是这类 XML。
4.2 通过 Open XML SDK 操作 custom.xml 的两种姿势
第一种姿势:使用 SDK 自带的 CustomFilePropertiesPart 强类型类。在 SpreadsheetDocument 里,通过doc.CustomFilePropertiesPart访问。如果这个部分不存在,需要先创建。示例思路:
using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.CustomProperties; if (doc.CustomFilePropertiesPart == null) { doc.AddCustomFilePropertiesPart(); } var customPart = doc.CustomFilePropertiesPart; var props = customPart.Properties ?? new Properties(); var newProp = new CustomDocumentProperty( new VTLpwstr { Text = "P2024-001" }) { Name = "项目编号", FormatId = "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}", PropertyId = 2 }; props.AppendChild(newProp); customPart.Properties = props;这个代码只是一个思路,具体到不同 SDK 版本,CustomDocumentProperty 的构造函数可能不同。SDK 版本迭代偶尔会调整这些强类型类的命名空间和属性名,网上很多旧代码直接粘贴会编译不过。如果你不想被版本差异折腾,可以用第二种姿势:直接操作 XML。
第二种姿势:通过 XDocument 读写 custom.xml。Open XML SDK 允许拿到某个 part 的 XML 内容,用XDocument.Load(part.GetStream())读出来,修改后再覆盖回去。这样逻辑直观,完全掌握结构。
以添加“项目编号”为例:
using System.Xml; using System.Xml.Linq; XNamespace cpNs = "http://schemas.openxmlformats.org/officeDocument/2006/custom-properties"; XNamespace vtNs = "http://schemas.openxmlformats.org/officeDocument/2006/docPropsVTypes"; // 从 part 读取 XDocument xDoc; using (var stream = part.GetStream()) { xDoc = XDocument.Load(stream); } // 新建 property 节点 var newProp = new XElement(cpNs + "property", new XAttribute("fmtid", "{D5CDD505-2E9C-101B-9397-08002B2CF9AE}"), new XAttribute("pid", "2"), new XAttribute("name", "项目编号"), new XElement(vtNs + "lpwstr", "P2024-001")); xDoc.Root.Add(newProp); // 写回 part using (var stream = part.GetStream(FileMode.Create)) { xDoc.Save(stream); }第二种方式的好处是不受 SDK 强类型限制,不管 SDK 版本怎么变,OOXML 结构是公开稳定的。缺点是要自己写 XML,容易手抖写错,比如大小写、命名空间前缀。不过写熟了就相当灵活。
我个人的习惯是:内置属性用 PackageProperties,自定义属性用 XDocument。前者是为了快,后者是为了清楚。两者混着用,代码反而最稳。
4.3 实战案例:给一批报价单打上“客户编号”和“审批人”
我现在演示一个完整的小案例:文件夹里有几十个模板生成的报价单,全部用同一个模板复制出来,内容已替换,但属性还没有写。我们需要做的是给每个文件添加两个自定义属性:客户编号和审批人。
大致逻辑:
string[] files = Directory.GetFiles(@"C:\orders", "*.xlsx"); foreach (string file in files) { string orderNo = Path.GetFileNameWithoutExtension(file).Split('_')[1]; // 例如 报价单_2024001.xlsx string approver = "王主管"; using (SpreadsheetDocument doc = SpreadsheetDocument.Open(file, true)) { if (doc.CustomFilePropertiesPart == null) doc.AddCustomFilePropertiesPart(); // 读取现有 custom.xml,或新建 XDocument xDoc = LoadOrCreateCustomXml(doc); AddCustomProperty(xDoc, "客户编号", orderNo); AddCustomProperty(xDoc, "审批人", approver); SaveCustomXml(doc, xDoc); doc.Save(); } } Console.WriteLine("完成");这里 LoadOrCreateCustomXml 和 AddCustomProperty 是辅助方法,核心逻辑就是把上文的 XML 操作封装起来。实际运行时,建议先处理两三个文件验证 XML 结构,再全量跑。别一次性处理几千个文件,万一规则写错,整批文件都得回滚。
注意:自定义属性的 pid 不能重复,新增时要在原文件基础上递增。如果想覆盖已有属性,就要找到对应的节点再修改值,而不是无限追加。无限追加会导致 Excel 打开时提示“文件已损坏”。
4.4 一个踩过的坑:自定义属性的 pid 冲突
有次我给模板文件添加属性,模板本身已经带了一个“内部版本号”自定义属性,pid 是 2。我写脚本时没检查现有节点,直接在根节点追加,新属性 pid 也写 2,结果生成的文件一打开 Excel 就弹“发现无法读取的内容”。排查了半天,发现就是 pid 重复了。
所以处理自定义属性前,第一步是扫描现有 XML 里的所有 property 节点,拿到当前最大 pid,新属性从最大 pid + 1 开始编号。如果文件有几十个属性,这也是必要的,否则极容易重复。
5. 常见问题与排查技巧实录
5.1 文件打不开、属性没生效怎么办
最常见的情况:用代码写完属性,双击打开 Excel 时提示内容有问题,或要求修复。这种情况 90% 是自定义属性写坏了。检查顺序是:
- 先看 custom.xml 里是否有重复的 pid,或 pid 从 1 开始。合法范围是 2 到某个上限,不允许 1。
- 看 name 是否为空或非法字符。属性名允许汉字、字母、数字,但不能包含
<、>、&等 XML 特殊字符,如果业务数据里有这些,必须转义或过滤。 - 看值类型是否匹配。比如用 lpwstr 写了文本,值却用了数字,Excel 会认为是类型错误。
- 看命名空间前缀是否写对。vt 前缀默认绑定到固定 URL,别改成别的。
如果只改了核心属性,一般不会导致损坏。损坏往往发生在 custom.xml 里,因为它结构更容易错。
5.2 为什么设置了 Title 但资源管理器里没显示
在 Windows 资源管理器里,文件列表上方的列可以添加“标题”“作者”等字段。你程序里写了 props.Title,但资源管理器里没看到对应值,通常有两个原因:
第一,列宽不够,标题被省略了。右键列头,选择“其他”,确认勾选了“标题”。
第二,资源管理器读取的元数据来自系统属性缓存,不是实时读取 xlsx 内的 core.xml。你刚改完属性,资源管理器还没有刷新缓存。按 F5 刷新,或者重新登录,一般就能看到。
另外一个好消息:在 Linux 服务器上用 C# 写入的属性,下载到 Windows 后同样有效,因为属性存在文件内部,跟操作系统文件系统无关。不过如果你用 FTP 传输,只要传输的是原文件,属性不会丢。如果通过某些“另存为”操作生成新文件,则可能丢失。
5.3 批量处理时的性能与代码建议
批量给数千个 Excel 写属性,性能瓶颈不在赋值,而在打开和压缩整个 xlsx 包。每个文件都需要解压、改 XML、重新打包,平均耗时根据文件大小在几十到几百毫秒。如果文件里有大量图片、图表,时间会更长。
几个优化建议:
- 优先处理小文件:没啥可说的,xlsx 体积越小操作越快。
- 避免同时打开太多文件:用 using 及时释放,不要在一次循环里持有全部流。
- 不要反复 Save():一次打开,改完属性,Save 一次,就关闭。如果还要操作单元格,尽量把属性和单元格的修改合并到同一次打开中。
- 先备份,再批量:跑之前把原目录整个复制一份。万一出问题,可以快速还原。
有次我在生成两千多个报告时,因为没留意某个文件被占用,中间异常退出,导致两千多个文件有些改完有些没改完,后面又写了个脚本去比对。如果你做批处理,最好在开始前把信息写入日志:哪些文件成功、哪些失败、失败原因,这样重跑时只处理失败的那几个。
5.4 如果项目环境用不了 Open XML SDK 怎么办
有些老项目还在 .NET Framework 3.5 上,或者内部网络不能上网装 NuGet 包。这时候你仍然可以用纯 .NET 方式读取和修改 xlsx 包:用 System.IO.Packaging(WindowsBase)打开 zip 包,然后直接操作 docProps/core.xml 和 docProps/custom.xml 的 XML。核心思路一样——xlsx 是 zip,包里有属性 XML,改 XML 后刷新 Package。不过代码会多写不少,而且要自己处理命名空间和保存流。如果条件允许,还是用 SDK 吧,稳定省事。
我在实际使用中,最推荐的做法不是封装一堆帮助类,而是直接写一个小的 ExcelDocumentPropertyWriter 类,把内置属性、自定义属性、复制模板三种操作统一封装,对外暴露方法。这样不管是控制台程序、WebAPI 还是定时任务,调用方只需要传文件路径和属性字典,其他细节都内部处理。用起来很顺手,后续排查问题也集中。
另外,给文件写属性之前,最好先确定到底该写哪一层。如果只是临时给单个文件补个备注,直接用 Open XML SDK 改 core.xml 就够了。但如果是要在产线里持续生成带属性的文件,建议一开始就规划好属性字典的字段命名,避免不同系统之间对“客户编号”的称呼不一致。命名不统一,后面做检索和归档还得再做一遍映射。