基于策略模式的短信验证码发送设计
短信验证码几乎是后台系统里最常见的基础能力之一。
但很多项目在一开始做这块功能时,目标通常只有一个:先把验证码发出去。
于是代码很容易直接绑死某一家厂商,模板散落在业务里,切换通道靠
if...else,一旦后面要接第二家、第三家短信服务,模块复杂度就会迅速失控。
如果你要设计一套短信验证码发送模块,应该怎样用设计模式把它做得更清晰、更容易扩展,也更方便做多通道切换。
一、为什么短信验证码模块值得单独设计
很多系统在最开始做短信功能时,往往只有一个目标:先把验证码发出去。
于是代码通常会长成这样:
▼java复制代码if (vendor.equals("A")) { // 调 A 厂商 SDK } else if (vendor.equals("B")) { // 调 B 厂商接口 } else { // 报错 }
这种写法短期很快,但很快就会遇到一连串问题:
- 业务代码直接依赖某一家短信厂商,后面很难替换。
- 登录、注册、找回密码、活动提醒这些模板逻辑会散落在不同地方。
- 一旦主通道故障,整个验证码链路会跟着出问题。
- 每接入一家新厂商,原有代码就会继续膨胀。
- 调用方知道的内部细节太多,模块边界越来越模糊。
所以,短信验证码模块真正要解决的不是“怎么调 SDK”,而是这四个更本质的问题:
| 要解决的问题 | 设计目标 |
|---|---|
| 业务层不应该到处接厂商接口 | 提供统一发送入口 |
| 厂商实现差异很大 | 用抽象隔离差异 |
| 通道可能失败 | 具备切换与降级能力 |
| 模板、签名、账号经常变化 | 配置化管理 |
如果把这四件事做好,短信功能才算是一个模块。
如果只做“调一次接口”,那最多只能算一段代码。
二、先从整体看:这类模块应该长什么样
为了让没看过源码的人也能先建立整体印象,我们先不看代码,先看结构。
▼mermaid复制代码flowchart LR A["业务系统<br/>登录 / 注册 / 找回密码"] --> B["统一发送入口<br/>SmsFacade"] B --> C["通道配置<br/>ChannelConfig"] B --> D["状态缓存<br/>Redis / Cache"] B --> E["发送策略接口<br/>SmsChannelHandler"] E --> F["厂商 A 实现"] E --> G["厂商 B 实现"] E --> H["厂商 C 实现"]
业务层不直接面对厂商,而是只面对一个统一入口;真正的厂商差异,被压到了模块内部。
这就是后面所有设计模式能够成立的前提。
三、3 个典型设计模式
- 门面模式:统一对外入口。
- 策略模式:隔离不同短信厂商。
- 注册表 / 工厂思想:根据通道类型选择具体实现。
这三个模式已经足够把这类模块讲明白。
四、模式一:门面模式,先把复杂度挡在模块后面
1. 什么叫门面模式
门面模式的核心思想很朴素:
对外只给一个简单入口,对内把复杂流程藏起来。
如果用生活中的例子来比喻,酒店前台就是一个典型门面。
住客不会自己去找保洁、找房态系统、找门锁系统、找财务系统。住客只跟前台说一句“我要入住”,前台负责把后面的复杂事情协调起来。
短信模块也是一样。
业务系统真正想表达的只有一句话:
“帮我给这个手机号发一条某种业务类型的验证码短信。”
调用方不应该知道这些内部细节:
- 当前默认用哪家短信厂商。
- 如果失败了应该切换到谁。
- 模板 ID 在不同厂商下如何映射。
- 这个通道是不是刚刚被临时禁用过。
- 错误次数要不要累计到缓存里。
这些都应该是模块自己的事。
2. 门面模式在短信模块里的作用
门面模式落到这类模块里,通常会有一个统一入口类,比如:
▼java复制代码public class SmsFacade { public boolean sendCode(String phone, String bizType, Map<String, String> params) { // 1. 读取可用通道 // 2. 选择起始通道 // 3. 检查通道状态 // 4. 找到对应厂商实现 // 5. 发送并处理结果 return true; } }
这段代码最重要的不是方法体,而是这个入口本身。
它让业务层形成一个非常清晰的依赖关系:
▼java复制代码smsFacade.sendCode(phone, "login", Map.of("code", code));
业务层到这里就应该结束。
不要再让业务层知道厂商名、模板编码、签名格式、接口路径这些底层信息。
五、模式二:策略模式,把“不同厂商的不同发法”拆开
1. 为什么这里天然适合策略模式
短信厂商的差异非常明显:
- 请求地址不一样。
- 鉴权方式不一样。
- 模板参数格式不一样。
- 响应结构不一样。
- 成功失败判断规则也不完全一样。
但从业务角度看,它们做的又是同一件事:
发短信。
这正是策略模式最适合发挥作用的地方。
策略模式不是为了显得高级,而是因为这里真的存在:
- 同一个目标。
- 多种实现方式。
- 实现方式之间可能随时替换。
2. 先抽出一个稳定接口
这时最合理的做法,就是先定义一个稳定的发送接口:
▼java复制代码public interface SmsChannelHandler { boolean send(ChannelConfig channel, List<String> phones, Map<String, String> params, String vendorTemplateId); }
这个接口传达的是一个非常重要的设计意识:
系统只约定“发送能力”,不约定“发送细节”。
接口只关心“你能不能发”,而不关心“你内部怎么发”。
3. 每个厂商一个策略实现
然后,每家厂商都各自实现:
▼java复制代码public class VendorAHandler implements SmsChannelHandler { @Override public boolean send(ChannelConfig channel, List<String> phones, Map<String, String> params, String vendorTemplateId) { // 组装 A 厂商请求 // 调 A 厂商接口 // 返回发送结果 return true; } }
▼java复制代码public class VendorBHandler implements SmsChannelHandler { @Override public boolean send(ChannelConfig channel, List<String> phones, Map<String, String> params, String vendorTemplateId) { // 组装 B 厂商请求 // 调 B 厂商接口 // 返回发送结果 return true; } }
这样一拆,效果非常直接:
| 不用策略模式 | 用策略模式 |
|---|---|
| 一个类里塞满所有厂商逻辑 | 一个厂商一个实现类 |
| 新增厂商要改旧代码 | 新增类即可 |
| 测试时容易牵一发动全身 | 每个厂商可独立测试 |
| 业务代码会被厂商细节污染 | 业务代码只调用统一入口 |
4. 这张图能把策略模式看得更直观
▼mermaid复制代码classDiagram class SmsChannelHandler { <<interface>> +send(channel, phones, params, templateId) boolean } class VendorAHandler class VendorBHandler class VendorCHandler SmsChannelHandler <|.. VendorAHandler SmsChannelHandler <|.. VendorBHandler SmsChannelHandler <|.. VendorCHandler
动作稳定,算法变化,这就是策略模式最典型的落点。
5. 策略模式的使用
真正要学会的是这种拆分思维:
- 先找到稳定不变的动作。
- 再找到会变化的实现方式。
- 用接口把稳定和变化隔开。
只要抓住这三步,很多基础模块都能用同样的方法设计。
不只是短信。
邮件、推送、支付、对象存储、地图服务,甚至 OCR、翻译、语音识别,本质上都可以这么拆。
六、模式三:注册表 / 工厂思想,运行时到底怎么选中正确实现
1. 只用了策略模式还不够
已经抽出了 SmsChannelHandler 接口,也写了多个厂商实现,但还差最后一步:
运行时,系统怎么知道当前应该调用哪一个实现?
如果这一步还写成一堆 if...else,那前面的策略模式就打折了。
所以,这里还需要一个“选择策略”的机制。
这个机制在很多项目里可以理解成:
- 注册表思想。
- 轻量工厂思想。
2. 什么叫注册表思想
你可以把它理解成一本“实现清单”。
系统会先把所有短信发送实现登记好:
▼java复制代码Map<String, SmsChannelHandler> registry = new HashMap<>(); registry.put("ALI", new AliSmsHandler()); registry.put("HUAWEI", new HuaweiSmsHandler()); registry.put("TENCENT", new TencentSmsHandler());
等真正发送时,就不再写 if...else,而是按“通道类型”直接去查:
▼java复制代码SmsChannelHandler handler = registry.get(channelType); handler.send(channel, phones, params, templateId);
这件事说白了就是:
先登记,再按名字取。
3. 为什么它也可以理解成工厂思想
从工程效果上看,它和工厂模式做的是同一件事:
根据输入条件,返回正确的对象。
所以这里没必要太纠结术语。
对这篇文章的读者来说,更重要的是理解它解决了什么问题:
调用方不用关心具体厂商类名,系统自己根据配置选出正确实现。
4. 运行时到底怎么选中正确实现
真正的过程其实很简单:
- 先拿到当前要用的通道。
- 看这个通道的
type是什么。 - 再去注册表里找到同名实现。
- 调用这个实现完成发送。
可以画成这样:
▼mermaid复制代码flowchart LR A["读取当前通道"] --> B["拿到通道类型"] B --> C["到注册表中查找实现"] C --> D["取出对应处理器"] D --> E["执行发送"]
所以系统真正做的,不是“我要不要调阿里云类”。
系统真正做的是:
当前通道类型是什么,这个类型在注册表里对应哪一个实现。
5. 为什么它特别适合短信场景
因为短信模块天然有两个变化点:
- 厂商实现会变。
- 运行时选中的通道也会变。
如果没有注册表 / 工厂思想,代码很容易重新退化成这样:
▼java复制代码if ("ALI".equals(channelType)) { // 调阿里云 } else if ("HUAWEI".equals(channelType)) { // 调华为云 } else if ("TENCENT".equals(channelType)) { // 调腾讯云 }
这会带来两个直接问题:
- 每增加一个厂商,就要改原有判断逻辑。
- 统一发送入口会越来越臃肿。
它是在保护前面已经拆好的策略结构,不让系统重新退回 if...else。
6. 故障切换时,这个模式怎么配合工作
短信验证码通常不只配一个通道。
因为主通道短时间故障时,系统往往要切到备用通道。
这时运行过程通常是这样:
▼mermaid复制代码flowchart TD A["先尝试主通道"] --> B["根据 type 找到对应实现"] B --> C["执行发送"] C --> D{"是否成功"} D -- 是 --> E["结束"] D -- 否 --> F["切换到下一个通道"] F --> B
这里最值得注意的一点是:
切换的是通道,命中的是实现。
也就是说:
- 门面层决定下一条要尝试的通道。
- 注册表 / 工厂机制根据新通道的
type取出新实现。 - 新实现继续发送。
这样职责就很清楚。
7. 这个模式和策略模式怎么分工
它们解决的问题不一样:
| 模式 | 解决的问题 |
|---|---|
| 策略模式 | 不同厂商的发送逻辑怎么拆开 |
| 注册表 / 工厂思想 | 运行时怎么选中正确的厂商实现 |
- 策略模式解决“有哪些发法”。
- 注册表 / 工厂思想解决“这次该用哪种发法”。
两者配合起来,模块才完整。
8. 设计判断
以后做类似模块时,要思考一下:
已经有多个实现了,那运行时谁来负责选中正确那个?
如果这个问题还只能靠 if...else 回答,那通常就说明还缺一个注册表 / 工厂层。
这不只是短信模块适用。
邮件、推送、支付、对象存储、OCR、翻译接口,很多“多供应商接入”场景,本质上都一样。
七、把三个模式连起来看,模块就清楚了
单看每个模式都不难,但很多开发者真正卡住的地方,是不知道它们如何组合。
这里是一张完整流程图。
▼mermaid复制代码sequenceDiagram participant Biz as 业务层 participant Facade as SmsFacade participant Cache as 状态缓存 participant Registry as HandlerRegistry participant HandlerA as 厂商A处理器 participant HandlerB as 厂商B处理器 Biz->>Facade: sendCode(phone, bizType, params) Facade->>Cache: 查询通道状态 Facade->>Registry: 按通道类型获取处理器 Registry-->>Facade: 返回对应 Handler Facade->>HandlerA: send(...) alt 主通道成功 HandlerA-->>Facade: success Facade-->>Biz: 返回成功 else 主通道失败 HandlerA-->>Facade: fail Facade->>Cache: 记录失败状态 Facade->>Registry: 获取备用通道处理器 Registry-->>Facade: 返回备用 Handler Facade->>HandlerB: send(...) HandlerB-->>Facade: 返回结果 Facade-->>Biz: 返回最终结果 end
- 业务层只找门面。
- 门面不自己发短信,而是去找策略。
- 找哪一个策略,由注册表或工厂机制决定。
- 如果失败,再由门面继续调度下一个通道。
这就是这类模块最核心的设计骨架。
八、除了设计模式,这类模块还有两个很重要的配套思路
虽然本文重点讲模式,但如果完全不提这两点,开发者照着做时还是容易踩坑。
1. 配置化,不要把厂商细节写死在代码里
下面这些信息,最好都放进配置里,而不是写死:
- 通道类型。
- 通道开关。
- 签名。
- 模板映射。
- 主备顺序。
- 失败阈值。
- 禁用时长。
可以抽象成这样的配置结构:
▼yaml复制代码sms: fail-threshold: 5 disable-minutes: 30 channels: - id: primary type: VENDOR_A enabled: true signature: your-sign templates: login: tpl_a_login register: tpl_a_register - id: backup type: VENDOR_B enabled: true signature: your-sign templates: login: tpl_b_login register: tpl_b_register
会变化的东西尽量放配置,不要塞进业务代码。
2. 故障切换不是设计模式,但它决定模块是否真正可用
验证码链路最怕的就是:
“代码没报错,但用户就是收不到验证码。”
所以,一个能拿出去复用的短信设计,不能只考虑成功路径。
它至少要考虑:
- 主通道失败怎么办。
- 失败次数怎么记录。
- 临时故障的通道要不要短时间禁用。
- 多实例部署时状态怎么共享。
设计模式决定模块是否清晰,故障切换决定模块是否可靠。
九、如果你自己要设计一套短信验证码模块,可以按这个顺序做
建议按下面这个顺序设计,而不是一上来就接 SDK。
▼mermaid复制代码flowchart TD A["先定义统一发送入口"] --> B["再抽象发送策略接口"] B --> C["再实现不同厂商策略"] C --> D["再设计注册表 / 工厂选择机制"] D --> E["再把模板、签名、通道做成配置"] E --> F["最后补失败切换、缓存状态和监控"]
这个顺序有一个很重要的设计原则:
先设计边界,再写实现。
为什么很多短信模块后期越来越难改?
不是因为厂商 SDK 太复杂,而是因为一开始就把实现写进了业务层,没有先把边界抽出来。
如果顺序反过来,通常会更糟:
- 先接 SDK。
- 再把 SDK 调用复制到各个业务。
- 再发现需要多个厂商。
- 再发现需要主备切换。
- 最后只能在旧代码上不断打补丁。
所以,真的想写出能长期维护的模块,顺序不能错。
十、设计判断
什么时候应该用门面模式
当调用方只需要一个简单动作,但这个动作背后其实包含多步协作时,就应该考虑门面模式。
短信验证码就是这种场景。
什么时候应该用策略模式
当目标相同,但实现方式有多种,而且未来还可能继续增加时,就应该考虑策略模式。
多短信厂商就是这种场景。
什么时候需要注册表 / 工厂思想
当你已经有多个策略实现,但运行时还需要根据条件动态选出一个正确实现时,就应该引入注册表或工厂机制。
通道类型选厂商,就是这种场景。
十一、总结
如果你以后也要写类似模块,无论是短信、邮件、推送、支付,还是 OCR、地图、翻译接口,要先考虑这些问题
问题
- 有没有一个统一对外入口。
- 有没有把不同供应商实现抽成策略接口。
- 有没有一个清晰的机制,能在运行时选出正确实现。
- 有没有把模板、签名、通道顺序等易变信息做成配置。
- 有没有考虑主通道失败后的切换逻辑。
- 有没有避免把供应商细节泄漏到业务层。
结语
这篇文章的重点,是一套可以迁移的设计方法:
- 用门面模式收口入口。
- 用策略模式隔离厂商差异。
- 用注册表 / 工厂思想完成动态选择。
剩下的配置化、缓存状态、失败切换、监控告警,都是围绕这套骨架继续往上搭。
