1. 为什么是"基于规则"而不是"写死代码":先想清楚再动手
在开始任何一行代码之前,我想先聊聊一个很关键的理念问题:你究竟是打算写一个"生成Word文档的工具",还是打算写一套"生成Word文档的规则系统"?这两者的差距,本质上决定了你未来维护这套代码是轻松还是痛苦。
我接手过不少类似的需求,最常见的一幕是这样的:某个业务方拿着一份格式极其讲究的红头文件或者项目报告,说"这个每周都要出,数据从系统里查出来填进去就行,你帮我做个自动化"。听起来简单,但如果你只是在代码里硬编码一些段落、表格、字体设置,把数据填进去,那确实能跑。可一旦业务方说"标题换个颜色""表格加个序号""这段要分页"——你就得改代码、改布局、重新测试、重新部署,一套流程下来几个小时就过去了。而业务方还觉得你就是改个参数而已。
这就是"基于规则"的真正价值所在。所谓规则,就是把排版经验、文档结构、样式约束从代码里剥离出来,变成外部可配置、可扩展、可解释的数据。代码只做一件事:解释规则并执行规则。业务想改文档形态的时候,不用动一行程序逻辑,只需要改规则配置,甚至业务人员自己都能完成。这听起来像是过度设计,但当你面对的是几十种文档类型、上千个文档模板的时候,规则化几乎是唯一的活路。
规则系统和硬编码的分界线在哪里?我自己的判断标准是这样的:如果文档结构基本固定、每周只生成一到两类,硬编码完全够用,不要引入规则引擎,那是过度设计。但如果文档类型超过三类、或者结构调整频率高于一个月一次、又或者同一套排版逻辑要被多个模块复用,就应该立刻切换到规则驱动的架构。你付出的代价是前期多写一层抽象,换来的是后期修改文档时的从容。
2. .NET里操作Word的技术路线:每一条路都有看不见的坑
选定规则理念之后,下一个问题就是技术选型。.NET生态里操作Word文档,至少有三条主流路线,各自适用的场景天差地别,我分别说清楚。
2.1 COM自动化:能不用就尽量别用
COM自动化是最"听起来取巧"的方案——直接在代码里启动Word进程,像人一样操作Word界面。这在早年间的桌面工具里非常流行,缺点是极其明显:慢、依赖客户端安装Office、进程崩溃后可能残留内存、服务器环境下微软也明确不推荐。杀进程、等句柄、处理弹窗,这堆破事会耗掉你大量时间。
但有一条COM路线例外:当你的产物是体积小、逻辑完全覆盖不了复杂交互的脚本工具时,用COM做快速原型几乎是最省力的。比如说做一个给同事内部用的"把Excel数据塞进Word模板"的小工具,出了问题重启一下就好,不需要追求生产级稳定性,那COM的简单粗暴力完全是可以接受的。
2.2 Open XML SDK:生产环境的合理选择
Open XML SDK走的是另一条路:它不启动Word,而是直接读写 .docx 文件内部的XML结构。.docx本质上是一个zip压缩包,里面是若干XML文件,Word只是渲染这些XML的引擎。Open XML SDK把这些底层结构映射成了强类型的.NET对象,你可以用代码精确控制段落、表格、样式、分页符、页眉页脚等一切元素。
这条路线的好处非常多:不依赖Office安装、适合服务器批量生成、内存可控、结构清晰。但坏处也明显——学习曲线陡峭。你面对的不再是"把字符串追加到文档末尾",而是Paragraph、Run、Text、ParagraphProperties、RunProperties这样一个元素树,初次接触很容易产生"我到底在写文档还是在搭积木"的困惑。
正常来说,Open XML SDK是大多数中大型项目的最佳选择,尤其是需要规则化、批量生成、模板化处理的场景。
2.3 模板引擎+占位符方案:业务侧的亲和力
还有一个经常被低估的方案:Open XML本身并不直接提供"把{CustomerName}占位符替换成真实数据"这样的模板能力,但你可以用模板方式做。也就是说,先用Word软件做好一个 .docx 模板,里面写好占位符,然后在代码里打开模板、定位文本、替换内容、导出新文档。
这种方案最受业务欢迎,因为模板本身是可视化维护的——业务人员会用Word,他们可以直接调整模板样式。你只需要保证占位符名称一致,以及替换逻辑健壮即可。它的局限在于:复杂条件逻辑(比如某些段落有数据才出现、没数据就删掉)实现起来比较绕,Open XML SDK的定位和删除操作需要你手工处理。
如果要走模板路线,我建议结合Open XML SDK来做占位符定位,而不是用简单的字符串替换。因为WPS和Word在保存文档时,可能会把文本拆成了多个run片段,一个完整的{CustomerName}可能会被拆成{CustomerName}三段,直接字符串替换会漏掉。这算是模板方案里很经典的一个坑。
2.4 怎么选:一张决策表帮你判断
我不太喜欢说"哪个最好",更愿意给一张基于实际约束的决策表:
| 场景特征 | 推荐路线 | 理由 |
|---|---|---|
| 客户端工具,终端装有Office,量小 | COM自动化 | 快、简单、能复用Word所有能力 |
| 服务端批量生成,文档类型结构化 | Open XML SDK | 稳定、可部署、可控、无进程残留 |
| 业务人员要维护模板样式 | 模板+Open XML替换 | 模板可视化,结构由业务方向主导 |
| 超大批量、数千份文档每秒 | Open XML SDK纯代码 | 依赖最少、可并行、内存可控 |
| 对Word高级特效要求极高(宏、嵌入式OLE、复杂协同) | COM或者人工处理 | Open XML覆盖不全面时该认怂就认怂 |
你问我个人偏好,我基本无脑选Open XML SDK,尤其是当你已经决定走规则驱动这条路时。COM更适合做原型验证,而不是生产跑批。
3. 规则设计的核心:把"排版经验"翻译成"机器可执行配置"
前面说了这么多,其实都在铺垫一个更核心的问题:规则该如何设计,才能既灵活又不过度复杂?这一节我讲清楚规则系统的元模型和我自己验证过的一套设计方法。
3.1 三个维度理清规则:内容、结构、样式
规则不是一股脑塞进一个配置项里。我拆成三层,每一层关注的问题完全不同:
内容规则:决定"数据从哪里来、如何计算、哪些需要展示"。比如说一份项目报告的某段摘要,内容是数据库里几条记录拼接在一起,如果某项为空则整段不输出。这就是内容规则的事情,跟格式无关。
结构规则:决定"文档由哪些部分构成、顺序如何、哪些重复、哪些可选"。例如一份验收报告就由"封面、概述、测试记录、结论、附件"构成,其中的测试记录是一个循环区块,每条测试记录对应一张表格。结构规则是文档的骨架。
样式规则:决定"每一个区块长什么样"——字体字号、加粗斜体、对齐方式、缩进、行距、边框底纹等。样式规则要和内容、结构彻底分离,这样同一套内容才能在不同场景下穿戴不同的"衣服"。
这三层分离做得好,规则系统的维护成本会急剧下降。业务要改样式?只动样式规则。需求调整内容?只动内容规则。新增一个结构部分?只要结构规则支持,加一个节点就好。
3.2 一个可落地的规则配置长什么样
为了让你看得见摸得着,我用一个C#类的形式展示核心规则对象。下面这段来自我实际项目里的简化版设计:
public class DocumentRule { public string RuleId { get; set; } // 规则唯一标识,比如 "REPORT_COVER" public string DocumentType { get; set; } // 文档类型:报告、合同、验收单…… public RuleType Type { get; set; } // 内容 / 结构 / 样式 public int Priority { get; set; } // 优先级,数值越大越先执行 public string TargetSelector { get; set; } // 目标选择器:作用于哪个区块/元素 public Dictionary<string, object> Params { get; set; } // 具体参数 } public enum RuleType { Content, Structure, Style }这里每个字段都不是随意设计的。RuleId用于追踪规则来源,排错时能直接看到是哪个规则造成了问题;Priority解决规则之间的冲突,这一点我在下一小节细讲;TargetSelector我们用了一个极简的路径表达法,比如root/cover/title或者root/records/item/table,类似于CSS的类路径。
实际运作时,我往往是把规则配置存成JSON文件或数据库表,程序启动时加载到内存,生成文档时按规则逐条执行。下面这段代码展示规则如何被加载进一个执行器:
var ruleExecutor = new RuleExecutor(); ruleExecutor.LoadRules(File.ReadAllText("rules/contract_rules.json")); var documentContext = new DocumentContext { DataSource = GetReportData(), OutputPath = "output/contract_2025.docx" }; ruleExecutor.Execute(documentContext);RuleExecutor内部做的事情其实不复杂:遍历规则集合,按照优先级排序,对命中TargetSelector的部分执行对应动作。真正复杂的是规则动作本身——比如"对段落应用样式"这个动作背后涉及到大量Open XML节点的构建,这部分我放到第4章讲。
3.3 冲突解决:优先级、就近原则、显式继承
规则一多,必然出现冲突。最典型的就是"同级标题默认二号黑体,但特定章节的标题要三号宋体"。这时候怎么处理?
我的策略很简单,三个原则依次判定:
显式优先级:规则上标了
Priority,数值高的先执行,后执行的覆盖先执行的。如果两个规则都命中同一个目标,优先级高的说了算。就近原则:如果规则作用在结构树的子节点上,子节点规则优先于父节点规则。比如"文档全局正文是小四宋体",但这个段落自己标了 "FONT_SIZE=14",那就听段落的。
显式覆盖标记:如果一条规则确认需要无视上述两条,就给它一个强制标志
ForceApply=true,少见但偶尔需要。
这套逻辑的核心是避免规则之间互相踩脚。规则系统最怕的不是规则多,而是规则之间的关系完全不可预测。有了明确的冲突仲裁机制,至少任何一种意外输出都能追溯到某条规则身上——这在生产环境里极其重要。
4. 实战:用Open XML SDK实现一个规则驱动的文档生成器
这章开始动手写代码。我以"项目验收报告"为例,完整展示从空白文档到规则驱动生成的全过程。我用的环境是 .NET 8 + DocumentFormat.OpenXml 3.0,Visual Studio 2022,代码同样适用于 .NET 6/7。
4.1 项目初始化和包引入
创建一个控制台应用程序,然后引入必要的NuGet包。除了Open XML SDK本身,我认为System.Text.Json做规则配置解析就够了,不需要额外引入重型依赖。
dotnet new console -n RuleDrivenWordGen cd RuleDrivenWordGen dotnet add package DocumentFormat.OpenXml在项目入口里先初始化生成器:
using DocumentFormat.OpenXml; using DocumentFormat.OpenXml.Packaging; using DocumentFormat.OpenXml.Wordprocessing; namespace RuleDrivenWordGen; class Program { static void Main(string[] args) { var generator = new ReportGenerator("rules/acceptance_report.json"); generator.AddData(new ReportData { ProjectName = "某跨平台系统", ReportDate = DateTime.Now, Tester = "A同学", TestItems = new List<TestItem> { new TestItem { Name = "登录功能", Status = Status.Passed, Remark = "并发10用户,响应时间达标" }, new TestItem { Name = "订单流程", Status = Status.Passed, Remark = "核心链路正常" }, new TestItem { Name = "异常恢复", Status = Status.Failed, Remark = "断网重连场景存在缺陷" } } }); generator.Generate("output/验收报告.docx"); Console.WriteLine("文档生成完成"); } }这里的ReportGenerator是核心类,它负责读取规则、解析规则、调用Open XML生成文档。下面我分步骤展示它内部的几个关键方法。
4.2 构建文档骨架:页面、页眉、页脚和基础样式
一个看起来"专业"的Word文档,首先不在于内容多花哨,而在于页面骨架是否完整——页边距对不对、有没有页眉页脚、页码从第几页开始、标题样式是否一致。这些基础配置在Open XML里对应的是SectionProperties和几个Paragraph组合。
下面是构建页面设置的核心代码:
using (WordprocessingDocument doc = WordprocessingDocument.Create( outputPath, WordprocessingDocumentType.Document)) { var mainPart = doc.AddMainDocumentPart(); mainPart.Document = new Document(); var body = mainPart.Document.AppendChild(new Body()); // 页面设置:A4、上下边距2.54cm、左右2.0cm var sectionProps = new SectionProperties( new PageSize { Width = (UInt32Value)11906U, Height = (UInt32Value)16838U }, new PageMargin { Top = 1440, Right = 1134, Bottom = 1440, Left = 1134, Header = 851, Footer = 992 } ); body.AppendChild(sectionProps); // 添加页眉 var headerPart = mainPart.AddNewPart<HeaderPart>(); var header = new Header(); var headerParagraph = new Paragraph( new ParagraphProperties( new Justification { Val = JustificationValues.Right }), new Run( new RunProperties(new RunFonts { Ascii = "Microsoft YaHei", HighAnsi = "Microsoft YaHei" }), new Text("某公司项目验收专用")) ); header.AppendChild(headerParagraph); headerPart.Header = header; // 页眉引用到章节 sectionProps.AppendChild(new HeaderReference { Type = HeaderFooterValues.Default, Id = "rIdHeader" }); }这里有两个细节值得注意:
一是页边距的度量单位问题。Open XML里默认长度单位是TwentiethsOfADegree(1英寸=1440个单位),所以边距1134实际上是0.7875英寸(约2厘米)。如果你直接写1134却在心里以为是1134毫米,出来的文档边距会离谱到没法看。我的习惯是先封装一个单位转换方法:
static UInt32Value CmToTwips(double cm) => (UInt32Value)(int)Math.Round(cm * 567);二是页眉引用的Id必须和mainPart的HeaderPart关系匹配。你需要先AddNewPart<HeaderPart>(),然后在主文档里通过HeaderReference关联关系ID。这个ID不是随便写的,要从关系缓存里拿。
正确的做法是先创建mainPart和HeaderPart,建立关系后再设置引用,否则Word会提示文档损坏。完整代码如下:
var headerPart = mainPart.AddNewPart<HeaderPart>(); headerPart.Header = header; string headerRelId = mainPart.GetIdOfPart(headerPart); sectionProps.AppendChild(new HeaderReference { Type = HeaderFooterValues.Default, Id = headerRelId });页脚也是一样的套路,只是在页脚里加的是页码字段代码。页码的FieldChar是Open XML里最绕的部分之一,好在它结构固定,做成辅助方法复用即可:
public static Paragraph CreatePageNumberField() { var run = new Run(); var fieldCharBegin = new FieldChar { FieldCharType = FieldCharValues.Begin }; var instrText = new FieldCode(" PAGE "); var fieldCharEnd = new FieldChar { FieldCharType = FieldCharValues.End }; run.Append(fieldCharBegin); run.Append(instrText); run.Append(fieldCharEnd); return new Paragraph( new ParagraphProperties(new Justification { Val = JustificationValues.Center }), run); }4.3 规则解析器:如何把JSON规则映射成文档元素
骨架搭好,接下来是最有意思的部分——让规则驱动正文的生成。我设计的规则JSON大致长这样:
[ { "RuleId": "SEC_TITLE", "DocumentType": "acceptance_report", "Type": "structure", "Priority": 10, "TargetSelector": "root/section[@name='title']", "Params": { "Text": "{ProjectName}验收报告", "Level": 1 } }, { "RuleId": "SEC_PARAGRAPH", "DocumentType": "acceptance_report", "Type": "style", "Priority": 5, "TargetSelector": "root/section[@name='overview']/paragraph", "Params": { "FontSize": 12, "Font": "SimSun", "LineSpacing": 1.5, "IndentFirstLine": true } } ]解析逻辑如下:拿到一个规则,判断TargetSelector是否命中当前正在构建的文档元素,然后根据Type分派到不同的执行器。
public void ApplyRule(DocumentRule rule, DocumentContext context) { switch (rule.Type) { case RuleType.Content: ApplyContentRule(rule, context); break; case RuleType.Structure: ApplyStructureRule(rule, context); break; case RuleType.Style: ApplyStyleRule(rule, context); break; default: throw new NotSupportedException($"Unsupported rule type: {rule.Type}"); } }ApplyStructureRule负责在文档里创建节点骨架——标题、段落、表格、分页符。ApplyContentRule负责把数据填进这些节点——查找{ProjectName}之类的占位符,替换成真实数据。ApplyStyleRule才是重头戏,所有字体、颜色、间距、缩进都在这里处理。
样式应用的核心代码如下,我用一个辅助方法来设置段落格式:
public void ApplyStyleToParagraph(Paragraph paragraph, StyleRuleParams style) { var pPr = paragraph.ParagraphProperties ?? paragraph.AppendChild(new ParagraphProperties()); // 字体大小 var rPr = new RunProperties(); if (style.FontSize > 0) { rPr.FontSize = new FontSize { Val = (string)(style.FontSize * 2).ToString() }; rPr.FontSizeComplexScript = new FontSizeComplexScript { Val = (string)(style.FontSize * 2).ToString() }; } // 字体 if (!string.IsNullOrEmpty(style.Font)) { rPr.RunFonts = new RunFonts { Ascii = style.Font, HighAnsi = style.Font, EastAsia = style.Font }; } // 行距 if (style.LineSpacing > 0) { var spacing = pPr.GetFirstChild<SpacingBetweenLines>(); if (spacing == null) { spacing = new SpacingBetweenLines(); pPr.Append(spacing); } spacing.Line = (style.LineSpacing * 240).ToString(); spacing.LineRule = LineSpacingRuleValues.Auto; } // 首行缩进2字符 if (style.IndentFirstLine) { var indentation = pPr.GetFirstChild<Indentation>(); if (indentation == null) { indentation = new Indentation(); pPr.Append(indentation); } indentation.FirstLineChars = 200; // 200 = 2字符 } // 将rPr应用到段落的所有run上 foreach (var run in paragraph.Descendants<Run>()) { run.PrependChild((RunProperties)rPr.CloneNode(true)); } }注意这里的几个进制关系:FontSize的单位是半磅(1号=1磅=2个FontSize单位),所以FontSize=12实际要写24。LineSpacing的单位是1/240行,1.5倍行距要写360。Indentation的FirstLineChars是以百分之一字符为单位的,2字符缩进就是200。这些都是Open XML的经典智商税,我不查文档永远记不住。
4.4 表格和列表的自动排版:从无序数据到规整区块
验收报告的核心往往是那张测试记录表。规则驱动下,表格的生成逻辑是:先看结构规则里有没有"表格区块",再看这一区块要展示哪些数据列,然后根据数据量和表格样式自动构建行和单元格。
生成基础表格的代码如下:
public Table BuildTable(List<TestItem> items) { var table = new Table( new TableProperties( new TableStyle { Val = "TableGrid" }, new TableWidth { Width = "100%", Type = TableWidthUnitValues.Pct }), new TableGrid( new GridColumn { Width = "2200" }, new GridColumn { Width = "1200" }, new GridColumn { Width = "3800" }) ); // 表头 var headerRow = new TableRow( new TableRowProperties(new TableRowHeight { Val = 450, HeightType = HeightRuleValues.Exact }), new TableCell(BuildCellParagraph("测试项", true)), new TableCell(BuildCellParagraph("结果", true)), new TableCell(BuildCellParagraph("备注", true)) ); headerRow.TableRowProperties!.AppendChild( new Shading { Fill = "D9E2F3", Val = ShadingPatternValues.Clear } ); table.Append(headerRow); // 数据行 foreach (var item in items) { var row = new TableRow( new TableCell(BuildCellParagraph(item.Name, false)), new TableCell(BuildCellParagraph(item.Status.ToString(), item.Status == Status.Passed)), new TableCell(BuildCellParagraph(item.Remark, false)) ); table.Append(row); } return table; } private static Paragraph BuildCellParagraph(string text, bool bold) { var run = new Run(new Text(text)) { RunProperties = new RunProperties( new RunFonts { Ascii = "SimSun", EastAsia = "SimSun" }, new FontSize { Val = "21" }, new Bold { Val = bold } ) }; var para = new Paragraph(run); para.ParagraphProperties = new ParagraphProperties( new Justification { Val = JustificationValues.Center }); return para; }这个示例里我用了固定列宽GridColumn,但在真实项目里我倾向于用百分比宽度配合自动表格布局,因为不同报告的数据长短差异很大,固定列宽很容易把"备注"挤到只剩一列字。改成自适应布局也很简单,把TableProperties里的TableLayout设为Fixed或Autofit即可。
需要注意的是,Open XML里表格的每个TableCell内部必须至少包含一个Paragraph,否则文档结构不合法,Word打开时会提示修复。我吃过这个亏,写了一个少段落的表格,代码跑得欢,打开看就报"文档中不可读取的内容"。
4.5 输出校验、保存和自动化流程对接
文档生成完毕,收尾工作别轻视。我的固定动作包括三件事:校验Open XML结构、关闭释放文件流、可选的Word兼容性检查。
结构校验最小可行方案如下,它会捕获大部分结构性错误:
public void ValidateDocument(WordprocessingDocument doc) { var validator = new OpenXmlValidator(); var errors = validator.Validate(doc).ToList(); if (errors.Any()) { foreach (var error in errors) { Console.WriteLine($"Error: {error.Description} (Path: {error.Path.XPath})"); } } }另外,保存文档时我强烈建议用文件流的方式显式关闭,避免文件被占用:
public void Generate(string outputPath) { using var stream = new FileStream(outputPath, FileMode.Create, FileAccess.Write); using (var doc = WordprocessingDocument.Create(stream, WordprocessingDocumentType.Document)) { // ... 构建文档逻辑 ValidateDocument(doc); doc.MainDocumentPart!.Document.Save(); } Console.WriteLine($"Saved to {outputPath}"); }到这里,一个面对"项目验收报告"的规则驱动生成器已经能用了。但你如果以为这就能应付全部业务,那就太天真了——规则越加越多、文档类型越来越复杂之后,你才会遇到真正的系统性问题,我放在第5章讲。
5. 走向稳定生产级:规则库维护、回归测试与性能调优
从"demo能跑"到"生产环境天天跑"之间,隔着一堆工程问题。这一章聊三个我认为最重要的问题:怎么管理规则库、怎么保证每次改规则不炸、怎么在批量生成时压住资源消耗。
5.1 把规则配置外部化:是配置文件还是数据库
规则可以放在三个地方:嵌入代码、独立JSON/XML文件、数据库表。我个人的经验是规则数量少于50条的阶段,用独立JSON文件就够了;超过了才开始考虑引入数据库。
JSON的优点是改完立即生效、可以用版本管理工具追踪每次变更差异、测试时容易构造任意场景。我在项目里是这样组织的:
rules/ acceptance_report.json contract_copy.json test_record.json每个JSON文件对应一种文档类型,相互独立。还有一种跨类型共享的基础规则,我放在common/base_style.json里,启动时先加载公共规则,再加载文档特有规则。这里有一个继承关系的处理,也就是第3章说的"就近原则"的实现:文档特有规则覆盖公共规则,所有规则合并后统一按优先级排序执行。
如果哪天规则数量大到JSON文件都管理不了,再考虑数据库。但我提醒一句:规则是很强的"代码",它跟代码一样有bug、需要测试。如果数据库化的成本高于收益,别为了"看起来更系统"而强行上。
5.2 回归测试:用"黄金文档"守住建站底线
规则驱动系统最怕改一处坏了整个文档。我的土办法是"黄金文档对比"——把业务认可的一批标准文档保存下来,每次规则变更后重新生成,再用工具比较新旧文档的内容和格式差异。
比较最快捷的方式是解压两个 .docx,逐个对比document.xml,或者用XmlDiff工具。但纯文本对比会遇到样式噪音,更实用的方式是只比对关键属性:标题文本、段落数量、表格行列数、关键字体字号。我写了一个简单的断言式测试框架:
[Fact] public void AcceptanceReport_NewRule_ShouldNotChangeCoverLayout() { var generator = new ReportGenerator("rules/acceptance_report.json"); generator.AddData(SampleData()); var outputPath = "output/test_report.docx"; generator.Generate(outputPath); using var doc = WordprocessingDocument.Open(outputPath, false); var body = doc.MainDocumentPart!.Document.Body!; // 断言封面标题存在且是唯一 var titleParagraphs = body.Descendants<Paragraph>() .Where(p => p.InnerText.Contains("验收报告")) .ToList(); Assert.Single(titleParagraphs); // 断言表格数量和数据行数 var tables = body.Descendants<Table>().ToList(); Assert.Single(tables); Assert.Equal(3, tables[0].Descendants<TableRow>().Count() - 1); // 减去表头 }这类测试平时看着蠢,但规则系统改动频繁之后,它们就是你的安全网。我见过不止一次因为调整某个通用样式,导致所有合同文档的落款日期从右对齐变成了左对齐——这种问题在测试框架下一秒钟就能暴露。
5.3 性能优化:批量生成中的CPU、内存与并发控制
批量生成几百份文档时,性能问题会立刻浮出水面。Open XML SDK本身计算量不小,但更大的瓶颈常常是I/O和对象分配。
几个非常实用的经验:
复用公共资源:如果所有文档都使用同一套规则、同一个模板骨架,那就只初始化一次
WordprocessingDocument的对象模板,复制MainDocumentPart.Document.OuterXml作为新文档的基础,比每次从磁盘加载模板快得多。并行生成时要限流:Open XML SDK线程安全是局部的——不同的文档实例可以并行,但共享同一个模板实例就可能出问题。建议用
Parallel.ForEach搭配一个最大并行度。我常用Parallel.ForEachAsync配MaxDegreeOfParallelism = 4,在普通云服务器上CPU不会顶满,文档也不会乱。及时释放对象:用
using包裹所有的文档流、MemoryStream、WordprocessingDocument。尤其是服务端场景,打开句柄太多会拖垮整个进程。避免无意义的格式化:别对每个
Run都执行CloneNode深拷贝。样式是固定的那个就固定用同一个对象引用,只有需要改单处的时候才克隆。
5.4 可扩展性:新文档类型上线的标准路径
当业务方说"下周要新增一个设备巡检报告",规则系统应该让这件事变成一个标准流程,而不是又一场代码大战。我的标准路径是:
- 梳理文档结构:拆成封面、基础信息区、巡检项表格、异常记录区、结论区。
- 确定数据源:巡检项来自哪个接口、结论字段如何计算。
- 编写结构化规则:把上面两个步骤翻译成JSON规则。
- 写一份最小黄金文档示例,提交给业务确认样式。
- 把示例文档录入回归测试。
这个流程走顺之后,新增一种文档类型大多只需要一天时间,而且大部分时间花在等业务确认样式上,不是被代码卡住。这才是规则驱动架构最重要的回报——它把"写文档生成代码"从软件开发问题变成了"配置数据模型+样式参数"的配置问题。
6. 我踩过的坑和最终沉淀下来的建议
最后这一章,聊一些零散但极其影响心情的实战经验。有些坑踩到过才明白,写在这里希望你能绕开。
6.1 中文字体和编码的坑
Word文档里中文字体设置不是只看RunProperties里的Ascii就完事,很多时候真正起作用的是EastAsia属性。如果你只设置了Ascii="SimSun"而忘了EastAsia,文档在中文字符上可能是默认字体甚至乱码。
更隐蔽的问题出现在换行符和缩进上。Open XML里中文段落的缩进,理想的单位是"字符"而不是"厘米"。2字符首行缩进用Indentation.FirstLineChars = 200就可以了;但如果你混用了FirstLine(Points单位),不同Word版本的解析结果会有差异,同一份文档在WPS和Word里看起来可能不一样。我的原则是:中文字符相关的排版,尽量用FirstLineChars这类相对单位,少用绝对长度。
6.2 图片插入与尺寸控制
Open XML插入图片的逻辑,其实是在文档里插入一个Drawing元素,同时把图片二进制存成ImagePart。这里最经典的问题有两个。
第一个是尺寸单位。Open XML图片尺寸默认单位是EMU(English Metric Unit),1英寸=914400 EMU。如果直接从文件流读取图片想保持原始尺寸,你需要读取图片像素尺寸再计算EMU。我的做法是用System.Drawing或ImageSharp读一次图片宽高,然后按比例换算:
int emuWidth = (int)(pixelWidth / dpi * 914400); int emuHeight = (int)(pixelHeight / dpi * 914400);第二个问题是占位符替换场景。你计划在模板里放一个图片占位符,然后替换成真实图片,这在Open XML里比想象中麻烦——你需要定位Drawing元素的Extent属性,替换图片同时更新尺寸。这比纯文本替换费劲得多,我的建议是如果这类需求频繁,就把"图片区"做成规则中的一个独立区块,从结构上避免占位符操作的复杂度。
6.3 进程残留和服务端部署教训
走COM路线时的一大坑是Word进程残留。程序崩溃或者异常退出后,WINWORD.EXE会一直赖在内存里,尤其在生产服务器上跑定时任务,连续跑几天服务器就卡到无法动弹。我早期做服务端文档生成时踩过这个雷,后来实在不堪其扰,才彻底切换到Open XML SDK。
切过来之后还有一个隐藏坑:WordprocessingDocument在操作结束后务必显式Dispose(),哪怕是在C#这种有GC的环境里。数据库连接池、文件流、临时文件这些资源都要想在前面。服务端一次性生成几十份文档时,如果处理不当,临时文件目录会被撑爆。
6.4 从"能生成"到"生成得好看"的三个阶段
如果你准备做一个长期维护的Word自动生成系统,我建议把目标分三个阶段来定:
- 第一阶段:能跑通。先把数据塞进去能产生合法文档,不追求美观。对应的是能处理基本段落、表格、标题,结构校验零错误。
- 第二阶段:能复用。抽象出规则,让不同的文档类型共享样式和结构,管理好优先级、冲突和文件组织。
- 第三阶段:好看。在第二阶段基础上,精细打磨样式规则:合理的页边距和分页策略、表格边框的视觉层次、段落在跨页时的关系设置(
KeepNext、KeepLines)、标题与正文的字号节奏。
我看到很多人一上来就追求第三阶段,结果死在"能跑通"这道坎上。文档自动化的地基是稳定的结构生成,地基不稳,样式越精细,文档越容易在各种边界情况下跑飞。
最后分享一个我个人的习惯:每次生成完文档,我都会用WPS和Word各打开看一眼,因为两个渲染器在个别设置上会有细微差异。这个动作花不了两分钟,但能帮你提前暴露很多规则配置里的隐性错误。如果连这一步都没时间做,那说明这套流程的成本控制还需要继续优化——而这,恰恰是规则系统应该替你解决的事情。