Gin 源碼學習(二)丨請求體中的參數是如何解析的?

上一篇文章 Gin 源碼學習(一)丨請求中 URL 的參數是如何解析的? 對 Gin 請求中 URL 的參數解析進行了講解,其中主要是存在於 URL 中的參數,這篇文章將講解 Gin 是如何解析請求體中的參數的。git

主要包括請求頭中 Content-Typeapplication/x-www-form-urlencoded 的 POST 表單以及 Content-Typeapplication/json 的 JSON 格式數據請求。github

下面開始 Gin 源碼學習的第二篇:請求體中的參數是如何解析的?算法

Go 版本:1.14json

Gin 版本:v1.5.0緩存

目錄

  • URL 編碼的表單參數解析
  • JSON 格式數據的參數解析
  • 小結

URL 編碼的表單參數解析

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

	router.POST("/form_post", func(c *gin.Context) {
		message := c.PostForm("message")
		name := c.DefaultPostForm("name", "Cole")
		m := c.PostFormMap("map")

		c.JSON(200, gin.H{
			"message": message,
			"name":    name,
			"map": m,
		})
	})
	router.Run(":8000")
}
複製代碼

引用 Gin 官方文檔中的一個例子,咱們先把關注點放在 c.PostForm(key)c.DefaultPostForm(key, defaultValue)上面。相信有些人已經猜到了,以 Default 開頭的函數,通常只是對取不到值的狀況進行了處理。app

c.JSON(code, obj) 函數中的參數 200 爲 HTTP 請求中的一種響應碼,表示一切正常,而 gin.H 則是 Gin 對 map[string]interface{} 的一種簡寫。ide

發起一個對該接口的請求,請求內容和結果以下圖所示:函數

在後續文章中會對 Gin 內部是如何處理響應的進行詳細講解,這裏就不對響應的內容進行講解。post

咱們一塊兒來看一下,Gin 是如何獲取到請求中的表單數據的,先看一下 c.PostForm(key)c.DefaultPostForm(key, defaultValue) 這兩個函數的源代碼:性能

// PostForm returns the specified key from a POST urlencoded form or multipart form
// when it exists, otherwise it returns an empty string `("")`.
func (c *Context) PostForm(key string) string {
	value, _ := c.GetPostForm(key)
	return value
}

// DefaultPostForm returns the specified key from a POST urlencoded form or multipart form
// when it exists, otherwise it returns the specified defaultValue string.
// See: PostForm() and GetPostForm() for further information.
func (c *Context) DefaultPostForm(key, defaultValue string) string {
	if value, ok := c.GetPostForm(key); ok {
		return value
	}
	return defaultValue
}
複製代碼

經過註釋,咱們能夠知道這兩個函數都用於從 POST 請求的 URL 編碼表單或者 multipart 表單(主要用於攜帶二進制流數據時使用,如文件上傳)中返回指定 Key 對應的 Value,區別就是當指定 Key 不存在時,DefaultPostForm(key, defaultValue) 函數將參數 defaultValue 做爲返回值返回。

能夠看到這兩個函數都是從 c.GetPostForm(key) 函數中獲取的值,咱們來跟蹤一下這個函數:

// GetPostForm is like PostForm(key). It returns the specified key from a POST urlencoded
// form or multipart form when it exists `(value, true)` (even when the value is an empty string),
// otherwise it returns ("", false).
// For example, during a PATCH request to update the user's email:
// email=mail@example.com --> ("mail@example.com", true) := GetPostForm("email") // set email to "mail@example.com"
// email= --> ("", true) := GetPostForm("email") // set email to ""
// --> ("", false) := GetPostForm("email") // do nothing with email
func (c *Context) GetPostForm(key string) (string, bool) {
	if values, ok := c.GetPostFormArray(key); ok {
		return values[0], ok
	}
	return "", false
}

// GetPostFormArray returns a slice of strings for a given form key, plus
// a boolean value whether at least one value exists for the given key.
func (c *Context) GetPostFormArray(key string) ([]string, bool) {
	c.getFormCache()
	if values := c.formCache[key]; len(values) > 0 {
		return values, true
	}
	return []string{}, false
}
複製代碼

首先是 c.GetPostForm(key) 函數,在其內部調用了 c.GetPostFormArray(key) 函數,其返回類型爲 ([]string, bool)

