解析 Gin 框架底層原理

0 前言

上一週和大家聊了 Golang HTTP 標準庫的實現原理. 本週在此基礎上做個延伸,聊聊 web 框架 Gin 的底層實現原理.

1 Gin 與 HTTP

1.1 Gin 的背景

Gin 是 Golang 世界裏最流行的 web 框架,於 github 開源:https://github.com/gin-gonic/gin

其中本文涉及到的源碼走讀部分,代碼均取自 gin tag:v1.9.0 版本.

支撐研發團隊選擇 Gin 作爲 web 框架的原因包括:

括號中的知識點將在後文逐一介紹.

1.2 Gin 與 net/http 的關係

上週剛和大家一起探討了 Golang net/http 標準庫的實現原理,正是爲本篇內容的學習打下鋪墊. 沒看過的同學建議先回到上篇內容中熟悉一下.

Gin 是在 Golang HTTP 標準庫 net/http 基礎之上的再封裝,兩者的交互邊界如下圖:

可以看出,在 net/http 的既定框架下,gin 所做的是提供了一個 gin.Engine 對象作爲 Handler 注入其中,從而實現路由註冊 / 匹配、請求處理鏈路的優化.

1.3 Gin 框架使用示例

下面提供一段接入 Gin 的示例代碼,讓大家預先感受一下 Gin 框架的使用風格:

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


func main() {
    // 創建一個 gin Engine,本質上是一個 http Handler
    mux := gin.Default()
    // 註冊中間件
    mux.Use(myMiddleWare)
    // 註冊一個 path 爲 /ping 的處理函數
    mux.POST("/ping", func(c *gin.Context) {
        c.JSON(http.StatusOK, "pone")
    })
    // 運行 http 服務
    if err := mux.Run(":8080"); err != nil {
        panic(err)
    }
}

2 註冊 handler 流程

2.1 核心數據結構

首先看下核心數據結構:

(1)gin.Engine

type Engine struct {
   // 路由組
    RouterGroup
    // ...
    // context 對象池
    pool             sync.Pool
    // 方法路由樹
    trees            methodTrees
    // ...
}
// net/http 包下的 Handler interface
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}


func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // ...
}

Engine 爲 Gin 中構建的 HTTP Handler,其實現了 net/http 包下 Handler interface 的抽象方法: Handler.ServeHTTP,因此可以作爲 Handler 注入到 net/http 的 Server 當中.

Engine 包含的核心內容包括:

9 種 http 方法展示如下:

const (
    MethodGet     = "GET"
    MethodHead    = "HEAD"
    MethodPost    = "POST"
    MethodPut     = "PUT"
    MethodPatch   = "PATCH" // RFC 5789
    MethodDelete  = "DELETE"
    MethodConnect = "CONNECT"
    MethodOptions = "OPTIONS"
    MethodTrace   = "TRACE"
)

(2)RouterGroup

type RouterGroup struct {
    Handlers HandlersChain
    basePath string
    engine *Engine
    root bool
}

RouterGroup 是路由組的概念,其中的配置將被從屬於該路由組的所有路由複用:

(3)HandlersChain

type HandlersChain []HandlerFunc


type HandlerFunc func(*Context)

HandlersChain 是由多個路由處理函數 HandlerFunc 構成的處理函數鏈. 在使用的時候,會按照索引的先後順序依次調用 HandlerFunc.

2.2 流程入口

下面以創建 gin.Engine 、註冊 middleware 和註冊 handler 作爲主線,進行源碼走讀和原理解析:

func main() {
    // 創建一個 gin Engine,本質上是一個 http Handler
    mux := gin.Default()
    // 註冊中間件
    mux.Use(myMiddleWare)
    // 註冊一個 path 爲 /ping 的處理函數
    mux.POST("/ping", func(c *gin.Context) {
        c.JSON(http.StatusOK, "pone")
    })
    // ...
}

2.3 初始化 Engine

方法調用:gin.Default -> gin.New

路由樹相關的內容見本文第 4 章;gin.Context 有關內容見本文第 5 章.

