☰
写一个轻量静态分析工具:AST、圈复杂度与重复代码检测的工程实践
2026/10/12 10:14:06 网站建设 项目流程

上个月我接到一个需求,要在一个三年没人动过的模块里加一个配置项。等我打开那个模块的时候,我明白了为什么前一个人特意在交接文档里写了一句"多看几遍再动手"。那个文件里三十多个函数,命名分别是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设计初期最重要的原则:

  1. 命令行优先,不搞Web界面。我要的是在终端里快速得到一个"哪里有问题"的答案,而不是打开一个网页、点三次筛选器。
  2. 默认规则少而准。宁可只检查二十条真正能推动重构的规则,也不追规则数量堆到几百条。
  3. 每条产出都要能读得懂。表达方式是"第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

权重默认值放在配置文件里:A=3、B=2、C=2。为什么这么设?从实际经验看,圈复杂度过高往往是逻辑腐烂的起点,权重必须最高;重复代码虽烦人,但至少行为一致,风险次之;超长函数更接近风格问题,排第三。下面是默认权重参考:

指标默认权重说明
圈复杂度超阈值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 重复代码检测:从"完全一致"升级到"擦掉变量名"

重复检测是重头戏。如果只做完全一致的字符串匹配,根本抓不住真实世界的重复代码,因为几乎没人会一字不改地复制粘贴,更多人会顺手改变量名、加空行、把两句合成一句。

我用的是分词归一化加滑窗签名的方案,核心思路分四步:

  1. 先对源码做词法分词,丢掉空白和注释。
  2. 把所有标识符替换成统一占位符,例如变量名全部变成VAR_NAME。这一步是关键,相当于把"张三和李四说的话"统一成"两个人在说同样的话"。
  3. 把归一化后的Token序列切成固定长度滑窗,我默认窗口大小是50个Token。
  4. 对每个窗口算哈希,相同哈希的窗口合并成候选块,再做一次行号连续性检查,过滤掉碰巧撞哈希的碎片。

归一化前后对比很直观。归一化前,两段代码看起来完全不同:

# 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( name="long_function", scope="function", severity="warning", 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-filter=ACM | 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永远只负责指出问题并给建议,真正的决定权和操作留在开发者手里。

下一步我倒打算做一件小事:把规则配置开放成团队可投票修改的文件。每条规则自带理由和案例,谁想加谁想改,先在配置文件里写清楚,再跑给所有人看。配置本身即讨论记录,规则变更就成了可追溯的团队决策,比在群里争论"要不要开这条规则"省力得多。

这阵子用下来,我最大的体会是:工具的价值不在于它多智能,而在于它能让"看不下去"这种模糊感受,变成一行行具体、可执行、有优先级的事项清单。代码质量的问题永远不可能靠一个工具单方面解决,但如果有一个稳定、说人话的扫描器站在旁边,至少不会再有人打开老模块时,只剩一句"这代码真难读"。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询