美文网首页
消息网关(二)-- 编码实现

消息网关(二)-- 编码实现

作者: 天草二十六_简村人 | 来源:发表于2022-11-16 11:13 被阅读0次

一、类的设计

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 通知类的短信,我们会在消息表的上一层,增加一个任务表,实现平台内的重试(保证短信发送到达,允许一定的时延)。
  • 和业务方对接,除了消息平台及时回调通知外, 还提供主动查询接口,让业务方知晓发送结果。

相关文章

网友评论

      本文标题:消息网关(二)-- 编码实现

      本文链接:https://www.haomeiwen.com/subject/niusxdtx.html