聊一聊Gin Web框架之前,看一眼httprouter

pixelpig發表於2020-04-05

HTTP Router

前言: Gin的詞源是金酒, 又稱琴酒, 是來自荷蘭的一種烈性酒。

在Go中,有一個經常提及的web框架,就是gin web,具備高效能,可靈活定製化的特點,既然它是如此被看好,在深入瞭解它之前,不妨先看下他是基於什麼實現的。

飲酒思源:httprouter

根據Git作者描述,Gin的高效能得益於一個叫httprouter的框架生成的,順著源頭看,我們先針對httprouter, 從HTTP路由開始,作為Gin框架的引入篇。

Router路由

在扎堆深入之前, 先梳理一下路由的概念:
路由: 大概意思是通過轉發資料包實現互聯,比如生活中常見的物理路由器,是指內網與外網之間資訊流的分配。
同理,軟體層面也有路由的概念,一般暴露在業務層之上,用於轉發請求到合適的邏輯處理器。

The router matches incoming requests by the request method and the path.

程式的應用上,常見的如對外伺服器把外部請求打到Nginx閘道器,再路由(轉發)到內部服務或者內部服務的“控制層”,如Java的springMVC,Go的原生router等對不同請求轉發到不同業務層。
或者再具體化點說,比如不同引數呼叫同名方法,如Java的過載,也可以理解為程式根據引數的不同路由到相應不同的方法。

httprouter

功能現象:

Git的README文件上,httprouter開門見山的展示了它的一個常見功能,
啟動一個HTTP伺服器,並且監聽8080埠,對請求執行引數解析,僅僅幾行程式碼,當我第一次見到這種實現時候,確實覺得go這種實現相當優雅。

router.GET("/", Index)
//傳入引數name
router.GET("/hello/:name", Hello)

func Hello(w http.ResponseWriter, r *http.Request) {
    //通過http.Request結構的上下文可以拿到請求url所帶的引數
    params := httprouter.ParamsFromContext(r.Context())
    fmt.Fprintf(w, "hello, %s!\n", params.ByName("name"))
}

//啟動監聽
http.ListenAndServe(":8080", router)

複製程式碼

介面實現

在觀察瞭如何建立一個監聽程式之後,挖掘這種優雅是如何封裝實現之前,我們要先了解,在原生Go中,每個Router路由結構都實現了http.Handler介面,Handler只有一個方法體,就是ServerHTTP,它只有一個功能,就是處理請求,做出響應。

type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}
複製程式碼

題外話,Go中比較傾向於KISS或者單一職責,把每個介面的功能都單一化,有需要再進行組合,用組合代替繼承,後續會把它當作一個編碼規範來看。

net\http\server

// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
    f(w, r)
}
複製程式碼

可以看到,在Go原生庫中,ServeHTTP()實現體HandlerFunc就是個func函式型別,具體實現又是直接套用HandlerFunc進行處理,我沒有在套娃哈,是不是有種“我實現我自己”的趕腳。

All in all, 我們先拋開第三方庫的封裝,複習一下標準庫,假如我們想用原生http\server包搭建一個HTTP處理邏輯,我們一般可以
方式1:

  1. 定義一個引數列表是(ResponseWriter, *Request)的函式
  2. 將其註冊到http.Server作為其Handler成員
  3. 呼叫ListenAndServerhttp.Server進行監聽

方式2:

  1. 定義一個結構,並且實現介面ServeHTTP(w http.ResponseWriter, req *http.Request)
  2. 將其註冊到http.Server作為其Handler成員
  3. 呼叫ListenAndServerhttp.Server進行監聽

示例如下:

//方式1
func SelfHandler(w http.ResponseWriter, r *http.Request) {
    fmt.Fprint(w, "<h1>Hello!</h1>")
}

//方式2
type HelloHandler struct {
}
//HelloHandler實現ServeHTTP()介面
func (* HelloHandler) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    fmt.Fprint(w, "<h1>Hello!</h1>")
}

s := &http.Server{
    Addr:           ":8080",
    //方式1
    Handler:        SelfHandler,
    //方式2
    //Handler:      &HelloHandler{},
    ReadTimeout:    10 * time.Second,
    WriteTimeout:   10 * time.Second,
    MaxHeaderBytes: 1 << 20,
}
s.ListenAndServe()
複製程式碼

