第44章:代码坏味道:霰弹式修改 - 实战搬移函数与提炼类
说实话,霰弹式修改这名字挺形象的。你想想看,改一个需求,得跑到七八个类里去改代码,就像霰弹枪打出去,弹片散得到处都是。我在项目里见过最夸张的一次,改个支付接口的字段名,愣是改了16个文件。嗯,那天加班到凌晨两点。
什么是霰弹式修改?
简单说,就是一个变化引发多处修改。它的反面是发散式变化——一个类因为不同原因被频繁修改。霰弹式修改正好相反:一个原因,却要改一堆类。
典型症状:
- 改一个业务逻辑,要改3个以上的类
- 新增一个字段,要同步修改5个文件
- 修一个bug,改了6个地方还有遗漏
为什么会这样?说白了就是职责没分清楚。本该属于同一个概念的东西,散落在不同的类里。就像你把钥匙、钱包、手机分别放在三个房间,出门时就得来回跑。
实战案例:订单状态机
我之前接手过一个电商项目,订单状态变更的逻辑散得到处都是。来看个简化版:
// 订单类
public class Order {
private String status;
public void pay() {
this.status = "PAID";
// 散弹1:通知物流
LogisticsService.notify(this);
// 散弹2:更新库存
InventoryService.deduct(this);
// 散弹3:发送短信
SmsService.send(this);
}
public void cancel() {
this.status = "CANCELLED";
// 散弹4:释放库存
InventoryService.release(this);
// 散弹5:退款
RefundService.process(this);
// 散弹6:通知用户
EmailService.send(this);
}
}
你看,每个状态变更方法里,都直接调用了各种外部服务。改个状态,得改Order类、LogisticsService、InventoryService、SmsService……这还只是冰山一角。
重构第一步:搬移函数
我个人习惯,先做搬移函数。把那些散落在各处的逻辑,先集中到一个地方。比如,把状态变更相关的逻辑搬到Order类里:
public class Order {
private String status;
private OrderStateMachine stateMachine;
public void pay() {
stateMachine.transitionTo(OrderState.PAID);
}
public void cancel() {
stateMachine.transitionTo(OrderState.CANCELLED);
}
}
// 新建的状态机类
public class OrderStateMachine {
private Order order;
public void transitionTo(OrderState newState) {
validateTransition(newState);
executeSideEffects(newState);
order.setStatus(newState.name());
}
private void executeSideEffects(OrderState state) {
switch(state) {
case PAID:
LogisticsService.notify(order);
InventoryService.deduct(order);
SmsService.send(order);
break;
case CANCELLED:
InventoryService.release(order);
RefundService.process(order);
EmailService.send(order);
break;
}
}
}
嗯,这里要注意:搬移函数不是简单的复制粘贴。你得考虑依赖关系、生命周期、测试覆盖。我曾经直接把一个方法搬过去,结果发现它依赖了5个私有字段……那叫一个惨。
重构第二步:提炼类
搬移完函数,你会发现OrderStateMachine这个类开始膨胀了。这时候就该提炼类了。把不同维度的逻辑拆开:
// 状态变更的副作用处理
public class OrderSideEffectHandler {
private Order order;
public void handle(OrderState state) {
switch(state) {
case PAID: handlePaid(); break;
case CANCELLED: handleCancelled(); break;
}
}
private void handlePaid() {
// 只处理支付后的副作用
LogisticsService.notify(order);
InventoryService.deduct(order);
SmsService.send(order);
}
private void handleCancelled() {
// 只处理取消后的副作用
InventoryService.release(order);
RefundService.process(order);
EmailService.send(order);
}
}
// 状态转换规则
public class OrderStateRule {
private static final Map<OrderState, List<OrderState>> TRANSITIONS =
Map.of(
OrderState.PENDING, List.of(OrderState.PAID, OrderState.CANCELLED),
OrderState.PAID, List.of(OrderState.SHIPPED, OrderState.REFUNDING)
);
public boolean canTransition(OrderState from, OrderState to) {
return TRANSITIONS.getOrDefault(from, List.of()).contains(to);
}
}
我的经验:提炼类时,先画个依赖图。把每个类依赖的外部服务列出来,看看哪些是强相关的。强相关的放一起,弱相关的拆出去。这样提炼出来的类,内聚性高,耦合度低。
重构第三步:统一入口
最后一步,给状态变更提供一个统一的入口。这样外部调用者只需要关心「我要做什么」,不需要关心「怎么做」:
public class OrderService {
private OrderRepository orderRepo;
private OrderStateMachine stateMachine;
public void payOrder(String orderId) {
Order order = orderRepo.findById(orderId);
stateMachine.transition(order, OrderState.PAID);
orderRepo.save(order);
}
public void cancelOrder(String orderId) {
Order order = orderRepo.findById(orderId);
stateMachine.transition(order, OrderState.CANCELLED);
orderRepo.save(order);
}
}
你看,现在改一个状态变更逻辑,只需要改OrderStateMachine和它内部的几个类。不会再出现「改一个需求,改16个文件」的惨剧了。
避坑指南
我曾经踩过的坑:
- 过度搬移:把不该搬的逻辑也搬过去了。比如日志记录,它跟业务逻辑无关,留在原地反而更好。
- 忽略测试:搬移函数后,原来的测试用例全废了。记得先写测试,再重构。
- 一次改太多:我试过一次重构5个类,结果改到一半发现依赖关系搞错了。建议一次只重构一个概念。
知识体系总览
下面这张图,概括了霰弹式修改的识别、重构步骤和最终效果:
总结
霰弹式修改说白了就是职责没分清楚。重构的核心思路就三步:搬移函数、提炼类、统一入口。每一步都要谨慎,尤其是搬移函数时,别忘了检查依赖关系。
我个人经验,重构这种代码时,最好先画个类图,把每个类的职责标清楚。然后一步步来,每次只改一个概念。改完一个,跑一遍测试,确认没问题再改下一个。这样虽然慢,但稳。
一句话记住:如果你改一个需求要改3个以上的类,那就是霰弹式修改在向你招手了。赶紧重构,别等到改16个文件的时候再后悔。
公众号:蓝海资料掘金营,微信deep3321