而後是 c.GetPostFormArray(key) 函數,在該函數內的第一行,調用了 c.getFormCache() 函數,看到這裏,你們是否是有種似曾類似的感受?沒錯,這和上一篇文章中的查詢字符串的參數解析是相似的,在 gin.Context 類型中,有着兩個 url.Values 類型的屬性,分別是 queryCacheformCache,而 url.Values 則是位於 net/url 包中的自定義類型,是一種鍵爲字符串,值爲字符串切片的 map,這在上一篇文章中有講過,就當作是複習。

在 Gin 內部,就是把 POST 請求中的表單數據解析並保存至 formCache 中,用於屢次獲取而無需重複解析。

接下來咱們來看一下,c.getFormCache() 函數是如何解析表單中的數據的:

func (c *Context) getFormCache() {
	if c.formCache == nil {
		c.formCache = make(url.Values)
		req := c.Request
		if err := req.ParseMultipartForm(c.engine.MaxMultipartMemory); err != nil {
			if err != http.ErrNotMultipart {
				debugPrint("error on parse multipart form array: %v", err)
			}
		}
		c.formCache = req.PostForm
	}
}
複製代碼

首先是對 c.formCache 進行初始化,而後把 c.Request 賦值給 req,再經過調用 req.ParseMultipartForm(maxMemory) 進行參數解析,最後把解析後的數據 req.PostForm 賦值給 c.formCache

問題來了,這裏爲何要把 c.Request 賦值給一個臨時變量 req 呢?爲何不直接使用 c.Request.ParseMultipartForm(maxMemory) 呢?這樣的話連 c.formCache 均可以省了。這個問題我也思考了一會,畢竟我也不是這塊功能的設計者,因此,個人我的理解以下(不必定正確):

第一個問題,想必是因爲 req.ParseMultipartForm(maxMemory) 的調用,會將表單數據解析至該 Request 對象中,從而增長該對象的內存佔用空間,並且在解析過程當中不只只會對 req.PostForm 進行賦值,而對於該 Request 對象中其餘屬性進行賦值並非 Gin 所須要的;

第二個問題,一樣地,因爲第一個問題形成的影響,致使若是直接使用 c.Request 對象來進行參數解析的話,會添加額外的沒必要要的內存開銷,這是阻礙到 Gin 高效的緣由之一,其次,假如使用 c.Request.PostForm 的話,那麼對參數的獲取操做,則操做的對象爲 Request 對象,而 Request 對象位於 Go 內置函數庫中的 net/http 庫,畢竟不屬於 Gin 的內部庫,若是這樣作的話,多多少少會增長二者之間的耦合度,可是若是操做對象是 gin.Context.formCache 的話,那麼 Gin 只需把關注點放在本身身上就夠了。

以上是筆者對這兩個疑問的一些見解,若是有其餘見解,歡迎留言討論!

差點走遠了~

好了,迴歸正題,終於到了咱們要找的地方,就是這個屬於 Go 自帶函數庫 net/http 庫中的函數 req.ParseMultipartForm(maxMemory)

經過上面的分析,咱們知道了在這個函數內部,會對錶單數據進行解析,而且將解析後的參數賦值在該 Request 對象的 PostForm 屬性上,下面,咱們來看一下該函數的源代碼(省略無關源代碼):

// ParseMultipartForm parses a request body as multipart/form-data.
// The whole request body is parsed and up to a total of maxMemory bytes of
// its file parts are stored in memory, with the remainder stored on
// disk in temporary files.
// ParseMultipartForm calls ParseForm if necessary.
// After one call to ParseMultipartForm, subsequent calls have no effect.
func (r *Request) ParseMultipartForm(maxMemory int64) error {
	// 判斷該multipart表單是否已解析過
	if r.MultipartForm == multipartByReader {
		return errors.New("http: multipart handled by MultipartReader")
	}
	if r.Form == nil {
		// 解析請求體中的參數
		err := r.ParseForm()
		if err != nil {
			return err
		}
	}
	if r.MultipartForm != nil {
		return nil
	}

	// 判斷該表單的Content-Type是否爲multipart/form-data
    // 並解析分隔符
	mr, err := r.multipartReader(false)
	if err != nil {
		// Content-Type不爲multipart/form-data
		// Urlencoded表單解析完畢
		return err
	}

	// 省略
	r.MultipartForm = f

	return nil
}
複製代碼

