AI辅助代码重构:拆一个日处理10万单的PHP订单模块,测试覆盖率从12%到78%,但差点炸了对账

30秒速览

  • 静态分析先跑一遍,把圈复杂度和全局变量揪出来再喂给AI,否则AI会瞎猜
  • 让AI生成测试时别放任它mock一切,涉及纯计算的必须用真实数据驱动
  • 影子流量+特性开关是必须的,我靠这个发现并修了3个线上差异,其中一个差点让账目多算200%
  • AI重构完的代码要用四个指标持续跟踪:覆盖率、圈复杂度、缺陷密度、延迟
  • 永远不要让AI一次改超过200行,也别信任它“优化”掉的死代码——那可能是历史包袱

重构之前,我先用静态分析摸了个底——这坨代码的圈复杂度居然380

三个月前,老板把我叫到会议室,说公司的订单系统最近老出诡异Bug,而且扩展新渠道慢得像在泥里走路。这个系统是十年前用PHP写的单体应用,核心文件 `order_processor.php` 超过4000行,注释里夹杂着意大利语和中文拼音,全局变量到处飞,`global $config` 像地雷一样埋了17处。更绝的是,整个模块没有任何单元测试,改一个常量都要靠人工回归。老板的意思很明确:“你把它拆成Go微服务吧,三个月搞定。”

我没立刻打开编辑器,而是先做了一件很多人会跳过的事——用静态分析工具做全身检查。我先跑了PHPStan(level 5)和phpmd,结果惨不忍睹:

指标 数值 我的心理活动
圈复杂度(Cyclomatic Complexity) 380(文件级) 这已经不是代码了,是迷宫
全局变量直接访问次数 43处 每次读代码血压都在升
直接SQL拼接 29处 注入漏洞能开超市
单元测试覆盖率 0% 果然
重复代码块 7段近乎一致的折扣计算逻辑 改一个漏五个

拿到这些数字后,我干了一件事:把静态分析报告扔给Claude Code(Anthropic发布的CLI工具,可以理解整个项目代码),让它帮我总结出最危险的10个依赖关系最容易改出问题的7段代码。这一步非常关键——AI在理解整个代码库依赖的时候,比人肉眼扫快几十倍,但前提是你得给它足够准确的静态分析数据作为“导航”。

举个例子,`order_processor.php` 里有一处计算税金的逻辑,它依赖一个全局的 `$tax_rates` 数组,而这个数组是在另一个文件 `config_loader.php` 中通过 `include` 动态加载的。静态分析工具标注了“Undefined variable: tax_rates”,但Claude Code在分析后告诉我,这个变量是在运行时通过全局作用域传递的,如果重构时直接移除全局引用,会把税金的计算结果变成0。我把这条记录记在了“重构风险清单”第一条。

这里有个容易被忽视的点:静态分析工具和AI是互补的。静态分析能精确告诉你语法层面和类型层面的问题,但它不知道业务含义。AI能猜测业务含义,但如果没有静态分析的约束,它就会瞎猜。比如第一次我直接让Claude Code重构税金计算,它很自信地把税率写死成0.1,因为它在代码注释里看到“tax 10%”,但实际税率是根据州、产品类别、促销活动动态决定的,静态分析报告里明明有“Dynamic tax rate from database”的警告,我没给它看,它就漏了。从那以后,我的工作流就固定为先跑静扫,再把报告作为“上下文”注入给AI。

摸底之后,我心里有底了:这4000行PHP代码,核心业务逻辑大概可以拆成订单校验、库存扣减、税金计算、支付对接、邮件通知五个模块。真正的脏活是解开全局变量耦合和消除重复逻辑。我决定不对整体大拆大建,而是分块让AI辅助修改,下面就是我的“三步走”。

三步走策略:先罩上测试,再切小块让AI改,最后用影子流量验收

很多人在用AI重构时上来就说“帮我把这个文件重写成干净的代码”,结果拿到一个无法通过编译、或者悄悄改变了行为的版本。我栽过一次跟头:让AI重写整个订单校验类,它自动把 `strtotime` 替换成了 `DateTime` 对象,但是忽略了一个地方原本是用时间戳和字符串做松散比较的,结果导致一个促销活动的时间边界判断偏移了1秒,秒杀订单全部被拒。从那时起,我坚决不搞“一把梭”式重构。

