大家好,我是晚安code。
说白了,设计模式这东西,背下来和用得出来,中间隔着一整条河。这篇不聊虚幻的"架构思想",只干三件事:把 SOLID 和迪米特法则讲成一套能用的判断标准,重点拆 5 个实际项目里出镜率最高的模式,最后给你一张选型表。
看完你至少能回答两个问题——「这个类到底该不该拆」和「这坨 if-else 该用哪个模式收」。代码可以直接抄,建议先收藏再慢慢看。
一、原则和模式,到底谁管谁
设计模式(Design Patterns):一套被反复验证过的、针对特定问题的代码组织方式,最早由 GoF 四人在 1994 年整理成 23 个。你可以把它当成木工的榫卯——不是谁发明的,是前人试错试出来的标准接法。
分清两件事,后面就顺了。
SOLID 原则和设计模式不是一回事:原则是判断标准,回答"这里该不该拆、该依赖谁";模式是落地手段,回答"具体用哪套写法"。很多人把顺序学反了——抱着 23 个模式满世界找场景,相当于先买了一把专用螺丝刀,再回家翻箱倒柜找能拧的螺丝。

二、SOLID 五原则:一把量代码的尺子
SOLID 原则(SOLID Principles):面向对象设计的五条基本准则,由 Robert C. Martin 归纳整理,用来判断类的职责划分、继承关系和依赖方向是否站得住。它是尺子不是法条——量出来超标,也不代表立刻就得动手改。
五个原则我不按同一个模板讲,那样读起来像背课文,每个换一个切入角度。
1)单一职责 SRP:一个类只该有一个被改的理由
单一职责原则(SRP,Single Responsibility Principle):一个类应该只有一个引起它变化的原因。别数方法有几个,数"谁会来要求你改它"。
示例场景,订单服务:
public class OrderService {
public void createOrder(Order order) {
checkStock(order); // 库存
BigDecimal amount = calc(order); // 计价
save(order); // 落库
sendSms(order); // 短信通知
exportPdf(order); // 导出对账单
}
}
问题不在方法多,在于改动的理由攒了四个:运营改短信模板要动这个文件,财务改对账单版式也要动,DBA 换存储还得动。四个人排着队改同一个类,冲突几乎是必然的。
改法就是把"被改的理由"请出去:OrderCreator 管下单、SmsNotifier 管通知、InvoiceGenerator 管对账,OrderService 退回来只做编排。
别拆过头。拆到每个方法一个类,那是另一种灾难——判断依据是"变化的原因",从来不是行数。
2)开放封闭 OCP:加需求别回头改老代码
开放封闭原则(OCP,Open-Closed Principle):对扩展开放,对修改关闭。加新功能时应该是新增代码,而不是改动已经跑通的老代码。
反例,支付折扣:
public BigDecimal pay(String type, BigDecimal amount) {
if ("alipay".equals(type)) return amount.multiply(new BigDecimal("0.99"));
if ("wechat".equals(type)) return amount.multiply(new BigDecimal("0.98"));
if ("unionpay".equals(type)) return amount.multiply(new BigDecimal("0.995"));
throw new IllegalArgumentException("不支持的渠道: " + type);
}
每接一家新支付,都得翻回这个方法加一行。测试要重跑,老分支还有被手抖改坏的风险。改法在第四节策略模式里细说——抽一个 PayChannel 接口,新增渠道就是新增一个实现类。
这里插一句我自己的看法:if-else 本身不是原罪,分支稳定就留着它。你只有两种支付方式,且三年没变过,硬抽接口纯粹是给自己找活干,还会让下一个接手的人在两个文件之间来回跳。判断标准是变化频率,不是"看起来够不够优雅"。
3)里氏替换 LSP:子类别拆父类的台
里氏替换原则(LSP,Liskov Substitution Principle):子类必须能顶替父类出现在任何位置,且不破坏程序原本的逻辑。父类承诺过的事,子类不能打折。
最经典的翻车案例,正方形继承长方形:
class Rectangle {
protected int w, h;
public void setWidth(int w) {
this.w = w; }
public void setHeight(int h) {
this.h = h; }
public int area() {
return w * h; }
}
class Square extends Rectangle {
@Override public void setWidth(int w) {
this.w = w; this.h = w; }
@Override public void setHeight(int h) {
this.w = h; this.h = h; }
}
调用方老老实实按父类的契约写:
Rectangle r = new Square();
r.setWidth(5);
r.setHeight(4);
assert r.area() == 20; // 挂在这里,拿到的是 16
父类白纸黑字写着"宽高互相独立",Square 偷偷把两者绑死了。判断标准特别简单:把子类塞进父类的位置,调用方的代码需不需要改逻辑?需要,就是违反了 LSP。
改法是别让 Square 继承 Rectangle,抽一个共同的 Shape 接口,里面只留 area()。
4)接口隔离 ISP:别逼人写空实现
接口隔离原则(ISP,Interface Segregation Principle):接口要小而专,不该强迫实现类去实现它根本用不到的方法。
这条有个一眼就能认出来的信号——只要看到 throw new UnsupportedOperationException() 或者空方法体,基本就是接口太胖了。
反例:
public interface UserService {
User findById(Long id);
void update(User user);
byte[] exportAll(); // 只有运维导出在用
void sendCoupon(Long id); // 只有营销在用
}
后面两个方法各自只服务一拨调用方,却逼着所有实现类都得写空壳。拆成 UserQueryService、UserCommandService、UserExportService,谁用谁依赖,干净得多。
5)依赖倒置 DIP:别让高层盯着低层
依赖倒置原则(DIP,Dependency Inversion Principle):高层模块不该依赖低层实现,两者都应该依赖抽象。
反例:
public class OrderService {
private final MySQLOrderRepository repo = new MySQLOrderRepository();
}
OrderService 是业务高层,MySQLOrderRepository 是存储细节,现在高层直接焊死在 MySQL 上。想换 MongoDB 要改业务代码,想写个单测得先起一个数据库实例。
改法是把实现从构造函数塞进来:
public OrderService(OrderRepository repo) {
this.repo = repo;
}
依赖接口而不是具体类——这正是 Spring 依赖注入每天在做的事,只是框架替你写了那行 new。
三、迪米特法则:少跟陌生人说话
迪米特法则(LoD,Law of Demeter):又叫最少知识原则,一个对象应该尽量少了解其他对象的内部结构,只跟自己的"直接朋友"打交道。
打个生活化的比方:你想让隔壁公司的老张帮你盖个章,正常做法是找你们对接的同事说一声。要是你绕过同事,直接去摸老张的上司、上司的助理、助理的实习生——这条链上任何一环换人,你就得重新走一遍流程。
代码里的反例是这种"火车残骸"式调用:
// 坏:一路 get 到底
String city = order.getCustomer().getAddress().getCity();
// 好:只跟直接朋友说话
String city = order.getCustomerCity();
但这里我要说个跟主流不太一样的看法:迪米特法则是我见过被滥用得最狠的一条。
有人把它理解成"每个字段都得加一层包装方法",于是 Order 上挂满了 getCustomerCity、getCustomerProvince、getCustomerZipCode……最后变成一个纯粹的转发壳,调用方要跳三层才能找到真正的逻辑。
判断标准是看这层包装有没有消化掉"知识"。比如 getCustomerCity() 内部处理了地址为空的情况,那它有存在价值;如果它的实现只是 return customer.getAddress().getCity();——把链式调用换个地方写了一遍,那就是白加一层。

