做Java开发的人,迟早会在某个微服务项目里遇到一个场景:同一个动作,根据不同的条件,要走不同的实现。拿我最近维护的一套订单系统来说,用户下单成功后要发通知,有人用短信,有人用邮件,有人只在App里看站内信。一开始代码很简单,if/else 一个个判断,后来渠道越接越多,Service 类像滚雪球一样膨胀。直到某次评审,组长指着那段40多行的判断逻辑说:这里是不是该用工厂模式?于是我把 Spring Cloud 项目的这一段重构了,连带把设计模式的应用补了课。
工厂模式,说到底是解决“创建对象”和“使用对象”之间耦合问题的一套套路。在 Spring Cloud 这种以 Spring IoC 容器为底的微服务技术栈里,对象创建大部分由容器托管,所以工厂模式的实际形态和教科书里的经典 UML 图并不完全一样,但核心思想是一致的:把“选哪个实现”这件事从业务代码里抽出来,放到一个专门的地方。这篇文章我就用订单通知、第三方渠道对接这些实际例子,把工厂模式在 Spring Cloud 项目里的设计思路、代码写法和排坑经验一次说完。
1. 为什么Spring Cloud微服务里要谈工厂模式
1.1 一段真实的业务代码演变史
我刚接手那套订单系统的时候,订单完成通知的逻辑长这样:
public void notifyUser(Order order) { User user = userService.getUser(order.getUserId()); String channel = user.getNotifyChannel(); if ("sms".equals(channel)) { smsClient.send(formatSms(order)); } else if ("email".equals(channel)) { emailClient.send(formatEmail(order)); } else if ("inbox".equals(channel)) { inboxClient.send(formatInbox(order)); } else { throw new IllegalArgumentException("unknown channel: " + channel); } }看着没毛病,业务简单的时候够用。但问题出在“渠道越接越多”之后。第一个问题,是这段代码所在的通知服务要感知所有渠道的客户端:短信 SDK、邮件 SDK、站内信 SDK,谁的版本升级或者初始化参数变了,这里都要跟着动。第二个问题,是每加一个渠道,就要改这个分支,然后通知服务的测试用例、发布流程全部重来一遍。第三个问题更隐蔽:多个业务模块都可能需要发通知,比如售后通知、营销通知,它们各自写一套 if/else,渠道判断逻辑散落各处,改一个渠道名要全局搜索替换。
这种“按条件选实现”的代码,在微服务里尤其危险。微服务强调独立演进,一个服务的改动应该尽量不影响其他服务。可如果每个服务都把渠道判断写在业务代码里,那么渠道的增减就会变成一个跨服务的大工程,和微服务的初衷完全相反。这不是代码风格问题,而是架构边界问题——业务服务不应该知道每一种渠道实现的内部细节。
1.2 工厂模式要解决的核心问题
工厂模式的本质,是把“选择并获取实现”的职责单独抽离。业务调用方不再关心实现类叫什么名字、构造函数要什么参数、初始化要做什么准备,只关心“我要什么类型,工厂给我对应的对象”。
放到 Spring Cloud 语境下,它的实际价值有三点:
- 开闭原则落地:增加新实现类时,业务代码和既有实现都不需要修改,只需要新增一个符合接口约定的 Bean。
- 依赖关系收敛:业务服务不再直接依赖一堆具体的客户端类,只依赖一个工厂接口或工厂类,依赖方向变得清晰。
- 配置驱动:配合配置中心这类组件,可以通过配置项指定当前环境使用哪个实现,工厂读取配置返回对应对象,连代码分支都不用写。
值得强调的是,在 Spring Cloud 里谈工厂模式,不能脱离 Spring IoC 容器。因为“new 对象”这一步绝大多数已经由容器完成了,工厂模式在这里更多是扮演“路由分发器”的角色:告诉调用方当前条件下该用哪个容器里的 Bean。理解了这一点,后续写代码才不会为了套设计模式而套设计模式。
2. 三种工厂模式的核心概念与应用分辨
2.1 简单工厂:最快消灭if-else
简单工厂也叫静态工厂,通常是一个类里放一个静态方法,方法内部通过 switch 或 if 判断参数,返回不同的实现。
public class MessageChannelFactory { public static MessageChannel getChannel(String type) { switch (type) { case "sms": return new SmsChannel(); case "email": return new EmailChannel(); case "inbox": return new InboxChannel(); default: throw new IllegalArgumentException("unknown channel: " + type); } } }好处是使用方便,坏处是“每加一种类型,还是要改工厂类”。所以简单工厂适合实现类数量不多、并且允许集中管理分支的场景。在我见过的项目中,Spring Cloud 项目里的简单工厂经常把 switch 去掉,直接在工厂类里注入一个 Map,让 Spring 容器替我们做“类型到实现”的映射,这就是下一章要讲的写法。
2.2 工厂方法:让每个实现自己决定创建逻辑
工厂方法模式把工厂抽象出接口,每种产品对应一个具体工厂子类。调用方持有一个工厂接口,通过不同的工厂子类创建不同的产品。
public interface ChannelFactory { MessageChannel create(); } @Component public class SmsChannelFactory implements ChannelFactory { @Override public MessageChannel create() { // 可能包含短信网关连接池初始化等复杂逻辑 return new SmsChannel(); } }这种方式在对象创建逻辑比较复杂、各有各的初始化参数时很有用。比如短信渠道需要先加载网关配置、做连接池初始化;邮件渠道需要读取发件人模板和签名;站内信渠道可能不需要额外准备。那么把这些差异封装到各自的工厂子类里,调用方就完全不用管。在 Spring Cloud 项目里,工厂子类本身也可以作为 Bean 被容器管理,从而继续享受依赖注入带来的便利。
2.3 抽象工厂:面向产品族的成体系设计
抽象工厂比工厂方法又进一层:它要创建的不是单个产品,而是一组相关的产品族。举个例子,一套对接外部平台的服务可能需要为每个合作方创建“请求加密器、响应解析器、日志记录器”这一组对象,不同合作方之间的产品族不能混用。抽象工厂就是把这组对象的创建逻辑打包到一个工厂接口里。
public interface IntegrationFactory { Encryptor createEncryptor(); Parser createParser(); Logger createLogger(); }在 Spring Cloud 项目中,抽象工厂的出场率确实低于前两种,因为微服务更倾向于单一职责的独立服务。但当你做一个 B2B 对接平台,要接入多个外部团队、每个团队都带来一套 SDK 和参数模型时,抽象工厂能让接入工作结构化,避免不同供应商的实现混在一起。常见的误区是一上来就把简单工厂升级成抽象工厂,其实大多数场景用第一种就够了。
2.4 一张表看清三种模式的选型
| 模式 | 核心思路 | 适合场景 | 扩展时的动作 |
|---|---|---|---|
| 简单工厂 | 一个工厂集中判断 | 实现类少,类型稳定 | 需要修改工厂类 |
| 工厂方法 | 工厂接口+子工厂 | 创建逻辑复杂且各异 | 新增子工厂类 |
| 抽象工厂 | 产品族成套创建 | 多个相关对象必须配套 | 新增一个产品族的工厂 |
我在 Spring Cloud 项目里用得最多的是简单工厂模式,确切地说是“Map 注入版简单工厂”。原因很现实:Spring 容器已经帮我们解决对象创建,缺的只是“按类型路由”这一步,用容器特性做路由最省事。如果哪天某个渠道的创建过程变得极其复杂,再把它升级成工厂方法也不迟。设计模式的选择应该跟着问题走,哪套方案改动最小就选哪套。
3. 在Spring Boot/Cloud中落地工厂模式的通用做法
3.1 核心前提:Spring容器本身就是个工厂
理解 Spring 生态里的工厂模式,要建立一个新的认知:Spring IoC 容器本身就是最大的工厂。所有 @Service、@Component、@Repository 标记的类,都会被容器创建、管理生命周期、注入依赖。当接口存在多个实现类时,容器里会有多个 Bean,Spring 还提供了两个非常方便的注入方式:
@Autowired private List<MessageChannel> channelList; @Autowired private Map<String, MessageChannel> channelMap;第一行会把所有 MessageChannel 类型的 Bean 收集成 List;第二行会把所有 MessageChannel 类型的 Bean 收集成 Map,key 默认是 Bean 名称(类名首字母小写),value 是对应的 Bean 实例。这个 Map,就是工厂模式最好的底座。我在代码评审里看到不少人自己写一个大的 Map 然后手动 put,其实完全没必要,容器已经帮你做好了,你只需要注入进来。
要注意的是,这种注入方式要求接口的所有实现都是 Spring 管理的单例 Bean。如果你的某个实现类是用 new 手动创建而不是交给容器,那么无论 List 还是 Map 都不会包含它。在实际项目中,这就是很多“工厂拿到了 null”问题的来源。
3.2 基于Map注入实现一个消息渠道工厂
第一步,定义渠道统一接口:
public interface MessageChannel { String type(); void send(Message message); }type() 方法是关键,它让每个实现自己声明自己代表什么渠道,避免到处散落魔法字符串。很多项目省略这一步,直接在工厂里用 if 判断类型,结果类型名散落四处。把 type() 定义在接口里,本质上是让“渠道类型”这个概念有了唯一的出处,后续无论是日志、监控还是配置,都能依赖这个方法做统一处理。
第二步,分别实现短信、邮件、站内信渠道:
@Component("sms") public class SmsChannel implements MessageChannel { @Override public String type() { return "sms"; } @Override public void send(Message message) { // 调用短信服务商接口 } } @Component("email") public class EmailChannel implements MessageChannel { @Override public String type() { return "email"; } @Override public void send(Message message) { // 调用邮件服务 } } @Component("inbox") public class InboxChannel implements MessageChannel { @Override public String type() { return "inbox"; } @Override public void send(Message message) { // 写入站内信表 } }注意 @Component 注解里的名字,要和 type() 保持一致。因为 Map 注入的 key 默认是 Bean 名称,如果类名不小心改过,或者 Bean 名称有特殊字符,type() 和 key 不一致,后面工厂 get 的时候就会踩坑。我习惯在实现类里同时维护 type() 和 @Component 的名称,并把它们统一定义在一个常量类或枚举里,从源头上避免不一致。
第三步,写工厂类:
@Service public class MessageChannelFactory { private final Map<String, MessageChannel> channelMap; public MessageChannelFactory(Map<String, MessageChannel> channelMap) { this.channelMap = channelMap; } public MessageChannel getChannel(String type) { MessageChannel channel = channelMap.get(type); if (channel == null) { throw new IllegalArgumentException("Unsupported message channel: " + type); } return channel; } }我习惯用构造器注入而不是字段注入,一是方便写单元测试,二是能明显看出这个工厂依赖了哪些 Bean。更重要的是,当容器启动时如果发现 MessageChannel 的某个实现没有被正确注册,构造器注入会在启动阶段就暴露问题,而不是等到某个业务请求进来才报空指针。
第四步,业务方使用:
@Service public class OrderNotifyService { private final MessageChannelFactory channelFactory; public OrderNotifyService(MessageChannelFactory channelFactory) { this.channelFactory = channelFactory; } public void notify(Order order) { MessageChannel channel = channelFactory.getChannel(order.getNotifyChannel()); channel.send(Message.of(order)); } }这个业务 Service 已经完全不认识 SmsChannel、EmailChannel 这些具体的类了。以后新增一个“微信通知渠道”,只需要增加一个实现类:
@Component("wechat") public class WechatChannel implements MessageChannel { @Override public String type() { return "wechat"; } @Override public void send(Message message) { // 调用微信通知接口 } }OrderNotifyService 一行代码都不用改,符合对扩展开放、对修改关闭的原则。在实际项目中,新增渠道的常规流程会变成:新同学写一个实现类,注册 Bean,在配置或测试环境验证,完事。老的业务代码基本不碰,回归范围被缩到最小。
3.3 工厂与策略模式组合使用的进阶写法
很多同学会把工厂模式和策略模式搞混。简单区分:工厂关注“怎么拿到正确的对象”,策略关注“拿到对象后怎么执行不同的算法/行为”。在 Spring Cloud 实践里,两者通常是组合出现的:工厂做路由,接口定义策略,实现类各自封装算法。
如果觉得 Map 注入依赖 Bean 名称不够直观,可以引入注册表模式,让每个实现通过 type() 自动注册,彻底摆脱 Bean 名称对 key 的干扰:
@Component public class MessageChannelRegistry { private final Map<String, MessageChannel> channels = new HashMap<>(); public MessageChannelRegistry(List<MessageChannel> channelList) { for (MessageChannel channel : channelList) { channels.put(channel.type(), channel); } } public MessageChannel get(String type) { return channels.get(type); } }这种写法的好处是,key 完全由业务接口的 type() 方法决定,和 Spring 的 Bean 命名规则解耦。即使某个实现类的 Bean 名称被 IDE 重命名了,只要 type() 不变,路由就不会出问题。代价是代码稍微多几行,如果你对 Bean 命名规范很有信心,直接用 Map 注入就够了。但如果团队里有多个小组同时维护渠道实现,我更推荐注册表模式,因为它把“渠道类型”的语义牢牢绑定在每个实现自己身上。
另一个进阶方向是给工厂增加“默认实现”的概念。比如渠道类型为空时,自动走站内信;或者某个渠道临时不可用时,降级到备用渠道。这些策略判断放在工厂里管理,业务方就不用关心降级逻辑,整体代码会更干净。
4. 工厂模式在Spring Cloud实际业务中的典型应用
4.1 消息通知渠道的统一调度
这是最经典的用法。比如你负责一个用户触达平台,短信、邮件、App 推送、站内信、甚至智能客服,每种触达方式都有独立的供应商 SDK 和不同的重试规则。用户可以选择接收渠道,运营活动可以选择多渠道组合。这时候用工厂模式把触达渠道统一管理,新增渠道只需要注册一个新的实现,触达编排服务完全不用动。
进一步配合异步化,可以在渠道实现里自己决定是同步调用还是发到消息队列,这属于策略内部的行为,工厂不需要关心。分布在不同服务模块里的渠道实现,只要都遵循统一接口,就能被注入到同一个 Map 里。我记得有个项目把短信和邮件实现放在独立的 provider 服务里,主服务通过 Feign 调用,后来为了让这个架构更稳定,又把 provider 接口抽象成统一 SPI,用工厂模式做本地优先、远程兜底的路由,整体改动量比预期小很多。
4.2 第三方服务对接的统一出口
做支付接入或者数据对账时,往往要对接多家服务商。每个服务商的接口协议、签名算法、回调验签逻辑都不一样。我见过一种比较规范的做法:定义一个供应商客户端接口,每家服务商一个实现,客户端工厂根据请求参数里的供应商编码返回对应的实现。上层业务写一套逻辑,供应商切换只需要改配置或改请求参数,不需要改业务流程。
尤其是当某些服务商接口升级、需要同时支持新旧版本时,可以按版本号注册不同的实现,例如同一家服务商的 v1、v2,工厂根据配置或请求头选择版本。这样既保证了老客户不受影响,又给新版本留了灰度验证的空间。这种“工厂 + 多版本实现”的模式,在微服务治理里是非常实用的手段,比直接改老实现类要安全得多。
4.3 与配置中心、注册中心联动
Spring Cloud 项目的配置中心给工厂模式提供了更大的发挥空间。假设我们有“本地存储”和“云对象存储”两种文件存储方案,工厂内部不写死用哪个,而是读取配置中心下发的配置项:
@Service public class FileStorageFactory { private final Map<String, FileStorage> storageMap; private final StorageConfig config; public FileStorage getStorage() { return storageMap.get(config.getActiveType()); } }这样在测试环境用本地存储,生产环境切到云存储,只需要修改配置,不用改代码,配合配置中心的动态刷新能力就能生效。这种“配置驱动工厂”的组合,是我认为工厂模式在微服务里最有价值的使用方式。它让工厂不再只是一个代码层面的路由,而成了能力开关。
注册中心联动的场景则是:实现类本身作为独立服务存在,工厂并不直接从本地容器获取 Bean,而是从注册中心拿到一个服务名,再通过负载均衡方式调用远程实现。这时候工厂的角色会从“对象路由器”变成“服务路由器”,但思想是一致的:屏蔽“到底选哪个实例、怎么调用”的细节,让调用方只依赖一个稳定接口。
提示:如果实现类分布在不同的微服务里,工厂就不能直接用 @Autowired 拿到全部实现,这时需要优先考虑把工厂定义在公共服务里,或者用配置中心维护当前可用的实现列表,再结合远程调用框架做转发。
5. 高频问题与排错经验实录
5.1 注入的Map是空的怎么办
这是最常见的现象:代码看起来没问题,但 @Autowired 注入的 List 或 Map 一直是空。原因基本指向实现类没有被 Spring 扫描到。排查思路很简单:
- 检查实现类是否加了 @Component/@Service 等注解;
- 检查实现类所在的包,是否在启动类扫描的包路径下;
- 检查实现类实现接口时,接口类型是否写错,比如实现了另一个同名的接口。
还有一种场景:某个实现类上有 @ConditionalOnProperty 之类的条件注解,条件不满足时容器里根本没有这个 Bean。这时候不是程序写错了,而是条件不满足,需要看启动日志里自动配置的匹配报告,或者临时去掉条件注解验证。
5.2 Bean名称和约定不一致导致找不到实现
Map 注入的 key 默认是 Bean 名称,也就是类名首字母小写。如果你的实现类叫 SmsChannel,那么 key 就是 smsChannel,但调用方传进来的渠道类型可能是“sms”,结果就是 get 不到。为什么我建议用 @Component("sms") 显式指定,就是不想依赖这套隐式约定。更稳的方案是让实现类实现接口时自报 type(),用注册表统一收集,这样 Bean 名称就算被 IDE 重命名了,也不会影响路由结果。
另外还要注意配置文件和代码的一致性。我曾经遇到过线上环境新渠道一直报“Unsupported channel”,查了两小时才发现,配置中心的开关没打开,导致那个渠道实现类当时根本没注册到容器里。这类问题用“看日志 + 看配置”两步就能定位,不需要把代码翻来覆去地看。
5.3 工厂类与其他Bean的循环依赖
如果工厂类构造器注入了 Map,Map 里包含所有渠道实现,渠道实现又注入了工厂,就会出现构造器循环依赖,Spring 启动时直接报错。解决办法:
- 优先重构依赖方向,让渠道实现不要反向依赖工厂,需要用工厂时改用方法入参传入;
- 如果确实存在循环,用 @Lazy 注解推迟其中一个 Bean 的实例化;
- 把 Map 注入改成 ObjectProvider 或 ApplicationContext.getBean 延迟获取。
在这里我特别想说,遇到循环依赖不要第一反应加注解绕过去,而是先画一下依赖图,通常会发现是设计上把依赖方向放反了。工厂应该是下层的稳定依赖,而不是被各个实现类反向依赖的那一层。
5.4 新增渠道后调用方没改却报不支持
这种情况多半是配置和代码不同步。比如新的渠道实现已经注册到容器了,但用户的渠道类型值还是旧的枚举,或者格式大小写不一致。排查时先在工厂 getChannel 方法里把传入的实际类型值打出来,一眼就能看出是数据问题还是代码问题。这种调用方传错值的小毛病,靠工厂的统一异常出口能快速暴露,不至于在业务代码里迷路。
还有一个容易被忽略的点:多个实现类如果标注了相同的 @Component 名称,Spring 启动时会报 Bean 冲突,容器直接启动失败。命名不冲突只是底线,关键还是要保证业务 key 的唯一性。如果有两个渠道的 type() 都返回“sms”,注册表模式会在启动阶段就抛出来,比运行到一半发现路由错了要舒服得多。
5.5 一个实用的防御性检查技巧
我习惯在工厂类里加一个启动时的自检方法,把所有已注册的渠道类型打印出来,同时和配置中声明的渠道类型集合做一次对比。这样新环境一启动就能发现“漏注册”或者“注册了不该注册的渠道”。很多问题如果等到线上流量进来才发现,已经很被动了。这个自检逻辑很简单,就是注入 Map 后在 @PostConstruct 方法里遍历一遍,成本很低,收益却很大。
最后分享一个我个人的习惯:我在写工厂模式时,会把“渠道类型”定义成 Java 枚举或常量类,而不是让调用方直接传字符串。虽然代码看起来多了一层,但枚举的编译期校验能挡掉一大半拼写错误。设计模式真正的价值不在类图画得多标准,而是让团队在长期演进中少踩坑。这套工厂写法我已经在两个微服务项目里落地了,新同学接手时只要懂“Factory 就是拿类型换实现”,基本不会问太多问题。