第一步:用AI生成高覆盖率的单元测试

没有测试的保护,重构就是裸奔。我让Cursor(集成了Claude模型)为 `order_processor.php` 中的关键函数逐个生成单元测试。但这里有一个陷阱:AI生成的测试经常只覆盖正常路径,而且会用很宽的mock,导致测试通过了但实际逻辑根本没测到。我的做法是:

  1. 先用静态分析报告的圈复杂度排名,挑出前5个最复杂的函数;
  2. 让AI为每个函数生成至少包含正常、边界、异常三种情况的测试;
  3. 人工审查mock策略——凡是涉及数据库、文件系统、邮件发送的,必须用mock;但涉及纯计算(如税金)的函数,不能用mock,要喂入真实数据样本验证;
  4. 跑测试,如果有函数很难写测试,说明耦合太重,先不重构这个函数,而是先做依赖提取。

下面是一段由AI生成的测试用例(我修改了mock部分),针对原来的税金计算函数。原来函数是这样的:

// 原代码片段,极度耦合全局变量
function calcTax($order_total, $state) {
    global $tax_rates; // 从config_loader.php加载
    if (!isset($tax_rates[$state])) {
        throw new Exception("Unknown state");
    }
    $rate = $tax_rates[$state]['general'];
    if ($order_total > 1000 and date('m') == 12) {
        $rate = $tax_rates[$state]['xmas_promo'] ?? $rate;
    }
    return $order_total * $rate;
}

AI最初生成的测试是这样的(被我否了):

// AI初版测试 - 直接mock了计算逻辑,等于没测
public function testCalcTax() {
    $mock = $this->getMockBuilder('TaxCalculator')->getMock();
    $mock->method('calcTax')->willReturn(50.0);
    $this->assertEquals(50.0, $mock->calcTax(1000, 'CA'));
}

我看完直接删了——这测试测了个寂寞。我改成这样:

// 我改后的版本,提取依赖,用数据驱动
class CalcTaxTest extends TestCase {
    /**
     * @dataProvider taxDataProvider
     */
    public function testCalcTaxWithGivenRates($total, $state, $month, $expectedTax) {
        // 模拟日期
        $clock = new FrozenClock(new DateTimeImmutable("2024-$month-15"));
        // 注入税率配置,而不是读全局变量
        $rates = [
            'CA' => ['general' => 0.08, 'xmas_promo' => 0.05],
            'NY' => ['general' => 0.0875],
        ];
        $calculator = new TaxCalculator($rates, $clock);
        $this->assertEqualsWithDelta($expectedTax, $calculator->calc($total, $state), 0.01);
    }

    public function taxDataProvider() {
        return [
            'CA normal under 1000' => [500, 'CA', 6, 40.0],
            'CA xmas promo applicable' => [1200, 'CA', 12, 60.0], // 1200*0.05
            'NY no xmas promo' => [1200, 'NY', 12, 105.0], // 1200*0.0875
            'edge zero total' => [0, 'CA', 1, 0.0],
        ];
    }
}

这样写测试虽然多花了我半小时,但值得——后来重构时果然发现促销月份判断有个时区bug。最终,我为5个核心函数产出了42个测试用例,行覆盖率从0%涨到了32%。注意是行覆盖率,不是分支覆盖率,后面我才把分支覆盖率也抓起来。

第二步:切小重构单元,用AI逐个击破

我从不一次让AI重构超过200行代码。经验告诉我,越大的上下文窗口,AI越容易产生“幻觉修改”——它会擅自简化条件、合并分支、甚至删掉它不理解的代码块。我的切分原则:

  • 每个重构单元必须是一个独立的函数或类方法;
  • 最多引入一个新的依赖,不能同时改接口和实现;
  • 每次AI产出后,立即运行此单元的所有测试,并通过git diff审查行为变化。

举个具体例子:原来的库存扣减逻辑直接嵌在 `process()` 方法里,使用 `mysql_query` 和字符串拼接。我让AI做的事是:“把这个库存扣减代码块提取成一个独立类 `InventoryService`,使用PDO和参数绑定,接口为 `decrement(string $sku, int $qty): bool`,同时保持原有事务处理逻辑不变。” 我给出原代码片段和周边5行上下文,禁止它擅自修改其他部分。Claude Code生成的结果我很满意:

// AI生成的重构后代码(经过我审查和微调)
class InventoryService {
    private PDO $pdo;
    public function __construct(PDO $pdo) {
        $this->pdo = $pdo;
    }

    /**
     * 扣减库存,如果库存不足返回false,并在同一事务中记录操作日志
     */
    public function decrement(string $sku, int $qty): bool {
        $this->pdo->beginTransaction();
        try {
            $stmt = $this->pdo->prepare(
                "UPDATE inventory SET quantity = quantity - :qty 
                 WHERE sku = :sku AND quantity >= :qty2"
            );
            $stmt->execute(['qty' => $qty, 'sku' => $sku, 'qty2' => $qty]);
            if ($stmt->rowCount() === 0) {
                throw new RuntimeException("库存不足或SKU不存在");
            }
            // 记录操作日志
            $logStmt = $this->pdo->prepare(
                "INSERT INTO inventory_log (sku, qty_change, created_at) 
                 VALUES (:sku, :qty, NOW())"
            );
            $logStmt->execute(['sku' => $sku, 'qty' => -$qty]);
            $this->pdo->commit();
            return true;
        } catch (Exception $e) {
            $this->pdo->rollBack();
            return false;
        }
    }
}

这段代码我能直接合并,因为对应的单元测试已经先写好了。测试确保了行为一致性。按这个节奏,我以平均每天处理1-2个重构单元的速度,三周把最核心的5个模块改完了。这期间测试覆盖率从32%涨到了78%。

第三步:用影子流量验证重构效果,避免“实验室OK,线上崩”

测试只能保语法和显式逻辑,但真实流量里藏着各种诡异组合:比如某渠道传的商品SKU带前导空格,库存扣减时没trim导致新老服务行为不一样。所以我搞了一套“影子流量”验证——将线上真实请求同时发给PHP老服务和Go新服务,对比响应,但不让新服务的响应返回给用户。

实现方案是这样:在API网关层(我们用的是Kong)写了个插件,对 `/order/create` 接口的请求做异步复制,发到新服务的 `/internal/shadow_order` 端点,并将两个响应以JSON格式写入Kafka,再由一个对比脚本分析差异。刚开始差异率高达23%,吓我一跳,仔细排查发现很多是浮点精度、null vs 空串的差异,真正的业务差异只有3%,逐一修掉后再上线。

代码片段:Kafka对比消费者(Go语言,简洁版)

// 实时对比消费者,发现差异超过阈值就告警
func compareResponses(msg kafka.Message) {
    var pair struct {
        OldResp order.Response `json:"old"`
        NewResp order.Response `json:"new"`
        RequestID string       `json:"request_id"`
    }
    json.Unmarshal(msg.Value, &pair)

    if !reflect.DeepEqual(pair.OldResp, pair.NewResp) {
        // 排除已知的非关键差异(如timestamp字段)
        if isSignificantDiff(pair.OldResp, pair.NewResp) {
            alertCriticalDiff(pair.RequestID, diffDetails(pair.OldResp, pair.NewResp))
        } else {
            logDebug("tolerable diff", pair.RequestID)
        }
    }
}

这套“影子+对比”的组合在我重构期间抓住了两个隐式依赖bug:一个是新服务没处理老服务对 `discount_code` 字段不区分大小写的行为,另一个是浮点数合计计算在新服务中使用了 `math/big.Float` 而老服务用 `floor`+字符串拼接导致末尾一分钱差异。每次发现差异都会成为测试用例的补充,逐渐把差异率压到了0.2%以下。

工具混用实测:Claude Code干粗活、Cursor写测试补丁、Copilot填细节,但谁都不能全信

这三个月我同时用了多个AI工具,每个都有自己的“脾气”。先说说我的工具箱:

工具 我主要用来做什么 典型场景 打分(10分)
Claude Code (CLI) 整块代码理解、复杂重构建议、分析依赖 读取整个订单模块,提取出全局依赖图;提出按模块拆分的计划 8/10,偶尔截断长上下文
Cursor(内置Claude 3.5 Sonnet) 快速生成测试、修复重复性小Bug、写文档 选中一个函数,Ctrl+K“为此函数写单元测试,mock掉数据库” 9/10,交互顺滑但偶尔自作主张改无关代码
GitHub Copilot (内联补全) 写样板代码、填写结构体、格式化SQL 在Go中定义model struct,它自动补全json tag 7/10,经常给过时的建议
PHPStan + Phpactor 静态类型检查、代码气味检测 重构前扫描,重构后验证类型安全 9/10,非AI但不可或缺