四、五个高频模式:够你应付八成场景
设计模式一共 23 个,但真正常用的就那么几个。下面这 5 个,是我这些年改别人代码时出现频率最高的——不是最优雅的 5 个,是最容易写错、也最值得弄明白的 5 个。
1)单例模式
单例模式(Singleton Pattern):保证一个类在全局只有一个实例,并提供一个统一的访问入口。典型场景是配置管理器、连接池、日志器。
Java 里几种写法,从最土到最稳。
饿汉式,类加载时就把实例建好,简单但不管用不用都占着内存:
public class Config {
private static final Config INSTANCE = new Config();
private Config() {
}
public static Config getInstance() {
return INSTANCE; }
}
双重检查锁,面试常问的那版,注意 volatile 一个字都不能省:
public class Config {
private static volatile Config instance;
private Config() {
}
public static Config getInstance() {
if (instance == null) {
synchronized (Config.class) {
if (instance == null) {
instance = new Config();
}
}
}
return instance;
}
}
少了 volatile 会怎样?new Config() 在字节码层面是三步——分配内存、初始化对象、把引用赋给变量,这三步可能被指令重排成"先赋值再初始化"。另一个线程在第一层判断时看到引用不为 null,拿到的却是一个字段还是默认值的半成品对象。这种 bug 极难复现,也极难排查。
枚举式,我现在的默认选择:
public enum Config {
INSTANCE;
public String getValue(String key) {
return System.getProperty(key); }
}
写法最短,还天然防反射攻击、防反序列化破坏——前面两种都能被反射强行再 new 一个出来,枚举不行。
可能有人会问:Spring 的
@Bean默认就是单例,我还需要自己手写单例模式吗?不需要。容器里的 bean 单例由 Spring 统一管生命周期,你手写枚举单例的价值在于理解"对象生命周期由谁控制"这件事本身。真在业务代码里 hardcode 一个单例,反而容易和容器打架——测试时两个上下文共享同一个实例,状态互相污染。
单例最大的代价其实不是线程安全,是它本质上是一个全局变量。两个测试用例共享同一个实例,先跑的那个改了状态,后跑的那个就挂,而且挂得莫名其妙。能不用全局状态就别用,这是我踩过几次之后的结论。
2)工厂模式
工厂模式(Factory Pattern):把对象的创建过程从使用方剥离出去,调用方只说"我要什么",不关心"怎么造"。它有三种常见形态,别搞混:
| 形态 | 做法 | 加新产品要改哪里 | 适用场景 |
|---|---|---|---|
| 简单工厂 | 一个方法里 switch/if 判断类型 | 改工厂方法(违反 OCP) | 产品少且稳定 |
| 工厂方法 | 每个产品配一个工厂类 | 只新增工厂类 | 产品会持续增加 |
| 抽象工厂 | 一个工厂造一族相关产品 | 新增产品族工厂 | 跨平台 UI 组件这类产品族 |
简单工厂的实现最直观:
public class PayChannelFactory {
public static PayChannel create(String type) {
switch (type) {
case "alipay": return new AlipayChannel();
case "wechat": return new WechatChannel();
default: throw new IllegalArgumentException("未知渠道: " + type);
}
}
}
它确实违反开放封闭——每加一个渠道就要回来改这个 switch。但它简单,产品数量稳定的时候反而是最优解。别一上来就奔着抽象工厂去,那是典型的用力过猛。
工厂方法把"创建"再往下推一层:AlipayChannelFactory 实现 PayChannelFactory,新增渠道等于新增一个工厂类,老代码一行不动。
抽象工厂解决的是另一个问题——当你需要"一族"配套产品时用。比如同时要造 Button 和 Checkbox,还得保证它们是同一套皮肤,那抽象工厂就是对的;只造单一产品硬套抽象工厂,只会多出一堆没必要的类。
3)建造者模式
建造者模式(Builder Pattern):把复杂对象的"构造过程"和"最终表示"分开,让你可以分步拼装出一个对象。它出现的信号非常明确——构造函数参数超过 4 个,且大部分是可选的。
反例:
new Order("A001", 2, null, true, false, null, "顺丰", 3);
读到第三个参数,你就已经忘了第一个是什么了。改完:
Order order = Order.builder()
.sku("A001")
.quantity(2)
.express("顺丰")
.insured(true)
.build();
Lombok 的 @Builder、JDK 自带的 StringBuilder、OkHttp 的 Request.Builder,都是这个模式。
它的价值不只是读起来清楚——build() 能把参数校验集中做一次,把非法组合拦在对象出生之前。insured=true 却没填保额这种问题,在这个方法里就能直接抛异常,比散在业务代码各处的 if 判断可靠得多。这一点比链式调用的观感重要。