func Default() *Engine {
    engine := New()
    // ...
    return engine
}
func New() *Engine {
    // ...
    // 創建 gin Engine 實例
    engine := &Engine{
        // 路由組實例
        RouterGroup: RouterGroup{
            Handlers: nil,
            basePath: "/",
            root:     true,
        },
        // ...
        // 9 棵路由壓縮前綴樹,對應 9 種 http 方法
        trees:                  make(methodTrees, 0, 9),
        // ...
    }
    engine.RouterGroup.engine = engine     
    // gin.Context 對象池   
    engine.pool.New = func() any {
        return engine.allocateContext(engine.maxParams)
    }
    return engine
}

2.3 註冊 middleware

通過 Engine.Use 方法可以實現中間件的註冊,會將註冊的 middlewares 添加到 RouterGroup.Handlers 中. 後續 RouterGroup 下新註冊的 handler 都會在前綴中拼上這部分 group 公共的 handlers.

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

2.4 註冊 handler

以 http post 爲例,註冊 handler 方法調用順序爲 RouterGroup.POST-> RouterGroup.handle,接下來會完成三個步驟:

func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes {
    return group.handle(http.MethodPost, relativePath, handlers)
}
func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
    absolutePath := group.calculateAbsolutePath(relativePath)
    handlers = group.combineHandlers(handlers)
    group.engine.addRoute(httpMethod, absolutePath, handlers)
    return group.returnObj()
}

(1)完整路徑拼接

結合 RouterGroup 中的 basePath 和註冊時傳入的 relativePath,組成 absolutePath

func (group *RouterGroup) calculateAbsolutePath(relativePath string) string {
    return joinPaths(group.basePath, relativePath)
}
func joinPaths(absolutePath, relativePath string) string {
    if relativePath == "" {
        return absolutePath
    }


    finalPath := path.Join(absolutePath, relativePath)
    if lastChar(relativePath) == '/' && lastChar(finalPath) != '/' {
        return finalPath + "/"
    }
    return finalPath
}

(2)完整 handlers 生成

深拷貝 RouterGroup 中 handlers 和註冊傳入的 handlers,生成新的 handlers 數組並返回

func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
    finalSize := len(group.Handlers) + len(handlers)
    assert1(finalSize < int(abortIndex)"too many handlers")
    mergedHandlers := make(HandlersChain, finalSize)
    copy(mergedHandlers, group.Handlers)
    copy(mergedHandlers[len(group.Handlers):], handlers)
    return mergedHandlers
}

(3)註冊 handler 到路由樹

路由註冊方法 root.addRoute 的信息量比較大,放在本文第 4 章中詳細拆解.

func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
    // ...
    root := engine.trees.get(method)
    if root == nil {
        root = new(node)
        root.fullPath = "/"
        engine.trees = append(engine.trees, methodTree{method: method, root: root})
    }
    root.addRoute(path, handlers)
    // ...
}

3 啓動服務流程

3.1 流程入口

下面通過 Gin 框架運行 http 服務爲主線,進行源碼走讀:

func main() {
    // 創建一個 gin Engine,本質上是一個 http Handler
    mux := gin.Default()
    
    // 一鍵啓動 http 服務
    if err := mux.Run(); err != nil{
        panic(err)
    }
}

3.2 啓動服務

一鍵啓動 Engine.Run 方法後,底層會將 gin.Engine 本身作爲 net/http 包下 Handler interface 的實現類,並調用 http.ListenAndServe 方法啓動服務.

func (engine *Engine) Run(addr ...string) (err error) {
    // ...
    err = http.ListenAndServe(address, engine.Handler())
    return
}

順便多提一嘴,ListenerAndServe 方法本身會基於主動輪詢 + IO 多路複用的方式運行,因此程序在正常運行時,會始終阻塞於 Engine.Run 方法,不會返回.

func (srv *Server) Serve(l net.Listener) error {
   // ...
   ctx := context.WithValue(baseCtx, ServerContextKey, srv)
    for {
        rw, err := l.Accept()
        // ...
        connCtx := ctx
        // ...
        c := srv.newConn(rw)
        // ...
        go c.serve(connCtx)
    }
}

