所有文章 > API设计 > 从gin框架看如何构建自己的http服务框架

从gin框架看如何构建自己的http服务框架

一、 背景

gin框架作为一个普遍使用的http服务开源框架,为了能更好地使用它,我们有必要对gin框架有个清晰的认识,了解框架中请求处理流程、中间件执行等细节。gin框架基于golang基础库 net/http 进行开发,先了解 net/http 的运行机制,对理解gin框架有很好的帮助。所以该文分为两大部分:net/http源码解读和gin框架源码解读。

二、 基础概念

理解http服务(又称api服务web服务),我们先要对几个概念有个大概的了解:

  • Request:用来解析用户的请求信息,包括method、query、body、cookie和url等信息
  • Response:服务端返回给客户端的信息
  • Connection:用户的每次请求
  • Handler:处理请求并生成返回信息的处理函数
  • Router:路由根据path找到对应的Handler并进行执行

一个http服务启动后,运行流程如下图所示:

在接受requst的过程中,核心是router,目的是为了找到path对应的处理函数handler。router实现在Multiplexer(复用器)中,golang中Multiplexer的实现基于 ServeMux 结构。

三、 net/http 库

注:本文基于go1.16版本分析,注意不同版本存在的差异

流程图

一种简单的实现方式

main函数中间两行代码分别实现了路由注册 和 启动服务 功能。其中 启动服务http.ListenAndServe 的实现如下:首先创建一个Server实例 server,然后调用server的同名方法ListenAndServe。代码如下

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

type Server struct {
Addr string
Handler Handler
...
}

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

在Server结构体中最关键的字段 Handler 是一个interface。任何结构体,只要实现了ServeHTTP方法,就可以称之为Handler对象。通过后面流程我们可以知道,处理client请求的协程正是调用了Server.Handler的ServeHTTP方法去处理业务逻辑。因此一种最简单的实现http请求调用的方式是,我们将业务处理函数包装成Handler对象直接通过http.ListenAndServe方法的第二个参数传入,代码如下


func main() {
http.ListenAndServe("127.0.0.0:8000", Hello{})
}

type Hello struct{}

func (*Hello) ServeHTTP(w http.ResponseWriter, r *http.Request) {
fmt.Println("hello world")
}

Multiplexer和路由注册

上面这种实现方法有个显而易见的问题:所有请求都执行同一个handler,没有办法根据不同path去进行不同处理。但http框架的核心功能就是能进行 路由注册 和 路由查找, ServeMux 结构体正是用来解决这个痛点的。我们关注到ServeMux有一个结构为map的m字段,m的key为url,value为muxEntry结构,而在muxEntry中定义存储了具体的url和handler函数。所有跟业务相关的path和handler映射信息都是通过m存在ServeMux中。

type ServeMux struct {
mu sync.RWMutex
m map[string]muxEntry
es []muxEntry // slice of entries sorted from longest to shortest.
hosts bool // whether any patterns contain hostnames
}

type muxEntry struct {
h Handler
pattern string
}

现在看一下http.HandleFunc方法如何实现注册路由。这里引入ServeMux的实例DefaultServeMux对象(在后面的路由查找中也会用到它),从流程图不难看出http.HandleFunc方法通过它调用ServeMux的同名方法HandleFunc,内部调用ServeMux.Handle方法,完成实际的路由注册功能,代码如下

// http.HandleFunc
func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}

func (mux *ServeMux) HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
if handler == nil {
panic("http: nil handler")
}
mux.Handle(pattern, HandlerFunc(handler))
}

func (mux *ServeMux) Handle(pattern string, handler Handler) {
mux.mu.Lock()
defer mux.mu.Unlock()
...
e := muxEntry{h: handler, pattern: pattern}
mux.m[pattern] = e
...
}

那么在ServeMux.HandleFunc中为啥要将传入的handler封装成HandlerFunc呢?在前文我们知道处理client请求的协程通过Server.Handler处理业务逻辑,而为了能在路由查找之后直接调用该handler,net包使用了适配器模式 对带有func(ResponseWriter, *Request)签名的处理函数进行统一封装。从下面的代码可以看到,HandlerFunc实现了Handler interface,它的ServeHTTP方法正是执行HandlerFunc方法本身。

type HandlerFunc func(ResponseWriter, *Request)
// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}

路由查找和处理请求

