1. 先把 SOLID 这张地图摊开讲清楚
1.1 一次改需求改到凌晨的经历
我印象最深的一次加班,不是因为需求有多难,而是因为一个三百多行的OrderService类。产品要加一种新的会员折扣规则,我打开这个类,发现里面塞了参数校验、库存扣减、支付调用、短信通知、日志埋点、优惠计算六件事。改折扣逻辑的时候,手一抖碰到校验分支,隔天测试反馈"库存扣减偶尔会重复执行"。那一刻我才真正理解 SOLID 五大原则存在的意义——它不是面试背的概念,而是用来回答一个非常现实的问题:当需求变化时,我希望只改一处,而不是牵一发动全身。
SOLID 是五个面向对象设计原则的首字母缩写,最早由 Robert C. Martin 系统整理,后来 Michael Feathers 把五个首字母拼成了这个好记的词。它包含单一职责原则(SRP)、开闭原则(OCP)、里氏替换原则(LSP)、接口隔离原则(ISP)、依赖倒置原则(DIP)。这五个原则解决的不是"代码写得漂不漂亮",而是代码在时间维度上的抗腐蚀能力。写完就能跑的代码到处都是,能在两年里被十几个不同的人反复修改还不出事的代码,才是这套原则真正的战场。
这篇内容适合三类人:刚学完面向对象语法、感觉"会用类了但又好像没用对"的初学者;负责维护老项目、被改一处崩三处折磨过的中级开发;以及需要做代码评审、想给团队一套统一评判标准的技术负责人。我会尽量用图解式的拆解、反面案例和可复制的重构手法,把这五条讲成能直接上手用的东西,而不是五句正确的废话。
1.2 五个字母分别在管什么
先用一张文字化的结构图把全局摆出来,后面每一章再逐个展开:
需求变化 | +--------------+--------------+ | | | 变化的原因 扩展的方式 替换的安全性 | | | SRP OCP LSP (谁来负责) (怎么加功能) (能不能换掉) | | | +--------------+--------------+ | 接口与依赖的边界 | ISP DIP (接口多细) (指向谁)对应到每个原则的核心疑问和典型症状,可以看下面这张对照表:
| 原则 | 一句话核心 | 违反时的典型症状 | 主要收益 |
|---|---|---|---|
| SRP 单一职责 | 一个模块只有一个被修改的理由 | 一个类几百行,改A功能影响B功能 | 改动影响范围可控 |
| OCP 开闭原则 | 对扩展开放,对修改关闭 | 每加一种类型就要动老代码的 if-else | 新增功能不动老逻辑 |
| LSP 里氏替换 | 子类必须能替换父类而不破坏契约 | 子类重写方法后抛异常或改语义 | 多态调用可放心使用 |
| ISP 接口隔离 | 客户端不该依赖它用不到的方法 | 接口方法一大堆,实现类大量空实现 | 接口小而专,耦合低 |
| DIP 依赖倒置 | 高层和低层都依赖抽象 | 业务层直接 new 具体数据库实现 | 可替换、可测试 |
看这张表你会发现,五条原则其实是在回答同一个问题的五个侧面:如何把"变化"关进笼子里。SRP 管的是变化的归属,OCP 管的是变化的入口,LSP 管的是变化的兼容性,ISP 管的是变化的传播面,DIP 管的是变化的传导方向。理解了这条主线,后面所有的技巧都是它的注脚。
1.3 三个常见的误解先摆出来
在展开讲之前,有三个误解必须先纠正,否则后面越学越拧巴。
第一个误解是"SOLID 是必须全部满足的硬性标准"。实际不是。它是权衡工具,不是法律条文。在一个人维护的小脚本里强行套五条原则,最后得到的是一堆只被调用一次的接口和工厂类,复杂度比业务本身还高。我的一般经验是:代码的预期寿命越长、修改它的人越多,SOLID 的收益才越明显。
第二个误解是"遵循 SOLID 就意味着类会变得很多、文件会变得很碎"。类变多是表象,不是目的。目的是让每个变化点有独立的落脚处。如果拆完之后你发现要跳五六个文件才能看懂一次下单流程,那说明拆分维度选错了——大概率是按"代码行数"拆而不是按"变化原因"拆。
第三个误解是把这五条当成孤立的技巧背。它们之间有强联动:DIP 用得好,OCP 自然容易实现;ISP 做得好,LSP 的违反概率会明显下降;而 SRP 是所有原则的地基。后面我会在综合案例里把这种联动关系具体演示一遍。
2. 单一职责原则(SRP):先搞清楚"职责"到底指什么
2.1 职责不是功能数量,而是变化的来源
很多人第一次看 SRP 的原始定义"一个类应该只有一个引起它变化的原因",会觉得这句话说了等于没说。因为在直觉里,"功能"和"变化原因"是两码事。我举个具体的例子你就明白了:一个UserService里有register()和sendWelcomeEmail()两个方法,看起来是两个功能。但如果它们的变化来源不同——注册逻辑跟着业务规则变,邮件模板跟着市场和运营变——那这个类就有两个职责,应该拆开。
判断标准不是"这个类有几个方法",而是问一句:哪一类人、因为什么原因,会要求我修改这段代码。财务改税率、运营改文案、运维改日志格式、安全团队改加密算法,这些不同的诉求如果都指向同一个类,那这个类就是职责过载的。功能多但变化来源单一,其实不算违反 SRP;功能少但变化来源有三四个,照样违反。
我在实际项目里用过一个很土但很准的自检方法:把类的每个方法列出来,在旁边标注"谁最可能要求改这个方法"。如果标注出来的角色超过两个,基本可以确定要拆。这个方法不需要任何工具,白板上三五分钟就能做一次,比事后读几百行代码判断快得多。
2.2 现场拆解:一个典型的"上帝类"
看一个被我简化过的真实案例。这是改造前的版本,用 Java 写:
public class OrderService { public void placeOrder(Order order) { // 1. 参数校验 if (order.getItems() == null || order.getItems().isEmpty()) { throw new IllegalArgumentException("订单不能为空"); } if (order.getAmount() <= 0) { throw new IllegalArgumentException("金额不合法"); } // 2. 库存扣减 for (Item item : order.getItems()) { jdbcTemplate.update("update stock set qty = qty - ? where sku = ?", item.getQty(), item.getSku()); } // 3. 调用支付 String result = httpClient.post("https://pay.internal/charge", toJson(order)); if (!"SUCCESS".equals(parse(result).get("status"))) { throw new RuntimeException("支付失败"); } // 4. 发通知 smsClient.send(order.getUserPhone(), "您的订单已支付成功"); // 5. 记录日志 logger.info("order placed: {}", order.getId()); } }这个类的问题表面看是"太长",实质是它同时暴露给了四类变化:业务规则的变化(校验条件)、库存策略的变化(扣减方式)、支付渠道的变化(接口协议)、通知方式的变化(短信改推送)。产品说"支持货到付款"要改它,运维说"支付网关换了"要改它,运营说"短信成本太高改成站内信"还要改它。每次改动都在同一个文件里叠加,冲突和回归风险自然指数级上升。
2.3 拆分粒度怎么定:三个自检问题
拆的方向通常是按变化来源切分,落到代码上就是抽出协作对象。改造后的形态大致是这样:
public class OrderService { private final OrderValidator validator; private final InventoryService inventory; private final PaymentGateway payment; private final Notifier notifier; public OrderService(OrderValidator validator, InventoryService inventory, PaymentGateway payment, Notifier notifier) { this.validator = validator; this.inventory = inventory; this.payment = payment; this.notifier = notifier; } public void placeOrder(Order order) { validator.validate(order); inventory.deduct(order.getItems()); payment.charge(order); notifier.notifyPaid(order); } }改造之后,OrderService的职责收敛成一句话:编排下单流程。库存规则变了,只动InventoryService;支付渠道换了,只动PaymentGateway的实现类。这个类本身反而变得很稳定,一年可能都不需要改。
粒度控制上,我通常用三个问题来卡:
- 这个类里的方法,是否都在操作同一组核心数据或同一个业务概念?
- 如果把其中一部分拆出去,拆出去的部分是否自己就能形成一个完整的、可独立测试的单元?
- 拆完之后,调用方是不是需要频繁地在两个类之间来回传数据?如果是,说明拆错了维度。
第三条尤其重要。我见过有人把Order实体拆成OrderBase、OrderAmount、OrderAddress,结果每次算个总价要跨三个对象取字段,调用方苦不堪言。这不是 SRP,这是拆碎。
2.4 注意事项:别让 SRP 变成过度设计
提示:SRP 判断的是"变化的原因",不是"方法数量"。一个类有二十个方法,只要它们都服务于同一个业务概念、同一个变化来源,就不算违反。
第一个坑是"按代码行数拆分"。500 行不是罪,如果这 500 行讲的是同一件事,硬拆反而增加阅读成本。我的经验阈值是:当一个文件里出现两个以上"为什么这段代码会变"的答案时,才考虑拆。
第二个坑是"service 层无限膨胀"。很多团队把所有业务逻辑都堆在XxxService里,最后变成十几个上千行的类。真正的做法是让领域对象承担一部分行为,Service 只负责编排和事务边界。
第三个坑是拆完之后忘了收敛调用方。拆出十个类但调用方还是自己 new 一堆,等于把复杂度从一个文件转移到了十个文件。拆必须配合依赖注入一起做,这也是后面 DIP 那一章要讲的。
3. 开闭原则(OCP):让新增功能只加代码,不改老代码
3.1 "对扩展开放"到底开放的是什么
OCP 的完整表述是"软件实体应该对扩展开放,对修改关闭"。这话听起来很别扭——功能总是要加的,怎么可能不修改代码?关键在"修改"这个词的指向:OCP 要保护的是那些已经被大量调用、已经稳定运行的核心代码,而不是禁止一切代码变更。换句话说,新增一个业务能力时,理想状态是你写一个新的类、注册进去,老代码一行不动。
这里的"扩展点"就是抽象。用生活中的类比:插座是抽象,电器是扩展。你要加一台新设备,不需要拆开墙重铺电线,插上就行。如果每加一个电器都要重新装修,那就是典型的违反 OCP。
判断是否违反 OCP 有个非常实用的信号:老代码里出现了按类型分支的 if-else 或 switch,而且这个分支随着新需求在持续增长。每来一种新类型就加一个 case,这就是在逼迫你修改已经测试通过的代码。
3.2 从 if-else 到策略模式的演进现场
先看违反 OCP 的写法。假设做会员折扣:
public BigDecimal discount(User user, BigDecimal amount) { if (user.getLevel() == Level.NORMAL) { return amount; } else if (user.getLevel() == Level.SILVER) { return amount.multiply(new BigDecimal("0.95")); } else if (user.getLevel() == Level.GOLD) { return amount.multiply(new BigDecimal("0.88")); } else if (user.getLevel() == Level.DIAMOND) { return amount.multiply(new BigDecimal("0.8")); } throw new IllegalArgumentException("未知等级"); }加一个"黑卡"等级,就要回来改这个方法,同时要保证不碰坏前四个分支。测试同学也得把所有等级重跑一遍。这就是"对修改开放"的典型代价。
改造思路是抽出策略接口:
public interface DiscountPolicy { Level supportedLevel(); BigDecimal apply(BigDecimal amount); } public class GoldDiscount implements DiscountPolicy { public Level supportedLevel() { return Level.GOLD; } public BigDecimal apply(BigDecimal amount) { return amount.multiply(new BigDecimal("0.88")); } }然后维护一张映射表,用构造函数或静态块把实现注册进去:
public class DiscountCalculator { private final Map<Level, DiscountPolicy> policies = new EnumMap<>(Level.class); public DiscountCalculator(List<DiscountPolicy> list) { for (DiscountPolicy p : list) { policies.put(p.supportedLevel(), p); } } public BigDecimal calculate(Level level, BigDecimal amount) { DiscountPolicy policy = policies.get(level); if (policy == null) throw new IllegalArgumentException("未知等级: " + level); return policy.apply(amount); } }现在加"黑卡"只需要新增一个BlackCardDiscount类,Spring 之类的容器会自动把它注入到 list 里,DiscountCalculator完全不用改。老代码零改动,新功能零风险,这就是 OCP 的落地形态。
3.3 注册表与插件式设计:OCP 的进阶形态
策略模式是入门级 OCP,往上一层是注册表模式。当扩展点不止"按类型分发",而是需要动态加载、按优先级排序、甚至运行期热插拔时,注册表会更合适。核心思路是:扩展点用接口定义,实现类通过某种机制自我注册,核心流程只面向注册表编程。
一个典型的注册表长这样:
public class PolicyRegistry { private static final Map<String, DiscountPolicy> REGISTRY = new ConcurrentHashMap<>(); public static void register(String key, DiscountPolicy policy) { REGISTRY.put(key, policy); } public static DiscountPolicy get(String key) { DiscountPolicy p = REGISTRY.get(key); if (p == null) throw new IllegalStateException("策略未注册: " + key); return p; } }配合 SPI 机制或者配置驱动的加载器,可以在启动时扫描所有实现类并注册。这种设计在规则引擎、支付渠道对接、多协议网关这类场景非常常见。它的好处不只是 OCP,还顺带解决了 ISP 和 DIP 的问题——后面讲综合案例时会看到这一点。
3.4 注意事项:抽象不是免费的
注意:OCP 的价值在于"未来会变化的维度"。如果你非常确定某个分支永远不会再有新类型,用 if-else 反而更清晰。为不存在的扩展做抽象,是纯粹的负债。
第一个坑是"抽象层级选错"。比如业务变化的是"折扣算法",你却把抽象点设在"用户等级"上,结果新需求是"同一个等级在不同活动下折扣不同",抽象立刻失效。抽象要建在变化的那个维度上,而不是建在表面的分类上。
第二个坑是"过早抽象"。在只有两种类型时就抽出三层接口,读代码的人要在三个文件之间跳转才能看明白。我的经验是:同类型分支超过三个、或者你明确知道近期还会加,才值得抽。
第三个坑是"忘了兜底"。策略模式下如果新增类型没注册,运行期会直接空指针或抛异常。所以要么在启动时做一次完整性校验,要么提供默认策略,别把错误留到线上。
4. 里氏替换原则(LSP):子类能不能换掉父类
4.1 契约式设计:前置、后置与不变式
LSP 的正式定义是"子类型必须能够替换掉它们的基类型"。这句话真正约束的是契约,而不是语法。一个类继承另一个类,语法上通过了,行为上未必能替换。契约包含三部分:
- 前置条件:调用方法前必须满足的条件,子类不能加强它。父类说"参数允许负数",子类就不能改成"只允许正数"。
- 后置条件:方法返回后必须保证的状态,子类不能削弱它。父类承诺"返回非空列表",子类就不能返回 null。
- 不变式:对象在生命周期内必须始终为真的约束,子类不能破坏。比如"账户余额永不为负"。
只要子类在这些条款上打了折扣,多态调用就会出问题。而多态恰恰是面向对象最依赖的机制,所以 LSP 事实上是"多态能不能被信任"的前提。
4.2 经典案例:正方形与长方形
这个例子被讲烂了,但它确实精准。设Rectangle有setWidth和setHeight,并且有一个约定:面积等于宽乘高,且宽高互相独立。
class Rectangle { protected int width, height; public void setWidth(int w) { this.width = w; } public void setHeight(int h) { this.height = h; } public int area() { return width * height; } } class Square extends Rectangle { @Override public void setWidth(int w) { this.width = w; this.height = w; } @Override public void setHeight(int h) { this.width = h; this.height = h; } }看语法完全没问题。但下面这段代码会挂:
void resize(Rectangle r) { r.setWidth(5); r.setHeight(4); assert r.area() == 20; // 传 Square 进来,结果是 16 }问题的根源在于:几何上"正方形是长方形",但代码契约上并不成立,因为Rectangle的隐含约定是"宽高独立可变",而Square强制"宽高一致"。前者被削弱了,LSP 就被破坏了。
修复方式不是硬改子类,而是承认两者不是继承关系。可以用一个共同的抽象Shape,各自实现area():
interface Shape { int area(); } class Rectangle implements Shape { private final int w, h; Rectangle(int w, int h) { this.w = w; this.h = h; } public int area() { return w * h; } } class Square implements Shape { private final int side; Square(int s) { this.side = s; } public int area() { return side * side; } }不可变对象 + 各自独立实现,问题从根上消失。这个改造顺带也符合 SRP——每个类只对自己的形状规则负责。
4.3 更贴近业务的例子:账户与定期账户
来看一个我项目里真实踩过的坑。有Account基类,提供withdraw(amount)方法。后来加了FixedDepositAccount(定期账户),继承Account并重写withdraw:
class Account { protected BigDecimal balance; public void withdraw(BigDecimal amount) { if (amount.compareTo(balance) > 0) throw new InsufficientException(); balance = balance.subtract(amount); } } class FixedDepositAccount extends Account { @Override public void withdraw(BigDecimal amount) { throw new UnsupportedOperationException("定期账户不支持取现"); } }调用方写了一段通用逻辑遍历所有账户做提现,结果遇到定期账户直接炸了。这就是典型的 LSP 违反:父类承诺"所有账户都能取现",子类直接把这个能力砍掉了。正确的做法是不要在Account上定义withdraw,而是把它放到WithdrawableAccount接口上,定期账户不实现这个接口。这就是 LSP 和 ISP 的联动——很多 LSP 问题,根子都在接口设计太胖。
4.4 注意事项:继承是最强的耦合
提示:写子类时问自己三个问题——有没有加强前置条件?有没有削弱后置条件?有没有破坏不变式?任何一个答案是"有",就不要用继承。
第一个坑是"为复用而继承"。看到父类有方法能省几行代码,就继承过来,这是最常见的 LSP 违反来源。复用应该优先用组合,继承只用于"确实是同一种东西"的场景。
第二个坑是"重写方法抛异常"。父类能做的事,子类做不了,用抛异常来回避,本质是能力契约被破坏。碰到这种情况,先反思抽象层级是不是错了。
第三个坑是"用 instanceof 判断子类型"。如果你在多态调用里写了if (x instanceof Square),说明父类的抽象没覆盖真实需求,多态已经名存实亡。这是一个非常灵敏的代码坏味道信号。
5. 接口隔离原则(ISP):别让实现类为用不到的方法买单
5.1 胖接口的真实代价
ISP 说的是"客户端不应该被强迫依赖它不使用的方法"。这句话对两种人有害:一是实现类,被迫写大量空实现或抛异常的假方法;二是调用方,因为接口太大,改动一个方法可能影响到不相关的调用者。
最直观的例子是打印机。很多教材用Device接口演示:
interface Device { void print(String doc); void scan(String doc); void fax(String doc); }然后一台只能打印的低端设备要实现三个方法:
class SimplePrinter implements Device { public void print(String doc) { /* 真实实现 */ } public void scan(String doc) { throw new UnsupportedOperationException(); } public void fax(String doc) { throw new UnsupportedOperationException(); } }这两个抛异常的方法不是小问题。它们意味着调用方拿到一个Device引用后,并不能安全地调用scan,只能靠文档、靠约定、靠运行期试错。系统里的"不确定性"就是这样一点点累积起来的。
5.2 拆分思路:按"客户端角色"切,不按"实现类"切
拆接口最容易犯的错是"按实现类拆",那样拆出来的接口只对某一个实现有意义,换个实现又要重拆。正确的做法是按调用方的角色拆。谁在用这个接口,用它做什么,就把这部分能力单独抽出来。
以上面的设备为例,三种能力对应三种调用者:打印任务队列用Printer,扫描服务用Scanner,传真服务用Fax。
interface Printer { void print(String doc); } interface Scanner { void scan(String doc); } interface Fax { void fax(String doc); } class SimplePrinter implements Printer { public void print(String doc) { /* ... */ } } class AllInOneMachine implements Printer, Scanner, Fax { public void print(String doc) { /* ... */ } public void scan(String doc) { /* ... */ } public void fax(String doc) { /* ... */ } }这样低端设备只实现Printer,调用方也只依赖Printer,谁都不需要为不存在的能力操心。而且以后加"云打印"设备,只要实现Printer就能接入打印队列。
5.3 用默认方法会更省事吗
Java 8 之后有接口默认方法,有人会想:那我在胖接口里给scan一个默认实现,抛UnsupportedOperationException不就行了?我的看法是:能用,但只适合"绝大多数实现都支持、极少数不支持"的场景。如果一半以上的实现都不支持某个方法,那这个方法就不该待在这个接口里。默认方法只是省了写代码的力气,没有解决契约模糊的问题。
判断标准可以简化成一句话:如果一个接口的方法集合里,存在一种合理的实现方式只用其中一部分,那这个接口就该拆了。
5.4 注意事项:拆过头比不拆更麻烦
注意:ISP 的目标是"接口刚好覆盖调用者的需求",不是"一个方法一个接口"。后者会让系统里充满毫无意义的单方法接口,反而增加认知负担。
第一个坑是"接口碎片化"。我见过一个项目,UserReader、UserWriter、UserNameReader各是一个接口,最后类型声明长得像绕口令。合理的做法是按读写、按角色、按聚合粒度拆,而不是按方法拆。
第二个坑是"公共接口被当成万能入口"。很多团队会有一个CommonService或者BaseRepository,什么方法都往里塞。这个接口的每次变更都会影响所有实现类,是编译冲突的高发地带。碰到这种接口,我一般会主动提出拆分,收益非常明显。
第三个坑是忽略调用方视角。同样是数据库访问,后台管理页面需要分页、统计、导出;前台业务只需要按 ID 查询。这两个角色就应该看到不同的接口,哪怕底层是同一套实现。这属于"同一份实现,多个角色接口"的常规做法,用类实现多个接口就能做到。
6. 依赖倒置原则(DIP):高层不依赖低层,两者都依赖抽象
6.1 先把三个容易混的词分清
DIP、IoC、DI 这三个词经常被混着用,实际是三个层级的概念。
- DIP(依赖倒置原则):一种设计原则。高层模块不依赖低层模块,两者都依赖抽象;抽象不依赖细节,细节依赖抽象。它讲的是"依赖的方向"。
- IoC(控制反转):一种设计思想。把程序的控制权从代码内部交给外部框架或容器。它讲的是"谁说了算"。
- DI(依赖注入):IoC 的一种具体实现方式。通过构造函数、Setter 或字段把依赖传进来,而不是自己 new。它讲的是"依赖怎么进来"。
三者的关系是:DIP 是目标,IoC 是思路,DI 是最常用的手段。实际项目里,只要做到"业务类依赖接口、接口通过构造函数传入",基本就同时满足了 DIP 和 DI。
6.2 从直接 new 到依赖注入的改造
违反 DIP 的典型写法:
public class OrderService { private final MySQLOrderRepository repo = new MySQLOrderRepository(); private final AliyunSmsSender sms = new AliyunSmsSender(); public void place(Order order) { repo.save(order); sms.send(order.getPhone(), "下单成功"); } }这段代码的问题在测试时暴露得最明显:想跑一个单元测试,必须先起数据库、配短信密钥,否则类根本构造不出来。而且以后换数据库、换短信服务商,都要回来改这个类。
改造后:
public interface OrderRepository { void save(Order order); } public interface Notifier { void send(String phone, String msg); } public class OrderService { private final OrderRepository repo; private final Notifier notifier; public OrderService(OrderRepository repo, Notifier notifier) { this.repo = repo; this.notifier = notifier; } public void place(Order order) { repo.save(order); notifier.send(order.getPhone(), "下单成功"); } }现在测试时传一个内存实现就行,换数据库也不用动OrderService。依赖方向从"业务层指向具体实现"倒转成"业务层和具体实现都指向接口",这就是"倒置"两个字的由来。
6.3 手工注入和容器注入,什么时候用哪个
依赖注入不必上框架。小项目里手工组装就够了:
public class App { public static void main(String[] args) { OrderRepository repo = new MySQLOrderRepository(dataSource); Notifier notifier = new AliyunSmsSender(smsConfig); OrderService service = new OrderService(repo, notifier); service.place(new Order(/* ... */)); } }这种写法的好处是依赖关系一目了然,任何一个类要什么,看构造函数就知道。等对象图变复杂了,再上容器。我一般建议:依赖数量少于二三十个、生命周期简单时,手工装配;超过这个规模,再引入容器。提前上容器,调试时你会很难追踪某个对象到底是谁注入的。
容器注入的典型形态就是给实现类加上注解,让框架扫描并装配。注意容器只是工具,它不会自动帮你设计出好的抽象。我见过不少项目用着容器,业务类里照样new一堆东西,DIP 一点没落地。
6.4 注意事项:抽象该归属谁
提示:接口应该定义在"使用方"所在的模块里,而不是实现方。谁调用,谁定契约,这样依赖方向才是倒置的。
第一个坑是"接口跟着实现走"。把OrderRepository接口放在数据库模块里,结果业务模块依赖数据库模块,方向还是没倒过来。正确做法是把接口放在业务模块或独立的领域模块里。
第二个坑是"抽象泄漏具体细节"。接口方法签名里出现ResultSet、HttpServletRequest这类具体技术类型,等于换汤不换药。抽象层要描述业务语义,不是描述技术细节。
第三个坑是"构造函数参数过多"。一个类需要注入七八个依赖,通常说明它职责太重,该拆了。这是 SRP 和 DIP 的联动信号,我一般把五六个依赖当成一个需要警惕的阈值。
7. 五条原则放在一起:一个下单系统的改造实录
7.1 改造前的状态
用一个完整的小需求把五条串起来。需求是:下单后要扣库存、走支付、发通知;折扣规则随会员等级变化;支付渠道将来可能增加;通知方式将来可能增加。
改造前的代码就是第 2 章那个OrderService,一个类包办所有事,折扣用 if-else,支付和通知直接 new 具体实现。这个结构违反的情况是:SRP(职责过载)、OCP(加折扣要改老代码)、DIP(直接依赖具体实现)、LSP 和 ISP 因为还没有继承和接口,暂时不涉及。
7.2 分四步改造
第一步做 SRP 拆分。把校验、库存、支付、通知分别抽成独立组件,OrderService只保留流程编排。这一步做完,改动的影响范围从整体收敛到局部。
第二步做 OCP 扩展点。折扣抽取DiscountPolicy接口,支付抽取PaymentGateway接口,通知抽取Notifier接口,每种实现各自独立成类。以后加一种新折扣、新支付渠道,只写新类。
第三步做 DIP 注入。OrderService的构造函数接收接口,不再自己 new。这样业务层对具体实现零依赖,测试可以用假实现。
第四步检查 LSP 和 ISP。检查所有实现类是否都能替换接口而不破坏契约,比如PaymentGateway的charge方法,所有实现都必须真正完成扣款,不能有实现抛UnsupportedOperationException;检查接口是否足够小,Notifier只保留send,不要塞进"查发送记录""重发"这些只有部分实现需要的方法。
改造后的结构用文字图表示:
+-------------------+ | OrderService | 高层:只懂流程 +---------+---------+ | 依赖 +----------+----------+----------+-----------+ | | | | | OrderValidator Inventory PaymentGateway Notifier DiscountPolicy | | | | | 具体校验类 具体库存类 微信/支付宝 短信/推送 各等级折扣 (可扩展) (可扩展) (可扩展) ^ | 都实现同一组抽象7.3 改造前后对比
| 维度 | 改造前 | 改造后 |
|---|---|---|
| 新增折扣类型 | 修改老 if-else,全部回归 | 新增一个类,零改动 |
| 切换支付渠道 | 修改业务类 | 新增实现并注册 |
| 单元测试 | 需要数据库和外部服务 | 传假实现即可 |
| 单次改动影响范围 | 整个下单流程 | 单个组件 |
| 新人上手 | 需读几百行主线代码 | 按组件逐个理解 |
这张表不是用来炫耀"改造得多漂亮"的,它对应的是非常具体的成本:回归测试工作量、上线风险、排障时间。SOLID 的收益最终都会折算成这三样东西。
8. 常见问题与排查技巧实录
8.1 常见问题速查表
| 现象 | 可能的违反原则 | 排查动作 |
|---|---|---|
| 一个类超过 500 行且方法主题分散 | SRP | 列出每个方法的"变化来源",同源才留 |
| 每加一种类型就要改老代码 | OCP | 找出按类型分支的 if-else,抽策略 |
| 子类重写方法后抛异常 | LSP | 检查前置、后置条件是否被改变 |
| 实现类里大量空方法 | ISP | 按调用方角色拆接口 |
| 业务层直接 new 数据库对象 | DIP | 抽接口,构造函数注入 |
| 单测必须起数据库 | DIP | 同上,把外部依赖替换为内存实现 |
| 改一个功能连带改五六个文件 | SRP + DIP | 先查职责是否过载,再查依赖是否过紧 |
这张表的使用方式是:遇到具体症状再查,不要教条式地全量重构。我见过团队喊着"全面提升代码质量",一次性重构两万行,结果三个月没上线,最后回滚。原则是用来指导局部改造的,不是用来搞运动的。
8.2 踩过的坑和实际技巧
第一个坑是在错误的抽象层级上拆分。有一次我把"订单金额计算"拆成了五个类,结果每次算一次总价要跨五个对象,性能没提升,可读性还下降了。后来回退成两个类,只是把"优惠计算"单独抽出。教训是:拆分的粒度要跟"变化的频率"匹配,变化频繁的拆细一点,几乎不变的可以合并。
第二个坑是用接口包装但没有真正解耦。接口方法签名里带着具体框架的类型,或者接口只有一个实现且永远不会变,这种抽象是摆设。判断标准很简单:如果这个接口的存在不能让你在测试里替换掉真实依赖,那它的价值就很有限。
第三个坑是忽略了数据层面的一致性。把库存扣减和支付拆成两个组件之后,中间失败会导致状态不一致。这时候需要引入事务边界或者补偿机制。原则只解决结构问题,不自动解决一致性问题。这部分必须单独设计,别指望 SOLID 能包治百病。
再分享几个实用技巧。做代码评审时,我会重点看三处:构造函数参数超过五个的类、方法名里带 "And" 的方法、参数里带布尔开关的方法。这三处几乎必然藏着 SRP 违反。重构老代码时,我会先写测试再动结构,因为 SOLID 改造本质是行为不变的结构调整,没有测试兜底就是盲改。
最后说一个我自己的经验。刚工作那几年,我把 SOLID 当成"必须遵守的规定",写代码时总想着"这里符不符合 OCP",结果经常为了抽象而抽象,代码写得很绕。后来慢慢想明白,这五条原则真正的作用是在变化发生的时候帮你找到最小的改动面。所以判断该不该用某条原则,最实在的标准就是问一句:如果明天需求变了,我现在这个写法要改几个地方?如果一个地方就够,那这个设计对你当下的项目就是合适的,不用管它是否符合教科书上的哪一条。