给新手的建议:永远不要只用一种AI工具。不同模型的强项完全不同。Claude Code擅长分析整个仓库,生成的重构方案逻辑性强,但有时候太“自信”,会删掉它认为是冗余的代码(那些可能是为了兼容老版本的特殊逻辑)。Cursor补全和行内修改极其便利,但会忽略文件其他部分的牵连影响。Copilot补全速度快,可经常给出老旧的PHP写法,需要你不停地纠正。

我的典型工作流是:先用Claude Code分析模块并生成重构计划(一份几十行的markdown),然后在Cursor里按计划分文件实施,过程中用Copilot加速常规编码。每一个改动提交前,跑PHPStan level max和单元测试;如果涉及接口变更,还要跑契约测试。这个流水线保证了AI输出不会失控。

说到坑,有一次我让Cursor重写一段配置加载逻辑,它自动把配置从PHP数组转成了YAML,并且把 `include` 换成了 `SymfonyYaml::parseFile()`。表面看起来干净,但是它在转换时把嵌套数组中的一个数值型字符串 `’1234’` 转成了数字 `1234`,导致后续字符串比对(老系统用严格等于 `===`)失败,一个渠道的支付通知回调全部被拒。查这个bug花了我四小时,因为日志里只显示“渠道未找到”,鬼才想到是类型变了。从那以后,我给AI任何重构指令都会加上一句:“保持所有现有数据类型不变,尤其是字符串和数字的区分”。

另外,AI生成的测试有时会使用不存在的mock方法。比如Copilot建议用 `->shouldReceive(‘find’)` 语法,但那是Mockery的,我的项目用的是PHPUnit原生mock。不检查就合并的话,测试直接Fatal Error。所以我现在跑测试前一定会先跑一次全量测试,确保所有测试本身能通过,再说检验代码的事。

风险控制:我搞了特性开关+双写对比,重构期间没停一次服务

重构遗留系统最让人睡不着的是——万一改坏了线上怎么办?我们日单量10万,虽然不算海量,但每错一单都涉及钱和库存,赔不起。我的风险控制方案基于两个原则:渐进式切换和可回滚。

1. 特性开关(Feature Flag)控制新旧实现

在Go新服务里,我用了OpenFeature标准的flagd,通过配置文件控制不同场景走新服务还是回退老服务。一开始只放行1%的内部测试用户,无异常后逐步扩大到5%、20%、100%。特性开关不仅仅是一个全局开关,还支持按渠道、用户ID哈希分桶,这样出问题时可以精确到一批用户快速关闭。

示例:Go服务中的特性开关判断

// 使用OpenFeature Go SDK
func createOrderHandler(w http.ResponseWriter, r *http.Request) {
    client := openfeature.NewClient("order-service")
    evalCtx := openfeature.NewEvaluationContext(
        r.Context(),
        map[string]interface{}{
            "userID":    r.Header.Get("X-User-Id"),
            "channel":   r.URL.Query().Get("channel"),
        },
    )
    useNewFlow, _ := client.BooleanValue(
        evalCtx, "order-processing-v2", false,
    )
    if useNewFlow {
        processOrderV2(r)
    } else {
        // 回退到调用PHP老服务
        proxyToLegacy(r)
    }
}

标志的配置放在一个YAML文件中,通过Git管理,修改后无需重启服务(通过file watcher热加载)。我们约定:所有重构相关的特性标志在稳定运行一个月后必须清理,避免旗杆积累。

2. 数据库双写,而非直接替换

PHP老服务依赖MySQL 5.7,新Go服务一开始就使用相同的数据库(避免迁移风险),但在关键的业务表上我们做了“双写对比”:新服务在写入数据时,除了写主表,还会写一个镜像表(用于对账),并且异步和PHP老服务写入的数据进行校验。这套机制让我发现了一个库存扣减的并发问题:老服务使用 `SELECT … FOR UPDATE` 但事务隔离级别是 REPEATABLE-READ,新服务用同样的隔离级别却因为Go的连接池复用方式不同导致死锁概率上升。我们后来调整了连接参数 `tx_isolation` 且加了重试机制才平息。

