配置漂移的克星:用AST扫描揪出死配置与悬空引用
最近整理手头的工具仓库看到一个叫 cua 的小项目躺着差点忘了它干嘛的。打开 README 才想起来这是我之前被一堆历史配置坑惨之后写的分析器。cua 的全名是 Config Usage Analyzer用来扫描代码里的配置读取点跟项目根目录下那些 YAML、JSON、.env 文件做交叉比对最终输出一份哪些配置项被真正引用、哪些成了死配置、哪些引用了但配置里根本没声明的报告。今天把这个小工具从设计思路到实际坑点完整梳理一遍也顺便说说它在真实项目中跑出来的效果给同样被配置项拖累的团队一个参考。1. 为什么要做cua三个字母背后被配置拖垮的真实项目1.1 一个普通老项目的配置账本我接手过一个典型的旧服务代码量不算夸张但配置文件的规模相当吓人。主配置里躺了 280 多个配置键横跨开发、测试、生产三个环境还有单独的 .env 文件管本地启动参数。这些配置有的是三年前加的有的是两拨人先后改了同一批键最要命的是没有人能说清楚哪些配置还在被代码读取。这种配置膨胀带来的问题不是简单的文件难看它会直接变成事故引信。我印象很深的一次故障某个链路重试参数写的是retry.count代码里却早换成了retry.maxAttempts读取结果retry.count在配置里安静躺了一年多某天值班同事为了让重试更快把这个看着像生效的参数从 3 改成 1线上完全没反应排查半天才发现改的是个死配置。反过来真正的生效参数长期依赖默认值行为已经偏离配置文档的语义了。死配置的危害可以拆成三层来看。第一层是误导新人看配置表会以为某个参数在控制系统行为实际上改了毫无反应这种假安全感非常危险。第二层是排查成本每次线上问题都多一个变量要排除多一台机器上 grep 都没结果时间就这么浪费掉。第三层是变更风险当你要升级依赖、迁移配置中心时每一个死配置都会产生一个要不要保留的判断题全团队没人能拍板最后只能全部带走配置中心里继续堆垃圾。于是我决定写个工具把哪些配置被代码引用这个问题一次性搞清楚。1.2 正则大法试过之后的下场第一版工具其实特别朴素就是用全局搜索和正则把常见配置键都搜一遍看哪些键有引用。这个方案在小型项目里勉强可用但拿到真实项目上很快就露馅了。首先是字符串拼接的代码直接绕过搜索。很多代码喜欢写成config.get(datasource. profile .url)正则根本匹配不到完整的datasource.prod.url这个键但程序运行起来又会真实读取它。再比如模板字符串const dbKey ${prefix}.connection.timeout;这种分裂的写法不是简单的文本匹配能覆盖的。其次是注释和文档里的误报我在文档中写了一句这里引用了 timeout 参数注意不要超过 3000正则一搜命中工具认为这个配置有引用实际上代码里根本没读它。还有更隐蔽的配置文件自身会包含占位符和参考说明像# 对应 xxx.timeout这样的注释同样会造成假命中。正则方案还有一个致命问题——它无法区分被代码真正读取和只不过在某处出现. 如果某个配置键只在测试用例里出现过正则也会认为它活着。这个维度不解决永远拿不到一份可信的死配置清单。所以第二版我干脆推倒重来改成用语法树分析把配置读取定义成代码里的语法行为而不是字符串出现行为。1.3 cua给自己定的三个目标在动手重写之前我给 cua 定了三个非常具体的验收标准。第一要能输出一张关系明确的配置引用表每个配置键对应它的代码引用位置列表没有引用的键标成死配置有引用但配置里没有声明的键标成悬空引用。这张表必须让使用者一眼就能判断不需要再翻代码二次确认。第二要把动态键单独分出来。像config.get(datasource. profile .url)这种静态分析无法确定它最终会拼出什么键但如果因为无法确认就直接把它归为死配置又是另一种误导。我的处理是单独标记成动态引用让使用者在排查时单独关注。第三性能要好。整体扫描一个十万行级别的项目目标控制在几秒内完成否则就不会有人愿意天天跑。后面的实现证明这个目标达成得还算轻松因为绝大多数时间花在 AST 解析上而解析本身就是快速操作。2. cua的核心设计让代码的配置读法变得可查2.1 选型与整体思路为什么选中ASTcua 的定位是一个偏静态分析的开发者工具不是在线服务所以实现语言选了 Python。原因很直接Python 自带ast模块解析 Python 代码几乎零成本生态里又有PyYAML、json、python-dotenv这类库处理各种配置格式非常顺手而且 CLI 工具用 Python 写后续想接进 CI 流程也非常简单用户只需要一行pip install。核心思路就是绕开字符串匹配直接看代码的语法结构。配置读取在代码里是有固定样式的要么是调用某个函数要么是访问某个对象的属性要么是读环境变量。这些在语法树里都是可以识别出来的节点。只要把节点找出来再把节点里的参数通常是字符串提取出来就得到了代码到底想读哪个配置键的准确答案。这个选择帮我避开了非常多的闹心事。注释里的键名不会被当成真引用因为注释在语法树里根本不参与节点匹配。字符串拼接虽然还是处理不了但至少不会漏掉那些纯字面量的调用。更关键的是AST 能给出精确行列号报告里可以直接定位到代码文件的某一行这对后续整改非常有用。2.2 主流程之一加载配置并建立统一键表第一步是把各类配置文件读进来压平成一个统一的键表。YAML 文件天然是嵌套结构datasource: primary: host: 127.0.0.1 port: 3306但代码里访问的时候通常是用datasource.primary.host这种点号路径去取。所以必须把嵌套的 YAML 压平成一张扁平键表def flatten_config(prefix, node): flat {} for key, value in node.items(): full_key f{prefix}.{key} if prefix else key if isinstance(value, dict): flat.update(flatten_config(full_key, value)) else: flat[full_key] value return flat这个压平过程看似简单却有几个细节要小心。第一数组类型的配置项不能直接压平比如timeout.list可能是[100, 500, 1000]代码访问时可能按索引取值config.get(timeout.list)[1]这种情况下键本身仍然存在只是值类型不同。所以我给压平函数加了值类型标记数组和对象分开处理。第二环境变量文件.env里的键通常是DB_HOST这种大写形式需要单独记录原始大小写避免跟 YAML 键混淆。多个配置文件之间还要考虑优先级。比如 Spring 项目里application.yml和application-prod.yml会按环境覆盖cua 目前的做法是把所有文件都加载进来在各文件内部各自建立键表不做覆盖合并。这样报告里能清楚看到这个键来自哪个文件如果要判断生产环境是否真的缺失某个配置使用者可以自己过滤。这个取舍在后面实际使用中被证明非常合理。2.3 主流程之二扫描代码中的配置读取点扫描阶段是 cua 最核心的部分。我写了一个 AST 访问器针对不同的配置读取方式写上匹配逻辑遍历整个代码文件时每遇到一个满足条件的节点就记录一条读取记录。import ast from dataclasses import dataclass dataclass class Access: file_path: str line_no: int key: str dynamic: bool class ConfigAccessCollector(ast.NodeVisitor): def __init__(self): self.accesses [] def visit_Call(self, node): if self.is_config_getter(node): arg node.args[0] if node.args else None if isinstance(arg, ast.Constant) and isinstance(arg.value, str): self.accesses.append( Access(, node.lineno, arg.value, False) ) else: folded fold_expr(arg) if folded is not None: self.accesses.append( Access(, node.lineno, folded, False) ) else: self.accesses.append( Access(, node.lineno, expr, True) ) self.generic_visit(node) def is_config_getter(self, node): func node.func if isinstance(func, ast.Attribute): return func.attr in (get, get_string, get_int, get_bool) if isinstance(func, ast.Name): return func.id in (get_config, get_env, env) return False这个访问器覆盖了最常见的读取函数名get、get_string、get_int、get_env、config.get、cfg.get、os.getenv等。实际使用时每个团队都要自定义一部分函数名单因为项目里总有自己封装的配置读取工具本质上逻辑都是一样的只是入口函数不同。对于动态键我先是尝试做常量折叠。Python 的一个好处是 AST 里所有字符串常量都能识别所以datasource. primary .url这种纯字符串拼接在编译期就能折叠成完整的字面量安装常量折叠优化后大部分动态键其实都能解决。真正处理不了的是那些引用了外部变量、环境变量或者函数返回值的拼接这时才标记为dynamicTrue。2.4 主流程之三匹配、分类与报告输出最后一步是把两张表做匹配配置键表和引用列表。匹配的规则很简单引用列表里的每个静态键去配置键表里查一下存在就标上已引用不存在就标成悬空引用。反过来配置键表里那些在引用列表里一个都没有命中的键就是死配置。但这里我没直接一刀切地输出删掉这 80 个键因为动态引用还提着一颗心某些键可能是通过动态拼接访问的静态分析看不出来实际部署却还在用。所以分类标准里专门留了一档分类判定标准已引用存在至少一条静态字符串完整对应疑似死配置没有任何静态引用也没有动态引用和文档说明待确认仅有动态引用或存在预留后续使用等注释悬空引用代码里引用了但配置文件中没有声明该键输出报告我选了两条线路一条是人看的直接在终端打印彩色表格按文件路径分组一条是机器看的导出 CSV 或者 Markdown 表格方便接进现有的文档系统。CSV 格式还有个作用——之后想接入 CI 时可以直接用脚本读取 CSV 作为判断依据不用再依赖终端输出做字符串解析。3. 实现中的三个大坑动态键、嵌套点号与框架模板3.1 动态键名常量折叠只能解决一半上面提到的常量折叠虽然能处理纯字符串拼接但实际代码里遇到的动态键比这复杂得多。最常见的是环境变量参与拼接def get_db_config(profile): return config.get(fdatasource.{profile}.url)这里的profile来自外部参数AST 层面永远无法确定它运行时是什么值。这种情况下 cua 的策略是识别出这是一个动态读取保留访问记录但不通过它来判断某个键是否活着。同时报告里会提示存在动态读取涉及的模式前缀是 datasource.让使用者去关注那些符合这个模式前缀的键。另一个更隐蔽的动态场景是通过循环批量读取for (String key : keyList) { String value config.get(key); }这已经超出了 cua 能分析的范围。我最后的处理方式是把所有批量读取点归类成通配访问它们读取的键范围无法确定所以不影响死配置判断。如果你项目里大量存在这种写法建议在配置迁移时专项人工排查这些通配访问指向的配置段。3.2 嵌套点号路径的坑YAML缩进和代码点号不总是一回事YAML 本身是缩进敏感的格式同一个键在文件里是嵌套的压平成点号路径后按理说应该跟代码访问路径一致了。但我在实际测试中发现并非所有团队都遵守这个约定。有的人喜欢在配置里用带点号的键名datasource.primary.host: 127.0.0.1这时候压平函数如果还按嵌套结构处理就会把这个带点号的键再拆一层形成datasource.primary.host的路径表示而代码里其实是直接config.get(datasource.primary.host)两边倒是能对上了。但如果反过来有人把嵌套键写成了带引号的扁平原点键再配合代码里用索引路径访问比如config.get(datasource)[primary][host]那压平结果就完全对不上号了。我的处理方案是压平函数保留两类路径。一类是自然路径就是 YAML 嵌套展平的结果一类是原始键名就是配置文件里字面上写的键。匹配时两个都查哪个命中都算已引用。这个 trick 在真实项目里减少了大几十条假阴性报告非常值得保留。3.3 框架注解模板需要的远超一个AST纯 Python 项目其实很好处理因为 AST 拿到的是完整的代码模型。但一旦项目是 Java、Spring 风格配置读取的场景不完全在代码方法内部很多配置直接通过注解声明Value(${datasource.primary.host}) private String host;这里 AST 访问器只能看到注解的字符串参数没法靠调用函数来判断。好在模板字符串本身非常规律${key}的格式可以用正则精确提取而且不会误伤注释和文档。类似地Node 生态中很多框架也会用process.env.DB_HOST这种形式读取环境变量这个在 AST 里反而很简单一个Attribute节点就能识别。比较棘手的是那些自定义封装。比如某项目统一写了个ConfigProvider.getConfigValue(MyKeys.API_TIMEOUT, timeout)它的第一个参数来自一个常量类并非字面量字符串这时要准确知道键名需要顺着常量类再解析一次。我给 cua 加了一层常量解析能力AST 里遇到SomeClass.CONSTANT的访问方式就去那个类文件里找常量的定义并取出值。这条路能解决一大半间接引用但如果是从配置文件里加载常量表中的值那就变成运行时动态直接归到待确认档。4. 把cua丢进一个真实仓库后的整改全流程4.1 扫描报告长什么样为了验证 cua 的效果我挑了一个体量中等的存量项目大概有 8 万行代码主配置文件三份application.yml、application-prod.yml、.env。跑完扫描后生成的报告大致长这样配置键状态引用位置建议server.port已引用config/AppConfig.java:42保留retry.count疑似死配置无重点排查datasource.primary.url已引用datasource/ConnPool.java:18保留mq.producer.retryTimes待确认mq/MqProducerFallback.java:7(动态)人工确认log.rule.level悬空引用log/LogFilter.java:11补充配置报告里最有价值的三个数字是疑似死配置 47 项待确认 9 项悬空引用 3 项。全项目只有 3 个悬空引用说明代码和配置基本还是同步的但 47 个死配置在 280 个总键里占了将近六分之一这个比例已经足够说明清理的必要性。4.2 按风险分三级清理拿到报告不能直接上手删配置。我按风险分了三级处理。第一级是无争议死键标准是没有任何静态引用没有动态引用没有任何注释说明且这个键在 git 历史里超过一年没有修改过。这一类直接删。操作上先清理代码里相关的常量基类和文档描述再删配置本身保证一步到位。第一轮删掉了 26 个键配置文件体积立刻下降了大约三分之一。第二级是有动态引用的待确认键。这部分不能凭报告自动删除需要拉上负责对应模块的同学逐个确认。实际操作中发现很多动态引用的模式其实非常固定比如datasource.{profile}.url里的profile无非是 dev、prod、test 三个环境变量。确认时只要把环境变量列表拿出来把展开后的键跟配置键表一对比就能判断配置是否还在使用。这一轮确认了 7 个键可以删2 个键确实还在被动态引用但配置缺失需要在对应环境文件里补上否则部署后必然出问题。第三级是改改就删的键。有些配置被工具当成已引用是因为它在测试代码里出现过但生产代码完全没读。这类键同样建议清理但要注意测试代码里如果真的依赖这个配置删配置之前得把测试用例一并改掉否则测试环境启动就会报错。4.3 防止回潮把cua接进日常检查清理完死配置如果不加约束半年后又会被新代码重新长回来。所以我把 cua 接进了日常开发流程方式很轻量在 CI 里加一个可选步骤不是直接拦截而是生成一份报告作为构建产物存档。每周由运维同学扫一眼看死配置数量有没有突然上涨上涨了就去追原因。接入方式也不复杂cua 本身做成命令行工具之后只要在 CI 里跑一行cua scan --dir . --config-dir . --output report.csv再把 CSV 上传到流水线产物即可。我特意把退出码设计成扫描成功但发现死配置时返回 0这样它不会因为告警就把构建搞红而是默默记录让团队决定要不要处理。这种温和接入的策略比强制拦截更可持续因为项目正在快速迭代期如果因为几个新加的配置就让构建失败谁都会想把这个检查撤掉反而失去了长期价值。另一个防回潮的技巧是给配置键加出生日期。在配置文件里用注释标注键的引入时间例如# since 2024-05-13, related: UserService。虽然这个需要团队成员自觉维护但实际效果很好半年后再跑 cua看到某个死配置的注释还是半年前的日期就可以直接判定废弃而不用翻 git 历史。不过这个属于锦上添花不强制要求。5. cua的边界与后续可以怎么长5.1 当前边界cua不处理什么cua 解决的是显式配置引用问题但它不是万能的。有几个场景它明确不处理使用者一定得知道。第一是反射和运行时生成键。Java 里通过反射读配置字段、Python 里用eval拼接配置路径这类操作静态分析根本无从下手只能靠人工审查。第二是动态加载配置中心。如果项目已经接入配置中心配置散落在远端本地仓库里只有默认值和占位符cua 的键表就缺失了真实配置的全集。这种情况下建议只扫描本地默认文件并限定它的用途为检查本地默认配置是否收敛不要试图覆盖全链路。第三是多语言混编项目的跨语言引用。比如 Java 后端写了配置前端 Node 服务也读同一份配置cua 目前按语言分别扫描输出时再合并引用列表实现上并不复杂但我还未完善。还有一个已知的遗憾cua 目前不处理配置之间的依赖关系。某些键的值里引用了别的键比如url: ${base.url}/path这种配置间引用在动态化框架中很常见但 cua 的匹配逻辑目前只检查代码引用不展开配置值里的变量。如果想支持需要对值做一层模板解析再递归到引用的键上这是后续计划里优先级较高的一项。5.2 可以继续长的方向如果继续把这个工具往下做我首先会加一个基于 git 历史的配置健康度指标。暂时效果就是对于没有引用的配置键如果它同时满足长时间未修改和配置文件中的相关注释已过时那就给出高置信度的废弃建议。目前我只能靠外部条件去推断做成功能的话会更方便。其次是 IDE 插件化。现在每次想查某个配置键被哪里引用还要去翻 CSV或者在代码里搜索插件化之后可以直接在配置文件里点一下就能弹出引用位置列表。这个方向实现成本不算高因为 AST 扫描的索引结果可以导成 JSONIDE 插件只需要消费这个预计算文件。最后是 CI 门槛的粒度控制。现在的方案是报告存档 人工每周检查后续可以做成新增死配置只警告删除疑似死配置需提供理由更细的策略比如只有超过 N 个死配置或配置文件膨胀超过 X% 时才硬失败。让自动化检查在可接受范围内完全咬住这个场景是 cua 面向生产环境的最终目标。在实际操作里我的体会是这类分析工具的价值不在于一次性清理掉多少配置而在于它给项目建立了一个配置使用情况可见的基线。只要基线在配置就不会再无声无息地腐烂下去。最后分享一个小技巧cua 扫描出的报告可以顺手以 Markdown 格式贴进项目文档作为该项目的配置索引。新人接手时先看这份索引比翻半天配置文件高效得多也不容易再对死配置产生误操作。即便项目后续不再维护这个工具这份索引本身也是资产值得保留。