注册好路由之后,我们该如何在第二步启动服务中实现路由查找呢?net包中的做法是让ServerMux实现Handler interface,在ServerMux.ServerHttp中封装路由查找和处理函数的执行。这么做是采用了装饰模式 ,能将ServerMux作为参数传入Server.Handler中,从而实现更简洁地调用。从流程图我们可以看到,在http.ListenAndServe方法中如果不传入handler,后面Server对象会使用DefaultServeMux作为默认的Handler,从而通过它调用ServerMux.ServerHttp方法实现路由查找并执行。代码如下:

func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
...
h, _ := mux.Handler(r)
h.ServeHTTP(w, r)
}

func (mux *ServeMux) Handler(r *Request) (h Handler, pattern string) {
...
return mux.handler(host, r.URL.Path)
}

func (mux *ServeMux) handler(host, path string) (h Handler, pattern string) {
...
if mux.hosts {
h, pattern = mux.match(host + path)
}
if h == nil {
h, pattern = mux.match(path)
}
...
}

func (mux *ServeMux) match(path string) (h Handler, pattern string)
v, ok := mux.m[path]
if ok {
return v.h, v.pattern
}
for _, e := range mux.es {
if strings.HasPrefix(path, e.pattern) {
return e.h, e.pattern
}
}
return nil, ""
}

让我们看一下在第二步启动服务中都做了些什么?http.ListenAndServe方法创建了一个Server对象,并且调用Server.ListenAndServe。该方法会初始化监听地址Addr,同时调用Listen方法设置监听。最后将监听的TCP对象传入Server.Serve方法。

func (srv *Server) ListenAndServe() error {
if srv.shuttingDown() {
return ErrServerClosed
}
addr := srv.Addr
if addr == "" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr)
if err != nil {
return err
}
return srv.Serve(ln)
}

Serve方法主要职能是创建一个上下文对象,然后调用Listen的Accept方法用来获取连接数据并用newConn方法创建连接对象。最后起个goroutine处理连接请求。因为每个连接都起了一个协程,请求的上下文不同,同时又保证了go的高并发。

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

newConn创建的实例调用自己的serve方法,完成后面的逻辑处理。serve方法使用defer定义了函数退出时连接关闭的相关处理,然后读取连接的数据并处理读取完毕时的状态,其中核心部分是接下来调用的 serverHandler{c.server}.ServeHTTP(w, w.req) 处理请求。(方法太长,这里就不放源码了) serverHandler只有Server结构一个字段,方法中传入的是在服务启动时创建的Server实例。它调用自己的ServeHTTP方法,并在该接口方法中做了一个重要的事情:初始化Handler。如果server对象没有指定Handler,则使用DefaultServeMux作为默认值,并调用该Handler的ServeHTTP方法执行业务逻辑。就像在前文说到的,net包正是将路由查找和业务函数执行封装在ServeMux.ServeHTTP方法中,让我们可以通过不指定Handler的方式直接使用它。至此一个client请求的处理就已经完成了。

type serverHandler struct {
srv *Server
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
...
handler.ServeHTTP(rw, req)
}

总结

net包通过http.HandlerFunc将路由和处理函数进行绑定,添加到DefaultServeMux的m字段里;在http.ListenAndServe方法中默认通过DefaultServeMux对象调用ServeMux.ServeHTTP来实现路由查找并执行handler。同时net包也暴露给开发者Handler interface这个http服务的核心入口,让开发者可以定义一个结构体实现interface来替换掉DefaultServeMux,这样就能在自定义的结构体中实现更加丰富的功能。开发者需要做的仅仅是在在服务启动的时候将自定义结构体作为handler参数传入。接下来要介绍的gin框架正是这么实现的。

四、gin框架

虽然我们可以通过net/http实现一个简单的具备路由功能的http服务,但是net/http本身提供的功能比较简单,不支持用户以中间件的形式自定义能力。而且该包暴露的函数签名参数是(w http.ResponseWriter, req *http.Request),开发者解析请求和回写结果都不是很方便,因此产生了很多优秀的http框架。其中就有我们的主角gin框架,gin框架具体有以下特点:

  • 使用是基于字典树的httprouter,路由查找效率高且节省存储空间
  • 支持自定义中间件,能对路由组设置中间件,使用上方便灵活
  • 功能强大的Context贯穿整个请求,内置各种数据绑定和响应形式
  • 通过对象池管理Context,减轻GC压力,提升系统性能
  • ……

流程图

