基于设计模式改造短信网关服务实战篇(设计思想、方案呈现、源码)

文章介绍了如何通过抽象工厂模式、策略模式和单例模式设计一个可扩展、低耦合的短信服务管理系统,以适应不同云服务商的对接需求。通过抽象出各云服务商的共同能力和实体,实现了功能的聚合和解耦,同时提供了源码示例。
摘要由CSDN通过智能技术生成

在这里插入图片描述

背景

博主通过在工作中的总结,接到过各种不同短信提供商的短信管理功能,一旦涉及到 2B 服务时,经常会出现需要根据不同的对接方来进行短信发放了,比如:阿里云、腾讯云、华为云等等,各大短信提供商平台,至此,想整理一套集成多种云服务短信提供商的方案出来,提供可扩展、低耦合的设计方案呈现出来,同时可以一起和大家回顾一下设计模式这块相关的理论知识以及具体落地!

方案

如何设计?

往往每个功能,例如:签名、模板,服务提供方它们对应的实体结构又截然不同,所以对于实体而言,它们也可以抽象出来,例如:签名->AbstractSMSSign、模板->AbstractSMSTemplate、短链->AbstractSMSShortLink
从实体这块抽象出来以后,最终都会去调用同一个能力,而实体只能有一个,这时候需要一个抽象工厂来对每个服务提供方工厂进行能力的聚合,每个服务提供方的工厂只创建与自己相关的操纵实体,从而引出了另外一种设计模式:抽象工厂模式

从短信服务提供方提供的能力来看,涉及到的功能有签名、模板、短链,短信发送,查询状态等等,而这些功能一般服务提供方都会存在,那么这些就可以看做是公共能力了,就可以将它们抽象出来作为父类的能力,从而让不同的服务提供方子类各自继承父类,由子类去作具体的能力实现逻辑,从而引出了一种设计模式:策略模式

In addition(另外),在调用服务提供方的能力时,我们往往需要与它们建立好连接,它们那边作为服务端,而我们这边作为客户端,因此创建客户端实例成为了必不可少的一部分,从这里看来,我们可以把客户端的这个实例,作为单例存在,所有能力都基于同一个客户端去向服务提供方发出请求,从而引出了另外一种设计模式:单例模式

改造方案

聚合实体

在这里插入图片描述
基于设计的方法进行类结构关系的梳理如上,使用抽象工厂模式来分解不同实体之间的耦合度

从传统的理解来看,把阿里云、腾讯云比喻为不同的产品,组合产品族,所以每一套有相似能力的产品将它们统一由抽象工厂去管理,从而引出了:AbstractSMSFactory

每一种产品中,创建签名、模板、短信实体是它所属的能力,但每个产品对它的能力又会不一样的生产过程,比如:校验方式不同、传值多与少;所以将这些能力统一由抽象实体去统一,当然它可以把公共的属性放入到其中,从而引出了:AbstractSign、AbstractTemplate、AbstractSendSms

对于抽象工厂这种模式,有一种弊端,当产品出现了新的能力以后,这时候需要改动抽象工厂类以及抽象工厂子类的代码,比如:增加了短链的功能,那么 AbstractSMSFactory 需要提供创建抽象短链的实体,而每个子工厂要去创建自己产品内短链特有的创建逻辑

对于短信这块能力,一般可以先采购需求,将服务商之间的功能差异进行并集,一次性把这些能力放在抽象工厂,后续这一块就无需再动了,只需要关注具体子工厂下的创建细节了!

聚合功能

上面通过抽象工厂模式方案对不同服务提供商的实体部分进行了聚合,这时就需要根据不同的服务提供商调用它们的 API 了,也就是有不同的实现
在这里插入图片描述
基于设计思路,对服务商的能力进行了抽象化,使用策略设计模式来分解不同能力之间的耦合度

短信签名模块:审核签名、删除签名、更新签名、查询签名审核状态
短信模板模块:审核模板、删除模板、更新模板、查询模板审核状态
短信发送模块:发送短信、查询短信发送回执状态

