这是「设计模式拆解」系列的第一篇。很多人学设计模式,是抱着一本 GoF 二十三式,一个一个背名字、抄 UML,背完发现工作里既想不起来用、也看不懂别人为什么这么写。问题出在顺序反了:设计模式不是凭空发明的招式,而是一群人反复踩坑之后,对"怎么写代码才不容易烂"这件事总结出的套路。而这些套路背后,是更底层的一层东西——设计原则。原则是"为什么",模式是"怎么做"。不先把原则的地基打牢,后面二十四篇模式看着就全是零散的技巧。
所以这一篇不讲任何一个具体模式,只做一件事:把公认的七大设计原则(SOLID 五条 + 迪米特法则 + 合成复用原则)一条条讲透。而且不是干巴巴地念定义,而是拿一段"能跑但很烂"的订单代码,让它在每一条原则的拷问下暴露出一个具体的毛病,再重构掉。整个设计模式系列都会围绕这个订单场景展开,正好从这里开个头。
这篇文章按这条线索展开:先说清楚设计模式到底在跟什么东西作斗争(答案是"变化"和"耦合");再摆出那段烂代码;然后逐条上原则,每条都对应烂代码里的一处具体病灶,讲清它是什么、违反了会怎样、怎么改;最后把七条原则收拢成一张全景图,并且泼一盆冷水——原则是用来权衡的,不是用来无脑堆砌的,过度设计比不设计还糟。
目录
一、设计模式到底在跟什么作斗争
先想清楚一件事:如果需求永远不变,代码写完再也不改,那世界上根本不需要设计模式。你把所有逻辑一路写到底、全塞在一个 main 方法里,只要能跑出正确结果,就是完美的代码。
设计模式的全部价值,都建立在"需求一定会变"这个前提上。 今天只支持微信支付,明天要加支付宝;这个月订单只有"实物商品",下个月要卖虚拟卡券;这个季度是单机,下个季度要拆微服务。代码的生命周期里,写的时间是一次性的,改的时间是无限的。真正让项目烂掉的,从来不是第一次没写好,而是每次改动都像拆炸弹——动一个地方,三个不相干的功能跟着崩。
所以设计的核心目标可以浓缩成两个词:
- 应对变化:把"容易变的部分"和"相对稳定的部分"隔开,让变化被控制在一个小范围里,而不是四处扩散。
- 降低耦合:模块之间的连接越少、越松,一处改动波及的范围就越小。
七大原则,本质上全是围绕这两个目标展开的具体手段。你会发现它们彼此高度相关,甚至有点互相重叠——因为它们本来就是从不同角度描述同一件事:怎么让代码在面对变化时,改得更少、崩得更少。 记住这条主线,下面每条原则你都能自己推出来,而不用背。
二、先看一段"能跑但很烂"的订单代码
空谈原则最容易变成正确的废话。我们先立一个靶子。假设我们在做一个订单模块,现在要实现"创建订单"这个功能:根据商品算出金额,按支付方式发起支付,然后把订单存进数据库,再发一条通知短信。一个刚上手的写法可能是这样的:
public class OrderService {
public void createOrder(long userId, long skuId, int count, String payType) {
// 1. 算钱:直接连数据库查价格
Connection conn = DriverManager.getConnection("jdbc:mysql://…", "root", "123456");
// … 查 sku 单价,乘以数量,这里省略
double amount = queryPrice(conn, skuId) * count;
// 2. 发起支付:一大坨 if-else
if (payType.equals("wechat")) {
// 调微信 SDK 的一堆代码
System.out.println("调微信支付 " + amount);
} else if (payType.equals("alipay")) {
// 调支付宝 SDK 的一堆代码
System.out.println("调支付宝支付 " + amount);
} else if (payType.equals("unionpay")) {
// 调银联 SDK 的一堆代码
System.out.println("调银联支付 " + amount);
}
// 3. 存库:又是直接拼 SQL
String sql = "INSERT INTO orders …";
// … 执行
// 4. 发通知:短信内容也在这拼
String content = "您的订单已创建,金额 " + amount + " 元";
System.out.println("发短信:" + content);
}
}
这段代码完全能跑,逻辑也对。但凡是有点经验的人看到都会皱眉。为什么?因为它把互不相干的四件事——算钱、支付、存库、通知——连同它们的所有实现细节,一股脑焊死在了一个方法里。它踩中了七大原则里的每一条。下面我们就拿它当活体标本,一刀一刀解剖。
三、单一职责原则:一个类只干一件事
单一职责原则(Single Responsibility Principle,SRP):一个类(或方法)应该只有一个引起它变化的原因。换句话说,它只负责一件事。
回头看那个 createOrder 方法,它至少有四个"变化的原因":支付渠道调整了要改它、数据库换了要改它、短信文案改了也要改它、订单金额规则变了还得改它。四拨完全不同的人(支付组、DBA、运营、产品)因为完全不同的理由,都会来动同一个方法。这就意味着这四件事被死死耦合在了一起:改支付的人一不小心就可能碰坏发短信的逻辑,而且这个方法会随着功能增加越来越长,最后长到没人敢动。
怎么判断一个类是不是职责太多?有个朴素的办法:试着用一句话描述它是干嘛的,如果这句话里出现了"并且"“同时”“然后”,那大概率就该拆了。 “OrderService 负责计算金额并且发起支付然后存库同时发通知”——四个连接词,四个职责。
按 SRP 重构,把每件事拆给专门的角色:
class PriceCalculator { /* 只负责算钱 */ }
class PaymentService { /* 只负责支付 */ }
class OrderRepository { /* 只负责存取订单 */ }
class NotifyService { /* 只负责发通知 */ }
public class OrderService {
// OrderService 只负责"编排"这几步,不亲自干活
public void createOrder(...) {
double amount = priceCalculator.calculate(skuId, count);
paymentService.pay(payType, amount);
orderRepository.save(order);
notifyService.notify(userId, amount);
}
}
现在支付组改支付,只会动 PaymentService,碰不到别人。这就是"把变化关进小房间"最基础的一步。 需要提醒的是,“职责"的粒度是相对的,不是越细越好——把一个类拆成二十个只有一个方法的类,同样是灾难。粒度的判断依据永远是"它们会不会因为不同的原因一起变”,而不是行数。
四、开闭原则:对扩展开放,对修改关闭
开闭原则(Open-Closed Principle,OCP):软件实体应该对扩展开放,对修改关闭。意思是,当需求变化、要加新功能时,应该通过新增代码来实现,而不是去改动已有的、已经测试过、正在稳定运行的代码。
这条是七大原则里的总纲,是"应对变化"这个目标最直接的表达。其余六条原则,某种意义上都是在为实现开闭原则服务。
看上一节拆出来的 PaymentService,如果它内部还是那坨 if-else:
if (payType.equals("wechat")) { ... }
else if (payType.equals("alipay")) { ... }
else if (payType.equals("unionpay")) { ... }
那么每接入一个新渠道(比如"数字人民币"),你都必须回去撬开这个方法,加一个 else if。这就违反了开闭原则——你在修改一段已经稳定的代码。改一次可能没事,改十次,这段方法就变成一个谁都不敢碰的雷区,而且每改一次,原有渠道都要重新回归测试一遍。
开闭原则的实现套路几乎是固定的:抽象出一个稳定的接口,把每种变化做成接口的一个实现。
// 稳定不变的抽象
public interface Payment {
void pay(double amount);
}
// 每个渠道是一个独立实现,新增渠道 = 新增一个类,不碰旧代码
public class WechatPayment implements Payment { public void pay(double a){ /*…*/ } }
public class AlipayPayment implements Payment { public void pay(double a){ /*…*/ } }
public class UnionPayment implements Payment { public void pay(double a){ /*…*/ } }
以后加"数字人民币",只需 new 一个 DcepPayment implements Payment,PaymentService 一个字都不用改。注意:此刻我们其实已经在不知不觉中用到了"策略模式"的雏形了——这正说明模式就是原则的具体落地。这条 Payment 接口会作为贯穿全系列的例子反复出现。
一个诚实的提醒:开闭原则不可能 100% 做到,你不可能预判所有未来的变化。它的正确姿势是——在你已经识别出"这里大概率会扩展"的地方(比如支付渠道),提前留好抽象;对那些没看出扩展苗头的地方,不必强行开闭。过早地为想象中的变化做抽象,就是下面第十节要批的"过度设计"。
五、里氏替换原则:子类要能顶替父类
里氏替换原则(Liskov Substitution Principle,LSP):所有引用父类的地方,都必须能透明地替换成它的子类,而程序的行为不发生异常。一句话:子类可以扩展父类的能力,但不能改变父类原有的行为契约。
这条原则是给"继承"划红线的。继承在语法上很简单,一个 extends 就完事,但它其实是一种非常强的承诺:你声明 子类 is-a 父类,就等于对所有使用父类的代码保证"我完全可以当父类用"。一旦这个承诺被打破,继承就会变成 bug 的温床。
举个订单场景里的经典反例。假设我们有个普通订单 Order,有一个"设置折扣"的行为;然后来了个"预售订单" PreSaleOrder,图省事直接继承 Order:
class Order {
// 普通订单:折扣必须在 0~1 之间
public void setDiscount(double d) {
if (d < 0 || d > 1) throw new IllegalArgumentException("折扣非法");
this.discount = d;
}
}
class PreSaleOrder extends Order {
@Override
public void setDiscount(double d) {
// 预售不许打折,直接抛异常 —— ✗ 改变了父类的行为契约
throw new UnsupportedOperationException("预售订单不能打折");
}
}
问题来了:某段代码原本拿着 Order 好好地跑着,order.setDiscount(0.8) 一切正常;有一天这个 order 被换成了 PreSaleOrder,同样一行代码,突然抛异常炸了。调用方什么都没改,却因为"子类偷偷改了规矩"而崩溃——这就是违反 LSP 的典型症状。它的危害在于隐蔽:编译器不会报错,问题要到运行时、在你意想不到的地方才爆出来。
违反 LSP 常见的几种表现,可以当自查清单:
- 子类重写父类方法后,抛出父类不会抛的异常(如上例);
- 子类重写方法后,缩小了输入范围或放宽了输出范围(要求比父类更苛刻、或返回比父类更宽松);
- 子类干脆把某个继承来的方法重写成空实现或抛异常,因为它"根本不需要这个方法"——这几乎是在明示:它俩其实不是 is-a 的关系。
正确的做法是回头审视继承关系:预售订单和普通订单也许根本不该是父子,而应该抽出共同的 Order 抽象,让两者作为平级的实现,各自持有自己的折扣规则。当你发现"这个子类没法完全替代父类"时,答案通常不是想办法绕过去,而是这里压根不该用继承——这就自然引到了本篇最后一条,合成复用原则。
六、依赖倒置原则:都依赖抽象,别依赖细节
依赖倒置原则(Dependency Inversion Principle,DIP):高层模块不应该依赖低层模块,两者都应该依赖抽象;抽象不应该依赖细节,细节应该依赖抽象。
名字里的"倒置"是相对于直觉而言的。按最自然的想法,高层的业务(OrderService)当然是直接调用低层的实现(WechatPayment),依赖箭头从上指向下。但这样一来,高层就被死死钉在了某个具体实现上:
public class OrderService {
// ✗ 直接 new 出具体实现,OrderService 从此和微信支付焊死
private WechatPayment payment = new WechatPayment();
}
这段代码里,OrderService 这个"高层业务"直接依赖了 WechatPayment 这个"低层细节"。后果是:想换成支付宝,得改 OrderService;想在单元测试里用一个假的支付(mock),没法换;WechatPayment 的任何改动,都可能波及到高层。业务逻辑本该是最稳定、最核心的部分,现在却被最容易变的支付细节牵着鼻子走。
"倒置"就是把这个箭头掰过来:让高层和低层都去依赖中间那层抽象接口,谁也不直接依赖谁的实现。
public class OrderService {
private final Payment payment; // ✓ 只依赖抽象接口 Payment
// 具体用哪个实现,由外部传进来(这就是"依赖注入")
public OrderService(Payment payment) {
this.payment = payment;
}
}
// 用的时候由外部决定注入谁
new OrderService(new WechatPayment());
new OrderService(new AlipayPayment());
new OrderService(new MockPayment()); // 测试时轻松替换
现在依赖关系变成了:OrderService → Payment ← WechatPayment,箭头都指向中间的抽象。高层不再认识任何具体实现,想换实现,从外面注入就行。Spring 的核心 IoC(控制反转)/ DI(依赖注入),整套体系就是这条原则的工业级落地——你 @Autowired 一个接口,由容器决定注入哪个实现,正是"依赖抽象 + 从外部注入"的实践。这也是为什么面向接口编程被反复强调:接口就是那个让高层和低层解耦的"抽象中间层"。
七、接口隔离原则:别让人依赖用不到的方法
接口隔离原则(Interface Segregation Principle,ISP):客户端不应该被迫依赖它用不到的方法。接口要小而专,而不是大而全。
如果说单一职责是从"类"的角度谈拆分,接口隔离就是从"接口"的角度谈同一件事:一个胖接口,会把不相干的能力捆在一起,强迫实现者去实现自己根本不需要的方法。
举例。假设我们图省事,定义了一个"万能订单操作"接口:
public interface OrderOperation {
void create(); // 创建
void pay(); // 支付
void refund(); // 退款
void ship(); // 发货
void comment(); // 评价
}
现在来了个"虚拟卡券订单",它没有物流,不需要发货。但因为实现了这个胖接口,它被迫也得写一个 ship() 方法——写啥呢?只能空着,或者抛个异常:
public class VirtualOrder implements OrderOperation {
public void ship() {
throw new UnsupportedOperationException("虚拟订单不发货"); // ✗ 被迫实现用不到的方法
}
// …
}
看出来了吗?这里的空实现 / 抛异常,和第五节 LSP 那个反例是同一种味道——胖接口逼着实现类去违反里氏替换。两条原则在这里交汇了。而且危害不止于此:哪天 OrderOperation 接口里加了个新方法 invoice()(开发票),所有实现类,包括那些根本不开发票的,全都得跟着改,一改一大片。
按接口隔离原则,把大接口拆成几个小接口,谁需要哪种能力就实现哪个:
interface Payable { void pay(); void refund(); }
interface Shippable { void ship(); }
interface Commentable { void comment(); }
// 实物订单:该有的都有
class PhysicalOrder implements Payable, Shippable, Commentable { /*…*/ }
// 虚拟订单:只实现自己需要的,不再被迫写 ship()
class VirtualOrder implements Payable, Commentable { /*…*/ }
接口小了,依赖它的人才不会被牵连。 这也和单一职责遥相呼应:单一职责管的是实现类别太杂,接口隔离管的是抽象契约别太胖,一个从实现侧、一个从抽象侧,共同把系统的耦合面积压小。
八、迪米特法则:只和直接朋友说话
迪米特法则(Law of Demeter,LoD),又叫最少知识原则:一个对象应该对其他对象保持最少的了解。通俗讲——只和你的"直接朋友"打交道,别去碰朋友的朋友。
什么叫"直接朋友"?一个对象的直接朋友,通常指:它的成员变量、它方法的参数、它方法的返回值、它自己创建的对象。而"朋友的朋友"——通过一长串点号 a.getB().getC().doSomething() 摸进去够到的那些对象,就属于陌生人了。
看个订单里常见的坏味道。要给订单的收货人发个通知,需要拿到收货人的手机号:
// ✗ 一长串 getter 链,OrderService 被迫认识了 Order 内部的 Address、User 结构
String phone = order.getAddress().getUser().getPhone();
notify(phone);
这行代码的问题在于:OrderService 本来只该认识 order,现在却顺着链条,把 Address、User 的内部结构全摸了个遍。它对 order 的内部实现知道得太多了。 后果是:哪天 Order 里 Address 的结构调整了(比如手机号不再挂在 User 上,而是直接挂在 Address 上),这行八竿子打不着的通知代码也得跟着改。一个类知道别人的内部结构越多,别人重构时就越束手束脚。
改法是让 Order 把"怎么找到手机号"这件事自己封装掉,只对外暴露一个直接朋友能调的方法:
public class Order {
// Order 自己知道去哪找收货手机号,外面不用管内部结构
public String getReceiverPhone() {
return address.getUser().getPhone();
}
}
// 调用方只和直接朋友 order 说话
notify(order.getReceiverPhone()); // ✓
迪米特法则的核心是"信息隐藏":把类的内部结构藏起来,只暴露必要的行为。这样各个类才能独立演化,改内部实现时不会牵一发而动全身。
别矫枉过正:迪米特法则不是让你消灭所有点号。流式 API(如 StringBuilder.append().append(),或 Stream 的链式调用)返回的是同一个对象或同类对象,并不违反它。它反对的是"穿透对象的封装、去操作深层内部对象",而不是链式调用本身。
九、合成复用原则:多用组合,少用继承
合成复用原则(Composite Reuse Principle,CARP):要复用一段代码时,优先使用组合 / 聚合(把别的对象当成员持有),而不是继承。
我们在第五节里已经埋下了伏笔:继承是一种很强的承诺,用不好就违反里氏替换。这一条原则更进一步地告诉你:大多数你以为需要继承的场景,其实用组合更好。
先看为什么继承是把双刃剑。假设我们想复用一个 Order 的基础能力,做一个"带日志的订单":
// ✗ 用继承来复用
class LoggingOrder extends Order {
@Override
public void pay(double amount) {
System.out.println("[日志] 准备支付");
super.pay(amount);
System.out.println("[日志] 支付完成");
}
}
这么写有几个隐患:
- 打破封装:子类继承了父类的全部实现细节,父类的任何内部改动都可能悄悄影响子类,两者被绑死了。这叫"白箱复用"——父类对子类是透明的。
- 静态、僵硬:继承关系在编译期就定死了,运行时没法换。而且 Java 是单继承,一个类只能有一个爹,你想同时复用两个类的能力,继承直接就做不到。
- 容易类爆炸:要"带日志的订单"“带缓存的订单”“既带日志又带缓存的订单”……用继承排列组合,类的数量会指数级膨胀。
再看组合怎么做——把要复用的对象当成员持有,而不是继承:
// ✓ 用组合来复用
class LoggingOrder {
private final Order order; // 持有一个 Order,而不是继承它
public LoggingOrder(Order order) { this.order = order; }
public void pay(double amount) {
System.out.println("[日志] 准备支付");
order.pay(amount); // 委托给持有的对象
System.out.println("[日志] 支付完成");
}
}
组合的好处正好对上继承的短处:它是"黑箱复用"——只依赖 Order 的公开方法,碰不到内部细节,封装性好;它可以在运行时动态替换持有的对象;想叠加多种能力就多持有几个,不会类爆炸。你会发现,后面的装饰器、代理、桥接、策略等一大票模式,骨子里都是"组合优于继承"的具体演绎。
这不是说继承一无是处。当两个类确实是纯粹的 is-a 关系、且子类能完全替代父类(满足里氏替换)时,继承依然是最自然的表达。 原则的意思是"优先考虑组合",在两者都能解决问题时倾向组合,而不是"禁止继承"。
十、七条原则串成一张图,以及一句泼冷水
到这里,七条原则都过完了。回头看,它们并不是七个孤立的教条,而是从不同角度指向同两个目标——应对变化、降低耦合。用一张表收拢一下:
| 单一职责 SRP | 一个类只干一件事 | 职责耦合、类太胖 |
| 开闭原则 OCP | 对扩展开放,对修改关闭 | 改动波及已稳定的代码(总纲) |
| 里氏替换 LSP | 子类要能顶替父类 | 继承用错、行为契约被破坏 |
| 依赖倒置 DIP | 依赖抽象,不依赖细节 | 高层被低层实现绑死 |
| 接口隔离 ISP | 接口要小而专 | 胖接口逼人实现无用方法 |
| 迪米特 LoD | 只和直接朋友说话 | 过度耦合别人的内部结构 |
| 合成复用 CARP | 组合优于继承 | 继承滥用、类爆炸 |
它们彼此还高度联动:接口隔离拆出的小接口,是依赖倒置要依赖的那个"抽象";胖接口的空实现,同时踩了里氏替换;合成复用是里氏替换那条红线的自然延续;而所有这些,最终都是为了服务开闭原则——让系统面对变化时,尽量只增不改。它们共同的抓手,其实就一个字:抽象。找准那个"会变的点",给它一层稳定的抽象,把变化挡在抽象后面。

