golang学习笔记02——gin框架及基本原理

目录

    • 1.前言
    • 2.必要的知识
    • 3.路由注册流程
      • 3.1 核心数据结构
      • 3.2 执行流程
      • 3.3 创建并初始化gin.Engine
      • 3.4 注册middleware
      • 3.5 注册路由及处理函数
        • (1)拼接完整的路径参数
        • (2)组合处理函数链
        • (3)注册完成路径及处理函数链到路由树
      • 3.6 服务端口监听
      1. 请求处理
      1. 请求绑定和响应渲染
      • 5.1. 请求绑定
      • 5.2 响应渲染
    • 结束语

1.前言

  • gin框架是golang中比较常见的web框架,截止到目前,github上已经累计了67.3K的star数,这足以表明其优秀。作为一名想要知其然亦想知其所以然的程序员,希望通过学习gin框架的实现原理来提高自己的技术能力,也希望通过分享来帮助想要进行学习的同学。

  • 框架源码地址: https://github.com/gin-gonic/gin

2.必要的知识

  • 其实golang本身的标准库已经足以实现简单的web服务,但是出于以下原因,使得直接使用标准库开发难以满足我们的需求:

    • 标准库本身提供了比较简单的路由注册能力,只支持精确匹配,而实际开发时难免会遇到需要使用通配、路径参数的场景
    • 标准库暴露给开发者的函数参数是(w http.ResponseWriter, req *http.Request),这就导致我们需要直接从请求中读取数据、反序列化,响应时手动序列化、设置Content-Type、写响应内容,比较麻烦
    • 有时候我们希望能够在不过多地侵入业务的前提下,对请求或响应进行一些前置或后置处理。直接基于标准库开发,业务和非业务代码难免会耦合在一起
  • 基于gin开发的一般流程可总结为:

    • 创建gin.Engine、注册middleware
    • 注册路由,编写处理函数,在函数内通过gin.Context获取参数,进行逻辑处理,通过gin.Context暴露的方法(如JSON())写回输出
    • 监听端口

相对于标准库的net/http简洁了很多,不用再关注响应内容的序列化和状态码问题了。

gin框架自身也是基于标准库net/http开发的,很多功能实现都是在标准库的基础上进行的封装,因此本文在剖析gin框架的过程中,点到为止,不会过多的对标准库的细节进行说明(后续会专门学习标准库的源码)。

3.路由注册流程

3.1 核心数据结构

使用gin开发前需要先调用gin.Default()函数,该函数返回一个*gin.Engine对象,该对象就是gin中的一个核心对象。

golang 复制代码
func Default() *Engine {
    debugPrintWARNINGDefault()
    engine := New()
    engine.Use(Logger(), Recovery())
    return engine
}

其实是先调用New方法创建了Engine对象,再调用Use注册middleware,这里先忽略。

  • gin.Engine
golang 复制代码
func New() *Engine {
  ...
    engine := &Engine{
        // NOTE: 实例化RouteGroup,路由管理相关(Engine自身也是一个RouterGroup)
        RouterGroup: RouterGroup{
            Handlers: nil,
            basePath: "/",
            root:     true,
        },
        ...
        // NOTE: 负责存储路由和处理方法的映射,采用类似字典树的结构(这里构造了几棵树,每棵树对应一个http请求方法)
        trees:            make(methodTrees, 0, 9),
        ...
    }
  ...
    // NOTE: 基于sync.Pool实现的context池,能够避免context频繁销毁和重建
    engine.pool.New = func() any {
        return engine.allocateContext(engine.maxParams)
    }
    return engine
}
  • 该结构中包含三个核心对象:
    • RouterGroup: 路由组,和路由管理相关
    • 路由树数组trees: 标准库本身的路由是不区分请求方法的,也就是说注册一个路由后,GET、POST都能匹配到该路由。这显然不是我们想要的,我们希望的是同一个路由在不同的请求方法下,由不同的逻辑进行处理。其实就是通过路由树实现的,gin的针对每个请求方法都有一棵路由树
    • context对象池: gin.Context是gin框架暴露给开发的另一个核心对象,可以通过该对象获取请求信息,业务处理的结果也是通过该对象写回客户端的。为了实现context对象的复用,gin基于sync.Pool实现了对象池

