29、设计模式与反模式:常见的设计模式误用案例

设计模式这东西,用好了是神器,用砸了就是灾难。我见过太多团队,学了几个模式就到处乱套,结果代码比不用模式还烂。今天咱们就聊聊那些常见的误用案例——说白了,就是告诉你哪些坑我踩过,你别再踩了。

反模式一:滥用单例,测试成噩梦

单例模式,可能是被误解最深的模式。很多人觉得「全局只有一个实例」很酷,于是日志、配置、数据库连接、缓存……全搞成单例。结果呢?

⚠️ 典型症状: 单元测试跑不动,一跑就报「实例已存在」;想 mock 某个依赖,发现根本插不进去。

我曾经接手过一个支付系统,里面有个 PaymentProcessor 类,被设计成单例。你猜怎么着?每次跑测试,都得先清空 JVM 里的单例缓存,否则上一个测试的 mock 对象会污染下一个测试。那段时间,我们团队光写测试清理代码就花了两天。

// ❌ 反模式:滥用单例
public class PaymentProcessor {
    private static PaymentProcessor instance;
    private PaymentGateway gateway;

    private PaymentProcessor() {
        // 硬编码依赖,无法替换
        this.gateway = new AlipayGateway();
    }

    public static synchronized PaymentProcessor getInstance() {
        if (instance == null) {
            instance = new PaymentProcessor();
        }
        return instance;
    }

    public void process(Order order) {
        gateway.charge(order.getAmount());
    }
}

// 测试时:想 mock gateway?没门!
// 除非改源码,否则只能连真实支付宝测试

为什么会这样?因为单例本质上是个全局状态。全局状态和单元测试天生八字不合。你想想看,测试要求每个用例独立、可重复,而全局状态让所有测试共享同一个实例,互相干扰。

💡 我的建议: 单例只适合两种情况:一是无状态的工具类(比如字符串工具),二是确实需要全局唯一的资源(比如硬件接口)。其他情况,优先考虑依赖注入。
// ✅ 正确做法:依赖注入 + 工厂模式
public class PaymentProcessor {
    private final PaymentGateway gateway;

    // 通过构造器注入,测试时可以传 mock
    public PaymentProcessor(PaymentGateway gateway) {
        this.gateway = gateway;
    }

    public void process(Order order) {
        gateway.charge(order.getAmount());
    }
}

// 测试代码
@Test
void testPayment() {
    PaymentGateway mockGateway = mock(PaymentGateway.class);
    PaymentProcessor processor = new PaymentProcessor(mockGateway);
    // 现在可以随意验证行为
}

反模式二:工厂泛滥,类爆炸

工厂模式本身没问题,但有些人恨不得给每个对象都配个工厂。我见过一个项目,总共 50 个业务类,却搞了 40 个工厂类。代码结构变成了「工厂的工厂的工厂」——三层抽象,最后连作者自己都找不到具体实现在哪。

📊 真实案例: 一个电商系统的订单模块,有普通订单、团购订单、秒杀订单、预售订单……每种订单都配了一个工厂。后来新增「拼团订单」,需要新建一个工厂类、一个产品类、修改工厂选择逻辑。改一处,牵动全局。
// ❌ 反模式:为每个产品建一个工厂
public class NormalOrderFactory {
    public Order createOrder() { return new NormalOrder(); }
}

public class GroupOrderFactory {
    public Order createOrder() { return new GroupOrder(); }
}

public class FlashOrderFactory {
    public Order createOrder() { return new FlashOrder(); }
}
// ... 每新增一种订单,就多一个工厂类

说白了,工厂模式的核心目的是「封装创建逻辑」,不是「每个类都要有个工厂」。如果创建逻辑很简单(就是 new 一下),你搞个工厂纯属脱裤子放屁。

// ✅ 正确做法:用简单工厂 + 注册机制
public class OrderFactory {
    private static final Map<String, Supplier<Order>> registry = new HashMap<>();

    static {
        registry.put("normal", NormalOrder::new);
        registry.put("group", GroupOrder::new);
        registry.put("flash", FlashOrder::new);
    }

    public static Order createOrder(String type) {
        Supplier<Order> supplier = registry.get(type);
        if (supplier == null) {
            throw new IllegalArgumentException("Unknown order type: " + type);
        }
        return supplier.get();
    }
}

// 新增订单类型时,只需注册,无需新建工厂类
registry.put("premium", PremiumOrder::new);
💡 避坑指南: 我曾经在重构一个报表系统时,把 12 个工厂合并成 1 个,代码量从 800 行降到 200 行。判断标准很简单:如果工厂里只有一行 return new XXX(),那这个工厂就是多余的。

反模式三:策略模式变成 if-else 豪华版