最后,必须泼一盆冷水,这盆水比上面所有内容都重要:
原则是用来权衡的,不是用来无脑执行的。 这七条原则,每一条推到极端都会走向反面:
- 单一职责推到极端,你会得到几百个只有一个方法的类,调用链长到没法读;
- 开闭原则用力过猛,你会为了应对根本不会发生的变化,预先造出一堆多余的抽象层,美其名曰"扩展性",实则是给后人挖坑;
- 处处依赖倒置、面向接口,每个类都配一个只有一个实现的接口,除了让人多点两下"跳转到实现",没有任何收益。
这种"为了用原则而用原则、为了显得有设计感而堆砌抽象"的毛病,有个专门的名字——过度设计(over-engineering)。它的危害不亚于完全不设计:后者是当下就烂,前者是用"未来可能有用"的名义,给现在平添一堆复杂度和理解成本,而那个"未来"往往永远不来。
真正的功力,不在于会背这七条,而在于判断力:面对一段具体的代码,你能看出"这里的变化点在哪、值不值得为它引入抽象、引入哪种抽象最划算"。设计模式就是前人总结好的、针对常见变化点的"标准答案"。但答案是死的,场景是活的——先有值得解决的问题,才谈得上用模式;没有问题硬套模式,就是过度设计。这句话,请带着它读完后面二十四篇的每一个模式。
小结。这一篇没讲任何一个具体模式,却是整个系列的地基:设计的目标是应对变化、降低耦合,手段是抽象,而七大设计原则就是运用抽象的七条准则——SRP 拆职责、OCP 立总纲、LSP 管继承、DIP 靠注入、ISP 瘦接口、LoD 降耦合、CARP 推组合。它们不是要背的教条,而是一套帮你识别"变化点该不该抽、怎么抽"的思维工具。记住三句话就够了:把会变的和不变的分开;优先组合、面向接口;以及——别过度设计。下一篇开始进入正题,我们从最有名、也最容易写错的单例模式讲起,看看"保证一个类只有一个实例"这么简单的一句话,背后藏着多少和类加载、并发有关的门道。
网硕互联帮助中心



评论前必须登录!
注册