以上三个模块的功能基本上服务商都有提供,这部分功能就是公共能力了,只是每个服务商它具体调用方式不同,从而将公共能力抽取出来,放在 AbstractSMSService 中体现,同时搭配 聚合实体 中提到的抽象实体一起使用,具体能力的实现交由子类去自行实现

对于能力这块,当调用服务商能力出现异常或增强时,只需要对具体的类进行修改,同时支持引入更多的短信服务商,适配能力

聚合实例

当调用短信服务商能力,要创建一个客户端实例与之通信,为了避免我们程序内部资源的开销,每次调用都创建一个实例,进行重复的操作,将实例通过安全单例设计模式实现,有几种:双重检测-单例懒汉式、静态内部类-单例懒汉式、枚举单例

博主是通过,静态内部类-单例懒汉式实现的:由 JVM 保证单例,加载外部类时不会加载内部类,这样可以实现懒加载

源码

前提改造方法讲解完了,这个时候就到了具体的代码实操实现过程

由于一个企业只会用到其中一种短信服务商来进行短信下发动作,此时就需要从 Bean 这方面进行约束只能存在一个,那么就可以通过 Nacos 配置以及搭配 @Condition 注解来完成,贴出部分源码

/**
 * sms.provider.service=TencentCloud 注入 Bean:TencentCloudSMSFactory、TencentCloudSMSServiceImpl
 * @author vnjohn
 * @since 2023/3/17
 */
public class TencentCloudOnCondition implements Condition {
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        String propertyVal = context.getEnvironment().getProperty(Constants.SMS_CLOUD_PROVIDER_PROPERTIES);
        return null != propertyVal && propertyVal.equals(SMSCloudProviderEnum.TENCENT_CLOUD.getCode());
    }
}
/**
 * sms.provider.service=AliCloud 注入 Bean:AliCloudSMSFactory、AliCloudSMSServiceImpl
 * @author vnjohn
 * @since 2023/3/17
 */
public class AliCloudOnCondition implements Condition {
    @Override
    public boolean matches(ConditionContext context, AnnotatedTypeMetadata metadata) {
        String propertyVal = context.getEnvironment().getProperty(Constants.SMS_CLOUD_PROVIDER_PROPERTIES);
        return null != propertyVal && propertyVal.equals(SMSCloudProviderEnum.ALI_CLOUD.getCode());
    }
}

抽象工厂模式

抽象工厂类图

抽象工厂以及它对应的工厂子类,通过 @Condition 注解确保容器中只会存在一个抽象工厂实现类,以审核签名为例

/**
 * @author vnjohn
 * @since 2023/3/17
 */
public abstract class AbstractSMSFactory {
    /**
     * 创建待审核签名实体
     *
     * @param <T>
     * @return
     */
    public abstract <T extends AbstractSMSSign> AbstractSMSSign createApplySign(ApplySignDTO applySignDTO);
    // 省略其他代码 .......
} 

@Component
@Conditional(AliCloudOnCondition.class)
public class AliCloudSMSFactory extends AbstractSMSFactory {

    @Override
    public <T extends AbstractSMSSign> AliApplyOrModifySign createApplySign(ApplySignDTO applySignDTO) {
        AliSignSourceEnum signSourceEnum = checkAliSignSource(source);
        AliSignTypeEnum signTypeEnum = checkAliSignType(type);
        String fileContent = processFileContent(file);
        return AliApplyOrModifySign.builder()
                                   .name(name)
                                   .source(signSourceEnum.getOutCode())
                                   .type(signTypeEnum.getCode())
                                   .fileList(fileContent)
                                   .remark(remark)
                                   .build();
    }
    // 省略其他代码 .......
}

@Component
@Conditional(TencentCloudOnCondition.class)
public class TencentCloudSMSFactory extends AbstractSMSFactory {

