1. 从一锅乱炖的老项目说起:SOLID 五大原则到底治什么病
SOLID 五大原则这套东西,我最早是在一本讲敏捷开发的书里看到的,当时觉得就是五个拗口的英文缩写,背下来应付面试用。真正让我对它改观,是接手了一个跑了六年多的订单系统。那个项目里有一个叫OrderManager的类,三千两百多行,方法从下单、算价、扣库存、生成对账单、发短信到对接财务接口全塞在里面。产品经理提一句“满减规则改一下”,我得先花半天时间把整个类的调用链摸清楚,改完之后还得让测试同学把支付、退款、开票全跑一遍,因为没人敢保证这一刀切下去会不会溅到别处。
那种“改一处崩三处”的疼,就是 SOLID 五大原则要解决的病。它不是什么高深的算法,也不是某个框架的专属技巧,而是一套关于代码该怎么分工、怎么留口子、怎么互相调用的工程约定。这五条原则分别是单一职责原则(Single Responsibility Principle)、开闭原则(Open-Closed Principle)、里氏替换原则(Liskov Substitution Principle)、接口隔离原则(Interface Segregation Principle)和依赖倒置原则(Dependency Inversion Principle),把首字母拎出来就是 SOLID。
这套内容适合谁看?我自己的判断是三类人。第一类是写了半年到三年代码、能跑通业务但一改需求就心慌的同学,SOLID 能帮你把“能跑”升级成“好改”。第二类是要做代码评审、要带新人的技术负责人,你需要一套能说得出口、能落地打分的话术,而不是一句“这代码写得不行”拍过去。第三类是准备面试的,SOLID 五大原则几乎是必考题,但面试官真正想听的不是定义,而是你有没有在真实项目里为了它纠结过、妥协过。
我下面不会给你背定义。我会拿一个贯穿全文的订单场景,把这五条原则一条一条拆开,讲清楚每条原则保护的到底是什么、代码怎么写、什么时候不该用。这篇内容偏长,建议你先收藏,写代码卡住的时候翻出来对着改。
2. 单一职责原则:一个类只能有一个“被改动的理由”
2.1 判断职责的标准不是“功能多少”,而是“谁会来改它”
大部分人对单一职责原则(SRP)的理解停留在“一个类只做一件事”,这个说法太模糊了。一个订单类既能算价又能入库,算几件事?说不清。我更喜欢 Uncle Bob 给的那个判定口径:一个类应该只有一个引起它变化的原因。换句话说,你去看这个类,问自己一句话——有哪些不同的角色会跑过来要求我改这个文件?
回到我那个OrderManager。它为什么难改?因为有三拨人盯着它。运营要改优惠规则,运维要改落库字段,客服要改短信模板。这三拨人的关注点完全不重叠,节奏也不一样,可他们改的却是同一个文件。三个人同时提需求,代码冲突能冲突到你想砸键盘。这就是“多个变更原因”压在同一个类上的典型症状。
所以判断要不要拆,我通常这么做:把最近三个月这个文件的所有提交记录拉出来,看提交信息里的关键词。如果提交集中在“优惠”“活动”“券”这类词,说明它是营销维度的;如果混着“数据库”“字段”“索引”,说明它同时承担了持久化职责。提交信息的词越杂,说明这个类的职责越脏。
这里有个容易踩的坑:职责的划分依据是业务变化的边界,不是技术分层。有人把 SRP 理解成“Controller 干 Controller 的、Service 干 Service 的”,然后在一个 Service 里同时做校验、计算、落库、发消息,觉得自己分层很规范。分层只是横向切,SRP 要求的是纵向把变化原因切开。一个订单服务里,计算逻辑会随促销活动变,落库逻辑会随表结构调整变,这两件事变化的原因不同,就该拆。
2.2 实操:把一个三千行的类拆成四个小角色
我拆OrderManager的思路很朴素,先按“变更原因”画线,画完再动代码。具体三步:
第一步,把所有方法列成一张表,标注每个方法服务的是谁。比如calcDiscount服务运营,saveOrder服务运维和 DBA,sendSmsNotice服务客服,buildMonthlyBill服务财务。标完之后你会发现方法天然聚成了几坨。
第二步,为每一坨定义一个角色接口。注意是接口,不是实现类,因为接口是你对外承诺的契约,实现类随时可以换。
第三步,原类只保留编排逻辑,也就是“先干什么、再干什么”,把具体干活的部分委托出去。
我给那段代码重构后的骨架大概长这样:
// 只负责编排,不负责具体计算和持久化 public class OrderService { private final PriceCalculator priceCalculator; private final OrderRepository orderRepository; private final Notifier notifier; public OrderService(PriceCalculator priceCalculator, OrderRepository orderRepository, Notifier notifier) { this.priceCalculator = priceCalculator; this.orderRepository = orderRepository; this.notifier = notifier; } public OrderResult submit(OrderCommand command) { Money payable = priceCalculator.calculate(command); Order order = Order.create(command, payable); orderRepository.save(order); notifier.notifyOrderCreated(order); return OrderResult.of(order); } }拆完之后最直观的变化是:运营再提优惠规则,我打开PriceCalculator就完事,测试只需要覆盖计算相关用例,压根不用碰支付和落库。变更半径从整个订单模块缩到了单个类,这就是 SRP 带来的实际收益。
2.3 注意事项:别把 SRP 用成“一个方法一个类”
我在代码评审里见过走极端的。有人把每个方法都抽成一个类,OrderValidator、OrderValidatorHelper、OrderValidatorHelperUtil,一个下单流程穿过了十四个类,读代码像在玩跳房子。这不是 SRP,这叫碎片化。
我的经验判断标准是:拆分的收益要大于跳转的成本。一个小工具方法只有三行,抽出去反而让读者多点两次跳转,那就不值得。真正的拆分信号是“变化原因不同”和“代码体积已经影响阅读”,而不是“职责听起来不一样”。
还有一个隐藏陷阱:拆完之后这几个类如果共享了一大堆私有状态,说明你的拆法有问题。比如你把计算逻辑抽走了,但新类里还要回调原类的十几个 getter,那本质上逻辑还是耦合的,只是换了个文件而已。这时候要考虑的是把共享状态提出来做一个值对象,而不是硬拆。
3. 开闭原则:需求变的时候,老代码一行都别动
3.1 扩展点该在哪儿预留,看三个信号
开闭原则(OCP)说的是软件实体应该对扩展开放、对修改关闭。很多人一听就皱眉:需求天天变,怎么可能不改老代码?这话对,但理解偏了。OCP 不是禁止你改代码,而是说面对同一类变化时,你应该只新增文件,而不是去动那个稳定的核心逻辑。
拿折扣计算举例,最原始的写法通常是这样:
public Money calculate(Order order) { if (order.getType() == OrderType.NORMAL) { return order.getAmount(); } else if (order.getType() == OrderType.VIP) { return order.getAmount().multiply(0.9); } else if (order.getType() == OrderType.PROMOTION) { return order.getAmount().multiply(0.7); } throw new IllegalArgumentException("unknown type"); }这段代码的问题不是丑,而是每加一种活动就要回来改一次 if-else,改的时候还有可能手滑把上一行的分号删了。更要命的是这个方法的测试用例会越来越多,每次改动都要重跑全量。
什么时候该引入扩展点?我一般看三个信号。第一,这段逻辑在过去半年里改过三次以上;第二,分支数量超过四五个并且还在涨;第三,不同的分支由不同的人维护。三个信号命中两个,我就动手改造了。
3.2 用策略加注册表把 if-else 换掉
改造的核心是定义一个稳定的抽象,把变化的实现挡在外面。
public interface DiscountPolicy { OrderType supportedType(); Money apply(Order order); } @Component public class NormalDiscountPolicy implements DiscountPolicy { @Override public OrderType supportedType() { return OrderType.NORMAL; } @Override public Money apply(Order order) { return order.getAmount(); } } @Component public class VipDiscountPolicy implements DiscountPolicy { @Override public OrderType supportedType() { return OrderType.VIP; } @Override public Money apply(Order order) { return order.getAmount().multiply(0.9); } }然后搞一个注册表,启动的时候把所有策略按类型塞进Map:
@Component public class DiscountPolicyRegistry { private final Map<OrderType, DiscountPolicy> policyMap; public DiscountPolicyRegistry(List<DiscountPolicy> policies) { this.policyMap = policies.stream() .collect(Collectors.toMap(DiscountPolicy::supportedType, p -> p)); } public Money calculate(Order order) { DiscountPolicy policy = policyMap.get(order.getType()); if (policy == null) { throw new IllegalArgumentException("no policy for " + order.getType()); } return policy.apply(order); } }改造完,运营说“加一个拼团折扣”,我要做的事情只有一件:新建GroupBuyDiscountPolicy,实现接口,加注解,重启。DiscountPolicyRegistry一行都不用动,它的单元测试也永远不用重写。这就是对扩展开放、对修改关闭的实际样子。
顺带说一句,这种写法还有个附带好处:策略类彼此独立,新人接手的时候只要看懂一个策略,就懂了全部策略的套路,学习成本直线下降。
3.3 什么时候不该硬套开闭原则
OCP 是最容易被过度使用的一条。我见过有人给一个永远只有两种状态的枚举字段也搞了策略模式,多写了三个类和一个注册表,只为了“符合开闭”。这纯属自残。
我的判断是:变化频率低、分支稳定、逻辑极短的判断,老老实实写 if-else。比如性别判断、订单是否已支付的布尔判断,你抽成策略类只会让代码更难看懂。OCP 值得投入的前提是“变化还会继续来”,如果它已经是终点了,抽象就是纯成本。
还有一点,引入扩展点是有代价的:它会打断阅读的连贯性。读者看calculate方法,本来一行 if 就懂了,现在要跳到接口、再跳到实现类。所以只有在“未来新增分支的收益 > 现在的跳转成本”时才划算,这个账得你自己算。
4. 里氏替换原则:子类别给父类拆台
4.1 契约式设计的四条硬要求
里氏替换原则(LSP)听起来最学术,其实说人话就是一句话:凡是父类能用的地方,换成子类之后程序的行为不能出问题。这里的“不能出问题”不是指不报错,而是指不违背调用者的预期。
具体拆成四条可检验的规则。前置条件不能加强,父类要求参数大于零,子类不能说必须大于一百,那调用者按父类约定传五十就会炸。后置条件不能减弱,父类承诺返回非空集合,子类不能返回 null。不变式必须保持,父类保证账户余额永不为负,子类不能破坏它。历史约束要遵守,父类规定方法是幂等的,子类不能改成调一次扣一次钱。
这四条听起来抽象,落到代码上就是一个个具体的坑。我印象最深的一次事故,是团队里有人给一个AccountService写了子类FrozenAccountService,重写了withdraw方法,遇到冻结账户直接抛异常。父类的调用方本来是按“返回失败结果”来处理的,结果整个批处理任务因为一个异常全部中断,第二天对账才发现少扣了一大笔。这就是典型的违背 LSP。
4.2 经典反例:正方形凭什么不能继承长方形
教科书上的正方形继承长方形,我一开始也觉得是抬杠,后来自己写图形渲染代码才明白。父类长方形有setWidth和setHeight两个独立方法,调用者写了一段逻辑:设置宽为五、高为四,然后断言面积是二十。这时候传进去一个正方形子类,setWidth(5)顺带把高也改成了五,最后面积变成二十五,断言直接失败。代码没报错,逻辑却错了,这种 bug 最难查。
正确的做法是让正方形和长方形都实现同一个Shape接口,各自暴露area()方法,谁也别继承谁。因为它们的行为契约根本不同,长方形允许宽高独立变化,正方形不允许。共用接口,不共用实现。
public interface Shape { double area(); } public class Rectangle implements Shape { private final double width; private final double height; public Rectangle(double width, double height) { this.width = width; this.height = height; } @Override public double area() { return width * height; } } public class Square implements Shape { private final double side; public Square(double side) { this.side = side; } @Override public double area() { return side * side; } }4.3 用契约测试把 LSP 变成可执行的检查
靠人眼盯 LSP 是不靠谱的,我的做法是写父类契约测试,然后让所有子类继承这套测试。父类测试里只依赖抽象接口,覆盖前置条件、后置条件和不变量。任何子类只要跑不过这套测试,就说明它违背了 LSP,构建直接挂掉,压根进不了主干。
public abstract class NotifierContractTest { protected abstract Notifier createNotifier(); @Test void 发送成功应返回成功结果() { Notifier notifier = createNotifier(); Result result = notifier.send(new Message("13800000000", "hello")); assertNotNull(result); assertNotEquals(ResultStatus.UNKNOWN, result.getStatus()); } @Test void 空接收人应返回失败而不是抛异常() { Notifier notifier = createNotifier(); Result result = notifier.send(new Message("", "hello")); assertEquals(ResultStatus.INVALID_TARGET, result.getStatus()); } }这条经验我觉得比任何定义都值钱:LSP 的落地手段是测试继承,不是代码评审。人会有侥幸心理,机器不会。
5. 接口隔离原则:别逼着别人实现他用不到的方法
5.1 胖接口的三个典型症状
接口隔离原则(ISP)说的是客户端不应该被强迫依赖它不使用的方法。这话翻译成现场语言就是:别搞那种一个接口十几个方法的“胖接口”。
胖接口有三个我一眼就能认出来的症状。第一个是空实现,某个实现类里有一堆方法是throw new UnsupportedOperationException()或者干脆return null。第二个是命名带“多功能”,比如CommonDeviceService、GeneralUserService,名字里带“通用”的接口基本都有问题。第三个是新增方法引发连锁反应,你往接口里加一个方法,结果七八个实现类全编译不过,挨个去补空实现。
我遇到过一个典型的设备管理接口,里面有print、scan、fax、copy、staple五个方法。老式打印机只能打印,去实现这个接口就得给scan和fax写空实现。后来新来的同事看见空实现,以为是历史遗留没删干净,顺手把fax的实现补上了,结果一调就崩。这种坑不怪人,怪接口一开始就设计得太贪。
5.2 按角色拆接口,而不是按实现拆
拆接口的正确姿势是按客户端的角色拆,不是按实现类的数量拆。上面那个设备,客户端其实分三类:只需要打印的、需要扫描的、需要传真的。那就拆成三个接口:
public interface Printer { void print(Document doc); } public interface Scanner { Document scan(); } public interface FaxMachine { void fax(Document doc); } // 支持打印和扫描的一体机,同时实现两个接口,绝不实现它不会的 public class MultiFunctionDevice implements Printer, Scanner { @Override public void print(Document doc) { /* ... */ } @Override public Document scan() { /* ... */ } }拆完之后那个“空实现”的臭味就没了。调用方需要打印就声明Printer,它自然拿不到scan方法,也就不可能在运行时调错。这叫把错误从运行期提前到编译期,是性价比极高的防御。
拆到什么粒度算合适?我的经验是看方法之间的使用相关性。如果每次调用 A 的人几乎都要调用 B,那 A 和 B 放在同一个接口里是合理的;如果 A 和 B 的调用者几乎不重叠,就该拆。别只看方法本身像不像同一类东西。
5.3 ISP 和 SRP 到底是不是一回事
很多人搞混 ISP 和 SRP,觉得都是从“拆”这个角度出发的。区别清楚对实际工作有用。
SRP 约束的是实现类,关注的是这个类有几个变更原因,是站在开发者的角度防止代码腐化。ISP 约束的是接口,关注的是客户端被迫依赖了多少无关方法,是站在调用者的角度减少耦合。一个是从里往外看,一个是从外往里看。
举个具体例子。一个接口有十个方法,全都属于同一个变更原因(比如都是订单查询相关),那它符合 SRP,但只要存在“有的调用者只用其中一个方法”的情况,它就违反了 ISP。反过来,一个接口只有两个方法但属于两个变更原因,那它违反 SRP 却可能不违反 ISP。所以这两条要分开检查,不能互相替代。
6. 依赖倒置原则:高层模块不该认识数据库长什么样
6.1 DIP、DI、IoC 三个词别搅在一起
依赖倒置原则(DIP)经常被和依赖注入(DI)、控制反转(IoC)混为一谈,面试的时候也常常被问懵。我用一句话把它们分开:
DIP 是设计原则,说的是高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。DI 是实现手段,说的是把依赖从内部 new 出来改成从外部传进来。IoC 是更宽的思路,把控制权从你的代码交给框架,DI 只是它的实现方式之一。
一句话概括:DIP 是目标,DI 是工具,IoC 是更大的容器。
拿订单服务举例。原始写法长这样:
public class OrderService { private final EmailSender sender = new EmailSender(); public void submit(Order order) { sender.send(order.getUserEmail(), "下单成功"); } }这段代码的问题不是它不能跑,而是OrderService把EmailSender钉死了。测试的时候你没法不真发邮件,换成短信通知你得回来改OrderService的源码,甚至要动构造函数。这就是高层模块依赖了低层细节。
6.2 手工装配和容器装配,先把原理搞明白
改造的第一步是在中间插一个抽象:
public interface OrderNotifier { void onOrderCreated(Order order); } public class EmailNotifier implements OrderNotifier { private final EmailClient emailClient; public EmailNotifier(EmailClient emailClient) { this.emailClient = emailClient; } @Override public void onOrderCreated(Order order) { emailClient.send(order.getUserEmail(), "下单成功"); } } public class OrderService { private final OrderNotifier notifier; public OrderService(OrderNotifier notifier) { this.notifier = notifier; } public void submit(Order order) { // 省略计算与落库 notifier.onOrderCreated(order); } }这时候OrderService只知道有个东西能通知用户,不知道是邮件还是短信。测试的时候你可以塞一个记录调用的假实现,一行网络请求都不发:
public class RecordingNotifier implements OrderNotifier { public final List<Order> notified = new ArrayList<>(); @Override public void onOrderCreated(Order order) { notified.add(order); } }有些同学会问:用了 Spring 之后这个构造函数的参数谁给我传?是容器扫描到OrderNotifier的实现类自动注入的。但我建议你在学 DIP 的阶段先手工装配一遍,也就是在 main 方法里自己 new 出来往里塞。只有手工装配过,你才知道容器到底帮你做了什么,出问题的时候才不会两眼一抹黑。我见过太多人只会写@Autowired,一旦出现循环依赖就完全不知道怎么下手。
6.3 抽象泄漏是 DIP 最常见的翻车方式
DIP 不是插个接口就完事了。我见过最典型的错误是接口里直接暴露了具体技术细节:
public interface OrderRepository { // 方法名里带 MySQL,参数是 SQL 语句,这就是抽象泄漏 List<Map<String, Object>> queryBySql(String sql); }这个接口虽然叫Repository,但它把 SQL 和表结构这两件最容易变的事情暴露给了高层。上层一旦按这个接口写代码,将来从关系库换到别的存储,改动量一点都没减少,DIP 白做了。合格的抽象应该是findById、findByUserAndStatus这种业务语义的方法,把技术细节关在实现类里面。
另一个坑是抽象过度。所有东西都插一层接口,UserService只有一个实现还非要搞UserService加UserServiceImpl,代码里到处都是没意义的跳转。我的原则是:只有当存在第二个实现、或者需要跨边界(数据库、外部系统、时间、随机数)的时候才抽接口。纯粹为了“规范”而抽的接口,最后只会变成负担。
7. 落地检查表:五大原则怎么在评审里真正用起来
7.1 代码评审时按这五条扫一遍
原则这东西写在书上很虚,落到评审里必须有可执行的动作。我整理了一份自己常用的速查表,评审的时候按顺序过一遍,效率比漫无目的地看代码高得多。
| 原则 | 评审时问自己的问题 | 危险信号 |
|---|---|---|
| SRP | 这个类最近三个月的提交里出现了几类关键词 | 文件超过八百行,提交信息跨了三个业务域 |
| OCP | 加一个新类型需要改几个已有文件 | 每加一种类型都要回来改 if-else 或 switch |
| LSP | 子类有没有加强参数校验或抛出父类没声明的异常 | 子类里有大量空实现和异常抛出 |
| ISP | 有没有实现类被迫写不用的方法 | 出现UnsupportedOperationException的空壳方法 |
| DIP | 高层模块有没有直接 import 具体的技术类 | Service 里直接 new 数据库连接或者 HTTP 客户端 |
这张表最大的价值是把主观感受变成客观提问。以前我说“这段代码耦合太重”,对方会问“重在哪”,现在我能指着OrderService里那个new EmailSender()说,这里违反了 DIP,改成构造函数注入,测试就能脱离网络跑。
7.2 常见问题排查速查
实际改代码的时候,症状和病因往往对不上,我整理了一份对照关系,出问题的时候可以顺着找。
| 症状 | 可能违反的原则 | 排查方向 |
|---|---|---|
| 改一个小需求要跑全量回归 | SRP、OCP | 看变更影响的类数量和测试范围 |
| 单元测试必须连数据库才能跑 | DIP | 检查构造函数里有没有 new 具体实现 |
| 加了新子类导致老功能异常 | LSP | 对比父子类的前置后置条件和异常声明 |
| 引入新框架要改上百个文件 | DIP | 看业务代码里有没有直接依赖框架类型 |
| 实现类里一堆空方法 | ISP | 检查接口的方法是否被所有实现类都用到 |
7.3 什么时候该停下来:过度设计的四个信号
最后说点反面经验。SOLID 这五条用过头,代码会变成另一种灾难。我见过一个项目,一个 CRUD 接口搞了四层抽象,从 Controller 到 Mapper 中间隔着六个类,全是透传,没有任何逻辑。新人进来第一周都在问“这个类到底干嘛的”。
我自己总结的四个过度设计信号:第一,接口只有一个实现,而且短期内确定不会有第二个;第二,为了符合原则引入的抽象,让最简单的一个操作需要跨三个文件才能看懂;第三,代码行数比功能本身多出好几倍,业务逻辑被淹没在样板里;第四,团队成员开始抱怨“看不懂”,而不是“不好改”。
任何一条命中,我都会退回去简化。SOLID 是手段不是目的,它服务的对象是未来的修改成本。如果为了遵守它反而增加了理解成本,那这笔买卖就是亏的。
我个人在这些年真正体会到的分寸感是:先让它能跑,再在第二次修改的时候重构。第一次写就想着抽象,往往会抽错,因为你还不知道变化会从哪里来;等改到第二次、第三次,变化的规律显现出来了,这时候按 SOLID 动手,抽象的位置才是准的。我最早学 SOLID 的时候特别着急,恨不得把手上所有代码都拆一遍,结果拆出一堆没人愿意维护的碎片。后来慢慢明白,这五条原则更像是给有经验的开发者准备的收敛工具,而不是给新手用的起手式——你得先写过足够多的烂代码,才知道好代码到底好在哪里。