前言
最近想學習一下閘道器相關的知識,搜了一下,看到有個悟空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的入口,也必然要求高可用。
總結
總的來說,目前開源的部分估計僅僅是單機的程式碼,並沒有我想要的東西。需要看其它開源的閘道器程式碼,繼續學習。