本文会比较 Gin 与 Netty 以及 Spring 在一些设计上的异同。如果你没有 Java 开发经验,也完全可以略去对比 Java 的部分内容,并不影响理解本文。

1. Gin 启动过程过程概述

下面是使用 Gin 实现监听本机端口 2333,然后返回一个 hello world 字符串作为 HTTP 响应正文,最终返回给前端的最简单案例:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
package main

import (
	"net/http"

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

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

	r.GET("/", func(c *gin.Context) {
		c.String(http.StatusOK, "Hello World!")
	})

	r.Run(":2333")
}

如上的代码块所示,最简单的 Gin 启动逻辑分为三步:

  1. 得到一个 Engine 结构体实例,其代表 Gin 的执行引擎
  2. 配置 Engine 的路由规则,例如当用户访问 / 路径时,逻辑是简单地返回一个 “Hello World!” 作为 HTTP 响应
  3. 负责监听本地 TCP 2333 端口

如果你写过 Netty 后台程序,你会发现这与 Netty 后台应用的启动逻辑非常相似。案例来自于:Netty-Example-http-helloworld

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
package io.netty.example.http.helloworld;

import io.netty.bootstrap.ServerBootstrap;
import io.netty.channel.Channel;
import io.netty.channel.ChannelOption;
import io.netty.channel.EventLoopGroup;
import io.netty.channel.nio.NioEventLoopGroup;
import io.netty.channel.socket.nio.NioServerSocketChannel;
import io.netty.handler.logging.LogLevel;
import io.netty.handler.logging.LoggingHandler;
import io.netty.handler.ssl.SslContext;
import io.netty.handler.ssl.SslContextBuilder;
import io.netty.handler.ssl.util.SelfSignedCertificate;

/**
 * An HTTP server that sends back the content of the received HTTP request
 * in a pretty plaintext form.
 */
public final class HttpHelloWorldServer {

    static final boolean SSL = System.getProperty("ssl") != null;
    static final int PORT = Integer.parseInt(System.getProperty("port", SSL? "8443" : "8080"));

    public static void main(String[] args) throws Exception {
        // Configure SSL.
        final SslContext sslCtx;
        if (SSL) {
            SelfSignedCertificate ssc = new SelfSignedCertificate();
            sslCtx = SslContextBuilder.forServer(ssc.certificate(), ssc.privateKey()).build();
        } else {
            sslCtx = null;
        }

        // Configure the server.
        EventLoopGroup bossGroup = new NioEventLoopGroup(1);
        EventLoopGroup workerGroup = new NioEventLoopGroup();
        try {
            ServerBootstrap b = new ServerBootstrap();
            b.option(ChannelOption.SO_BACKLOG, 1024);
            b.group(bossGroup, workerGroup)
             .channel(NioServerSocketChannel.class)
             .handler(new LoggingHandler(LogLevel.INFO))
             .childHandler(new HttpHelloWorldServerInitializer(sslCtx));

            Channel ch = b.bind(PORT).sync().channel();

            System.err.println("Open your web browser and navigate to " +
                    (SSL? "https" : "http") + "://127.0.0.1:" + PORT + '/');

            ch.closeFuture().sync();
        } finally {
            bossGroup.shutdownGracefully();
            workerGroup.shutdownGracefully();
        }
    }
}

基于 Netty 的 HTTP 后台启动过程如下(过滤掉 SSL 的相关逻辑):

  • 配置基于 Reactor 模型的线程池 bossGroup 以及 workerGroup
  • 配置管道处理 Handler,包含了路由规则配置
  • 监听本地 TCP 8080 端口

得益于 Go runtime 对 NIO 网络通信逻辑的封装,Gin 框架并不需要自己实现基于 Reactor 模型的网络通信底层框架。

2. Gin 中的请求处理-拦截过滤器模式

Intercepting filter pattern 拦截过滤器模式在 Web 后端非常常见,一个请求的处理流程可能包含如下若干步骤:

  1. 日志
  2. Session/Cookie 逻辑
  3. 权限校验
  4. CRUD
  5. 异常处理
  6. 结果返回

同一路由下的大部分请求将经过完全相同的后端处理逻辑,因此使用过拦截过滤器模式非常合适。

或许,可能你还听说过责任链模式

