Java 代码优化:从 if-else 地狱到策略模式的重构

fanrenxiuxian
fanrenxiuxian 正式会员正式会员认证极客认证极客
发布于 2026-10-09 03:28 ·1 浏览 ·2 回复

照着做完,你就能把一坨几百行、改一个分支要翻半天的心跳式 if-else,拆成「加一个类就多一种规则」的可维护结构,而且改完还能保证行为完全一致。

第一步:先承认它确实是 if-else 地狱

典型现场长这样:

public BigDecimal calc(String type, BigDecimal amount) {
    if ("NORMAL".equals(type)) {
        return amount;
    } else if ("VIP".equals(type)) {
        return amount.multiply(new BigDecimal("0.9"));
    } else if ("SVIP".equals(type)) {
        return amount.multiply(new BigDecimal("0.8"));
    } else if ("COUPON".equals(type)) {
        return amount.subtract(new BigDecimal("10"));
    } else {
        throw new IllegalArgumentException("未知类型: " + type);
    }
}

判断标准很简单:分支数 ≥ 3,且半年内还在加,就该重构了。如果只有两个分支且永远不变,别动它——过度设计比 if-else 更贵。

第二步:抽出策略接口

先想清楚两件事:输入是什么、输出是什么。上面的例子输入是金额(其实 type 已经被拆出去了),输出是折后金额。

public interface PriceStrategy {
    /** 该策略负责的类型,必须全局唯一 */
    String type();
    /** 计算折后价 */
    BigDecimal calc(BigDecimal amount);
}

注意:接口里只放「变的部分」。如果所有实现都要校验金额非负,把它写在调用方或模板方法里,别复制到每个实现类。

第三步:一个分支一个类

把原代码的每个 else if 体原样搬过来,逻辑一个字都别改——重构和行为修改绝不能在同一提交里做。

public class NormalPriceStrategy implements PriceStrategy {
    @Override public String type() { return "NORMAL"; }
    @Override public BigDecimal calc(BigDecimal amount) { return amount; }
}

public class VipPriceStrategy implements PriceStrategy {
    private static final BigDecimal RATE = new BigDecimal("0.9");
    @Override public String type() { return "VIP"; }
    @Override public BigDecimal calc(BigDecimal amount) {
        return amount.multiply(RATE);
    }
}

SVIP、COUPON 同理。此时每个类都短到一眼能看完,写单元测试也只需要 new 一个对象。

第四步:用 Map 建注册表,干掉 if-else

分支判断的本质是「按 key 找处理逻辑」,那用一个 Map 就够了。

public class PriceStrategyFactory {

    private final Map<String, PriceStrategy> map = new ConcurrentHashMap<>();

    public PriceStrategyFactory(List<PriceStrategy> strategies) {
        for (PriceStrategy s : strategies) {
            PriceStrategy old = map.put(s.type(), s);
            if (old != null) {
                throw new IllegalStateException("重复的策略 type: " + s.type());
            }
        }
    }

    public PriceStrategy get(String type) {
        PriceStrategy s = map.get(type);
        if (s == null) {
            throw new IllegalArgumentException("未知类型: " + type);
        }
        return s;
    }
}

调用处只剩一行:

return factory.get(type).calc(amount);

注意:注册时主动检查重复 key。用 Map 最大的风险就是两个类返回了同一个 type 字符串,静默覆盖后线上行为诡异,宁可启动就报错。

第五步:接 Spring 时让它自动收集

Spring 容器里,List<PriceStrategy> 会自动注入所有实现类,不用手写注册代码:

@Component
public class PriceStrategyFactory {
    private final Map<String, PriceStrategy> map = new ConcurrentHashMap<>();

    public PriceStrategyFactory(List<PriceStrategy> strategies) {
        strategies.forEach(s -> map.put(s.type(), s));
    }
    // get 方法同上
}

不想用 Spring 的,就在工厂里手动 register(new VipPriceStrategy()),或者用枚举 + 抽象方法(分支极少时更轻)。

注意:如果某个策略需要事务、要发 MQ,务必让 Spring 管理它,别自己 new,否则 @Transactional 会静默失效——这是最常见的坑。

第六步:验证改造没改变行为

改造前先给旧方法补上测试,跑绿;改造后再跑一遍,两次都绿才算安全。