图中蓝色部分为net/http内部逻辑,绿色部分为gin框架实现逻辑,通过这个图可以很好地理解net/http和gin框架的边界,清楚gin框架在处理流程中所做的事情。从gin框架图我们发现实际上框架核心逻辑就是实现一个以Engine结构为核心的路由,用它代替了 net/http 包的 DefaultServeMux并实现其逻辑。整个路由注册和请求处理的流程在上文中有比较清晰的阐述,所以接下来的重点放在理解gin框架的核心Engine结构,其中包括context、router tree、RouterGroup、中间件与请求链条等部分。在接下来的介绍中,我们能够对一个成熟的http服务框架所具备的能力以及它们的实现有个比较清晰的认知,对之后搭建自己的http服务框架有个好的参考。

Engine

Engine是gin框架框架的入口,是框架的核心发动机。我们通过Engine对象来进行服务路由注册和查找、组装业务处理函数和中间件、进行路由组的管理。这几部分的能力都是通过其核心字段trees和RouteGroup实现的。

// Default returns an Engine instance with the Logger and Recovery middleware already attached.
func Default() *Engine {
debugPrintWARNINGDefault()
engine := New()
engine.Use(Logger(), Recovery()) // 默认加载日志和异常处理两个中间件
return engine
}

// New returns a new blank Engine instance without any middleware attached.
func New() *Engine {
debugPrintWARNINGNew()
// 嵌套RouterGroup,实现路由相关的注册
engine := Engine{
RouterGroup: RouterGroup{
Handlers: nil,
basePath: "/",
root: true,
},
...
// 路由树,根据路径快速查找handlers,对于九种http请求方法分别生成单独的路由树
trees: make(methodTrees, 0, 9),
...
}
// RouterGroup嵌套Engine结构,能调用engine的方法
engine.RouterGroup.engine = engine
// 通过对象池管理Context,减轻GC压力,提升系统性能
engine.pool.New = func() interface{} {
return engine.allocateContext()
}
return engine
}

// 添加路由
func (engine *Engine) addRoute(method, path string, handlers HandlersChain)

Engine对象包含一个addRoute方法用于添加URL请求处理器,它会将请求对应的路径和处理器挂接到相应的请求树中。Engine通过RouterTree进行路由的注册和查找。

RouterTree

在gin框架中,Engine.trees 使用是基于字典树的httprouter,路由查找效率高且节省存储空间。路由规则被分成了最多9棵前缀树,每一个HTTP Method(POST/GET/…)对应一棵前缀树,树的节点按照URL中的/符号进行层级划分,URL支持 :name 形式的名字匹配。

之所以这么设计,在httprouter的README.md中是这么描述的:由于 URL 路径具有分层结构并且仅使用有限的一组字符,因此会存在许多公共前缀。这使我们能够轻松地将路由拆分为更小的部分。此外路由器为每个请求方法管理一个单独的树,一方面它比在每个节点中保存一个方法去映射更节省空间;另一方面它还允许我们在开始查找前缀树之前减少很多路由问题。

type methodTree struct {
method string
root *node
}

type node struct {
path string
indices string
wildChild bool
nType nodeType
priority uint32
children []*node // child nodes, at most 1 :param style node at the end of the array
handlers HandlersChain
fullPath string
}

// HandlerFunc defines the handler used by gin middleware as return value.
type HandlerFunc func(*Context)

// HandlersChain defines a HandlerFunc array.
type HandlersChain []HandlerFunc

每个节点除了保存按/符号分割的URL的某段path之外,还会挂接若干请求处理函数构成一个请求处理链 HandlersChain。每当对请求进行路由查找时,在这棵树上找到的请求URL对应的节点,拿到对应的请求处理链进行组装,等待之后的执行。

RouterGroup

我们经常会遇到类似的场景,需要基于版本或者模块将相同前缀的路由放在一起,方便使用。而Engine对象通过RouterGroup对路由实现了分组管理,并且支持分组嵌套和对组设置中间件。RouterGroup是对路由树的包装,所有的路由规则最终都是由它来进行管理的。Engine结构体继承了RouterGroup,所以Engine直接具备了RouterGroup所有的路由管理功能。同时RouterGroup对象里还会包含一个Engine的指针,可以调用engine的addRoute方法。

type Engine struct {
RouterGroup
...
}

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

