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

前言

最近想学习一下网关相关的知识,搜了一下,看到有个悟空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的入口,也必然要求高可用。

总结

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

推荐阅读更多精彩内容