设计模式入门:吃透 SOLID 原则与迪米特法则,再学 5 个高频模式

大家好,我是晚安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):一个类应该只有一个引起它变化的原因。别数方法有几个,数"谁会来要求你改它"。

示例场景,订单服务:

java 复制代码
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):对扩展开放,对修改关闭。加新功能时应该是新增代码,而不是改动已经跑通的老代码。

反例,支付折扣:

java 复制代码
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):子类必须能顶替父类出现在任何位置,且不破坏程序原本的逻辑。父类承诺过的事,子类不能打折。

最经典的翻车案例,正方形继承长方形:

java 复制代码
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; }
}

调用方老老实实按父类的契约写:

java 复制代码
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() 或者空方法体,基本就是接口太胖了。

反例:

java 复制代码
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):高层模块不该依赖低层实现,两者都应该依赖抽象。

反例:

java 复制代码
public class OrderService {
    private final MySQLOrderRepository repo = new MySQLOrderRepository();
}

OrderService 是业务高层,MySQLOrderRepository 是存储细节,现在高层直接焊死在 MySQL 上。想换 MongoDB 要改业务代码,想写个单测得先起一个数据库实例。

改法是把实现从构造函数塞进来:

java 复制代码
public OrderService(OrderRepository repo) {
    this.repo = repo;
}

依赖接口而不是具体类------这正是 Spring 依赖注入每天在做的事,只是框架替你写了那行 new。

三、迪米特法则:少跟陌生人说话

迪米特法则(LoD,Law of Demeter):又叫最少知识原则,一个对象应该尽量少了解其他对象的内部结构,只跟自己的"直接朋友"打交道。

打个生活化的比方:你想让隔壁公司的老张帮你盖个章,正常做法是找你们对接的同事说一声。要是你绕过同事,直接去摸老张的上司、上司的助理、助理的实习生------这条链上任何一环换人,你就得重新走一遍流程。

代码里的反例是这种"火车残骸"式调用:

java 复制代码
// 坏:一路 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 里几种写法,从最土到最稳。

饿汉式,类加载时就把实例建好,简单但不管用不用都占着内存:

java 复制代码
public class Config {
    private static final Config INSTANCE = new Config();
    private Config() {}
    public static Config getInstance() { return INSTANCE; }
}

双重检查锁,面试常问的那版,注意 volatile 一个字都不能省:

java 复制代码
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 极难复现,也极难排查。

枚举式,我现在的默认选择:

java 复制代码
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 组件这类产品族

简单工厂的实现最直观:

java 复制代码
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 个,且大部分是可选的。

反例:

java 复制代码
new Order("A001", 2, null, true, false, null, "顺丰", 3);

读到第三个参数,你就已经忘了第一个是什么了。改完:

java 复制代码
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 动态代理的核心骨架长这样:

java 复制代码
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。

回头看第二节那个支付折扣的例子,改完是这样:

java 复制代码
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"));
    }
}

调用方从"判断类型"变成"取出策略直接算":

java 复制代码
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 拆职责

三条要留神的误用:

  1. 为了消掉少量稳定分支引入策略 + 工厂,纯属过度设计,后人维护成本比原来还高。
  2. 单例当全局变量用,测试之间互相污染,最后谁也不敢动它。
  3. 迪米特法则包装成一层转发壳,多了一层却什么也没解决。

还有一条更根本的:**设计模式是重构出来的结果,不是设计的起点。**先写能跑通的代码,等第二、第三次改动找上门来,模式自己会浮出来。第一版就上全套模式,通常错得最贵------因为你对需求的理解,那时候还最浅。


我是晚安code,持续分享编程干货。觉得有用的话记得点赞收藏和关注~也欢迎在评论区聊聊:你项目里最常用的是哪个设计模式,有没有被过度设计坑过?

相关推荐
专业程序开发源4 小时前
django新闻推荐系统70655-计算机课程设计、毕业设计
java·javascript·spring boot·后端·python·django·课程设计
打工仔折腾 AI4 小时前
把 AI Agent 托管到家里电脑:UU远程端口映射与CLI实测记录
人工智能·后端·python·langchain·电脑·ai agent 实战
vx_Biye_Design4 小时前
springboot一站式旅游管理平台81037-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·课程设计·express·旅游
嵌入式学习菌5 小时前
ESP32 ModbusTCP 分片缓存
java·后端·spring
程序员小杰@5 小时前
Spring Boot 常用注解分类速记
java·spring boot·后端
挖掘狂人5 小时前
Git 从 0 到 1:用一个小项目走完 add / commit / reset / merge / rebase / push
git·后端·github
vx_Biye_Design5 小时前
springboot小学生英语学习APP62773-计算机课程设计、毕业设计
java·vue.js·spring boot·后端·python·学习·课程设计
打工仔折腾 AI5 小时前
用UU远程把家里电脑变成AI Agent常驻服务器:CLI、端口映射与代理实测
运维·服务器·人工智能·后端·python·电脑·ai agent 实战
明月_清风5 小时前
AI Agent 进入系统层:现在有哪些开源项目在解决这个问题?
人工智能·后端