輕量級 Web 框架 Gin 結構分析

碼洞發表於2019-03-04

Go 語言最流行了兩個輕量級 Web 框架分別是 Gin 和 Echo,這兩個框架大同小異,都是外掛式輕量級框架,背後都有一個開源小生態來提供各式各樣的小外掛,這兩個框架的效能也都非常好,裸測起來跑的飛快。本節我們只講 Gin 的實現原理和使用方法,Gin 起步比 Echo 要早,市場佔有率要高一些,生態也豐富一些。

go get -u github.com/gin-gonic/gin
複製程式碼

輕量級 Web 框架 Gin 結構分析

Hello World

Gin 框架的 Hello World 只需要 10 行程式碼,比大多數動態指令碼語言稍微多幾行。

package main

import "github.com/gin-gonic/gin"

func main() {
	r := gin.Default()
	r.GET("/ping", func(c *gin.Context) {
		c.JSON(200, gin.H{
			"message": "pong",
		})
	})
	r.Run() // listen and serve on 0.0.0.0:8080
}

複製程式碼

程式碼中的 gin.H 是 map[string]interface{} 的一個快捷名稱,寫起來會更加簡潔。

type H map[string]interface{}
複製程式碼

gin.Engine

Engine 是 Gin 框架最重要的資料結構,它是框架的入口。我們通過 Engine 物件來定義服務路由資訊、組裝外掛、執行服務。正如 Engine 的中文意思「引擎」一樣,它就是框架的核心發動機,整個 Web 服務的都是由它來驅動的。

發動機屬於精密裝置,構造非常複雜,不過 Engine 物件很簡單,因為引擎最重要的部分 —— 底層的 HTTP 伺服器使用的是 Go 語言內建的 http server,Engine 的本質只是對內建的 HTTP 伺服器的包裝,讓它使用起來更加便捷。

gin.Default() 函式會生成一個預設的 Engine 物件,裡面包含了 2 個預設的常用外掛,分別是 Logger 和 Recovery,Logger 用於輸出請求日誌,Recovery 確保單個請求發生 panic 時記錄異常堆疊日誌,輸出統一的錯誤響應。

func Default() *Engine {
	engine := New()
	engine.Use(Logger(), Recovery())
	return engine
}
複製程式碼

路由樹

在 Gin 框架中,路由規則被分成了最多 9 棵字首樹,每一個 HTTP Method對應一棵「字首樹」,樹的節點按照 URL 中的 / 符號進行層級劃分,URL 支援 :name 形式的名稱匹配,還支援 *subpath 形式的路徑萬用字元 。

// 匹配單節點 named
pattern = /book/:id
match /book/123
nomatch /book/123/10
nomatch /book/

