前阵子做代码评审,遇到一个让我印象特别深刻的类:订单价格计算方法,一个方法里塞了十多个 if-else,从会员等级判断到优惠券叠加,从新人专享到节假日折扣,层层嵌套,最外层还包着一个 for 循环,计算一批订单的总价。读代码的人得顺着每一层 if 的 true 和 false 两条路分别往下追,追到第五层基本就忘了前面到底在判断什么条件。后来新增一个"预售尾款立减"的需求,团队里两个人改了同一个方法,一个人改在 284 行,另一个人改在 351 行,合并时冲突叠着冲突,最后 Code Review 时发现两个分支互相把对方的判断条件覆盖了一半。我说这东西得重构,同事说"重构 = 引入设计模式 = 更复杂",我听完就知道,我们对设计模式的认知可能存在一个很大的偏差。
先说我的结论:设计模式不是用来炫技的,也不是为了消灭 if-else 本身。if-else 的问题从来不在"用了判断",而在"每个新需求都在改同一个方法、同一个分支结构",时间一长,旧分支被新分支牵动,新分支被旧逻辑干扰,改哪都像踩地雷。真正干净的做法是让"变化的业务规则"各自独立,让代码的骨架稳定下来。这篇文章我会直接用四个实战场景,把策略模式、工厂加策略组合、责任链模式、状态模式怎么替换 if-else 讲明白,每一段都附上重构前后的代码对比,把踩过的坑也一并列出来。如果你是后端、客户端同学,或者正在准备设计模式相关的内容,看完应该能直接在自己的项目里动手改造。
1. if-else 真正的问题:不是可读性差,而是每次需求变化都要跟着变
很多人把 if-else 当成过街老鼠,其实这是一个误伤。几层 if-else 的判断逻辑,自己写的当下看肯定无比清晰,就算换成别人来读,只要分支数量少、条件简单,也完全能顺下来。if-else 真正的杀伤力,藏在需求迭代的节奏里。
1.1 一个折扣需求的演变史
假设现在要写一个订单价格计算模块,第一版需求特别简单:普通用户不打折,会员用户打 95 折。你写出来的代码大概是这样:
java复制public BigDecimal calculatePrice(Order order) {
BigDecimal total = order.getGoodsAmount();
if (order.getUser().isMember()) {
total = total.multiply(new BigDecimal("0.95"));
}
return total;
}
这个 if 写得没有任何问题,清晰、简短、可读性极高。第二个月产品说加一个"新人专享 88 折",你开始追加分支:
java复制public BigDecimal calculatePrice(Order order) {
BigDecimal total = order.getGoodsAmount();
User user = order.getUser();
if (user.isMember()) {
total = total.multiply(new BigDecimal("0.95"));
}
if (user.isNewUser()) {
total = total.multiply(new BigDecimal("0.88"));
}
return total;
}
看起来还能接受吧?毕竟也没有嵌套,只是两个平级 if。但很快产品又来了第三版需求:"双十一大促,全场满 300 减 40;会员在满减基础上再打 95 折;新人不参与满减,但享受折上 88 折;礼盒类商品不参与任何促销,走固定价。"
这个时候你再看代码,if 和 else 开始互相嵌套、互相排除,一个用户可能同时命中多个条件,还要考虑条件的优先级。为了表达这套规则,你只能不断往上叠判断,每叠一层,就得重新梳理前面所有分支的顺序是否还正确。等这个类长到四百行、五百行的时候,代码已经不是给人读的了,是给调试器一遍遍捋的。
1.2 判断与规则混杂,改一个分支容易带崩一片
我仔细观察过一堆被 if-else 堆坏的代码,发现它们的共同点是:把一个"规则"和一堆"条件判断"混在同一个方法体里。判断用户是不是会员,是条件;会员打多少折,是规则;判断商品是不是礼盒,是条件;礼盒走固定价,是规则。如果代码把条件和规则完全搅在一起,新的需求进来,你就不只是"加规则",而是在“改条件”,改条件就会影响旧规则,影响旧规则就会产生线上 bug。
设计模式在这里的作用,是把"条件判断"和"业务规则"拆成两个维度:规则各自封装,条件只用来选择一个规则组。这样一来,新增需求时新增的是一个独立的规则实现,而不是在老方法里找位置插入一个新的 if 分支。
判断一个 if-else 是否值得处理,我有一个比较实用的标准:看这个 if 块过去的修改频率。如果一个 if 分支在过去三个月被改了三次以上,说明它对应的业务逻辑处于高频变化中,留在这里就是一颗定时炸弹;如果某个 if 从上线到现在三年没人动它,那它反而可以继续留着,不要为了重构而重构。我见过不少团队把稳定代码强行套上设计模式,最后出现的问题是:类数量暴涨,可读性不升反降,老成员离职以后,新来的同事看着二十个策略类根本不知道哪个才是真正被使用的那个。所以,下面要讲的四种模式,全部针对的是"高频变化、分支复杂、判断交叉"的典型场景,如果不符合这些特征,完全可以按兵不动。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 策略模式:把"各分支该干什么"提取成独立策略
策略模式是干掉 if-else 最基础、也最常用的一招。它的核心思想特别简单:把每个分支里的具体执行逻辑抽取成独立的类,让调用方在运行期选择用哪个类。
2.1 重构前:结算方法里的 if-else
我们看一个最常见的结算场景,不同的订单类型走不同的计价逻辑:
java复制public BigDecimal settle(Order order) {
OrderType type = order.getType();
if (type == OrderType.NORMAL) {
return normalSettle(order);
} else if (type == OrderType.SECKILL) {
return seckillSettle(order);
} else if (type == OrderType.GROUPON) {
return grouponSettle(order);
} else if (type == OrderType.CREDIT) {
return creditSettle(order);
}
throw new UnsupportedOperationException("unsupported type");
}
这个代码的坏味道不在那几行 if,而在于每次新增一种订单类型,你都得打开这个 settle 方法,在最后面再补一个 else if。多个订单类型对应的结算逻辑互相之间其实毫无关系,但是被写在同一个类里以后,它们在物理层面就被绑定到了一起。一个人改秒杀结算逻辑,他要面对的却是整个文件里四种订单类型的代码。
2.2 重构后:策略接口与实现类
用策略模式重构之后,结构变这样:
java复制public interface SettleStrategy {
OrderType supportType();
BigDecimal settle(Order order);
}
然后每种订单类型对应独立的策略实现:
java复制@Component
public class NormalSettleStrategy implements SettleStrategy {
@Override
public OrderType supportType() {
return OrderType.NORMAL;
}
@Override
public BigDecimal settle(Order order) {
// 普通订单结算逻辑
return order.getGoodsAmount();
}
}
秒杀、团购、白条这几个策略类都长一个套路,各自实现自己的结算逻辑,不再互相看见彼此。调用方代码简化成下面这样:
java复制public class SettleService {
private final Map<OrderType, SettleStrategy> strategyMap;
public SettleService(List<SettleStrategy> strategies) {
this.strategyMap = strategies.stream()
.collect(Collectors.toMap(SettleStrategy::supportType, Function.identity()));
}
public BigDecimal settle(Order order) {
SettleStrategy strategy = strategyMap.get(order.getType());
if (strategy == null) {
throw new UnsupportedOperationException("unsupported type");
}
return strategy.settle(order);
}
}
这一版改完以后,settle 方法里已经不存在 if-else 了,只剩一个从 Map 里取值的过程。新增订单类型的时候,开发人员只需要做两件事:新建一个实现类,在实现类里定义 supportType 返回值。老的方法、老的策略类完全不用碰。
2.3 策略模式解决的核心矛盾与 C++ 场景补充
策略模式解决的核心矛盾是"分支逻辑和调用入口耦合在一起"。把调用入口固定下来,把分支逻辑变成可以独立扩展的插件,这在软件设计里就是所谓的"开闭原则":对扩展开放,对修改关闭。
如果你写的是 C++,思路完全一样,只是实现形态上略有区别。C++ 可以用抽象基类加虚函数来组织策略,也可以用 std::function 加权重的 map 来做轻量级策略,避免了大量小类的产生:
cpp复制class SettleService {
public:
using SettleFunc = std::function<double(const Order&)>;
void registerStrategy(OrderType type, SettleFunc func) {
strategies_[type] = std::move(func);
}
double settle(const Order& order) {
auto it = strategies_.find(order.type());
if (it == strategies_.end()) {
throw std::runtime_error("unsupported type");
}
return it->second(order);
}
private:
std::unordered_map<OrderType, SettleFunc> strategies_;
};
我在实际项目里用 C++ 这种写法比较多,因为在一些策略粒度非常细的场景(比如不同广告位 ID 对应不同扣费逻辑),每个策略都写一个类会有点重,用函数对象去注册就显得干净利落。不过一旦策略内部有非常复杂的私有状态,还是老老实实拆成独立类更合适。语言只是工具,结构思想才是核心。
3. 工厂加策略组合:把"选哪个策略"的 if 也一并收口
看完上一节,有人已经认识到策略模式的好处,但紧接着又会发现一个新的问题:上一节的调用方代码确实没有 if-else 了,可那只是因为我们用 Spring 的构造器注入自动把策略实现类收集了起来,然后靠 Map 来路由。如果项目里没有 Spring,还是需要自己在调用方用 if 或 switch 来决定 new 哪个策略,那么那个创建策略的 switch 往往又长得很难看。
3.1 工厂里的 switch 依然难看
很多项目在引入策略模式的时候,做了一半就往仓库里推了。表现是:业务计算逻辑确实各自封装到了策略类,但策略类的创建集中在一个工厂里,工厂内部是一大段 switch:
java复制public class SettleStrategyFactory {
public SettleStrategy getStrategy(OrderType type) {
switch (type) {
case NORMAL:
return new NormalSettleStrategy();
case SECKILL:
return new SeckillSettleStrategy();
case GROUPON:
return new GrouponSettleStrategy();
case CREDIT:
return new CreditSettleStrategy();
default:
throw new UnsupportedOperationException("unsupported type");
}
}
}
这相当于把原本分布在业务代码里的 if-else,统一挪到了工厂里,然后换了个马甲变成了 switch。问题真的解决了吗?好像解决了,又好像没有完全解决:新增订单类型时确实不用改上层业务代码了,但你还是得回头来打开这个工厂类,在那个 switch 里新加一行 case。只要忘加,系统运行时就找不到策略。这个工厂类成了一个需要反复修改的新入口,等于把变更的压力从业务方法转移到了工厂方法,并没有根治。
3.2 注册表 Map 装配策略
真正的根治手段是取消 if 型工厂,把策略注册做成自动装配。有 Spring 的团队很简单,直接用构造器注入 + Map 收集就完事,上一节示例里我写到的 Map<OrderType, SettleStrategy> 就是一种自动注册表。它等价于一个不需要维护的工厂,所有实现了 SettleStrategy 接口的 Bean 都会被 Spring 自动收进来,规则定义在 Bean 自身的 supportType 方法里,谁也漏不掉。
没有 Spring 的团队,可以手工做一个注册表:
java复制public class SettleStrategyRegistry {
private final Map<OrderType, SettleStrategy> strategies = new HashMap<>();
public void register(OrderType type, SettleStrategy strategy) {
strategies.put(type, strategy);
}
public SettleStrategy get(OrderType type) {
SettleStrategy strategy = strategies.get(type);
if (strategy == null) {
throw new UnsupportedOperationException("unsupported type");
}
return strategy;
}
}
然后在系统启动阶段把策略一个个注册进去:
java复制registry.register(OrderType.NORMAL, new NormalSettleStrategy());
registry.register(OrderType.SECKILL, new SeckillSettleStrategy());
registry.register(OrderType.GROUPON, new GrouponSettleStrategy());
这个启动装配的过程,可以使用配置驱动或者 SPI 机制,也可以简单到就在一个装配类里写几行代码,总之核心在于:增加新策略时,不再需要修改已有的注册逻辑主流程,你只是在启动时额外加一行注册。这就是注册表模式相对工厂 switch 的本质区别——它对原来的路径是"零侵入"的。
3.3 无框架环境下如何维护注册中心
如果你既没有 Spring,也没有一套现成的依赖注入容器,那我建议至少把注册动作集中到一个 Configuration 类 或者一个 Assembly 类里,而不是散落在各个业务入口。比如 Java 项目里可以做一个 BootstrapListener,在容器初始化的时候完成所有注册。
在实际改造中还有一个容易被忽视的细节:策略 key 的语义设计。我见过有人直接把数据库里的类型 code 作为 key,比如 "1"、"2"、"3" 这种,结果换了个环境之后 code 的含义变了,整个注册表里的对象全对不上,而且这种问题很难通过编译期发现。更好的做法是使用领域内的枚举类型作为 key,让类型定义和策略映射都保持强类型检查,即使将来数据字典变化,代码层面也能有一个集中的修改点。
另外,千万不要在 get 方法里偷偷塞一个 if 判断然后返回默认实现。有的同事为了省事,在 registry.get(type) 查不到的时候直接 new DefaultStrategy() 兜底,结果某次上游传错了一个 code,本来应该是抛异常立刻暴露问题,结果因为兜底策略的存在,系统静默地给所有无法识别的订单用了默认结算方案,导致一整天下来对账对不上。这里的教训是:找不到策略就是配置缺失,是程序错误,应该大声地失败(fail fast),而不是静默吞掉。
4. 责任链模式:把连续判断改造成处理环节
策略模式处理的是"从多个方案里选一个"的情况。如果一段逻辑是"依次经过多道判断,任何一道不通过就中断",if-else 的写法往往是一长串叠加,那么责任链模式比策略模式更对症。
4.1 适合责任链的场景长什么样
日常开发里这种场景很多。比如下单前的风控校验,一个订单要检查用户是否在黑名单、商品是否限售、收货地址是否在配送范围、该用户今天的下单频率有没有超过阈值。写成 if-else 的话大概是:
java复制public void checkOrder(Order order) {
if (!checkUserInBlacklist(order.getUserId())) {
throw new IllegalStateException("用户在黑名单中");
}
if (!checkProductLimited(order.getProductId())) {
throw new IllegalStateException("商品限售");
}
if (!checkAddressSupported(order.getAddress())) {
throw new IllegalStateException("地址不支持配送");
}
if (!checkUserOrderFrequency(order.getUserId())) {
throw new IllegalStateException("下单频率过高");
}
}
单看这个代码,似乎没那么"噩梦",每一行的意图也算清晰。但问题在于:这些检查项的顺序可能被业务要求调整。比如大促期间要优先校验下单频率,防止刷单,那么你就得手动调整这几个 if 的顺序;如果想临时加一道"IP 风险校验",你得决定它放在黑名单之前还是之后。每调整一次顺序,都要小心翼翼地把整个方法看一遍,确认没有漏掉哪个判断。而且,这里每一行 if 其实都藏着一次远程调用或数据库查询,在单元测试中,想单独测试"只有地址不支持"这个分支,你就得 mock 掉前面所有的依赖,测试成本很高。
4.2 用抽象类实现链式处理
责任链模式把每个校验动作做成独立节点,节点之间靠"下一个节点"指针连接,请求从链头进入,逐个经过节点,每个节点决定自己处理还是放行:
java复制public abstract class OrderCheckHandler {
protected OrderCheckHandler next;
public void setNext(OrderCheckHandler next) {
this.next = next;
}
public void doCheck(Order order) {
if (!check(order)) {
throw new IllegalStateException(getErrorMessage());
}
if (next != null) {
next.doCheck(order);
}
}
protected abstract boolean check(Order order);
protected abstract String getErrorMessage();
}
具体节点这样实现:
java复制public class BlacklistCheckHandler extends OrderCheckHandler {
@Override
protected boolean check(Order order) {
return !userService.isInBlacklist(order.getUserId());
}
@Override
protected String getErrorMessage() {
return "用户在黑名单中";
}
}
然后把所有节点串起来:
java复制OrderCheckHandler blackListHandler = new BlacklistCheckHandler();
OrderCheckHandler productLimitedHandler = new ProductLimitedCheckHandler();
OrderCheckHandler addressHandler = new AddressSupportedCheckHandler();
OrderCheckHandler frequencyHandler = new UserOrderFrequencyCheckHandler();
blackListHandler.setNext(productLimitedHandler);
productLimitedHandler.setNext(addressHandler);
addressHandler.setNext(frequencyHandler);
blackListHandler.doCheck(order);
走到这一步,节点之间已经实现了解耦。你要调整校验顺序,只需要调整链的组装顺序,节点本身不用动;要临时加一道校验,也只要新建一个 handler 类,在链条中插入对应的位置。
4.3 链式重建的注意事项与函数式写法
责任链模式有一个很容易踩的坑,就是链的装配分散在多个地方。如果项目里每个业务入口都自己 setNext 一遍,那么链顺序就会变得不可控。更稳妥的做法是把链的组装收敛到一个 builder 或者一个 Provider 里,让所有入口复用同一条链。
如果你用的是 Java 8 以上,还可以把链做得更轻量,不是非要做抽象类。用 List<Function<Order, Boolean>> 或者 List<Predicate
java复制public class OrderCheckChain {
private final List<Predicate<Order>> checks = new ArrayList<>();
private final List<String> messages = new ArrayList<>();
public OrderCheckChain addCheck(Predicate<Order> predicate, String errorMessage) {
checks.add(predicate);
messages.add(errorMessage);
return this;
}
public void execute(Order order) {
for (int i = 0; i < checks.size(); i++) {
if (!checks.get(i).test(order)) {
throw new IllegalStateException(messages.get(i));
}
}
}
}
这种写法在校验逻辑比较短的时候很轻便,但缺点是很校验逻辑一旦变复杂,Predicate 的 lambda 会越写越长,最后还不如抽象类清晰。我的取舍标准是:单个节点的判断逻辑超过十行,就拆独立的 Handler 类;只有一两行判断时才用 Predicate 写法。
还要提醒一点:责任链和策略模式最容易混淆。策略模式是"同一类事情,有多个不同做法,选其中一个执行";责任链模式是"同一件事,需要依次经过多道关卡检查"。判断场景时可以先问自己一句:当前这段代码,是只能走一个分支,还是一条链路需要全部通过?如果只能走一个分支,优先考虑策略;如果必须全部执行,优先考虑责任链。
5. 状态模式:状态流转类 if-else 的最终方案
第三种用 if-else 写得极其痛苦的地方,是带有状态流转的业务。最典型的代表就是订单流程。一个订单要经历待支付、已支付、已发货、已完成这些状态,每个状态下都有用户事件进来,事件来了之后要判断当前状态允不允许这个操作,允许的话还要把状态切到下一个节点。直接把状态流转写成 if-else 的话,代码像蜘蛛网一样混乱,状态模式就是专门来处理这类问题的。
5.1 订单状态机里到处是 if
假设现在有这样一个方法,处理用户支付回调:
java复制public void onPaymentCallback(Order order, PaymentResult result) {
if (order.getStatus() == OrderStatus.WAIT_PAY) {
if (result.isSuccess()) {
order.setStatus(OrderStatus.PAID);
// 触发已支付后续逻辑
afterPaid(order);
} else {
// 支付失败,保持待支付,记录失败原因
order.setPayFailReason(result.getReason());
}
} else if (order.getStatus() == OrderStatus.PAID) {
// 重复支付回调,直接忽略或做幂等处理
log.info("duplicate pay callback");
} else {
throw new IllegalStateException("unexpected status");
}
}
目前看起来还只是两层 if。如果你再加一个"用户申请退款"的事件进来,退款在不同状态下处理逻辑完全不一样:待支付状态不能申请退款;已支付未发货要拦截并自动退款;已发货状态要进入退款审核流程;已完成状态退款要特殊处理。再加上管理员"发货"操作、用户"确认收货"操作、超时"自动关闭"操作,事件类型一多,每个方法里都是满屏的状态判断,代码的复杂程度直接以乘法级别增长。
这种代码最要命的地方是非法流转很难被系统拦截。比如已发货状态下又收到一个支付回调,虽然代码里可以写一个 else 去处理,但如果写漏了,运行时就会有状态错乱的问题。条件越多,漏判的可能性越大,排查起来就越费劲。
5.2 状态类实现的状态流转
状态模式的思路是把每个状态封装成独立对象,状态对象内部负责处理到达该状态的事件,并且知道自己应该流转到哪个状态。接口定义通常是:
java复制public interface OrderState {
void handlePaymentCallback(OrderContext context, PaymentResult result);
void handleRefundRequest(OrderContext context);
void handleShipping(OrderContext context);
}
OrderContext 是订单状态机的上下文对象,持有当前 OrderStatus 和当前的状态对象。用状态模式重构后,待支付状态对应的类是:
java复制public class WaitPayState implements OrderState {
@Override
public void handlePaymentCallback(OrderContext context, PaymentResult result) {
if (result.isSuccess()) {
// 回调成功,切到已支付状态
context.setState(new PaidState());
context.setOrderStatus(OrderStatus.PAID);
afterPaid(context.getOrder());
} else {
context.setPayFailReason(result.getReason());
}
}
@Override
public void handleRefundRequest(OrderContext context) {
// 待支付状态,订单还没有真正成交,直接关闭或提示用户先支付
throw new IllegalStateException("订单未支付,不能申请退款");
}
@Override
public void handleShipping(OrderContext context) {
throw new IllegalStateException("订单未支付,不能发货");
}
}
已支付状态的类长这样:
java复制public class PaidState implements OrderState {
@Override
public void handlePaymentCallback(OrderContext context, PaymentResult result) {
// 重复支付回调,直接做幂等返回
log.info("duplicate pay callback");
}
@Override
public void handleRefundRequest(OrderContext context) {
if (context.getOrder().isShippable()) {
// 未发货,自动退款,流转到已关闭
context.doRefund();
context.setState(new ClosedState());
context.setOrderStatus(OrderStatus.CLOSED);
} else {
// 已发货需要转人工审核
context.setState(new RefundAuditState());
context.setOrderStatus(OrderStatus.REFUND_AUDIT);
}
}
@Override
public void handleShipping(OrderContext context) {
context.setState(new ShippingState());
context.setOrderStatus(OrderStatus.SHIPPING);
context.doShipping();
}
}
在整个重构前后,顶层调用方不需要再关心当前订单状态到底是什么,它只需要把事件消息丢给上下文:
java复制orderContext.handlePaymentCallback(paymentResult);
orderContext.handleRefundRequest();
orderContext.handleShipping();
方法内部会根据当前持有的状态对象自动路由到对应逻辑。非法流转的拦截被收到了各个状态类自己的方法里,而不是散落在多个 if 判断中。每个状态类只关注自己状态内能发生的事件,代码多起来了,但维护者只需要打开和当前订单状态对应的那个类,不必在几百行里找匹配的分支。真实订单场景往往还有更复杂的子状态,比如退款中、退款完成、已关闭、待评价,状态多起来后,靠纯手写状态类也会变得繁琐,这时可以考虑引入现成的状态机框架来辅助,但理解状态模式本身依旧是根基。
5.3 状态模式的适用范围:别把简单状态也做成类
状态模式用法强大,但它的适用边界也比前两个模式更严格。如果业务里只有两个状态,而且状态之间的流转路径少于三条,用 if-else 或者简单的枚举字段反而能少写很多代码。比如一个任务只有"排队中"和"已完成",这种场景完全没有必要引入状态模式,否则光类的数量就会翻好几倍,阅读路径也被强行拉长,收益很低。
我处理一个状态机类重构时的经验判断依据是:状态总数乘以事件总数的矩阵里,如果有超过 60% 的格子不是"什么都不做"或"直接抛异常",说明事件在不同状态下的行为差异足够大,状态模式才有价值。如果大量格子都是非法操作、直接忽略,那反而更适合用一张"状态 + 事件 -> 新状态"的流转表来表达,用一个映射表驱动,这样比类爆炸更直观。毕竟代码只是实现业务的手段,真正重要的是让逻辑尽量贴合业务的自然表达方式。
6. 设计模式不是免死金牌:什么时候留 if 更合理
说了这么多用设计模式干掉 if-else 的方法,其实我内心并不认为 if-else 是绝对的坏味道。下面这些场景下,if-else 反而可能是最优解。
6.1 分支稳定、规则简单时直接明写
如果分支只是基于某个枚举或者布尔字段做简单的数据返回,比如根据用户性别展示默认头像,这种规则很少变化,用 if 或 switch 其实比策略模式更容易读。把它硬改成策略模式,每次读代码反而要多跳转几个文件,团队维护成本更高,得不偿失。
再比如,某段代码只有一个 if,判断是否为空、是否同一个对象,这种基础设施层面的判断完全没有任何重构的必要。空指针判空写成一个策略类那才叫灾难,读到的人会以为你在搞"为了模式而模式"的样板工程。
6.2 如何判断"未来会不会变"
设计模式天然是为了"应对变化"存在的,但"未来会不会变"并不能精准预测。我的实践方法是看产品需求的历史演进速度:过去三个月该功能迭代过几次、出现过多少次规则的叠加或互斥,这比拍脑袋猜未来可靠得多。如果一个需求在过去两个迭代里改了四次,那它一定是高变化区域,值得用策略模式把规则独立封装。如果一个需求上线后一年都没动过,那么就算它现在有七八个 if-else,也不建议在这个时间点去大规模重构,因为重构本身是在消耗当前的确定性去换取未来可能出现的变化弹性,收益是不能立刻兑现的。
还有一点很重要:不要追求把"所有 if-else"一次性全部消灭。一个方法里能消灭五成以上的判断,让核心规则清晰可见,就已经算非常大的胜利。剩余的那些条件判断里,没准有相当一部分是数据兜底、防御性检查,它们和业务规则本质上是不同维度的事情。比如你先判断参数是否为空、再判断缓存是否命中、最后判断优惠策略走哪个实现,这三种 if 混杂在一起时,被策略拆掉的应当只是最后一种业务选择。前面那两种判断,留在原处反而是合理的。
当我在代码评审里看到一个"设计模式满汉全席"的提交时,往往点开文件列表心已经凉了一半:一个看起来简单到只需要一个 if 的功能,作者新建了 3 个接口和 8 个类进去。这种代码表面上是"优雅",细看全是职责划分过度,把本来顺序执行的代码强行切成一堆互相调用的类方法,给后续读代码的人造成很大的寻路负担。所以引入设计模式一定要和"当前问题严重程度"成正比,杀鸡不用牛刀,砍大树才需要电锯。
7. 平滑清理 if-else 的实战建议
前面讲了四种模式各自的适用场景和实现思路,但真到动手改造老代码的时候,会遇到很多和模式本身无关的麻烦。比如你重构到一半,发现旧的逻辑里隐藏着一个特别隐蔽的分支条件,直接替换导致行为不一致;比如团队正在并行开发,你重构的类别人也在改,冲突不断。这些坑我基本都踩过,在这里把能够降低风险的操作路径一并分享。
7.1 先补测试再用最小替换
任何重构的第一原则都是:先有测试保护,再动结构代码。如果目标方法已经有一些测试,先跑一遍确认当前基线是绿的;如果没有测试,重构之前一定得把这部分补齐。哪怕只是补几个最核心的主路径用例,也是极大的保障。
我自己的习惯是重构前先给原有方法画一张简单的分支矩阵表:有哪些输入类型、各自走哪几条分支、输出什么。然后照着这个矩阵表写测试,保证每个分支至少有一个用例覆盖。等结构改造完成之后,直接跑全量测试,不需要人工逐行对比新旧代码,测试结果就能告诉我们行为有没有发生漂移。
7.2 一次只动一条链路
不要指望在一个大版本里把一个满是 if-else 的老类一步到位全部重构成策略模式。对存在大量分支逻辑的长方法,每次只拆一条链路,比如本周只把"订单类型 NORMAL"对应的那段行为抽成 NormalSettleStrategy,其他分支继续保持原来的 if-else。每次改动后确保测试通过,再拆下一路。这种增量式改造虽然耗时会拉长,但风险被控制到了非常小,而且每个中间节点上系统都是可运行的,不会出现那种"改到一半,代码根本没法上线"的尴尬状态。
在分支比较多的情况下,增量替换还有一个额外好处:你能不断验证策略注册表的设计是否正确。每拆一个分支就经历一次"新增策略类 + 注册 + 删除旧分支"的完整循环,等拆完五六个分支,你自然会发现这个流程里哪些环节最容易被遗漏,比如忘记注册、Key 写错等等。
7.3 替换后的代码怎么守住
模式重构完了并不意味着战斗结束。真正难的是防止下一个人又写回新的 if-else。团队内部如果有代码评审,我会在评审清单里明确提示:新增的业务规则判断优先往策略区新增实现类,而不是在老的 service 里追加分支。同时不要忘了给策略注册表加一个启动时的自检逻辑,扫描所有枚举类型是否都有对应策略被装配,没有就直接启动失败,这样可以从机制上防止漏注册。
另外,可以顺手统计一下重构前后那个核心方法的行数和圈复杂度。我自己的一个真实案例是:一个支付价格计算方法重构前 320 行、圈复杂度约 17,算下来每个分支的单元测试要 mock 无数依赖;重构后主方法缩到 16 行,圈复杂度降到 3,新增强券需求时只新增了一个策略类,36 个测试用例全部跑通,没有一个原方法被改过。看到这些数字,比什么语言的夸奖都有说服力。
在实际编码中我还保留一个习惯:所有策略类命名尽量带上业务语义,比如 VipDiscountStrategy、GrouponSettleStrategy、BlacklistCheckHandler,而不是用策略 1、策略 2 这种抽象名称。人是靠语义记忆的,一个能直接从类名读出业务含义的类,即使数量再多,维护成本也远远低于一个充满魔法判断的老方法。代码最终是要给团队看的,而不只是让编译器通过——如果哪个同事调试一个 bug 要在十几个类之间反复横跳,只要每次跳转的目标角色足够清晰,他心里也是踏实的。
