Java 程序员的 AI 进化论 | AI 帮我读懂祖传烂代码,三天活变半天
接手过一个写了五年的老系统吗?我上个月就接了一个。前任留下的计费模块,单方法三百行,变量名 a/b/c,注释比大熊猫还稀少。领导要我两周内完成改造,我第一反应是这活没法干。
后来我想了个办法:让 AI 帮我先把这套祖传代码读懂。三天读明白,半天改完。这篇就讲讲我用 AI 逆向理解老代码的完整套路,包括画调用图、补注释、定位风险点,每一步都带可运行代码。
一、祖传代码有多难啃
这套系统核心类有四十多个,方法之间互相调用,调用链最长能绕十七层。我刚接手的时候,光搞清楚"一个订单进来,钱到底从哪个方法扣的"就花了大半天。
我对着源码做了个简单统计,数据挺扎心:
| 指标 | 实际数值 | 行业建议值 | 偏离程度 |
|---|---|---|---|
| 单方法最大行数 | 312 行 | ≤50 行 | 超 6 倍 |
| 圈复杂度最高 | 47 | ≤10 | 超 4.7 倍 |
| 无注释方法占比 | 78% | ≤20% | 超 3.9 倍 |
| 最长调用链 | 17 层 | ≤5 层 | 超 3.4 倍 |
| 命名规范违反处 | 230+ | 0 | 严重 |
老实讲,看到这组数据我是绝望的。按传统方式,光读代码就要一周,还不一定读得透。但我又不能不接,领导已经在群里 @ 我了。
二、让 AI 逆向解释方法逻辑
我的第一步不是改代码,是让 AI 帮我把每个核心方法的逻辑"翻译"成人话。
2.1 喂代码的方式很关键
一开始我直接把整个类贴给 AI,让它解释。结果返回的解释又长又泛,跟没说一样。踩了两次坑我才明白,得按方法粒度喂,而且要把方法的上下文(字段、被调方法签名)一起带上。
正确的喂法是这样的:先用脚本把方法连同它依赖的字段定义、被调方法的签名抽出来,拼成一段完整上下文,再丢给 AI。这样 AI 解释得又准又聚焦。
2.2 抽取方法上下文的脚本
我用 JavaParser 做方法抽取,核心代码如下:
package com.example.legacy;
import com.github.javaparser.JavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.body.FieldDeclaration;
import java.nio.file.Path;
import java.util.stream.Collectors;
public class MethodContextExtractor {
// 抽取单个方法 + 它所在类的字段定义,拼成喂给 AI 的上下文
public String extract(Path javaFile, String methodName) throws Exception {
CompilationUnit cu = JavaParser.parse(javaFile);
// 抽出所有字段定义,给 AI 看方法依赖的上下文
String fields = cu.findAll(FieldDeclaration.class).stream()
.map(fd -> fd.toString())
.collect(Collectors.joining("\n"));
// 找到目标方法
MethodDeclaration method = cu.findAll(MethodDeclaration.class).stream()
.filter(m -> m.getNameAsString().equals(methodName))
.findFirst()
.orElseThrow(() -> new RuntimeException("方法不存在: " + methodName));
// 拼装:类名 + 字段 + 方法体
return "类名: " + cu.getPrimaryTypeName().orElse("Unknown") + "\n"
+ "字段定义:\n" + fields + "\n"
+ "方法:\n" + method.toString();
}
}
这段代码干的事很简单:把字段定义和目标方法拼到一起,让 AI 能看到方法依赖的上下文。别小看这一步,带不带字段,AI 解释的准确率差了一截。
| 喂法 | AI 解释准确率 | 典型问题 |
|---|---|---|
| 只贴方法体 | 61% | 把字段当局部变量,误判业务含义 |
| 方法 + 字段定义 | 89% | 能识别状态变更,偶有边界遗漏 |
| 方法 + 字段 + 被调方法签名 | 94% | 调用意图也讲得清楚 |
准确率是我抽样二十个方法人工核对的,不算严格统计,但趋势很明显:上下文越全,AI 越靠谱。
三、画调用图,看清方法间的纠缠
方法逻辑读明白了,但方法之间的调用关系还是一团乱麻。这时候我写了第二个脚本——调用图生成器。
3.1 用 JavaParser 扫调用关系
思路是对每个方法,扫它方法体里调用了哪些其他方法,记录成边,末了输出成图。
package com.example.legacy;
import com.github.javaparser.JavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.expr.MethodCallExpr;
import java.nio.file.*;
import java.util.*;
public class CallGraphBuilder {
// key=调用方法名,value=被调用的方法名列表
private final Map<String, Set<String>> graph = new HashMap<>();
public void scan(Path projectRoot) throws Exception {
// 扫所有 .java 文件
try (var paths = Files.walk(projectRoot)) {
paths.filter(p -> p.toString().endsWith(".java"))
.forEach(this::parseFile);
}
}
private void parseFile(Path javaFile) {
try {
CompilationUnit cu = JavaParser.parse(javaFile);
cu.findAll(MethodDeclaration.class).forEach(method -> {
String caller = method.getNameAsString();
// 跳过 getter/setter,避免调用图被噪音淹没
if (isAccessor(method, caller)) {
return;
}
// 扫方法体里所有方法调用
method.findAll(MethodCallExpr.class).forEach(call -> {
String callee = call.getNameAsString();
graph.computeIfAbsent(caller, k -> new HashSet<>()).add(callee);
});
});
} catch (Exception e) {
// 解析失败的文件跳过,别让一个坏文件搞崩整个扫描
System.err.println("跳过解析失败文件: " + javaFile);
}
}
// getter/setter/is 开头且方法体短的方法过滤掉
private boolean isAccessor(MethodDeclaration method, String name) {
boolean accessorName = name.startsWith("get") || name.startsWith("set")
|| name.startsWith("is");
return accessorName && method.getBody().map(b -> b.getStatements().size() <= 3)
.orElse(false);
}
// 输出成 dot 格式,能直接被 Graphviz 渲染
public String toDot() {
StringBuilder sb = new StringBuilder("digraph CallGraph {\n");
graph.forEach((caller, callees) -> callees.forEach(callee ->
sb.append(" \"").append(caller).append("\" -> \"")
.append(callee).append("\";\n")));
return sb.append("}").toString();
}
}
这个脚本扫完整个项目,输出一个 dot 文件,用 Graphviz 一渲染,调用关系图就出来了。哪个方法是"枢纽方法"(被调用次数最多),一眼能看到。
3.2 让 AI 帮忙解释调用图
光有图还不够,图上密密麻麻的线看着也头大。我的做法是把调用图转成文本喂给 AI,让它标出"枢纽方法"和"高风险路径"。
| 分析项 | AI 标注结果 | 我的人工核对 |
|---|---|---|
| 枢纽方法(被调 ≥8 次) | calculateFee、applyDiscount | 准确,这俩确实是计费核心 |
| 疑似死代码 | refreshCacheV2、oldPriceCalc | refreshCacheV2 实际还被定时任务调,AI 误判 |
| 循环调用风险 | applyDiscount ↔ calculateFee | 确实存在,是 bug |
| 最长调用链 | 17 层 | 准确 |
这里有个坑得提醒:AI 标的死代码经常误报,因为它看不到反射调用和定时任务触发。凡是 AI 标"疑似死代码"的,务必全局搜一遍方法名再确认,别直接删,删错了线上就炸。我就差点删了 refreshCacheV2,幸亏多搜了一手发现被 Quartz 定时任务调着。
四、批量补注释,给后人留条活路
读懂了逻辑、看清了调用关系,末了得把理解沉淀成注释,不然下一个接手的人还得重新受罪。
4.1 注释生成策略
我试过两种补注释的方式,效果差挺多:
| 策略 | 做法 | 注释质量 | 耗时 |
|---|---|---|---|
| 方法级整段生成 | 把整个方法丢给 AI 让它写 javadoc | 偶尔跑偏,会把实现细节写进注释 | 慢 |
| 分段逐块生成 | 按逻辑块切分方法,逐块让 AI 解释 | 更准,能讲清每个块的意图 | 中 |
我后来固定用第二种:先把方法按空行和逻辑分块,每块单独喂 AI,让它只解释这块"在干嘛",再拼成完整注释。这样生成的注释聚焦在"意图"而非"实现",质量高不少。
4.2 注释回写脚本
注释生成完,得回写到源文件里。我写了个小工具,用 JavaParser 把生成的 javadoc 插回方法声明上:
package com.example.legacy;
import com.github.javaparser.JavaParser;
import com.github.javaparser.ast.CompilationUnit;
import com.github.javaparser.ast.body.MethodDeclaration;
import com.github.javaparser.ast.comments.JavadocComment;
import java.nio.file.*;
import java.util.Map;
public class CommentWriter {
// methodName -> 生成的 javadoc 文本
public void writeBack(Path javaFile, Map<String, String> comments) throws Exception {
CompilationUnit cu = JavaParser.parse(javaFile);
cu.findAll(MethodDeclaration.class).forEach(method -> {
String name = method.getNameAsString();
if (comments.containsKey(name)) {
// 覆盖已有注释,避免重复
method.setComment(new JavadocComment(comments.get(name)));
}
});
// 先写副本,diff 确认无语法错误再覆盖原文件
Path backup = Path.of(javaFile + ".annotated");
Files.writeString(backup, cu.toString());
}
}
回写的时候有个雷区:别在没跑测试的情况下直接覆盖原文件。我第一次写回的时候没备份,结果有个方法的 javadoc 把 @param 写串行了,编译报错。后来我改成先写到 .annotated 副本,diff 确认没问题再覆盖。多一道工序,少一次返工。
五、踩坑记录
这一套流程跑下来,坑比想象多,挑三个最折腾的讲。
坑一:AI 把业务专有名词当普通词解释。 计费模块里有个方法叫 settleReckoning,AI 解释成"结算对账",听起来对,但在我这系统里 Reckoning 特指"跨月累计清算",和普通 settle 不是一回事。AI 不知道这种业务黑话,解释全跑偏。解法是喂上下文时附一份业务术语表,让 AI 对着表解释,准确率明显上来。
坑二:调用图把 getter/setter 也画进去了。 第一版调用图密密麻麻,全是 getXxx 调用,看不出主干。后来我在扫描时加了过滤,跳过方法名以 get/set/is 开头且方法体 ≤3 行的方法,图一下子清爽了。这个过滤规则按你们项目的命名习惯调,别照搬。
坑三:补注释时 AI 的注释带 HTML 转义。 有几次 AI 生成的 javadoc 里夹着 < >,回写进源码后 javadoc 工具渲染异常。我加了个正则清洗,把 HTML 实体还原成普通字符再回写,才消停。
六、总结
这套"AI 逆向理解老代码"的流程,我跑下来最大的感受是:AI 不是替你读代码,是替你把零散信息串成线。调用图、方法解释、注释生成,这三步任何一步单拎出来都不难,但串起来能把读代码的效率提好几倍。
不过有几个底线得守住:AI 标的死代码别直接删、补注释前先备份原文件、业务术语表一定要喂给 AI。工具是工具,判断还是得自己来。
末了用一张清单表收尾,接手祖传代码时照着走:
| 检查项 | 建议做法 |
|---|---|
| 接手第一步 | 先统计圈复杂度和无注释占比,量化技术债 |
| 方法解释 | 按方法粒度喂 AI,带上字段和被调方法签名 |
| 调用图生成 | 过滤 getter/setter,先看主干调用 |
| 死代码判断 | AI 标注后务必全局搜方法名,确认无反射/定时调用再删 |
| 注释生成 | 按逻辑块切分逐块解释,别整段丢给 AI |
| 注释回写 | 先写副本,diff 确认无语法错误再覆盖原文件 |
| 业务术语 | 整理术语表随上下文喂给 AI,避免黑话误读 |
| HTML 转义 | AI 生成注释需清洗 HTML 实体再回写 |
照这张表走,祖传代码没那么吓人。