拆輪子:閘道器GOKU-API-Gateway

樑健發表於2018-07-15

前言

最近想學習一下閘道器相關的知識,搜了一下,看到有個悟空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的入口,也必然要求高可用。

總結

總的來說,目前開源的部分估計僅僅是單機的程式碼,並沒有我想要的東西。需要看其它開源的閘道器程式碼,繼續學習。

相關文章