拋磚引玉:
以上就是Go標準庫實現http服務的常用用法,現在進行擴充,假如我們需要通過url去獲取引數,如Get請求,localhost:8080/abc/1 Q: 我們如何拿到abc或者1呢?
A: 其實有個相對粗暴的方法,就是硬解:

  • 利用net/urlParse()函式把8080後面的那一段提取出來
  • 使用stings.split(ul, "/")
  • 利用下標進行引數範圍

示例如下

func TestStartHelloWithHttp(t *testing.T)  {
    //fmt.Println(path.Base(ul))
    
    ul := `https://localhost:8080/pixeldin/123`
    parse, e := url.Parse(ul)
    if e != nil {
    	log.Fatalf("%v", e)
    }
    //fmt.Println(parse.Path)	// "/pixeldin/123"
    name := GetParamFromUrl(parse.Path, 1)
    id := GetParamFromUrl(parse.Path, 2)
    fmt.Println("name: " + name + ", id: " + id)	
}

//指定下標返回相對url的值
func GetParamFromUrl(base string, index int) (ps string) {
    kv := strings.Split(base, "/")
    assert(index < len(kv), errors.New("index out of range."))
    return kv[index]
}

func assert(ok bool, err error)  {
    if !ok {
    	panic(err)
    }
}
複製程式碼

輸出:

name: pixeldin, id: 123
複製程式碼

這種辦法給人感覺相當暴力,而且需要記住每個引數的位置和對應的值,並且多個url不能統一管理起來,每次定址都是遍歷。儘管Go標準庫也提供了一些通用函式,比如下面這個栗子:
GET方式的url: https://localhost:8080/?key=hello,
可以通過*http.Request來獲取,這種請求方式是在url中宣告鍵值對,然後後臺根據請求key進行提取。

//摘取自:https://golangcode.com/get-a-url-parameter-from-a-request/
func handler(w http.ResponseWriter, r *http.Request) {
    
    keys, ok := r.URL.Query()["key"]
    
    if !ok || len(keys[0]) < 1 {
        log.Println("Url Param 'key' is missing")
        return
    }
    
    // Query()["key"] will return an array of items, 
    // we only want the single item.
    key := keys[0]
    
    log.Println("Url Param 'key' is: " + string(key))
}
複製程式碼

但是,曾經滄海難為水。相信大家更喜歡開篇列舉的那個例子,包括現在我們習慣的幾個主流的框架,都傾向於利用url的位置去尋參,當然httprouter的優勢肯定不止在這裡,這裡只是作為一個瞭解httprouter的切入點。

router.GET("/hello/:name", Hello)
router.GET("/hello/*name", HelloWorld)
複製程式碼

到這裡先止住,後續我們來追蹤它們封裝之後的底層實現以及是如何規劃url引數的。


httprouter對ServerHTTP()的實現

前面提到,所有路由結構都實現了http.Handler介面的ServeHTTP()方法,我們來看下httprouter基於它的實現方式。

julienschmidt\httprouter

httprouter中,ServeHTTP() 的實現結構就叫*Router,它內部封裝了用於檢索url的tree結構,幾個常用的布林選項,還有幾個也是基於http.Handler實現的預設處理器,它的實現如下:

// ServeHTTP makes the router implement the http.Handler interface.
func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    if r.PanicHandler != nil {
    	defer r.recv(w, req)
    }
    
    path := req.URL.Path
    
    if root := r.trees[req.Method]; root != nil {
        //getValue()返回處理方法與引數列表
    	if handle, ps, tsr := root.getValue(path); handle != nil {
    	    //匹配執行
    		handle(w, req, ps)
    		return
    	} else if req.Method != http.MethodConnect && path != "/" {
    		//...
    	}
    }
    
    if req.Method == http.MethodOptions && r.HandleOPTIONS {
    	// Handle OPTIONS requests
    	//...
    } else if r.HandleMethodNotAllowed { // Handle 405
    	//執行預設處理器...
    }
    
    // Handle 404
    if r.NotFound != nil {
    	r.NotFound.ServeHTTP(w, req)
    } else {
    	http.NotFound(w, req)
    }
}
複製程式碼

