一个运行正常的 CakePHP 应用,被工程师在没人要求的情况下连续几周重写成 Laravel。重写后功能一样、速度一样、用户无感。这个故事刺耳,是因为它不像灾难事故,更像日常工程现场里经常出现的冲动:代码让我不舒服,所以我要把它换成我喜欢的样子。
“我看不懂”不等于“系统该重写”
很多重写的起点并不是业务瓶颈,而是工程师的心理摩擦。
旧框架、旧目录结构、陌生 ORM、没有自己偏好的抽象,这些都会让人产生强烈的不适感。但“不顺手”和“不值得维护”之间隔着很长一段距离。一个 CakePHP 应用可能风格陈旧,命名也不漂亮,但只要它稳定处理订单、登录、报表、支付回调,它就是业务资产,不是待清理的个人练习题。
重写最危险的地方在于,它看起来像技术改进,实际可能只是把确定可运行的复杂性,换成一套尚未被生产验证的新复杂性。用户不关心框架名字,运营不关心控制器是否优雅,老板也不会因为服务从 CakePHP 迁到 Laravel 就自动获得增长。
判断重写是否必要,可以先问几个更冷的问题:
- 现在是否存在明确的业务损失,例如故障、性能瓶颈、交付周期严重变慢?
- 这个损失能否用小范围重构、加测试、拆模块、升级依赖解决?
- 重写期间谁维护旧系统?谁验证功能一致性?谁承担上线风险?
- 新系统上线后,用户能感知什么收益?团队能减少什么长期成本?
如果这些问题答不上来,重写很可能只是工程师给自己开的技术爽感支票。
重写不是免费午餐,真正贵的是“功能一样”
“功能一样”听起来简单,实际最难。
老系统里最值钱的往往不是框架代码,而是多年运行中积累出来的边界行为:某个老客户的特殊折扣、某个失败支付回调的补偿逻辑、某个报表字段的历史口径、某个管理后台按钮的隐含权限。它们不一定写在文档里,甚至不一定被团队成员完整理解。
所以一次全量重写通常会引入三类成本:
- 发现成本:重新理解旧系统实际做了什么,而不是它看起来应该做什么。
- 验证成本:证明新系统没有丢掉隐藏规则。
- 迁移成本:数据、配置、任务、队列、缓存、权限、部署流程都要被搬过去。
如果重写后的结果是“功能一样,速度一样,用户没感觉”,那项目没有创造新价值,只是重新支付了一遍理解系统的成本。更糟的是,团队可能还要同时维护旧系统和新系统,直到所有边界都被确认。
可以这样实践:给重写冲动加一道量化门槛
下面这个小脚本不是管理银弹,但它能把“我不喜欢这个代码库”从决策里剥离出来,逼团队讨论可验证的成本和收益。
把下面内容保存为 rewrite_score.py,直接运行。你可以按自己的团队情况调整权重。
#!/usr/bin/env python3
from dataclasses import dataclass
@dataclass
class RewriteCase:
business_loss: int # 0-5: 当前系统造成的明确业务损失
delivery_blocker: int # 0-5: 当前架构阻碍交付的程度
operational_risk: int # 0-5: 当前系统的故障/安全/合规风险
incremental_option: int # 0-5: 是否存在渐进式替代方案,越高代表越可行
rewrite_scope: int # 0-5: 重写范围和未知数,越高越大
validation_cost: int # 0-5: 功能一致性验证成本,越高越贵
def score(case: RewriteCase) -> tuple[int, str]:
benefit = (
case.business_loss * 4
+ case.delivery_blocker * 3
+ case.operational_risk * 4
)
cost = (
case.incremental_option * 3
+ case.rewrite_scope * 4
+ case.validation_cost * 4
)
total = benefit - cost
if total >= 10:
decision = "可以立项,但必须拆阶段、设回滚点、补验收测试"
elif total >= 0:
decision = "先做架构尖刺或局部替换,不要直接全量重写"
else:
decision = "不要重写;优先加测试、修热点、清理最痛的模块"
return total, decision
if __name__ == "__main__":
case = RewriteCase(
business_loss=1,
delivery_blocker=2,
operational_risk=1,
incremental_option=4,
rewrite_scope=5,
validation_cost=5,
)
total, decision = score(case)
print(f"rewrite_score={total}")
print(f"decision={decision}")
运行:
python3 rewrite_score.py
示例里的系统没有明显业务损失,但重写范围大、验证成本高、又存在渐进式改造空间,所以结果会倾向于“不重写”。这类工具的意义不是替你做决定,而是阻止团队用一句“旧代码太烂了”跳过工程判断。
更靠谱的路线:把重写拆成可撤退的改造
如果系统真的难维护,通常也不必一刀切。更稳的做法是把重写目标改写成可交付的工程动作。
可以从这些动作开始:
- 给核心业务路径补端到端测试,先定义“不能坏”的行为。
- 把最常变、最痛的模块从旧框架边界里包出来。
- 对新功能使用新架构,但让旧功能继续稳定运行。
- 用网关、队列、数据库视图或 API 层做隔离,而不是一次性搬空系统。
- 每次替换一个业务能力,并保留回滚路径。
例如,一个旧 PHP 单体要逐步迁出订单查询能力,可以先在边界上固定接口,而不是立刻重写所有页面:
# orders-api-contract.yaml
openapi: 3.0.3
info:
title: Orders Read API
version: 1.0.0
paths:
/orders/{order_id}:
get:
summary: Read one order by id
parameters:
- name: order_id
in: path
required: true
schema:
type: string
responses:
"200":
description: Order found
content:
application/json:
schema:
type: object
required: [id, status, total]
properties:
id:
type: string
status:
type: string
total:
type: number
"404":
description: Order not found
这份契约可以让旧系统、新服务、测试用例、前端调用先对齐边界。等边界稳定了,再决定内部到底是 CakePHP、Laravel,还是别的实现。框架选择应该服务于迁移策略,而不是反过来支配业务节奏。
什么时候重写才算正当
重写不是禁忌。真正的问题是,它必须有业务账本。
比较站得住的理由包括:现有系统无法满足合规或安全要求;核心依赖已经停止维护且无法隔离;架构导致关键需求长期无法交付;运行成本高到影响业务模型;团队已经通过测试和监控掌握了足够多的现有行为。
即便如此,也要给重写设置硬边界:
- 明确不重写哪些部分。
- 明确第一阶段上线后用户或团队得到什么收益。
- 明确功能一致性的验收方式。
- 明确旧系统下线条件。
- 明确失败时如何回滚。
工程师当然可以追求更好的工具和更干净的代码。但在公司系统里,代码不是个人审美作品,而是业务运行的机器。重写之前,先证明你是在减少系统风险,而不是在安抚自己的不适感。