OfficeCLI 开源仓库:Excel 处理器 47 个文件怎么分组

2026-08-05

本文基于 OfficeCLI 仓库 commit 459b1a4(2026-08-04)梳理,该项目仍在高频迭代,具体行为以仓库 https://github.com/iOfficeAI/OfficeCLI 最新代码与文档为准。

把一个类摊成 47 个文件,省的不是代码行数,是「我下次要改的东西在哪一行」这个检索成本。 OfficeCLI 的 Excel 处理器就是按这个思路组织的,粒度拆得相当细——单元格写入、表格与命名区域、校验规则、HTML 预览各自独占文件,谁都不跟谁挤。你要读它的代码,先拿到这张分组图,比从任何一个入口硬啃都快。

先消歧两句。OfficeCLI 是 GitHub 上 iOfficeAI/OfficeCLI 这个 Apache-2.0 项目的专有名字,不是「用命令行操作 Office」这类泛指,也不是微软出品——仓库 NOTICE 写明 Copyright 2026 OfficeCLI,由 goworm 创建维护。文中出现 Word/Excel/PowerPoint 时,指的是文件格式和会打开这些文件的应用程序,与项目归属无关。

站内相近的几篇分工不同:用 AI 写数据分析脚本 讲的是让模型产出一次性分析代码,Agent 工具设计 讲工具接口本身该怎么定义,Pascal Editor 的 systems 机制 拆的是另一个项目的职责切分方式;这篇只干一件事——把 OfficeCLI Excel 处理器的文件布局和读码顺序讲清楚。

一、先弄清「分部类」,再看这堆文件

C# 的 partial class(分部类)是一个编译期特性:同一个类的成员可以分散写在多个 .cs 文件里,编译器把它们拼成一个类型。运行期没有「文件」这个概念,只有一个类。所以下面看到的所有文件切分,都是给人看的目录索引,不是给运行时看的模块边界——这个区别在后面「边界与代价」一节会变成实实在在的坑。

具体到这个仓库:src/officecli/Handlers/Excel/ 目录下有 47 个 .cs 文件(ls 一下就是这个数),其中 44 个文件里写着 partial class ExcelHandler,剩下 3 个是独立类型——ExcelBatchEmitter.csExcelBatchEmitter.Elements.csExcelDataFormatter.cs。加上上一层目录的 src/officecli/Handlers/ExcelHandler.cs(类的主入口和生命周期都在这),ExcelHandler 这一个类型一共由 45 个文件拼成。

这个规模放在整仓的背景下看:仓库共 1201 个受版本控制文件,src/officecli/ 占 469 个(其中 353 个是 .cs)。也就是说,光 Excel 一个格式的处理器就吃掉了源码目录里 1/8 强的文件数。Word 和 PowerPoint 的处理器也在同一层 Handlers/ 下,Excel 之所以最胖,是因为它要处理的元素种类最多:单元格、行列、表格、命名区域、条件格式、数据验证、图表、切片器、透视表、批注、绘图对象、迷你图——每一类在 OOXML 里都是独立的一套元素。

顺带解释一下 OOXML:xlsx / docx / pptx 这些文件本质上是 zip 压缩包,解开后是一组按约定命名的 XML 文件加图片等附件,每个 XML 文件叫一个「部件」(part)。工作表数据、样式表、共享字符串、图表定义各占一个或多个部件,部件之间靠关系文件互相引用。所以「处理 Excel」在代码层面就是「按规则改这一堆 XML 部件并把包重新压回去」,不需要本机装 Office,也不需要调用它的 COM 接口——项目走的正是这条路,用的是开源的 OpenXML SDK(源码里 using DocumentFormat.OpenXml.* 那一串就是它)。

二、按动词分组:加、改、查、删、看

Handlers/Excel/ 下的文件名遵循一个很朴素的约定:ExcelHandler.<动词>.<对象>.cs。前缀是动词,后缀是对象类别。你按前缀数一遍,分组关系一目了然。

