likes
comments
collection
share

短信服务(一):实现腾讯云SDK并利用装饰器模式为短信服务添加限流和重试功能

作者站长头像
站长
· 阅读数 47

一、需求

我们作为用户端几乎每天都在大大小小各个平台上实现着 “手机验证码登录账号” 这个功能,但实际上,这个功能涉及了 3 个功能模块:短信模块验证码模块用户登录模块,模块间的关系如下图。 短信服务(一):实现腾讯云SDK并利用装饰器模式为短信服务添加限流和重试功能 所以,我们需要设计一个独立的短信发送服务,在独立的短信发送服务的基础上,封装一个验证码功能,在验证码功能的基础上,封装一个登录功能。这就是业务上的超前半步设计,也叫做叠床架屋

二、短信供应商选择:腾讯云

腾讯云短信服务api:cloud.tencent.com/document/sd…

腾讯短信API的特点:首先是初始化短信客户端,里面要求传入各种鉴权参数。其次是发送一条短信的请求构造,里面传入的主要是短信本身相关的参数。关键的是:

  1. 目标手机号码
  2. appId:你在腾讯短信上创建的应用的 ID
  3. 签名:要求发送的短信必须标记是谁发的
  4. 模板:要求你提前在服务商里面配置好,得到模板 ID
  5. 参数:发送短信的时候的具体参数

(注意:个人无法获得 secretIDsecretKey (更无法获得 appId),需要利用公司的短信配置。)

三、短信服务的实现

目录:

短信服务(一):实现腾讯云SDK并利用装饰器模式为短信服务添加限流和重试功能

(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)

短信服务(一):实现腾讯云SDK并利用装饰器模式为短信服务添加限流和重试功能

4.2 思路

  1. 尽量不要搞崩第三方==>为短信服务添加限流【下文将提供实现】
  2. 万一第三方崩了,你的系统还要能够稳定运行==>自动切换服务商【下次再更新】

在腾讯的短信 API 里面,它系统本身的限流阈值是 3000/s 。对于你的系统来说,如果你一秒内发送了 3000 个请求,都能处理。 但是如果你超过了 3000 次,就会被拒绝。 因此,与其等着腾讯那边返回限流响应,还不如你直接本地就限制住短信服务(一):实现腾讯云SDK并利用装饰器模式为短信服务添加限流和重试功能

4.3 实现

(1)目录结构

短信服务(一):实现腾讯云SDK并利用装饰器模式为短信服务添加限流和重试功能

(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!)

下面的实现,从功能性上说没有问题,但从扩展性和无侵入式上来说,问题很大!

短信服务(一):实现腾讯云SDK并利用装饰器模式为短信服务添加限流和重试功能

无数的屎山就是这么一点点“侵入式”地改出来的。如果你准备让自己职业生涯过得轻松,就一定要克制自己,三思而后行

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. 装饰器是对“开闭原则”和“非侵入式”的编程思想的体现

开闭原则:对修改闭合,对扩展开放。 非侵入式:不修改已有代码。

记住一句话:侵入式修改是万恶之源。它会降低代码可读性,降低可测试性,强耦合,降低可扩展性。

侵入式 = 垃圾代码,这个等式基本成立。

除非逼不得已,不然绝对不要搞侵入式修改!!

  1. 装饰器图示: 短信服务(一):实现腾讯云SDK并利用装饰器模式为短信服务添加限流和重试功能
转载自:https://juejin.cn/post/7387394977128955904
评论
请登录