    @Override
    public <T extends AbstractSMSSign> AbstractSMSSign createApplySign(ApplySignDTO applySignDTO) {
        TencentSMSTypeEnum signTypeEnum = checkTencentSignType(applySignDTO.getType());
        TencentDocumentTypeEnum certificationTypeEnum = checkCertificationType(applySignDTO.getCertificationType());
        TencentSignSourceEnum signSourceEnum = checkTencentSignSource(applySignDTO.getSource(), certificationTypeEnum);
        String imageBase64 = CertificationFileUtil.encryptFileToBase64(applySignDTO.getFile());
        return TencentApplyOrModifySign.builder()
                                       .name(applySignDTO.getName())
                                       .documentType(certificationTypeEnum.getCode())
                                       .purpose(applySignDTO.getPurpose())
                                       .type(signTypeEnum.getOutCode())
                                       .proofImage(imageBase64)
                                       .remark(applySignDTO.getRemark())
                                       .source(signSourceEnum.getOutCode())
                                       .build();
    }
    // 省略其他代码 .......
}

号外号外,当前在这里你可以自己任意扩展实现其他云服务,只需要继承抽象工厂类,然后在创建对应该云服务下的功能实体,继承至抽象能力实体类即可!

策略模式

在这里插入图片描述

抽象短信服务类以及它对应的短信服务子类,通过 @Condition 注解确保容器中只会存在一个抽象短信服务实现类,以审核签名为例

/**
 * 抽象短信服务公共能力
 *
 * @author vnjohn
 * @since 2023/3/17
 */
public abstract class AbstractSMSService {
    /**
     * 申请签名
     *
     * @param applySmsSign
     * @param <T>
     */
    public abstract <T extends AbstractSMSSign> String applySign(AbstractSMSSign applySmsSign);
    // 省略其他代码 .......
}

@Slf4j
@Component
@Conditional(AliCloudOnCondition.class)
public class AliCloudSMSServiceImpl extends AbstractSMSService {

	@Override
    public <T extends AbstractSMSSign> String applySign(AbstractSMSSign applySmsSign) {
        AliApplyOrModifySign applySign = (AliApplyOrModifySign) applySmsSign;
        AddSmsSignRequest addSmsSignRequest = applySign.toApplySmsSignRequest();
        try {
            AddSmsSignResponse applySmsSignResponse = getInstance().addSmsSign(addSmsSignRequest);
            log.info("apply ali sign,request【{}】,response【{}】", JacksonUtils.toJson(addSmsSignRequest), JacksonUtils.toJson(applySmsSignResponse));
            processMessageByCode(applySmsSignResponse.getBody().getCode(), applySmsSignResponse.getBody().getMessage());
            return applySmsSignResponse.getBody().getSignName();
        } catch (TeaException teaException) {
            log.error("Ali applySign teaException:{}", teaException.getMessage());
            throw new SmsBusinessException("apply ali sign fail");
        } catch (Exception e) {
            log.error("Ali applySign Exception:{}", e.getMessage());
            throw new SmsBusinessException(e.getMessage());
        }
    }
    // 省略其他代码 .......
}

@Slf4j
@Component
@Conditional(TencentCloudOnCondition.class)
public class TencentCloudSMSServiceImpl extends AbstractSMSService {

	@Override
    public <T extends AbstractSMSSign> String applySign(AbstractSMSSign applySmsSign) {
        TencentApplyOrModifySign applySign = (TencentApplyOrModifySign) applySmsSign;
        AddSmsSignRequest addSmsSignRequest = applySign.toAddSmsSignRequest();
        try {
            AddSmsSignResponse applySmsSignResponse = getInstance().AddSmsSign(addSmsSignRequest);
            log.info("apply tencent sign,request【{}】,response【{}】", JacksonUtils.toJson(addSmsSignRequest), JacksonUtils.toJson(applySmsSignResponse));
            return String.valueOf(applySmsSignResponse.getAddSignStatus().getSignId());
        } catch (TencentCloudSDKException sdkException) {
            log.error("Tencent applySign sdkException:{}", sdkException.getMessage());
            processMessageByCode(sdkException.getErrorCode());
        } catch (Exception e) {
            log.error("Tencent applySign Exception:{}", e.getMessage());
            throw new SmsBusinessException(e.getMessage());
        }
        return null;
    }
    // 省略其他代码 .......
}

