写一个轻量静态分析工具:AST、圈复杂度与重复代码检测的工程实践
上个月我接到一个需求要在一个三年没人动过的模块里加一个配置项。等我打开那个模块的时候我明白了为什么前一个人特意在交接文档里写了一句多看几遍再动手。那个文件里三十多个函数命名分别是f1、f2、do_thing、handle_data、_t缩进有的两空格有的四空格同一个字段在三个地方用了三种拼写。改完那需求我又多花了至少半天在查复制粘贴代码带来的隐藏bug。这种事遇到一次两次还能忍次数多了我就动了个念头与其靠人眼去读不如写一个工具替我读。于是就有了rea。rea这个名字我琢磨了一下读起来就是re背后是Readability Efficiency Analyzer——可读性与效率分析器。它干的事很单纯扫描一个代码库里看起来不对劲的地方然后告诉我哪里值得重构、哪里埋着隐患、哪些代码是从别处复制粘贴过来的。如果你也维护过老项目或者正在从零搭一套轻量的团队代码质量检查方案这篇文章里的思路应该能派上用场。1. 从哪里来一次代码审查逼出来的小工具1.1 一个典型的维护型需求我先说说触发这个工具的原始场景。表面问题是加一个配置项实际问题是这个模块经过三个人手每个人都按自己的习惯往上堆代码。我印象最深的几个现象如下复制粘贴两个函数除了变量名函数体一模一样改了一处忘了另一处。魔法数字一段页面跳转逻辑里全是1、0、-1没人知道它们代表什么状态。超长函数一个函数两百多行中间夹杂着数据校验、日志打印、状态轮询三种职责。命名自创缩写完全无规律statusWritten缩写成sw结果和另一个表示switch的sw撞了。这些现象用肉眼当然能发现但前提是你愿意一行一行读。人眼最大的问题是看到一个两百行的超长函数会习惯性地跳过细节、只猜大意。工具不一样工具没有情绪、没有耐心它会一行不漏地数还会把统计结果拍到你面前。我把这些现象整理成了一张小表贴在项目笔记里作为需求基线问题类型肉眼排查成本工具判定成本修复优先度复制粘贴代码高容易看漏低哈希比对即可高长函数/多职责函数中依赖经验低数行号即可高命名杂乱无章高需要项目上下文低查命名规范中魔法数字中需要理解逻辑低查字面量中1.2 为什么没直接拿现成质量平台来用动手之前我不是没考虑过市面上的代码质量分析平台。它们确实强大支持几十种语言、上千种规则、自带Web界面、还能对接CI。但当时的处境很尴尬这个老模块所在仓库有十几万行代码直接上一套重型扫描全量跑一次要半个多小时而且噪音极大——一堆风格告警塞满看板真正要紧的逻辑问题反而被淹没。所以我给自己重新定了三条约束这也是rea设计初期最重要的原则命令行优先不搞Web界面。我要的是在终端里快速得到一个哪里有问题的答案而不是打开一个网页、点三次筛选器。默认规则少而准。宁可只检查二十条真正能推动重构的规则也不追规则数量堆到几百条。每条产出都要能读得懂。表达方式是第X行函数体超过推荐长度建议拆成三部分而不是一堆晦涩的规则编号。这三条约束基本上把工具形态定死了一个纯本地运行、输出人类可读文本的命令行分析器。后来的所有架构决策都是在不违背这三条的前提下做的。1.3 rea要回答的三个核心问题进一步梳理需求时我发现自己真正想回答的是三个问题哪段代码最值得重构、哪段代码最可能藏着隐患、哪些代码是复制粘贴出来的。这三个问题分别对应rea的三大核心模块命名与结构检查、圈复杂度计算、重复代码检测。我决定先只做这三个方向是因为这三个方向的信息密度最大也最容易在团队里形成共识。比如你说这个函数圈复杂度高建议拆一下开发者普遍能接受你要是说这里缺少一个工厂模式那大概率要争论半天设计方案。工具不适合提偏好性建议它只负责把客观的结构问题找出来具体怎么改还是人的决策。2. rea的静态分析管道设计扫描、解析、匹配、打分2.1 一条六步处理管道rea从拿到路径到输出报告中间走一条固定的管道。把每一步拆开说是因为很多轻量分析工具的问题恰恰出在管道不清晰上规则和遍历逻辑混在一起后面每加一条新规则都要动主流程越改越乱。步骤输入输出说明1. 收集文件仓库根路径、目标语言待分析文件列表用git ls-files避开.gitignore同时跳过二进制和自动生成文件2. 词法/语法解析单个文件源码语法树按文件类型选解析器解析失败的进入异常台账3. 规则匹配语法树问题清单每条规则只认AST节点不碰源码字符串4. 重复检测归一化后的Token流重复块候选独立于规则匹配单独走一套相似度算法5. 聚合打分问题清单、重复块、复杂度文件级与模块级评分权重可配置默认值后面会贴6. 生成报告评分结果终端表格、Markdown摘要支持只看Top N和只看某类问题这个管道最大的好处是每一步输入输出都是确定的。后来帮团队加新语言支持时只替换第二步的解析器和第六步的规则包其余四步完全不动。可以说前期花在管道设计上的时间在后来的扩展里全赚回来了。2.2 为什么最终选了AST而不是纯正则这是整个项目里最值得强调的选型决策。最开始图省事我写过一版纯正则扫描匹配line too long、匹配TODO、匹配超过N个参数的函数签名。用在小型测试文件上确实快但一碰真实项目就露馅。主要有三个问题正则没法理解嵌套结构。找函数体太长至少要识别函数起点和终点而函数体里的字符串、注释、匿名函数会让边界判定直接失控。正则容易误伤注释和字符串。一段注释里写TODO: remove this workaround正则扫到会以为真有个待办事项其实只是历史备注。正则无法回答这段代码属于哪个函数这种结构性问题。而AST天然带父子关系一条规则能轻松判断这个if是不是嵌在for循环里正则完全做不到。所以我选择了AST方案。以Python为例直接用标准库ast模块就行不引入外部重型依赖。引入AST之后代码量确实变大了但规则的准确率和表达能力完全不在一个量级。为了验证选型我拿同一个坏味道样本分别跑了两个原型正则版有47条误报AST版只有3条。从那之后我就没再动过正则单干的念头。2.3 打分模型不追求绝对公平追求相对排序打分这块花了不少心思。我一开始想得简单发现问题就扣分超长函数扣10分、魔法数字扣2分。真正实现时才发现不同文件的可比性太差——一个只有200行的工具函数和一个2000行的业务控制器天然不在一个量级上。我最后采用的公式是这样的可读性得分 100 - 结构违规扣分 - 命名违规扣分 - 魔法数字扣分 注释覆盖加成分风险指数 圈复杂度超阈值次数 × 权重A 重复代码块数量 × 权重B 超长函数数量 × 权重C权重默认值放在配置文件里A3、B2、C2。为什么这么设从实际经验看圈复杂度过高往往是逻辑腐烂的起点权重必须最高重复代码虽烦人但至少行为一致风险次之超长函数更接近风格问题排第三。下面是默认权重参考指标默认权重说明圈复杂度超阈值3直接影响测试难度与回归风险重复代码块2修改遗漏概率高但行为稳定超长函数2阅读成本高但不直接生产bug命名违规1新人不友好但好修需要强调一句rea的分数更适合排序不适合当绝对体检线。同一项目团队应该看这一轮扫描和上一轮相比Top问题变化了多少跨项目对比分数没有意义因为代码风格基线完全不同。3. 实现里值得较真的三个细节AST边界、相似归一化与规则注册3.1 三类文件要精准跳过否则报告全是噪音第一次全量扫描仓库时报告里塞满了来自自动生成目录和第三方依赖的问题——这些根本不是人能改得动的东西报出来只会白费注意力。我梳理后确认了三类必须跳过的文件自动生成代码文件头有Code generated by xxx. DO NOT EDIT这类标记改了也会被生成器覆盖。第三方依赖目录常见有vendor、node_modules、dist路径特征明显必须白名单式排除。锁文件和状态快照各类lock文件里几乎全是哈希和版本号没有分析价值文件还特别长。判断逻辑是路径特征过滤优先、文件头注释二次确认。路径特征过滤要放在最前面因为它最便宜——不需要打开文件字符串比较就能结束。文件头注释确认则是为了拦住那些路径不典型但确实是生成代码的漏网之鱼。这套组合下来报告里的无效噪音直接消掉了七成以上。3.2 重复代码检测从完全一致升级到擦掉变量名重复检测是重头戏。如果只做完全一致的字符串匹配根本抓不住真实世界的重复代码因为几乎没人会一字不改地复制粘贴更多人会顺手改变量名、加空行、把两句合成一句。我用的是分词归一化加滑窗签名的方案核心思路分四步先对源码做词法分词丢掉空白和注释。把所有标识符替换成统一占位符例如变量名全部变成VAR_NAME。这一步是关键相当于把张三和李四说的话统一成两个人在说同样的话。把归一化后的Token序列切成固定长度滑窗我默认窗口大小是50个Token。对每个窗口算哈希相同哈希的窗口合并成候选块再做一次行号连续性检查过滤掉碰巧撞哈希的碎片。归一化前后对比很直观。归一化前两段代码看起来完全不同# A版本 def _parse_user(data): return data[name].strip() # B版本 def _parse_member(item): return item[name].strip()归一化后两者都变成类似FUNC ( VAR ) : RETURN VAR [name] . strip ( )的结构滑窗哈希一致于是被判定为重复候选。这就是擦掉变量名的威力。配合重复阈值设置我默认相似度低于80%的块会被忽略避免把两段结构近似但没有血缘关系的代码误判成复制粘贴。踩过几次阈值调参的坑后我总结的经验是阈值太高发现不了半路改写的逻辑阈值太低误报满天飞80%是一个对多数语言都够用的起点。3.3 规则引擎用装饰器注册加新规则不碰主流程规则引擎的写法直接决定了未来的扩展成本。我把每条规则都设计成独立类用装饰器注册到引擎里大致长这样rule( namelong_function, scopefunction, severitywarning, description函数体超过推荐长度建议拆分为更小的函数 ) class LongFunctionRule: max_lines 80 def check(self, node): for func_node in collect_functions(node): total func_node.end_line - func_node.start_line if total self.max_lines: yield func_node, f函数超过{self.max_lines}行当前{total}行引擎只负责遍历语法树、调用已注册规则的check方法、收集结果。这样想加一条新规则只需要新建一个类文件完全不用碰管道代码。我后来接到团队需求加一条禁止重复捕获同一异常的规则从写类到验证十分钟就搞定靠的就是这个插件化架子。这里有个细节值得单独说规则里一定要用语法树节点提供的行列号不要自己去源码里数行数。前者在注释和字符串存在时依然准确后者很容易被字符串里恰好换行之类的情况骗到。4. 实跑之后踩过的三个坑误报、性能瓶颈与没人看报告4.1 误报之痛把递归高亮当成了复杂度超标工具第一次给自己项目跑结果时我自己看了一眼就笑了某个核心递归函数被标成复杂度超标危险区域。那个函数确实复杂但它的复杂是业务场景本身需要并不是编码质量差。这就是纯规则检查的通病——它不知道上下文。我的解法分两层。第一层是白名单豁免在配置文件里允许显式声明某些函数跳过复杂度检查。第二层是做一个横向对照把告警字段里明显是同一家族的重复结构合并只在第一次出现的位置给提示避免一个递归栈里的每个调用点都被各报一遍。这个改动让误报率肉眼可见地下降了一半左右。优化前优化后递归函数每次调用都触发告警同一结构块仅报一次误报率约30%误报率约12%团队开始怀疑工具的可靠性团队愿意读报告了误报不可能清零目标应该是让每个告警都有清楚的上文背景。一条没有上下文的报错和没报没有任何区别。4.2 性能瓶颈两万文件仓库扫描从十分钟降到几十秒第一个版本扫描一个中型仓库跑一次要差不多十分钟。我当时一度想放弃因为这样的性能撑不起日常使用。排查之后发现瓶颈有三处文件遍历方式太笨。一开始用os.walk把node_modules这类目录也扫进去了。改成git ls-files之后直接只拿Git跟踪的文件一次就少了几万个无用路径。AST解析是CPU密集型任务。我把单进程改成multiprocessing并行池按文件数量分片解析效率接近线性提升。重复检测的哈希计算一直在白费力气。同一个文件被反复切窗、反复算哈希。后来加了一道文件级缓存缓存键是文件mtime加文件大小文件没变就直接复用上次的哈希结果。优化完同一仓库冷启动大概50秒第二次因为有缓存能压到十几秒。我整理过一张对比记录在项目文档里阶段全量扫描耗时说明初版约10分钟os.walk遍历 单进程解析改用git ls-files约6分钟去掉无用目录增加并行池约3分钟CPU跑到接近满载增加文件级缓存冷启动50秒/次运行十几秒接近可日常使用十几秒是一个心理临界点再快人就会愿意频繁跑再慢人就会找理由不跑。性能优化做到够用而不炫技的程度对工具的采用率帮助很大。4.3 没人看报告的教训输出格式决定工具生死最挫败的一件事是工具跑通了结果团队没人看。我一开始输出的是JSON心想数据分析总该从结构化数据起步。但现实是没有谁会为了看一眼问题清单去解析一个大JSON。我把格式改成终端友好的表格再把最严重的Top 20单独拎出来用高亮行标注对应的代码行。这样开发者扫一眼就能知道我接下来该改哪里。再后来我加了Markdown摘要模式可以直接贴到文档或IM频道里报告就从没人看变成了每周自动生成一版。输出格式这件事的启示是不要高估同事对工具的耐心。任何人打开一个分析报告如果前五秒没有看到和自己有关的结论大概率直接关掉。工具输出足够直白才谈得上有后续行动。5. 落地之后团队的变化以及我为什么不打算再加一项功能5.1 接入pre-commit钩子只检查改动的文件工具做好之后如何让大家不忘记用比工具本身更难。最有效的一步是把rea挂到pre-commit钩子里并且只对本次变更的文件做增量检查。钩子脚本很简单#!/usr/bin/env bash CHANGED_FILES$(git diff --cached --name-only --diff-filterACM | grep -E \.(py)$) if [ -z $CHANGED_FILES ]; then exit 0 fi REA_STATUS$(rea scan --files $CHANGED_FILES --format summarize) if [ -n $REA_STATUS ]; then echo $REA_STATUS exit 1 fi exit 0提交时触发检查能让问题暴露在最早阶段。为了避免影响开发节奏我只让它拦截最严重级别的告警其余级别只打印提示。这里有个小教训一开始我把所有警告级别都设成拦截结果同事提交一个配置文件都过不去差点把钩子卸了。后来改成只有高危告警拦截配合度立刻提升。半年用下来团队在提交前顺手处理掉的坏味道比过去集中式审查时多了一倍多。5.2 把体检分数当趋势看而不是当考核表刚开始把分数贴进周报效果并不好大家有一种被考核的感觉。后来调整了用法分数不横向比只看单个模块的纵向趋势。每个迭代跑一次目标是上一轮的后十名问题这一轮还剩哪些。这个定位从扣分批评变成了帮助排优先级团队自然就接受多了。新来的同学对这个工具的反馈反而最好。他们不像老人那样有一肚子项目历史记忆遇到看不懂的代码本来就慌。rea的输出相当于一个按图索骥的提示先看这里再看那里。有位新同事照着报告里的Top问题清单反推代码两天就把一个老模块的脉络摸清了他说这比光看文档高效得多。5.3 我拒绝增加的一项功能自动改代码不断有人建议给rea加一个自动修复模式发现坏味道直接改改完自动提交。我认真考虑过最后还是决定不做。原因很简单自动改风格类问题比如缩进、格式是安全的但自动改结构类问题——拆函数、消重复、降复杂度——很容易引入隐蔽的行为变化。工具改代码出了问题背锅的最终还是人。所以rea永远只负责指出问题并给建议真正的决定权和操作留在开发者手里。下一步我倒打算做一件小事把规则配置开放成团队可投票修改的文件。每条规则自带理由和案例谁想加谁想改先在配置文件里写清楚再跑给所有人看。配置本身即讨论记录规则变更就成了可追溯的团队决策比在群里争论要不要开这条规则省力得多。这阵子用下来我最大的体会是工具的价值不在于它多智能而在于它能让看不下去这种模糊感受变成一行行具体、可执行、有优先级的事项清单。代码质量的问题永远不可能靠一个工具单方面解决但如果有一个稳定、说人话的扫描器站在旁边至少不会再有人打开老模块时只剩一句这代码真难读。