如果了解golang的http标准库,应该知道: http.ListenAndServe函数的第二个参数是一个接口类型,只要实现了该接口的ServeHTTP(ResponseWriter, *Request)方法,就能够对请求进行自定义处理。

golang 复制代码
type Handler interface {
    ServeHTTP(ResponseWriter, *Request)
}

gin.Engine对象其实就是该接口的一个实现,因为它实现了该方法。至于具体处理过程,后续会详细说明。

golang 复制代码
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  ...
}
  • 路由组RouterGroup

路由组的目的是为了实现配置的复用。

比如有一组对user的请求: /user/add、/user/get、/user/update等,我们希望在注册路由时尽量简单(不要每次都写/user),并且与user相关的请求使用一组单独的middleware(与其他对象的请求隔离开),这时候就可以使用路由组。

下面是其定义:

golang 复制代码
type RouterGroup struct {
    // 路由组处理函数链,其下路由的函数链将结合路由组和自身的函数组成最终的函数链
    Handlers HandlersChain
    // 路由组的基地址,一般是其下路由的公共地址
    basePath string
    // 路由组所属的Engine,这里构成了双向引用
    engine *Engine
    // 该路由组是否位于根节点,基于RouterGroup.Group创建路由组时此属性为false
    root bool
}

需要注意的是gin.Engine对象本身就是一个路由组。

  • 处理器链 HandlersChain

上述路由组对象中有一个很重要的字段,即Handlers,用于收集该路由组下注册的middleware函数。在运行时,会按顺序执行HandlersChain中的注册的函数。

golang 复制代码
type HandlerFunc func(*Context)

// HandlersChain defines a HandlerFunc slice.
// NOTE: 路由处理函数链,运行时会根据索引先后顺序依次调用
type HandlersChain []HandlerFunc

3.2 执行流程

一般情况下使用gin框架开发时使用默认的engine即可,因为相对于直接使用gin.New()创建Engine对象,它只是多注册了两个中间件。

下面是一般流程:

  • 创建并初始化Engine对象
  • 注册middleware
  • 注册路由及处理函数
  • 服务端口监听

3.3 创建并初始化gin.Engine

我们调用gin.Default创建一个默认的gin.Engine对象,其实际上会调用gin.New

golang 复制代码
func New() *Engine {
  ...
    engine := &Engine{
        // NOTE: 实例化RouteGroup,路由管理相关(Engine自身也是一个RouterGroup)
        RouterGroup: RouterGroup{
            Handlers: nil,
            basePath: "/",
            root:     true,
        },
        ...
        // NOTE: 负责存储路由和处理方法的映射,采用类似字典树的结构(这里构造了几棵树,每棵树对应一个http请求方法)
        trees:            make(methodTrees, 0, 9),
        ...
    }
  ...
    // NOTE: 基于sync.Pool实现的context池,能够避免context频繁销毁和重建
    engine.pool.New = func() any {
        return engine.allocateContext(engine.maxParams)
    }
    return engine
}

对以下对象进行初始化:

  • 创建根路径下的路由组
  • 创建九棵路由树
  • 初始化context对象池

3.4 注册middleware

gin.Default调用gin.New创建gin.Engine后,紧接着就会调用gin.Use函数进行middleware的注册。默认会注册Logger()和Recovery()这两个中间件函数。

golang 复制代码
func (engine *Engine) Use(middleware ...HandlerFunc) IRoutes {
    // NOTE: 将注册的中间件添加到RouterGroup的Handlers处理函数链中
    engine.RouterGroup.Use(middleware...)
    engine.rebuild404Handlers()
    engine.rebuild405Handlers()
    return engine
}

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

注册中间件其实就是将中间件处理函数添加到HandlersChain结构(HandlerFunc切片)中

3.5 注册路由及处理函数

golang 复制代码
mux.GET("/user", func(c *gin.Context) {
  m := map[string]string{
    "username": "用户名123",
  }
  c.JSON(http.StatusOK, m)
})

以我们的案例中的GET为例,这里的GET方式其实是gin.Engine对象的方法。

除了GET,http协议中的九个请求方法都在该对象中有一个同名的实现,这九个方法都是通过调用RouterGroup.handle方法实现的。

golang 复制代码
func (group *RouterGroup) POST(relativePath string, handlers ...HandlerFunc) IRoutes {
    return group.handle(http.MethodPost, relativePath, handlers)
}

