Java 程序员的 AI 进化论 | AI 帮我读懂祖传烂代码,三天活变半天

2 阅读8分钟

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、oldPriceCalcrefreshCacheV2 实际还被定时任务调,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 里夹着 &lt; &gt;,回写进源码后 javadoc 工具渲染异常。我加了个正则清洗,把 HTML 实体还原成普通字符再回写,才消停。

六、总结

这套"AI 逆向理解老代码"的流程,我跑下来最大的感受是:AI 不是替你读代码,是替你把零散信息串成线。调用图、方法解释、注释生成,这三步任何一步单拎出来都不难,但串起来能把读代码的效率提好几倍。

不过有几个底线得守住:AI 标的死代码别直接删、补注释前先备份原文件、业务术语表一定要喂给 AI。工具是工具,判断还是得自己来。

末了用一张清单表收尾,接手祖传代码时照着走:

检查项建议做法
接手第一步先统计圈复杂度和无注释占比,量化技术债
方法解释按方法粒度喂 AI,带上字段和被调方法签名
调用图生成过滤 getter/setter,先看主干调用
死代码判断AI 标注后务必全局搜方法名,确认无反射/定时调用再删
注释生成按逻辑块切分逐块解释,别整段丢给 AI
注释回写先写副本,diff 确认无语法错误再覆盖原文件
业务术语整理术语表随上下文喂给 AI,避免黑话误读
HTML 转义AI 生成注释需清洗 HTML 实体再回写

照这张表走,祖传代码没那么吓人。

AI 逆向理解老代码流程图