下面是对账脚本的关键SQL,每天跑一次,差异会推送到企业微信告警:

-- 对比老服务和新服务在今天产生的订单金额差异
SELECT 
    ABS(SUM(leg.total) - SUM(new.total)) as diff_total
FROM orders_legacy leg
JOIN orders_new new ON leg.order_id = new.order_id
WHERE leg.created_at >= CURDATE() 
  AND new.created_at >= CURDATE()
HAVING diff_total > 0.01; -- 超过一分钱就告警

3. 限流与熔断

我在新服务前面加了一个基于Hystrix-go的熔断器,一旦新服务的错误率超过5%或者P99延迟超过1s,自动熔断,所有流量切回老服务。这让我在试运行阶段有底气在白天高峰期做灰度,因为最坏情况也就是服务降级,不会雪崩。事实证明这个熔断救了我一次:新服务上线30%流量后,因为一个未被mock的真实第三方支付回调超时,错误率飙升到8%,熔断器在3秒内触发,用户几乎无感。事后我补上了支付回调的超时处理和测试,才继续扩量。

这些风险控制手段不是AI教我的,而是传统分布式系统的基础建设,但AI重构时你必须有这些东西兜底,否则你会畏手畏脚不敢推进。

怎么评估改得行不行:我盯住四个指标,用一张表跟踪了三个月

老板问我:“重构完了到底好不好?” 我不能只凭感觉回答“代码干净了”,得有数据。我设了四个核心评估指标:

  • 测试覆盖率:包括行覆盖和分支覆盖,因为高行覆盖可能都是false positive;
  • 圈复杂度:每个函数和方法的最大复杂度,目标<10;
  • 线上缺陷密度:每千单中由订单模块导致的Bug数;
  • 性能:P95延迟和吞吐量(QPS)。

我建了一张简单的BigQuery表(我们日志在BigQuery),每周拉数据,生成趋势。下面是重构前后关键节点的数据对比:

指标 重构前(PHP单体) 重构后(Go微服务) 变化
单元测试行覆盖率 12% 78% +550%
分支覆盖率 5% 65% +1200%
最大函数圈复杂度 42 9 -78%
月线上缺陷数 14 3(且都是对接配置问题) -78%
创建订单P95延迟 320ms 85ms -73%
峰值QPS(单实例) 180 620 +244%

延迟从320ms降到85ms,主要因为Go的并发模型和对数据库连接池的优化,而不是AI的功劳。AI帮我们减少了代码中大量无用的序列化和重复查询。QPS提升部分来自更好的缓存策略,但核心还是解耦后可以水平扩展。

评估过程中有个让我意外的发现:AI生成的代码在分支覆盖率上提升有限。很多地方AI会自动简化条件,例如把 `if (a && b || c)` 合并,但测试没有覆盖所有组合,所以分支覆盖率只有65%,虽然行覆盖高了。这提醒我测试的质量比数量更重要,后续我专门针对关键条件做了变异测试(mutation testing),杀死了3个AI留下的逻辑漏洞。

另外,我还会关注代码变更的频率和回滚率。重构后的模块三个月内只有5次变更,而老模块每月被动改动10+次。这说明新模块的职责更清晰、扩展通过新增代码而不是修改原有代码,这本身就是成功。

但仅靠数据不够,我还拉了客户支持团队做了一次问卷:“订单相关问题是否有减少?” 他们的回答是“以前每周都有支付状态不同步的投诉,现在几乎没听到”。这种主观反馈对说服管理层很重要。

差点翻车的那天:AI把订单金额计算公式里的常量改了,账目多算了两倍

这是在重构支付对接模块时发生的,我至今记得那个周五下午的冷汗。

原PHP代码里有一段计算平台服务费(佣金)的逻辑,硬编码了一个常量 `0.035` 代表3.5%费率。但这个常量实际上是存放在数据库 `config` 表里的,某个“天才”前同事为了省事直接在代码里写死了,后来换了业务团队,费率调整是直接改数据库,老系统因为没重启所以一直能用——代码里那个0.035从来没被执行过,是个僵尸常量。

