• 打造千万级流量秒杀第二十五课 过滤器:如何实现用户认证和反黄牛过滤无效请求?


    你好,欢迎来到模块九。从这一讲开始,我将给你介绍如何实现高并发中的流量漏斗模型。

    在漏斗模型中,最重要的就是用户认证和反黄牛。为什么呢?

    对于秒杀系统来说,只要服务还处于可用状态,它就需要尽可能接收并处理合法用户请求,拒绝非法用户请求。而用户认证和反黄牛的最大作用,就是帮秒杀系统识别出哪些请求是合法请求,哪些是非法请求。

    用户认证和反黄牛是两个不同的功能,唯一有关系的地方是用户认证负责判断用户是否登录并提取用户 ID,而反黄牛过滤器拿到用户 ID 后会判断该用户是不是黄牛。因此,通常将它们实现为两个不同的中间件。

    什么是中间件呢?中间件是负责执行特定功能的组件,它通常在两个时间点执行:系统接收到请求与请求被真正执行之间,以及请求执行完与将结果返回给调用方之间。 也就是说,它不参与具体的业务逻辑,但它会对每个请求都做特定的处理。

    接下来,我为你详细介绍下中间件的原理,以及用户认证过滤器和反黄牛过滤器是如何实现为中间件的。

    中间件原理

    秒杀接口服务是 Web 服务,在 Go Web 编程中,中间件是什么样子的呢?

    Go Web 程序中的中间件通常是一种对入参和返回值有特定要求的函数或者接口类,比如最常见的是 Go 标准库 net/http 的 http.HandlerFunc 和 http.Handler 这两种类型。它们在 net/http/server.go 中的具体定义如下:

    type Handler interface {
       ServeHTTP(ResponseWriter, *Request)
    }
    type HandlerFunc func(ResponseWriter, *Request)
    func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
       f(w, r)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    需要注意的是,Handler 是接口类型,它定义了一个 ServeHTTP 方法,用于处理 HTTP 请求。而 HandlerFunc 则是函数类型,同样用于处理 HTTP 请求,同时还实现了 ServeHTTP 方法。因此,HandlerFunc 类型的变量可以赋值给 Handler 类型的变量。

    当有多个中间件的时候,我们该如何使用它们呢?通常有两种方法,一种是使用函数栈嵌套调用,另一种是使用数组来管理。

    函数栈方式

    Go 语言中,函数也是一种类型,可以被当作值赋值给一个函数类型的变量,也就是说,一个函数也可以作为另一个函数的参数或者返回值。基于这点,我们就可以实现中间件的嵌套调用,一层一层地执行中间件。

    比如,我们可以基于 http.HandlerFunc 定义一个 Middleware 类型,并实现一个 Add 方法,该方法支持传入一个 http.HandlerFunc 类型并返回 Middleware 类型。具体代码如下:

    type Middleware http.HandlerFunc
    func (m Middleware) Add(f http.HandlerFunc) Middleware {
       return func(w http.ResponseWriter, r *http.Request) {
          f(w, r)
          m(w, r)
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    接下来,我们就可以实现 handlerA、handlerB、handlerC 这三个中间件函数,并分别输出 A、B、C 这三个字母。在 TestMiddleware 函数,我们可以通过 Middleware(handlerA).Add(handlerB).Add(handlerC) 这种链式调用的方式,将这三个中间件组合起来。代码如下:

    func handlerA(w http.ResponseWriter, r *http.Request) {
       fmt.Println("A")
    }
    func handlerB(w http.ResponseWriter, r *http.Request) {
       fmt.Println("B")
    }
    func handlerC(w http.ResponseWriter, r *http.Request) {
       fmt.Println("C")
    }
    func TestMiddleware(t *testing.T) {
       m := Middleware(handlerA).Add(handlerB).Add(handlerC)
       var w http.ResponseWriter
       var r *http.Request
       m(w, r)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15

    当我们将最终的返回值 m 当作函数来执行时,你将会看到终端上依次输出 C、B、A 这三个字母,顺序刚好跟它们在链式调用中的顺序相反,符合栈的“先入后出”特点。当然,你也可以修改 Add 方法,将调用 f 和 m 的顺序调整下,改成先入先出的顺序。

    数组方式

    要想使用数组来管理多个中间件,我们可以采用以下步骤:

    第一步,我们可以先定义一个结构体类型(如 MiddlewareGroup)来保存数组,然后实现 Add 方法添加中间件到数组中,返回的结构体也是它自身,以便支持链式调用 Add 方法添加其他中间件。

    第二步,实现 ServeHTTP 方法,以便能转换成 http.Handler,在该方法中遍历数组中的中间件,并执行它们。

    如下所示:

    type MiddlewareGroup struct {
       group []Middleware
    }
    func NewMiddlewareGroup() *MiddlewareGroup {
       return &MiddlewareGroup{
          group: make([]Middleware, 0),
       }
    }
    func (mg *MiddlewareGroup) Add(m ...Middleware) *MiddlewareGroup {
       mg.group = append(mg.group, m...)
       return mg
    }
    func (mg *MiddlewareGroup) ServeHTTP(w http.ResponseWriter, r *http.Request) {
       for _, m := range mg.group {
          m(w, r)
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17

    第三步,我们就可以在 TestMiddlewareGroup 函数中调用 Add 函数,将前面实现的三个中间件函数加到 MiddlewareGroup 中,并调用它的 ServeHTTP 方法执行中间件。代码如下:

    func TestMiddlewareGroup(t *testing.T) {
       mg := NewMiddlewareGroup()
       mg.Add(handlerA, handlerB, handlerC)
       var w http.ResponseWriter
       var r *http.Request
       mg.ServeHTTP(w, r)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    第四步,执行该测试函数后,你将看到终端上输出 A、B、C。

    秒杀系统用的 gin 框架也支持中间件,它采用哪种方式管理中间件呢?它用的就是数组的方式。在 gin 框架的 routergroup.go 文件中,定义 RouterGroup 结构体,其中有个 Handlers 字段,它的类型就是 HandlerFunc 数组。而 RouterGroup 结构体有个 Use 方法,它可以将中间件添加到该数组中。代码如下:

    // RouterGroup is used internally to configure router, a RouterGroup is associated with
    // a prefix and an array of handlers (middleware).
    type RouterGroup struct {
       Handlers HandlersChain
       basePath string
       engine   *Engine
       root     bool
    }
    var _ IRouter = &RouterGroup{}
    // Use adds middleware to the group, see example code in GitHub.
    func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
       group.Handlers = append(group.Handlers, middleware...)
       return group.returnObj()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14

    因此,我们在秒杀系统中,可以按照 gin 框架中 HandlerFunc 定义实现中间件,并调用 gin 框架的 Use 方法将中间件注入框架中。

    用户认证过滤器

    前面提到,用户认证过滤器的主要作用是:**在一些需要登录的接口中(如抢购接口),拦截掉未登录或者登录状态异常的用户请求。**也就是说,我们需要将用户认证过滤器作为中间件,注入那些需要登录才能访问的接口路由中。

    不过,需要注意的是,除了登录后才能访问的接口外,还有一些接口(如活动列表接口、商品活动信息接口)不需要登录也能访问。但如果用户登录了,就要提取用户登录状态,以便前端能根据用户状态展示不同的页面效果。因此,我们的用户认证过滤器需要有个参数,用来设定中间件在判断用户未登录时,是否要返回 401 状态码或者重定向到登录页。

    由于用户认证过程需要提取用户信息,为此,我们需要实现相应函数来做这个事情。具体来说,用户信息属于用户领域,我们在 domain/user/auth.go 中就定义一个 Info 结构体。这个结构体中包含用户 ID、登录时间、过期时间这三个字段。然后开始实现一个 Auth 函数,传入一个 token 字符串并返回用户信息。

    为了方便测试,我还实现了一个 Login 函数,与 Auth 函数配对使用。Login 函数中主要是根据用户 ID 和密码进行验证,并生成一个后续用于认证的字符串。

    具体代码如下:

    type Info struct {
       UID        string `json:"uid"`
       LoginTime  int64  `json:"loginTime"`
       ExpireTime int64  `json:"expireTime"`
    }
    const (
       TokenPrefix = "Bearer "
       TokenHeader = "Authorization"
    )
    var authKey = []byte("seckill2021")
    func padding(src []byte, blkSize int) []byte {
       l := len(src)
       for i := 0; i < blkSize-l%blkSize; i++ {
          src = append(src, byte(0))
       }
       return src
    }
    func Auth(token string) *Info {
       defer func() {
          if err := recover(); err != nil {
             logrus.Error(err)
          }
       }()
       cipher, err := aes.NewCipher(authKey)
       if err != nil {
          logrus.Error(err)
          return nil
       }
       src, err1 := base64.StdEncoding.DecodeString(token)
       if err1 != nil || len(src) == 0 {
          return nil
       }
       src = padding(src, cipher.BlockSize())
       output := make([]byte, len(src))
       cipher.Decrypt(output, src)
       var info *Info
       err = json.Unmarshal(output, &info)
       if err != nil || info.ExpireTime < time.Now().Unix() {
          return nil
       }
       return info
    }
    func Login(uid string, passwd string) (*Info, string) {
       defer func() {
          if err := recover(); err != nil {
             logrus.Error(err)
          }
       }()
       info := &Info{
          UID:        uid,
          LoginTime:  time.Now().Unix(),
          ExpireTime: time.Now().Unix() + 24*3600,
       }
       data, err := json.Marshal(info)
       if err != nil {
          return nil, ""
       }
       cipher, err1 := aes.NewCipher(authKey)
       if err1 != nil {
          logrus.Error(err1)
          return nil, ""
       }
       data = padding(data, cipher.BlockSize())
       dst := make([]byte, len(data))
       cipher.Encrypt(dst, data)
       return info, base64.StdEncoding.EncodeToString(dst)
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27
    • 28
    • 29
    • 30
    • 31
    • 32
    • 33
    • 34
    • 35
    • 36
    • 37
    • 38
    • 39
    • 40
    • 41
    • 42
    • 43
    • 44
    • 45
    • 46
    • 47
    • 48
    • 49
    • 50
    • 51
    • 52
    • 53
    • 54
    • 55
    • 56
    • 57
    • 58
    • 59
    • 60
    • 61
    • 62
    • 63
    • 64
    • 65
    • 66
    • 67

    需要注意的是,可能每家公司的登录认证算法不一样,我们这里仅仅是为了模拟用户登录认证,方便后续的性能测试。

    接下来,我们就可以实现用户认证过滤器了,也就是用户认证的 gin 框架中间件。具体来说,先实现一个函数,我将该函数命名为 NewAuthMiddleware,并放到 interfaces/api/middlewares 目录下的 auth.go 文件中。该函数传入一个 redirect 参数,用于控制在中间件中认证失败后是否返回 401,而它的返回值就是用户认证中间件函数。

    具体代码如下:

    func NewAuthMiddleware(redirect bool) gin.HandlerFunc {
       return func(ctx *gin.Context) {
          var info *user.Info
          token := ctx.Request.Header.Get(user.TokenHeader)
          if token != "" && strings.Contains(token, user.TokenPrefix) {
             token = strings.Trim(token, user.TokenPrefix)
             token = strings.TrimSpace(token)
             info = user.Auth(token)
          }
          if info != nil {
             ctx.Set("UserInfo", info)
          } else if redirect {
             utils.Abort(ctx, http.StatusUnauthorized, "need login")
             return
          }
          ctx.Next()
       }
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18

    你可以看到,登录认证中间件会尝试从请求的 Header 中获取 Token,并调用 user.Auth 从 Token 中提取用户信息。如果提取成功,就设置到 ctx 中;如果失败,就直接返回 http.StatusUnauthorized 也就是 401 状态码,并提示需要登录。如果不需要返回失败,就在中间件最后调用 ctx.Next 来执行下一个中间件。

    反黄牛过滤器

    在实现反黄牛过滤器前,我们需要将黄牛名单加载到内存缓存中。之前我们已经实现了监控文件变更的逻辑,接下来我们可以实现黄牛内存缓存,以及判断用户 ID 是否在内存缓存中,以此来判断该用户是不是黄牛。

    我在 infrastructure/utils/blacklist.go 中定义了一个 blacklist 结构体,它包含一个读写锁和一个类型为 map 的 data 字段,用于管理黄牛名单。然后,我在 init 函数中初始化黄牛名单的 data 字段。代码如下:

    var blacklist struct {
       sync.RWMutex
       data map[string]struct{}
    }
    func init() {
       blacklist.data = make(map[string]struct{})
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7

    接下来,为了及时更新黑名单,我完善了 updateBlacklist 函数,在该函数中读取文件并更新到内存缓存中;同时,我还实现了一个 InBlacklist 函数,传入一个用户 ID,返回 true 或者 false,表示该用户是不是黄牛。具体代码如下:

    func updateBlacklist() {
       filePath := viper.GetString("blacklist.filePath")
       fp, err := os.Open(filePath)
       if err != nil {
          logrus.Error(err)
          return
       }
       defer fp.Close()
       data := make(map[string]struct{})
       f := bufio.NewReader(fp)
       for {
          line, _, err := f.ReadLine()
          if err != nil {
             break
          }
          data[string(line)] = struct{}{}
       }
       blacklist.Lock()
       blacklist.data = data
       blacklist.Unlock()
    }
    func InBlacklist(uid string) bool {
       blacklist.RLock()
       _, ok := blacklist.data[uid]
       blacklist.RUnlock()
       return ok
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13
    • 14
    • 15
    • 16
    • 17
    • 18
    • 19
    • 20
    • 21
    • 22
    • 23
    • 24
    • 25
    • 26
    • 27

    有了 InBlacklist 这个函数后,我们就可以实现反黄牛中间件 Blacklist 了。具体做法是在 Blacklist 中间件里获取 Auth 中间件中设置的 UserInfo,然后通过其中的用户 ID 调用 InBlacklist 函数,以此来判断该用户是否为黄牛。如果取不到 UserInfo,则返回“需要登录”的提示;如果是黄牛,则返回“请求已被禁止”的错误码;如果不是黄牛,则表示合法请求,就调用 ctx.Next 执行下一个中间件。具体代码如下:

    func Blacklist(ctx *gin.Context) {
       data, _ := ctx.Get("UserInfo")
       info, ok := data.(*user.Info)
       if !ok {
          utils.Abort(ctx, http.StatusUnauthorized, "need login")
          return
       }
       if utils.InBlacklist(info.UID) {
          utils.Abort(ctx, http.StatusForbidden, "blocked")
          return
       }
       ctx.Next()
    }
    
    • 1
    • 2
    • 3
    • 4
    • 5
    • 6
    • 7
    • 8
    • 9
    • 10
    • 11
    • 12
    • 13

    实现完中间件后,为了让它们能被执行,我们需要将它们注入框架里了。具体做法是在 interfaces/api/routers.go 的 initRouters 函数中,使用路由组的 Use 方法按照接口需要,注入相应中间件。比如,将 event 路由组加上不需要返回错误码的 Auth 中间件,将 subscribe 路由组加上需要返回错误码的 Auth 中间件,给 shop 路由组同时加上 Auth 和 Blacklist 中间件。核心代码如下:

       event := g.Group("/event").Use(middlewares.NewAuthMiddleware(false))
    
    • 1

    subscribe := g.Group(“/event/subscribe”).Use(middlewares.NewAuthMiddleware(true))
    shop := g.Group(“/shop”).Use(middlewares.NewAuthMiddleware(true), middlewares.Blacklist)

    图片1.png

    小结

    这一讲我主要给你介绍了中间件的基本原理,以及如何利用 Go 函数式编程风格,实现 gin 框架的用户认证和反黄牛中间件。希望你已掌握这项技术的诀窍,并熟练运用到工作中。

    接下来你也可以思考下:如果不用 gin 框架,而是用标准库的 net/http 框架,应该如何实现用户认证中间件呢?

    可以将答案写在留言区哦,我很期待你的回答。

    好了,这一讲就到这里了。下一讲我将给你介绍“如何实现熔断器和限流器防止宕机和雪崩”。到时见!

    源码地址:https://github.com/lagoueduCol/MiaoSha-Yiletian


    精选评论

  • 相关阅读:
    机械转码日记【26】二叉搜索树
    sort() 排序
    基于元模型优化算法的主从博弈多虚拟电厂动态定价和能量管理MATLAB程序
    设计模式----单例模式
    二叉搜索树的实现
    PyTorch模型导出到ONNX文件示例(LeNet-5)
    网络基础 【发展、协议、传输、地址】
    611. 有效三角形的个数
    Redis 三种特殊的数据类型 - Geospatial地理位置 - Hyperloglog基数统计的算法 - Bitmaps位图(位存储)
    学习 vite + vue3 + pinia + ts(四)setup异步返回 async setup
  • 原文地址:https://blog.csdn.net/fegus/article/details/126377973