我见过最让人头大的对象创建代码,是在一个报表导出模块里:一个Service方法内联了七八个new,每个new后面跟三行长参数。后来业务加了一个"按部门定制页脚"的需求,差不多全组人花了两个下午,一个个去排查构造器参数到底该传什么、哪里该改。那会儿我就想,要是早一点把"创建对象"这件事交给专门的机制去管,代码库不会这么脆弱。这种机制,就是设计模式里的工厂模式。
工厂模式听起来玄乎,其实本质就一句话:创建对象的活不让你直接new,而是交给一个专门的组件去决定最终实例化谁。它解决的不是"怎么写代码"的问题,而是"代码该怎么组织"的问题。这篇文章我按自己的理解把工厂模式的三种形态捋一遍:简单工厂、工厂方法、抽象工厂,用真实的日志系统演进过程做例子,最后说说我平时判断"该不该上工厂"的四条标准。不管你是刚接触设计模式的初学者,还是写了两三年业务代码想重构的老手,这篇都能给你一点能直接落地的东西。
1. 硬编码new的灾难:那种"改一处崩三处"的绝望怎么来的
1.1 需求变更为什么总在new的地方连环爆炸
先还原一下最典型的"事故现场"。某个报表模块早期只有一种PDF导出方式,代码里到处是new PdfReportExporter(configA, configB, configC)。随着业务发展,出现了Word导出、Excel导出、按模板定制导出,于是有人加了if判断,有人复制粘贴改参数,几十个调用点各写各的。
等到"按部门定制页脚"这个需求下来,问题就炸了:因为每个调用点都直接依赖了PdfReportExporter的具体构造器,只要构造器签名一变,所有new的地方全都编译报错。表面上你只是改一个导出类的构造参数,实际上你是在改全项目所有引用它的地方。这种情况我见过太多次,根因不是需求变态,而是调用方和具体实现之间的耦合太深。
这种耦合的正式说法叫"依赖倒置被违反":高层业务代码本该依赖一个稳定的抽象接口,结果直接绑死在了底层具体类上。用大白话讲,调用方不仅知道"我想要一个能导出的东西",还得知道"这玩意儿是用PDF还是Word实现的、构造时要传哪些参数"。知道得越多,改起来就越痛。
1.2 硬编码创建还要你赔上可测试性
除了改代码痛,硬编码new还有一个容易被忽略的代价:测试难写。假设某个支付对账服务里直接写了new FileBasedDataSource("/etc/pay/config.ini"),你在单元测试里想验证对账逻辑,就不得不真的去准备一个配置文件、真的让代码去读磁盘。想换成内存版数据源?对不起,new已经写死在方法里了,你动不了。
正确做法是让业务逻辑依赖一个DataSource接口,具体实例由外部传入或者通过工厂拿到。测试的时候,你只需要用一个实现了DataSource接口的假对象替换进去,整个测试就轻量了。工厂模式在这种场景里最大的价值,不是让代码看起来更"设计模式",而是让依赖可以被替换、被Mock、被控制。这是硬编码new永远给不了的自由。
1.3 一个类比:点单和进后厨开火的区别
我经常给团队里新同学打一个比方:没有工厂的时候,业务代码就像客人去餐厅吃饭,得自己跑进后厨,自己打开冰箱找食材、自己起锅烧油炒菜。你当然能炒出一盘菜,但你从此被绑死在后厨了——冰箱进新货、燃气灶换了、师傅换了配方,你都受影响。
工厂模式要做的事,就是让客人坐在餐桌前,看着菜单说一句"我要宫保鸡丁",然后由后厨统一决定用哪块鸡胸肉、放多少花生米。客人不需要关心食材怎么来的、锅在哪、火候怎么控制,只需要拿到最终那盘菜。代码里的"菜单"就是工厂接口,"后厨"就是工厂实现,"一盘菜"就是返回的对象。
这个类比还能解释工厂的另一个好处:多个食材的搭配关系被集中管理了。比如"宫保鸡丁必须配花生米",如果你每次自己进后厨炒,可能有人忘了放花生米,有人放了腰果,产品就乱套了。工厂把整套搭配规则锁死在一个地方,谁也别想乱来。
2. 三种工厂一条线:简单工厂、工厂方法、抽象工厂的演进逻辑
2.1 简单工厂:把new集中到一个收银台
很多人以为三种工厂是三个并列选项,其实不是。它们更像一条需求催生出来的升级路线:从"集中管理"到"开放扩展"再到"产品成套"。
首先是简单工厂,也叫静态工厂。它用一个类里面的一两个方法,集中处理所有创建逻辑。调用方传一个类型参数,工厂内部用switch或if来判断到底new哪个对象。
public class LoggerFactory { public static Logger create(LoggerType type) { switch (type) { case CONSOLE: return new ConsoleLogger(); case FILE: return new FileLogger(); case DATABASE: return new DatabaseLogger(); default: throw new IllegalArgumentException("未知日志类型: " + type); } } }这段代码的好处在哪?所有new都收拢到一个入口了,调用方再也不用关心具体类名和构造参数。缺点也很明显:每新增一种日志类型,你得打开LoggerFactory改这个switch,加一个case。它违反了"开闭原则"的一半——对扩展不算友好,对修改倒是相当友好,因为所有修改都集中在一个文件里,不容易漏。
简单工厂适合变体不多、变化不频繁的场景。如果你确定未来半年都不会有新类型,用它最省事。一上来就上抽象工厂,属于拿大炮打蚊子。
2.2 工厂方法:把"选谁"下沉到子类
简单工厂的问题在于:创建逻辑被if/switch写死,无法在不动已有代码的前提下扩展。工厂方法就是来解决这个问题的。
工厂方法的核心思想是:定义一个创建对象的接口,让子类决定实例化哪个类。也就是说,"选谁"这个决策从工厂类里挪出来,下沉到各个子类。
public abstract class LoggerCreator { // 工厂方法:延迟到子类实现 public abstract Logger createLogger(); // 模板方法:使用Logger的公共逻辑 public void log(String message) { Logger logger = createLogger(); logger.write(message); } } public class FileLoggerCreator extends LoggerCreator { @Override public Logger createLogger() { return new FileLogger(); } } public class ConsoleLoggerCreator extends LoggerCreator { @Override public Logger createLogger() { return new ConsoleLogger(); } }现在想新增一种数据库日志,你只需要新建一个DatabaseLoggerCreator子类,完全不用碰已有的FileLoggerCreator和ConsoleLoggerCreator。这就是真正的"对扩展开放,对修改关闭"。
工厂方法也有代价:每增加一种产品,就要增加一个对应的工厂子类。类数量会膨胀,结构也会变复杂。如果你的产品类型只有两三种且基本固定,工厂方法带来的收益不一定抵得过类数量的成本。
2.3 抽象工厂:为成套产品立规矩
抽象工厂解决的是另一个层面的问题:不是单个对象的创建,而是"一组相关对象"的创建。它允许你创建一个产品族,并保证这个产品族内部的配套关系是固定的。
举个例子。一个日志系统如果只是"控制台还是文件",简单工厂就够了。但如果我们还要求:日志的格式必须是JSON、序列化方式必须是批量压缩、上报通道必须是消息队列,而且这三个东西必须成套更换——测试环境用"普通文本+直接写入+本地文件"一套,生产环境用"JSON+批量压缩+MQ"另一套——这时候就需要抽象工厂了。
public interface LoggingFactory { LogFormatter createFormatter(); LogCompressor createCompressor(); LogTransport createTransport(); } public class ProductionLoggingFactory implements LoggingFactory { @Override public LogFormatter createFormatter() { return new JsonFormatter(); } @Override public LogCompressor createCompressor() { return new BatchCompressor(); } @Override public LogTransport createTransport() { return new MqTransport(); } }客户端只依赖LoggingFactory这个抽象接口,不依赖任何具体实现;拿到一个工厂实例后,通过它拿到的Formatter、Compressor、Transport天然就是配套的,不会出现"JSON格式化配了一个本地文件直写"这种组合错误。
抽象工厂的缺点是:新增一个产品维度(比如增加一个"日志脱敏器"),所有既有工厂实现都得跟着改。所以它适合产品族结构稳定、但族与族之间切换频繁的场景。
2.4 三者的差异对照表
| 维度 | 简单工厂 | 工厂方法 | 抽象工厂 |
|---|---|---|---|
| 封装粒度 | 一个方法/一个类 | 一个工厂接口 + 多个子类 | 一组产品接口 + 多个工厂实现 |
| 核心机制 | 调用方传类型参数 | 子类决定具体实例 | 工厂决定整套产品族 |
| 扩展方式 | 修改工厂内部判断 | 新增工厂子类 | 新增一整套工厂实现 |
| 是否必然违反开闭原则 | 会 | 基本不违反 | 不违反,但新增产品维度成本高 |
| 适合场景 | 变体少、变化不频繁 | 单条产品线频繁扩展 | 产品族强配套、成组切换 |
| 复杂度 | 低 | 中 | 高 |
我见过不少团队在选型时陷入纠结:到底用哪一种?我的经验是,不要一开始就选最复杂的,先从简单工厂开始,当你在某个switch里开始积累超过五六种case,并且预感到下一步还要加的时候,再顺势升级到工厂方法。如果出现的是"配套产品要整体切换"的需求,再考虑抽象工厂。让模式跟着需求走,而不是让需求迁就模式的华丽。
3. 一个日志系统走完三种工厂:每一步重构都在解决什么
3.1 第一版:用简单工厂统一日志来源
为了讲明白三种工厂到底在什么场景下登场,我用一个真实的演进过程来串。某系统早期日志功能很简单,就两种输出:控制台和文件。当时的代码并不复杂,但问题在于每个业务模块都自己new,有的写new ConsoleLogger(),有的写new FileLogger(),还有的为了统一,自己封装了一个工具方法,散落各处。
第一步重构,先引入简单工厂。所有调用方统一走LoggerFactory.create(LoggerType.FILE),创建逻辑集中到一处。这次重构没什么技术含量,纯粹是把散落的new收拢。收益立刻出现:后来要统一给所有Logger增加一个初始化参数,比如日志前缀、默认级别,只需要在工厂内部改一处,不用全项目搜索替换。
3.2 第二版:工厂方法让创建行为随环境切换
再往后,系统需要部署到不同环境:测试环境打控制台日志,生产环境写文件并异步刷新。如果继续用简单工厂,你得在create()里写if (env == TEST)这样的判断。分支越来越多,工厂内部开始变成一个环境配置分发器,这已经超出了简单工厂该干的活。
于是升级成工厂方法:定义EnvironmentLoggerCreator抽象类,测试环境有TestLoggerCreator,生产有ProductionLoggerCreator,各自实现createLogger()。业务代码只依赖抽象类,具体创建行为由当前环境的工厂实例决定。此时你会发现,增加的类虽然变多了,但每个类都极其干净,职责单一,改测试环境不影响生产,扩展新环境也只是新增一个文件。
3.3 第三版:抽象工厂处理"规范+序列化+上报"的组合约束
真正的转折点出现在合规需求。某次安全审计要求:所有生产日志必须是JSON格式、需要脱敏、需要批量压缩后上报到统一的日志采集通路。而测试环境的日志可以保持普通文本、直接写入本地文件。说白了,我们有了两套完全不同的产品族。
如果继续用工厂方法,只能保证"Logger怎么创建",但没法保证"Logger依赖的Formatter、Compressor、Transport"也是配套的。你可能会不小心在测试环境用了JSON格式,或者在生产环境忘了加脱敏器。这时候抽象工厂的价值就体现了:把LogFormatter、LogCompressor、LogTransport三个产品绑定在同一个LoggingFactory下,TestLoggingFactory和ProductionLoggingFactory各自产出成套的组件,谁也别想破坏组合规则。
public class TestLoggingFactory implements LoggingFactory { @Override public LogFormatter createFormatter() { return new PlainTextFormatter(); } @Override public LogCompressor createCompressor() { return new NoOpCompressor(); } @Override public LogTransport createTransport() { return new LocalFileTransport(); } }这次升级之后,业务代码完全不需要知道"生产环境用的什么压缩算法",只需要在启动时装配好ProductionLoggingFactory,剩下的组件都通过抽象接口拿。
3.4 重构记录里最值得回看的三个坑
这次日志系统的重构,前前后后踩了三个坑,写出来给你避一避。
第一个坑是"万能工厂"倾向。第一版重构时,我一度想在一个工厂里把所有对象都创建出来,包括数据库连接、缓存客户端、配置中心客户端。结果工厂类越来越大,什么都要管,最后变成了个上帝类。后来忍痛拆开,只让工厂负责"日志相关对象"的创建。记住,工厂也应该遵循单一职责,它不是一个杂物间。
第二个坑是字符串参数地狱。最早写简单工厂的时候用字符串当类型参数,调用方传"file"、"db",拼错了一个字母,编译期完全无感,运行期才炸。后来改成枚举LoggerType,错误提前暴露。再后来升级到工厂方法,直接把类型参数彻底消掉了,因为每个子类对应一种产品,根本不需要传参。能少传一个参数,就少一类错误。
第三个坑是组合约束没有提前考虑。我们最开始只有"控制台还是文件"这一个维度,完全没考虑Formatter和Transport将来也会跟着变。等到合规需求来了,简单工厂和工厂方法都没法优雅地表达"这三样必须成套",只能硬着头皮重构到抽象工厂。如果早期就预见到"日志格式、压缩方式、上报通道大概率要整体切换",一开始直接上抽象工厂反而省事。但话说回来,大多数项目一开始看不清这些,我也理解,关键是重构的时候要果断,不要因为代码丑就一直将就。
4. 工厂与单例、建造者、原型的边界:别把所有创建活都塞给工厂
4.1 单例:工厂经常要和它签一份合作契约
很多人在创建型模式里容易混淆,以为"工厂模式就是管创建的,那单例、建造者、原型不也管创建吗,到底用哪个?"其实这几个模式关注点完全不同。
单例解决的是"实例数量"问题:保证一个类全局只有一个实例,并提供全局访问点。工厂解决的是"实例化逻辑谁来负责"的问题。两者经常一起出现——比如日志工厂本身通常是单例,因为工厂内部可能持有配置缓存、连接池等状态,多实例没有必要;但工厂创建的Logger对象本身,又往往是多实例的,每个业务模块一个。单例管工厂自己,工厂管产品对象,各司其职。
4.2 建造者:负责"怎么拼",工厂负责"给谁"
建造者模式解决的是"对象构造过程太复杂"的问题。一个对象有十几个可选参数、必填参数,如果直接写构造器,调用方根本分不清哪个参数是干嘛的,代码可读性极差。建造者把组装过程拆成一步步的withXxx()调用,让代码读起来像自然语言。
工厂和建造者不是替代关系,而是合作关系。一个典型的组合是:工厂决定"返回哪种对象",建造者负责"这个对象怎么一步步组装"。比如ExporterFactory.createPdfExporter()内部可以返回一个new PdfExporter.Builder().withHeader(...).withFooter(...).build()。工厂管方向,建造者管拼装。
4.3 原型:需要的是复制而不是新建
原型模式解决的是"如何高效地获取一个已有对象的副本"。如果一个对象的创建开销很大,或者需要大量相似对象,用clone()复制比重新new再设置一遍属性更划算。它不是替代工厂,而是和工厂并列的另一种创建策略。
实际选型时可以这样想:如果每次要的对象内部状态差异很大、组装路径不一样,工厂更合适;如果每次拿到的都是同一个模板的变体,只是个别字段不同,原型更合适。比如报表模板,几百个部门可能共用一套基础模板,只是页脚不同,这时候从原型复制再改改,比每次从头组装高效得多,内存和CPU都能省一笔。
4.4 四种模式的分工速查表
| 模式 | 核心问题 | 典型回答 | 和工厂的关系 |
|---|---|---|---|
| 简单工厂/工厂方法/抽象工厂 | 创建逻辑谁来负责 | 专门的工厂组件负责实例化 | 本身 |
| 单例 | 实例数量是否唯一 | 全局只有一个 | 工厂经常做成单例 |
| 建造者 | 复杂对象如何优雅组装 | 步骤化连缀式构造 | 工厂内部可调用建造者 |
| 原型 | 如何低成本得到相似对象 | 复制已有实例 | 替代"新建"的一种策略 |
这张表对我选型的作用很大:先问"我到底愁什么"。愁的是"调用方不该知道具体类"——上工厂;愁的是"全局到处new同一个对象太浪费"——上单例;愁的是"构造参数太多写得像天书"——上建造者;愁的是"每次新建太重了"——上原型。搞混了需求,模式就变成了负担。
5. 工厂不是万灵药:上帝工厂和过度抽象的四条翻车记录
5.1 翻车记录一:上帝工厂把所有创建权限揽到自己身上
我见过一个项目,所有对象都从一个ObjectFactory.createXxx()方法里取,从数据库连接池到DTO,从工具类到业务服务,全塞在一起。刚开始确实方便,所有创建逻辑都集中了嘛。后来这个工厂的代码超过两千行,任何人改需求都要动这个类,频繁的冲突让人抓狂,没人说得清某个方法到底依赖哪些配置。
这个翻车的根子在于把"创建集中管理"理解成了"所有对象全部集中管理"。工厂应该分领域、分模块地存在:订单模块有订单模块的工厂,日志模块有日志模块的工厂,它们各管各的。集中管理一旦超出边界,就变成单点故障了。
5.2 翻车记录二:参数里到处飞字符串,类型保护全丢了
另一个常见问题是工厂的入参设计太随意,直接用字符串或整数标识类型。前面日志系统踩过一次这个坑。用字符串的后果是:编译器帮你兜不住错误,哪天拼错一个"fireLogger",直接到运行期才炸。改枚举能好一点,但枚举本质上还是让"调用方去告诉工厂要什么"。
真正解决这个问题的是工厂方法:不需要任何类型参数,子类本身已经代表了该创建谁。所以我在第二次重构日志系统时,第一时间把字符串参数全部删掉,让每个调用点不再关注"传什么类型",只关注"用哪个工厂"。少了参数,就少了出错面。
5.3 翻车记录三:只有一种实现也硬套工厂
有些团队为了显得"规范",即使某类只有一个实现,也硬套一层工厂。举个例子,系统里只有一个DefaultCacheManager,没有第二个实现,也没有任何替换的迹象,却引入了一个CacheManagerFactory。这带来的纯粹是负面成本:调用方多跳一层、阅读代码多一处转折、IDE搜索路径变长,而收益趋近于零。
过度抽象是工厂模式最容易被吐槽的点。我的态度很明确:如果只有一个实现且看不到第二个,就老老实实new。等出现第二个变体,再上工厂修复,重构成本并不高。别为想象中的未来付今天的利息。
5.4 翻车记录四:把工厂的复杂度甩给了调用方
还有一类翻车,表现在抽象工厂设计得太碎。比如一个工厂接口里定义了十几个创建方法,调用方为了拿到一个日志对象,得先createFormatter()、再createCompressor()、再createTransport(),自己拼装。这等于把工厂该干的组装活又甩给了调用方,只是让"new"变成了"调工厂方法",没有本质改善。
正确做法是工厂对外暴露的业务方法应该尽量少,最好一个方法就能拿到一个组装好的可用对象。如果实在需要组合多个组件,也应该由工厂内部再提供一个高层的createBundle()之类的组合方法,而不是让每个调用方重复拼装。工厂存在的意义是简化调用方,而不是重新把复杂性转移回去。
6. 用四条标准判断"该不该上工厂":我平时的决策习惯
6.1 变体数量与增长趋势:一个产品还是十几个产品
我判断该不该用工厂,第一条标准就是看"现在和可预期的未来,这个产品到底有几个变体"。只有一个实现且一年都没变过,直接new,不要搞工厂。有两三个变体但几乎不增长,一个简单工厂就够。变体数量开始朝五六个以上走,且你观察到业务方几乎每季度都在提新类型,工厂方法就该上了。产品族要整体切换,才考虑抽象工厂。
这条标准的核心是"趋势比现状重要"。我曾经因为"现在只有一个实现"就没上工厂,结果三个月内连续加了三种数据源,每次都要到各个调用点改构造器,改得头皮发麻。后来复盘:当时只要多问一句"这个数据源未来会不会有新的类型",就能预判到风险。
6.2 构造过程的复杂度:三行参数和三十行装配是两种难度
第二条看构造过程。一个对象的创建如果只需要new加两三个简单参数,不值得上工厂。但如果创建它需要读配置、建连接、设默认值、做校验,前后几十行,那一定要把这段逻辑收拢进工厂或者建造者,否则每个调用点都是一份重复的定时炸弹。
举个例子,我们系统里的消息客户端,创建时要加载TLS证书、设置连接池参数、注册心跳回调,整套流程三十多行。早期每个调用方都自己初始化,结果有三处忘记设超时时间,生产环境出了好几个怪问题。后来把初始化逻辑全部收进一个工厂类,问题立刻消失。创建过程越复杂,工厂的边际收益越高。
6.3 是否需要把创建权交给调用方或测试代码
第三条标准来自测试体验。如果你在单元测试里频繁想替换某个依赖,但发现依赖的new被埋在一个方法深处,替换非常痛苦,这就说明该有工厂介入了。有了工厂之后,测试代码可以通过工厂注入一个假实现,整个测试的难度瞬间下降一个量级。
反过来,如果业务代码本来就通过构造函数或依赖注入拿到了依赖,根本没有自己new,那也不需要工厂,因为你已经有了比工厂更优雅的依赖管理方式。工厂只在"调用方确实需要自己获取对象"的时候才有用武之地。
6.4 结合我最近一次重构的最终决策
拿最近一次报表导出的重构收个尾。那个模块符合三条标准:变体已经有PDF、Word、Excel三种,且明确知道还要加HTML;构造过程极其复杂,涉及模板加载、数据填充、样式设置;测试里我需要频繁替换不同导出实现做对比。三个条件齐了,我最终选用工厂方法,再叠加建造者处理复杂构造参数。
重构完成后,业务代码从原来每个导出点几十行配置代码,缩到三五行,只需要从工厂拿到一个Exporter接口然后调用export()。后来加HTML变体时,只新增了一个HtmlExporter和一个HtmlExporterCreator,没碰任何既有代码。说实话,这个需求下来的时候我心里是很爽的,因为这就是工厂模式该有的样子:变化来了,代码稳稳接住。
我个人的体会是,工厂模式不是一道必须每一步都遵循的八股题,而是一种"把创建这件事变得可维护、可替换、可测试"的思路。它解决的真实痛点,永远是那些你在重构时才会切身体会到的东西。如果你也遇到类似的味道——改构造器改到怀疑人生、测试里替换一个依赖比登天还难——那大概率就是该认真考虑上工厂的时候了。