到這裡可以大致猜測它把處理method注入到內部trees結構,利用傳入url在trees進行匹配查詢,對執行鏈進行相應執行。 可以猜測這個Router.trees包含了handle和相應的引數,接著我們進入它的路由索引功能,來看下它是怎麼實現++url匹配++與++引數解析++的。

結構梳理:
這個trees存在tree.go原始檔中,其實是個map鍵值對,
key是HTTP methods(如GET/HEAD/POST/PUT等),method就是當前method與方法繫結上的節點

我在原始碼補充些註釋,相信大家容易看懂。

// Handle registers a new request handle with the given path and method.
//
// For GET, POST, PUT, PATCH and DELETE requests the respective shortcut
// functions can be used.
// ...
func (r *Router) Handle(method, path string, handle Handle) {
    if len(path) < 1 || path[0] != '/' {
    	panic("path must begin with '/' in path '" + path + "'")
    }
    
    //首次註冊url,初始化trees
    if r.trees == nil {
    	r.trees = make(map[string]*node)
    }
    
    //繫結http methods根節點,method可以是GET/POST/PUT等
    root := r.trees[method]
    if root == nil {
    	root = new(node)
    	r.trees[method] = root
    
    	r.globalAllowed = r.allowed("*", "")
    }
    
    //對http methods方法樹的路徑劃分
    root.addRoute(path, handle)
}
複製程式碼

Router.treesmap鍵值對的value是一個node結構體,每個HTTP METHOD 都是一個root節點,最主要的path分配是在這些節點的addRoute() 函式,
簡單理解的話, 最終那些字首一致的路徑會被繫結到這個樹的同一個分支方向上,直接提高了索引的效率。

下面我先列舉出node幾個比較重要的成員:

type node struct {
    path      string
    //標識 path是否後續有':', 用於引數判斷
    wildChild bool
    /* 當前節點的型別,預設是0,
    (root/param/catchAll)分別標識(根/有引數/全路徑)*/
    nType     nodeType
    maxParams uint8
    //當前節點優先順序, 掛在上面的子節點越多,優先順序越高
    priority  uint32
    indices   string
    //滿足字首的子節點,可以延申
    children  []*node
    //與當前節點繫結的處理邏輯塊
    handle    Handle
}
複製程式碼

其中子節點越多,或者說繫結handle方法越多的根節點,priority優先順序越高,作者有意識的對每次註冊完成進行優先順序排序。 引用作者的批註:

This helps in two ways:

  • Nodes which are part of the most routing paths are evaluated first. This helps to make as much routes as possible to be reachable as fast as possible.
  • It is some sort of cost compensation. The longest reachable path (highest cost) can always be evaluated first. The following scheme visualizes the tree structure. Nodes are evaluated from top to bottom and from left to right.

優先順序高的節點有利於handle的快速定位,相信比較好理解,現實中人流量密集處往往就是十字路口,類似交通樞紐。基於字首樹的匹配,讓定址從密集處開始,有助於提高效率。

由淺入深:
我們先給路由器router註冊幾個作者提供的GET處理邏輯,然後開始除錯,看下這個trees成員隨著url新增有什麼變化,

router.Handle("GET", "/user/ab/", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
    //do nothing, just add path+handler
})

router.Handle("GET", "/user/abc/", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
    //do nothing, just add path+handler
})

router.Handle(http.MethodGet, "/user/query/:name", func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
    routed = true
    want := httprouter.Params{httprouter.Param{"name", "gopher"}}
    if !reflect.DeepEqual(ps, want) {
    	t.Fatalf("wrong wildcard values: want %v, got %v", want, ps)
    }
})
複製程式碼

上述操作把【RESTful的GET方法url路徑匿名函式handler】作為router.Handler()的引數,router.Handler()的操作上面我們已經簡單的分析過了,主要節點劃分在其中的addRoute()函式裡面,下面我們簡單過一下它的邏輯


