1. 为什么值得专门整理一份GOF笔记
写代码写了这些年,回头看看,真正让我从“能跑就行”进化到“设计得还行”的转折点,就是认真啃了一遍GoF的《设计模式》。不过说句实话,光看书是不够的。那本书英文原版四百多页,每一段都精炼得跟宪法条文似的,看的时候觉得自己懂了,合上书写代码一动手,还是老一套。真正让这些模式在我脑子里扎根的,是我花了两周时间,用自己的项目代码和踩过的坑,重新整理了一份GOF笔记。这份笔记不追求覆盖全部23个模式的每一个字,而是把每个模式对应的场景、代码骨架和最容易翻车的地方提炼出来,形成一份可以随时翻阅的手册。
这份笔记适合谁?我觉得只要你写过一年以上面向对象代码,日常在跟类、接口、继承打交道,并且隐约觉得“代码越来越难改”“加一个新功能要动好多地方”,那这份笔记的思路就很值得参考。不管你是做Java、C#、Python还是Go(Go语言虽然不走传统继承路线,但大部分模式的思想照样能落地),整理一份自己的GOF笔记,本质上就是给自己建立一套“代码设计的决策清单”。
另一个很重要的原因是,网上关于设计模式的文章太多了,但大部分都犯了一个毛病:用动物园或者餐厅点餐的例子讲模式。例子是好例子,可一到真实项目中就不知道怎么套了。所以我的笔记里,每个模式我都强行找了一个真实系统中的映射,哪怕牵强一点,也比停留在概念上强。当你把抽象的模式和具体的代码场景绑在一起,记忆会牢固得多。
2. 笔记框架怎么组织:别照抄书上的章节
整理笔记的第一步不是记录,而是搭框架。GOF原书是按创建型、结构型、行为型三大类分的,这种分法学术上很严谨,但对于写代码的人来说,并不一定是最高效的检索方式。因为很多时候你打开笔记是想问“我现在这个需求该用什么模式”,而不是“创建型模式有哪些”。
我的笔记框架是按“解决什么问题”来组织的,分成五组:对象怎么创建、类与对象怎么组合、行为怎么复用和扩展、外部依赖怎么隔离、复杂流程怎么管理。这五组不是我凭空想的,是根据我做过的几个中大型项目的实际痛点归纳出来的。比如“外部依赖怎么隔离”这一组,收录了门面模式、适配器模式、代理模式——它们解决的问题都是“别让你的核心代码跟外部东西耦合太深”。
每一组下面,我的笔记结构是固定的五段式:
- 一句话说清模式本质:不超过三十个字,用自己的话。
- 解决什么具体问题:必须配上我遇到过的真实场景,哪怕是很小的场景。
- 代码骨架:最小可运行的代码片段,不搞花活,就是核心结构。
- 变体与取舍:这个模式在实际工程里常见的调整方式。
- 反模式预警:什么情况千万别用这个模式。
这套结构我建议你也直接用,因为它的核心逻辑是“从问题出发”,而不仅仅是“从模式出发”。比如说,当你遇到“我想给一个已有的类加功能,但又不想改它的代码”这个问题时,你能直接翻到“行为怎么复用和扩展”这一组,到装饰器模式和策略模式里去选,而不是把一个创建型模式硬套上去。
还有一点要提醒的是,笔记不要写成抄书。我见过很多人整理笔记就是把书上的定义复制一遍,然后用荧光笔划重点。那样的话,整理笔记这个动作本身没有给你带来任何增量价值。真正有价值的笔记,是你把别人的知识用自己的代码、自己的项目语境重新翻译了一遍。哪怕你翻译得不太准确,这个“翻译”的过程本身就是学习和内化。
3. 核心模式深度拆解:我的笔记里最常用的六个
3.1 策略模式:最容易被“过度设计”的模式
策略模式我这几年用得非常频繁,特别是做支付对接的时候。那时候接入过微信、支付宝、银联,每种支付方式的参数校验、签名算法、回调处理都不一样,但对外暴露的动作又都是“支付”和“回调处理”。最初的实现当然是if-else,后来加上银行卡支付、余额支付之后,一个方法里出现了四五个if分支,每个分支还有几十行的逻辑,已经没法看了。
用策略模式改完之后,结构一下子清晰了。核心思想其实就一句话:把算法封装成独立的类,让它们可以互相替换,调用方只面向接口编程。我的笔记里记的代码骨架大概是这样的:
public interface PayStrategy { void pay(String orderId, BigDecimal amount); void handleCallback(String rawCallbackData); } public class WechatPayStrategy implements PayStrategy { @Override public void pay(String orderId, BigDecimal amount) { // 微信支付特有逻辑:生成预支付单、调起支付 } @Override public void handleCallback(String rawCallbackData) { // 微信回调验签逻辑 } }注意一点,策略模式不是用来消灭if-else的。实际上在获取具体策略对象时,你仍然需要一个选择逻辑——要么用工厂、要么用Map注册。我用的是在支付工厂里维护一个Map<String, PayStrategy>,初始化时把所有策略注册进去,调用时直接按支付渠道名取,这才是实践中更常见的做法。
策略模式最大的坑是“小需求也套策略”。如果你只有一个实现,未来一年也看不到第二个实现的可能,那就别用策略模式,直接写一个普通类方法就够了。我刚开始学的时候犯过这个错误,给一个只有一种实现的计算逻辑套了策略接口,结果多写了一堆空接口,纯粹是自找麻烦。
3.2 观察者模式:事件驱动的最朴素形态
在做一个电商中台项目的时候,订单创建之后要触发一堆后续动作:发短信、发App推送、更新库存、给推荐系统发送行为数据。起初这些动作都是直接写在订单服务里,加一个动作就要改订单服务的代码,而且改动还要重新回归测试整个订单流程,非常痛苦。
观察者模式解决的就是这种“一个事件发生之后,多个对象需要响应,但响应方不应该被事件源硬编码”的问题。我用Spring的事件机制落地过,也用纯Java实现过。纯Java版本的骨架是这样的:
public class OrderEventManager { private final List<OrderEventListener> listeners = new ArrayList<>(); public void registerListener(OrderEventListener listener) { listeners.add(listener); } public void publishOrderCreated(Order order) { for (OrderEventListener listener : listeners) { listener.onOrderCreated(order); } } }后面接新功能的时候爽多了。比如后来要加一个“订单完成后赠送积分”的需求,我只需要新增一个PointsListener注册进去,订单核心代码一行都不用改。
观察者模式也有要注意的地方,最典型的问题是监听器的执行顺序。发布事件时监听器的调用顺序如果不可控,可能出现“先发短信通知用户,后减库存”这样的问题。另外,如果一个监听器抛异常,其他监听器还会不会执行?我的经验是,除非明确知道顺序和异常隔离的必要性,否则最好用线程池把每个监听器丢到独立线程去执行,或者至少在整个循环外套一层try-catch,避免一个监听器的故障拖垮整个链路。这一点在团队协作时尤其重要,因为别人写的监听器不受你控制。
3.3 工厂模式:把对象创建从业务代码里剥出去
工厂模式是23个模式里最容易被滥用、也最容易被误解的。它的本质不是“帮你创建对象”,而是“把创建对象的时机和方式从使用者那里隔离开”。
我以前维护过一个多数据源的报表系统,支持从MySQL、Oracle、ES三种数据源拉数据。每种数据源都要做连接、做查询语法适配、做结果集转换。报表业务代码不应该关心自己连接的是什么数据库,它只需要说“给我一个数据查询器”。这就是抽象工厂的典型场景。
实践中最常用的还是“简单工厂+反射注册”的组合。用这种方式,新增一种数据源只需要实现接口并注册,不需要修改工厂的代码:
public class QueryExecutorFactory { private static final Map<String, QueryExecutor> REGISTER = new ConcurrentHashMap<>(); static { REGISTER.put("mysql", new MysqlQueryExecutor()); REGISTER.put("oracle", new OracleQueryExecutor()); } public static QueryExecutor getExecutor(String type) { QueryExecutor executor = REGISTER.get(type); if (executor == null) { throw new IllegalArgumentException("unsupported type: " + type); } return executor; } }需要注意,工厂模式解决的是“创建逻辑复杂且会变化”的问题,如果你的对象构造函数就是简单的new X(),没有任何变化空间,那工厂就是多余的。有个判断标准我一直用:如果创建对象时需要根据配置、参数、环境做一系类判断,或者需要统一管理生命周期,工厂才值得用。
3.4 单例模式:最简单的模式,最容易写错
单例模式每个程序员都认识,但写对的人真不多。它解决的问题很单纯:有些对象全局只需要一个实例,比如配置管理器、线程池、连接池、日志器。但“全局只有一个”这个需求背后,隐藏着三个问题:线程安全、延迟加载、序列化破坏。
我以前写过一个配置管理类,用的就是最常见的双检锁写法,看起来一点问题没有:
public class ConfigManager { private static volatile ConfigManager instance; private ConfigManager() {} public static ConfigManager getInstance() { if (instance == null) { synchronized (ConfigManager.class) { if (instance == null) { instance = new ConfigManager(); } } } return instance; } }很多老手都会告诉你volatile关键字加上双检锁就够了,这话在绝大多数场景下没问题。但如果你做的是框架级代码,还得防止反射构造和序列化破坏。反射可以强行调用私有构造器,序列化反序列化也能产生新实例。不过我还是坚持一个务实的原则:绝大多数业务系统,双检锁+volatile就已经足够了。为了防御完全不可能出现的反射攻击而写上几百行枚举实现,属于为了炫技而设计。
单例模式真正的负面影响不是写错,而是被滥用。当一个对象承担了太多职责,你让它变成单例,就等于把这份重量焊死在全局了。我见过把业务Service写成单例的,而那个Service内部还存了可变的业务状态,后来线上就出现了“A用户的请求把B用户的数据给覆盖了”这种诡异事故。所以我在笔记里专门给自己加了一条警告:单例只适合无状态或者状态全局一致的对象,有状态的业务对象千万别用单例。
3.5 装饰器模式:比继承更灵活的功能扩展
给一个类加功能,最朴素的想法是继承,但继承是静态的、编译期绑定的,而且会把一整套父类行为都继承下来,造成大量无用的暴露。装饰器模式是在“不修改原类代码、不改变调用方式”的前提下,给对象动态添加职责。
最典型的就是Java IO。BufferedInputStream就是InputStream的一个装饰器,它不改变读字节这个核心行为,只是加了一层缓冲。我用装饰器比较多的地方是给支付接口加日志、加耗时统计、加重试机制。这些都是横切关注点,不应该侵入核心支付逻辑。
装饰器的实现核心就是组合+转发。骨架大概是:
public class LoggingPayDecorator implements PayStrategy { private final PayStrategy delegate; public LoggingPayDecorator(PayStrategy delegate) { this.delegate = delegate; } @Override public void pay(String orderId, BigDecimal amount) { long start = System.currentTimeMillis(); try { delegate.pay(orderId, amount); } finally { System.out.println("pay cost " + (System.currentTimeMillis() - start) + "ms"); } } }但装饰器有个麻烦的地方:包多了之后对象嵌套层次很深,Debug的时候调用栈看起来非常吓人。而且如果装饰器之间还有依赖关系(比如重试装饰器依赖于日志装饰器先填了某些上下文),代码管理的复杂度就会直线攀升。我的经验是,装饰器适合3层以内的轻量横切逻辑,超过3层就要考虑用责任链或者代理框架(比如Spring AOP)来解决了。
3.6 模板方法模式:把流程定死,细节留开
模板方法模式,我的理解是“父类写剧本,子类当演员”。它是GUI框架和流程引擎里非常常见的模式,在业务系统里也经常遇到:比如一个审批流程,大致骨架是提交→校验→业务处理→通知,但每种审批单的校验规则和处理逻辑都不同。父类把这个四步流程固定下来,子类只实现每一步的差异部分。
写模板方法模式的时候,我觉得两个关键细节决定成败:
第一,骨架方法的访问权限应该设计成final,防止子类不小心重写了整个流程。我见过一个团队,父类的骨架方法没锁死,结果有人为了图省事直接把整个模板流程在子类里重写了一遍,后来流程改了要改五六个子类,简直是灾难。
第二,差异方法尽量抽象成“钩子”而不是“必须实现的步骤”。比如“是否执行某一步骤”这种判断,用isXxxNeeded()这样的钩子方法,默认返回true,子类按需覆盖。这样新增子类时的负担最小,不会为了不关心的步骤写空实现。
当然模板方法不是银弹。如果你的“流程骨架”本身也会变,比如有时候三步有时候五步,那模板方法就僵化了。这时候用策略模式把“流程”本身作为策略传入,反而更灵活。模式之间本来就是可以组合的,关键是看变化的维度在哪里。
4. 用生活化类比帮助记忆:一份私人的模式隐喻表
整理笔记的过程中我发现一个规律:那些我能记住的模式,不是因为背得熟,而是因为我在脑子里建立了一个鲜明的“画面”。抽象的概念一旦绑定了具体的图像,记忆就牢固了。所以笔记里我专门建了一张“模式隐喻表”,每个模式配一个自己的比喻。
- 策略模式= 换轮胎。轮胎的尺寸、花纹可以换,但换轮胎的过程(顶起车、拧螺丝)不变。
- 观察者模式= 订阅报纸。报社不知道你有多少人订阅,但报纸一出刊,所有订户都会收到。
- 工厂模式= 食堂打饭。你不用管今天师傅炒了什么菜,只要说“打一份套餐”,师傅按菜单做出来给你。
- 单例模式= 公司的唯一打印机。全公司共享这一台,不允许私自再装一台。
- 装饰器模式= 穿衣服。穿外套不改变你是“人”这个本质,但给你增加了保暖功能。
- 模板方法模式= 做菜谱上固定步骤的菜:备料、切菜、炒制、装盘,但每个厨师具体怎么做不同。
- 适配器模式= 旅行转换插头。你的设备插头是两脚的,墙上插座是三孔的,转换插头让两者匹配。
- 代理模式= 经纪人或助理。电话先打到经纪人那里,经纪人过滤之后才转给本人。
- 责任链模式= 层层审批的报销单。从组长到经理到老板,每个人只处理自己能处理的金额。
- 迭代器模式= 翻书翻阅。不管书是厚是薄,只要顺着页码一页一页翻就行。
这些比喻不追求百分之百准确,但它们帮我完成了从“抽象描述”到“直觉理解”的转换。你在整理笔记时也可以做类似的事情,关键是用自己熟悉的生活场景,而不是网上的标准比喻。记忆心理学里有个说法叫“精细编码”,意思是说把新知识和已有的知识经验绑在一起,记忆效果远好于机械重复。我深以为然。
5. 实战中最容易踩的坑:五个血的教训
5.1 为了模式而模式
我刚学设计模式的时候,最大的毛病就是“手里拿着锤子,看什么都是钉子”。学了策略模式就想着把if-else全改了,学了单例就想着把所有Manager类都搞成单例。结果代码变得极其绕,一个业务逻辑要穿过五六层抽象才能看懂。
设计模式的核心是“在适当的时候解决适当的问题”,不是代码风格的装饰品。一个原则我后来一直遵守:**没有两个以上的可能变体或替代方案,就不用引入模式。**比如前面说的支付策略,是因为真的有四五个渠道且还在不断增加,才值得用策略。如果你只有一个实现,就老老实实写普通方法,等第二个实现真的出现了再重构也不迟。
还有一个判断标准非常好用:**如果以后要改这个需求,改动点是集中在一处还是会散落到很多地方?**模式的引入应该是让“改动点”尽量集中的。如果重构完之后,加一个功能反而要动更多文件,那这个模式大概率用错了。
5.2 接口设计得太重
业务系统里最常见的反模式是“万能接口”。一个接口里塞了五六个方法,新实现类为了凑数,不得不写空实现或者抛异常。这在适配器和策略模式里特别常见。
比如我见过一个团队设计的消息发送策略接口,里面同时包含了sendEmail、sendSms、sendAppPush三个方法。结果短信策略实现类里sendEmail直接抛UnsupportedOperationException,推送给策略实现类里sendSms也是空方法。这就是接口隔离原则被破坏的典型案例。正确做法是拆分成三个独立接口,或者用“实现类按需选择”的方式设计,比如使用默认方法(default method)时不要滥用,因为默认方法本质上会让接口承担太多隐性职责。
5.3 忽略了模式的代价
每个模式都有代价,只是很多教程不告诉你。策略模式的代价是多了一堆类和接口,类爆炸;观察者模式的代价是调用链不确定性,出问题难排查;代理模式的代价是性能损耗和调试困难;单例模式的代价是全局状态和测试困难。
所以我在笔记每个模式的“反模式预警”里,都会写清楚引入这个模式后我实际付出的额外成本。比如观察者模式,我付出的代价是有一次一个监听器抛了个NPE,导致事件发布链路中断,后面的监听器全没执行。那次线上故障之后,我强制规定所有监听器必须实现异常隔离。这个教训后来就成了笔记里很重要的一条提醒。
5.4 模式落地时没有考虑团队认知水平
这是个很多人忽略的问题。设计模式是团队协作的工具,而不是一个人的自嗨。如果你用了责任链模式,但团队里大多数人从来没写过责任链,那你就要考虑代码的可维护性。一个模式用得再优雅,如果别人看不懂,他修改的时候最可能做的事情就是绕过你的优雅设计,在旁边直接加一个if-else。
我的做法是,重要项目里引入不常见的模式之前,先给团队做一次简短的分享,把核心思想和代码结构讲一遍,同时在代码注释里写好“为什么这么做”的上下文。技术分享这种事,看似费时间,实际上能省下未来大把的沟通成本。另外,代码Review的时候也要特别关注“模式使用是否克制”这个问题,如果引入的模式只是为了好看而没有解决实际问题,就果断退回去。
5.5 学完就忘:模式需要刻意练习
最后一个坑,也是大多数人学设计模式失败的原因——没有刻意练习。看书、抄代码、背定义,这些都属于“输入”,而真正让模式内化的是“输出”。
所谓输出,就是找几个真实的需求场景,强迫自己用不同的模式去实现,然后对比它们各自的优劣。比如我做过一个练习:同样的“运费计算”需求,分别用策略模式、装饰器模式、责任链模式、模板方法模式写一遍,然后从可扩展性、可读性、性能、测试难度四个维度打分。做一遍这样的对比,比看十篇文章都管用。因为这些模式在你手里真正“交过手”了,它们的边界在哪里,各自的优势是什么,你就会有一个切身的体会。
6. 从GOF笔记到架构思维:设计模式的进阶路径
整理完GOF笔记之后,我最大的感受是:**设计模式的终点不是记住模式,而是形成架构思维。**所谓架构思维,就是当你看到一个需求的时候,脑子里自然浮现出的不是某一个具体模式,而是一幅权衡图:变化在哪里、稳定在哪里、该用什么机制去隔离变化。
拿一个最简单的例子说,用户要求“增加一个充值渠道”。没有架构思维的开发,第一反应是“在业务代码里加一个if-else分支”。有架构思维的开发,第一反应是“充值渠道是一个变化点,应该用一个稳定的机制来隔离它”。这时候他们可能会想到策略模式、工厂模式、或者依赖注入的方式,但不管具体用什么,核心的思路是一致的:识别变化,封装变化,隔离变化。
设计模式就是这个过程的工具箱。23个模式本质上是23种常见的“变化点封装方式”的总结。你在笔记上花的时间越多,对这些封装方式的理解就越深,遇到新问题时做判断的速度就越快。
进阶的第二步是从模式上升到原则。阅读GOF笔记的时候,你会发现很多模式背后重复出现的几条原则:面向接口编程而不是面向实现、组合优于继承、开闭原则、依赖倒置。这些才是比模式更底层的思想。掌握了一个模式,你解决的是一个问题;掌握了背后的原则,你可以自己发明模式解决一类问题。
我之前在做日志系统的时候,需要设计一个“多级缓存写入”的机制。一开始套模板方法,后来发现缓存层数不确定,又改成了责任链,但写起来总是别扭。最后回过头来想想,其实核心原则就是“每一个缓存层只关心自己这层的写入策略,同时把请求往下传”。想通了这一点,具体用什么模式反而不重要了——我用了一个非常类似责任链但又不完全一样的自定义结构,反而更贴合当时的场景。这就是从“模式思维”升级到“原则思维”之后的自由度。
所以我的建议是,GOF笔记不是用来背的,是用来越翻越薄的。第一遍整理时你可以事无巨细,第二遍就只留下你自己踩过坑的模式,到第三遍,你甚至可以把大部分笔记删掉,只留下一张“模式选择路线图”。到了那个阶段,设计模式就真正成为你思维的一部分了,而不是脑子里的一个负担。
我个人在实际操作中的体会是,这份笔记带给我最大的价值不是某个模式本身,而是让我养成了一种条件反射:遇到一个需求,先停下来问自己“哪里会变?哪里稳定?怎么隔离?”带着这种思维去写代码,哪怕不用任何模式,代码的质量也会比之前高一个台阶。整理GOF笔记,表面上是在学23个模式,实际上是在练一种面向变化的思维方式。