type IRouter interface {
Use(...HandlerFunc) IRoutes // 注册中间件

// Handle、Any等方法调用handle完成路由注册
Handle(string, string, ...HandlerFunc) IRoutes
Any(string, ...HandlerFunc) IRoutes
...

Group(string, ...HandlerFunc) *RouterGroup // 创建一个新的路由组
}

// 路由注册的入口方法
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()
}

// 拼接前缀路径
func (group *RouterGroup) calculateAbsolutePath(relativePath string) string {
return joinPaths(group.basePath, relativePath)
}

// 合并处理函数
func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
...
mergedHandlers := make(HandlersChain, finalSize)
copy(mergedHandlers, group.Handlers)
copy(mergedHandlers[len(group.Handlers):], handlers)
return mergedHandlers
}

RouterGroup实现了IRouter接口,暴露了一系列路由方法,这些方法最终都是通过调用Engine.addRoute方法将请求处理器挂接到路由树中。RouterGroup内部有一个前缀路径字段basePath,它会调用calculateAbsolutePath方法将所有的子路径都加上这个前缀再放进路由树中。有了这个前缀路径,就可以实现URL分组功能。RouterGroup调用combineHandlers方法将分组嵌套下所有组维度设置的中间件和请求处理函数进行组装成handlers。

中间件与请求链

在gin框架中插件和业务处理函数形式是一样的,都是func(*Context),函数链前面的是插件函数,业务处理函数在链的最尾端。当我们定义路由时,gin框架会将插件函数和业务处理函数合并在一起形成链条结构HandlersChain。

type Context struct {
...
handlers HandlersChain
index int8
...
}

// 挨个调用函数链中的处理函数
func (c *Context) Next() {
c.index++
for c.index < int8(len(c.handlers)) {
c.handlers[c.index](c)
c.index++
}
}

const abortIndex int8 = math.MaxInt8 >> 1

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

gin框架在接收到客户端请求后,通过路由树找到相应的处理链,构造一个Context对象,再调用它的Next()方法进入请求处理流程。

gin支持Abort()方法中断请求链的执行,它的原理是将Context.index调整到一个比较大的数字,这样Next()方法中的调用循环就会立即结束。因为执行Abort()方法之后,需要让当前函数内后面的代码逻辑继续执行,所以不能通过panic的方式或者事件等方式中断执行流。如果在插件中显示调用Next()方法,那么它就改变了正常的执行顺序执行流,会嵌套执行流,嵌套执行流是让后续的处理器在前一个处理器进行到一半的时候执行,等后续处理器完成执行后,再回到前一个处理器继续往下执行。

Context

gin框架会为每个请求分配单独的Context,其中包含了请求的参数、响应、engine、handlers等全部上下文信息,并且Context会贯穿这次请求的所有流程。由于分配给每个请求Context,当百万并发到来时,频繁的创建对象会给golang的GC带来非常大的压力,因此gin框架就利用sync.Pool将Context对象复用起来。

type Context struct {
writermem responseWriter
Request *http.Request // 请求对象
Writer ResponseWriter // 响应对象

Params Params // URL路径匹配参数
handlers HandlersChain // 需要处理的请求链
index int8
fullPath string

engine *Engine
params *Params
skippedNodes *[]skippedNode
mu sync.RWMutex

Keys map[string]interface{} // 自定义上下文信息
Errors errorMsgs // 函数链记录的每个handler的错误信息
Accepted []string

queryCache url.Values
formCache url.Values
sameSite http.SameSite
}

Context对象提供了非常丰富的方法用于获取当前请求的上下文信息,提供了很多内置的数据绑定和响应形式,其中包括JSON、HTML、Protobuf、MsgPack、Yaml等,它会为每一种形式都单独定制一个渲染器。所有的渲染器最终调用内置的http.ResponseWriter(Context.Writer)将响应对象转换成字节流写到socket中。

// ServeHTTP conforms to the http.Handler interface.
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)
}

Engine.ServeHTTP方法中,每次响应请求都会先从临时对象池中取一个context对象,使用完之后再放回取。需要注意这个context是从临时对象池中取出后再reset,而不是使用完之后reset。所以这个context可能会包含上一次请求的上下文信息,如果上一次请求开启新的协程使用context,那么新请求会reset这个context。如果需要在新协程里保留上下文信息,可以通过Context.Copy() copy这个context进行参数传递 。

文章转自微信公众号@IEG增长中台技术团队

#你可能也喜欢这些API文章!