// GET is a shortcut for router.Handle("GET", path, handlers).
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
    return group.handle(http.MethodGet, relativePath, handlers)
}
...

下面是handle方法的定义,该方法主要做了以下几件事:

  • 拼接完整的路径参数
  • 组合处理函数链
  • 注册完成路径及处理函数链到路由树
golang 复制代码
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)拼接完整的路径参数

这个很好理解,上面说过使用路由组之后,注册路由时不用每次都写前缀。比如/user/add、/user/get、/user/update这几个,路由组的路径是/user,基于该路由组注册路由时只需要注册/add、/get、/update就行了。其实就是在这里进行拼接的。

golang 复制代码
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)组合处理函数链

我们可以针对每个路由组单独设置middleware,实际执行时会先执行注册的中间件,最后才执行注册的业务处理函数。实现上,则是将路由组中注册的中间件和业务处理函数组合在一起。由于是按照顺序append到切片中的,所以执行顺序其实就是注册顺序。

golang 复制代码
func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
    // 构造新的切片,其长度为路由组过滤器链长度 + 路由的处理链长度
    finalSize := len(group.Handlers) + len(handlers)
    // 这里要求处理器链的长度最大为63,超过此长度注册路由会失败(Abort就是通过设置Index为63来提前中断处理器链的执行的)
    assert1(finalSize < int(abortIndex), "too many handlers")
    mergedHandlers := make(HandlersChain, finalSize)
    // 深拷贝路由组处理器链
    copy(mergedHandlers, group.Handlers)
    // 深拷贝路由处理器链
    copy(mergedHandlers[len(group.Handlers):], handlers)
    return mergedHandlers
}
(3)注册完成路径及处理函数链到路由树

前面说过gin针对每个http请求方法,都构造了一棵路由树。这里就需要根据注册路由的请求方法获取对应的路由树,再将路由的完整路径和对应的处理函数链注册到路由树中,后续才能根据请求路径调用对应的处理函数链进行处理。

golang 复制代码
func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
  ...
    // 每个请求方法(GET/POST...)都对应一棵前缀树,这里获取当前方法的前缀树
    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.6 服务端口监听

前面已经完成了接收请求前的准备工作,现在只差一步,即调用Engine.Run进行端口监听即可。

golang 复制代码
func (engine *Engine) Run(addr ...string) (err error) {
    defer func() { debugPrintError(err) }()
  ...
    err = http.ListenAndServe(address, engine.Handler())
    return
}

4. 请求处理

在3.1 - (1)中有说,由于Engine实现了http.ServeHTTP方法,所以http标准库收到请求后,对请求的处理入口其实就是Engine.ServeHTTP方法。

golang 复制代码
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {

    c := engine.pool.Get().(*Context)
    c.writermem.reset(w)
    c.Request = req
    c.reset()

    engine.handleHTTPRequest(c)

    engine.pool.Put(c)
}

其核心处理处理逻辑如下:

  • 从context对象池取一个可用的context对象,后续交互就是靠这个对象完成的
  • 将http.ResponseWriter和http.Request对象保存到context中。我们通过context获取请求参数、写入响应,其实是因为其底层封装了这两个对象的方法
  • 调用Engine的handleHTTPRequest方法,对请求进行处理。注意到其参数已经变成了gin.Context了。
  • 请求处理完毕,回收context,以便下次复用。

下面来看handleHTTPRequest的具体实现:

golang 复制代码
func (engine *Engine) handleHTTPRequest(c *Context) {
    httpMethod := c.Request.Method
    rPath := c.Request.URL.Path
    ...
    // Find root of the tree for the given HTTP method
    t := engine.trees
    for i, tl := 0, len(t); i < tl; i++ {
        // 根据http请求方法获取对应的路由树
        if t[i].method != httpMethod {
            continue
        }
        root := t[i].root
        // Find route in tree
        // 根据请求路径获取路由树节点信息,包括处理器链和路径
        value := root.getValue(rPath, c.params, c.skippedNodes, unescape)
        if value.params != nil {
            c.Params = *value.params
        }
        // 将处理器链注入到context中
        if value.handlers != nil {
            c.handlers = value.handlers
            c.fullPath = value.fullPath
            // NOTE: 开启 handlers 链的遍历调用流程
            c.Next()
            c.writermem.WriteHeaderNow()
            return
        }
        ...
        break
    }
  ...
}

