ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

中文脚本解释器第三篇:为Excel自动化补上流程控制能力

中文脚本解释器第三篇:为Excel自动化补上流程控制能力 第三篇继续写我的中文脚本解释器项目。前两篇解决了两个基础问题一个能把中文Excel操作语句切成Token的词法分析器一个能执行单元格读写、变量赋值的解释器运行时。但说实话做到那个程度脚本只能算单条命令的批处理离真正的自动化还差一个关键能力——流程控制。举个例子把所有金额大于1000的单元格标红这句话听起来很简单但没有条件判断的解释器根本不知道该拿大于1000怎么办它只会机械地执行标红所有单元格。再比如对每个Sheet里的最后一行做汇总没有循环结构就得一行一行手写命令等于没自动化。这篇的核心工作就是给解释器补上条件分支和循环顺便把一些批量操作、格式化操作的能力也加了进去。我尽量把语法设计、AST构建、执行过程的思路都讲清楚最后用三个真实场景的脚本做演示。如果你也在做类似的DSL领域专用语言这篇文章里的几个坑你迟早也会踩到。1. 流程控制从单条命令走向完整程序1.1 为什么第三篇必须做流程控制做解释器这东西有个很实际的体验词法分析和基础执行做完了你会觉得哎呀还挺好用然后马上就会遇到第一个坎——你写的脚本开始需要看情况办和重复干。我自己当时列的待办事项是这样的一个销售表要根据销售额列是否超过阈值来决定备注列写什么一个工作簿有12个月份Sheet要把每个月的数据汇总到一张总表里一堆零散的表格要把符合某个条件的行复制到另一个工作簿里。这三件事没有一个是靠一条条顺序语句能完成的全都要条件判断和循环。很多人在这一步会犯一个设计上的错误一上来就照着Python或者JavaScript的语法模型去做把if/else/for直接翻译成中文关键字就完事。结果做出来的东西怎么看怎么别扭用户拿到手还要学一套中文Python。我的建议是反过来的先想清楚你的目标用户会怎么写这句话再去定语法。对我来说目标用户就是我自己以及团队里那些不太会写代码的同事。他们的表达习惯是如果销售额大于1000那么备注写重点而不是IF sales 1000 THEN。所以语法设计上必须往自然中文靠拢。1.2 中文关键字如何表达条件与循环我最终的语法方案定成了这样条件判断用如果...那么...否则...结尾用结束如果收口遍历用遍历...在...中搭配结束遍历数值循环用循环...从...到...循环内支持跳过相当于continue和中断相当于break整套关键字列表如下关键字含义示例如果条件分支开始如果 金额 大于 1000 那么那么分支内容引导词上例中的那么否则另一分支否则结束如果条件块结束结束如果遍历集合遍历遍历 行 在 数据表 中结束遍历遍历块结束结束遍历循环数值循环循环 列号 从 1 到 10跳过跳过本轮循环跳过中断退出循环中断并且 / 或者逻辑组合如果 金额 大于 1000 并且 部门 等于 销售 那么这套语法设计有几个关键取舍值得展开。第一结束如果和结束遍历这种显式收口我认为是必须的。中文没有花括号也没有缩进换行这种强约束你要是只靠那么和否则来划分分支解释器遇到嵌套时根本不知道该在哪里结束。显式收口词的解析成本很低但带来的可读性和可解析性收益非常大。第二遍历和循环做了区分。遍历面向Excel表格的行集合循环面向数字区间两者在语义上和底层实现上都有差异。把它们分开词法分析可以更精准执行器也能针对不同的迭代对象做优化。第三逻辑运算符我选用了并且和或者而不是且或ANDOR这些。原因是且或太短在中文分词时容易跟前后词汇发生粘连增加歧义并且和或者词边界清晰分词时好处理。1.3 最终确定先支持哪几种语法做设计最怕的是贪大求全。我知道不少做DSL的人一开始就把函数定义、类、模块导入全规划进去结果写了一堆根本用不上的AST节点。我在这一篇里给自己的边界划得很清楚只做条件分支、集合遍历、数值循环、还有循环转移语句跳过/中断。函数定义、自定义函数调用、内置函数的动态注册这些功能我明确留到后面再说因为从Excel自动化的实际需求来看99%的日常活儿靠条件循环基础读写表达式计算就能完成。另一个取舍是当...循环while循环我没做。当时想的是用Excel的人很少会写出当单元格不为空时一直执行这种逻辑因为表格的边界是固定的遍历行集合完全够用。当你发现自己在一遍遍重写同一段脚本时优先缺的不是while而是把常用操作封装成函数的能力。这个判断在后来的实际使用中算是验证了。2. 解释器核心代码的改造路径2.1 词法分析从切词到切句子结构前两篇的基础词法分析器只能处理动词参数这种扁平的句子结构TRUE TOKEN 流基本是GET、CELL、A1、TO、变量这种一串Token。现在加入流程控制后词法分析要能识别结构块的开头和结尾而且要考虑中文没有空格的现实。中文分词的一个天然难题是关键字和参数之间没有空格分隔如果金额大于1000那么是一整串连续字符。单纯的按空格split肯定不行。我用的方案是最长匹配优先的扫描策略也就是说词法分析器手里有一个关键字表扫到某个位置时先尝试匹配最长的关键字词条。比如扫描到如果金额大于1000那么这个字符串时从开头逐字扫描发现如果是一个关键字就切出来再往后走金额在代理解释器的符号表里存在识别为变量名大于匹配到比较运算符1000是数字字面量那么是引导词。每一步都是最长匹配。这里有一个很容易踩的坑因为如果也包含了如这个字如果扫描机制不够精细很可能把如果切错位置。所以我把关键字的匹配优先级按词条长度倒序排列先匹配长词再匹配短词。同时在切分规则里明确关键字只匹配连续中文串的最长前缀保证完整性。词法分析器最终输出Token的种类扩展成了这几类关键字KW_IF、KW_THEN、KW_ELSE、KW_END_IF、KW_FOR_EACH、KW_FOR、KW_SKIP、KW_BREAK、比较运算符大于、小于、等于、不等于、逻辑运算符并且、或者、括号左括号、右括号、类型字符串、数字、浮点数、变量名、单元格引用、属性引用行.金额这种带点号的引用。词法分析器代码里我维护了一个Token模式表大致长这样KEYWORDS { 如果: TokenType.KW_IF, 那么: TokenType.KW_THEN, 否则: TokenType.KW_ELSE, 结束如果: TokenType.KW_END_IF, 遍历: TokenType.KW_FOR_EACH, 结束遍历: TokenType.KW_END_FOR, 循环: TokenType.KW_FOR, 跳过: TokenType.KW_SKIP, 中断: TokenType.KW_BREAK, 大于: TokenType.OP_GREATER, 小于: TokenType.OP_LESS, 等于: TokenType.OP_EQUAL, 不等于: TokenType.OP_NOT_EQUAL, }扫描的时候优先查最长匹配def tokenize(script: str) - list[Token]: tokens [] pos 0 length len(script) while pos length: token, pos match_longest_keyword(script, pos) if token is None: token, pos match_identifier(script, pos) if token is None: token, pos match_number_or_string(script, pos) if token is None: raise SyntaxError(f位置 {pos} 无法识别的字符) tokens.append(token) tokens.append(Token(TokenType.EOF, , pos)) return tokens这里面match_longest_keyword是核心。它会把关键字列表按长度排序从当前位置尝试匹配如果匹配不上就去看下一个。2.2 AST节点扩展与嵌套规则词法分析搞定了Token流接下来是语法分析。我沿用前两篇的手写递归下降解析器新增了几个AST节点类型。AST节点设计如下class IfNode: 条件分支节点condition 是条件表达式then_branch 和 else_branch 是子命令列表 def __init__(self, condition: ConditionNode, then_branch: list, else_branch: list): self.condition condition self.then_branch then_branch self.else_branch else_branch class ForEachNode: 遍历节点遍历某个集合或表格的行 def __init__(self, var_name: str, iterable_expr: ExpressionNode, body: list): self.var_name var_name self.iterable_expr iterable_expr self.body body class ForLoopNode: 数值循环节点从 start 到 end 逐个赋值给循环变量 def __init__(self, var_name: str, start: ExpressionNode, end: ExpressionNode, body: list): self.var_name var_name self.start start self.end end self.body body class BreakNode: 中断循环节点 pass class SkipNode: 跳过本轮循环节点 pass解析嵌套结构时用了一个很朴素但也很好用的递归思路解析到如果关键字就调用parse_if()parse_if内部解析完分支体之后遇到结束如果就返回IfNode解析到遍历就调parse_for_each()内部递归构建body列表。举个实际脚本的例子遍历 行 在 当前Sheet 如果 行.金额 大于 1000 那么 行.备注 重点 否则 行.备注 普通 结束如果 结束遍历解析器最终会构建出如下的AST结构我简化成缩进表示的树形图ForEachNode ├── var_name: 行 ├── iterable: 当前Sheet └── body: └── IfNode ├── condition: BinaryOp(, FieldAccess(行,金额), 1000) ├── then_branch: │ └── AssignNode(FieldAccess(行,备注), 重点) └── else_branch: └── AssignNode(FieldAccess(行,备注), 普通)这种AST结构的好处是执行器非常直接照着一个节点一个节点往下处理就行不需要大量回溯。嵌套层级再多也只是递归深度的增加不会对解析逻辑产生额外的复杂度。2.3 执行器递归求值与作用域管理执行器的改造相比解析器要简单一些因为前两篇已经有了一个能执行基础命令的execute()函数。现在要做的事情就是在这个execute()里增加对新节点类型的处理分支。def execute(self, node, context): if isinstance(node, IfNode): result self.eval_condition(node.condition, context) if result: self.execute_block(node.then_branch, context) elif node.else_branch: self.execute_block(node.else_branch, context) elif isinstance(node, ForEachNode): iterable self.eval_expression(node.iterable_expr, context) for item in iterable: context.set_variable(node.var_name, item) try: self.execute_block(node.body, context) except LoopControlSignal as signal: if signal.control_type break: break elif signal.control_type skip: continue elif isinstance(node, ForLoopNode): start self.eval_expression(node.start, context) end self.eval_expression(node.end, context) for i in range(start, end 1): context.set_variable(node.var_name, i) try: self.execute_block(node.body, context) except LoopControlSignal as signal: if signal.control_type break: break elif signal.control_type skip: continue ...这里有一个设计细节值得说一下执行器里我用了一个LoopControlSignal异常类来承载跳过和中断的控制信号。为什么不直接用Python的break和continue因为解释器的循环是Python的循环包着一层但你自己写的脚本块是在一个execute_block()递归调用里执行的直接用break只能跳出最里层的Python for循环跳不出去你自定义的脚本循环。用异常控制流的做法虽然有点重但仔细想想是合理的——脚本的嵌套层级是不确定的而异常能穿透任意深度的调用栈直接让最外层的循环解释器收到控制信号。这也是很多真正的解释器实现会采用类似机制的原因。作用域管理上我多加了一个变量栈的结构。前两篇的context只是一个简单的字典变量名直接映射到值。现在加上循环之后如果两层循环都定义一个同名变量比如两层都用行作为变量名内层赋值就会覆盖外层退出内层循环后外层的值就丢了甚至可能直接污染外层。解决方案是给Context加一个变量栈每个循环块进入时压栈一层退出时弹栈一层变量查找时从栈顶往下找。这样内层同名变量不会影响外层循环变量退出循环后也自动消失。class Context: def __init__(self): self.variable_stack [{}] def set_variable(self, name, value): self.variable_stack[-1][name] value def get_variable(self, name): for scope in reversed(self.variable_stack): if name in scope: return scope[name] raise NameError(f未定义的变量: {name}) def push_scope(self): self.variable_stack.append({}) def pop_scope(self): self.variable_stack.pop()执行循环体之前push_scope执行完pop_scope就这么简单但效果极其显著。后来我在实际用的时候有一个脚本写了三层嵌套循环变量名全是ijk要不是有这个变量栈隔离早就乱套了。3. 配套Excel自动化能力的精进3.1 cell读取增强动态行号与列名有了条件判断和循环之后Excel操作接口的频率和模式都变了。前两篇的cell读取是写死的比如读取 A1但现在脚本要遍历几百行数据行号必须动态计算列名也可能是由循环变量带出来的。我给运行时加了一个单元格引用的动态解析机制支持两种写法直接引用A1、C10这是固定的单元格地址变量拼接引用行.金额这实际上是一个字段访问表达式执行时会从当前行的数据记录里取值具体来说遍历 行 在 当前Sheet这个操作执行时运行时会把当前Sheet的数据按行读出来每一行打包成一个对象——我叫它RowRecord。这个对象支持属性访问属性名对应Excel的列标题。比如表格的表头是地区、销售额、备注那遍历的时候访问行.地区就能取到当前行的地区字段行.销售额就是当前行的销售额。行.备注 xxx则是修改该字段的值之后一次性回写Excel。动态列名也是靠这个机制间接实现的。如果你想按列号访问单元格比如第5列第3行可以写成单元格(5, 3)这种函数式表达式。执行器对这类的求值比较直接先计算括号里的下标表达式再调用底层Excel接口。3.2 批量写入与格式设置增加循环之后遇到性能问题这个问题我放在后面常见问题部分细说这里先说接口层面的解决方案。原先的写入 A1 xxx一次只操作一个单元格效率极低。我在这一版里加了两个接口读取区域 A1:C10 到 变量一次性把区域数据读入一个二维列表脚本里就可以对这个列表做任意处理。写入区域 A1:C10 变量把二维列表一次性写回Excel。这两个接口的底层实现用的是pywin32或者openpyxl的批处理能力效率上比逐格写入提升了一个数量级。另外我还加了一个格式设置命令设置格式 行.金额 颜色红色 加粗是它可以批量应用格式到当前行、当前列或者某个区域。格式设置也是批量执行避免循环里针对每格单独调用格式化接口。说实话格式这块最初没打算在这一版做但后来发现实际业务里标记重点行这个需求太常见了配个颜色比生成一列文字更直观。3.3 循环内当前行上下文的设计这部分算是我自己觉得比较得意的设计。循环体内部用户的脚本写法是行.金额、行.备注这个行是循环变量。但实际执行时解释器要知道当前正在遍历的是哪一行这个状态在遍历过程中是动态变化的。实现上ForEachNode执行时每次迭代会把迭代对象当前的记录包成一个RowRecord放入Context的变量栈里。而行.金额这个表达式在求值时会先解析出行这个变量指向的RowRecord再取出它的金额属性。这个设计带来一个好处脚本里循环变量名可以自己取遍历 记录 在 销售表就可以用记录.金额语义上更自然。词法分析器不关心变量名具体叫什么只在表达式求值时通过Context去查。变量名就只是一个标识符。另外当前行上下文还可以配合内置变量使用比如脚本里可以访问当前表、当前Sheet名这类全局上下文变量。这个对于跨Sheet处理特别有用。4. 三个真实场景的脚本实操4.1 场景一销售明细表的自动归档第一个场景来自一个真实的业务需求一张8000多行的销售明细表包含地区、产品、销售额、销售日期四列。业务流程要求把不同地区的数据分别归档到对应的分表工作簿里。用中文脚本写出来是这样的读取区域 B1:D8000 到 销售数据 遍历 行 在 销售数据 如果 行.销售额 大于 0 那么 遍历 表 在 当前工作簿 如果 表.名称 等于 行.地区 那么 写入 表.最后一行1 到 行.产品 写入 表.最后一行1 到 行.销售额 写入 表.最后一行1 到 行.销售日期 结束如果 结束遍历 结束如果 结束遍历这段脚本看起来很长但你要想如果手工在Excel里做这件事8000行数据、按地区归档没有几小时下不来。脚本跑一次大概20秒。这里有一个执行细节写入表.最后一行1这个位置是怎么算出来的运行时在执行写入命令前会先计算目标行号表达式表达式里调用了最后一行这个内置属性实时从Excel对象模型里查询该Sheet的最后数据行。如果你的写入顺序是先写产品再写销售额再写日期每写一次最后一行都会变化所以我把表达式求值时机放在每条写入语句执行时而不是循环开始时统一计算。这个细节如果做错所有数据都会堆到同一行。实际跑下来的效果是各个地区的数据被分行、有序地追写到了对应分表。稍微再优化了一下在写入前用一条清除命令把分表的旧数据清掉这样脚本就能反复跑不会重复归档。4.2 场景二跨Sheet数据汇总与一致性校验第二个场景是校验月度报表的数据一致性。工作簿里有12个月的Sheet外加一张汇总Sheet。汇总表里列出了所有应该有的产品编号月度表里如果某个编号缺失要在汇总表里标记出来。脚本思路先遍历汇总表拿到所有编号然后遍历每个月度Sheet逐个检查编号是否存在于汇总表。遍历 月度表 在 [一月, 二月, 三月, 四月, 五月, 六月, 七月, 八月, 九月, 十月, 十一月, 十二月] 遍历 编号 在 月度表.列A 如果 编号 不等于 那么 事件 0 遍历 汇总行 在 汇总表 如果 汇总行.编号 等于 编号 那么 事件 1 结束如果 结束遍历 如果 事件 等于 0 那么 设置格式 汇总行.编号 颜色黄色 结束如果 结束如果 结束遍历 结束遍历这个脚本在处理逻辑上有个隐藏的嵌套复杂度两层遍历里又套了一层遍历而且中间的事件变量充当了标志位的角色。这是初学者很容易写乱的场景因为汇总行这个变量在内层遍历结束时它的值已经变成了汇总表的最后一行如果不小心在外层继续引用它会拿错数据。这块在语法层面我没做什么特殊设计就是靠变量作用域隔离来保证循环变量不串味。每个循环体有自己的作用域编号和汇总行各自独立就算和外层变量重名也不会覆盖。真实数据大概是8000个编号、12个月份脚本跑了一分钟左右一次性把缺失编号的月份找了出来并做了黄色标记。手工核对的话这个工作量真不是人干的。4.3 场景三条件批量生成报表列第三个场景更贴近数据分析。一张订单表需要根据订单金额这个字段批量生成一列档位标签大于等于5000是A档2000到5000是B档其余是C档并且把档位单元格的背景色区分开。遍历 行 在 订单表 如果 行.金额 大于等于 5000 那么 行.档位 A档 设置格式 行.档位 颜色红色 否则 如果 行.金额 大于等于 2000 那么 行.档位 B档 设置格式 行.档位 颜色橙色 否则 行.档位 C档 设置格式 行.档位 颜色蓝色 结束如果 结束如果 结束遍历这段脚本其实暴露了一个设计短板我最初没有做否则如果elif这样的语法所以条件链只能靠嵌套如果实现。对于没有复杂嵌套经验的使用者写起来确实有点丑。但换一个角度看这种朴素的嵌套结构反而让AST和执行器都更简单不用处理elif和if之间的剪枝关系。如果你也想做类似的解释器我建议在第一版里也可以先不做elif等到真的有很多用户抱怨嵌套层级太深时再加。至少在我的实际场景里两三层嵌套的如果完全够用而且读起来也不算特别费劲。跑完这段脚本订单表多了一列档位数据同时每个单元格的背景色跟档位一一对应。后续如果要做透视分析直接按档位列筛选就行。5. 常见问题与排查技巧实录5.1 中文关键字的歧义问题中文脚本解释器最大的坑之一就是分词歧义。我遇到最典型的两个案例一是如果这个关键字和变量名如果撞了。比如你有一个字段叫如果脚本里写行.如果 等于 1 那么词法分析器在处理时可能把如果误判成关键字。解决方案是在词法分析阶段变量名和属性访问的上下文有限定。当解析器处于访问属性的语境时点号后面就不会再把它当成关键字。这其实不是词法分析能单方面解决的需要语法分析的上下文参与。二是运算符不等于和等于的处理。如果关键字表里只有等于而扫描器遇到不等于按最长匹配会出问题。我的做法是先把不等于作为一个整词加到关键字表里再匹配等于靠最长匹配保证不等于先被识别。这种坑你只要不小心就会遇到写中文DSL的人一定注意关键词表的匹配顺序。5.2 循环嵌套里的作用域坑这个问题前面提过一版我在实际使用中真的被坑过一次。当时写了一个两层遍历内层和外层都用了行作为循环变量。按我原始的Context简单字典实现内层循环赋值行会直接覆盖外层的行变量内层循环结束时外层循环的行再也找不到了。后果是外层循环第二次迭代时行还是第二个内层循环最后那个值数据错得离谱。后来加了变量栈每个循环块进栈、出栈这个问题就彻底解决了。我建议所有做自定义语言或DSL的人无论规模大小作用域都按块级作用域变量栈来做不要图省事用单层字典。5.3 性能瓶颈与批量优化前面提到批量写入这里详细说一下性能问题的来源和优化手段。我的解释器最初的Excel操作是通过COM对象逐格访问的。遍历8000行数据逐格读取要做8000次COM调用逐格写入又是8000次再加格式设置总共轻松超过两万次COM调用。COM调用的开销是每次调用都要跨进程做对象访问性能瓶颈往往卡在往返通讯上。实测下来逐格读8000行可能要20秒而一次批量读入区域只耗时几百毫秒。所以我在解释器里特意加了读取区域和写入区域这两个批量接口让脚本设计者优先用区域操作。如果你要处理几万行数据逐格操作基本不可用。我的建议是遍历前的数据先一次性读入内存读取区域脚本里的遍历操作实际上遍历的是内存中的二维列表而不是Excel对象模型。这个优化让程序的性能从分钟级直接跳到秒级。这其实也解释了为什么我的遍历命令操作的是数据表和当前Sheet而不是Excel对象集合——因为底层实际上是先做了一次批量快照再在快照上迭代。你的解释器如果也打算做类似功能这个思路可以少走很多弯路。5.4 调试武器AST打印与执行日志写解释器的人都会遇到这种时刻脚本逻辑明明看起来没问题但执行结果就是不对。这时候如果没有好的调试手段排查效率极低。我给自己加了一组调试命令和工具。首先是AST打印功能在解析阶段结束后可以用命令行参数控制是否输出AST结构。比如执行器加一个详情模式SET 调试模式 开启开启后解释器会在每条语句执行前打印该语句对应的AST节点类型和关键参数。这样定位问题非常方便你一眼就能看出来解析器有没有把如果的condition解析对或者遍历的iterable到底取到了什么。其次是执行日志每个Excel接口调用都记录操作类型、目标单元格、耗时。跑完脚本之后统一输出。这个日志在性能排查时特别有用你能直观看到是哪个操作耗了多少时间然后针对性优化。第三是表达式求值的跟踪在调试模式下每个二元表达式的左右操作数求值结果都会打印出来。判断条件到底是不是满足预期一跑日志全明白。我自己排查条件判断不触发这种问题90%靠这个功能就能解决。注意调试模式开启后脚本运行速度会慢不少因为它对每条语句都做日志输出。建议只在调脚本阶段开启调通了就关掉。5.5 避开中文关键字吃掉变量的经典陷阱最后补充一个很隐蔽的坑。词法分析器用最长匹配处理中文关键字时有时候会遇到中文里两个短语组合起来恰好形成了另一个关键字的情况。举个例子我的关键字表里有结束和结束如果如果脚本里有一个变量名就叫结束那么当解析器看到结束如果时它会优先匹配结束如果而不是结束这是没问题的。但如果脚本里写的是结束 如果中间带了个空格那么这个词法分析会先切出结束再切出如果语法解析器就会认为这是一个结束如果关键字吗不会因为Token位置信息不同语法分析器按Token类型匹配。但如果有用户写结束如果时在中间敲了个空格词法分析就会把它拆成两个Token语法分析器就会报错意外的关键字。这个坑在最初测试时经常被人撞到后来我做了处理在词法分析时先剔除关键字内部空格并提示用户。你如果也在做中文DSL这类用户输入的灵活性问题值得提前考虑。写到这里这篇的内容其实已经讲完了这一版做完我自己的解释器才算真正能接手一些日常的Excel处理任务。从产品、销售到财务的表格都有了可复用的脚本。整个过程中最大的体会是做解释器这类东西最难的不是词法分析也不是AST执行而是克制。语法上少加一个功能接口上少做一层抽象当时看起来像是偷懒实际用下来反而更稳。最后留一个小经验给正在做类似工具的朋友不要急着给脚本语言加优雅的语法特性比如闭包、装饰器、泛型这些一概不碰先把最常见的业务场景跑顺。你自己觉得有点丑的设计在真实用户那里可能反而更直接、更好理解。一个好的中文脚本解释器不是最像Python的那个而是用户最不需要查文档的那个。
返回列表