PHP 遗留代码现代化实战:坏味道识别、绞杀者模式与灰度切换

系统讲解 PHP 遗留系统的现代化路径:遗留代码症状评估、坏味道识别、特性化测试护栏、绞杀者模式渐进替换、接缝抽取与依赖注入、依赖升级与兼容层、数据层双写校验、灰度切换与回滚,以及团队协作与长期治理。

引言

每个 PHP 团队迟早都会面对这样一套代码:跑在 PHP 5.6 上,全局变量满天飞,业务逻辑写在 3000 行的控制器里,SQL 用字符串拼接,没有任何测试,但它每天处理着公司 80% 的收入。想重写,老板问「多久能上线、风险多大」;想重构,一动就出线上事故。

遗留代码的定义不是「老代码」,而是 Michael Feathers 说的「没有测试的代码」——因为改不动、不敢改,才成为负担。理解这一点,现代化路径就清晰了:先补测试,再动代码;先建接缝,再换实现;先灰度,再全量。

本文给出 PHP 语境下的完整路线图:如何评估、如何识别坏味道、如何用特性化测试建立安全网、如何用绞杀者模式把老系统一块块替换掉,以及依赖升级、数据迁移、灰度与回滚的实操细节。核心心法只有一句:永远不要停下来做「大重写」。

关联阅读:PHP 版本迁移的语言特性对照见 https://plumephp.com/php8-modern-features/;静态分析与自动重构工具见 https://plumephp.com/php-static-analysis-quality/。


目录


1. 遗留系统的典型症状与评估

症状表现风险
无测试改一行靠手工点页面回归靠运气
上帝类3000 行控制器、800 行模型无人敢改
全局状态global $db、静态单例无法并行测试
拼 SQL字符串拼接、mysql_query注入、无法换库
隐式依赖new 散落各处无法替换、无法 Mock
版本陈旧PHP 5.6 / Laravel 5.x无安全更新
部署黑箱FTP 上传、无 CI不可回滚

动手之前先给系统「体检」:

cloc src/                                                     # 规模分布
find src -name '*.php' | xargs wc -l | sort -rn | head -20     # 最大的文件
ls tests/ 2>/dev/null || echo "无测试目录"
vendor/bin/phpstan analyse src --level=0 --no-progress         # 最低等级摸底
composer outdated --direct && composer audit
维度健康阈值
测试覆盖核心链路行覆盖率 > 60%
文件规模单文件 < 500 行、单方法 < 50 行
依赖高危漏洞数 0
版本PHP 与框架仍在安全支持期

体检报告不是用来「评判历史」,而是用来排序改造优先级:改动最频繁、故障最多、依赖最深的模块优先;几年不动、稳定运行的模块可以最后处理。


2. 代码坏味道的识别方法

坏味道识别信号重构手法
长方法超过一屏、多层 if 嵌套提取方法、早返回
上帝类类名含 Manager/Helper 且方法 > 20按职责拆分
重复代码复制粘贴的校验/格式化提取公共方法或服务
参数过多方法参数 > 4 个引入参数对象
全局状态global、static 缓存依赖注入
魔数/魔串if ($status == 3)常量或枚举
空 catchcatch (Exception $e) {}记录并处理

肉眼判断容易遗漏,用工具扫描更可靠:

composer require --dev phpmd/phpmd rector/rector
vendor/bin/phpmd src text codesize,unusedcode        # 圈复杂度与重复度
vendor/bin/rector process src --dry-run              # 列出可自动化的改动

把坏味道放进「痛苦程度 × 修改频率」矩阵:高痛苦 + 高频率 → 立刻处理(通常是订单、支付、计价);高痛苦 + 低频率 → 排期;低痛苦 + 高频率 → 顺手改善;低痛苦 + 低频率 → 不动。不要为了「代码整洁」去重构稳定运行的边缘模块,那是浪费预算。


3. 先建测试护栏:特性化测试

特性化测试(Characterization Test)不验证「应该怎样」,只记录「现在怎样」,目的是给现有行为拍一张快照,让后续重构有回归参照:

public function test_legacy_discount_calculation_snapshot(): void
{
    $calculator = new LegacyDiscountCalculator($this->db());

    // 用真实历史数据喂进去,首次运行生成快照,之后作为回归基线
    $result = $calculator->calculate(orderId: 1001);

    $this->assertSame(1850, $result['discountCents']);
    $this->assertSame(['满减', '会员'], $result['appliedRules']);
}

遗留代码内部难以直接测试,但入口是清晰的:HTTP 接口、CLI 命令、定时任务。先在这些边界写端到端测试,成本最低、覆盖最广:

public function test_legacy_order_endpoint_snapshot(): void
{
    $response = $this->post('/legacy/order/create', [
        'sku' => 'A-100', 'qty' => 2, 'user_id' => 9527,
    ]);

    $response->assertStatus(200);
    $this->assertMatchesSnapshot($response->json());   // 全量响应快照
}