号外号外,当前在这里你可以自己任意扩展实现其他云服务,只需要继承抽象短信服务类,然后在短信服务实现类写入 API 逻辑!

单例模式

通过阿里短信云、腾讯短信云 Open API 对接服务,客户端以单例的方式进行调用,源码如下:

核心参数配置类,如下:

@Data
@Component
@RefreshScope
public class SMSCloudProviderConfig {
    @Value("${sms.provider.ali.access-key}")
    private String aliAccessKey;

    @Value("${sms.provider.ali.secret}")
    private String aliSecret;

    @Value("${sms.provider.ali.endpoint:dysmsapi.aliyuncs.com}")
    private String aliEndPoint;

    @Value("${sms.provider.tencent.access-key}")
    private String tencentAccessKey;

    @Value("${sms.provider.tencent.secret}")
    private String tencentSecret;

    @Value("${sms.provider.tencent.endpoint:sms.tencentcloudapi.com}")
    private String tencentEndPoint;

    @Value("${sms.provider.tencent.region:ap-guangzhou}")
    private String tencentRegion;
}

阿里云

	private static String END_POINT;
    private static String ACCESS_KEY;
    private static String SECRET;

    @PostConstruct
    public void init() {
        ACCESS_KEY = smsProviderConfig.getAliAccessKey();
        SECRET = smsProviderConfig.getAliSecret();
        END_POINT = smsProviderConfig.getAliEndPoint();
    }

    /**
     * 调用阿里云客户端-确保安全单例模式
     */
    private static final class SingletonClientHolder {
        static Client SINGLETON_CLIENT = null;

        static {
            try {
                SINGLETON_CLIENT = new Client(new Config().setAccessKeyId(ACCESS_KEY).setAccessKeySecret(SECRET).setEndpoint(END_POINT));
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static Client getInstance() {
        return SingletonClientHolder.SINGLETON_CLIENT;
    }

腾讯云

	private static String ACCESS_KEY;
    private static String SECRET;
    private static String END_POINT;
    private static String REGION;

	@PostConstruct
    public void init() {
        ACCESS_KEY = smsProviderConfig.getTencentAccessKey();
        SECRET = smsProviderConfig.getTencentSecret();
        END_POINT = smsProviderConfig.getTencentEndPoint();
        REGION = smsProviderConfig.getTencentRegion();
    }

    /**
     * 调用腾讯云客户端-确保安全单例模式
     */
    private static final class SingletonClientHolder {
        static SmsClient SINGLETON_CLIENT = null;
        static {
            try {
                Credential cred = new Credential(ACCESS_KEY, SECRET);
                HttpProfile httpProfile = new HttpProfile();
                httpProfile.setEndpoint(END_POINT);
                // 实例化要请求产品的 client 对象,clientProfile 是可选的
                SINGLETON_CLIENT= new SmsClient(cred, REGION);
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    }

    public static SmsClient getInstance() {
        return SingletonClientHolder.SINGLETON_CLIENT;
    }

总结

基于阿里云 OpenApi 2.x、腾讯云 OpenApi 3.0 接口实现,对短信网关服务支持可扩展、解耦合,在实际业务场景中,通过几大原则以及几种设计模式对短信网关服务进行了重构

初衷为了回顾一下六大设计原则以及设计模式,其实重构的设计也从中运用了几大原则,如下:

  • 开闭原则:对扩展开放,对修改关闭,但不意味着不做任何的修改;对其他的短信网关可支持动态扩展
  • 单一职责原则:一个接口或类只有一个原因引起变化,也就是一个接口或类只有一个职责,它就负责一件事情;比如,阿里云短信服务实现类、腾讯云短信服务类
  • 里氏替换原则:所有引用基类的地方必须能透明地使用其子类的对象。通俗点讲,只要父类能出现的地方子类就可以出现,而且替换为子类也不会产生错误和异常;比如,抽象实体,在短信服务实现类中能够隐式转换为子类
  • 迪米特法则(最少知识法则):一个对象应该对其他对象有最少的了解。通俗地讲,一个类应该对自己需要耦合或调用的类知道得最少,你(被耦合或调用的类)内部时如何复杂都和我没关系,那是你的事情,我就知道你提供的这么多 public 方法,我就调用这么多,其他的我一概不关心;比如,将能力抽取出来放在抽象类中,调用方只支持对这些能力进行调用

关于源码:GitHub 短信服务网关开源项目,博主以开源的方式放在 GitHub 中,希望能得到你的支持,对于该部分源码,博主有进行整体的单元测试,大家可以基于公司的业务进行接入,能够帮助到你是我最大的快乐!

博主在业余时间会进行 README.md 文档优化(方便快速引入)、代码结构优化以及支持其他云的扩展实现,大家有什么问题可以在底下评论或私信留言

如果觉得博文不错,关注我 vnjohn,后续会有更多实战、源码、架构干货分享!
大家的「关注❤️ + 点赞👍 + 收藏⭐」就是我创作的最大动力!谢谢大家的支持,我们下文见!

短信网关接口源代码:口标准:客户端通过Tcp连接到服务器(211.162.36.89:8021, IP可能会变动,变动时,以www.pohoo.com网站公布的为准)。连接成功后客户端应首先发送注册串为:Login Name=【注册名】&Pwd=【注册密码】&Type=【注册类型,0:接收和发送;1:接收;2:发送;默认为0】(回车换行) 注:强烈建议使用Type=0的模式。如果所有服务注册成功,服务器返回给客户端字符串:Pass(回车换行)否则将断开连接。指令集: 1:分发中心向服务方发送用户的短信请求,格式如下: 分发中心==>Deliver CommandId=【命令标识,4字节整型,循环使用】 &GateName=【源网关名】 &ItemId=【节目标识】 &UserNumber=【用户号码】 &SpNumber=【服务号码,必须以9160开头】 &MsgCode=【短信编码,0:ASCII串;3:短信写卡操作;4:二进制信息;8:UCS2编码;15:含GB汉字; 24:UCS2编码闪电短信(Msg<=69个汉字),124:GBK编码闪电短信(Msg<=69个汉字)】 &Msg:=【短信内容,经加码处理,需解码】 (回车换行) 回应:Received CommandId=【对应于发送时的命令标识】(回车换行) 2:服务方向分发中心提交发送内容,格式如下: 服务方==>Submit (空格) CommandId=【命令标识,4字节整型,循环使用】 &GateName=【目的网关名】,默认由分发中心根据手机号码决定目的网关名】 &ItemId=【节目标识】 &SpNumber=【服务号码,以9160开头】 &UserNumber=【目的用户号码,如果是群发将个号码之间用“,”隔开,注意最多只能有255个群发号码】 &FeeNumber=【计费号码,短信产生的费用由该号码承担,不填时默认向目的用户号码收费】 &FeeType=【计费类型,1:免费,需申请,2:按条计费,3:定制包月计费(同时要求ReportFlag=2)。默认:2】 &ScheduleTime=【定时发送时间,默认立即发送,格式举例:2002年09月10日20:08:00为:020910200800】 &ExpireTime=【短信寿命中止时间,格式举例:021201090508,默认为移动或联通(24小时后)中止时间】 &MtFlag=【*引起MT消息的原因,仅当向联通用户发短信时需要该参数,0-MO点播引起的第一条MT消息;1-MO点播引起的非第一条MT消息; 2-非MO点播引起的MT消息;3-系统反馈引起的MT消息。默认为0】 &ReportFlag=【状态报告标志,0:不需要 状态报告;1:无论成功与否都返回状态报告;2:该条消息仅携带包月计费信息,不下发给用户; 3:只有最后出错时要返回状态报告,默认:0】 注:在每次包月定制计费时都需发送一条内容为空串,ReportFlag=2,FeeType=3的记录,该短信不会下发给用户,仅用于告知网关向 该用户收取包月费用,在用户没有取消定制的情况下每月必须且只能发送一次。 &MsgCode=【短信编码,0:ASCII串;3:短信写卡操作;4:二进制信息;8:UCS2编码;15:含GB汉字; 24:UCS2编码闪电短信(Msg<=69个汉字),124:GBK编码闪电短信(Msg<=69个汉字)】 &MsgId=【用户自定义消息标识,推荐格式:年月日时分秒+6位自递增码,例如:9月23日10:00:03发出的序号为1记录可定义为 923100003000001。自定义格式最大不超过20个字符且不能有需加码解码的特殊字符】 &ExtData:=【短信扩展数据,服务短信发送的附加信息,在有报告反馈时会连带该扩展数据反馈给服务方,需加码处理,但加码后不能超过 120个字节长度。默认为空串】 &TP_pId=【GSM协议类型。详细解释请参考GSM0 3.40中的9.2.3.9】 &TP_udhi=【GSM协议类型。详细解释请参考GSM03.40中的9.2.3.23,仅使用1位,右对齐】 &Msg:=【短信内容,需加码处理】 (回车换行) 回应:Received CommandId=【对应于发送时的命令标识】(回车换行) 3:分发中心向服务方发送报告,格式如下: 分发中心==>Report CommandId=【命令标识,4字节整型,循环使用】 &GateName=【源网关名】 &MsgId=【服务方在Submit时写在MsgId参数中的值】 &ExtData=【服务方在Submit时写在ExtData参数中的值】 &State=【发送状态,0:向网关提交成功,1:向网关提交失败,2:发送成功,3:等待发送,4:发送失败,5:Submit参数错误】 (回车换行) 回应:Received CommandId=【对应于发送时的命令标识】(回车换行) 4:分发中心为了测试服务方是否连接,会在等待1分钟未收到任何数据发送测试指令,该指令也可由服务方主动发起: 分发中心或服务方==>ActiveTest CommandId=【命令标识,4字节整型,循环使用】(回车换行) 回应:Received CommandId=【对应于发送时的命令标识】(回车换行) 5:无论分发中心还是服务方,只要3分钟之内未收到任何数据要主动断开连接,对于服务方在断开后重新连接。加码解码规则: 加码时将字符串中的所有字符转换成其对应的ASCII值的16进制值,例如:“A”的ASCII码值为65,以16进制值表示为41,故应发送两个字符 “41”以代表字符“A”。对于汉字则以其内码的16进制值来表示,如“测试”应为:B2E2CAD4。参数中只要参数标识与内容之间用 “:=”连接的都需要解码后方可使用,解码时将没两位当成其ASCII值的16进制值将其还原。 注: 1、命令和回应并非一个命令完了后紧接者就回应,服务方可一次发出许多条指令,可能在若干条后才陆续收到回应,根据“Received”的 “CommandId”可知道是对于哪一条发出指令的回应。 2、指令和参数标识不区分大小写,但各参数内容区分大小写。 3、不需要的参数可不参与发送,此时系统认为该参数值为系统默认值。同时所有参数的位置并不固定,请不要按照位置获取特定参数值。 4、信息发送方对于参数如果进行过加码处理的其参数标识和参数之间用“:=”连接,否则用“=”连接。同样对于接收方,只要发现参数标识和 参数之间用“:=”连接,接收方必须对参数内容进行解码方可使用。 5、当注册类型为发送,回应内容也是从该通道反馈,但报告的反馈是从同注册名的接收注册通道反馈的。 6、新网关测试需向鸿讯要求提供测试的注册名和密码。 6: 错误代码: 1、 100:用户名或密码不正确,登录失败 2、 110:记费号码与注册手机不符。 3、 111: 实际IP与登录IP不符
评论 8
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

vnjohn

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值