4)代理模式
代理模式(Proxy Pattern):给一个对象提供一个代理,由代理控制外界对原对象的访问。生活中到处都是它——代购就是代理,你不用直接找国外商家,代购替你处理下单、清关、汇率这一整套。
结构是:调用方 → 代理 → 真实对象。价值在于代理能在转发前后顺手加点东西:鉴权、日志、事务、缓存。
Java 里三种实现方式:
| 方式 | 原理 | 限制 |
|---|---|---|
| 静态代理 | 手写一个实现同一接口的类 | 每个被代理类都得配一个,类会爆炸 |
| JDK 动态代理 | Proxy + InvocationHandler,运行时生成实现类 |
必须基于接口 |
| CGLIB | 运行时生成目标类的子类 | 不能代理 final 类和 final 方法 |
JDK 动态代理的核心骨架长这样:
public class LogHandler implements InvocationHandler {
private final Object target;
public LogHandler(Object target) {
this.target = target; }
@Override
public Object invoke(Object proxy, Method m, Object[] args) throws Throwable {
long start = System.currentTimeMillis();
Object result = m.invoke(target, args);
System.out.println(m.getName() + " 耗时 "
+ (System.currentTimeMillis() - start) + "ms");
return result;
}
}
Spring AOP 的底层就是代理模式。目标类有接口默认走 JDK 动态代理,没有接口就走 CGLIB 生成子类。你写的 @Transactional、@Cacheable 生效的那一刻,实际调用的是代理对象,不是你自己写的那个类。
这也顺带解释了一个经典坑:同一个类里 this.xxx() 调用另一个带 @Transactional 的方法,事务注解会失效——this 是真实对象,根本没经过代理。
可能有人会问:JDK 动态代理和 CGLIB 该怎么选?
Spring Boot 从 2.x 起默认就把
proxyTargetClass打开了,走 CGLIB。你不需要手动选:有接口用 JDK 代理性能更好,没接口 CGLIB 兜底。真正要留心的是 CGLIB 代理不了 final 类,类上加了final关键字而事务莫名失效,八成是撞在这上面。
一次代理调用到底经过了谁,看下面这张时序图就够了——调用方从头到尾只认识代理,真实对象是被代理"挡"在后面调用的:

5)策略模式
策略模式(Strategy Pattern):把一组可互换的算法各自封装成类,让它们能在运行时自由替换。它最典型的用途,就是干掉臃肿的 if-else。
回头看第二节那个支付折扣的例子,改完是这样:
public interface PayChannel {
BigDecimal calc(BigDecimal amount);
}
public class AlipayChannel implements PayChannel {
@Override
public BigDecimal calc(BigDecimal amount) {
return amount.multiply(new BigDecimal("0.99"));
}
}
调用方从"判断类型"变成"取出策略直接算":
PayChannel channel = channelMap.get(type);
BigDecimal actual = channel.calc(amount);
channelMap 在应用启动时把所有实现注册进去,新增渠道等于加一个类加一行注册,老代码一行不动。
但这条得泼盆冷水。

策略模式最常见的误用,是为了消掉 3 个 if-else,引入了 6 个类。
模式是为了降低改动成本,不是为了代码好看。分支数量少、每个分支只有几行、变化频率又低,就老老实实写 if-else,这不丢人。
那什么时候该上策略?我的经验是满足下面两条再动手:分支数量 ≥ 4、每个分支超过 10 行、分支还在持续增加——三条里占两条。三个分支三年没动过还去抽策略,纯属给自己找活。

五、剩下的模式,混个脸熟就行
GoF 那 23 个模式按目的分成三类。除了上面重点讲的,剩下的不用背,知道它大概解决什么问题、被问到时能说出一个真实场景,就够了。
| 分类 | 模式 |
|---|---|
| 创建型(5) | 单例★、工厂方法★、抽象工厂★、建造者★、原型 |
| 结构型(7) | 代理★、适配器、桥接、组合、装饰器、外观、享元 |
| 行为型(11) | 策略★、责任链、命令、解释器、迭代器、中介者、备忘录、观察者、状态、模板方法、访问者 |
(带★的是本文重点讲过的)
剩下这些挑几个高频的,一句话过一下:
- 装饰器:JDK 那串
new BufferedReader(new InputStreamReader(...))就是它,不改原类、层层加功能。 - 观察者:事件监听、消息订阅,Spring 的
ApplicationEvent就是这套。 - 模板方法:父类定流程骨架、子类填具体步骤,
JdbcTemplate是这个思路。 - 责任链:过滤器链、审批流,Servlet 的
Filter是它。 - 适配器:老接口对接新系统时的转换层。
用不到的时候硬套,比不用还糟。
六、怎么选:别为了模式而模式
一张表收尾,遇到情况直接对号入座:
| 你遇到的情况 | 优先考虑 |
|---|---|
| 全局只需要一个实例(配置、连接池) | 单例 |
| 创建逻辑复杂,或要按类型决定造哪个 | 工厂 |
| 构造函数参数多且大多可选 | 建造者 |
| 要在方法前后加统一逻辑(日志、事务、鉴权) | 代理 |
| 一堆 if-else 分支还会继续加 | 策略 |
| 类职责混杂、改动理由不止一个 | 先回头用 SOLID 拆职责 |
三条要留神的误用:
- 为了消掉少量稳定分支引入策略 + 工厂,纯属过度设计,后人维护成本比原来还高。
- 单例当全局变量用,测试之间互相污染,最后谁也不敢动它。
- 迪米特法则包装成一层转发壳,多了一层却什么也没解决。
还有一条更根本的:设计模式是重构出来的结果,不是设计的起点。先写能跑通的代码,等第二、第三次改动找上门来,模式自己会浮出来。第一版就上全套模式,通常错得最贵——因为你对需求的理解,那时候还最浅。
我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你项目里最常用的是哪个设计模式,有没有被过度设计坑过?