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

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

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

于是代码很容易直接绑死某一家厂商,模板散落在业务里,切换通道靠 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