责任链模式与拦截过滤器模式很接近,区别主要在于:一个请求(也被称为消息)在责任链(或者说管道 Pipeline)上从头向后传播,责任链上的每一个处理器只能处理特定的请求。如果某一个处理器不能处理该请求,通常是将该请求向后传播给其他处理器。如果能够处理,那么处理完成后,可以选择将处理后的结果继续向后传播,或者选择不再传播。总结对比来说:

  • 责任链模式:每一个处理器负责处理前序处理传过来的特定类型消息,每一个处理器能够处理的消息类型通常是不同的。
  • 拦截过滤器模式:每一个处理器作为整个处理链的一环,每一个处理器能处理的消息通常是同一个类型,就像流水线作业一般,通常在流水线末端才能够产出一个可用的商品。

拦截过滤器模式的案例如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
public class FilterChain {
   //过滤器链
   private List<Filter> filters = new ArrayList<Filter>();
 	 //接收结果的 Target 实例
   private Target target;
 	 //添加一个处理器
   public void addFilter(Filter filter){
      filters.add(filter);
   }
 	 //请求在过滤器链上遍历处理(还有一种方式是链式调用)
   public void execute(String request){
      for (Filter filter : filters) {
         filter.execute(request);
      }
      target.execute(request);
   }
 	//设置结果值
   public void setTarget(Target target){
      this.target = target;
   }
}

在 Gin 框架中利用拦截过滤器模式来处理路由来的请求。

gin.go 源码文件中 Engine.handleHTTPRequest 方法用于处理请求,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
func (engine *Engine) handleHTTPRequest(c *Context) {
	httpMethod := c.Request.Method
	rPath := c.Request.URL.Path
	unescape := false
	if engine.UseRawPath && len(c.Request.URL.RawPath) > 0 {
		rPath = c.Request.URL.RawPath
		unescape = engine.UnescapePathValues
	}

	if engine.RemoveExtraSlash {
		rPath = cleanPath(rPath)
	}

	// Find root of the tree for the given HTTP method
	t := engine.trees
	for i, tl := 0, len(t); i < tl; i++ {
		if t[i].method != httpMethod {
			continue
		}
		root := t[i].root
		// Find route in tree
		value := root.getValue(rPath, c.params, 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
		}
		if httpMethod != "CONNECT" && rPath != "/" {
			if value.tsr && engine.RedirectTrailingSlash {
				redirectTrailingSlash(c)
				return
			}
			if engine.RedirectFixedPath && redirectFixedPath(c, root, engine.RedirectFixedPath) {
				return
			}
		}
		break
	}

	if engine.HandleMethodNotAllowed {
		for _, tree := range engine.trees {
			if tree.method == httpMethod {
				continue
			}
			if value := tree.root.getValue(rPath, nil, unescape); value.handlers != nil {
				c.handlers = engine.allNoMethod
				serveError(c, http.StatusMethodNotAllowed, default405Body)
				return
			}
		}
	}
	c.handlers = engine.allNoRoute
	serveError(c, http.StatusNotFound, default404Body)
}

其中最关键的方法定义于 context.go 源码 Context.Next 方法,用于实现拦截器的遍历:

1
2
3
4
5
6
7
func (c *Context) Next() {
  c.index++
  for c.index < int8(len(c.handlers)) {
    c.handlers[c.index](c)
    c.index++
  }
}

可见,Context.Next 方法符合典型的拦截过滤器模式。这种设计与 Spring Interceptor 拦截器设计类似,在 HandlerExecutionChain 的 applyPreHandleapplyPostHandle 以及 triggerAfterCompletion 方法上都能看到拦截过滤器模式的应用。

其中 handlers 切片的元素类型为 HandlersChain 函数,定义如下:

1
2
type HandlerFunc func(*Context)
type HandlersChain []HandlerFunc

可见,Gin 中每一个 HandlersChain 元素相互并不感知彼此的存在,那么如果前序 HandlersChain 认为该请求不应当继续被后续 HandlersChain 处理,该如何实现?

Context.next() 方法中的 index 不是方法内的局部变量,而是 Context 实例内部字段。可见,我们只要控制此字段的值就能够控制 Next() 方法的执行逻辑。

Context.Abort() 方法能够确保将 index 值置为比 handlers 切片长度更大,这就确保了 Context.Next() 将无法满足 c.index < int8(len(c.handlers)) 条件,因而无法继续遍历。

1
2
3
4
5
const abortIndex int8 = math.MaxInt8 / 2

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

这种设计与责任链略有不同,责任链中的每一个处理器需要自己显式决定是否将继续将请求交给后续处理器,例如 Netty 的 ChannelHandlerContext.fireChannelRead,如果显式调用,就是希望将消息向后传播;否则就是拒绝向后传播,相当于执行了 Context.Aboirt() 方法。