// 將當前url與處理邏輯放在當前節點
func (n *node) addRoute(path string, handle Handle) {
	fullPath := path
	n.priority++
	//提取當前url引數個數
	numParams := countParams(path)

	// 如果當前節點已經存在註冊鏈路
	if len(n.path) > 0 || len(n.children) > 0 {
	walk:
		for {
			// 更新最大引數個數
			if numParams > n.maxParams {
				n.maxParams = numParams
			}

			// 判斷待註冊url是否與已有url有重合,提取重合的最長下標
			// This also implies that the common prefix contains no ':' or '*'
			// since the existing key can't contain those chars.
			i := 0
			max := min(len(path), len(n.path))
			for i < max && path[i] == n.path[i] {
				i++
			}

			/*
			    如果進來的url匹配長度大於於當前節點已有的url,則建立子節點
			    比如當前節點是/user/ab/ func1,
			    
			    新進來一個/user/abc/ func2,則需要建立/user/ab的子節點/ 和 c/
			    樹狀如下:
					    |-/user/ab
					    |--------|-/ func1  
					    |--------|-c/ func2
					    
			    之後如果再註冊一個/user/a/ 與func3
			    則最終樹會調整為:
				優先順序3 |-/user/a 
				優先順序2 |--------|-b 
				優先順序1 |----------|-/ func1
				優先順序1 |----------|-c/ func2
				優先順序1 |--------|-/ func3
			    
			*/
			if i < len(n.path) {
				child := node{
					path:      n.path[i:],
					wildChild: n.wildChild,
					nType:     static,
					indices:   n.indices,
					children:  n.children,
					handle:    n.handle,
					priority:  n.priority - 1,
				}

				// 遍歷子節點,取最高優先順序作為父節點優先順序
				for i := range child.children {
					if child.children[i].maxParams > child.maxParams {
						child.maxParams = child.children[i].maxParams
					}
				}

				n.children = []*node{&child}
				// []byte for proper unicode char conversion, see #65
				n.indices = string([]byte{n.path[i]})
				n.path = path[:i]
				n.handle = nil
				n.wildChild = false
			}

			// Make new node a child of this node
			if i < len(path) {
				path = path[i:]

				if n.wildChild {
					n = n.children[0]
					n.priority++

					// Update maxParams of the child node
					if numParams > n.maxParams {
						n.maxParams = numParams
					}
					numParams--

					// Check if the wildcard matches
					if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&
						// Adding a child to a catchAll is not possible
						n.nType != catchAll &&
						// Check for longer wildcard, e.g. :name and :names
						(len(n.path) >= len(path) || path[len(n.path)] == '/') {
						continue walk
					} else {
						// Wildcard conflict
						var pathSeg string
						if n.nType == catchAll {
							pathSeg = path
						} else {
							pathSeg = strings.SplitN(path, "/", 2)[0]
						}
						prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path
						panic("'" + pathSeg +
							"' in new path '" + fullPath +
							"' conflicts with existing wildcard '" + n.path +
							"' in existing prefix '" + prefix +
							"'")
					}
				}

				c := path[0]

				// slash after param
				if n.nType == param && c == '/' && len(n.children) == 1 {
					n = n.children[0]
					n.priority++
					continue walk
				}

				// Check if a child with the next path byte exists
				for i := 0; i < len(n.indices); i++ {
					if c == n.indices[i] {
					    //增加當前節點的優先順序,並且做出位置調整
						i = n.incrementChildPrio(i)
						n = n.children[i]
						continue walk
					}
				}

				// Otherwise insert it
				if c != ':' && c != '*' {
					// []byte for proper unicode char conversion, see #65
					n.indices += string([]byte{c})
					child := &node{
						maxParams: numParams,
					}
					n.children = append(n.children, child)
					n.incrementChildPrio(len(n.indices) - 1)
					n = child
				}
				n.insertChild(numParams, path, fullPath, handle)
				return

			} else if i == len(path) { // Make node a (in-path) leaf
				if n.handle != nil {
					panic("a handle is already registered for path '" + fullPath + "'")
				}
				n.handle = handle
			}
			return
		}
	} else { // Empty tree
		n.insertChild(numParams, path, fullPath, handle)
		n.nType = root
	}
}
複製程式碼

以上的大致思路是,把各個handle func()註冊到一棵url字首樹上面,根據url字首相同的匹配度進行分支,以提高路由效率。

