1. 为什么写这篇文章:从“到处new对象”的痛感说起
1.1 一种很常见的代码气味
先聊一个场景。假设你维护着一个不算小的后端服务,里面有一个消息推送模块,用户下单后要发短信、发邮件、发站内信。业务方今天说“短信渠道加一个”,明天说“邮件模板要改成国际版”,后天又说“顺便接入一下钉钉机器人”。你打开代码一看,发现推送入口处全是new SmsSender()、new EmailSender()、new DingTalkSender()。每加一个渠道,就得改一遍入口方法,加一个else if,改一次测试用例,再重新部署一次。
这种代码并不算“写错”,但它有一个很别扭的地方:调用方本意是“你给我发个通知”,结果它却被迫知道了所有具体渠道类的名字、构造函数参数、依赖的配置。这就是耦合——创建对象的逻辑和业务逻辑搅在一起,所有类之间互相认识,互相依赖。我最早接触设计模式里的工厂方法时,就是被这种“到处new对象”的痛感逼着去学的。
1.2 工厂方法是什么,一句话讲清楚
工厂方法(Factory Method)的核心思想并不复杂:把“创建对象”这件事从调用方手里拿过来,交给一个专门的工厂逻辑去处理。但和很多人理解的“一个工具类里写個switch”不同,工厂方法强调的是“让子类去决定具体实例化哪一个类”。父类定义一个创建产品的接口,子类各自实现这个接口,返回不同类型的产品对象。
一句话版本:定义一个创建对象的接口,让子类决定实例化哪个类。工厂方法把对象的创建延迟到子类中完成。
这句话听起来很绕,我拆开讲。假设有一个Logger接口,里面有log(String message)方法。父类LoggerFactory声明了一个createLogger()方法,但父类自己不知道返回什么;子类FileLoggerFactory返回FileLogger,子类ConsoleLoggerFactory返回ConsoleLogger。调用方只依赖LoggerFactory和Logger这两个抽象,具体是文件日志还是控制台日志,由运行时到底用了哪个子类工厂决定。
1.3 适用人群与阅读收益
这篇文章适合几类人:刚学完面向对象三大特性、想进一步理解多态怎么落地的初学者;写了一段时间业务代码、发现if-else和new越来越多、想引入设计模式改善结构的开发者;以及准备面试、需要把几种工厂的关系讲清楚的人。
我会从标准结构讲起,给出一段可以运行的 Java 示例,再对比简单工厂、工厂方法、抽象工厂三者的边界,然后聊实际业务里的用法和框架身影,最后把我踩过的坑一并列出来。看完之后,你应该能判断“什么时候值得用工厂方法”“什么时候用了反而画蛇添足”。
2. 工厂方法的标准结构拆解:四个角色到底在干什么
2.1 Product 与 ConcreteProduct:产品和具体产品
工厂方法模式里有两个基础角色:Product和ConcreteProduct。Product是产品对象的抽象,可以是一个接口,也可以是一个抽象类。调用方能够接触到的只有Product,不应该看到任何具体产品类。ConcreteProduct则是Product的具体实现,也就是真正被创建出来的对象。
举一个例子。假设你在做一个报表导出功能,产品抽象是ReportExporter,它声明了export(ReportData data)方法。具体产品包括PdfExporter、ExcelExporter、CsvExporter。对调用方来说,它只需要知道“我拿到的是一个ReportExporter,我能调用export方法”,至于底层是 PDF 还是 Excel,调用方不关心,也不应该关心。
2.2 Creator 与 ConcreteCreator:工厂和被实现的工厂
另两个角色是Creator和ConcreteCreator。Creator是工厂的抽象,它声明了一个工厂方法,通常签名长这样:
public abstract ReportExporter createExporter();Creator内部往往还有一些业务逻辑,这些业务逻辑会调用这个工厂方法。ConcreteCreator则负责实现createExporter(),返回具体的产品对象。
这里有一个关键点:Creator不一定要是抽象类,它也可以是一个普通类,里面的工厂方法默认抛异常或返回 null;真正干活的是子类。但为了类型安全和语义清晰,绝大多数场景下我会把Creator定义为抽象类,或者定义成一个接口,让实现类各自返回产品。
2.3 工厂方法的运行时协作流程
一个典型的调用流程是这样的:
- 调用方持有某个
Creator类型的引用,但它不知道这个引用背后到底是哪个ConcreteCreator。 - 调用方调用 Creator 上的业务方法,业务方法内部执行到某个地方,需要产品对象,于是调用
createExporter()。 - 由于 Java 的多态机制,实际执行的是
ConcreteCreator.createExporter(),返回一个具体的ConcreteProduct。 - 业务方法拿到
Product类型的对象,继续执行自己的逻辑。
这个流程的核心收益是:新增产品类型时,不需要改动既有代码,只需要新增一个ConcreteProduct和一个ConcreteCreator。这符合开闭原则——对扩展开放,对修改关闭。
3. 用一段能跑的代码落地:模拟日志框架的演进
3.1 第一版:硬编码的日志器
为了把模式讲透,我模拟一个日志模块的演进过程。先看一个最原始的版本,所有日志都输出到控制台,调用方直接new:
public class ConsoleLogger { public void log(String message) { System.out.println("[console] " + message); } } public class NotificationService { private ConsoleLogger logger = new ConsoleLogger(); public void send(String message) { logger.log("准备发送通知: " + message); // 业务逻辑... } }这个版本能用,但只要需求一变,问题立刻出现。有一天产品经理说:“日志不能只打到控制台,要存到文件里,出问题后能查记录。”于是你新建了一个FileLogger,然后把NotificationService里的ConsoleLogger改成FileLogger。再过两天,又要支持数据库日志、云日志。每次改动都是同一个套路:新建具体类,然后去所有使用的地方改构造函数。
这种改动的痛苦在于,NotificationService的职责是“发通知”,它不该关心日志是写文件还是写控制台。日志的实现细节被强行塞进了业务类里,导致任何日志层面的改动都会引发业务代码的连锁修改。
3.2 重构:引入 LoggerFactory
我用工厂方法重构这段代码。先定义产品的抽象:
public interface Logger { void log(String message); } public class ConsoleLogger implements Logger { @Override public void log(String message) { System.out.println("[console] " + message); } } public class FileLogger implements Logger { private final String filePath; public FileLogger(String filePath) { this.filePath = filePath; } @Override public void log(String message) { // 实际场景这里会写文件,这里简化为打印 System.out.println("[file:" + filePath + "] " + message); } }然后定义工厂的抽象:
public abstract class LoggerFactory { // 模板逻辑:所有子类共用 public void log(String message) { Logger logger = createLogger(); logger.log(message); } // 工厂方法:由子类决定创建哪种 Logger protected abstract Logger createLogger(); }注意,这个LoggerFactory不只是有createLogger(),它还带了一个默认的log流程。这就是工厂方法最常见的形态:父类定义骨架,子类决定细节。
接着实现两个具体工厂:
public class ConsoleLoggerFactory extends LoggerFactory { @Override protected Logger createLogger() { return new ConsoleLogger(); } } public class FileLoggerFactory extends LoggerFactory { private final String filePath; public FileLoggerFactory(String filePath) { this.filePath = filePath; } @Override protected Logger createLogger() { return new FileLogger(filePath); } }改造后的NotificationService只依赖抽象:
public class NotificationService { private final LoggerFactory loggerFactory; public NotificationService(LoggerFactory loggerFactory) { this.loggerFactory = loggerFactory; } public void send(String message) { loggerFactory.log("准备发送通知: " + message); // 业务逻辑... } }使用的时候,根据环境配置选择工厂即可:
// 生产环境 NotificationService service = new NotificationService(new FileLoggerFactory("/var/log/app.log")); // 本地开发 NotificationService localService = new NotificationService(new ConsoleLoggerFactory());NotificationService不再认识ConsoleLogger、FileLogger任何一个具体类。它只知道LoggerFactory能帮它写日志,至于日志写到哪,是创建工厂时决定的。后续想加一个DatabaseLogger,我只需要新增DatabaseLogger和DatabaseLoggerFactory,然后在使用处更换工厂,不用碰NotificationService内部任何代码。
3.3 扩展新日志类型时发生了什么
假设现在要新增一个 JSON 格式日志,输出到 Kafka。扩展步骤很清晰:
- 写一个
KafkaJsonLogger implements Logger。 - 写一个
KafkaJsonLoggerFactory extends LoggerFactory,在createLogger()里返回KafkaJsonLogger。 - 在系统启动配置的地方,把
LoggerFactory实例换成KafkaJsonLoggerFactory。
全程没有改动LoggerFactory,也没有改动NotificationService。这种扩展方式对新需求非常友好,尤其是当系统里有十几个服务都在使用同一个日志模块时,改动被限制在“新增类 + 切换装配点”两步,回归测试范围大大缩小。
提示:工厂方法并不是银弹。如果产品类型极其稳定、几年都不会变,那么引入这套结构反而增加类数量。是否值得,取决于扩展频率。
4. 别把工厂方法用成简单工厂:三种模式的边界选择
4.1 简单工厂:集中式switch的便利与代价
很多人在学工厂方法之前先接触过“简单工厂”的概念:一个工厂类,里面写一堆if或switch,根据参数返回不同产品。
public class LoggerFactory { public Logger createLogger(String type) { if ("console".equals(type)) { return new ConsoleLogger(); } else if ("file".equals(type)) { return new FileLogger("app.log"); } throw new IllegalArgumentException("unknown logger type: " + type); } }这确实能解决“调用方直接new”的问题,但它有一个明显的隐患:每新增一种产品,都要修改这个工厂类的判断逻辑。换句话说,简单工厂的“开关”是集中式的,修改不可避免。如果产品只有两三种,这样写完全没问题,代码也简单。但如果产品类型越来越多,这个工厂类会膨胀,测试用例也会围绕这一个类不断增加。
严格来说,简单工厂不是 GoF 二十三种设计模式之一,它更像一种编程习惯。工厂方法则把它升级为“每个具体产品对应一个具体工厂”,用“新增类”代替“修改判断分支”。
4.2 工厂方法:让子类决定产品
工厂方法的核心理念是:父类不知道也不关心具体产品是什么,创建逻辑被推迟到子类。正因为如此,新增产品类型时不需要改既有工厂,只需要新增具体工厂去实现那个创建方法。
代价也很明显:每个产品都要配对创建一个工厂类,类数量增加。如果产品本身只有两三个,而且变化频率很低,为此引入工厂方法反而会让项目结构显得臃肿。我在实际项目中见过有人为两个不会变的产品类型建了四个类,结果后续三年一行代码都没改过,这种设计就属于过度投资。
4.3 抽象工厂:产品族的组装
再往上走一层,抽象工厂(Abstract Factory)解决的是“产品族”的创建问题。工厂方法关注的是“一个工厂创建一个产品”,抽象工厂关注的是“一个工厂创建一组相互关联的产品”。
举个例子。一个跨平台 UI 组件库需要创建按钮、输入框、弹窗。Windows 风格的按钮和 Linux 风格的按钮是不同产品,同时它们隶属于同一个风格族。用抽象工厂,你会有一个UIFactory接口,里面有createButton()、createInput()、createDialog()三个方法;WindowsUIFactory返回一套 Windows 风格组件,LinuxUIFactory返回一套 Linux 风格组件。
抽象工厂往往基于工厂方法实现:每个createXxx()方法就是一个工厂方法,只是它们被组合在同一个接口里。所以这三者不是互斥关系,而是层层递进的关系。
4.4 一张表帮你做选型
我在项目里做选型时,通常会画这样一张对照表:
| 维度 | 简单工厂 | 工厂方法 | 抽象工厂 |
|---|---|---|---|
| 核心意图 | 集中创建,减少调用方的直接依赖 | 让子类决定创建哪个产品 | 创建一组相关的产品 |
| 创建逻辑位置 | 一个工厂类 | 多个具体工厂子类 | 多个具体工厂子类 |
| 新增产品时 | 修改工厂类判断分支 | 新增具体产品 + 新增具体工厂 | 新增具体产品族 + 新增具体工厂 |
| 扩展代价 | 低,但集中修改 | 中,类数量增加 | 中高,涉及产品族一致性 |
| 适用场景 | 产品少、稳定、不易变化 | 产品变化频繁、按环境区分 | 产品族成套出现、需要保持一致 |
选型建议其实很简单:产品类型很少且几乎不变,直接用简单工厂就够了;产品类型多、扩展频繁、希望符合开闭原则,用工厂方法;存在多套产品族、需要保证配套关系,用抽象工厂。别一上来就追求最复杂的方案,设计模式是解决问题的手段,不是 KPI。
5. 实际业务里工厂方法的高频用法与框架身影
5.1 配置驱动对象创建
工厂方法在生产代码里最常见的用法之一,就是和配置文件配合。系统启动时读配置,根据配置项决定装配哪个具体工厂。比如日志、缓存、消息队列、存储引擎,这些基础组件往往都会通过工厂方法来支持不同的实现。
以缓存为例。接口Cache,实现有RedisCache、LocalCache、ClusterCache。工厂CacheFactory是一个抽象类,子类分别对应不同缓存实现。系统根据部署环境选择CacheFactory的实现类,然后注入到业务代码里。业务代码全程只跟Cache打交道,不会出现“如果用 Redis 就走 Redis 客户端,如果用本地缓存就换成本地实现”这类判断。
这种做法的好处是,当你准备从单机缓存切换到集群缓存时,业务代码一行都不改,只需要在配置层替换工厂实现。替换后,业务代码内部所有依赖Cache的调用自动生效。
5.2 框架基类与模板方法配合
工厂方法经常与模板方法模式一起出现。模板方法定义了业务步骤的骨架,工厂方法则为某些步骤提供对象创建逻辑。我前面写的LoggerFactory就是这种组合:log()方法是一个模板,固定了“先创建 Logger,再写日志”的流程;createLogger()是一个工厂方法,让子类自定义 Logger。
在实际框架中,这种写法很常见。比如一个消息处理框架,父类定义processMessage(Message msg)的整体流程:解析消息 → 创建处理器 → 处理 → 记录结果。其中“创建处理器”就是工厂方法,每种消息类型对应一个子类,子类返回不同的处理器。这样新增消息类型时,只需要新增消息处理器和对应的子类,框架主干不动。
5.3 测试注入与mock协作
工厂方法对单元测试也很友好。因为调用方依赖的是抽象工厂和抽象产品,测试时可以注入一个 fake 或 mock 工厂,让产品返回预置的对象。
比如NotificationService依赖LoggerFactory,测试里我可以写:
public class MockLoggerFactory extends LoggerFactory { private final List<String> messages = new ArrayList<>(); @Override protected Logger createLogger() { return message -> messages.add(message); } public List<String> getMessages() { return messages; } }这样测试就能验证NotificationService.send()确实写入了预期日志,而不需要真的去操作文件系统或控制台。这在接口测试里有很强的实用价值:测试不需要依赖底层具体实现,也能覆盖业务逻辑。
5.4 工厂方法与依赖注入的配合关系
有人会问:既然有了依赖注入容器,工厂方法是不是过时了?我的看法是,两者解决的问题不在同一层。依赖注入容器负责组装对象图;工厂方法负责封装某个对象的创建逻辑。二者完全可以共存。
实际中常见的组合是:容器里注册LoggerFactory的具体实现,业务类通过构造器注入LoggerFactory。这时候工厂方法的价值依然存在——它把“怎么创建一个具体的 Logger”的规则封装在工厂内部,容器不关心Logger怎么来的。尤其是当 Logger 的构造过程需要读取配置、检查连接池、做参数校验时,把这些逻辑放在工厂里比放在容器配置里更清晰,也更容易单测。
6. 踩坑手记:使用工厂方法最容易翻车的六个场景
6.1 产品类型判断写死在工厂里
我把工厂方法写成了“半工厂方法”:抽象工厂定义好了createLogger(),但子类实现时又回到了if-else的怀抱。比如某个HybridLoggerFactory里,根据配置项if ("file".equals(config)) return new FileLogger(...)。这样一来,新增类型时又要改这个子类,开闭原则在子类内部被破坏了。
正确做法是:如果HybridLoggerFactory自己都判断不过来,说明它内部实际上应该拆成多个独立工厂,或者在更高层根据配置选择对应的工厂实现。工厂方法的价值在于“每个子类绑定一种产品”,不要让一个工厂内部继续做产品路由。
6.2 过度设计:只有一个产品也要建工厂
有次我要封装一个简单的配置读取器,当时只有一种实现,我却照着工厂方法搞了一套接口、抽象类、具体工厂的完整结构。事后三个月里,这个配置读取器从未出现过第二个实现。那套额外增加的类全是负担——每次编译都要多编译几个文件,阅读代码的人还要花时间搞清楚这些类之间的关系。
这是我至今印象最深的一条教训:设计模式的选择依据是“变化点”,不是“未来的想象”。如果只有一个产品,且没有任何迹象表明会有第二个,直接返回具体对象就够了,等真正出现第二个实现时再重构,成本并不高。
6.3 返回具体类导致调用方依赖泄露
有些朋友写的工厂方法签名直接返回具体类型:
public ConsoleLogger createLogger() { ... }这会让调用方拿到ConsoleLogger,随后自然而然地调用ConsoleLogger特有的方法。一旦你决定把实现替换成FileLogger,调用方就会报错。工厂方法的签名应该返回抽象类型Logger,哪怕当前只有一个具体产品,也要保持返回类型的抽象层次。这就相当于对外承诺“我给你一个 Logger”,而不是“我给你一个 ConsoleLogger”。承诺越具体,以后越难变。
6.4 忽略对象的生命周期管理
工厂方法只管创建,不管销毁。对于普通对象这没问题,但如果产品是重量级对象,比如数据库连接、HTTP 客户端、线程池,创建成本很高,就需要考虑缓存和复用策略。你可以让工厂在创建时做缓存,也可以把生命周期管理交给容器。
我遇到过一个真实问题:一个工厂方法每次被调用时都新建一个数据库连接,业务高峰期连接数飙升,数据库被打挂。问题不在工厂方法本身,而在于我没用心思考产品的生命周期。工厂方法返回一个对象之前,你要清楚这个对象是短暂的还是有状态的,要不要复用,由谁负责释放。
6.5 工厂类做成静态方法后不可扩展
有人觉得工厂方法没必要实例化,直接把创建函数写成static,于是写成了public static Logger create(String type)。一旦用了静态方法,你就没办法通过多态去覆盖它,也没办法在测试中替换它。这实际上就退化成了简单工厂。
如果你确实只有一个静态工厂,那也没问题,但不要称它为工厂方法。工厂方法需要“实例方法 + 继承”才能发挥作用。如果你需要在不同环境返回不同产品,请放弃静态方法,改用实例化工厂。
6.6 产品构造参数差异过大时硬套工厂方法
工厂方法适合“产品结构一致、只是类型不同”的场景。但如果不同产品需要的构造参数天差地别——比如一个需要读数据库配置、一个需要连接外部服务、一个干脆不需要任何参数——强行让所有产品走同一个工厂方法,会导致参数传递非常别扭。
这种情况下,我往往会在工厂的子类构造时传入需要的依赖。比如FileLoggerFactory的构造器接收filePath,KafkaJsonLoggerFactory的构造器接收 Kafka 连接地址。抽象工厂方法的签名保持精简,具体差异在子类构造阶段消化。如果连“创建什么产品”都依赖运行时的强上下文,那你更可能需要的是策略模式或建造者模式,而不是工厂方法。
7. 最后聊点个人体会
写代码这几年,我越来越觉得工厂方法不是一个“会用”就够了的模式,它背后其实是一种思维方式:把变化的创建逻辑和稳定的业务逻辑分开,让调用方依赖抽象而不是依赖具体。这种思维方式比类图本身重要得多。
我自己的使用习惯是:先写出最朴素的版本,冒着“以后要改”的风险写硬编码;当我发现同一个创建流程第三次出现时,再开始抽象工厂方法。这个节奏避免了过度设计,也不会错过真正的扩展点。同时,我会把工厂的选型决定尽量放到系统启动装配阶段,让业务代码对“今天用哪个实现”完全无感。
如果你正在读这篇博客,我建议你自己动手把日志框架的例子写一遍,然后试着加一种新日志类型,体会一下“不改老代码、只加新类”的过程。然后反过来,再试一下用简单工厂实现同样需求,感受一下两种方式的差别。只有亲手对比过,你才知道什么时候该用工厂方法,什么时候简单粗暴更好。
最后分享一个小技巧:工厂方法的命名尽量统一,比如都用createXxx()或buildXxx()。这样团队里的人看到方法名就能猜到这是一处创建逻辑,代码的可读性会明显提升。模式的名字没那么重要,重要的是大家都能快速理解这段代码在做什么。