组成部分它负责什么对应仓库位置你什么时候会碰到它
入口与生命周期打开包、脏页跟踪(记住哪些工作表被改过、留到统一时刻再写)、Save/Dispose、Raw 读写原始 XMLsrc/officecli/Handlers/ExcelHandler.cs关心「什么时候真落盘」「异常退出会留下什么」
Add 组(6 个文件)新增元素,Add.cs 是总路由,按对象分到 Add.Cells/Add.Cf/Add.Chart/Add.Drawings/Add.TablesHandlers/Excel/ExcelHandler.Add.Tables.cs新建表格、命名区域、批注、数据验证、透视表
Set 组(9 个文件)改已有元素的属性,含 Set.Cells/Set.Ranges/Set.RowsCols/Set.Sheet/Set.Tables/Set.WorkbookHandlers/Excel/ExcelHandler.Set.Cells.cs改单元格值、公式、样式、行高列宽
Query 组(3 个文件)读取与条件筛选ExcelHandler.Query.csQuery.Cf.csQuery.RowWhere.cs按条件捞行、读条件格式规则
Remove删除各类元素ExcelHandler.Remove.cs删行、删表、删工作表
Helpers 组(10 个文件)跨组共享的工具与校验,按 Cell/Chart/Drawing/Node/Ole/Sheet/TableStyle/ConditionalFormat/Validation 分篇ExcelHandler.Helpers.Validation.cs「这么写 Excel 会不会拒绝」的判定集中在 Validation 一篇
预览与视图(5 个文件)HTML 预览按 Charts/Pictures/Shapes 分篇,另有纯文本视图ExcelHandler.HtmlPreview.csExcelHandler.View.cs让模型「看见」表格内容而不是啃 XML
选择器Sheet1!row[...] 之类的选择表达式解析成目标集合ExcelHandler.Selector.cs一条命令批量改多个元素
缓存重算公式结果缓存与图表数据缓存的过期重刷ExcelHandler.FormulaCache.csExcelHandler.ChartCache.cs下游读者读到的是旧值

剩下的是一批单一职责的小文件:ExcelHandler.DynamicArray.cs(溢出数组写回)、ExcelHandler.CheckOverflow.cs(文字溢出体检)、ExcelHandler.SheetShift.cs(插删行列后的引用平移)、ExcelHandler.Import.csExcelHandler.Slicer.csExcelHandler.RichValueImage.csExcelHandler.DumpSupport.cs

这个分组带来的直接好处是定位速度。你要查「为什么工作表名被拒了」,不需要通读任何一个大文件——Helpers.Validation 这个名字已经把答案框定了。反过来,文件体积也在提示你哪里最复杂:ExcelHandler.HtmlPreview.cs 单文件 21 万字节量级,Query.cs 11 万,Add.Tables.csAdd.Cells.csRemove.cs 都在 9 万上下。这几个是这套代码真正的重量所在。

三、写一个单元格,这条最热的路径都干了什么

ExcelHandler.Set.Cells.cs 是理解整套设计的最佳切口,因为「改一个格子」看起来最简单,实际分支最多。

入口是 SetCellProperties,它做完属性应用后有一串收尾动作:先 PruneEmptyCell 把彻底空掉的单元格从 XML 里摘掉,判定条件是三个都不成立才算空——

var hasValue = cell.CellValue != null && !string.IsNullOrEmpty(cell.CellValue.Text);
var hasFormula = cell.CellFormula != null;
var hasStyle = cell.StyleIndex != null && cell.StyleIndex.Value != 0;
if (!hasValue && !hasFormula && !hasStyle)

摘掉之后如果整行没剩下任何单元格,行也一起删,并同步维护行索引缓存(类里那个 _rowIndex,按工作表数据存一份「行号 → 行对象」的有序表;源码注释写明它把原来遍历所有行的线性扫描换成了 O(1) 查找加 O(log n) 插入,行被结构性改动时整份索引作废重建)。接着才是表格自动扩展、表头名同步、删除计算链,最后 SaveWorksheet

真正有意思的是 ApplyCellProperties 里的分支。挑几个能直接改变你调用姿势的:

clear 被提成前置步骤单独跑。代码注释说得很直白:如果放在属性字典的遍历循环里,字典迭代顺序就决定了结果,同一次调用里同时给 valueclear 会把刚写进去的值又抹掉。这是一类典型的「顺序即语义」问题,靠加注释救不了,只能把顺序从数据结构里拿出来固化成代码结构。

值以 = 开头会被自动当成公式,除非你用前导单引号或者显式 type=string 压住。前导单引号是 Excel 自己的「强制文本」惯用法,这里的处理是:把单引号从存储值里剥掉,同时在单元格样式上打 quotePrefix 标记,让应用程序按文本渲染而不显示那个引号。

valueformula 同时给,公式赢,字面值被丢弃,只在 stderr 输出一行警告。用一个已存在公式的单元格写字面值,同样只有一行警告。两处都是「不阻断、但留痕」的取舍。

数字与日期的自动识别有明确的拒绝线。ISO 日期会被转成 Excel 序列值,但早于 1900-01-01 的日期直接抛异常——因为序列纪元是 1899-12-30,更早的日期只能落成负序列,应用程序要么显示一串井号要么静默夹到纪元,两种都不是调用者要的结果。显式 type=number 时,非有限值(NaN、Infinity)也会被拒,理由是它们不是合法的 xs:double 内容。反过来,前导 0 的纯数字串和超过 15 位的纯数字串会自动按字符串存——这正是你写身份证号、订单号时想要的行为。