// 匹配子節點 catchAll mode
/book/*subpath
match /book/
match /book/123
match /book/123/10
複製程式碼

輕量級 Web 框架 Gin 結構分析
每個節點都會掛接若干請求處理函式構成一個請求處理鏈 HandlersChain。當一個請求到來時,在這棵樹上找到請求 URL 對應的節點,拿到對應的請求處理鏈來執行就完成了請求的處理。

type Engine struct {
  ...
  trees methodTrees
  ...
}

type methodTrees []methodTree

type methodTree struct {
	method string
	root   *node  // 樹根
}

type node struct {
  path string // 當前節點的路徑
  ...
  handlers HandlersChain // 請求處理鏈
  ...
}

type HandlerFunc func(*Context)

type HandlersChain []HandlerFunc
複製程式碼

Engine 物件包含一個 addRoute 方法用於新增 URL 請求處理器,它會將對應的路徑和處理器掛接到相應的請求樹中

func (e *Engine) addRoute(method, path string, handlers HandlersChain)
複製程式碼

gin.RouterGroup

RouterGroup 是對路由樹的包裝,所有的路由規則最終都是由它來進行管理。Engine 結構體繼承了 RouterGroup ,所以 Engine 直接具備了 RouterGroup 所有的路由管理功能。這是為什麼在 Hello World 的例子中,可以直接使用 Engine 物件來定義路由規則。同時 RouteGroup 物件裡面還會包含一個 Engine 的指標,這樣 Engine 和 RouteGroup 就成了「你中有我我中有你」的關係。

type Engine struct {
  RouterGroup
  ...
}

type RouterGroup struct {
  ...
  engine *Engine
  ...
}
複製程式碼

RouterGroup 實現了 IRouter 介面,暴露了一系列路由方法,這些方法最終都是通過呼叫 Engine.addRoute 方法將請求處理器掛接到路由樹中。

GET(string, ...HandlerFunc) IRoutes
POST(string, ...HandlerFunc) IRoutes
DELETE(string, ...HandlerFunc) IRoutes
PATCH(string, ...HandlerFunc) IRoutes
PUT(string, ...HandlerFunc) IRoutes
OPTIONS(string, ...HandlerFunc) IRoutes
HEAD(string, ...HandlerFunc) IRoutes
// 匹配所有 HTTP Method
Any(string, ...HandlerFunc) IRoutes
複製程式碼

RouterGroup 內部有一個字首路徑屬性,它會將所有的子路徑都加上這個字首再放進路由樹中。有了這個字首路徑,就可以實現 URL 分組功能。Engine 物件內嵌的 RouterGroup 物件的字首路徑是 /,它表示根路徑。RouterGroup 支援分組巢狀,使用 Group 方法就可以讓分組下面再掛分組,於是子子孫孫無窮盡也。

func main() {
	router := gin.Default()

	v1 := router.Group("/v1")
	{
		v1.POST("/login", loginEndpoint)
		v1.POST("/submit", submitEndpoint)
		v1.POST("/read", readEndpoint)
	}

	v2 := router.Group("/v2")
	{
		v2.POST("/login", loginEndpoint)
		v2.POST("/submit", submitEndpoint)
		v2.POST("/read", readEndpoint)
	}

	router.Run(":8080")
}
複製程式碼

上面這個例子中實際上已經使用了分組巢狀,因為 Engine 物件裡面的 RouterGroup 物件就是第一層分組,也就是根分組,v1 和 v2 都是根分組的子分組。

gin.Context

這個物件裡儲存了請求的上下文資訊,它是所有請求處理器的入口引數。

type HandlerFunc func(*Context)

type Context struct {
  ...
  Request *http.Request // 請求物件
  Writer ResponseWriter // 響應物件
  Params Params // URL匹配引數
  ...
  Keys map[string]interface{} // 自定義上下文資訊
  ...
}
複製程式碼

Context 物件提供了非常豐富的方法用於獲取當前請求的上下文資訊,如果你需要獲取請求中的 URL 引數、Cookie、Header 都可以通過 Context 物件來獲取。這一系列方法本質上是對 http.Request 物件的包裝。

// 獲取 URL 匹配引數  /book/:id
func (c *Context) Param(key string) string
// 獲取 URL 查詢引數 /book?id=123&page=10
func (c *Context) Query(key string) string
// 獲取 POST 表單引數
func (c *Context) PostForm(key string) string
// 獲取上傳的檔案物件
func (c *Context) FormFile(name string) (*multipart.FileHeader, error)
// 獲取請求Cookie
func (c *Context) Cookie(name string) (string, error) 
...
複製程式碼

Context 物件提供了很多內建的響應形式,JSON、HTML、Protobuf 、MsgPack、Yaml 等。它會為每一種形式都單獨定製一個渲染器。通常這些內建渲染器已經足夠應付絕大多數場景,如果你覺得不夠,還可以自定義渲染器。

func (c *Context) JSON(code int, obj interface{})
func (c *Context) Protobuf(code int, obj interface{})
func (c *Context) YAML(code int, obj interface{})
...
// 自定義渲染
func (c *Context) Render(code int, r render.Render)

// 渲染器通用介面
type Render interface {
	Render(http.ResponseWriter) error
	WriteContentType(w http.ResponseWriter)
}
複製程式碼

所有的渲染器最終還是需要呼叫內建的 http.ResponseWriter(Context.Writer) 將響應物件轉換成位元組流寫到套接字中。

type ResponseWriter interface {
 // 容納所有的響應頭
 Header() Header
 // 寫Body
 Write([]byte) (int, error)
 // 寫Header
 WriteHeader(statusCode int)
}
複製程式碼

外掛與請求鏈

我們編寫業務程式碼時一般也就是一個處理函式,為什麼路由節點需要掛接一個函式鏈呢?

type node struct {
  path string // 當前節點的路徑
  ...
  handlers HandlersChain // 請求處理鏈
  ...
}
type HandlerFunc func(*Context)
type HandlersChain []HandlerFunc
複製程式碼

這是因為 Gin 提供了外掛,只有函式鏈的尾部是業務處理,前面的部分都是外掛函式。在 Gin 中外掛和業務處理函式形式是一樣的,都是 func(*Context)。當我們定義路由時,Gin 會將外掛函式和業務處理函式合併在一起形成一個鏈條結構。

type Context struct {
  ...
  index uint8 // 當前的業務邏輯位於函式鏈的位置
  handlers HandlersChain // 函式鏈
  ...
}

// 挨個呼叫鏈條中的處理函式
func (c *Context) Next() {
	c.index++
	for s := int8(len(c.handlers)); c.index < s; c.index++ {
		c.handlers[c.index](c)
	}
}
複製程式碼

Gin 在接收到客戶端請求時,找到相應的處理鏈,構造一個 Context 物件,再呼叫它的 Next() 方法就正式進入了請求處理的全流程。

輕量級 Web 框架 Gin 結構分析

Gin 還支援 Abort() 方法中斷請求鏈的執行,它的原理是將 Context.index 調整到一個比較大的數字,這樣 Next() 方法中的呼叫迴圈就會立即結束。需要注意的 Abort() 方法並不是通過 panic 的方式中斷執行流,執行 Abort() 方法之後,當前函式內後面的程式碼邏輯還會繼續執行。

const abortIndex = 127
func (c *Context) Abort() {
	c.index = abortIndex
}

func SomePlugin(c *Context) {
  ...
  if condition {
    c.Abort()
    // continue executing
  }
  ...
}
複製程式碼

如果在外掛中顯示呼叫 Next() 方法,那麼它就改變了正常的順序執行流,變成了像洋蔥一樣的巢狀執行流。換個角度來理解,正常的執行流就是後續的處理器是在前一個處理器的尾部執行,而巢狀執行流是讓後續的處理器在前一個處理器進行到一半的時候執行,待後續處理器完成執行後,再回到前一個處理器繼續往下執行。

輕量級 Web 框架 Gin 結構分析
要是你學過 Python 語言,這種巢狀結構很容易讓人聯想到裝飾器 decorator。如果所有的外掛都使用巢狀執行流,那麼就會變成了下面這張圖
輕量級 Web 框架 Gin 結構分析

RouterGroup 提供了 Use() 方法來註冊外掛,因為 RouterGroup 是一層套一層,不同層級的路由可能會註冊不一樣的外掛,最終不同的路由節點掛接的處理函式鏈也不盡相同。

func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
	group.Handlers = append(group.Handlers, middleware...)
	return group.returnObj()
}

// 註冊 Get 請求
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
	return group.handle("GET", relativePath, handlers)
}

func (g *RouterGroup) handle(method, path string, handlers HandlersChain) IRoutes {
 // 合併URL (RouterGroup有URL字首)
	absolutePath := group.calculateAbsolutePath(relativePath)
	// 合併處理鏈條
 handlers = group.combineHandlers(handlers)
	// 註冊路由樹
 group.engine.addRoute(httpMethod, absolutePath, handlers)
	return group.returnObj()
}
複製程式碼

HTTP 錯誤

當 URL 請求對應的路徑不能在路由樹裡找到時,就需要處理 404 NotFound 錯誤。當 URL 的請求路徑可以在路由樹裡找到,但是 Method 不匹配,就需要處理 405 MethodNotAllowed 錯誤。Engine 物件為這兩個錯誤提供了處理器註冊的入口

func (engine *Engine) NoMethod(handlers ...HandlerFunc)
func (engine *Engine) NoRoute(handlers ...HandlerFunc)
複製程式碼

異常處理器和普通處理器一樣,也需要和外掛函式組合在一起形成一個呼叫鏈。如果沒有提供異常處理器,Gin 就會使用內建的簡易錯誤處理器。

注意這兩個錯誤處理器是定義在 Engine 全域性物件上,而不是 RouterGroup。對於非 404 和 405 錯誤,需要使用者自定義外掛來處理。對於 panic 丟擲來的異常需要也需要使用外掛來處理。

靜態檔案服務

RouterGroup 物件裡定義了下面三個用來服務靜態檔案的方法

// 服務單個靜態檔案
StaticFile(relativePath, filePath string) IRoutes
// 服務靜態檔案目錄
Static(relativePath, dirRoot string) IRoutes
// 服務虛擬靜態檔案系統
StaticFS(relativePath string, fs http.FileSystem) IRoutes
複製程式碼

它不同於錯誤處理器,靜態檔案服務掛在 RouterGroup 上,支援巢狀。這三個方法中 StaticFS 方法比較特別,它對檔案系統進行了抽象,你可以提供一個基於網路的靜態檔案系統,也可以提供一個基於記憶體的靜態檔案系統。FileSystem 介面也很簡單,提供一個路徑引數返回一個實現了 File 介面的檔案物件。不同的虛擬檔案系統使用不同的程式碼來實現 File 介面。

type FileSystem interface {
 Open(path string) (File, error)
}

type File interface {
 io.Closer
 io.Reader
 io.Seeker
 Readdir(count int) ([]os.FileInfo, error)
 Stat() (os.FileInfo, error)
}
複製程式碼

靜態檔案處理器和普通處理器一樣,也需要經過外掛的重重過濾。

表單處理

當請求引數數量比較多時,使用 Context.Query() 和 Context.PostForm() 方法來獲取引數就會顯得比較繁瑣。Gin 框架也支援表單處理,將表單引數和結構體欄位進行直接對映。

package main

import (
	"github.com/gin-gonic/gin"
)

type LoginForm struct {
	User     string `form:"user" binding:"required"`
	Password string `form:"password" binding:"required"`
}

func main() {
	router := gin.Default()
	router.POST("/login", func(c *gin.Context) {
  var form LoginForm
		if c.ShouldBind(&form) == nil {
			if form.User == "user" && form.Password == "password" {
				c.JSON(200, gin.H{"status": "you are logged in"})
			} else {
				c.JSON(401, gin.H{"status": "unauthorized"})
			}
		}
	})
	router.Run(":8080")
}
複製程式碼

Context.ShouldBind 方法遇到校驗不通過時,會返回一個錯誤物件告知呼叫者校驗失敗的原因。它支援多種資料繫結型別,如 XML、JSON、Query、Uri、MsgPack、Protobuf等,根據請求的 Content-Type 頭來決定使用何種資料繫結方法。

func (c *Context) ShouldBind(obj interface{}) error {
 // 獲取繫結器
	b := binding.Default(c.Request.Method, c.ContentType())
	// 執行繫結
 return c.ShouldBindWith(obj, b)
}
複製程式碼

預設內建的表單校驗功能很強大,它通過結構體欄位 tag 標註來選擇相應的校驗器進行校驗。Gin 還提供了註冊自定義校驗器的入口,支援使用者自定義一些通用的特殊校驗邏輯。

Context.ShouldBind 是比較柔和的校驗方法,它只負責校驗,並將校驗結果以返回值的形式傳遞給上層。Context 還有另外一個比較暴力的校驗方法 Context.Bind,它和 ShouldBind 的呼叫形式一摸一樣,區別是當校驗錯誤發生時,它會呼叫 Abort() 方法中斷呼叫鏈的執行,向客戶端返回一個 HTTP 400 Bad Request 錯誤。

HTTPS

Gin 不支援 HTTPS,官方建議是使用 Nginx 來轉發 HTTPS 請求到 Gin。

輕量級 Web 框架 Gin 結構分析

相關文章