事情是这样的:我把支付计算部分独立成一个Go服务时,让Claude Code帮我翻译那段佣金计算。Claude分析了旧代码,看到了 `$fee = $amount * 0.035;` 又看到了代码上方注释 `// fee rate 3.5% from config table`,它居然“聪明”地认为这是一个死代码,并生成了一段新的逻辑:从数据库读取费率动态计算。看起来非常合理,但是AI忽略了一个关键细节:那个数据库配置表 `config` 中并没有字段叫 `fee_rate`,而是 `commission_percent`,而且值存储为 `’0.035’` 字符串。AI生成的代码使用了错误的字段名,导致读取失败时又用了fallback 0.035,但因为错误处理逻辑不严谨,fallback并没有被触发,最终返回了0。结果就是佣金为0,订单金额原封不动,对账时就发现少收了平台费,相当于订单总额多记了两倍(因为本来要扣掉佣金再记收入)。

发现过程很惊险:双写对比检测到新老服务返回的 `total_amount` 字段有巨大差异,告警响起时我正在喝咖啡。我立刻熔断关闭新服务流量,开始排查。一开始以为是精度问题,后来dump出完整请求才发现佣金字段是空。回溯代码,才看到AI那个“自作聪明”的修改。

修复很简单——我把那个写死的常量恢复,并且加上明确的注释:

// 严重注意:此费率必须在代码中与数据库保持同步,但不能从DB读取
// 因为老系统历史原因,运行时实际取值走数据库,此处仅用于对账校验
const DefaultCommissionRate = 0.035

但这件事给我上了一课:AI在重构时,看到“死代码”或“似乎不一致的注释”时,会倾向于“修正”它们,而不会考虑历史包袱。 这是AI最大的弱点之一——它假定代码库是逻辑自洽的,但遗留系统恰恰充满了反直觉的补丁。这次之后,我要求所有AI产出必须在代码审查时标记出“与原有逻辑不一致的改动点”,并强制一个人工核对。哪怕慢一点,也不能再发生这种财务级别的错误。

这个坑也验证了我在风险评估里的一条原则:涉及金额计算的模块,重构后必须先用只读流量跑足一周,再用双写对比观察,绝对不能直接切读写流量。

AI和静态分析、人工审查必须打配合:我的工作流和教训

重构结束后我复盘了整个流程,画了一张工作流图(其实就是一个白板)。核心是:

输入:遗留代码 + 静态分析报告 + 现有测试(如果有)
AI辅助层:分析依赖、生成重构方案、编写新代码和测试
人工审查层:审查AI输出、设计特性开关、确认mock策略、检查数据类型和边界
验证层:自动化测试(单元+契约+端到端)、影子流量对比、对账脚本
发布:特性开关渐进放量,配合熔断和监控

我学到的几个关键教训:

  • AI擅长机械性的代码翻译和拆分,但不懂业务含义和隐藏约定。所以你永远要在它输出的基础上,问一句“如果这段逻辑放到线上会不会有并发问题?会不会影响到关联系统?”
  • 静态分析工具是AI的“眼睛”。如果没有PHPStan告诉我 `$tax_rates` 类型不确定,AI根本不会意识到这个依赖是隐式的。喂给AI上下文时,一定要包含静态分析警告。
  • 测试必须由人定义“足够好”的标准。AI能生成大量测试,但只有人能判断哪些逻辑路径是法律或财务上必须100%准确的,哪些可以容忍微小差异。
  • 别迷信任何单一工具。我用Claude Code做粗粒度分析,Cursor做细粒度修改,Copilot加速,PHPStan校验,Git diff审查,少一个环节都可能漏过致命bug。
  • 影子流量是重构的“降落伞”。没有它我不敢在白天切流量。它比任何人工review都更早暴露行为差异。

三个月重构下来,代码量从4000行PHP缩减到了约1200行Go(加上测试大概2000行),但更重要的是可维护性、可测试性、可观测性都上了几个台阶。AI在其中贡献了约60%的代码产出,但没有人工把关,那60%可能就是60%的Bug。

如果你也要用AI重构遗留系统,我的核心建议是:先别急着让AI写代码,先让它读代码,读完之后和你的静态分析结果对齐,然后从最小的、最独立的单元开始,罩上测试,用开关保护,每一步都对比老系统的行为。AI是加速器,不是方向盘。

发表评论