一、类的设计
image.png具体的发送实现,本文就不介绍了,很多云厂商在对接文档中,都有基本的示例代码。
二、目标
- 1、短信在发送验证码的时候,往往会有频率的限制,比如说一分钟内只能发送一条。我们的业务有时候就是有需求,所以需要突破这一点,对接多个第三方渠道。
- 2、同一个手机号,当对接多个第三方短信渠道的时候,支持轮询、权重等策略发送。
- 3、当某个第三方渠道被封禁时,用户重试获取短信的时候,可以选择下一个渠道进行尝试。
- 4、支持快速接入新的渠道,要求可扩展性好。
三、设计思路
发送消息的核心逻辑是:
- 第一步,对入参的校验。
- 第二步,根据应用ID(applicationId)和账号account,查询出本次对应的渠道channel。
- 第三步,根据传入的消息模板编号,查询它配置的渠道列表mapping。
(这是因为有些短信模板,不是每个第三方渠道都有申请通过。所以某个短信有哪些渠道,和应用配置了哪些渠道,不是一一对应的关系) - 第四步,根据消息类型,选择对应的实现类。
- 第五步,根据消息模板和传入的变量值,解析出完整的消息正文,调用第三方渠道请求发送。
- 第六步,接收第三方渠道的回调接口,更新消息表的状态。
四、伪代码逻辑
1、查询本次对应的渠道channel
// messageType 消息类型
// account 即账号,比如手机号,邮箱等
// templateCode 消息模板编号
private Channel findMessageChannel(final String channelId, final String applicationId, final String messageType, final String account, final String templateCode) {
Channel channel;
// 指定了发送渠道
if (channelId不为空) {
channel = channelRepository.lookupMessageChannelOfApplication(applicationId,
channelId);
} else {
// 查询应用下的对应消息类型的所有渠道
final List<Channel> channels = channelRepository.lookupMessageChannelOfApplication(
applicationId, messageType);
if (channels == null || channels.isEmpty()) {
if (log.isWarnEnabled()) {
log.warn("未找到应用下的消息类型配置. [applicationId={}, messageType={}]", applicationId, messageType);
}
return null;
}
// 由工厂类根据一定的策略,选择最优的渠道
channel = messageChannelFactory.selectDeliveryChannel(messageType, templateCode, account, channels);
}
return channel;
}
2、渠道工厂类MessageChannelFactory
// 这里的if/else, 也可以替换为case/when,Map<Bean>等都行
public Channel selectDeliveryChannel(MessageType messageType, String templateCode, String account, List<Channel> channels) {
if (MessageType.SMS.equals(messageType)) {
// templateCode 如果为空,则抛出异常
SmsTemplate smsTemplate = smsTemplateService.find(templateCode);
if (null == smsTemplate) {
if (log.isWarnEnabled()) {
log.warn("消息模板编号未配置. [templateCode={}, messageType={}]", templateCode, messageType);
}
return null;
}
return smsDeliverStrategy.getChannel(account, channels);
} else if (MessageType.EMAIL.equals(messageType)) {
return getChannel(channels, new ProviderId(EmailChannel.QQ.getCode()));
} else if (MessageType.WX.equals(messageType)) {
return getChannel(channels, new ProviderId(WxChannel.WEWORK.getCode()));
}
return null;
}
- 选择指定的渠道
private Channel getChannel(List<Channel> channels, ProviderId providerId) {
return channels.stream().filter(k -> k.providerId().sameValueAs(providerId)).findFirst().get();
}
3、路由策略之轮询
public class SmsDeliveRoundRobin {
private final StringRedisTemplate redisTemplate;
private static final String SMS_MOBILE = "messageGateway:sms:mobile:%s";
// 获取渠道
public Channel getChannel(String account, List<Channel> channels) {
Map<String, Channel> channelMap; // TODO 将List<Channel> channels转换为Map
final String redisKey = String.format(SMS_MOBILE, clientId);
String smsChannelCode = redisTemplate.opsForValue().get(redisKey);
// 如果是第一次,或者存储的上次渠道已过期, 则重新走选择流程
if (StringUtils.isEmpty(smsChannelCode)) {
// 默认渠道
SmsChannel defaultChannel = getDefaultChannel(channelMap);
if (null == defaultChannel) {
// 未找到默认的渠道
return null;
}
redisTemplate.opsForValue().set(redisKey, defaultChannel.getCode());
redisTemplate.expire(redisKey, 12, TimeUnit.HOURS);
return channelMap.get(defaultChannel.getCode());
}
// 如果redis缓存未过期,则取出上一次的渠道
SmsChannel lastChannel = SmsChannel.of(smsChannelCode);
// 根据上一次的渠道,计算本次应该选择的渠道
SmsChannel thisChannel = getThisChannel(channelMap, lastChannel);
// 将本次的渠道存储到redis,以便下一次的渠道选取
redisTemplate.opsForValue().set(redisKey, thisChannel.getCode());
return channelMap.get(thisChannel.getCode());
}
// smsTemplateMapping 是一个Json格式,key是第三方渠道,value是第三方渠道的模板编号
// 默认取排序中的第一个即可。
private SmsChannel getDefaultChannel(Map<String, Channel> channelMap) {
// 根据已配置渠道,选择枚举类中优先级最高的那个
// 所以必须是已排序的渠道列表SmsChannel.SORTED_CHANNELS
Optional<SmsChannel> channel = SmsChannel.SORTED_CHANNELS.stream()
.filter(k -> channelMap.containsKey(k.getCode()))
.findFirst();
//TODO Channel 增加sort排序字段,不从枚举类中获取已排序的渠道列表,
// 而从数据库读取--也即channelMap。
if (!channel.isPresent()) {
return null;
}
return channel.get();
}
// TODO channelMap 修改为已排序的渠道列表,后期就不依赖枚举类,
// 也不用增加channelMap.containsKey的判断了
private SmsChannel getThisChannel(Map<String, Channel> channelMap, SmsChannel lastChannel) {
int totalChannelSize = SmsChannel.SORTED_CHANNELS.size();
// 根据上次渠道,循环选择枚举类的渠道
for (int i = 0; i < totalChannelSize; i++) {
SmsChannel smsChannel = SmsChannel.SORTED_CHANNELS.get(i);
// 条件1:数据库已配置该渠道
if (!channelMap.containsKey(smsChannel.getCode())) {
break;
}
// 条件2:(上一次渠道的序号+1)% 总的渠道数量 = 本次渠道的序号,说明轮询命中
if (smsChannel.getSort() != (lastChannel.getSort() + 1) % totalChannelSize) {
continue;
}
return smsChannel;
}
return lastChannel;
}
}
在总共三个第三方渠道的情况下,寻轮策略的每次步长是固定值--1.
默认第一次是0-阿里云, 第二次就是(0+1) % 3==1--华为云,第三次就是(1+1) % 3==2--腾讯云,第四次就是(2+1) % 3==0--阿里云。
总结它的规律就是:
(上次渠道的序号 + 步长)% (总的渠道数) == 本次渠道的序号
4、渠道枚举类,支持排序。
后期可以调整为从数据库里查询,由DB排序实现。
/**
* 短信渠道枚举
*
**/
@Getter
public enum SmsChannel {
ALIYUN("SMS_ALIYUN", 0, "阿里云"),
HUAWEIYUN("SMS_HUAWEIYUN", 1, "华为云"),
SHIYUAN("SMS_TENCENT", 2, "腾讯云");
private String code;
private int sort;
private String desc;
private static final Map<String, SmsChannel> CODE_MAP =
Collections.unmodifiableMap(Arrays.stream(values())
.collect(toMap(v -> v.code, v -> v)));
public static final List<SmsChannel> SORTED_CHANNELS = Collections.unmodifiableList(Arrays.stream(values())
.sorted(Comparator.comparing(SmsChannel::getSort))
.collect(Collectors.toList()));
SmsChannel(String code, int sort, String desc) {
this.code = code;
this.sort = sort;
this.desc = desc;
}
public static SmsChannel of(String code) {
return CODE_MAP.get(code);
}
}
五、总结
- Channel 增加sort 排序字段,用于获取默认的渠道,设置渠道的优先级。
- TODO 根据到达率,选择最优的第三方渠道,这要求算法很及时,而且验证码这一块的限流,不太好控制。
- 现在的重试是交给用户,特别是验证码类的短信。TODO 通知类的短信,我们会在消息表的上一层,增加一个任务表,实现平台内的重试(保证短信发送到达,允许一定的时延)。
- 和业务方对接,除了消息平台及时回调通知外, 还提供主动查询接口,让业务方知晓发送结果。
网友评论