Skip to content

SOLID 与设计原则:从坏味道说起

本文是设计模式系统学习系列的 L1 入门篇。前置:无。 学完可以配合面试题食用:无(本模块首篇)。

先看一段让你难受的代码

假设你接手一个订单处理模块,打开 OrderService 发现一个方法长了 150 行:

java
public class OrderService {
    public void createOrder(OrderRequest req) {
        // 1. 校验参数
        if (req.getUserId() == null || req.getUserId().isEmpty()) {
            throw new IllegalArgumentException("用户不能为空");
        }
        if (req.getItems() == null || req.getItems().isEmpty()) {
            throw new IllegalArgumentException("订单项不能为空");
        }

        // 2. 计算价格
        double total = 0;
        for (OrderItem item : req.getItems()) {
            double price = getProductPrice(item.getProductId());
            if (item.getQuantity() > 10) {
                price *= 0.9; // 批量折扣
            }
            total += price * item.getQuantity();
        }

        // 3. 落库
        Order order = new Order();
        order.setUserId(req.getUserId());
        order.setTotal(total);
        order.setStatus("CREATED");
        order.setCreateTime(new Date());
        orderDao.insert(order);

        // 4. 发通知
        sendEmail(req.getUserId(), "订单已创建,金额:" + total);
        sendSms(req.getUserId(), "您的订单已提交");

        // 5. 更新库存
        for (OrderItem item : req.getItems()) {
            inventoryDao.reduce(item.getProductId(), item.getQuantity());
        }
    }
}

这段代码能跑,但你能一口气说出几个问题?参数校验、计价、持久化、通知、库存扣减全塞在一个方法里。这就像把厨房的洗菜、切菜、炒菜、洗碗全堆在同一个台面上——不是不能吃饭,但一个人改煤气灶的时候,整个厨房都停摆。

这就是"坏味道"(code smell),它不直接让你出错,但会慢慢透支你的可维护性。

单一职责:一个类只因为一个原因被修改

SRP 是五个原则里最基础也最容易被误解的一条。职责的划分依据不是"代码行数",而是"变化的原因"

上面那段 OrderService 违反了 SRP,因为以下场景都会迫使你修改它:

  • 积分规则变了(计价改动)
  • 通知渠道从短信换成推送(通知改动)
  • 库存扣减从实时改为异步(库存改动)

反例:有人把 300 行拆成 3 个 100 行的类,但每个类里还是混杂着订单和支付逻辑——这叫"拆文件",不叫 SRP。

正解:按"需求变更的方向"划分。计价变化是一类原因,通知变化是另一类原因,它们就该分到不同类里。

开闭原则:对扩展开放,对修改关闭

OCP 的核心思路是:加新功能时尽量不碰已有的代码。手段是抽象+多态

回到订单的例子,如果现在要加一种"满减"促销,在原代码里只能在 total 计算处再加一个 if

java
if (hasCoupon) { total -= couponAmount; }

这个 if 每加一种促销就膨胀一次,最终变成二三十个 if-else 的嵌套地狱。违反 OCP 的信号就是 "if-else 越滚越大"

OCP 的做法是把计价抽象成接口:

java
public interface PricingStrategy {
    double calculate(List<OrderItem> items);
}

新增促销就写一个新实现,不改 OrderService 本身。工厂或配置类帮你把新策略注册进去。

里氏替换:子类不能收窄父类的契约

LSP 最经典的反例是 Square extends Rectangle

java
class Rectangle {
    protected int w, h;
    public void setWidth(int w) { this.w = w; }
    public void setHeight(int h) { this.h = h; }
}

class Square extends Rectangle {
    @Override
    public void setWidth(int w) { this.w = this.h = w; }
}

看起来合理?正方形是长方形的特例。但调用方如果写了 rectangle.setWidth(5); rectangle.setHeight(4);,期望宽 5 高 4,结果正方形给它变成了 4x4。这就是"子类收窄了父类的契约"——父类说宽高独立,子类说不。

LSP 的要点:继承不是"is-a"的语法,而是"行为可替换"的契约。子类可以扩展父类的行为,但不能削弱它。如果 SquareRectangle 的行为契约不一致,就别用继承,改成组合或者拉平为独立类。

接口隔离与依赖倒置

ISP:胖接口是接口的坏味道。一个接口里有 10 个方法,但实现类只用到其中 2 个,另外 8 个只能抛 UnsupportedOperationException 或者空实现。这强迫实现类依赖它不需要的东西。应该拆成多个小接口,每个接口只服务一个客户端。

DIP:高层模块不应该依赖低层模块,两者都应该依赖抽象。OrderService 直接 new 了一个 OrderDao 实例,就是依赖了具体实现。改成通过接口注入,你才能在测试时换成 mock,在生产时切换实现。

两者共同指向一个实践:面向接口编程。依赖注入容器(Spring 的 IoC)就是 DIP 的工业化落地。

组合优于继承 + 迪米特法则