策略模式的本意是「把算法抽出来,让它们可以互相替换」。但很多人用着用着,就变成了「用策略模式包装 if-else」——每个策略类里就一行代码,然后客户端写个长长的 switch 来选策略。

// ❌ 反模式:策略类过于细粒度
public interface DiscountStrategy {
    double calculate(double price);
}

public class NoDiscount implements DiscountStrategy {
    public double calculate(double price) { return price; }
}

public class TenPercentDiscount implements DiscountStrategy {
    public double calculate(double price) { return price * 0.9; }
}

public class TwentyPercentDiscount implements DiscountStrategy {
    public double calculate(double price) { return price * 0.8; }
}
// ... 每个折扣率一个类,共 20 个

// 客户端:还是得写 switch
switch (discountType) {
    case "NONE": strategy = new NoDiscount(); break;
    case "10%": strategy = new TenPercentDiscount(); break;
    // ...
}

你想想看,这跟直接写 if-else 有什么区别?代码量反而更多了。策略模式应该用在「算法逻辑复杂」或「运行时需要动态切换」的场景。如果只是参数不同,用函数式接口或枚举就够了。

// ✅ 正确做法:用枚举 + 函数式接口
public enum DiscountType {
    NONE(price -> price),
    TEN_PERCENT(price -> price * 0.9),
    TWENTY_PERCENT(price -> price * 0.8);

    private final UnaryOperator<Double> calculator;

    DiscountType(UnaryOperator<Double> calculator) {
        this.calculator = calculator;
    }

    public double apply(double price) {
        return calculator.apply(price);
    }
}

// 使用:直接传枚举,无需创建策略对象
double finalPrice = DiscountType.TEN_PERCENT.apply(100.0);

反模式四:观察者模式导致内存泄漏

观察者模式在事件驱动系统里很常见,但有个经典问题:观察者注册了却不注销。我调试过一个线上故障,用户打开页面后,每次操作都会触发一个事件,而事件监听器没有被正确移除。结果呢?内存里的观察者列表越积越多,最后 OOM 了。

⚠️ 关键点: 使用观察者模式时,一定要成对出现 register/unregister。特别是在 Android 或 Web 前端中,组件销毁时必须移除监听器。
// ❌ 反模式:只注册不注销
public class EventBus {
    private List<Listener> listeners = new ArrayList<>();

    public void register(Listener listener) {
        listeners.add(listener);  // 从不检查重复,也不提供移除方法
    }

    public void fire(Event event) {
        listeners.forEach(l -> l.onEvent(event));
    }
}

// 客户端:每次打开页面都注册,关闭页面不注销
// 结果:listeners 无限增长
// ✅ 正确做法:使用弱引用或显式注销
public class SafeEventBus {
    private List<WeakReference<Listener>> listeners = new ArrayList<>();

    public void register(Listener listener) {
        listeners.add(new WeakReference<>(listener));
    }

    public void fire(Event event) {
        listeners.removeIf(ref -> ref.get() == null);  // 清理已回收的引用
        listeners.forEach(ref -> {
            Listener l = ref.get();
            if (l != null) l.onEvent(event);
        });
    }
}

知识体系总览

下面这张图,是我总结的设计模式误用全景。你可以对照着检查自己的项目:

设计模式误用全景图 ❌ 反模式(常见误用) 滥用单例 → 测试困难、全局状态污染 滥用工厂 → 类爆炸、过度抽象 策略模式变 if-else 豪华版 观察者不注销 → 内存泄漏 重构 ✅ 正确实践 依赖注入 + 无状态设计 简单工厂 + 注册机制 枚举 + 函数式接口 弱引用 / 显式生命周期管理 核心原则:模式服务于代码,不是代码服务于模式

总结:怎么避免掉进反模式?

我个人的经验是三条:

  1. 先有坏味道,再谈模式。 别为了用模式而用模式。代码出现重复、难以测试、难以扩展时,才考虑用模式解决。
  2. 模式是手段,不是目的。 如果用了模式后代码更复杂了,那一定是哪里搞错了。好的模式应用应该让代码更简单。
  3. 多写测试。 测试是反模式的照妖镜。单例好不好测?工厂有没有必要?跑一遍测试就知道了。

我记得有一次 code review,看到同事给一个只有两个字段的 DTO 写了工厂模式。我问他为什么,他说「为了以后扩展」。我说:「那你现在扩展了吗?」他说没有。我说:「那就先别加。等真正需要的时候,再重构也来得及。」——这就是 YAGNI 原则(你不会需要它)。

设计模式本身没有错,错的是滥用。保持简单,保持可测试,比套用任何模式都重要。


公众号:蓝海资料掘金营,微信deep3321