如何重构遗留 PHP 代码 不至于崩溃
当你意识到自己接手了一坨遗留代码
通常是这样开始的。
有人让你修一个小 bug,可能是个验证问题,可能是个只在生产环境出现的奇怪边界情况。你打开文件,想着很快就能搞定。
然后你看到了:
- 一个文件 3000 行
- 业务逻辑和 HTML 混在一起
- 循环里面写数据库查询
- 变量名叫
$x、$temp、$data2 - 函数的返回类型取决于代码当时的心情
这时候你才意识到:这就是遗留 PHP 代码。
不是"老"PHP,不是"烂"PHP,只是活得够久、变成了核心系统的代码。
现在它归你了。
重构遗留 PHP 的名声一直不好------痛苦、高风险、精神损耗大。但大部分痛苦来自重构的方式,而不是代码本身。
这篇文章讲的是怎么重构遗留 PHP,同时保持心态不崩、系统不炸、也不用推翻重来。
先搞清楚"遗留代码"到底是什么意思
遗留代码不等于"烂代码"。
遗留代码是没有安全网的代码:
- 没有测试
- 没有清晰的边界
- 没有文档
- 改了之后心里没底,不知道会不会炸
有些遗留 PHP 是 15 年前写的。有些是去年写的------赶工期,没时间收拾。
怪过去没有用。你的任务不是评判,而是让下一次改动比上一次更安全。
光是这个心态转变,就能改变很多事。
黄金法则:永远不要盲目重构
在动结构、格式、架构之前,你需要的是可见性。
不理解行为就动手重构,是线上事故的典型成因。
找到关键路径
先问这几个问题:
- 哪些接口被调用得最频繁?
- 哪些脚本涉及资金、认证或数据完整性?
- 哪些定时任务在自动运行?
从这些地方开始,而不是从最丑的那个文件开始。
遗留 PHP 系统通常能活下来,是因为核心路径是稳定的------哪怕代码很难看。
改代码之前,先锁住行为
你不需要完整的测试覆盖率,你需要的是行为锚点。
特征测试(Characterization Tests)
不是测代码"应该"做什么,而是测它"目前"在做什么。
PHPUnit 示例:
php
public function testLegacyDiscountCalculation()
{
$calculator = new LegacyDiscountCalculator();
$result = $calculator->calculate(100, 'VIP');
$this->assertEquals(85, $result);
}
你可能不喜欢这个逻辑,可能还没看懂。没关系。
目标很简单:如果我重构了这段代码,我要能知道行为变了没有。
这些测试是临时的安全带------但关键时刻能救命。
先止血
在"清理"之前,先修掉那些正在持续制造问题的东西。
遗留 PHP 常见的出血点
全局状态
php
global $db;
global $user;
全局变量让重构几乎不可能。第一步:把它包起来。
php
class Database
{
public function query(string $sql): array
{
return $GLOBALS['db']->query($sql);
}
}
不完美------但你有了一条缝(seam)。
职责混杂
php
function processOrder()
{
// validate input
// query database
// calculate totals
// send email
// render HTML
}
不要一口气全改,一次只抽离一个职责。
先建边界,再追求完美
重构不是重写。
目标是划出清晰的边界,而不是打磨漂亮的内部实现。
用有意义的名字提取函数
遗留代码往往做的事情是对的,只是形式不对。
php
$total = 0;
foreach ($items as $item) {
if ($item['type'] === 'digital') {
$total += $item['price'];
} else {
$total += $item['price'] + 10;
}
}
第一步重构:
php
$total = calculateOrderTotal($items);
function calculateOrderTotal(array $items): float
{
$total = 0;
foreach ($items as $item) {
$total += itemTotal($item);
}
return $total;
}
行为没变,但可读性和可测试性变了。
这就是一次有效的改进。
在最痛的地方加类型
不需要第一天就全面开启严格类型。
从 bug 容易藏身的地方开始:
- 函数输入
- 函数输出
- 公开方法
php
function findUser($id)
{
// returns array or false or null
}
重构方向:
php
function findUser(int $id): ?User
{
// return User or null
}
类型做两件事:表达意图 ,暴露隐藏的假设。
遗留 PHP 能跑通,往往是因为什么都是"灵活"的。类型会逼你面对真实情况。
用有意义的表达替换魔术数字
遗留 PHP 特别喜欢用标志位。
php
if ($status === 1) {
// active
} elseif ($status === 2) {
// pending
}
用常量或枚举重构:
php
enum UserStatus: int
{
case Active = 1;
case Pending = 2;
}
然后:
php
if ($user->status === UserStatus::Active) {
// ...
}
代码一下子就能自己解释自己了。
慢慢消灭复制粘贴的代码
重复代码是生存策略,不是无能的表现。
遗留 PHP 之所以有大量重复,是因为当时做抽象的风险太大。
你的做法:
- 找到稳定的重复
- 搞清楚差异之后再提取
例如这段代码到处都是:
php
$tax = $price * 0.1;
$total = $price + $tax;
提取出来:
php
function calculateTax(float $price): float
{
return $price * 0.1;
}
不要过早做过度抽象。重构的方向是清晰,不是理论上的复用。
把基础设施和业务逻辑分开
遗留 PHP 重构中收益最大的一件事,就是把下面这些东西和真正的业务规则分开:
- SQL
- HTTP
- 框架胶水代码
重构前:
php
$result = mysqli_query($conn, "SELECT * FROM users WHERE id = $id");
if ($row['type'] === 'premium') {
$discount = 0.2;
}
重构后(渐进式):
php
$user = $userRepository->findById($id);
$discount = $discountPolicy->forUser($user);
即使 repository 内部仍然用的是裸 SQL,你也已经创造了一条缝。
有缝,重构才安全。
不要一次性"升级"所有东西
在遗留系统中升级 PHP 版本是件让人紧张的事,因为升级会把隐藏的问题暴露出来。
把升级当成反馈,而不是失败:
- 遇到废弃警告就修
- 在非生产环境开启 warning
- 把 notice 当成重构线索
每一条警告都在告诉你:"代码的这个部分不够清晰,或者不够安全。"
这是有价值的信息。
用日志当临时调试网
测试缺失的时候,日志能给你信心。
php
logger()->info('Calculating discount', [
'user_id' => $user->id,
'type' => $user->type,
]);
重构之后对比日志。如果行为出现了意料之外的偏差,你能及时发现。
切薄片来重构
永远不要一次性重构:
- 整个模块
- 整个功能
- 整个目录
一次改一条路径、一个函数、一个职责。
小改进会累积。
遗留系统不是瞬间崩塌的------它是慢慢被侵蚀的。你的重构也应该以同样的节奏推进。
知道什么时候该停手
完美代码是个陷阱。
以下情况就该收手了:
- 下一次改动让你觉得有安全感
- 代码意图是清晰的
- 各部分有明确的边界
- 你能向另一个开发者讲清楚这段代码
你不是在创作艺术品,你是在降低风险。
重构遗留 PHP 的情感面
重构遗留 PHP 之所以让人难受,是因为:
- 代码在跟你对着干
- 问题一眼就能看到
- 系统还依赖着这些代码在跑
但别忘了:这些代码能活到今天,说明它一直在发挥作用。
你的任务不是抹掉过去,而是让未来没那么痛苦。
最后
重构遗留 PHP 不需要英雄主义。
它需要的是:
- 耐心
- 克制
- 对历史约束的理解
- 在小改进上的持续纪律
你不是通过重写来重构遗留 PHP 的。你是通过一次又一次安全的小改动,一点一点赢得它的信任。
如果做对了------你不会崩溃。说不定还会觉得挺有意思。 如何重构遗留 PHP 代码 不至于崩溃