引數查詢:
接下來我們看下httprouter是怎麼將引數param封裝在上下文裡面的:
不難猜測分支劃分的時候是通過判斷關鍵字“:”來提取預接收的引數,這些引數是存字串(字典)鍵值對,底層儲存在一個Param結構體:

type Param struct {
	Key   string
	Value string
}
複製程式碼

關於上下文的概念在其他語言也挺常見,如Java Spring框架中的application-context,用來貫穿程式生命週期,用於管理一些全域性屬性。
Go的上下文也在不同框架有多種實現,這裡我們先初步瞭解Go程式最頂級的上下文是background(),是所有子上下文的來源,類似於Linux系統的init()程式。

先舉個例子,簡單列舉在Gocontext傳參的用法:

func TestContext(t *testing.T) {
	// 獲取頂級上下文
	ctx := context.Background()
	// 在上下文寫入string值, 注意需要返回新的value上下文
	valueCtx := context.WithValue(ctx, "hello", "pixel")
	value := valueCtx.Value("hello")
	if value != nil {
	    /*
	        已知寫入值是string,所以我們也可以直接進行型別斷言
	        比如: p, _ := ctx.Value(ParamsKey).(Params)
	        這個下劃線其實是go斷言返回的bool值
	    */
		fmt.Printf("Params type: %v, value: %v.\n", reflect.TypeOf(value), value)
	}
}
複製程式碼

輸出:

Params type: string, value: pixel.
複製程式碼

httprouter中,封裝在http.request中的上下文其實是個valueCtx,型別和我們上面的栗子中valueCtx是一樣的,框架中提供了一個從上下文獲取Params鍵值對的方法,

func ParamsFromContext(ctx context.Context) Params {
	p, _ := ctx.Value(ParamsKey).(Params)
	return p
}
複製程式碼

利用返回的Params就可以根據key獲取我們的目標值了,

params.ByName(key)
複製程式碼

經過追尋,Params是來自一個叫getValue(path string) (handle Handle, p Params, tsr bool)的函式,還記得上面列舉的*Router路由實現的ServeHTTP()介面嗎?

//ServeHTTP() 函式的一部分
if root := r.trees[req.Method]; root != nil {
    /**   getValue(),返回處理方法與引數列表  **/
	if handle, ps, tsr := root.getValue(path); handle != nil {
	    //匹配執行, 這裡的handle就是上面的匿名函式func
		handle(w, req, ps)
		return
	} else if req.Method != http.MethodConnect && path != "/" {
		//...
	}
}
複製程式碼

ServeHTTP()其中有個getValue函式,它的返回值有兩個重要成員:當前路由的處理邏輯和url引數列表,所以在路由註冊的時候我們需要把params作為入參傳進去。 像這樣子:

router.Handle(http.MethodGet, "/user/query/:name", 
//匿名函式func,這裡的ps引數就是在ServeHttp()的時候幫你提取的
func(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
	fmt.Println(params.ByName("name"))
})
複製程式碼

getValue()在對當前節點n進行型別判斷,如果是‘param’型別(在addRoute的時候已經根據url進行分類),則填充待返回引數Params。

//-----------go
//...github.com/julienschmidt/httprouter@v1.3.0/tree.go:367
switch n.nType {
case param:
	// find param end (either '/' or path end)
	end := 0
	for end < len(path) && path[end] != '/' {
		end++
	}
	
    //遇到節點引數
	if p == nil {
		// lazy allocation
		p = make(Params, 0, n.maxParams)
	}
	i := len(p)
	p = p[:i+1] // expand slice within preallocated capacity
	p[i].Key = n.path[1:]
	p[i].Value = path[:end]
//...
複製程式碼

流程梳理:
So far,我們再次歸納一下httprouter的路由過程:

  1. 初始化建立路由器router
  2. 註冊簽名為:type Handle func(http.ResponseWriter, *http.Request, Params)的函式到該router
  3. 呼叫HTTP通用介面ServeHTTP(),用於提取當前url預期的引數並且供業務層使用

以上這是這兩天對httprouter的瞭解,順便對go的Http有了進一步的認知,後續將嘗試進入gin中,看下Gin是基於httprouter做了什麼擴充以及熟悉常見用法。

參考連結:

julienschmidt/httprouter
github.com/julienschmi…

相關文章