测试语料最好来自生产数据(mysqldump --where=... 导出后必须脱敏手机号与地址)——把真实用户数据带进测试环境是合规事故。

最后一条纪律:覆盖率不是目标,护栏才是。不要追求 100%,而是确保「即将改动的代码路径」有断言;重构前先跑一遍相关测试确认基线通过,再动手。


4. 绞杀者模式:渐进式替换

绞杀者模式(Strangler Fig)源自藤蔓缠死老树的方式:在旧系统旁边长出新系统,用路由把流量一点点切过去,直到旧系统无事可做再关停。它最大的价值是任何时候都可以停下来,而不是「改到一半发现做不完」。

阶段 1:[ 旧系统 ] ← 100%
阶段 2:[ 路由 ] ─ 80% → [ 旧系统 ],20% → [ 新系统 ]
阶段 3:[ 路由 ] → [ 新系统 ]      [ 旧系统 ](只读兜底)
阶段 4:关停旧系统

在旧系统的入口加一层分发,按功能开关决定走哪边:

$feature = Feature::for($userId);
if ($feature->active('new-order-flow')) {
    return $this->forwardToNewApp($request);   // 新系统
}
return $this->legacyDispatch($request);        // 旧逻辑

选择切入点有三条标准:边界清晰(有明确的输入输出)、风险可控(失败不会直接造成资金损失或可快速回滚)、价值可见(改完能立刻减少故障或提升性能)。推荐从「只读查询」开始(如订单列表、报表),跑通后再动写链路。

替换期间旧系统仍可能被使用,因此要保证:旧系统只修 bug 不加功能、新增字段在两系统间同步、明确并公示旧系统的关停日期——否则它会永远活着。


5. 抽取接缝与依赖注入

接缝(Seam)是「可以不修改代码就改变行为」的位置。在 PHP 里,最常见的接缝是方法调用与构造函数。

// ❌ 硬编码:无法替换、无法测试
class OrderService
{
    public function notify(int $orderId): void
    {
        $client = new AliyunSmsClient('key', 'secret');   // 接缝为零
        $client->send('13800000000', "订单 {$orderId} 已发货");
    }
}
// ✅ 引入接缝:依赖从外部注入
interface SmsSender { public function send(string $to, string $msg): void; }

class OrderService
{
    public function __construct(private SmsSender $sms) {}

    public function notify(int $orderId, string $phone): void
    {
        $this->sms->send($phone, "订单 {$orderId} 已发货");
    }
}

不必一次性改完所有 new,可以按「先包一层适配器」的方式局部改造:写一个 LegacyAliyunSmsSender implements SmsSender,内部仍是原来的 new AliyunSmsClient(...)。这样调用方先依赖接口,将来换实现甚至换厂商只改一处。

一旦有了一批接口,就可以引入 PSR-11 容器统一装配($container->set(SmsSender::class, fn () => new LegacyAliyunSmsSender())),避免 new 到处扩散。记住:接缝优先于框架——先把依赖变成可注入的,再考虑用哪个容器。


6. 依赖升级与兼容层

顺序目标说明
1Composer 与锁文件先让依赖可复现
2静态分析摸底PHPStan level 0 起步
3PHP 版本5.6 → 7.4 → 8.0 → 8.3
4框架大版本按官方升级指南逐版本走
5第三方包用 composer outdated 排序

Rector 能把大量机械改动自动化(类型声明、构造器提升、旧函数替换),让人力集中在真正需要判断的地方:

// rector.php
return RectorConfig::configure()
    ->withPaths([__DIR__ . '/src'])
    ->withSets([
        \Rector\Set\ValueObject\LevelSetList::UP_TO_PHP_80,
        \Rector\Set\ValueObject\SetList::DEAD_CODE,
    ]);
vendor/bin/rector process src --dry-run   # 先看会改什么
vendor/bin/rector process src             # 执行(务必先提交或分支)

需要在新旧 PHP 版本上同时运行时,可加 symfony/polyfill-php80 之类的 polyfill 过渡;更常见的做法是双版本 CI:同一套代码在 PHP 7.4 与 8.3 上跑同一份测试,确保迁移期间两边都绿。

破坏性变更的排查方式各有侧重:

变更类型排查方式
弱比较语义变化静态扫描 == 用法,逐个人工确认
移除的函数PHPStan/Rector 会报未定义函数
类型错误改为异常先跑 E_ALL 全量日志观察
框架废弃 API升级指南 + deprecation 日志

关键动作:升级到目标版本的前一个版本时,把所有 deprecation 警告当作错误处理,逐个消灭,再升目标版本会顺畅得多。


7. 数据层迁移:双写与校验

