hertz源码浅析 — 一次HTTP请求做了些啥

109 阅读3分钟

前言

hertz是公司开源的http框架,相比gin框架全面优化了协议、路由等核心模块的性能,如今也已经在cloudwego上开源(github.com/cloudwego),…

hertz的学习文档主要重点在于整体架构以及性能优化点上,而对于源码的解读文档目前较少,本文将聚焦hertz处理一次http请求的源码,分析hertz的协议层读写流程及路由树匹配流程。

启动流程简介

func main() {
   h := server.Default()
   h.StaticFS("/", &app.FS{Root: "./", GenerateIndexPages: true})

   h.GET("/ping", func(c context.Context, ctx *app.RequestContext) {
      ctx.JSON(consts.StatusOK, utils.H{"ping": "pong"})
   })
   ......

   h.Spin()
}

首先通过调用server.Default()方法创建一个新的Hertz结构体,结构体的代码注释为:

// Hertz is the core struct of hertz.

由此可见,该结构体就是整个hertz框架的核心,网络连接、路由、中间件等多种功能都是依据Hertz结构体展开的,下面看一下Hertz结构体:

type Hertz struct {
   *route.Engine
   signalWaiter func(err chan error) error
}

type Engine struct {
    ......
   // engine name
   Name       string
   serverName atomic.Value

   // Options for route and protocol server
   options *config.Options

   // route
   RouterGroup
   trees MethodTrees

   ......
   // RequestContext pool
   ctxPool sync.Pool
    ......
}

有gin框架基础的同学这里可以很明显地关注到三个模块:RouterGroup、trees、ctxPool,与gin中的核心数据结构完全吻合,即路由组、方法路由树、Context对象内存池,可以大胆猜测他们的核心功能大差不差。

第五行h.GET()方法即注册路由,这块儿主要是维护了压缩前缀树(radix tree)对路由进行注册,后面路由匹配时再将路由对应的节点挂载的handler拿出来并执行。

本文的核心在于hertz的http请求处理流程,我们从h.Spin()方法进行切入:

// Spin runs the server until catching os.Signal or error returned by h.Run().
func (h *Hertz) Spin() {
   errCh := make(chan error)
   h.initOnRunHooks(errCh)
   go func() {
      errCh <- h.Run()
   }()
   signalWaiter := waitSignal
   ......
   if err := signalWaiter(errCh); err != nil {
      ......
      return
   }
    ......
}

func (engine *Engine) Run() (err error) {
   ......
   return engine.listenAndServe()
}

func (engine *Engine) listenAndServe() error {
   ......
   return engine.transport.ListenAndServe(engine.onData)
}

第24行底层使用网络库netpoll,构建EventLoop并启动服务监听,并在接收客户端请求时,执行engine.onData所指代的方法。至此,http服务端启动完毕。

源码解读—请求处理流程

一次http请求的处理流程

(*Engine).onData方法包含了hertz的读、处理、写流程。在完成服务监听后,收到客户端请求就会执行onData来进行相应的流转,这里简化一下,只看HTTP1.1版本协议对应的处理流程:

func (engine *Engine) onData(c context.Context, conn interface{}) (err error) {
   switch conn := conn.(type) {
   case network.Conn:
      err = engine.Serve(c, conn)
   case network.StreamConn:
      err = engine.ServeStream(c, conn)
   }
   return
}

func (engine *Engine) Serve(c context.Context, conn network.Conn) (err error) {
   defer func() {
      errProcess(conn, err)
   }()
   ......
   // HTTP1 path
   err = engine.protocolServers[suite.HTTP1].Serve(c, conn)
   return
}

err = engine.protocolServers[suite.HTTP1].Serve(c, conn) 这行代码会调用到pkg.protocol.http1.Server.Service()方法,这块儿代码比较多,可总结为以下流程:

image.png

