
1. 设计模式到底解决了什么问题先别急着背 UML很多初学者看到设计模式四个字第一反应是去背类图、记结构结果越学越抽象一写业务代码还是老样子。我刚开始接触时也走过这个弯路直到后来被一个同事点醒设计模式的本质是解决代码改不动的问题。什么是改不动最典型的表现就是你加一个新需求发现要动的地方牵一发动全身或者某个类又长又臭几千行代码里全是 if-else 判断类型又或者一行代码报错你追了半小时才发现问题出在三个模块之外的某个隐蔽耦合点。这些痛苦的根源其实都是代码结构没有做好变化隔离。设计模式就是在面向对象设计里被反复验证过的一些套路它告诉你针对某种反复出现的设计问题可以这样拆分职责、这样定义接口、这样组织对象关系让代码在将来面对变化时只需要动最小范围的地方。这篇文章我不会铺开讲 23 种经典模式。只挑实际项目里出现频率最高的 4 种——策略模式、模板方法模式、观察者模式、单例模式——配套可运行的核心代码片段说明用法。这 4 个模式覆盖了我在日常开发里大约八成以上的应用场景学完就能直接落到自己的项目里不搞花架子。先建立一个共识设计模式不是性能优化工具不是代码规范工具而是代码的组织工具。它让你把做什么和怎么做分开把固定的流程和变化的部分分开把事件产生者和事件处理者解耦。理解了这三个分开接下来的代码示例你看起来就会豁然开朗。2. 策略模式把 if-else 从你的代码里赶出去2.1 什么时候该用策略模式策略模式解决的是多种算法/多种行为运行时动态选择的问题。最典型的场景就是订单折扣计算、支付渠道选择、消息推送渠道选择、不同文件的解析方式。我用一个大家都能看懂的场景来举例商城系统里计算订单折扣。很多人在刚入行时会这样写public double calculateDiscount(Order order) { double discount 0.0; if (VIP.equals(order.getUserType())) { discount order.getAmount() * 0.85; } else if (GOLD.equals(order.getUserType())) { discount order.getAmount() * 0.80; } else if (NORMAL.equals(order.getUserType())) { discount order.getAmount() * 0.95; } return discount; }这段代码看着简单但一旦用户类型从 3 种变成 8 种这个方法的 elif 分支就会爆炸式增长。更麻烦的是每次新增用户类型你都要进到这个方法里改代码——这就违反了我们常说的开闭原则对扩展开放对修改关闭。2.2 策略模式的标准写法策略模式的核心拆法是三步定义策略接口、实现具体策略类、让调用方持有策略。第一步定义策略接口public interface DiscountStrategy { double calculate(Order order); }第二步为每种用户类型实现一个策略类public class VipDiscountStrategy implements DiscountStrategy { Override public double calculate(Order order) { return order.getAmount() * 0.85; } } public class GoldDiscountStrategy implements DiscountStrategy { Override public double calculate(Order order) { return order.getAmount() * 0.80; } } public class NormalDiscountStrategy implements DiscountStrategy { Override public double calculate(Order order) { return order.getAmount() * 0.95; } }第三步改造调用方不再通过 if-else 判断类型而是直接依赖接口public class OrderService { private DiscountStrategy discountStrategy; // 通过构造方法或 setter 注入策略 public OrderService(DiscountStrategy discountStrategy) { this.discountStrategy discountStrategy; } public double calculateOrderAmount(Order order) { return discountStrategy.calculate(order); } }如果构建 OrderService 时用的是 Spring通常就直接注入一个 MapString, DiscountStrategy把策略类的 Bean 名作为 key运行时按用户类型取值。这已经是在业务项目里最常见的改法了。2.3 策略模式在 Spring 项目中的实际落地方式如果你的项目是 Spring Boot策略模式还能再往前走一步利用依赖注入彻底摆脱手动工厂。核心逻辑是用一个 Map 把所有的策略实现收集起来再按需取出。Service public class DiscountStrategyContext { private final MapString, DiscountStrategy strategyMap; // Spring 会把所有 DiscountStrategy 的实现类注入到这个 Map 中 public DiscountStrategyContext(MapString, DiscountStrategy strategyMap) { this.strategyMap strategyMap; } public double executeStrategy(String userType, Order order) { DiscountStrategy strategy strategyMap.get(userType.toLowerCase()); if (strategy null) { throw new IllegalArgumentException(Unsupported user type: userType); } return strategy.calculate(order); } }策略实现类上标注 Bean 名称比如Component(vip)、Component(gold)这样 Map 的 key 就对应到了用户类型。新增一种用户类型时只需要新增一个实现类业务调用处一行都不用改。这就是策略模式最实际的收益面向扩展开放面向修改关闭。策略模式的判断标准其实很简单你的代码里有没有出现同一个意图、多个实现、靠条件分支选择的情况有就值得重构。2.4 策略模式容易踩的两个坑第一个坑策略类过多。当策略实现类超过 10 个时光靠 Map 注入类数量会显得很零散。这时候建议配合注解驱动用一个自定义注解标注策略类型写一个注册器在启动阶段统一扫描注册维护性会好很多。倒不是说 Map 方式不行而是类一多分散在多个文件里的策略实现别人接手时需要额外花时间去梳理哪个策略对应哪种场景。第二个坑策略模式不是万能的。如果业务逻辑里不同用户类型走的不只是折扣率不同而是整个业务流程都不一样比如 VIP 用户要走特殊审核流程那要考虑的就不是策略模式而是下面的模板方法模式或者责任链模式了。判断的核心是变化的维度是算法细节还是流程骨架。3. 模板方法模式把固定流程和可变步骤解耦3.1 先理解流程骨架这个概念模板方法模式解决的是另一个典型问题多个业务流程的步骤顺序固定但其中某几步的具体实现不同。举个例子数据同步任务。无论是从 A 渠道同步用户数据还是从 B 渠道同步商品数据流程骨架都差不多拉取数据、校验数据、转换格式、写入数据库、记录日志。但每一步的具体实现完全不同。如果你在每个业务类里都把这段流程写一遍就会出现大量重复代码而且一旦流程中需要加一步比如加个告警通知你就得同时改好几个类。模板方法模式的做法是把流程骨架定义在父类里把可变步骤设计成抽象方法或钩子方法让子类只负责实现自己关心的那部分。3.2 用一个数据同步场景走通完整代码先定义抽象父类把固定流程写好public abstract class AbstractDataSyncTask { // 模板方法定义算法骨架用 final 防止子类修改流程 public final void execute() { Object rawData fetchData(); boolean isValid validate(rawData); if (!isValid) { logAndSkip(); return; } Object convertedData convert(rawData); saveData(convertedData); afterCommit(); } // 抽象方法子类必须实现 protected abstract Object fetchData(); protected abstract boolean validate(Object rawData); protected abstract Object convert(Object rawData); protected abstract void saveData(Object data); // 钩子方法默认空实现子类可选择性地覆盖 protected void afterCommit() { // 子类可覆盖用于收尾日志、发送通知等 } // 公共的兜底行为 private void logAndSkip() { System.out.println(数据校验失败任务终止); } }然后是两个子类只实现自己的差异化逻辑public class UserDataSyncTask extends AbstractDataSyncTask { Override protected Object fetchData() { // 从用户中心接口拉取增量数据 return rawUserData; } Override protected boolean validate(Object rawData) { // 校验用户数据的必填字段 return true; } Override protected Object convert(Object rawData) { // 把外部数据转换为本地用户实体 return convertedUser; } Override protected void saveData(Object data) { // 写入用户表 } Override protected void afterCommit() { // 发一封通知邮件 } }public class ProductDataSyncTask extends AbstractDataSyncTask { Override protected Object fetchData() { // 从商品中心接口拉取数据 return rawProductData; } Override protected boolean validate(Object rawData) { return true; } Override protected Object convert(Object rawData) { return convertedProduct; } Override protected void saveData(Object data) { // 写入商品表 } }看到区别了吗用户同步任务覆盖了 afterCommit 钩子商品同步任务没有。父类代码完全复用流程固定、步骤可变这对矛盾就这样被化解了。3.3 模板方法与控制反转的关系这里有个值得玩味的点模板方法模式其实是控制反转思想的一种体现。通常我们写代码是子类调用父类的方法但模板方法模式里反过来父类在 execute 方法里主动调用了子类实现的抽象方法。这个反向调用就是好莱坞原则别给我们打电话我们会给你打电话。理解这一点再去看 Spring 里的 JdbcTemplate、RedisTemplate包括很多框架里的 AbstractAdapter、AbstractHandler你会发现它们的骨架设计思路几乎同源把不可变的部分写死在父类把可变的部分留给子类覆盖。读源码时遇到这种抽象类第一反应就应该是去找它的 protected 方法——那些就是设计者留给你的扩展缝。3.4 什么样的场景别硬套模板方法模板方法不适用的场景也同样值得注意当子类之间的流程差异过大超过某个阈值比如每一步都要重写或者需要跳过中间步骤步骤数都不一样父类的骨架就失去意义了。这时候强行抽象父类只会造出一个所有方法都是空实现、子类全部要靠重写才能工作的假模板。遇到这种情况说明拆分的维度错了应该回到业务流程本身去重新梳理到底是统一流程里的差异化步骤还是多个各不相同的流程另外模板方法模式会增加类层次继承本身就带着强耦合属性。能用组合解决的问题优先考虑组合模板方法更适合那些结构稳定、短期内流程不会大改的中间层设计。4. 观察者模式解除通知方和被通知方的强绑定4.1 毫无设计感的事件通知是怎样的观察者模式对应的是一改多动的联动问题。你在业务里大概率见过这种代码public void orderPaid(Order order) { // 更新订单状态 order.setStatus(PAID); orderDao.update(order); // 发短信通知用户 smsService.send(order.getUserId(), 您的订单已支付); // 推送优惠券 couponService.issue(order.getUserId()); // 通知仓库发货 warehouseService.notifyShip(order); }这个 orderPaid 方法看起来功能完整实际上是定时炸弹。每增加一个支付后要做的事这个方法就要改一次而且所有逻辑同步执行只要某个服务超时整个支付流程都会被拖慢。观察者模式的做法是订单支付这个事件本身只负责发布事件至于谁关心这个事件、关心之后干什么都由独立的观察者自行处理。4.2 Java 自带观察者模式的标准代码在 Java 里最直接的方式是实现一个简单的事件发布机制不需要引入任何额外框架。先定义事件对象public class OrderPaidEvent { private Order order; public OrderPaidEvent(Order order) { this.order order; } public Order getOrder() { return order; } }再定义观察者接口和发布器public interface OrderPaidListener { void onOrderPaid(OrderPaidEvent event); } public class OrderEventPublisher { private final ListOrderPaidListener listeners new ArrayList(); public void registerListener(OrderPaidListener listener) { listeners.add(listener); } public void publishOrderPaid(Order order) { OrderPaidEvent event new OrderPaidEvent(order); for (OrderPaidListener listener : listeners) { listener.onOrderPaid(event); } } }最后把短信、优惠券、仓库这些逻辑拆成独立的监听器public class SmsListener implements OrderPaidListener { public void onOrderPaid(OrderPaidEvent event) { // 发送短信 } } public class CouponListener implements OrderPaidListener { public void onOrderPaid(OrderPaidEvent event) { // 发放优惠券 } } public class WarehouseListener implements OrderPaidListener { public void onOrderPaid(OrderPaidEvent event) { // 通知仓库 } }orderPaid 方法被简化为两件事更新订单状态、发布事件。之后的联动逻辑全部由监听器消化。新增支付后送积分只需要新增一个监听器注册进 publisher其他代码一行不动。4.3 Spring 事件机制观察者模式的工程化形态大多数真实项目不会徒手写这种 publisher而是直接用 Spring 的ApplicationEventPublisher。它本质上就是一个更完善的观察者模式实现自带同步/异步切换、事件多播器等机制。用 Spring 重构上面的场景代码量能再降一个量级Service public class OrderService { Autowired private ApplicationEventPublisher eventPublisher; public void orderPaid(Order order) { order.setStatus(PAID); orderDao.update(order); // 发布事件不需要关心谁监听 eventPublisher.publishEvent(new OrderPaidEvent(order)); } }监听器只需要一个EventListener注解Component public class CouponListener { EventListener public void onOrderPaid(OrderPaidEvent event) { // 发放优惠券 } }如果想要异步执行加一个Async注解就够了。但是异步有个需要注意的地方监听器一旦异步就一定不要依赖事件处理顺序也别在同一线程里共享 ThreadLocal 数据。我之前见过一次事故某个监听器在异步后拿不到登录用户信息排查到凌晨才发现是Async把线程切走了ThreadLocal 里的用户上下文全丢了。这种坑在观察者模式的实战中几乎必踩提前有个预期能省很多事。4.4 同步还是异步别拍脑袋决定观察者模式有一个工程选择题事件监听应该是同步还是异步。同步事件的优点是语义简单发布事件后监听器立即执行完事务内数据一致性好报错也能推动事务回滚。缺点是发布线程会被监听器拖慢。异步事件则反过来发布者秒回但数据一致性窗口变大如果是同一个数据库事务内的监听器异步后事务边界就完全分开了异常也不会影响主流程。我的建议是默认先同步除非你明确知道某个监听器比较耗时、并且监听器失败不影响主流程才改成异步。我说的这个原则是在好几个项目里验证过大多数感觉应该异步的监听器实际上改成异步后反而引入了更多一致性问题。观察者模式本身只是解耦代码结构不负责替你解耦事务边界。5. 单例模式多线程环境下的唯一实例该怎么写5.1 为什么单例是最容易写错的一种模式单例模式在概念上最简单一个类只允许创建一个实例。但它是这 4 种模式里最容易在多线程环境下写出 BUG 的一个。不少人对单例的印象还停留在把构造方法私有化就算完事一旦并发量上来就会发现实例被创建了不止一个单例名存实亡。先看一个最常见的错误写法public class ConfigManager { private static ConfigManager instance; private ConfigManager() {} // 错误示例非线程安全 public static ConfigManager getInstance() { if (instance null) { instance new ConfigManager(); } return instance; } }这个写法在单线程下没问题但多线程并发调用 getInstance 时两个线程可能同时看到 instance 为 null然后各自 new 一个对象。虽然在某些 JVM 实现下概率不高但在高并发的系统初始化阶段后果是不确定的。5.2 懒汉式、饿汉式、双重检查锁、静态内部类哪一种更适合你Java 世界里单例的主流写法有几种我按推荐度从低到高聊一下。饿汉式类加载时就创建实例天然线程安全缺点是无法做到懒加载。如果这个单例创建成本很高但业务上又不是总会用到就会造成无谓的资源消耗。优点则是代码极简、绝对安全public class ConfigManager { private static final ConfigManager INSTANCE new ConfigManager(); private ConfigManager() {} public static ConfigManager getInstance() { return INSTANCE; } }懒汉式 方法同步把 getInstance 方法加上 synchronized线程安全了但每次调用都要过锁性能有损耗虽然现代 JVM 对无竞争锁做了大量优化但在高并发读的场景下依然不够优雅public class ConfigManager { private static ConfigManager instance; private ConfigManager() {} public static synchronized ConfigManager getInstance() { if (instance null) { instance new ConfigManager(); } return instance; } }双重检查锁DCL只在 instance 为 null 时才加锁锁定后再检查一次是懒加载 高性能的常见方案。但这里有个关键细节单例实例字段必须用 volatile 修饰否则在高并发下有可能拿到一个半初始化的对象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因为new ConfigManager()在字节码层面不是原子操作它大致分为三步分配内存、调用构造方法初始化、把引用赋值给 instance。在不做特殊处理的情况下第三步有可能被 JIT 重排序到第二步之前导致另一个线程拿到一个分配完内存但还没初始化完成的对象引用。volatile 在这里的作用就是禁止这种重排序保证先初始化完成再引用赋值。静态内部类这是我最喜欢也最推荐的一种写法它同时满足了懒加载、线程安全、代码简洁三项要求。利用的是 JVM 类加载机制——内部类不会被立即加载只有真正调用 getInstance 时才会触发加载而 JVM 保证一个类的类加载过程是线程安全的public class ConfigManager { private ConfigManager() {} private static class InstanceHolder { private static final ConfigManager INSTANCE new ConfigManager(); } public static ConfigManager getInstance() { return InstanceHolder.INSTANCE; } }5.3 枚举单例防御反射攻击的终极大招如果要求上升一层要考虑防御反射攻击和序列化破坏那终极答案就是枚举单例。Java 规范保证了枚举实例只能有一个反射也无法绕过这种限制public enum ConfigManagerEnum { INSTANCE; private String configPath; public void setConfigPath(String path) { this.configPath path; } public String getConfigPath() { return configPath; } }调用处直接ConfigManagerEnum.INSTANCE.getConfigPath()即可。不过说实话在 Spring 管理的容器环境下大多数单例需求根本不需要自己写注册为Component或Service后Spring 容器本身保证了每个 Bean 默认只有一个实例。手写单例模式更多地出现在工具类、SDK、框架组件的设计中。5.4 单例模式在 Spring 容器中的对应关系一定要分清楚单例模式和Spring 单例 Bean不是一回事。Spring 里一个默认的单例 Bean指的是在同一个 Spring 容器中只存在一个实例它由容器负责创建和持有生命周期由容器管理。而你手写的单例类是 JVM 层面保证全局唯一实例。两者解决的问题不同。在引入 Spring 的项目中如果一个状态类既被声明为Component又手写了私有构造方法反而可能触发 Spring 的实例化异常——这种两层单例策略的冲突我在新人的代码里见到不止一次。6. 从入门到实战如何把设计模式真正揉进你的代码里6.1 模式不是越多越好先关注变化点学完这 4 种模式最容易出现的误区是为了用模式而用模式。我见过有人给一个只调用一次的简单方法套上策略模式类数量翻了三倍可扩展性没有半分提升——这就是过度设计。真正务实的做法是先找出你的代码里最可能发生变化的地方。什么叫做可能发生变化对外部接口的适配逻辑、对多类型业务的分支判断、跨模块的事件联动、对全局共享资源的访问这四类需求在真实项目里变化频率最高恰好对应了本文的四种模式。换句话说模式是跟着变化点走的不是跟着代码行数走的。代码里找不到明显的变化点就别急着抽象。6.2 一个混合使用的实践策略模板方法观察者实际项目中的模式使用很少是孤立的。这里分享一个我在一个订单促销模块里的设计思路算是把这几种模式串起来的一次演练整个促销活动有一个固定的参与流程校验参与资格、计算优惠金额、记录参与记录、发放活动奖励。这个流程我用模板方法模式定义四个步骤的默认实现写在抽象父类里。因为不同活动的优惠计算规则完全不一样我把计算优惠金额这个步骤用策略模式实现通过活动类型选择具体的计算策略。用户参与活动后需要发送站内信、送积分、更新用户标签这些联动用观察者模式发布一个 ParticipationSuccessEvent 事件由各监听器自行处理。这套组合下来新接一个促销活动类型时我只需要新增一个策略类最多再加一个监听器模板骨架和核心流程完全不用动。三个模式各司其职没有互相打架也没有任何多余的抽象。这个案例如果你想深入研究可以用一个叫模拟项目X的代码片段来还原思路是完全可复用的。6.3 给新人的三条实操建议第一从重构开始练不要从新项目开始练。找一个你写过的最乱的 if-else 分支方法先试试用策略模式重写它。重写完对比一下新增一个分支时老代码和新代码各需要改多少处体会这种改动范围的差异比看十篇教程都印象深刻。第二画变化点地图。动手写代码前用几分钟时间列出这个模块里近期需求最可能引入变化的位置可能是支付渠道列表、消息通知事件种类、第三方 API 适配层。按照这张地图决定在哪里引入模式。没有这张地图模式用在哪、该不该用永远是懵的。第三遇到框架里的抽象类、事件监听器时主动停下来问一句这个类用了什么模式为什么这里要这么设计读源码时养成把代码归类到模式体系里的习惯那些在博客上背不下来的 UML 关系会在你看过 20 个真实实现后自动内化。我个人的经验是设计模式不是靠学出来的是靠改出来的。哪怕最开始重构出来的代码并不完美但只要你经历过一次新增需求只需要新增一个文件的快乐就再也不会回到那个堆 if-else 的写法里去了。