3. 默认的中间件 Logger 以及 Recovery

(1)Logger 中间件

Logger 中间件是默认 Engine 自带的中间件,其本质是一个 HandlerFunc 类型的函数,其被 logger.go 中的 LoggerWithConfig 方法返回,如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
	formatter := conf.Formatter
	if formatter == nil {
		formatter = defaultLogFormatter
	}

	out := conf.Output
	if out == nil {
		out = DefaultWriter
	}

	notlogged := conf.SkipPaths

	isTerm := true

	if w, ok := out.(*os.File); !ok || os.Getenv("TERM") == "dumb" ||
		(!isatty.IsTerminal(w.Fd()) && !isatty.IsCygwinTerminal(w.Fd())) {
		isTerm = false
	}

	var skip map[string]struct{}

	if length := len(notlogged); length > 0 {
		skip = make(map[string]struct{}, length)

		for _, path := range notlogged {
			skip[path] = struct{}{}
		}
	}
	//这里返回一个 HandlerFunc 类型的函数
	return func(c *Context) {
		// Start timer
		start := time.Now()
		path := c.Request.URL.Path
		raw := c.Request.URL.RawQuery

		// Process request,日志依赖于此方法实现方法的计时逻辑
		c.Next()

		// Log only when path is not being skipped
		if _, ok := skip[path]; !ok {
			param := LogFormatterParams{
				Request: c.Request,
				isTerm:  isTerm,
				Keys:    c.Keys,
			}

			// Stop timer
			param.TimeStamp = time.Now()
			param.Latency = param.TimeStamp.Sub(start)

			param.ClientIP = c.ClientIP()
			param.Method = c.Request.Method
			param.StatusCode = c.Writer.Status()
			param.ErrorMessage = c.Errors.ByType(ErrorTypePrivate).String()

			param.BodySize = c.Writer.Size()

			if raw != "" {
				path = path + "?" + raw
			}

			param.Path = path

			fmt.Fprint(out, formatter(param))
		}
	}
}

可见,Logger 对应的 HandlerFunc 函数执行逻辑分为 3 步:

  1. 在请求被处理前记录一些参数,例如当前时间
  2. 利用 Context.Next() 方法进行拦截过滤器的逻辑调用
  3. 请求处理后记录一些参数,例如请求处理耗时,并打印相关日志

(2)Recovery 中间件

Gin 在其官网上说明其具有 Crash-free 的特点:

image-20210519130805793

这是如何实现的?

在 Go 中一个简单的 panic 处理如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func badCall() {
    panic("bad end")
}

func test() {
    defer func() {
        if e := recover(); e != nil {
            fmt.Printf("Panicing %s\r\n", e)
        }
    }()
    badCall()
    fmt.Printf("After bad call\r\n") // <-- wordt niet bereikt
}

Gin 作为一个 HTTP 框架,应当能够处理任何 panic,并进行 recover,因为 panic 进程停止运行是无法接受的。

Gin 提供一个 HandlerFunc 类型的方法用于捕获所有 panic,其注入 Engine 的逻辑可以从 Gin.Default() 函数出发。该 HandlerFunc 函数在 revobery.go 源码中的 CustomRecoveryWithWriter 方法负责提供,代码如下:

1
func CustomRecoveryWithWriter(out io.Writer, handle RecoveryFunc) HandlerFunc {	var logger *log.Logger	if out != nil {		logger = log.New(out, "\n\n\x1b[31m", log.LstdFlags)	}	return func(c *Context) {		defer func() {			if err := recover(); err != nil {				// Check for a broken connection, as it is not really a				// condition that warrants a panic stack trace.				var brokenPipe bool				if ne, ok := err.(*net.OpError); ok {					if se, ok := ne.Err.(*os.SyscallError); ok {						if strings.Contains(strings.ToLower(se.Error()), "broken pipe") || strings.Contains(strings.ToLower(se.Error()), "connection reset by peer") {							brokenPipe = true						}					}				}				if logger != nil {					stack := stack(3)					httpRequest, _ := httputil.DumpRequest(c.Request, false)					headers := strings.Split(string(httpRequest), "\r\n")					for idx, header := range headers {						current := strings.Split(header, ":")						if current[0] == "Authorization" {							headers[idx] = current[0] + ": *"						}					}					headersToStr := strings.Join(headers, "\r\n")					if brokenPipe {						logger.Printf("%s\n%s%s", err, headersToStr, reset)					} else if IsDebugging() {						logger.Printf("[Recovery] %s panic recovered:\n%s\n%s\n%s%s",							timeFormat(time.Now()), headersToStr, err, stack, reset)					} else {						logger.Printf("[Recovery] %s panic recovered:\n%s\n%s%s",							timeFormat(time.Now()), err, stack, reset)					}				}				if brokenPipe {					// If the connection is dead, we can't write a status to it.					c.Error(err.(error)) // nolint: errcheck					c.Abort()				} else {					handle(c, err)				}			}		}()		c.Next()	}}

HandlerFunc 函数的运行逻辑分为两步:

  1. 调用 defer 注册一个异常处理逻辑,将在 Context.Next() 返回后执行。
  2. 调用 Context.Next() 方法进行拦截过滤器模式的请求处理。

可见,Gin 框架利用上述逻辑来处理一切从 Context.Next() 方法中抛出的 panic 异常。

(3)链式调用模型与方法栈

在 Gin 中,默认的 Logger 以及 Recovery 中间件由于主动执行了 Context.Next() 函数,因此在方法栈上看,这两个 HandlerFunc 类型的方法会先后压入方法栈。这两个方法与 Context.handlers 切片中其余 HandlerFunc 类型的方法实际上是链式调用的关系。

Context.handlers 切片中的 HandlerFunc 类型的方法如果内部不主动调用 Context.Next() 函数,那么切片内部元素之间并没有形成链式调用关系,这避免了方法栈过深。如下图所示:

stack

拦截过滤器的两种遍历方式:链式调用以及基于数组的遍历,它们的优缺点如下

  • 链式调用
    • 优势:可以拿到后序处理器的处理情况,Logger 与 Recover 组件都需要后序处理的结果,因此选择链式调用。
    • 缺点:会增加方法栈深度,增加内存消耗(不考虑方法栈重用优化)。
  • 数组遍历
    • 优势:不会增加方法栈深度,goroutine 是轻量级协程,方法栈深度的下降有利于整个应用占用内存的下降
    • 缺点:无法得到后续处理器的执行情况,无法实现日志、panic 捕获等机制。

因此,Gin 的中间件如果要得到后续 HandlerFunc 方法的执行情况,可以选择显式调用 Context.Next(),否则不需要调用,Gin 会自动完成过滤器链的遍历。

4. Gin 的 Goroutine 模型

学习 Go 语言最令人痴迷的是其 Goroutine 的使用。出于如下两个目标,现代后台系统通常会选择 NIO 而不是 BIO 作为网络通信框架:

  • 避免大量创建线程,线程切换的代价很大
  • 能够处理海量的网络请求,避免浪费 CPU 资源

基于 NIO 衍生出了基于事件的 Reactor 网络通信模型,Netty 是一个典型代表。这种通信模型的优势是整个后台框架的运行效率非常高,但缺陷是对程序员要求高,编程难度大。Goroutine 的 runtime 巧妙地解决了这个问题,使我们可以像传统 BIO 那样直接写可以阻塞于网络读写的 I/O 逻辑,只不过传统 BIO 中阻塞的是线程,而 Go 阻塞的是 Goroutine。

Go 的 HTTP 后台框架模型可以表示为如下伪码:

1
2
3
4
5
6
7
//while 循环
for{
  //1.阻塞监听某一个端口,当有新连接到来时,停止阻塞
  conn := accept()
  //2.新建一个协程,负责该新连接的处理
  go ioProcess(conn)
}

下面我们看看 Gin 是如何做到的。

首先我们来看 Gin.Run 方法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (engine *Engine) Run(addr ...string) (err error) {
	defer func() { debugPrintError(err) }()

	trustedCIDRs, err := engine.prepareTrustedCIDRs()
	if err != nil {
		return err
	}
	engine.trustedCIDRs = trustedCIDRs
	address := resolveAddress(addr)
	debugPrint("Listening and serving HTTP on %s\n", address)
	err = http.ListenAndServe(address, engine)
	return
}

其中最关键的步骤是将 Gin 中的 Engine 结构体实例作为 Go 官方 http 包的 ListenAndServe 方法的入口参数:

1
func ListenAndServe(addr string, handler Handler) error {	server := &Server{Addr: addr, Handler: handler}	return server.ListenAndServe()}

Engine 结构体实际上实现了 http.Handler 接口:

1
type Handler interface {	ServeHTTP(ResponseWriter, *Request)}

其实现方式如下:

1
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {  //从缓存得到一个 Context 实例	c := engine.pool.Get().(*Context)  //进行相关结构体的重置,并与当前请求建立关系	c.writermem.reset(w)	c.Request = req	c.reset()	//这个方法负责处理请求	engine.handleHTTPRequest(c)	//归还缓存,由于 Recovery 中间件会负责处理 panic,因此这里总是可以执行到	engine.pool.Put(c)}

我们在本文第二章中已经提到了 Engine.handleHTTPRequest 是基于拦截过滤器调用逻辑实现的,这里就不在复述了。

Spring 一个常见的问题是:Spring 与 Tomcat 的关系是什么?

一个简单的回答是 Spring 的 DispatcherServlet 作为全局路由器被嵌入到 Tomcat 中,DispatcherServlet 的静态逻辑会负责加载 Spring 容器,最终提供 Spring Web 的各种功能。

那么 Gin 框架与 Go 官方提供的 http 包有什么关系?

首先,http 包中 Server 结构体的 ListenAndServe 会负责监听指定端口,当有新连接接入时,就会启动一个 goroutine 来处理此新连接。代码如下所示:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
func (srv *Server) Serve(l net.Listener) error {
	if fn := testHookServerServe; fn != nil {
		fn(srv, l) // call hook with unwrapped listener
	}

	origListener := l
	l = &onceCloseListener{Listener: l}
	defer l.Close()

	if err := srv.setupHTTP2_Serve(); err != nil {
		return err
	}

	if !srv.trackListener(&l, true) {
		return ErrServerClosed
	}
	defer srv.trackListener(&l, false)

	baseCtx := context.Background()
	if srv.BaseContext != nil {
		baseCtx = srv.BaseContext(origListener)
		if baseCtx == nil {
			panic("BaseContext returned a nil context")
		}
	}

	var tempDelay time.Duration // how long to sleep on accept failure

	ctx := context.WithValue(baseCtx, ServerContextKey, srv)
	for {
		rw, err := l.Accept()
		if err != nil {
			select {
			case <-srv.getDoneChan():
				return ErrServerClosed
			default:
			}
			if ne, ok := err.(net.Error); ok && ne.Temporary() {
				if tempDelay == 0 {
					tempDelay = 5 * time.Millisecond
				} else {
					tempDelay *= 2
				}
				if max := 1 * time.Second; tempDelay > max {
					tempDelay = max
				}
				srv.logf("http: Accept error: %v; retrying in %v", err, tempDelay)
				time.Sleep(tempDelay)
				continue
			}
			return err
		}
		connCtx := ctx
		if cc := srv.ConnContext; cc != nil {
			connCtx = cc(connCtx, rw)
			if connCtx == nil {
				panic("ConnContext returned nil")
			}
		}
		tempDelay = 0
		c := srv.newConn(rw)
		c.setState(c.rwc, StateNew, runHooks) // before Serve can return
    //启动一个 Goroutine 来处理新连接
		go c.serve(connCtx)
	}
}

conn 结构体实例代表一个新连接,其 serve 方法比较长,因为处理 HTTP 请求本身就涉及很多复杂的处理逻辑(例如 HTTP 版本就很多,还包括 HTTPS 的握手建立)。

不过最关键的是 conn.serve 方法会调用 http.Handler.ServeHTTP 方法,最终就会调用 Gin 框架中 Engine 对此接口的具体实现。源码比较长,不贴了,这里给出链接:https://github.com/golang/go/blob/release-branch.go1.16/src/net/http/server.go#L1952

5. SUMMARY

  • Gin 与基于 Netty 的 HTTP 后台启动逻辑类似,可以归纳为三步:初始化后台服务引擎、配置路由规则、监听后台端口
  • Gin 依赖于拦截过滤器模式来处理请求,过滤器在 Gin 中被称为中间件,其本质上是实现了 HandlerFunc 接口的任意函数。通过 Context.Next() 方法来实现请求在过滤器上的遍历处理,通过调用 Context.Abort() 方法能够提前结束遍历逻辑
  • Gin 拦截过滤器链中的默认的中间件是 Logger 以及 Recovery,它们分别用于实现日志以及 panic 捕获处理。我们还分析了拦截过滤器链两种执行方式的优缺点。
  • Gin 通过 Engine 实现 http.Handler 接口的 ServeHTTP 方法,注入到 http 包中。http 包会负责在新连接到来时,启动一个新的 goroutine。此 goroutine 会负责调用 Engine.ServeHTTP 方法,实现 HTTP 请求的逻辑处理。