經過函數上方的註釋,咱們能夠知道該函數會將請求體中的數據解析爲 multipart/form-data,而且請求體中數據最大爲 maxMemory 個字節,還說文件部分存儲於內存中,另外部分存儲於磁盤中,不過這些都與咱們的 URL 編碼表單無關,惟一有關的,只是簡單的一句,若是須要會調用 ParseForm

經過添加的註釋,咱們能夠知道,URL 編碼的表單在調用 r.multipartReader(allowMixed) 函數以後,直接 return,其中參數 allowMixed 表示是否容許 multipart/mixed 類型的 Content-Type,至於 multipart 表單數據的解析這裏不作過多說明,其數據以 --分隔符-- 進行分隔,因爲使用二進制編碼,所以適合用於文件上傳等。

下面是對 URL 編碼的表單數據進行解析的函數:

func (r *Request) ParseForm() error {
	var err error
	if r.PostForm == nil {
		if r.Method == "POST" || r.Method == "PUT" || r.Method == "PATCH" {
			r.PostForm, err = parsePostForm(r)
		}
		if r.PostForm == nil {
			r.PostForm = make(url.Values)
		}
	}
	if r.Form == nil {
		// 省略
	}
	return err
}

func parsePostForm(r *Request) (vs url.Values, err error) {
	if r.Body == nil {
		err = errors.New("missing form body")
		return
	}
	ct := r.Header.Get("Content-Type")
	// RFC 7231, section 3.1.1.5 - empty type
	// MAY be treated as application/octet-stream
	if ct == "" {
		ct = "application/octet-stream"
	}
	ct, _, err = mime.ParseMediaType(ct)
	switch {
	case ct == "application/x-www-form-urlencoded":
		var reader io.Reader = r.Body
		maxFormSize := int64(1<<63 - 1)
		// 斷言r.Body是否爲*maxBytesReader(用於限制請求體的大小)
		if _, ok := r.Body.(*maxBytesReader); !ok {
			// 設置請求體最大爲10M
			maxFormSize = int64(10 << 20) // 10 MB is a lot of text.
			// 建立從r.Body讀取的Reader
			// 可是當讀取maxFormSize+1個字節後會以EOF中止
			reader = io.LimitReader(r.Body, maxFormSize+1)
		}
		// 將請求體內容以字節方式讀取
		b, e := ioutil.ReadAll(reader)
		if e != nil {
			if err == nil {
				err = e
			}
			break
		}
		if int64(len(b)) > maxFormSize {
			err = errors.New("http: POST too large")
			return
		}
		vs, e = url.ParseQuery(string(b))
		if err == nil {
			err = e
		}
	case ct == "multipart/form-data":
		// 無具體實現
	}
	return
}
複製代碼

首先看一下 r.ParseForm() 函數,經過源代碼能夠發現,其支持的請求方式有 POST, PATCHPUT,若是請求類型符合條件,那麼就調用 parsePostForm(r) 函數爲 r.PostForm 賦值,而後下面是判斷 r.Form 屬性是否爲空,這裏面的源代碼省略了,由於沒涉及到對 Gin 所須要的 r.PostForm 屬性進行賦值操做。

而後是 parsePostForm(r) 函數,經過源代碼,咱們能夠發現,除了 Content-Typeapplication/x-www-form-urlencoded 的表單請求外,其他類型的在此函數中都不作處理,源代碼邏輯以在註釋中給出,須要注意的是 url.ParseQuery(string(b)) 函數的調用,該函數用於解析表單中的數據,而表單中的數據存儲方式,與上一篇文章中講的 URL 中的查詢字符串的存儲方式一致,所以,表單內數據解析的方式與 URL 中的查詢字符串的解析也是同樣的。

最後再看一下 Gin 獲取請求中 map 類型參數的實現源代碼:

// PostFormMap returns a map for a given form key.
func (c *Context) PostFormMap(key string) map[string]string {
	dicts, _ := c.GetPostFormMap(key)
	return dicts
}

// GetPostFormMap returns a map for a given form key, plus a boolean value
// whether at least one value exists for the given key.
func (c *Context) GetPostFormMap(key string) (map[string]string, bool) {
	c.getFormCache()
	return c.get(c.formCache, key)
}