上述流程不包含Trace上报,步骤大致可概括为:

  1. 从内存池中获取RequestContext(简称ctx),这时的ctx一定是初始化过的,可直接拿来使用

  2. 对当前ctx进行基本属性参数赋值,并将抓取到的连接network.Conn赋值给ctx

  3. 拉起for true循环

    1. 判断参数connRequestNum,在每次循环该参数都会+1

      1. 若connRequestNum>1,则说明本连接为长连接,使用的底层网络库netpoll有个特性,即长连接需要保证两次数据报接受间隔在idleTimeout之内,故将当前读取超时时间设置为idleTimeout,并调用zeroCopyReader.Peek(),若未在idleTimeout之内peek到4个字节的数据,则抛出idlTimeoutError,退出本次ctx处理;若在idleTimeout之内peek到数据,则继续往下走

      2. 若connRequestNum<=1,则说明为首次循环,直接往下走

    2. 响应头基本属性信息设置

    3. 读请求头信息。若失败,则根据error决策是否抛出,并终止流程

    4. 读取请求体信息。若失败,则根据error决策是否抛出,并终止流程

    5. 检查请求头是否为"Expect:100-continue"的POST请求

      1. 是,则先发送"HTTP/1.1 100 Continue\r\n\r\n"到对等端,告知client端是否同意接收POST数据,若同意,然后再去读取客户端后续发送的请求数据

      2. 否,则此时本次所有请求数据已经读取至当前ctx.Request缓存中

    6. 调用Engine.ServeHTTP,处理接收到的请求

    7. 写ctx.Response,并将返回数据发送至对等端

    8. 收尾工作,如ctx初始化并放回context内存池、释放未释放的zeroCopyReader等

hertz处理核心方法 — Engine.ServeHTTP

Engine.ServeHTTP方法核心为路由寻址。在读取到请求信息后,hertz进行前缀树的路由匹配,并使用目标路径节点的handlerChain对http的处理进行增强封装。主要流程为:

whiteboard_exported_image.png

路由核心数据结构 — 路由树

路由寻址的核心在于路由树路径匹配方法(*router).find,即寻找当前http请求地址所映射到的handlerChain。在梳理其核心流程前,首先看下路由树的核心结构——压缩前缀树:

type MethodTrees []*router
type router struct {
   method        string
   root          *node
   hasTsrHandler map[string]bool
}

type (
   node struct {
      kind     kind      // 树节点的类型:static/param/any
      label    byte      // 标签 这里一般指代的是前缀prefix第一位 如param节点的label是":" any节点的label是"*"
      prefix   string    // 当前节点前缀
      parent   *node     // 父节点 用以实现寻址回溯
      children children  // 子节点
      // original path
      ppath string       // 当前节点绝对路径
      // param names
      pnames     []string            // 参数名 比如当前节点前缀为:id,则pnames为id
      handlers   app.HandlersChain   // 当前节点关联的handlerChain
      paramChild *node   // 参数子节点
      anyChild   *node   // 通配符子节点
      // isLeaf indicates that node does not have child routes
      isLeaf bool        // 是否为叶子节点(gin中标识为是否为根节点)
   }
   kind     uint8
   children []*node
)

压缩前缀树(radix tree)是对前缀树(trie tree)的改良版本,改良点主要在于,如果树子节点是其父节点的唯一子节点,那么将该对父子节点进行合并,如下图所示:

image

路由树寻址流程可概括为下:

whiteboard_exported_image (1).png

需要注意以下几点

  1. 树节点有三种类型,按匹配优先级排序为:static>param>any。

  2. 回溯方法返回的并不是真实的节点类型,而是回溯完毕之后需要使用什么逻辑进行处理。比如说,我们按照优先级static>param>any,static节点没有匹配到,进行回溯找到其父节点,这时虽然其static子节点没有成功匹配,但是可能还有param/any子节点,还是可以进行匹配的,所以此时就需要指定param/any的匹配逻辑去执行。

  3. 回溯方法会reset掉本次寻址已经加上的paramIdx、searchIdx等下标数据,并会回退掉search。paramIdx主要用于在对应下标下存储paramValue,而searchIdx主要用做回溯之后的search回退。

  4. 打标重定向后并不会强制结束流程,但后续匹配会失败,在外层做重定向处理。

路由树匹配方法到这里已经整明白了,至于路由树的注册流程本文就不再赘述了

总结

本文从源码角度总结了hertz的http处理流程以及路由树寻址流程,算是整体串了一下hertz的核心源码流程,供对http框架有兴趣的朋友们一同学习交流,更加深入的底层能力介绍以及性能优化实战可参考官方文档。