短信服务(一):实现腾讯云SDK并利用装饰器模式为短信服务添加限流和重试功能
一、需求
我们作为用户端几乎每天都在大大小小各个平台上实现着 “手机验证码登录账号” 这个功能,但实际上,这个功能涉及了 3 个功能模块:短信模块、验证码模块和用户登录模块,模块间的关系如下图。
所以,我们需要设计一个独立的短信发送服务,在独立的短信发送服务的基础上,封装一个验证码功能,在验证码功能的基础上,封装一个登录功能。这就是业务上的超前半步设计,也叫做叠床架屋。
二、短信供应商选择:腾讯云
腾讯云短信服务api:cloud.tencent.com/document/sd…
腾讯短信API的特点:首先是初始化短信客户端,里面要求传入各种鉴权参数。其次是发送一条短信的请求构造,里面传入的主要是短信本身相关的参数。关键的是:
- 目标手机号码
- appId:你在腾讯短信上创建的应用的 ID
- 签名:要求发送的短信必须标记是谁发的
- 模板:要求你提前在服务商里面配置好,得到模板 ID
- 参数:发送短信的时候的具体参数
(注意:个人无法获得 secretID
和 secretKey
(更无法获得 appId
),需要利用公司的短信配置。)
三、短信服务的实现
目录:
(1)发送短信的抽象接口 types.go
package sms
import "context"
type Service interface {
// Send tplId:模板id
Send(ctx context.Context, tplId string, args []string, numbers ...string) error
}
(2)腾讯服务的具体实现 service.go
package tencent
import (
"context"
"fmt"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
tencentSMS "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111" // 引入sms
"go.uber.org/zap"
"refactor-webook/webook/internal/service/sms"
)
type Service struct {
client *tencentSMS.Client
// 腾讯云的短信SDK设计的就是string的指针
appId *string
signName *string
}
func NewService(client *tencentSMS.Client, appId, signName string) sms.Service {
return &Service{
client: client,
appId: &appId,
signName: &signName,
}
}
func (s *Service) Send(ctx context.Context, tplId string, args []string, numbers ...string) error {
request := tencentSMS.NewSendSmsRequest()
request.SmsSdkAppId = s.appId
request.SignName = s.signName
request.TemplateId = &tplId
request.TemplateParamSet = common.StringPtrs(args)
request.PhoneNumberSet = common.StringPtrs(numbers)
response, err := s.client.SendSms(request)
// note 研发环境经常用 debug 日志,线上环境不用
zap.L().Debug("请求腾讯的SendSMS接口",
zap.Any("request", request),
zap.Any("response", response))
if err != nil {
fmt.Printf("An API error has returned: %s", err)
return err
}
for _, statusPtr := range response.Response.SendStatusSet {
// 解引用
status := *statusPtr
if status.Code != nil && *status.Code != "Ok" {
return fmt.Errorf("短信发送失败 code:%s mag: %s", *status.Code, status.Message)
}
}
return nil
}
(3)ioc 包中初始化短信服务
package ioc
import (
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
tencentSMS "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
"os"
"refactor-webook/webook/internal/service/localsms"
"refactor-webook/webook/internal/service/sms"
"refactor-webook/webook/internal/service/sms/tencent"
)
func initTencentSMSService() sms.Service {
secretId, ok := os.LookupEnv("SMS_SECRET_ID")
if !ok {
panic("找不到腾讯 SMS 的 secret id")
}
secretKey, ok := os.LookupEnv("SMS_SECRET_KEY")
if !ok {
panic("找不到腾讯 SMS 的 secret key")
}
c, err := tencentSMS.NewClient(
common.NewCredential(secretId, secretKey),
"ap-nanjing",
profile.NewClientProfile(),
)
if err != nil {
panic(err)
}
return tencent.NewService(c, "1400842696", "傻瓜科技")
}
四、利用装饰器模式为短信服务添加限流(第三方服务治理)
4.1 目的
俗话说,一切不在你控制范围内的东西,都是不定时炸弹,冷不丁就炸了。
所以,针对一切跟第三方打交道的地方,都要做好容错!!!。
推而广之,只要接口不是你写的,你调用的时候都要考虑做好治理。
记住血泪教训:不要相信公司外的人,不要相信别的部门的人,不要相信同组的人,最后不要相信昨天的自己(doge)。
4.2 思路
- 尽量不要搞崩第三方==>为短信服务添加限流【下文将提供实现】
- 万一第三方崩了,你的系统还要能够稳定运行==>自动切换服务商【下次再更新】
在腾讯的短信 API 里面,它系统本身的限流阈值是 3000/s 。对于你的系统来说,如果你一秒内发送了 3000 个请求,都能处理。 但是如果你超过了 3000 次,就会被拒绝。 因此,与其等着腾讯那边返回限流响应,还不如你直接本地就限制住。
4.3 实现
(1)目录结构
(2)限流方法抽象 types.go
package limiter
import "context"
type Limiter interface {
// Limit key用来表示什么供应商的短信服务,如 'sms_tencent'
Limit(ctx context.Context, key string) (bool, error)
}
(3)Limiter
具体实现
package limiter
import (
"context"
_ "embed"
"github.com/redis/go-redis/v9"
"time"
)
//go:embed slide_window.lua
var luaScript string
// RedisSlidingWindowLimiter 基于 Redis 实现的滑动窗口限流结构体
type RedisSlidingWindowLimiter struct {
cmd redis.Cmdable
interval time.Duration
// 阈值
rate int
}
func NewRedisSlidingWindowLimiter(cmd redis.Cmdable, interval time.Duration, rate int) *RedisSlidingWindowLimiter {
return &RedisSlidingWindowLimiter{cmd: cmd, interval: interval, rate: rate}
}
func (r *RedisSlidingWindowLimiter) Limit(ctx context.Context, key string) (bool, error) {
return r.cmd.Eval(ctx, luaScript, []string{key},
r.interval.Milliseconds(), r.rate, time.Now().UnixMilli()).Bool()
}
(4)lua脚本
-- 1, 2, 3, 4, 5, 6, 7 这是你的元素
-- ZREMRANGEBYSCORE key1 0 6
-- 7 执行完之后
-- 限流对象
local key = KEYS[1]
-- 窗口大小
local window = tonumber(ARGV[1])
-- 阈值
local threshold = tonumber( ARGV[2])
local now = tonumber(ARGV[3])
-- 窗口的起始时间
local min = now - window
redis.call('ZREMRANGEBYSCORE', key, '-inf', min)
local cnt = redis.call('ZCOUNT', key, '-inf', '+inf')
-- local cnt = redis.call('ZCOUNT', key, min, '+inf')
if cnt >= threshold then
-- 执行限流
return "true"
else
-- 把 score 和 member 都设置成 now
redis.call('ZADD', key, now, now)
redis.call('PEXPIRE', key, window)
return "false"
end
4.4 可以在腾讯云的Service方法中直接调用 limiter.Limit()
吗?(No!)
下面的实现,从功能性上说没有问题,但从扩展性和无侵入式上来说,问题很大!
无数的屎山就是这么一点点“侵入式”地改出来的。如果你准备让自己职业生涯过得轻松,就一定要克制自己,三思而后行!
4.5 用装饰器模式将limiter.Limit()
用到腾讯云的Send()
中
装饰器模式思想:不改变原有实现而增加新特性的一种设计模式
package ratelimit
import (
"context"
"errors"
"refactor-webook/webook/internal/service/sms"
"refactor-webook/webook/pkg/limiter"
)
var ErrLimited = errors.New("触发短信服务限流")
type SmsServiceRateLimit struct {
// svc是被装饰者
svc sms.Service
l limiter.Limiter
key string
}
func (s *SmsServiceRateLimit) Send(ctx context.Context, tplId string, args []string, numbers ...string) error {
// note 先执行起装饰作用的方法
isLimit, err := s.l.Limit(ctx, s.key)
if err != nil {
return err
}
if isLimit {
return ErrLimited
}
// note 最终委托被修饰的 svc 调用Send方法
return s.svc.Send(ctx, tplId, args, numbers...)
}
func NewSmsServiceRateLimit(svc sms.Service, l limiter.Limiter) *SmsServiceRateLimit {
return &SmsServiceRateLimit{
svc: svc,
l: l,
key: "sms-tencent-limit",
}
}
在 ioc 包中启用该装饰器类
package ioc
import (
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common"
"github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/common/profile"
tencentSMS "github.com/tencentcloud/tencentcloud-sdk-go/tencentcloud/sms/v20210111"
"os"
"refactor-webook/webook/internal/service/localsms"
"refactor-webook/webook/internal/service/sms"
"refactor-webook/webook/internal/service/sms/tencent"
)
func InitSMSService() sms.Service {
// 装饰器类
return ratelimit.NewSmsServiceRateLimit(initTencentSMSService(), limiter.NewRedisSlidingWindowLimiter(
redis.NewClient(&redis.Options{
Addr: "localhost:6379",
}), time.Second, 100))
// return localsms.NewService()
}
func initTencentSMSService() sms.Service {
secretId, ok := os.LookupEnv("SMS_SECRET_ID")
if !ok {
panic("找不到腾讯 SMS 的 secret id")
}
secretKey, ok := os.LookupEnv("SMS_SECRET_KEY")
if !ok {
panic("找不到腾讯 SMS 的 secret key")
}
c, err := tencentSMS.NewClient(
common.NewCredential(secretId, secretKey),
"ap-nanjing",
profile.NewClientProfile(),
)
if err != nil {
panic(err)
}
return tencent.NewService(c, "1400842696", "傻瓜科技")
}
4.6 对装饰器进行 单元测试
package ratelimit
import (
"context"
"errors"
"github.com/stretchr/testify/assert"
"go.uber.org/mock/gomock"
"refactor-webook/webook/internal/service/sms"
smsmocks "refactor-webook/webook/internal/service/sms/mocks"
"refactor-webook/webook/pkg/limiter"
limitermocks "refactor-webook/webook/pkg/limiter/mocks"
"testing"
)
func TestSmsServiceRateLimit_Send(t *testing.T) {
testCases := []struct {
name string
mock func(ctrl *gomock.Controller) (sms.Service, limiter.Limiter)
// note 不用写任何输入
wantErr error
}{
{
name: "没有限流",
mock: func(ctrl *gomock.Controller) (sms.Service, limiter.Limiter) {
svc := smsmocks.NewMockService(ctrl)
l := limitermocks.NewMockLimiter(ctrl)
l.EXPECT().Limit(gomock.Any(), gomock.Any()).Return(false, nil)
svc.EXPECT().Send(gomock.Any(), gomock.Any(), gomock.Any(), gomock.Any()).Return(nil)
return svc, l
},
wantErr: nil,
},
{
name: "限流",
mock: func(ctrl *gomock.Controller) (sms.Service, limiter.Limiter) {
svc := smsmocks.NewMockService(ctrl)
l := limitermocks.NewMockLimiter(ctrl)
l.EXPECT().Limit(gomock.Any(), gomock.Any()).Return(true, nil)
return svc, l
},
wantErr: ErrLimited,
},
{
name: "限流器错误",
mock: func(ctrl *gomock.Controller) (sms.Service, limiter.Limiter) {
svc := smsmocks.NewMockService(ctrl)
l := limitermocks.NewMockLimiter(ctrl)
l.EXPECT().Limit(gomock.Any(), gomock.Any()).Return(false, errors.New("redis限流器错误"))
return svc, l
},
wantErr: errors.New("redis限流器错误"),
},
}
for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
ctrl := gomock.NewController(t)
defer ctrl.Finish()
smsSvc, l := tc.mock(ctrl)
svc := NewSmsServiceRateLimit(smsSvc, l)
// note 对测试无影响,所以可以随便填,写死在这里
err := svc.Send(context.Background(), "随便填", []string{"随便填"}, "随便填")
assert.Equal(t, err, tc.wantErr)
})
}
}
五、为短信服务提供自动重试功能
5.1 目的
提供自动重试功能。也就是调用方不需要操心重试,以及重试失败怎么处理的问题
5.2 代码实现
(1)装饰器实现:
package retry
import (
"context"
"errors"
"refactor-webook/webook/internal/service/sms"
"sync/atomic"
"time"
)
type RetrySmsService struct {
svc sms.Service
MaxAttempts int32
Delay time.Duration
}
func NewRetrySmsService(svc sms.Service, maxAttempts int32) *RetrySmsService {
return &RetrySmsService{svc: svc, MaxAttempts: maxAttempts, Delay: time.Second*3}
}
func (r *RetrySmsService) Send(ctx context.Context, tplId string, args []string, numbers ...string) error {
atomic.StoreInt32(&r.MaxAttempts, 0)
cnt := atomic.LoadInt32(&r.MaxAttempts)
for cnt <= r.MaxAttempts {
err := r.svc.Send(ctx, tplId, args, numbers...)
switch err {
case nil:
atomic.StoreInt32(&r.MaxAttempts, 0)
return nil
case context.Canceled, context.DeadlineExceeded:
return err
default:
// 其他错误,需要重试
atomic.AddInt32(&r.MaxAttempts, 1)
}
time.Sleep(r.Delay)
}
// 重试失败的处理,可以是更换为下一个服务商等措施
return errors.New("重试失败。。。")
}
六、总结
1. 装饰器是对“开闭原则”和“非侵入式”的编程思想的体现
开闭原则:对修改闭合,对扩展开放。 非侵入式:不修改已有代码。
记住一句话:侵入式修改是万恶之源。它会降低代码可读性,降低可测试性,强耦合,降低可扩展性。
侵入式 = 垃圾代码,这个等式基本成立。
除非逼不得已,不然绝对不要搞侵入式修改!!
- 装饰器图示:
转载自:https://juejin.cn/post/7387394977128955904