Java 标准库的演进可以说明趋势:早期 AWT 大量用继承,后来的 Swing 和 JavaFX 转向组合。Properties extends Hashtable 是经典继承败笔——Properties 只需要键值对,但继承了 Hashtable 的 put 方法,非字符串的 key 也能塞进去,破坏了 Properties 的约束。

组合优于继承:继承暴露了父类的所有实现细节(白箱复用),组合只暴露接口(黑箱复用)。除非你确定"子类是父类的一个特化版本且行为一致",否则优先用组合。

迪米特法则:只和你的直接朋友通信。如果 orderService 需要知道 order.getItems().get(0).getPrice() 的细节,就说明耦合过深了——应该让 order 提供 getTotal() 这样的方法,不让调用方遍历内部结构。

原则之间的冲突:工程判断优先于教条

原则不是绝对的。SRP 拆得太细会导致类爆炸——一个促销模块拆成 15 个接口、15 个实现、5 个配置类,结果改个字段要跨 5 个文件。YAGNI 说"你现在不需要这么灵活"。

原则之间会互相制衡:SRP 让你拆,迪米特让你不要暴露太多,但拆坏了又违反"高内聚"。真正的工程判断是在"过度拆分"和"过度耦合"之间找平衡。

一个参考:两次重复才抽象,三次才上正式模式。第一次重复可以直接复制,第二次抽成方法,第三次才考虑策略模式/工厂模式这一级。

动手实操:坏味道 → 重构后

下面是上面那段 OrderService 按 SRP、OCP、DIP 重构后的结构(只展示骨架,不贴完整 300 行):

java
// 职责分离
public class OrderValidator {
    public void validate(OrderRequest req) { /* 校验逻辑 */ }
}

public class PricingService {
    private List<PricingStrategy> strategies;  // OCP:可扩展策略列表
    public double calculate(List<OrderItem> items) { /* 累计各策略 */ }
}

public class InventoryService {
    public void reduce(List<OrderItem> items) { /* 库存扣减 */ }
}

public class NotificationService {
    private List<Notifier> notifiers;  // 通知渠道可扩展
    public void notify(String userId, String msg) { /* 遍历推送 */ }
}

// 依赖注入,而非自己 new
public class OrderService {
    private final OrderValidator validator;
    private final PricingService pricingService;
    private final InventoryService inventoryService;
    private final NotificationService notificationService;

    // 通过构造函数注入,可测试、可替换
    public OrderService(OrderValidator v, PricingService p,
                        InventoryService i, NotificationService n) {
        this.validator = v; this.pricingService = p;
        this.inventoryService = i; this.notificationService = n;
    }

    public void createOrder(OrderRequest req) {
        validator.validate(req);
        double total = pricingService.calculate(req.getItems());
        Order order = new Order(req.getUserId(), total, "CREATED");
        orderDao.insert(order);  // 也可以进一步抽象
        inventoryService.reduce(req.getItems());
        notificationService.notify(req.getUserId(), "订单已创建");
    }
}

重构后的 createOrder 只有 7 行,每行调用一个职责清晰的组件。加新促销、换通知渠道、改库存策略,都不动 OrderService 本身。

常见误区与小结

  • 误区:一个方法 100 行就觉得违反 SRP。SRP 是"变化原因",不是"行数"。一个 200 行的纯数学计算函数如果只有一个原因会变,它就没违反 SRP。行数只是参考指标。
  • 误区:加了接口就实现了 OCP。给一个类加接口只是结构的准备,真正的 OCP 是要让扩展不动已有代码。如果每次加功能都要改工厂类的 if-else,那只是把 if-else 从业务代码搬到了创建代码。
  • 误区:LSP 就是"不要重写父类方法"。LSP 允许重写,只要不削弱父类的前置条件和后置条件。用 @Override 重写但保持行为契约一致,是正常的。
  • 误区:原则越多越好。五个原则在同一个场景里会互相拉扯。比如为了 DIP 抽了一堆接口,违反了 ISP(接口太胖)或者 YAGNI(过度设计)。工程上只解决当前最痛的问题,别为未来一年可能不会出现的场景上原则。
  • 误区:组合一定优于继承。组合优于继承是个"倾向性"建议,不是绝对规则。当子类确实能完整继承父类的行为且无需覆盖时(比如 HashMap 总是继承 AbstractMap 的通用结构),继承代码更少、更清晰。

小结:SOLID 原则不是设计模式,而是设计模式的"为什么"。工厂模式是在实现 OCP,策略模式是在实现 SRP 和 DIP,代理模式是 ISP 的体现。搞清楚原则,你才能判断一个模式在这个场景里用对了没有。下一篇全景图会把 23 种模式按"解决问题"分类,给你一张选型地图。

参考

参考:Martin Fowler, Refactoring: Improving the Design of Existing Code 第 2 章(坏味道清单) 参考:Robert C. Martin, Agile Software Development, Principles, Patterns, and Practices 第 7-11 章(SOLID 原文) 参考:Joshua Bloch, Effective Java 第 3 版 Item 1-4(静态工厂方法、Builder、单例、私有构造)

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。
粤ICP备2026104257号-1