基于策略模式的短信验证码发送设计

短信验证码几乎是后台系统里最常见的基础能力之一。

但很多项目在一开始做这块功能时,目标通常只有一个:先把验证码发出去。

于是代码很容易直接绑死某一家厂商,模板散落在业务里,切换通道靠 if...else,一旦后面要接第二家、第三家短信服务,模块复杂度就会迅速失控。

如果你要设计一套短信验证码发送模块,应该怎样用设计模式把它做得更清晰、更容易扩展,也更方便做多通道切换。


一、为什么短信验证码模块值得单独设计

很多系统在最开始做短信功能时,往往只有一个目标:先把验证码发出去。

于是代码通常会长成这样:

▼
java
复制代码
if (vendor.equals("A")) { // 调 A 厂商 SDK } else if (vendor.equals("B")) { // 调 B 厂商接口 } else { // 报错 }

这种写法短期很快,但很快就会遇到一连串问题:

  1. 业务代码直接依赖某一家短信厂商,后面很难替换。
  2. 登录、注册、找回密码、活动提醒这些模板逻辑会散落在不同地方。
  3. 一旦主通道故障,整个验证码链路会跟着出问题。
  4. 每接入一家新厂商,原有代码就会继续膨胀。
  5. 调用方知道的内部细节太多,模块边界越来越模糊。

所以,短信验证码模块真正要解决的不是“怎么调 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. 门面模式:统一对外入口。
  2. 策略模式:隔离不同短信厂商。
  3. 注册表 / 工厂思想:根据通道类型选择具体实现。

这三个模式已经足够把这类模块讲明白。


四、模式一:门面模式,先把复杂度挡在模块后面

1. 什么叫门面模式

门面模式的核心思想很朴素:

对外只给一个简单入口,对内把复杂流程藏起来。

如果用生活中的例子来比喻,酒店前台就是一个典型门面。

住客不会自己去找保洁、找房态系统、找门锁系统、找财务系统。住客只跟前台说一句“我要入住”,前台负责把后面的复杂事情协调起来。

短信模块也是一样。

业务系统真正想表达的只有一句话:

“帮我给这个手机号发一条某种业务类型的验证码短信。”

调用方不应该知道这些内部细节:

  1. 当前默认用哪家短信厂商。
  2. 如果失败了应该切换到谁。
  3. 模板 ID 在不同厂商下如何映射。
  4. 这个通道是不是刚刚被临时禁用过。
  5. 错误次数要不要累计到缓存里。

这些都应该是模块自己的事。

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. 为什么这里天然适合策略模式

短信厂商的差异非常明显:

  1. 请求地址不一样。
  2. 鉴权方式不一样。
  3. 模板参数格式不一样。
  4. 响应结构不一样。
  5. 成功失败判断规则也不完全一样。

但从业务角度看,它们做的又是同一件事:

发短信。

这正是策略模式最适合发挥作用的地方。

策略模式不是为了显得高级,而是因为这里真的存在:

  1. 同一个目标。
  2. 多种实现方式。
  3. 实现方式之间可能随时替换。

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. 策略模式的使用

真正要学会的是这种拆分思维:

  1. 先找到稳定不变的动作。
  2. 再找到会变化的实现方式。
  3. 用接口把稳定和变化隔开。

只要抓住这三步,很多基础模块都能用同样的方法设计。

不只是短信。

邮件、推送、支付、对象存储、地图服务,甚至 OCR、翻译、语音识别,本质上都可以这么拆。


六、模式三:注册表 / 工厂思想,运行时到底怎么选中正确实现

1. 只用了策略模式还不够

已经抽出了 SmsChannelHandler 接口,也写了多个厂商实现,但还差最后一步:

运行时,系统怎么知道当前应该调用哪一个实现?

如果这一步还写成一堆 if...else,那前面的策略模式就打折了。

所以,这里还需要一个“选择策略”的机制。

这个机制在很多项目里可以理解成:

  1. 注册表思想。
  2. 轻量工厂思想。

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. 运行时到底怎么选中正确实现

真正的过程其实很简单:

  1. 先拿到当前要用的通道。
  2. 看这个通道的 type 是什么。
  3. 再去注册表里找到同名实现。
  4. 调用这个实现完成发送。

可以画成这样:

▼
mermaid
复制代码
flowchart LR A["读取当前通道"] --> B["拿到通道类型"] B --> C["到注册表中查找实现"] C --> D["取出对应处理器"] D --> E["执行发送"]

所以系统真正做的,不是“我要不要调阿里云类”。

系统真正做的是:

当前通道类型是什么,这个类型在注册表里对应哪一个实现。

5. 为什么它特别适合短信场景

因为短信模块天然有两个变化点:

  1. 厂商实现会变。
  2. 运行时选中的通道也会变。

如果没有注册表 / 工厂思想,代码很容易重新退化成这样:

▼
java
复制代码
if ("ALI".equals(channelType)) { // 调阿里云 } else if ("HUAWEI".equals(channelType)) { // 调华为云 } else if ("TENCENT".equals(channelType)) { // 调腾讯云 }

这会带来两个直接问题:

  1. 每增加一个厂商,就要改原有判断逻辑。
  2. 统一发送入口会越来越臃肿。

它是在保护前面已经拆好的策略结构,不让系统重新退回 if...else。

6. 故障切换时,这个模式怎么配合工作

短信验证码通常不只配一个通道。

因为主通道短时间故障时,系统往往要切到备用通道。

这时运行过程通常是这样:

▼
mermaid
复制代码
flowchart TD A["先尝试主通道"] --> B["根据 type 找到对应实现"] B --> C["执行发送"] C --> D{"是否成功"} D -- 是 --> E["结束"] D -- 否 --> F["切换到下一个通道"] F --> B

这里最值得注意的一点是:

切换的是通道,命中的是实现。

也就是说:

  1. 门面层决定下一条要尝试的通道。
  2. 注册表 / 工厂机制根据新通道的 type 取出新实现。
  3. 新实现继续发送。

这样职责就很清楚。

7. 这个模式和策略模式怎么分工

它们解决的问题不一样:

模式解决的问题
策略模式不同厂商的发送逻辑怎么拆开
注册表 / 工厂思想运行时怎么选中正确的厂商实现
  1. 策略模式解决“有哪些发法”。
  2. 注册表 / 工厂思想解决“这次该用哪种发法”。

两者配合起来,模块才完整。

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. 业务层只找门面。
  2. 门面不自己发短信,而是去找策略。
  3. 找哪一个策略,由注册表或工厂机制决定。
  4. 如果失败,再由门面继续调度下一个通道。

这就是这类模块最核心的设计骨架。


八、除了设计模式,这类模块还有两个很重要的配套思路

虽然本文重点讲模式,但如果完全不提这两点,开发者照着做时还是容易踩坑。

1. 配置化,不要把厂商细节写死在代码里

下面这些信息,最好都放进配置里,而不是写死:

  1. 通道类型。
  2. 通道开关。
  3. 签名。
  4. 模板映射。
  5. 主备顺序。
  6. 失败阈值。
  7. 禁用时长。

可以抽象成这样的配置结构:

▼
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. 故障切换不是设计模式,但它决定模块是否真正可用

验证码链路最怕的就是:

“代码没报错,但用户就是收不到验证码。”

所以,一个能拿出去复用的短信设计,不能只考虑成功路径。

它至少要考虑:

  1. 主通道失败怎么办。
  2. 失败次数怎么记录。
  3. 临时故障的通道要不要短时间禁用。
  4. 多实例部署时状态怎么共享。

设计模式决定模块是否清晰,故障切换决定模块是否可靠。


九、如果你自己要设计一套短信验证码模块,可以按这个顺序做

建议按下面这个顺序设计,而不是一上来就接 SDK。

▼
mermaid
复制代码
flowchart TD A["先定义统一发送入口"] --> B["再抽象发送策略接口"] B --> C["再实现不同厂商策略"] C --> D["再设计注册表 / 工厂选择机制"] D --> E["再把模板、签名、通道做成配置"] E --> F["最后补失败切换、缓存状态和监控"]

这个顺序有一个很重要的设计原则:

先设计边界,再写实现。

为什么很多短信模块后期越来越难改?

不是因为厂商 SDK 太复杂,而是因为一开始就把实现写进了业务层,没有先把边界抽出来。

如果顺序反过来,通常会更糟:

  1. 先接 SDK。
  2. 再把 SDK 调用复制到各个业务。
  3. 再发现需要多个厂商。
  4. 再发现需要主备切换。
  5. 最后只能在旧代码上不断打补丁。

所以,真的想写出能长期维护的模块,顺序不能错。


十、设计判断

什么时候应该用门面模式

当调用方只需要一个简单动作,但这个动作背后其实包含多步协作时,就应该考虑门面模式。

短信验证码就是这种场景。

什么时候应该用策略模式

当目标相同,但实现方式有多种,而且未来还可能继续增加时,就应该考虑策略模式。

多短信厂商就是这种场景。

什么时候需要注册表 / 工厂思想

当你已经有多个策略实现,但运行时还需要根据条件动态选出一个正确实现时,就应该引入注册表或工厂机制。

通道类型选厂商,就是这种场景。


十一、总结

如果你以后也要写类似模块,无论是短信、邮件、推送、支付,还是 OCR、地图、翻译接口,要先考虑这些问题

问题

  1. 有没有一个统一对外入口。
  2. 有没有把不同供应商实现抽成策略接口。
  3. 有没有一个清晰的机制,能在运行时选出正确实现。
  4. 有没有把模板、签名、通道顺序等易变信息做成配置。
  5. 有没有考虑主通道失败后的切换逻辑。
  6. 有没有避免把供应商细节泄漏到业务层。

结语

这篇文章的重点,是一套可以迁移的设计方法:

  1. 用门面模式收口入口。
  2. 用策略模式隔离厂商差异。
  3. 用注册表 / 工厂思想完成动态选择。

剩下的配置化、缓存状态、失败切换、监控告警,都是围绕这套骨架继续往上搭。

0个评论
点击登录,快来和大家讨论吧~
表情
图片
暂无评论
下载 APP