这篇文章的启发是我在阅读Go的http源码时获得的,之前对这块缺乏深入的了解,这篇文章会结合源码讨论包括典型http request的路由,还会涉及到一些并发和中间件的issue。
我们先从一个简单的go server谈起,下面的代码从https://gobyexample.com/http-servers 截取:
| |
追踪请求的生命周期我们从http.ListenAndServe这个方法开始,下面的图示说明了这一层的调用关系:

这里实际上inlined了一些代码,因为初始的代码有很多其他的细节不好追踪。
主要的flow其实和我们预期的一致:ListenAndServe方法对你一个目标地址监听一个TCP端口,而后循环不断接受新的连接。每一个连接,它会起一个新的goroutine去serve,serve的具体操作是:
- 从连接里解析HTTP请求: 产生
http.Request - 将
http.Request传给用户自定义的handler
一个handler实际上就是实现了http.Handler接口:
| |
默认Handler
在我们上述的代码中,ListenAndServe方法的第二个参数为nil,实际上应该是用户自定义的handler, 这是为何?我们的图解中省去了很多细节,实际上当HTTP包serve一个请求的时候,它并没有直接调用用户的handlers而是使用一个adaptor:
| |
上述代码表示了,如果handler == nil, http.DefaultServeMux会作为默认的handler。这个default server mux是在http包中一个http.ServeMux类全局实例。而当我们的样例代码通过http.HandleFunc注册handlers的时候,同样会注册到default mux中。
所以我们可以重写我们的样例代码如下:
| |
ServeMux只是一个Handler
在看了很多Go的server例子以后,很容易会把ListenAndServe想象成把mux作为参数,但是这个明显是不准确的。从上面的例子看到,ListenAndServe实际传入的是实现了http.Handler接口的值,我们可以重写一下代码并且不用任何的muxes:
| |
这个snippet里面没有路由,所有的HTTP请求直接传进PoliteServer的ServeHTTP参数里,并且所有的请求都有相同的响应。可以尝试用不同的路径和方法去curl一下这个server。
然后我们再用http.HandlerFunc简化一下这个polite server:
| |
http.HadnlerFunc是http包里的一个很好用的adaptor:
| |
在这篇文章最开始的例子里,用到了http.HandleFunc,注意和http.HandlerFunc很像,但是他们是完全不同的实体,也承担着不同的任务。
如同PoliteServer表现的那样,http.ServeMux是实现http.Handler接口的一个类,这里查看源码
ServeMux维护了一个以长度排序的{pattern, handler}切片Handle或者HandleFunc向这个切片添加新的handlerServeHTTP:- 通过查询这个排序好的切片,找到对应请求path的handler
- 调用handler的
ServeHTTP方法
至此,mux可以被看作为一个forwarding handler,这种编程模式在HTTP server中很常见,也就是middleware。
http.Handler Middleware
如何去定义清楚middleware的含义是比较困难的,因为在不同的上下文、语言以及框架里它的概念都有一些不同。我们再看一下文章一开始的信息流图解,这里我们再简化一下,隐藏一些http包做的细节:

下面是我们增加了middleware以后的图解:

在Go中,middleware只是一个HTTP handler,而这个handler包了一个不同的handler。middleware handler通过调用ListenAndServe被注册,当这个middleware被调用到,他可以做任意的预处理,调用到被包的handler然后做任意的后处理。
我们在上面了解了一个middleware的例子–http.ServeMux, 在那个例子中,预处理指的是基于特定的请求path去选择用户定义的handler,然后去调用。并且没有对应的后处理。
举一个另外的例子,我们可以在polite server中加一个基本的logging middleware, 这个middleware能够对所有请求的的细节记录日志,包括了请求执行的时间等:
| |
请注意logging middleware其本身就是一个http.Handler包含了用户定义的handler作为一个field。当ListenAndServe调用其ServeHTTP方法的时候,做了以下的事情:
- 预处理: 在user handler被执行前打时间戳
- 调用user handler,传入请求体和response writer
- 后处理:日志记录请求细节,包括耗费的时间
middleware一个巨大的优点是composable(组合性),被middleware包着的handler可以是另一个middleware等等。所以这个是一个相互包裹的http.Handler链。实际上,这个是在Go中的常见模式,这个例子也像我们展现一个经典的Go middleware是怎么样的。下面是一个logging polite server的详细例子,写法上更容易辨认:
| |
这里省去了通过方法对结构体的创建,loggingMiddleware利用了http.HandlerFunc以及闭包让代码变得更为简洁,当然功能还是和前面代码相同。但是这个写法,彰显了一个middleware的标准特征:一个函数传入一个http.Handler以及其他状态,然后返回另一个http.Handler。被返回的handler可以视作传入middleware的handler的替代品,而且会magically执行middleware所拥有的功能。
例如,标准库里有如下的middleware:
| |
所以我们可以这样玩:
| |
这样就能创建一个2秒超时机制的handler了。
而middleware的组合可以由如下所示:
| |
仅仅两行,handler能够有超时和记录日志的功能,你或许会感觉middleware的链条写起来可能比较繁琐,不过Go有很多流行的包会解决这个问题,当然已经超出了这篇文章讨论的范围,后续我也会补充。
除此之外,http包本身也在按照其需求使用middleware,比如之前serverHandler适应器的例子,它能够使用非常简洁的手段去默认处理nilhandler的情况(通过把请求传给default mux)
因此,middleware可以说是一种attractive design aid,我们能够聚焦在业务逻辑handler,同时利用一般性的middleware去增强handler的功能,更多的探讨会新开一些文章。
并发和panic处理
最后我们来研究额外的两个主题:并发和panic处理,作为我们探究Go HTTP Server中HTTP请求路径问题的结尾。
首先关于并发的问题,前面讨论了对于每一个连接,其都由http.Server.Serve去起一个新的gorountine去处理。这利用了Go强大的并发能力,因为goroutine非常cheap并且这种简洁的并发模型对于HTTP handlers的处理也很适宜。一个handler可以阻塞(例如读取数据库)且不会停止其他handlers。不过在处理一些共享数据的goroutine并发时,还是要注意一些东西,这点我会在另外的文章谈。
最后,panic处理。HTTP Server一般来说是一个长期运行的程序。如果在一个用户定义的handler中发生了问题,例如一些导致runtime panic的bug,有可能会让整个server都挂掉。所以最好能够在main里用recover来保护你的server,不过这种方式还是有以下的问题:
- 当控制返回到
main中时,ListenAndServe已经结束了所以其他serving也结束了。 - 因为每一个独立的goroutine处理一个connection,handlers里的panic甚至不会到达
main而是挂掉整个进程。
为了防止这些问题,net/http内置了对每个goroutine的recovery(在conn.serve方法中),我们可以看一个例子:
| |
如果我们起这个server并且用/panic去curl:
$ curl localhost:8090/panic
curl: (52) Empty reply from server
server端会打下以下的日志:
2021/02/16 09:44:31 http: panic serving 127.0.0.1:52908: oops
goroutine 8 [running]:
net/http.(*conn).serve.func1(0xc00010cbe0)
/usr/local/go/src/net/http/server.go:1801 +0x147
panic(0x654840, 0x6f0b80)
/usr/local/go/src/runtime/panic.go:975 +0x47a
main.doPanic(0x6fa060, 0xc0001401c0, 0xc000164200)
[... rest of stack dump here ...]
当然server还在持续运行。
虽然这种内置的方式比挂掉整个进程好,不过开发者还是觉得这样有很多限制。它能做的只有关闭连接然后记录下日志,但是一般的情形下,最好给client端返回一些错误信息(例如错误码500等)。