拆轮子系列:网关GoKu-API-Gateway

作者: Ljian1992 | 来源:发表于2018-07-15 21:21 被阅读13次

    前言

    最近想学习一下网关相关的知识,搜了一下,看到有个悟空API网关的项目。文档图文并茂,又是企业级别的,就决定第一个网关代码就是它了,项目地址:GOKU-API-Gateway

    问题

    看在源码之前,得先定一下目标,盲目地看代码容易迷失。在看了官方的文档和跟着文档搭起来试用了一下之后,定下了下面这些目标。

    • GOKU-API-Gateway监控信息如何收集?如何存储?
    • 如何做到高效的转发?
    • QPS限制,在分布式的情况下是怎么做的,尤其是秒级的限制?
    • 如何做到方便添加新的过滤功能?
    • 有没有什么可以学习的?
    • 有没有可以改进的地方?
    • 思考网关应该提供一些什么功能?
    • 思考网关所面临着的挑战有哪些?

    GOKU关键的结构体

    看代码之前,有必要理解一下GOKU-API-Gateway中数据的抽象是怎样的。这个打开管理后台,把用起需要设置的东西都设置一遍,这一块基本也就可以了。对应的结构体在这里:server/conf。

    关键的

    API: 定义了一个接口转发,里面主要包含了,请求的URL,转发的URL,方法,流量策略等等信息

    策略: 定义了流量限制的策略,主要有:鉴权方式,IP的黑白名单,流量控制等等信息

    一次请求处理的大体流程

    入口

    在工程的最外层有两个文件:goku-ce.go,goku-ce-admin.go。点进去瞄一眼,大体就知道goku-ce-admin.go是后台管理的接口,goku-ce.go是真正的网关服务。

    goku-ce.go

    看到有ListenAndServe估计就是web框架那一套东西,可以全局搜一下ServeHTTP。其中middleware.Mapping是每一个API的处理函数。

    func main() {
        server := goku.New()
        
        // 注册路由的处理函数     server.RegisterRouter(server.ServiceConfig,middleware.Mapping,middleware.GetVisitCount)
        fmt.Println("Listen",server.ServiceConfig.Port)
        
        // 启动服务
        err := goku.ListenAndServe(":" + server.ServiceConfig.Port,server)
        if err != nil {
            log.Println(err)
        }
        log.Println("Server on " + server.ServiceConfig.Port + " stopped")
        os.Exit(0)
    }
    

    ServeHTTP

    看到代码中的trees就想到了gin这个框架,点进去发现路由树这一块基本上和gin框架的差不多,但是节点中的内容有点不一样。不再是一个接口对应一组处理函数,而是只有一个。多了个Context的指针,Context对象里面主要是保存了API的中的转发地址,限流策略,统计信息等等,context对象是理解整个网关的处理最重要的对象,没有之一相当于接口信息的本地缓存,当找到路由的处理函数时,就找到了接口信息的本地缓存,减少了一次缓存查询,这个思路非常棒!!!

    
    func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
        // 省略N多代码
        
        // 看到这个trees就想到了之前看的gin框架,
        if root := r.trees[req.Method]; root != nil {
            
            // context是个关键点,
            handle, ps, context,tsr := root.getValue(path); 
            if handle != nil {
                handle(w, req, ps,context)
                return
            } else{
                // 省略N多代码
            }
        
        // 省略N多代码
    }
    
    // 
    type node struct {
        path      string
        wildChild bool
        nType     nodeType
        maxParams uint8
        indices   string
        children  []*node
        
        // 只有一个处理函数
        handle    Handle
        priority  uint32
        
        // API的中的转发地址,限流策略,统计信息都这context里面
        context   *Context
    }
    

    middleware.Mapping

    在goku-ce.go中就说了这个是接口的处理函数,整个流程很清晰,各种过滤是怎么做的顺着点进去就可以看到了。其实可以发现,整个代码对应处理高并发中的一些小细节做不是很好,具体的在有什么可以改进的地方会重点描述。

    func Mapping(res http.ResponseWriter, req *http.Request,param goku.Params,context *goku.Context) {
        // 更新实时访问次数
        go context.VisitCount.CurrentCount.UpdateDayCount()
    
        // 验证IP是否合法
        f,s := IPLimit(context,res,req) 
        if !f {
            res.WriteHeader(403)
            res.Write([]byte(s))
            
            // 统计信息的收集
            go context.VisitCount.FailureCount.UpdateDayCount()
            go context.VisitCount.TotalCount.UpdateDayCount()
            return
        }
    
        // 权限验证
        f,s = Auth(context,res,req)
        if !f {
            res.WriteHeader(403)
            res.Write([]byte(s))
            go context.VisitCount.FailureCount.UpdateDayCount()
            go context.VisitCount.TotalCount.UpdateDayCount()
            return
        }
    
        // 速率限制
        f,s = RateLimit(context)
        if !f {
            res.WriteHeader(403)
            res.Write([]byte(s))
            go context.VisitCount.FailureCount.UpdateDayCount()
            go context.VisitCount.TotalCount.UpdateDayCount()
            return
        }
    
        //接口转发
        statusCode,body,headers := CreateRequest(context,req,res,param)
        for key,values := range headers {
            for _,value := range values {
                res.Header().Set(key,value)
            }
        }
        res.WriteHeader(statusCode)
        res.Write(body)
        if statusCode != 200 {
            go context.VisitCount.FailureCount.UpdateDayCount()
            go context.VisitCount.TotalCount.UpdateDayCount()
        } else {
            go context.VisitCount.SuccessCount.UpdateDayCount()
            go context.VisitCount.TotalCount.UpdateDayCount()
        }
        return
    }
    

    问题的答案

    GOKU-API-Gateway监控信息如何收集?如何存储?

    监控信息请求过程中进行手机,直接存储在接口对应的Context里面。问题来了,当网关部署多个节点时,怎么将各个节点的监控信息收集起来?带着问题,去找代码,发现没有这一块的代码。估计这个开源的版本的阉割版吧,只能单节点部署。

    QPS限制,在分布式的情况下是怎么做的,尤其是秒级的限制?

    代码当中木有考虑到这一块

    如何做到方便添加新的过滤功能?

    有新的过滤功能需要,在middleware.Mapping函数里面添加。我觉得这里可以借鉴gin框架那一套,一个URI对应多个处理函数,每个处理函数就是一个过滤功能。这样的话,甚至可以实现热拔插功能,只要每个进程提供对应的接口修改,URI的处理函数列表。

    有没有什么可以学习的?

    接口信息放在路由树中

    这个在上面已经说了,就不再做说明,很棒的思路。

    有没有可以改进的地方?

    在超高并发的场合,对代码要求会很高,没有必要的开销能省就省,考虑到一般用上了网关这东西,并发量肯定比较高的了,所以才有了下面的那些改进点。

    时间如果不需要绝对的精确,没有必要每次都调用time.now()获取

    代码里面有很多关于时间判断,其实都不要求绝对的精准,可以直接从缓存里面获取时间。因为每次调用time.now()都会进行系统调用,开销虽然很小。缓存也很简单,弄个定时器每秒更新一次就好。代码中的可以改进的例子。

    func (l *LimitRate) UpdateDayCount() {
        // TODO 改进
        l.lock.Lock()
        now := time.Now()
    
    
        // 这里损失1以内秒的统计不会造成太大的影响,当前时间也应该从缓存里面拿,避免系统调用
        if now.Day() != l.begin.Day(){
            l.begin = now
            l.count = 0
        }
        l.count++ 
        l.lock.Unlock()
    }
    

    能缓存的就缓存起来,不需要每次都计算

    func (l *LimitRate) UpdateDayCount() {
        // TODO 改进
        l.lock.Lock()
        now := time.Now()
    
        // 应为begin的时间是不变的日期应该在初始化的时候就计算好,这样就不用每次都调用l.begin.Day()
        if now.Day() != l.begin.Day(){
            l.begin = now
            l.count = 0
        }
        l.count++ 
        l.lock.Unlock()
    }
    

    高并发场景尽量不要打LOG,而且LOG也要有缓冲区的,缓冲区满了再打印

    这里的尽量不要打log,并不是说不要不打log。 因为把log打印到磁盘是涉及到IO的,对性能是有所影响的。如果可以忍受一定的丢失,log应该设置一定的缓冲区,等缓冲区满了才打印到磁盘。

    func (l *LimitRate) DayLimit() bool {
        result := true
        l.lock.Lock()
        now := time.Now()
    
        // 清除,重新计数
        if now.Day() != l.begin.Day(){
            l.begin = now
            l.count = 0
        }
    
        if l.rate != 0 {
            t := now.Hour()
            bh := l.begin.Hour()
    
            // TODO 改进 求加括号,用意很不明确
            if bh <= t && t < l.end || (bh > l.end && (t < bh && t < l.end)){
    
                // TODO 改进 万一有错超过了rate那就GG了,应用用>=
                if l.count == l.rate {
                    result = false
                } else {
                    l.count++
                }
            } 
        }
    
        // TODO 改进 这种高并发场景不要打印
        fmt.Println("Day count:")
        fmt.Println(l.count)
        
        l.lock.Unlock()
        return result
    }
    

    开启goruntime是有成本的,简单的操作不应该开新的goruntime

    goruntimes的声誉非常非常之好,既轻量,又廉价,开成千上万不成问题,但是这并不意味着没有开销。goruntime也是要有结构体来保存,也是要参与调度,也是要排队的等等。在代码当中,统计信息的收集都是开启一个goruntime,里面仅仅是加个锁,将计数器++,这个完全是没有必要的。这里可以通过channle的方式,弄常驻的goruntime专门来处理统计信息。

    func Mapping(res http.ResponseWriter, req *http.Request,param goku.Params,context *goku.Context) {
        // 更新实时访问次数
        go context.VisitCount.CurrentCount.UpdateDayCount()
    
        // 验证IP是否合法
        f,s := IPLimit(context,res,req) 
        if !f {
            res.WriteHeader(403)
            res.Write([]byte(s))
            go context.VisitCount.FailureCount.UpdateDayCount()
            go context.VisitCount.TotalCount.UpdateDayCount()
            return
        }
    }
    

    思考网关应该提供一些什么功能?

    这个需要再看看其它的网关代码,才能总结出来。

    思考网关所面临着的挑战有哪些?

    网关作为所有API的入口,几乎可以说必然会有高并发的挑战。由于是所有API的入口,也必然要求高可用。

    总结

    总的来说,目前开源的部分估计仅仅是单机的代码,并没有我想要的东西。需要看其它开源的网关代码,继续学习。

    相关文章

      网友评论

        本文标题:拆轮子系列:网关GoKu-API-Gateway

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