ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

创建型模式不再难:工厂方法、抽象工厂、单例、建造者、原型全解析

创建型模式不再难:工厂方法、抽象工厂、单例、建造者、原型全解析 写了这么多年代码每天和new关键字打交道你有没有想过一个问题当一个对象的创建逻辑变得复杂或者系统里到处都在散弹式地new同一个类时后续的维护会有多痛苦我当年重构一个订单系统时光是把各处直接new出来的支付对象收敛到统一的创建入口就梳理了三天。那种酸爽经历过的人才懂。创建型模式就是专门解决这类问题的。创建型模式是 GoF 设计模式五大分类中的第一类核心关注点是对象的创建机制。它不是为了让你“多写几个类”而存在的而是为了解决一个非常现实的问题如何让对象的创建过程与使用过程解耦让系统在扩展时不需要改动原有代码。这篇内容我会把创建型模式里最核心的五个模式工厂方法、抽象工厂、单例、建造者、原型逐个拆开不讲那些教科书式的定义直接讲它们各自解决什么痛点、代码怎么写、什么场景下选哪个以及我踩过的一些坑。适合正在学设计模式的同学也适合工作两三年、想系统梳理创建逻辑的开发者参考。1. 创建型模式到底在解决什么问题——从“直接new”说起1.1 直接new的代价先看一段再常见不过的代码OrderService { public void pay(Order order, String channel) { if (alipay.equals(channel)) { AlipayClient client new AlipayClient(); client.pay(order); } else if (wechat.equals(channel)) { WechatClient client new WechatClient(); client.pay(order); } } }这段代码有什么问题如果新增一个unionpay渠道你得打开这个类加一个else if再new一个对象。如果AlipayClient的构造参数从两个变成三个比如加了config里面又依赖appId、privateKey、notifyUrl那所有new AlipayClient()的地方都要跟着改。这里的问题本质是调用方对对象的具体类型和构造过程产生了强依赖。new不仅仅是一个语法它代表了三层耦合依赖具体类而不是抽象接口依赖构造参数构造参数一变调用方就要跟着改依赖创建逻辑对象创建前的初始化、缓存、校验等逻辑被分散到各处创建型模式的核心思路就是把这层耦合从调用方手里接管过来。你不关心对象到底怎么来的你只关心拿到的对象能干什么。这就像你去餐厅吃饭你只需要跟服务员说“来一份招牌菜”而不需要知道后厨用的是哪口锅、哪把勺、什么火候。1.2 五种模式的本质差异很多初学者把五种创建型模式混在一起觉得“都是new对象的有什么区别”。我换个角度帮你去理解其实它们对应了五个不同维度的痛点模式核心痛点一句话本质简单工厂 / 工厂方法创建逻辑集中在调用方扩展要改老代码把“创建哪个类”的判断交给工厂抽象工厂多个相关对象必须配套使用不能混搭把“产品族”的一致性约束交给工厂单例某些对象只需一个实例多了就出事从构造层面掐死多实例的可能性建造者对象参数太多、组合顺序复杂直接构造易错把复杂构造过程拆成可控的步骤原型创建对象成本高且对象之间差异小用克隆代替新构造看到没有这五个模式没有一个是为了“炫技”而存在的它们全是被真实工程问题逼出来的。下面我一个一个展开讲。2. 工厂三兄弟从简单工厂到抽象工厂2.1 简单工厂不是GoF模式但它是理解一切的入口很多资料把简单工厂排除在 GoF 23 种设计模式之外因为它不是一个“模式”而更像一种“编程习惯”。但我觉得学创建型模式必须从它开始因为它是理解工厂方法的最好跳板。简单工厂的核心做法是把创建逻辑抽到一个独立的类里调用方不再直接new而是调用工厂的静态方法传入一个参数工厂根据参数决定返回哪个产品。public class PayClientFactory { public static PayClient create(String channel) { if (alipay.equals(channel)) { return new AlipayClient(); } else if (wechat.equals(channel)) { return new WechatClient(); } throw new IllegalArgumentException(不支持的支付渠道: channel); } }调用方的代码就变成了PayClient client PayClientFactory.create(alipay); client.pay(order);好处是显而易见的调用方和具体的AlipayClient、WechatClient解耦了后续增加渠道只需要改PayClientFactory一个类。但坏消息是这个工厂类本身成了一个“万能类”每次新增渠道都要改它违反了开闭原则对扩展开放对修改关闭。简单工厂只适合产品种类不多、且不经常变化的场景。如果产品数量多、还在持续膨胀就得升级到工厂方法。2.2 工厂方法模式把“创建”的决定权交还给子类工厂方法模式对简单工厂的改进是不再用一个大工厂里的if-else去判断而是把工厂本身也抽象化。每种产品对应一个工厂子类子类负责创建对应的产品。public interface PayClientFactory { PayClient create(); } public class AlipayFactory implements PayClientFactory { Override public PayClient create() { return new AlipayClient(); } } public class WechatFactory implements PayClientFactory { Override public PayClient create() { return new WechatClient(); } }调用方拿到的是PayClientFactory接口具体是哪个工厂实例由上层通过配置或依赖注入来决定。这时候你再新增一个银联渠道只需要新增一个UnionpayFactory完全不用碰已有的工厂类和调用方代码。这就是工厂方法和简单工厂之间最本质的区别——扩展从“改代码”变成了“加类”。我实际项目里的经验是工厂方法模式特别适合那些产品类型容易膨胀、并且每个产品的创建逻辑各有不同的场景。比如我们做过一个消息推送系统短信、邮件、App Push、站内信每种推送的初始化逻辑差别很大短信要加载签名配置邮件要初始化 SMTP 连接池App Push 要设置厂商证书。如果全塞到一个工厂类里那个类会变成上千行的怪物。拆成独立工厂后每个工厂各管各的清晰得多。2.3 抽象工厂模式产品族的一致性才是灵魂抽象工厂模式比工厂方法更进一步它解决的是一系列相关对象必须配套使用的问题。工厂方法关注“一个产品”怎么创建抽象工厂关注“一个产品族”怎么保证一致性。举个例子假设你在做一个跨平台的 UI 组件库需要支持暗色主题和亮色主题。每个主题下都有一组配套组件按钮、输入框、对话框。你不能让暗色主题下冒出一个亮色按钮那样视觉上就乱套了。public interface UIFactory { Button createButton(); Input createInput(); Dialog createDialog(); } public class DarkUIFactory implements UIFactory { Override public Button createButton() { return new DarkButton(); } Override public Input createInput() { return new DarkInput(); } Override public Dialog createDialog() { return new DarkDialog(); } } public class LightUIFactory implements UIFactory { // 同理返回亮色系列组件 }这里的关键在于调用方只面对UIFactory接口它拿到的组件天然就是一个主题内的配套组合。如果某个组件忘了实现或者实现错了主题编译器在创建工厂时就暴露了问题不会等到运行时 UI 乱套。抽象工厂的问题也很明显要新增一个产品维度比如加一个“滑块组件”所有工厂实现类都要改。所以抽象工厂适合产品族结构相对稳定、不频繁增加新维度的场景。如果产品族还在快速演进抽象工厂反而不合适。3. 单例模式最熟悉也最容易翻车3.1 从懒汉到饿汉几种常见写法的取舍单例模式可能是五个创建型模式里使用频率最高、但讨论声也最大的一个。它的核心诉求很简单某个类在整个系统生命周期内只需要一个实例。典型的场景包括配置管理器、数据库连接池、线程池、日志组件、Spring 中的默认 Bean 等等。最基础的写法是懒汉式也就是第一次使用时才创建实例public class ConfigManager { private static ConfigManager instance; private ConfigManager() { // 私有构造外部无法 new } public static synchronized ConfigManager getInstance() { if (instance null) { instance new ConfigManager(); } return instance; } }在getInstance方法上加synchronized保证了多线程下的安全但代价是每次获取实例都要经历一次锁竞争。虽然现代 JVM 对锁竞争做过优化但在高并发场景下这依然是不必要的开销。饿汉式的写法更简洁利用类加载机制天然保证了线程安全public class ConfigManager { private static final ConfigManager instance new ConfigManager(); private ConfigManager() { } public static ConfigManager getInstance() { return instance; } }类加载时就创建实例没有线程安全问题也不存在锁竞争。缺点是如果这个类没有被用到它也白白占了一份内存而且初始化时机被提前了。如果类的构造过程依赖一些外部配置比如系统启动后才加载的配置项饿汉式可能是致命的因为类加载时配置还不存在会直接抛异常白屏启动。3.2 双重检查锁定DCL的实现细节如果你既想懒加载又不想每次获取实例都有锁竞争那就得用双重检查锁定。这里是最容易翻车的地方写错了就是线上事故而且不一定会立刻暴露只会在高并发时才偶发。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是绝对不能少的。原因要从 JVM 的指令重排序说起instance new ConfigManager()这行代码在底层并不是原子操作它大致分了三步分配内存空间、调用构造方法初始化对象、把引用赋值给instance。在不加volatile的情况下第 2 步和第 3 步可能被重排序。也就是说线程 A 可能先把引用赋值给了instance但对象还没完成构造这时线程 B 进来看到instance ! null直接返回了一个半初始化状态的对象一用就出事。3.3 单例的边界什么时候不该用聊完怎么写单例我想聊聊比“怎么写”更重要的问题什么时候不该用单例。我对单例的态度是越来越谨慎了。很多开发者把单例当成“全局变量的遮羞布”任何地方想用就直接Singleton.getInstance()结果就是系统里充满了隐式的全局状态。两个模块都用同一个单例对象一个在改字段另一个读出了脏数据排查起来让人崩溃。我的建议是单例适合用来管理无状态的服务或本质上就是共享的资源连接池、线程池、缓存不适合用来保存业务上的可变状态。如果你的单例类里有一堆setXxx()方法、一堆可变字段那它迟早会成为团队协作的噩梦。另一个问题是单例和依赖注入的关系。在 Spring 框架里默认的 Bean 就是单例的但你不需要自己写getInstance()把实例交给容器管理就行。写框架代码的时候尽量让单例对调用方透明别在业务代码里满天飞地调用getInstance()。4. 建造者模式与原型模式两个极端场景的对症下药4.1 建造者模式当构造参数多到让人怀疑人生我在项目里见过一个非常离谱的类构造函数有 18 个参数。你根本分不清第 11 个参数是干啥的传参顺序错了也不会报编译错运行的时候行为诡异排查半天才发现是参数传反了。建造者模式就是解决这个问题的。它把对象的构造过程拆成一步步的“设置操作”每一步都有明确的方法名最后通过build()生成对象。public class HttpClientConfig { private String baseUrl; private int connectTimeout; private int readTimeout; private boolean enableRetry; private int retryTimes; private HttpClientConfig(Builder builder) { this.baseUrl builder.baseUrl; this.connectTimeout builder.connectTimeout; this.readTimeout builder.readTimeout; this.enableRetry builder.enableRetry; this.retryTimes builder.retryTimes; } public static class Builder { private String baseUrl; private int connectTimeout 3000; // 默认值 private int readTimeout 5000; private boolean enableRetry false; private int retryTimes 0; public Builder baseUrl(String baseUrl) { this.baseUrl baseUrl; return this; } public Builder connectTimeout(int millis) { this.connectTimeout millis; return this; } public Builder retry(boolean enable, int times) { this.enableRetry enable; this.retryTimes times; return this; } public HttpClientConfig build() { // 这里可以做参数校验提前暴露错误 if (baseUrl null || baseUrl.isEmpty()) { throw new IllegalStateException(baseUrl 不能为空); } return new HttpClientConfig(this); } } }使用时的体验完全不一样HttpClientConfig config new HttpClientConfig.Builder() .baseUrl(https://api.example.com) .connectTimeout(5000) .readTimeout(10000) .retry(true, 3) .build();整个过程读起来像一句自然语言每个参数的含义一目了然。更重要的是Builder可以在build()方法里集中做参数校验和默认值填充把那些“忘了传参就用了默认值”的隐患在上线前就暴露出来。建造者模式的优点是代码可读性高、参数灵活、校验集中它非常适合参数多、参数存在依赖关系比如开启重试后必须指定重试次数的对象。缺点是代码量明显增加如果参数只有三四个直接用构造函数加BuilderLombok就够了没必要手工写一堆 Builder 类。4.2 原型模式克隆与浅拷贝陷阱原型模式的思路非常直白与其重新创建一个对象不如克隆一个已有对象作为模板然后修改差异部分。它适合那些创建成本高比如涉及大量 IO、网络请求加载默认数据且对象之间差异不大的场景。Java 里实现原型模式有两个途径实现Cloneable接口重写clone()或者自己写一个复制构造方法。这里有个天坑是浅拷贝 vs 深拷贝的问题。public class Order implements Cloneable { private String orderId; private ListOrderItem items; Override public Order clone() { try { Order copy (Order) super.clone(); // 如果不做这个copy 和原对象会共享同一个 items 列表 copy.items new ArrayList(this.items); return copy; } catch (CloneNotSupportedException e) { throw new RuntimeException(e); } } }如果不手动深拷贝items克隆出来的对象和原对象会指向同一个ArrayList。你在副本上往列表里加一个商品原对象的商品列表也变了这种问题在测试时极难发现因为它不报错只是数据诡异地“串”了。我个人在实际编码里其实更喜欢用“拷贝构造方法”而不是Cloneable因为clone()是Object的方法返回类型是Object用起来要强转而且CloneNotSupportedException是个受检异常烦得很。拷贝构造方法写起来更直观public Order(Order source) { this.orderId source.orderId; this.items new ArrayList(source.items); }原型模式用的场景没有前面几个那么高频但它在批量生成相似对象时非常有用。比如报表系统里要生成一份基础报表模板然后基于模板生成各个部门的不同版本原型模式就能省掉重复的初始化代码。5. 创建型模式的选型决策与重构落地5.1 一张表看懂五模式选型学完五种模式之后最容易犯的毛病就是“手里拿着锤子看什么都是钉子”。所以我每次做技术分享都会强调模式是工具不是目的选型的核心依据是你正在面对的痛点。判断条件推荐模式原因产品种类少创建逻辑简单几乎不变简单工厂代码量最少够用就好产品种类多且在持续增加每种创建逻辑不同工厂方法新增产品只加类不改旧代码多个产品必须成套出现不能用混抽象工厂保证产品族一致性编译期约束全局只需一个实例且是共享资源单例从构造上杜绝多实例对象参数多有校验逻辑可读性差建造者链式调用可读性好校验集中创建成本高对象差异小需要复制原型克隆替代重建省成本有一件事我想多说几句那就是不要把设计模式变成“为了用而用”。我见过有人为了展示自己会用抽象工厂在一个只有两种产品、且永远不会有第三种的系统里硬套抽象工厂结果就是多出一堆接口、一堆实现类维护起来还不如直接new。模式的核心是解决痛点没有痛点的模式应用就是在给代码注水。5.2 实战重构从一堆new到工厂聊完了选型我拿一个真实的重构案例来串一遍。之前我们做一个多租户系统每个租户的短信供应商不一样代码刚上线时只有阿里云一家大家都是直接new AliyunSmsClient()写起来挺爽。后来接第二家供应商时问题就来了所有业务代码里散落了十几个new AliyunSmsClient()而且每个新来的同事都习惯性再复制一段。我当时做的第一步不是急着写工厂而是先做了一件事把创建逻辑的使用点全部列出来。数了一下总共有 10 处直接 new分布在 5 个类里。这个动作很重要它能帮你评估重构的波及范围。第二步才动手写工厂。考虑到后续还会接第三、第四家供应商我直接选了工厂方法模式而不是简单工厂。核心思路是定义一个SmsClientFactory接口各家供应商各自实现至于系统里当前激活的是哪家工厂通过配置中心下发。第三步是把那 10 处new AliyunSmsClient()全部替换为调用SmsClientFactory并把工厂实例通过依赖注入的方式传递而不是在业务代码里自己 new 工厂。这一步非常关键否则你只是把“散弹式 new 产品”变成了“散弹式 new 工厂”问题根本没解决。重构完成后新增供应商的工作量从“改动 5 个类、10 处代码”变成了“新增工厂实现类 注册”。系统上线运营一年我们顺利接入了另外两家供应商期间没有改动过任何业务代码。这个效果就是创建型模式价值的最好证明。5.3 框架视角为什么 Spring 里很少手写工厂稍微有经验的开发者可能会发现在 Spring 项目中你很少需要手写工厂类。这不是说工厂模式没用了而是Spring 容器本身就是一个大工厂。ApplicationContext就是那个最顶层的工厂你通过Autowired注入一个PayClient接口Spring 在启动时已经把具体的实现类实例化好并放入容器中了。你需要切换实现类时只要改注解标注的Qualifier或者改配置类里的Bean方法。但这不意味着你就不需要懂创建型模式了。恰恰相反理解创建型模式能让你更好地理解框架的设计意图。比如Spring Bean 默认单例就是单例模式的应用ConfigurationProperties配合 Builder 风格的数据绑定有建造者的影子Spring 的FactoryBean接口本质上是工厂方法模式的一种封装看懂框架背后的模式你在排查问题时会有一种“开天眼”的感觉。遇到奇怪的 Bean 初始化顺序问题、单例失效问题你能很快定位到是哪个环节出了问题而不是一头雾水地乱试。6. 常见问题排查与实操经验笔记6.1 高频问题速查表我在带团队和做代码审查的过程中总结了一些创建型模式使用中最常踩的坑整理成了一张速查表方便你对照自查。问题现象根因分析解决方案单例拿到的是不同实例状态不同步没有用 volatile或序列化破坏了单例DCL 加 volatile单例类实现readResolve()工厂里if-else越来越多每次加类型都改工厂工厂设计退化成简单工厂产品膨胀升级为工厂方法用子类工厂或注册表克隆对象修改列表原对象跟着变浅拷贝内部引用类型未复制对内部可变对象做深拷贝建造者模式里参数漏传用了默认值引发问题build()没有做必填项校验在build()中做完整校验抽象工厂新增一个产品维度改动波及所有实现产品族结构不稳定谨慎使用抽象工厂或考虑用组合替代继承反序列化时绕过单例构造方法readObject()会创建新实例实现readResolve()返回现有实例6.2 创建型模式的代码评审心得最后想分享几个我在代码评审中总结的心得都是实战中一点点磨出来的。第一关注构造函数的个数和参数个数。如果一个类的构造函数超过六七个参数几乎可以确定它需要建造者模式或者拆分类。我在评审时会把“构造函数参数个数”当成一个信号参数越多直接 new 的散弹式影响越大。第二警惕“魔法工厂”。我看到某些工厂类里大量使用字符串判断创建什么产品而且字符串常量在业务代码里满天飞就知道这个系统迟早要出问题。建议把产品类型设计成枚举或使用注册表工厂只需要在注册表里查一下就能找到对应的创建器不需要写一堆if-else。第三把“校验提前”贯彻到底。创建型模式给你提供了一个天然的校验收口点单例在初始化时校验、建造者在build()时校验、工厂在创建时校验。我遇到的线上事故里有很大一部分都是因为对象在构造时缺少必要校验导致一个非法参数在系统里传递了好几层最后在一个八竿子打不着的地方爆出来。把校验放在“创建”这一步是从源头堵住问题比在使用的每一处都做防御要靠谱得多。第四别让创建逻辑和业务逻辑混在一起。有一次我接手一个老项目发现一个对象的初始化过程里居然嵌套了一个长达 50 行的业务判断逻辑这个对象的“创建”和“使用”严重耦合在一起。我后来把它拆成了两个部分创建部分用工厂逻辑收敛业务部分挪到调用的地方。代码可读性上升了一个量级。6.3 一点个人建议如果你正在系统学习设计模式我的建议是从创建型模式入手是对的因为它离实际代码最近几乎天天用得到。但学习方法上别只看书上的 UML 图和示例代码那都是抽象的玩具。更好的做法是把手头项目里“到处 new”的地方找出来尝试用工厂方法收敛把那些参数爆多、看一眼就头晕的构造方法找出来尝试用建造者改造。在真实代码上做一次顶得上你看十遍理论。我个人的经验是创建型模式的学习曲线并不陡但它带来的收益是长期的。它不像某个新框架那样能给系统带来立竿见影的性能提升但它决定了你的系统在半年后、一年后新增需求时是“改两行代码就行”还是“改一坨代码还不一定对”。搞清楚对象是怎么创建的很多看似玄学的 bug 其实从一开始就不会存在。你手头的项目里如果也有那种改一次就要牵连十几个文件的创建逻辑不妨试试用创建型模式收敛一下。不用一次全改完挑一个最痛的地方下手跑顺了再推下一个。模式的落地从来不是一口气的事而是日拱一卒的持续优化。
返回列表