公式路径上还挂着一层动态数组处理。溢出数组(dynamic array,也叫 spill)说的是这么一件事:一个公式算出的不是一个值而是一片值,它会自动铺满相邻的一块单元格区域,铺出去的那些格子叫溢出区。ExcelHandler.DynamicArray.cs 的文件头注释写明了触发条件——锚点单元格必须同时带数组公式标记(t="array")和一个指向 xl/metadata.xmlXLDAPR 记录的元数据索引(cm="1"),只有前者的话会被当成锁死在单格的旧式数组公式,根本不铺开。这个文件的另一个决定值得注意:只写锚点,不写溢出出来的那些「影子」单元格,把溢出区域的生命周期整个留给应用程序自己算。好处是不会覆盖用户已有数据,也不用回收过期影子格。

四、校验层为什么值得单独成篇

ExcelHandler.Helpers.Validation.cs 这个文件里几乎没有业务逻辑,全是「什么写法会让 Excel 拒绝打开文件」的判定。这类代码单独成篇的价值,在读了几屏之后就很明显:它其实是一份可执行的格式红线清单。

摘几条写死在源码里、任何人都能当场核对的常量:

  • 公式长度上限 8192 字符,源码里就是 internal const int MaxFormulaLength = 8192;,单元格公式、定义名的引用体、条件格式表达式共用这一条。
  • 单个函数调用的参数上限 255 个,ValidateFormulaArgCount 按括号嵌套层数统计逗号,跳过字符串字面量,也不把数组常量 {} 里的逗号算成参数分隔。
  • 网格边界:列 1..16384(A..XFD)、行 1..1048576。ValidateSqref 会对区域引用的每个分量做边界检查,还会把写反的区间归一化(比如按列和行分别取小到大)。
  • 工作表名 31 字符上限,禁用字符是 \ / ? * : [ ] 七个,不能以单引号开头或结尾,且 History 是保留名。
  • 定义名 255 字符上限,标识符必须以字母或下划线、反斜杠开头,不能解析成单元格引用,单字母 RC 被保留(与 R1C1 记法冲突)。

这些数字全部来自 ExcelHandler.Helpers.Validation.csExcelHandler.Add.Tables.cs。源码注释里反复出现同一个错误码,说明这些校验不是洁癖,而是踩出来的:越过红线写出的文件通常仍然通过 schema 校验,问题要等到用户双击打开时才爆。把这类判断集中在一个文件,也顺带解决了「多个调用方各自校验、慢慢漂移」的问题——注释里明确写着某几条是为了让单元格公式、定义名、条件格式表达式三条路径的限制完全一致。

ExcelHandler.Add.Tables.cs 里能看到同一套思路的另一半。文件头注释说这些函数是从原来的 Add() 巨型方法里机械抽出来的,现在按对象分成 AddNamedRangeAddCommentAddValidationAddAutoFilterAddTableAddPivotTable 六个。以命名区域为例,它在写入前串了一长串检查:标识符形状、长度、是否会被当成单元格引用、是否是保留的单字母、是否跨工作簿引用、是否缺少工作表限定、是否与已有定义名同名同作用域、是否与某个表格的名字撞车。最后这条尤其容易漏——定义名和表格名在 Excel 里共用一个命名空间,撞了也能存盘,打开时才报修复。

五、边界与代价

这套组织方式放弃了一些东西,说清楚比夸它有用。

文件边界不是封装边界。 45 个文件拼成的仍然是一个类,_doc_dirtyWorksheets_rowIndex 这些私有字段,还有 Modified 这个内部标志位,对每一个文件都可见可写,编译器不会拦。你在 Set.Cells 里顺手删掉一个空行,Query 那边算出来的最大行号就跟着变了。分部类给了你目录,没给你契约——想知道谁动了某个字段,只能全目录搜字段名。

这不是可插拔架构。 新增一类元素要同时在 Add 的路由 switch、Set 的分派、Query、Remove、预览等多处落笔,没有注册表让你「注册一个新元素类型」就自动接上所有动词。换来的是调用链短、跳转少,代价是横切改动要摸多个文件。

校验只拦「Excel 会拒绝」的写法,不保证语义正确。 有些判定还刻意做宽:识别错误值的那个函数只检查是不是 # 加大写字母开头,注释里承认 #FOO 也会被误判成错误值,理由是错误码集合会随版本增加,写死白名单反而更容易过期。

明确不管的事也在源码里写着。跨工作簿引用([Other.xlsx]Sheet1!A1 这种形式)直接抛异常,错误信息里直说需要 externalLinks 部件而项目没暴露,让你改用原始 XML 写入的路子。溢出数组的溢出区、溢出冲突检测,整个交给应用程序。HTML 预览是近似渲染,不要拿它当排版真值。