@ParameterizedTest
@CsvSource({"NORMAL,100,100", "VIP,100,90", "SVIP,100,80", "COUPON,100,90"})
void testCalc(String type, String in, String out) {
    assertEquals(new BigDecimal(out),
        factory.get(type).calc(new BigDecimal(in)));
}

COUPON 减 10 的逻辑、金额边界(0、负数、超大值)都要覆盖到,别只测 happy path。

小结

  • 判断是否值得重构:分支 ≥ 3 且还会增长;只有两个固定分支就别动。
  • 顺序:补测试 → 抽接口 → 原样搬实现类 → Map 注册 → 替换调用点。
  • type() 返回的 key 要唯一,注册时检测重复,宁可启动失败。
  • 接 Spring 用 List<接口> 自动注入,注意别绕过容器 new 对象,否则事务失效。
  • 最大收益不是代码好看,而是新增一种规则不用改老代码——这才是开闭原则的落地。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-757.html
转载请注明出处,版权归原作者所有。

全部回复 2

runyu
runyu 正式会员正式会员认证极客认证极客 1楼 2026-10-09 03:38

思路完全正确,唯一缺的是「第三步之后的那一环」——策略注册表。没有它,调用方还是得写 if-else 去挑策略,等于把地狱从方法里搬到了工厂里。

Spring 环境下不用手写工厂,直接让容器帮你收:

@Service
public class PriceService {
    private final Map<String, PriceStrategy> map;

    public PriceService(List<PriceStrategy> strategies) {
        this.map = strategies.stream()
            .collect(Collectors.toMap(PriceStrategy::type, s -> s));
    }

    public BigDecimal calc(String type, BigDecimal amount) {
        PriceStrategy s = map.get(type);
        if (s == null) throw new IllegalArgumentException("未知类型: " + type);
        return s.calc(amount);
    }
}

几个要点:

type 别用字符串。type() 返回 "VIP" 这种裸字符串,改个名得全局搜。换成枚举 PriceType,编译期就能挡住拼写错误,toMap 遇到重复 key 会直接抛异常——这是好事,能在启动时就暴露两个人抢了同一个类型。

策略必须无状态。上面 VipPriceStrategy 用 static final 常量是对的,别在里面存成员变量,否则单例 bean 并发下会串数据。

校验放调用方。你帖子里提的这点很关键,金额非负、精度 scale 这类公共校验,要么在 PriceService.calc 入口做一次,要么抽个抽象基类做模板方法,别散到四个实现里。

验证重构没改行为。写一个参数化测试,把老 if-else 原样留在一个测试专用的私有方法里,用同一批入参(含边界:0、负数、超大金额)跑新旧两条路径对比结果。跑绿了再删老代码。

顺带说一句,分支只有两三个且明确不会增长的场景,真心别上策略模式——一个 Map、一个枚举、甚至一个 switch 都够。判断标准你自己已经写清楚了:分支 ≥ 3 且还在加。

shandian
shandian 见习用户见习用户 #576 2楼 2026-10-09 03:44
runyu:思路完全正确,唯一缺的是「第三步之后的那一环」——策略注册表。没有它,调用方还是得写 if-else 去挑策略,等于把地狱从方法里搬到了工厂里。 Spring…

注册表这一环补得对,我只在几个细节上加点料。

Collectors.toMap 撞 key 抛的是 IllegalStateException,消息里只有重复的那个 key,不告诉你是谁跟谁抢了。启动报这个错时还得回去一个个翻实现类。改成手动收集更划算:forEach 塞进 map,冲突时把两个实现类的 getName() 一起丢进异常消息;顺便在里面加 Objects.requireNonNull(s.type()),避免某个实现手滑返回 null 直接 NPE。

Spring 还有条捷径:直接注入 Map<String, PriceStrategy>,key 就是 bean name。不推荐——bean name 会随着重命名、@Component("xxx") 漂移,而且谁和谁重名要到运行时才发现。显式 type() + 启动即校验,这个方向是对的。

无状态那条再补一刀:策略真需要外部配置(折扣率来自数据库之类)时,也别写成成员变量,构造时注入不可变的配置对象,或者干脆当参数传进去。单例 bean 里只要有一个可变字段,就是并发事故的种子。

验证那步还想加个更狠的:单测跑绿只证明了你想到的用例没变。上线后可以并行跑新旧两条路径,结果不一致就记日志、只返回旧值,跑一周再切。真实流量的边界比参数化测试刁钻得多。