引言
每个 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. 遗留系统的典型症状与评估
- 2. 代码坏味道的识别方法
- 3. 先建测试护栏:特性化测试
- 4. 绞杀者模式:渐进式替换
- 5. 抽取接缝与依赖注入
- 6. 依赖升级与兼容层
- 7. 数据层迁移:双写与校验
- 8. 灰度切换与回滚策略
- 9. 团队协作与长期治理
- 延伸阅读
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) | 常量或枚举 |
| 空 catch | catch (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. 依赖升级与兼容层
| 顺序 | 目标 | 说明 |
|---|---|---|
| 1 | Composer 与锁文件 | 先让依赖可复现 |
| 2 | 静态分析摸底 | PHPStan level 0 起步 |
| 3 | PHP 版本 | 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. 团队协作与长期治理
让改造可持续的三条纪律:
- 童子军规则:每次改动顺手把「碰到的那一小块」改好,而不是专门开重构项目;
- 新代码必须带测试:新增功能走新架构,旧代码只做「遇改则改」;
- 不许新增坏味道:用 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 的语言特性对照与迁移策略
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。