前面讲到过,gin为每一个http请求方法创建了一棵路由树,每棵树保存了完整的路由路径和对应的处理器链。所以这部分逻辑其实是:

  • 根据当前客户端的请求方法,获取到对应的路由树。
  • 根据请求的路径在路由树中进行路径匹配,能够获取到路径参数和该路由的完整处理器链(包括预先设置的middleware处理函数),并保存到context对象中。有关路由树匹配的细节将在下一章节详细讲解。
  • 调用c.Next(),其实是开始按顺序调用处理器链中的每一个处理器,对请求进行处理。
  • 一般情况下,会在业务处理函数中调用context暴露的方法将响应写入到http输出流中。但是如果没调用,这里会帮忙做这件事(WriteHeaderNow),给客户端一个响应。代码如下:
golang 复制代码
func (w *responseWriter) WriteHeaderNow() {
    if !w.Written() {
        w.size = 0
        w.ResponseWriter.WriteHeader(w.status)
    }
}

func (w *responseWriter) Written() bool {
    return w.size != noWritten
}

上面说过,注册处理器时,会将所属RouterGroup注册的中间件函数和路由处理器组合在一个切片中。

由于采用的是append操作,所以注册的顺序就是实际执行的顺序。

正常情况下,注册的处理器会依次执行,通过context中的index字段控制执行进度,比如想要对请求进行一系列的前置操作。

也可以通过在处理器中调用c.Next()提前进入下一个处理器,待其执行完后再返回到当前处理器,这种比较适合需要对请求做前置和后置处理的场景,如请求执行时间统计。

golang 复制代码
func (c *Context) Next() {
    c.index++
    for c.index < int8(len(c.handlers)) {
        c.handlers[c.index](c)
        c.index++
    }
}

有时候我们可能会希望,某些条件触发时直接返回,不再继续后续的处理操作。Context提供了Abort方法帮助我们实现这样的目的。这也是通过index字段实现的,gin中要求一个路由的全部处理器个数不超过63,每次执行一个处理器时,会先判断index是否超过了这个限制,如果超过了就不会执行。如下:

golang 复制代码
func (c *Context) Abort() {
    c.index = abortIndex
}

const abortIndex int8 = math.MaxInt8 >> 1

5. 请求绑定和响应渲染

基于标准库开发时,我们可以从请求体中以字节流的方式读取请求内容,也可以将内容以字节流的方式写回去。但是会比较麻烦,

请求时我们需要基于请求的数据格式,决定应该怎样反序列化输入流、自己实现数据校验。

响应时,需要自己去序列化响应结构、设置content-type、写入响应流。

这几个过程不仅重复,而且需要多次判断error,最好是交给框架来做这件事,从而将开发的注意力集中在业务逻辑上。

5.1. 请求绑定

问题在于,从请求中读取的数据应该以什么类型组织呢,是string、int还是某个自定义的结构体?

为此gin提供了一系列的方式,用于从请求中获取参数和数据等信息,如常用的ShoudBindJson。

golang 复制代码
func (c *Context) ShouldBindJSON(obj any) error {
    return c.ShouldBindWith(obj, binding.JSON)
}

func (c *Context) ShouldBindWith(obj any, b binding.Binding) error {
    return b.Bind(c.Request, obj)
}

这里binding.Binding是一个接口,所有用于实现请求数据绑定的类型都应该实现这个接口。如上述调用的是jsonBinding,最终会使用json包的反序列化方法进行反序列化。

golang 复制代码
func (jsonBinding) Bind(req *http.Request, obj any) error {
    if req == nil || req.Body == nil {
        return errors.New("invalid request")
    }
    return decodeJSON(req.Body, obj)
}

func decodeJSON(r io.Reader, obj any) error {
    decoder := json.NewDecoder(r)
    if EnableDecoderUseNumber {
        decoder.UseNumber()
    }
    if EnableDecoderDisallowUnknownFields {
        decoder.DisallowUnknownFields()
    }
    if err := decoder.Decode(obj); err != nil {
        return err
    }
    return validate(obj)
}

反序列化完毕后,还涉及输入内容的校验,哪些字段必填、长度是否固定等,如果我们要在程序中判断,会比较繁琐。我们一般会采用 https://github.com/go-playground/validator 这个库的实现。实际上,gin也是基于这个库实现的。

