上个月我接手一个运营后台,第一次看到那段渠道分发代码的时候,脑子里蹦出八个字:复制粘贴,无穷无尽。需求是接入消息通知渠道:短信、邮件、站内信,后来还要加企业微信、钉钉机器人。前面的人怎么接的?在 send 方法里继续堆 if-else,每个分支都是同一套动作:new 一个配置对象,set 一堆连接参数,再 new 一个客户端,调 send。我数了一下,光一个分发方法就有 200 多行,其中至少 150 行是从上一段复制过来的。
当时我决定不再加分支了,直接用一个真实实例,把五种创建型模式全部用上,做一次以冗余消除为目标的代码优化。这里说的优化不是调接口性能、不是压并发,而是纯靠重构把重复的“创建逻辑”挤出去。适合谁看?写过业务代码、每天和 if-else、new 打交道的后端开发,尤其是想系统掌握设计模式但一直不知道怎么落到项目里的人。
1. 场景原貌:一段到处是 new 的烂代码
1.1 业务背景:渠道越来越多,方法越来越胖
这个后台本身不复杂,核心就是给用户发通知。最开始只有短信,后来加了邮件,再后来运营说要在站内信里也推一条,紧接着又要求支持企业微信机器人。每一次新需求来了,大家心照不宣:去 send 方法里,照着上一个渠道的写法,再复制一段。
听上去很顺利,直到有一天要接入第五个渠道,我看着那段越来越长的 if-else 彻底崩溃了。不是不能跑,是彻底不敢动:随便改一个分支的前置逻辑,就要在所有渠道里同步改一遍,漏改一个线上就出问题。要接新供应商,得把参数校验、内容清洗、失败重试、日志埋点这些逻辑全部重新写一遍。
这就是典型的冗余堆积。冗余不只是“代码重复”,更可怕的是“变化时所有重复点都要跟着变”。想消除这种冗余,目光不能停在具体一行上,要看向对象的创建过程。
1.2 第一版代码:每个分支都是复制品
当时 send 方法大概是这种画风:
java复制public void send(String type, String to, String content) {
if ("sms".equals(type)) {
SmsConfig cfg = new SmsConfig();
cfg.setAccessKey("ak_xxx");
cfg.setSecretKey("sk_xxx");
SmsClient smsClient = new SmsClient(cfg);
smsClient.send(to, content);
} else if ("email".equals(type)) {
MailConfig cfg = new MailConfig();
cfg.setHost("smtp.trade.com");
cfg.setPort(465);
cfg.setUserName("noreply@trade.com");
cfg.setPassword("pwd_xxx");
MailClient mailClient = new MailClient(cfg);
mailClient.send(to, "系统通知", content);
} else if ("im".equals(type)) {
ImConfig cfg = new ImConfig();
cfg.setWebhook("https://work.trade.com/hook");
ImClient imClient = new ImClient(cfg);
imClient.send(to, content);
} else {
throw new IllegalArgumentException("unsupported channel: " + type);
}
}
三个分支虽然调用的类不一样,但骨架完全一样:创建配置对象、填充参数、创建客户端、发送。真正变化的只有两部分,一是配置来源,二是客户端类型。
这段代码最大的问题不是行数多,而是每次修改都要在 N 个分支里重复。给短信加个超时重试,得记得在里面补一个重试逻辑;给邮件加个抄送人,又得在邮件分支改一遍。分支越多,这种“同步修改”的代价就越高,直到某次漏改,线上通知直接雪崩。
1.3 把冗余分成三类,才知道用什么打
动手重构之前,我先把项目里所有和“创建对象”相关的代码扫了一遍,归纳出三类典型冗余:
第一类是创建逻辑冗余。每个分支都在 new XxxConfig,然后 setter 塞参数,配置值散落在业务代码里。渠道多的时候,同一个配置类可能被 new 了好几次,每次参数还不完全一样,出了问题排查起来极麻烦。
第二类是分发结构冗余。if-else if 不断膨胀,每加一个渠道,主流程就要动一次。这类冗余的典型特征就是“结构性重复”,新分支和旧分支之间只有类型名不同,其他都差不多。
第三类是对象组装冗余。发消息不是只传一个 string 就完事了,一个 NotificationTask 对象有收件人、标题、内容、优先级、重试次数、定时时间、扩展头十几个字段。很多地方 new 出来以后,要么忘了填默认值,要么填错字段,缺少统一约束。
这三类冗余,恰好对应了创建型模式能处理的三种场景:配置对象的“构建过程”该被收拢,渠道对象的“创建入口”该被统一,复杂对象的“组装规矩”该被约束。这也是我后来决定把五个创建型模式都用在同一个案例里的直接原因。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 为什么是创建型模式:先定方案再动手
2.1 冗余的本质是“创建过程”失控
重构前我问自己一个问题:第一版代码里真正重复的是什么?是业务逻辑吗?不是,业务逻辑其实就是“把消息发给目标用户”这一句话。那 90% 的代码是什么?是在创建各种对象:配置对象、客户端对象、消息对象、模板对象。
对象创建这件事看着很基础,但它分散在代码各处时,就成了一大隐患。一个对象该在哪儿创建、用什么参数创建、创建完怎么初始化,全凭调用者自觉。这次重构与其说是“用设计模式”,不如说是把整个系统里所有对象创建的入口全部收拢,让创建过程有规矩、有默认值、有校验。创建型模式正好是干这个的。
2.2 五种创建型模式各治哪一种冗余
先上一张对照表,后面细化代码时会反复用到。这张表的思路就是:不用去背设计模式的定义,只看它消除了哪类重复。
| 冗余类型 | 第一版代码里的表现 | 对应的创建型模式 |
|---|---|---|
| 分支重复分发 | 每个渠道一段 if-else,结构几乎一样 | 工厂方法 |
| 渠道族整体切换 | 从标准渠道切到高级渠道要改多处 | 抽象工厂 |
| 复杂对象参数混乱 | 十几个字段的请求对象,到处漏填 | 建造者 |
| 模板对象重复构建 | 同样一个模板反复手动 new、手动赋值 | 原型 |
| 配置对象频繁加载 | 每次发消息都重新读配置、重新建客户端 | 单例 + 注册表 |
注意第五行,单例往往不是单独出现的,它通常和工厂、原型配合,作为“对象创建过程中的共享底座”。比如配置注册表是单例,模板池是单例,工厂的 Creator 注册表也可以做成单例。它们消除的冗余是同一个类型:重复加载、重复初始化。
2.3 为什么不用简单工厂,而是工厂方法
很多文章讲到这里会说“工厂方法可以扩展”,但不容易讲清和简单工厂的区别。我当时也纠结了一下:把 if-else 换成 switch,再挪到一个单独的 ChannelFactory 里,不也清爽吗?
是清爽,但没解决根本问题。简单工厂把 if-else 收拢成一个方法,新增渠道时还是要改这个方法的内部结构,改完还得重新测试整个工厂。而工厂方法把“创建动作”下放到各自的 Creator 子类里,主流程里只剩一个注册表和一个调用:新增渠道时,新写一个 Creator 子类,注册一行,完事,旧代码一个字符都不用碰。
这背后的设计原则是开闭原则:对扩展开放,对修改关闭。只要能把“修改”挡在核心链路之外,冗余就不会像病毒一样从新分支扩散到老分支。
3. 落地实现:五个模式依次上场
3.1 单例:配置不再重复加载
先解决最底层的配置加载问题。第一版里每个分支都在手动 new SmsConfig、setAccessKey,这些配置值写死在代码里,换个环境就得改代码。我用一个枚举单例实现配置注册表:
java复制public enum ChannelConfigRegistry {
INSTANCE;
private final Map<String, ChannelConfig> configs = new ConcurrentHashMap<>();
public ChannelConfig get(String key) {
return configs.computeIfAbsent(key, this::loadFromConfigCenter);
}
private ChannelConfig loadFromConfigCenter(String key) {
// 读配置文件或配置中心,解析 apiKey、secretKey、webhook 等
// 例如 configService.fetch("channel." + key)
ConfigSource source = ConfigClient.fetch("channel." + key);
ChannelConfig config = new ChannelConfig();
config.setApiKey(source.get("apiKey"));
config.setSecretKey(source.get("secretKey"));
config.setExtra(source.getMap("extra"));
return config;
}
}
这里用枚举而不是普通 class,是因为枚举单例天生线程安全,还能防止反射破坏单例。computeIfAbsent 的好处是并发下只会初始化一次,同一个渠道配置不会被重复加载。
放在重构里,这个类解决的是“读配置代码散落各处”的问题。之前每个分支都有一段 new Config + setter 的代码,现在统一收敛到注册表里;后续任何地方想要某个渠道的配置,一行代码拿走,找不到新配置就抛异常,也不会出现配置值为 null 导致的隐性问题。
3.2 工厂方法:干掉分发 if-else
配置收拢以后,下一步是把 send 方法里的 if-else 整体干掉。我引入了一个 ChannelCreator 抽象层:
java复制@FunctionalInterface
public interface ChannelCreator {
Channel create();
}
public class SmsChannelCreator implements ChannelCreator {
@Override
public Channel create() {
ChannelConfig cfg = ChannelConfigRegistry.INSTANCE.get("sms");
return new SmsChannel(cfg.getApiKey(), cfg.getSecretKey());
}
}
public class MailChannelCreator implements ChannelCreator {
@Override
public Channel create() {
ChannelConfig cfg = ChannelConfigRegistry.INSTANCE.get("mail");
return new MailChannel(cfg.getApiKey());
}
}
然后把这组 Creator 注册到一个统一的工厂类里:
java复制public class ChannelFactory {
private static final Map<String, ChannelCreator> CREATORS = new HashMap<>();
static {
CREATORS.put("sms", SmsChannelCreator::new);
CREATORS.put("email", MailChannelCreator::new);
CREATORS.put("im", ImChannelCreator::new);
}
public static Channel createChannel(String type) {
ChannelCreator creator = CREATORS.get(type);
if (creator == null) {
throw new IllegalArgumentException("unsupported channel: " + type);
}
return creator.create();
}
}
改造完之后,send 方法被简化成三行:
java复制public void send(String type, String to, String content) {
Channel channel = ChannelFactory.createChannel(type);
channel.send(to, content);
}
这个改造最核心的价值是:新增渠道时不需要碰 send 方法和 ChannelFactory 的 switch 逻辑,只要新建一个 Channel 实现类和一个 Creator 实现类,再往集合里塞一行注册代码就行。if-else 的膨胀点被彻底移除了。
当时很多同事问我为什么不用反射自动注册,非要在静态块里手动 put。理由很简单:手写 put 不依赖包扫描和类加载,阅读时能直接看到渠道清单,IDEA 里还可以一键跳转实现类,排查问题速度快得多。为了炫技引入反射,会把简单问题重新变复杂。
3.3 抽象工厂:接管“一个渠道族”
工厂方法解决的是单个渠道的创建,但重构过程中我发现还有一个更上层的问题:通知平台为了适配不同的资费策略,出现了“基础套餐”和“高级套餐”两套渠道组合。基础套餐的短信走供应商 A,邮件走内部 SMTP;高级套餐的短信走供应商 B,邮件走第三方邮件服务,还额外给了企业微信机器人。
如果只用工厂方法,业务层每次都要先判断套餐,再逐个调用多个工厂方法创建两三个对象,判断逻辑还是会在多个地方重复。抽象工厂的价值就在这里:把一整套相互关联的渠道对象当成“一个族”来创建。
java复制public interface ChannelKitFactory {
SmsChannel createSmsChannel();
MailChannel createMailChannel();
WorkWechatChannel createWorkWechatChannel();
}
public class StandardKitFactory implements ChannelKitFactory {
@Override
public SmsChannel createSmsChannel() {
return (SmsChannel) ChannelFactory.createChannel("sms");
}
@Override
public MailChannel createMailChannel() {
return (MailChannel) ChannelFactory.createChannel("email");
}
@Override
public WorkWechatChannel createWorkWechatChannel() {
return (WorkWechatChannel) ChannelFactory.createChannel("im");
}
}
public class PremiumKitFactory implements ChannelKitFactory {
// 高级套餐使用不同的供应商,内部同样是调 ChannelFactory
}
业务层真正使用时,只需要在启动阶段根据配置决定用哪个 KitFactory,之后所有创建调用都走这一个接口:
java复制ChannelKitFactory kit = AppConfig.isPremium()
? new PremiumKitFactory()
: new StandardKitFactory();
kit.createSmsChannel().send(mobile, "验证码 1234");
抽象工厂和工厂方法的区别,在这个例子里看得很清楚:工厂方法解决“单个产品怎么创建”,抽象工厂解决“一组配套产品怎么整套创建”。后者减少了业务层那句重复的“套餐判断”,本质上是把“选型”这个逻辑从业务中抽离,也算一种冗余消除。
3.4 建造者:复杂请求对象不再漏参数
渠道通道搞定了,另一个让我头疼的是消息对象。发消息经常要带标题、内容、优先级、是否重试、重试次数、定时时间、扩展字段,后期还要支持带附件。字段最多的时候有十几个,用全参构造函数,调用方根本记不住第几个参数是什么;用 setter,很容易漏掉必填项,对象被创建成“半个状态”。
建造者模式在这里是最顺手的工具:
java复制public class NotificationTask {
private final String channelType;
private final String to;
private final String title;
private final String content;
private final boolean retry;
private final int retryTimes;
private final LocalDateTime scheduleTime;
private final Map<String, String> extra;
private NotificationTask(Builder builder) {
this.channelType = builder.channelType;
this.to = builder.to;
this.title = builder.title;
this.content = builder.content;
this.retry = builder.retry;
this.retryTimes = builder.retryTimes;
this.scheduleTime = builder.scheduleTime;
this.extra = builder.extra;
}
public static class Builder {
private String channelType;
private String to;
private String title;
private String content;
private boolean retry = true;
private int retryTimes = 3;
private LocalDateTime scheduleTime;
private Map<String, String> extra = new HashMap<>();
public Builder channelType(String channelType) {
this.channelType = channelType;
return this;
}
public Builder to(String to) {
this.to = to;
return this;
}
public Builder title(String title) {
this.title = title;
return this;
}
public Builder content(String content) {
this.content = content;
return this;
}
public Builder retry(boolean retry, int retryTimes) {
this.retry = retry;
this.retryTimes = retryTimes;
return this;
}
public Builder scheduleTime(LocalDateTime scheduleTime) {
this.scheduleTime = scheduleTime;
return this;
}
public Builder extra(String key, String value) {
this.extra.put(key, value);
return this;
}
public NotificationTask build() {
if (channelType == null || to == null || content == null) {
throw new IllegalStateException("channelType/to/content are required");
}
return new NotificationTask(this);
}
}
}
建造者消除冗余体现在两个地方。第一,所有默认值集中管理,比如 retry 默认 true、retryTimes 默认 3,调用方不用每次重写;第二,必填项校验只在 build 里做一次,不管系统里有多少处发送消息的代码,非法对象都不可能被创建出来。
实际体验下来,链式调用还有一个隐藏好处:调用方代码像在读一个清单,channelType、to、content、scheduleTime 一目了然,少了哪个一眼就能看出来。这比十几个参数的构造函数高效得多,也避免了 setter 模式下出现“对象建了一半就被人拿去用”的问题。
3.5 原型:模板对象不再手工拼装
最后一个是原型。系统里很多消息不是运营手写的,而是基于模板生成的,比如验证码模板、告警模板、活动通知模板。这些模板在配置中心里只有一份,但每次发消息都要重新获取模板、重新替换变量。
第一版代码里模板变量替换逻辑在调用方散落得到处都是,同一个验证码模板,A 服务填的是 code,B 服务填的是 verifyCode,时间长了模板参数根本无法治理。我引入了一个消息模板池,池子里保存的是配置中心加载过来的原型对象:
java复制public class MessageTemplate implements Cloneable {
private String templateId;
private String title;
private String content;
private Map<String, String> vars = new HashMap<>();
private List<String> ccList = new ArrayList<>();
@Override
public MessageTemplate clone() {
try {
MessageTemplate clone = (MessageTemplate) super.clone();
clone.vars = new HashMap<>(this.vars);
clone.ccList = new ArrayList<>(this.ccList);
return clone;
} catch (CloneNotSupportedException e) {
throw new IllegalStateException("MessageTemplate clone error", e);
}
}
public MessageTemplate fill(String key, String value) {
MessageTemplate clone = this.clone();
clone.vars.put(key, value);
clone.content = content.replace("${" + key + "}", value);
return clone;
}
}
发送验证码时,直接从池里拿模板原型,再填一个 code 参数:
java复制MessageTemplate template = TemplatePool.get("verify_code");
MessageTemplate finalTemplate = template.fill("code", "4821");
channel.send(new NotificationTask.Builder()
.channelType("sms")
.to(userMobile)
.title(finalTemplate.getTitle())
.content(finalTemplate.getContent())
.build());
模板只有固定几种,从原型 clone 的成本非常低,而且 clone 之后修改 vars、ccList 都不会影响原始模板,因为我在 clone 方法里对 Map 和 List 做了深拷贝。
原型模式在这里消除的冗余是“模板对象重复构建”。如果没有原型,每次发送都要写一遍从配置中心拉模板、再一层层替换变量的逻辑,这些代码又臭又长,还容易把原始模板改坏。用原型之后,模板只加载一次,后续全部是复制和微调。
有个细节要特别提醒:Cloneable 接口默认是浅拷贝,对象内部的 Map、List 必须手动 new 一份。我第一次写完就踩了坑,两个消息对象互相污染了变量,排查了半天才发现是浅拷贝的问题。
4. 优化效果:冗余到底被消除了多少
4.1 代码量和分支数量的变化
这次重构后的效果,我当时做了个粗略统计,直接用表格列出来更直观:
| 维度 | 重构前 | 重构后 |
|---|---|---|
| 渠道分发方法行数 | 约 200 行 | 约 5 行 |
| 渠道分支数量 | 每次加渠道增加一个 else if | 0,只有注册表增加一行 |
| 配置创建代码 | 散落在各个分支 | 统一在 ChannelConfigRegistry |
| 模板填充逻辑 | 每个调用方各写一遍 | 模板池 + fill 方法 |
| 新增一个渠道的改动 | 复制 150~200 行,改半天 | 约 40 行新类 + 1 行注册 |
当然这些数字和业务复杂度有关,不同项目不能直接对比,但它足够说明一个趋势:把对象的创建集中起来之后,真正的业务逻辑会浮出水面,重复代码会肉眼可见地减少。
这个优化影响范围不只是通知模块。后续接人工客服通知、对接第三方告警平台,团队成员按照同样的套路,新增渠道只写实现类和注册行,不需要再理解纷繁复杂的分发逻辑。代码 Review 也从“逐行看 if-else 有没有复制错”变成了“看看新渠道实现类的参数校验是否完整”。
4.2 新增一个渠道的标准改动步骤
如果现在要接入一个飞书机器人,需要动手的地方只有三处:
第一步,在配置中心新增 channel.feishu.webhook、channel.feishu.secret。第二步,写一个 FeishuChannel 实现类,实现 Channel 接口里的 send 方法,大约 30 到 50 行。第三步,在 ChannelFactory 的注册表里加一行,CREATORS.put("feishu", FeishuChannelCreator::new)。
核心的 send 分发方法不用动,配置加载逻辑不用动,消息对象组装逻辑不用动,模板逻辑不用动。以前那种“改一个分支影响一片”的连锁反应,从机制上被堵死了。
这里有一个非常直观的检验标准:重构完毕之后,我特意做了一次实验,让团队里一个刚入职两周的同事去加一个企业微信渠道,他只花了二十分钟,而且写完的代码和我的预期一致,没有多余的复制粘贴。
4.3 这次优化真正改变的东西
很多人会把这次重构理解成“少写了不少代码”,但我更看重的是改变维护方式。以前加渠道,最怕的是忘记处理模板变量、忘记设置重试、忘记做参数校验,这些细节散落在各个层面,没有人能保证每个渠道都覆盖完整。现在模板填充、参数校验、重试默认值都集中到了指定位置,新渠道只要跑一遍统一的渠道测试用例,行为就能和旧渠道对齐,覆盖率也更容易控制。
从测试角度讲,变化更明显。以前测试五个渠道,需要准备五套分支测试用例,而且分支之间还有重复逻辑。重构后,测试对象被拆成了三个独立小单元:配置注册表测加载逻辑、工厂测注册与异常分支、各渠道实现类测自身发送逻辑。用例更清晰了,维护成本也下降了。
5. 实操中踩过的坑与使用限制
5.1 单例在单元测试里的污染问题
全局单例在并发场景下很方便,但单测环境里容易出问题。ChannelConfigRegistry 一旦在某个用例里放了一份 mock 配置,跑完不清理,下一个用例拿到的就是脏数据。
我常用的处理办法是给注册表加一个 reset 方法,专门给测试用。普通业务代码永远不调用,测试用例在 setUp 里清空,在 tearDown 里还原。如果嫌 reset 方法破坏单例的封装,也可以用 JUnit 的 @BeforeEach 单独替换注册表实例,总之不能让状态穿梭于测试之间。
5.2 原型模式浅拷贝是最隐蔽的坑
原型模式的坑比单例更隐蔽。默认的 clone 是浅拷贝,对象内部的 List、Map 都是引用复用。我那次就是因为 ccList 没有深拷贝,两个通知任务共享了同一个抄送人列表,A 任务给列表加了一个人,B 任务莫名也跟着多了一个人。
解决办法只有一个:克隆方法里,所有可变引用类型都手动 new 一份。还有一个更省心的方案:如果项目里已经有 Jackson,可以把 clone 实现成序列化再反序列化,虽然效率低一点,但对复杂对象的整体深拷贝非常稳。
5.3 模式不是越多越好,什么时候该收手
使用这些模式的前提是“确实有对应种类的冗余”。如果系统里只有一个通知渠道,抽象工厂就是纯纯的累赘;如果请求对象只有两三个字段,建造者也是过度设计。我给自己定的判断规则很简单:
一个创建点后面跟着超过三行初始化代码,考虑用工厂或建造者收口;同一个 if-else 结构出现第二次,考虑用工厂方法消灭分支;一组对象出现“成套替换”的需求,才考虑抽象工厂;同一个模板对象被多处手动赋值,才考虑原型。
把这条规则背下来,就不太会为了用模式而用模式。创建型模式本身是消除冗余的工具,不是必须摆在代码里的装饰品。用之前先问一句:这里到底有没有重复的创建逻辑?没有就别上。
5.4 重构前先补测试,再动手
最后分享一个差点让我翻车的教训。当时我重构心切,代码改到一半才意识到,原有的渠道行为细节非常多,比如短信失败重试的规则、邮件退信的静默策略、企业微信的限流等待,这些散落在 if-else 分支里,一旦重构过程中没对齐,线上就会出事故。
后来我把流程改成:第一步先给现有 send 方法写黑盒测试,把五种渠道的行为用例固化下来;第二步才动代码;第三步跑全部测试看行为是否被破坏。创建型模式重构本质是改变对象的创建路径,最容易出问题的是创建方式和原来不一致,比如配置加载时机变了、默认值变了、初始化顺序变了,这些只有依赖测试才能兜底。
我个人在实际操作中的体会是,创建型模式这件事,你单独看任何一个都感觉“就这?”,但把它们放在同一个真实项目里去治理冗余时,威力就出来了。五种模式分别负责五个层级的创建收敛:单例管配置来源,工厂管渠道入口,抽象工厂管套餐选型,建造者管复杂对象组装,原型管模板复制。它们不是互相替代的关系,而是各自解决了不同位置上的重复创建逻辑。
那次重构之后我养成了一个习惯:每到一个新项目,先搜代码里的 new 关键字,把创建点全部列出来,心里大致过一遍哪些位置有重复,哪些位置该收口。不需要一上来就上全套模式,哪怕只把最明显的 if-else 换成工厂注册表,都能省下后面很多改动的功夫。