// get is an internal method and returns a map which satisfy conditions.
func (c *Context) get(m map[string][]string, key string) (map[string]string, bool) {
	dicts := make(map[string]string)
	exist := false
	for k, v := range m {
		if i := strings.IndexByte(k, '['); i >= 1 && k[0:i] == key {
			if j := strings.IndexByte(k[i+1:], ']'); j >= 1 {
				exist = true
				dicts[k[i+1:][:j]] = v[0]
			}
		}
	}
	return dicts, exist
}
複製代碼

c.PostFormMap(key) 函數中直接調用 c.GetPostFormMap(key) 函數獲取請求中的 map 類型參數,在 c.GetPostFormMap(key) 函數中,一樣先是調用 c.getFormCache() 判斷請求中的數據是否已緩存處理,而後以存儲表單緩存數據的參數 c.formCache 和要獲取參數的 key 做爲 c.get(m, key) 的參數,調用該函數,獲取該 map 類型數據。

咱們來看一下最後的這個 c.get(m, key) 函數,首先聲明瞭一個類型爲 map[string]string 的變量 dicts 用於結果集,聲明瞭一個值爲 false 的變量 exist 用於標記是否存在符合的 map。

隨後是對 c.formCache 的遍歷,以最初的請求爲例,則該 c.formCache 的值爲:

{"message": ["name"], "name": ["Les An"], "map[a]": ["A"], "map[b]": ["B"]}
複製代碼