golang 复制代码
var Validator StructValidator = &defaultValidator{}

type defaultValidator struct {
    once     sync.Once
    validate *validator.Validate
}

func validate(obj any) error {
    if Validator == nil {
        return nil
    }
    return Validator.ValidateStruct(obj)
}

5.2 响应渲染

除了文章开头案例中提到的JSON方法,gin还提供了针对以下类型的的处理方法:

text 复制代码
├── any.go
├── data.go
├── html.go
├── json.go
├── msgpack.go
├── protobuf.go
├── reader.go
├── redirect.go
├── render.go
├── text.go
├── toml.go
├── xml.go
└── yaml.go

以context.JSON方法为例:

golang 复制代码
func (c *Context) JSON(code int, obj any) {
    c.Render(code, render.JSON{Data: obj})
}

func (c *Context) Render(code int, r render.Render) {
    c.Status(code)

    if !bodyAllowedForStatus(code) {
        r.WriteContentType(c.Writer)
        c.Writer.WriteHeaderNow()
        return
    }

    if err := r.Render(c.Writer); err != nil {
        // Pushing error to c.Errors
        _ = c.Error(err)
        c.Abort()
    }
}

首先调用Status设置状态码,然后调用r.Render进行渲染。

golang 复制代码
func (c *Context) Status(code int) {
    c.Writer.WriteHeader(code)
}

这里r是一个接口类型,该类型用于对所有响应内容的方法进行抽象。需要实现的方法包括:

  • Render: 渲染方法,用于将响应内容写入到http.ResponseWriter中
  • WriteContentType: 用于设置响应头中的Content-Type
golang 复制代码
type Render interface {
    // Render writes data with custom ContentType.
    Render(http.ResponseWriter) error
    // WriteContentType writes custom ContentType.
    WriteContentType(w http.ResponseWriter)
}

以JSON类型为例。

Render其实就是基于json库将相应结构体序列化为字节数据,再写入http.ResponseWriter中。

golang 复制代码
func (r JSON) Render(w http.ResponseWriter) error {
    return WriteJSON(w, r.Data)
}

func WriteJSON(w http.ResponseWriter, obj any) error {
    writeContentType(w, jsonContentType)
    jsonBytes, err := json.Marshal(obj)
    if err != nil {
        return err
    }
    _, err = w.Write(jsonBytes)
    return err
}

WriteContentType则是直接将响应头设置为application/json.

golang 复制代码
jsonContentType = []string{"application/json; charset=utf-8"}

func writeContentType(w http.ResponseWriter, value []string) {
    header := w.Header()
    if val := header["Content-Type"]; len(val) == 0 {
        header["Content-Type"] = value
    }
}

结束语

由于篇幅较长,核心数据结构、gin.Context的讲解、前缀树、压缩前缀树和代码实现,我将放到下一篇文章《golang学习笔记03------gin框架的核心数据结构》中详细说明

本人技术水平有限,文章中可能存在不足和遗漏,如果有同学愿意一起学习golang和gin的代码,也可以留言补充,一起学习共同成长!

关注我,带你发现更多有意思的技术和应用~👉👉

相关推荐
技术小齐17 分钟前
网络运维学习笔记 016网工初级(HCIA-Datacom与CCNA-EI)PPP点对点协议和PPPoE以太网上的点对点协议(此处只讲华为)
运维·网络·学习
竹言笙熙26 分钟前
代码审计初探
学习·web安全
日记成书28 分钟前
物联网智能项目
物联网·学习
虾球xz1 小时前
游戏引擎学习第118天
学习·游戏引擎
gz927cool1 小时前
大模型做导师之开源项目学习(lightRAG)
学习·开源·mfc
电棍2332 小时前
verilog笔记
笔记·fpga开发
让我安静会2 小时前
Obsidian·Copilot 插件配置(让AI根据Obsidian笔记内容进行对话)
人工智能·笔记·copilot
世事如云有卷舒3 小时前
FreeRTOS学习笔记
笔记·学习
靡不有初1114 小时前
CCF-CSP第18次认证第一题——报数【两个与string相关的函数的使用】
c++·学习·ccfcsp
gu205 小时前
c#编程:学习Linq,重几个简单示例开始
开发语言·学习·c#·linq