表结构演进分四个阶段:① 双写(新代码同时写旧表与新表)→ ② 回填(离线任务补齐历史数据)→ ③ 双读校验(读新表并与旧表比对,差异上报)→ ④ 切换(读新表为主,旧表转只读,最后删除)。

final class DualWriteOrderRepository implements OrderRepository
{
    public function __construct(
        private OrderRepository $legacy,
        private OrderRepository $modern,
        private LoggerInterface $logger,
    ) {}

    public function save(Order $order): void
    {
        $this->legacy->save($order);            // 主写:失败则整体失败
        try {
            $this->modern->save($order);        // 副写:失败只记录
        } catch (\Throwable $e) {
            $this->logger->error('新表写入失败', ['order' => $order->id()]);
        }
    }
}

主写决定成败,副写失败只告警——这是双写期间保证可用性的关键取舍。

双读校验阶段则同时读两边,差异写指标与日志,但仍以旧数据为准:

$legacy = $this->legacy->find($id);
$modern = $this->modern->find($id);
if (!$this->equals($legacy, $modern)) {
    $this->metrics->increment('dual_read_mismatch');
}
return $legacy;   // 直到差异率归零才切换读路径

差异率必须降到可接受阈值以下(通常 0.01%)才能切换。表结构变更本身的零停机手法(扩展-收缩、gh-ost)见 https://plumephp.com/php-database-migrations-architecture/。


8. 灰度切换与回滚策略

final class FeatureFlags
{
    public function __construct(private CacheInterface $cache) {}

    public function enabled(string $flag, string $userId): bool
    {
        $percent = (int) $this->cache->get("flag:{$flag}:percent", 0);
        if ($percent >= 100) { return true; }
        return $percent > 0 && (crc32($userId) % 100) < $percent;
    }
}

按用户 ID 取模而非随机,保证同一用户在整个灰度期间体验一致。

阶段流量观察指标停留时间
内部试用员工功能正确性1 天
小流量1%错误率、耗时1~2 天
放量10% → 50%下单成功率各 1~3 天
全量100%稳定性—

回滚有三件套:功能开关关掉(秒级生效,无需发版,首选)、代码回滚(保留上一版本镜像,一键切回)、数据回滚(双写期间旧表仍在写入,切回旧逻辑即可继续服务)。回滚能力必须在切换之前验证过,而不是出事时才第一次尝试。部署与回滚的工程细节见 https://plumephp.com/php-deployment-nginx/。

观察指标分两类:技术指标(错误率、P99 延迟、慢查询数)与业务指标(下单成功率、支付成功率、转化率)——技术指标正常但业务指标下跌同样要回滚;同时保留对照组,用数据说话。


9. 团队协作与长期治理

让改造可持续的三条纪律:

  1. 童子军规则:每次改动顺手把「碰到的那一小块」改好,而不是专门开重构项目;
  2. 新代码必须带测试:新增功能走新架构,旧代码只做「遇改则改」;
  3. 不许新增坏味道:用 CI 门禁(PHPStan 基线只降不升、phpunit --fail-on-warning)阻止劣化。

知识转移同样关键:用领域词汇表记录业务规则,把口口相传的知识落成文档;关键链路补架构决策记录(ADR)说明「为什么是这样」;定期代码走读,避免知识锁死在离职风险最高的人身上。

失败模式后果纠正
大爆炸重写工期失控、上线即事故改用绞杀者模式
只重构不加测试改完不知对错先建特性化测试
冻结旧系统需求无处落地允许小步改动
无回滚方案出事只能硬扛开关 + 镜像 + 双写
追求 100% 覆盖成本远超收益只覆盖改动路径

一句话总结:遗留系统现代化的本质是风险管理——用测试换信心、用接缝换自由、用绞杀换时间、用灰度换退路。凡是「一次改完、上线即好」的方案,几乎都是陷阱;凡是「随时可以停下来」的方案,才是真正可执行的。


延伸阅读

  • https://plumephp.com/php-static-analysis-quality/ — PHPStan 基线与 Rector 自动重构的落地细节
  • https://plumephp.com/php-testing-practice/ — 特性化测试之外的测试体系与 CI 门禁
  • https://plumephp.com/php-database-migrations-architecture/ — 零停机表结构变更与在线数据迁移
  • https://plumephp.com/php-deployment-nginx/ — 灰度发布、镜像回滚与部署流水线
  • https://plumephp.com/php8-modern-features/ — PHP 5.6 到 8.x 的语言特性对照与迁移策略

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「php」更多文章

  1. Symfony 框架实战:组件化架构、依赖注入容器与 Doctrine 集成
  2. PHP 领域驱动设计实战:限界上下文、聚合与六边形架构
  3. PHP 文件上传与图片处理实战:安全校验、分片续传与对象存储