3.3 處理請求

在服務端接收到 http 請求時,會通過 Handler.ServeHTTP 方法進行處理. 而此處的 Handler 正是 gin.Engine,其處理請求的核心步驟如下:

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // 從對象池中獲取一個 context
    c := engine.pool.Get().(*Context)
    
    // 重置/初始化 context
    c.writermem.reset(w)
    c.Request = req
    c.reset()
    
    // 處理 http 請求
    engine.handleHTTPRequest(c)


    // 把 context 放回對象池
    engine.pool.Put(c)
}

Engine.handleHTTPRequest 方法核心步驟分爲三步:

此處根據 path 從路由樹尋找 handlers 的邏輯位於 root.getValue 方法中,和路由樹數據結構有關,放在本文第 4 章詳解;

根據 gin.Context.Next 方法遍歷 handler 鏈的內容放在本文第 5 章詳解.

func (engine *Engine) handleHTTPRequest(c *Context) {
    httpMethod := c.Request.Method
    rPath := c.Request.URL.Path
    
    // ...
    t := engine.trees
    for i, tl := 0, len(t); i < tl; i++ {
        // 獲取對應的方法樹
        if t[i].method != httpMethod {
            continue
        }
        root := t[i].root
        // 從路由樹中尋找路由
        value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
        if value.params != nil {
            c.Params = *value.params
        }
        if value.handlers != nil {
            c.handlers = value.handlers
            c.fullPath = value.fullPath
            c.Next()
            c.writermem.WriteHeaderNow()
            return
        }
        // ...
        break
    }
    // ...
}

4 Gin 的路由樹

4.1 策略與原理

在聊 Gin 路由樹實現原理之前,需要先補充一個壓縮前綴樹 radix tree 的基礎設定.

(1)前綴樹

前綴樹又稱 trie 樹,是一種基於字符串公共前綴構建索引的樹狀結構,核心點包括:

tries 樹在 leetcode 上的題號爲 208,大家感興趣不妨去刷刷算法題,手動實現一下.

(2)壓縮前綴樹

壓縮前綴樹又稱基數樹或 radix 樹,是對前綴樹的改良版本,優化點主要在於空間的節省,核心策略體現在:

倘若某個子節點是其父節點的唯一孩子,則與父節點進行合併

在 gin 框架中,是用壓縮前綴樹

(3)爲什麼使用壓縮前綴樹

與壓縮前綴樹相對的就是使用 hashmap,以 path 爲 key,handlers 爲 value 進行映射關聯,這裏選擇了前者的原因在於:

(4)補償策略

在 Gin 路由樹中還使用一種補償策略,在組裝路由樹時,會將註冊路由句柄數量更多的 child node 擺放在 children 數組更靠前的位置.

這是因爲某個鏈路註冊的 handlers 句柄數量越多,一次匹配操作所需要話費的時間就越長,被匹配命中的概率就越大,因此應該被優先處理.

4.2 核心數據結構

下面聊一下路由樹的數據結構,對應於 9 種 http method,共有 9 棵 methodTree. 每棵 methodTree 會通過 root 指向 radix tree 的根節點.

type methodTree struct {
    method string
    root   *node
}

node 是 radix tree 中的節點,對應節點含義如下:

type node struct {
    // 節點的相對路徑
    path string
    // 每個 indice 字符對應一個孩子節點的 path 首字母
    indices string
    // ...
    // 後繼節點數量
    priority uint32
    // 孩子節點列表
    children []*node 
    // 處理函數鏈
    handlers HandlersChain
    // path 拼接上前綴後的完整路徑
    fullPath string
}

4.3 註冊到路由樹

承接本文 2.4 小節第(3)部分,下述代碼展示了將一組 path + handlers 添加到 radix tree 的詳細過程,核心位置均已給出註釋,此處就不再贅述了,請大家盡情享用源碼盛宴吧!