因爲 m 的類型爲 map[string][]string,所以 kv 分別爲 m 的字符串類型的鍵和字符串切片類型的值,在遍歷過程當中判斷 m 中的每一個 k 是否存在 [,若存在則判斷位於 [ 前面的全部內容是否與傳入的參數 key 相同,若相同則判斷 [ 中是否存有間隔大於等於 1 的 ],若存在則將 [] 之間的字符串做爲要返回的 map 中的其中一個鍵,將該 k 對應的 v 字符串切片的第一個元素做爲該鍵的值,以此循環。

JSON 格式數據的參數解析

Gin 提供了四種可直接將請求體中的 JSON 數據解析並綁定至相應類型的函數,分別是:BindJSON, Bind, ShouldBindJSON, ShouldBind。下面講解的不會太涉及具體的 JSON 解析算法,而是更偏向於 Gin 內部的實現邏輯。

其中,可爲它們分爲兩類,一類爲 Must Bind,另外一類爲 Should Bind,前綴爲 Should 的皆屬於 Should Bind,而以 Bind 爲前綴的,則屬於 Must Bind。正如其名,Must Bind 一類在對請求進行解析時,若出現錯誤,會經過 c.AbortWithError(400, err).SetType(ErrorTypeBind) 終止請求,這會把響應碼設置爲 400,Content-Type 設置爲 text/plain; charset=utf-8,在此以後,若嘗試從新設置響應碼,則會出現警告,如將響應碼設置爲 200:[GIN-debug] [WARNING] Headers were already written. Wanted to override status code 400 with 200;而 Should Bind 一類在對請求進行解析時,若出現錯誤,只會將錯誤返回,而不會主動進行響應,因此,在使過程當中,若是對產生解析錯誤的行爲有更好的控制,最好使用 Should Bind 一類,自行對錯誤行爲進行處理。

先來看一下下面這段代碼,以及發起一個請求體爲 JSON 格式數據的請求後獲得的響應內容(解析正常的狀況下 /must/should 響應內容一致):

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

	router.POST("/must", func(c *gin.Context) {
		var json map[string]interface{}
		if err := c.BindJSON(&json); err == nil {
			c.JSON(http.StatusOK, gin.H{"msg": fmt.Sprintf("username is %s", json["username"])})
		}
	})

	router.POST("/should", func(c *gin.Context) {
		var json map[string]interface{}
		if err := c.ShouldBindJSON(&json); err != nil {
			c.JSON(http.StatusOK, gin.H{"msg": err.Error()})
			return
		}
		c.JSON(http.StatusOK, gin.H{"msg": fmt.Sprintf("username is %s", json["username"])})
	})

	router.Run(":8000")
}
複製代碼

咱們先來看一下開始提到的 BindBindJSON 這兩個函數的實現源代碼:

// Bind checks the Content-Type to select a binding engine automatically,
// Depending the "Content-Type" header different bindings are used:
// "application/json" --> JSON binding
// "application/xml" --> XML binding
// 省略部分註釋
func (c *Context) Bind(obj interface{}) error {
	b := binding.Default(c.Request.Method, c.ContentType())
	return c.MustBindWith(obj, b)
}

// BindJSON is a shortcut for c.MustBindWith(obj, binding.JSON).
func (c *Context) BindJSON(obj interface{}) error {
	return c.MustBindWith(obj, binding.JSON)
}

// MustBindWith binds the passed struct pointer using the specified binding engine.
// It will abort the request with HTTP 400 if any error occurs.
// See the binding package.
func (c *Context) MustBindWith(obj interface{}, b binding.Binding) error {
	if err := c.ShouldBindWith(obj, b); err != nil {
		c.AbortWithError(http.StatusBadRequest, err).SetType(ErrorTypeBind) // nolint: errcheck
		return err
	}
	return nil
}
複製代碼

首先是 c.Bind(obj) 函數,其與 c.BindJSON(obj) 的惟一區別就是,它會自動檢查 Content-Type 來選擇綁定引擎,例如 application/json,則使用 JSON 綁定,application/xml 則選擇 XML 綁定,省略的註釋內容筆者本人在閱讀時,感受寫得不是很正確,多是在更新過程當中沒有修改該註釋的緣由,所以將其省略。

在該函數中,經過調用 binding.Default(method, contentType) 函數,根據請求方法的類型以及 Content-Type 來獲取相應的綁定引擎,首先是判斷請求的方法類型,若是爲 GET,則直接返回 Form 綁定引擎,不然使用 switch 根據 Content-Type 選擇合適的綁定引擎。

接下來則是使用傳遞進來的 obj 指針結構和相應的綁定引擎來調用 c.MustBindWith(obj, b) 函數,該函數將會使用該綁定引擎將請求體中的數據綁定至該 obj 指針結構上。

c.MustBindWith(obj, b) 函數內部,實際上調用的是 c.ShouldBindWith(obj, b) 函數,看到這裏,你們應該也懂了,沒錯,Must Bind 一類內部的實現其實也是調用 Should Bind 一類,只不過 Must Bind 一類主動對錯誤進行了處理並進行響應,也就是一開始提到的,設置響應碼爲 400 以及 Content-Typetext/plain; charset=utf-8 同時對請求進行響應並返回錯誤給調用者,至於 c.AbortWithError(code, err) 函數的具體實現,咱們這裏不作過多解釋,只需理解其做用就行,在本系列的後續文章中,會對其再作詳細講解。

下面,咱們先來一塊兒看一下,c.BindJSON(obj) 函數中使用的 binding.JSONc.MustBindWith(obj, b) 函數中的參數 b binding.Binding 有什麼關係,其相關源代碼以下:

// Binding describes the interface which needs to be implemented for binding the
// data present in the request such as JSON request body, query parameters or
// the form POST.
type Binding interface {
	Name() string
	Bind(*http.Request, interface{}) error
}

// These implement the Binding interface and can be used to bind the data
// present in the request to struct instances.
var (
	JSON          = jsonBinding{}
	XML           = xmlBinding{}
	Form          = formBinding{}
	Query         = queryBinding{}
	FormPost      = formPostBinding{}
	FormMultipart = formMultipartBinding{}
	ProtoBuf      = protobufBinding{}
	MsgPack       = msgpackBinding{}
	YAML          = yamlBinding{}
	Uri           = uriBinding{}
	Header        = headerBinding{}
)

type jsonBinding struct{}

func (jsonBinding) Name() string {
	return "json"
}

func (jsonBinding) Bind(req *http.Request, obj interface{}) error {
	if req == nil || req.Body == nil {
		return fmt.Errorf("invalid request")
	}
	return decodeJSON(req.Body, obj)
}
複製代碼

從源代碼中,能夠看出 binding.Binding 是一個接口類型,其有兩個可實現方法,Name()Bind(*http.Request, interface{}),而後 binding.JSON 是一個 jsonBinding 類型的對象,相似 Gin 還提供了 binding.XML, binding.Form 等全局變量,分別表示不一樣類型的綁定引擎,jsonBinding 是 Gin 內部定義的一個自定義類型,其實現了 binding.Binding 接口,Name() 函數返回綁定引擎的名稱,Bind(req, obj) 函數用於對請求體中的數據進行解析並綁定至傳遞進來的指針變量 obj 上。

Gin 默認使用的是 Go 自帶函數庫中的 encoding/json 庫來進行 JSON 解析,可是因爲 encoding/json 庫提供的 JSON 解析性能不算特別快,所以還提供了一個 JSON 解析庫 json-iterator,使用的方式也很簡單,只需在運行或構建時加上 -tags=jsoniter 選項便可,例如:go run main.go -tags=jsoniter,這是 Go 提供的一種條件編譯方式,經過添加標籤的方式來實現條件編譯,在 Gin 的 internal/json 庫中,有着 json.gojsoniter.go 兩個源文件,內容分別以下:

// json.go
// +build !jsoniter

package json

import "encoding/json"

var (
	// Marshal is exported by gin/json package.
	Marshal = json.Marshal
	// Unmarshal is exported by gin/json package.
	Unmarshal = json.Unmarshal
	// MarshalIndent is exported by gin/json package.
	MarshalIndent = json.MarshalIndent
	// NewDecoder is exported by gin/json package.
	NewDecoder = json.NewDecoder
	// NewEncoder is exported by gin/json package.
	NewEncoder = json.NewEncoder
)
複製代碼
// jsoniter.go
// +build jsoniter

package json

import "github.com/json-iterator/go"

var (
	json = jsoniter.ConfigCompatibleWithStandardLibrary
	// Marshal is exported by gin/json package.
	Marshal = json.Marshal
	// Unmarshal is exported by gin/json package.
	Unmarshal = json.Unmarshal
	// MarshalIndent is exported by gin/json package.
	MarshalIndent = json.MarshalIndent
	// NewDecoder is exported by gin/json package.
	NewDecoder = json.NewDecoder
	// NewEncoder is exported by gin/json package.
	NewEncoder = json.NewEncoder
)
複製代碼

json.go 文件的頭部中,帶有 // +build !jsoniter,而在 jsoniter.go 文件的頭部中,則帶有 // +build jsoniter,Go 的條件編譯的實現,正是經過 +build-tags 實現的,此處的 // +build !jsoniter 表示,當 tags 不爲 jsoniter 時,使用該文件進行編譯,而 // +build jsoniter 則與其相反。

經過上面的源碼講解,咱們能夠從中發現一個問題,在對請求中的數據進行綁定時,僅有在調用 binding.Default(method, contentType) 函數時,纔會對請求的 Content-Type 進行判斷,以 JSON 格式的請求數據爲例,假設請求體中的 JSON 格式數據正確,那麼,當調用 c.BindJSON(obj) 或者 c.ShouldBindJSON(obj) 時,即便請求頭中的 Content-Type 不爲 application/json,對請求體中的 JSON 數據也是可以正常解析並正常響應的,僅有在使用 c.Bind(obj) 或者 c.ShouldBind(obj) 對請求數據進行解析時,纔會去判斷請求的 Content-Type 類型。

小結

這篇文章講解了 Gin 是如何對請求體中的數據進行解析的,分別是使用 URL 編碼的表單數據和 JSON 格式的數據。

第一部分講的是 URL 編碼的表單參數解析過程,首先是對錶單中的數據進行提取,提取以後使用與上一篇文章中 URL 查詢字符串參數解析相同的方式進行解析並作緩存處理;同時還涉及到了另外的一種適用於上傳二進制數據的 Multipart 表單,其使用切割符對數據進行解析;最後還講了 Gin 從緩存起來的請求數據中獲取 map 類型數據的實現算法。

第二部分講的是 JSON 格式數據的參數解析過程,在 Gin 內部提供了多種用於解析不一樣格式參數的綁定引擎,其共同實現了 binding.Binding 接口,因此其餘格式數據的請求參數解析也是與此相似的,不一樣的地方僅僅是將數據從請求體中綁定至指針變量中使用的解析算法不同而已;而後還講了 Gin 額外提供了另一個可供選擇的 JSON 解析庫 json-iterator,其適用於對性能有較高要求的場景,而且介紹了 Go 提供的條件編譯的實現方式。

本系列的下一篇文章將對 Gin 中路由匹配機制的實現進行講解,至此,Gin 源碼學習的第二篇也就到此結束了,感謝你們對本文的閱讀~~

相關文章
相關標籤/搜索