风险最集中的一处:它改的是你磁盘上那个文件本身。 可编辑会话直接以读写方式打开原路径,不是先复制一份副本再改。缓解手段是写回走临时文件加原子替换(AtomicPackageWriter),中途进程死掉不会留下截断的半个包。只读会话完全不写回,而且当会话没有发生任何修改时,Dispose 会先关掉磁盘流再丢弃内存里的包,避免序列化过程本身改动字节。

还有两处「命令返回了 ≠ 盘上已经有了」:

一是常驻进程模式。ResidentServer.cs 里的落盘策略有四档,由 OFFICECLI_RESIDENT_FLUSH 控制——每条变更命令返回前都刷、按空闲自适应去抖(默认档,间隔取 4 倍保存耗时的指数移动平均并夹在 2 到 10 秒之间)、固定秒数、以及只在显式保存或关闭时刷。另有 OFFICECLI_RESIDENT_IDLE_SECONDS 控制多久空闲后退出。你如果在旁边用别的程序读同一个文件,读到的可能是几秒前的状态。

二是批量执行。批处理默认是原子的:任何一项失败,整批回滚,磁盘上什么都不留;加上显式的尽力而为开关,已成功的项才会保留。这个默认值决定了「中途失败留下什么」——默认什么都不留,而不是留下半张表。

另外,打开文件时如果检测到工作表声明了海量空单元格,会在加载阶段就把它们过滤掉,并往 stderr 打一条 warning 说明这些声明保存时不会写回去。这是「读进来的字节和原文件不完全一致」的一处,做批量处理时值得知道。

六、上手与避坑清单

别把文件名当模块边界。 会踩是因为目录看起来太像模块划分了,你以为改 Set.Cells 只影响写入。改之前先按字段名全目录搜一遍,看看还有谁在读同一个私有状态。

别假设 value 就是字面量。= 开头的值会被当公式,这在写「=预计到货」这类中文文案时也成立。要字面量就加前导单引号,或者显式指定字符串类型;反过来想写公式却被当文本,多半是因为同一次调用里带了强制文本的信号。

别在一次调用里同时给值和公式。 公式赢、值静默丢,只有 stderr 一行警告——而 Agent 跑批时 stderr 常常没人看。让上层在拼参数时就二选一。

别指望命令返回即落盘。 常驻模式下这取决于落盘策略。如果下游有别的进程或工具要读同一个文件,要么显式触发保存,要么把策略调成每条命令都刷。

别把校验报错当成工具挑剔。 工作表名 31 字符、公式 8192 字符、函数 255 参数这些不是项目自己加的限制,绕过去只会换来打开文件时的修复对话框。看到这类报错,正确反应是改数据不是找绕法。

别让定义名和表名撞车。 会踩是因为它们在两套 API 里创建,感觉像两个命名空间,实际是一个。命名时加统一前缀区分即可。

读代码别从最大的文件开头啃。 21 万字节的预览文件和 11 万字节的查询文件都不适合线性阅读。从 Handlers/ExcelHandler.cs 看完构造函数、SaveDispose 这三段,你就拿到了整个生命周期,剩下的按需跳。

收束:接下来该读哪个文件

如果你只有半小时,按这个顺序走:src/officecli/Handlers/ExcelHandler.cs 看生命周期与脏页机制,Handlers/Excel/ExcelHandler.Helpers.Sheet.cs 看延迟落盘怎么实现(那段循环只有六行,把「多次修改一次写盘」讲得比任何文档都清楚),Handlers/Excel/ExcelHandler.Set.Cells.cs 看最热路径的分支,Handlers/Excel/ExcelHandler.Helpers.Validation.cs 当格式红线手册翻。

foreach (var part in _dirtyWorksheets)
{
    ReorderWorksheetChildren(GetSheet(part));
    GetSheet(part).Save();
}
_dirtyWorksheets.Clear();

读之前给自己三个问题,读完能答上来就算过关:这次修改会在什么时刻真正写到磁盘、失败了磁盘上会留下什么、以及这条报错是项目的规则还是文件格式的规则。这三个问题的答案,决定了你敢不敢把它挂到一个无人值守的 Agent 上。想再往前一步,可以对照看看 Agent 工具调错的排查思路用 AI 做 Excel 自动化

本篇属于一个把开源Office 文档读写套件 OfficeCLI逐层拆开讲的系列,整体地图见 OfficeCLI 是什么:给 AI Agent 用的开源文档读写套件;沿着这条线往下,还可以看 OfficeCLI 开源项目的写盘底线:原子包写入与临时目录守卫开源项目 OfficeCLI 为何自研 Excel 公式引擎与求解器

想系统学会用 AI?报名体系课或加入会员,照着学、照着用。