// 插入新路由
func (n *node) addRoute(path string, handlers HandlersChain) {
    fullPath := path
    // 每有一個新路由經過此節點,priority 都要加 1
    n.priority++


    // 加入當前節點爲 root 且未註冊過子節點,則直接插入由並返回
    if len(n.path) == 0 && len(n.children) == 0 {
        n.insertChild(path, fullPath, handlers)
        n.nType = root
        return
    }


// 外層 for 循環斷點
walk:
    for {
        // 獲取 node.path 和待插入路由 path 的最長公共前綴長度
        i := longestCommonPrefix(path, n.path)
    
        // 倘若最長公共前綴長度小於 node.path 的長度,代表 node 需要分裂
        // 舉例而言:node.path = search,此時要插入的 path 爲 see
        // 最長公共前綴長度就是 2,len(n.path) = 6
        // 需要分裂爲  se -> arch
                        -> e    
        if i < len(n.path) {
        // 原節點分裂後的後半部分,對應於上述例子的 arch 部分
            child := node{
                path:      n.path[i:],
                // 原本 search 對應的參數都要託付給 arch
                indices:   n.indices,
                children: n.children,              
                handlers:  n.handlers,
                // 新路由 see 進入時,先將 search 的 priority 加 1 了,此時需要扣除 1 並賦給 arch
                priority:  n.priority - 1,
                fullPath:  n.fullPath,
            }


            // 先建立 search -> arch 的數據結構,後續調整 search 爲 se
            n.children = []*node{&child}
            // 設置 se 的 indice 首字母爲 a
            n.indices = bytesconv.BytesToString([]byte{n.path[i]})
            // 調整 search 爲 se
            n.path = path[:i]
            // search 的 handlers 都託付給 arch 了,se 本身沒有 handlers
            n.handlers = nil           
            // ...
        }


        // 最長公共前綴長度小於 path,正如 se 之於 see
        if i < len(path) {
            // path see 扣除公共前綴 se,剩餘 e
            path = path[i:]
            c := path[0]            


            // 根據 node.indices,輔助判斷,其子節點中是否與當前 path 還存在公共前綴       
            for i, max := 0, len(n.indices); i < max; i++ {
               // 倘若 node 子節點還與 path 有公共前綴,則令 node = child,並調到外層 for 循環 walk 位置開始新一輪處理
                if c == n.indices[i] {                   
                    i = n.incrementChildPrio(i)
                    n = n.children[i]
                    continue walk
                }
            }
            
            // node 已經不存在和 path 再有公共前綴的子節點了,則需要將 path 包裝成一個新 child node 進行插入      
            // node 的 indices 新增 path 的首字母    
            n.indices += bytesconv.BytesToString([]byte{c})
            // 把新路由包裝成一個 child node,對應的 path 和 handlers 會在 node.insertChild 中賦值
            child := &node{
                fullPath: fullPath,
            }
            // 新 child node append 到 node.children 數組中
            n.addChild(child)
            n.incrementChildPrio(len(n.indices) - 1)
            // 令 node 指向新插入的 child,並在 node.insertChild 方法中進行 path 和 handlers 的賦值操作
            n = child          
            n.insertChild(path, fullPath, handlers)
            return
        }


        // 此處的分支是,path 恰好是其與 node.path 的公共前綴,則直接複製 handlers 即可
        // 例如 se 之於 search
        if n.handlers != nil {
            panic("handlers are already registered for path '" + fullPath + "'")
        }
        n.handlers = handlers
        // ...
        return
}
func (n *node) insertChild(path string, fullPath string, handlers HandlersChain) {
    // ...
    n.path = path
    n.handlers = handlers
    // ...
}

呼應於 4.1 小節第(4)部分談到的補償策略,下面這段代碼體現了,在每個 node 的 children 數組中,child node 在會依據 priority 有序排列,保證 priority 更高的 child node 會排在數組前列,被優先匹配.

func (n *node) incrementChildPrio(pos int) int {
    cs := n.children
    cs[pos].priority++
    prio := cs[pos].priority




    // Adjust position (move to front)
    newPos := pos
    for ; newPos > 0 && cs[newPos-1].priority < prio; newPos-- {
        // Swap node positions
        cs[newPos-1], cs[newPos] = cs[newPos], cs[newPos-1]
    }




    // Build new index char string
    if newPos != pos {
        n.indices = n.indices[:newPos] + // Unchanged prefix, might be empty
            n.indices[pos:pos+1] + // The index char we move
            n.indices[newPos:pos] + n.indices[pos+1:] // Rest without char at 'pos'
    }




    return newPos
}

4.4 檢索路由樹

承接本文 3.3 小節,下述代碼展示了從路由樹中匹配 path 對應 handler 的詳細過程,請大家結合註釋消化源碼吧.

type nodeValue struct {
    // 處理函數鏈
    handlers HandlersChain
    // ...
}
// 從路由樹中獲取 path 對應的 handlers 
func (n *node) getValue(path string, params *Params, skippedNodes *[]skippedNode, unescape bool) (value nodeValue) {
    var globalParamsCount int16


// 外層 for 循環斷點
walk: 
    for {
        prefix := n.path
        // 待匹配 path 長度大於 node.path
        if len(path) > len(prefix) {
            // node.path 長度 < path,且前綴匹配上
            if path[:len(prefix)] == prefix {
                // path 取爲後半部分
                path = path[len(prefix):]
                // 遍歷當前 node.indices,找到可能和 path 後半部分可能匹配到的 child node
                idxc := path[0]
                for i, c := range []byte(n.indices) {
                    // 找到了首字母匹配的 child node
                    if c == idxc {
                        // 將 n 指向 child node,調到 walk 斷點開始下一輪處理
                        n = n.children[i]
                        continue walk
                    }
                }


                // ...
            }
        }


        // 倘若 path 正好等於 node.path,說明已經找到目標
        if path == prefix {
            // ...
            // 取出對應的 handlers 進行返回 
            if value.handlers = n.handlers; value.handlers != nil {
                value.fullPath = n.fullPath
                return
            }


            // ...           
        }


        // 倘若 path 與 node.path 已經沒有公共前綴,說明匹配失敗,會嘗試重定向,此處不展開
        // ...
 }

5 Gin.Context

5.1 核心數據結構

gin.Context 的定位是對應於一次 http 請求,貫穿於整條 handlersChain 調用鏈路的上下文,其中包含了如下核心字段:

type Context struct {
    // ...
    // http 請求參數
    Request   *http.Request
    // http 響應 writer
    Writer    ResponseWriter
    // ...
    // 處理函數鏈
    handlers HandlersChain
    // 當前處於處理函數鏈的索引
    index    int8
    engine       *Engine
    // ...
    // 讀寫鎖,保證併發安全
    mu sync.RWMutex
    // key value 對存儲 map
    Keys map[string]any
    // ..
}

5.2 複用策略

gin.Context 作爲處理 http 請求的通用數據結構,不可避免地會被頻繁創建和銷燬. 爲了緩解 GC 壓力,gin 中採用對象池 sync.Pool 進行 Context 的緩存複用,處理流程如下:

sync.Pool 並不是真正意義上的緩存,將其稱爲回收站或許更加合適,放入其中的數據在邏輯意義上都是已經被刪除的,但在物理意義上數據是仍然存在的,這些數據可以存活兩輪 GC 的時間,在此期間倘若有被獲取的需求,則可以被重新複用.

和對象池 sync.Pool 有關的內容可以閱讀我的文章 《Golang 協程池 Ants 實現原理》,其中有將對象池作爲協程池的前置知識點,進行詳細講解.

type Engine struct {
    // context 對象池
    pool             sync.Pool
}
func New() *Engine {
    // ...
    engine.pool.New = func() any {
        return engine.allocateContext(engine.maxParams)
    }
    return engine
}
func (engine *Engine) allocateContext(maxParams uint16) *Context {
    v := make(Params, 0, maxParams)
   // ...
    return &Context{engine: engine, params: &v, skippedNodes: &skippedNodes}
}

5.3 分配與回收時機

gin.Context 分配與回收的時機是在 gin.Engine 處理 http 請求的前後,位於 Engine.ServeHTTP 方法當中:

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    // 從對象池中獲取一個 context
    c := engine.pool.Get().(*Context)
    // 重置/初始化 context
    c.writermem.reset(w)
    c.Request = req
    c.reset()
    // 處理 http 請求
    engine.handleHTTPRequest(c)
    
    // 把 context 放回對象池
    engine.pool.Put(c)
}

5.4 使用時機

(1)handlesChain 入口

在 Engine.handleHTTPRequest 方法處理請求時,會通過 path 從 methodTree 中獲取到對應的 handlers 鏈,然後將 handlers 注入到 Context.handlers 中,然後啓動 Context.Next 方法開啓 handlers 鏈的遍歷調用流程.

func (engine *Engine) handleHTTPRequest(c *Context) {
    // ...
    t := engine.trees
    for i, tl := 0, len(t); i < tl; i++ {
        if t[i].method != httpMethod {
            continue
        }
        root := t[i].root        
        value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
        // ...
        if value.handlers != nil {
            c.handlers = value.handlers
            c.fullPath = value.fullPath
            c.Next()
            c.writermem.WriteHeaderNow()
            return
        }
        // ...
    }
    // ...
}

(2)handlesChain 遍歷調用

推進 handlers 鏈調用進度的方法正是 Context.Next. 可以看到其中以 Context.index 爲索引,通過 for 循環依次調用 handlers 鏈中的 handler.

func (c *Context) Next() {
    c.index++
    for c.index < int8(len(c.handlers)) {
        c.handlers[c.index](c)
        c.index++
    }
}

由於 Context 本身會暴露於調用鏈路中,因此用戶可以在某個 handler 中通過手動調用 Context.Next 的方式來打斷當前 handler 的執行流程,提前進入下一個 handler 的處理中.

由於此時本質上是一個方法壓棧調用的行爲,因此在後置位 handlers 鏈全部處理完成後,最終會回到壓棧前的位置,執行當前 handler 剩餘部分的代碼邏輯.

結合下面的代碼示例來說,用戶可以在某個 handler 中,於調用 Context.Next 方法的前後分別聲明前處理邏輯和後處理邏輯,這裏的 “前” 和“後”相對的是後置位的所有 handler 而言.

func myHandleFunc(c *gin.Context){
    // 前處理
    preHandle()  
    c.Next()
    // 後處理
    postHandle()
}

此外,用戶可以在某個 handler 中通過調用 Context.Abort 方法實現 handlers 鏈路的提前熔斷.

其實現原理是將 Context.index 設置爲一個過載值 63,導致 Next 流程直接終止. 這是因爲 handlers 鏈的長度必須小於 63,否則在註冊時就會直接 panic. 因此在 Context.Next 方法中,一旦 index 被設爲 63,則必然大於整條 handlers 鏈的長度,for 循環便會提前終止.

const abortIndex int8 = 63


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

此外,用戶還可以通過 Context.IsAbort 方法檢測當前 handlerChain 是出於正常調用,還是已經被熔斷.

func (c *Context) IsAborted() bool {
    return c.index >= abortIndex
}

註冊 handlers,倘若 handlers 鏈長度達到 63,則會 panic

func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
    finalSize := len(group.Handlers) + len(handlers)
    // 斷言 handlers 鏈長度必須小於 63
    assert1(finalSize < int(abortIndex)"too many handlers")
    // ...
}

(3)共享數據存取

gin.Context 作爲 handlers 鏈的上下文,還提供對外暴露的 Get 和 Set 接口向用戶提供了共享數據的存取服務,相關操作都在讀寫鎖的保護之下,能夠保證併發安全.

type Context struct {
    // ...
    // 讀寫鎖,保證併發安全
    mu sync.RWMutex


    // key value 對存儲 map
    Keys map[string]any
}
func (c *Context) Get(key string) (value any, exists bool) {
    c.mu.RLock()
    defer c.mu.RUnlock()
    value, exists = c.Keys[key]
    return
}
func (c *Context) Set(key string, value any) {
    c.mu.Lock()
    defer c.mu.Unlock()
    if c.Keys == nil {
        c.Keys = make(map[string]any)
    }


    c.Keys[key] = value
}

6 總結

對全文內容做個總結回顧:

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/x8i9HvAzIHNbHCryLw5icg