golang echo 框架路由分析

几个问题

在分析之前,带着问题去查找答案。

  • 官方 http 包已经提供了server的功能,为什么要用框架?

路由注册

简单的程序

我们来看看 echo 的三种匹配模式和优先级顺序匹配,优先级从下到下:

  • Static (固定路径) 类似于/users/new
  • Param (参数路径) 类似于/users/:id
  • Match any (匹配所有) 类似于/users/1/files/*

看到这些模式,http 自带的路由只支持 固定路径 和 匹配所有的模式。这也是提高的地方。

我们来写个例子覆盖 echo 路由所支持的模式,

package main

import (
    "fmt"
    "net/http"
    "time"

    "github.com/labstack/echo"
)

func main() {
    // Echo instance
    e := echo.New()

    // Routes
    e.GET("/", index)
    e.GET("/users/new", usersNew)
    e.GET("/users/", users)
    e.GET("/ups/", ups)
    e.GET("/users/:name", usersName)
    e.GET("/users/1/files/*", usersFiles)
    addr := fmt.Sprintf("%s:%s", "localhost", "1323")
    server := &http.Server{
        Addr:         addr,
        ReadTimeout:  5 * time.Second,
        WriteTimeout: 5 * time.Second,
    }

    // Start server
    e.Logger.Fatal(e.StartServer(server))
}

func index(c echo.Context) error {
    return c.String(http.StatusOK, "index~")
}

func usersNew(c echo.Context) error {
    return c.String(http.StatusOK, "userNew!")
}

func ups(c echo.Context) error {
    return c.String(http.StatusOK, "ups!")
}

func users(c echo.Context) error {
    return c.String(http.StatusOK, "user!")
}

func usersName(c echo.Context) error {
    name := c.Param("name")
    return c.String(http.StatusOK, fmt.Sprintf("%s, %s", "hi", name))
}

func usersFiles(c echo.Context) error {
    return c.String(http.StatusOK, "usersFiles!")
}

例子先放着,我们先往下看看路由结构。

路由结构

先来看看路由的结构,了解一下路由需要哪些信息。

type (
    // Router 结构是`Echo`实例注册路由的地方。路由树
    Router struct {
        tree   *node // 节点
        routes map[string]*Route // map 形式,Route 包含请求handler 和 匹配信息
        echo   *Echo
    }
    // Route 包含请求的 handler 和 用于匹配的信息。
    Route struct {
        Method string `json:"method"`
        Path   string `json:"path"`
        Name   string `json:"name"`
    }
    // 节点结构
    node struct {
        kind          kind // 路由类型skind 0(/echo/hi), pkind 1 (/users/:name),  akind 2(/orders/*)
        label         byte // prefix的第一个字符,根据label和kind来查找子节点
        prefix        string // 前缀
        parent        *node // 父节点
        children      children // 子节点,列表
        ppath         string // 原始路径
        pnames        []string // 路径参数只有类型为 1(:后面的的字段), 2([*])才有,
        methodHandler *methodHandler // 请求类型
    }
    kind          uint8
    children      []*node
    methodHandler struct {
        connect  HandlerFunc
        delete   HandlerFunc
        get      HandlerFunc
        head     HandlerFunc
        options  HandlerFunc
        patch    HandlerFunc
        post     HandlerFunc
        propfind HandlerFunc
        put      HandlerFunc
        trace    HandlerFunc
    }
)

Echo 的路由基于 radix tree ,它让路由的查询非常快,且使用 sync pool 来重复利用内存并且几乎达到了零内存占用。看路由的结构,跟字典树的结构一致,基数树就是字典树的一种优化结构。所以,通过请求来查找 handler 会比 http 提供的路由要快。在 http 的路由查找中是通过遍历方式的O(n),这里使用基数树O(K)的时间复杂度要好的多,同样比普通的Trie树的效率也要高。

路由绑定

以示例的代码,来看看是如何把路由信息装入到 Router 树的。


路由绑定

根据整个分析流程,我们先把整颗树还原成图像,这样更有利于了解。我们可以用广度优先搜索把树遍历出来。

// 广度层序打印节点
func (e *Echo) BfsShowRoute() {
    bfsTree(e.router.tree)
}

func bfsTree(n *node) error {
    var queue []*node
    queue = append(queue, n)
    for len(queue) > 0 {
        queueLen := len(queue)
        fmt.Println("----------------level----------------")
        for i := queueLen; i > 0; i-- {
            cn := queue[0]
            parentPrefix := ""
            if cn.parent != nil {
                parentPrefix = cn.parent.prefix
            }
            fmt.Println("kind:", cn.kind, ",lable:", string(cn.label), ",prefix:", cn.prefix, ",ppath:", cn.ppath, ",pnames:", cn.pnames, ",handler:", runtime.FuncForPC(reflect.ValueOf(cn.methodHandler.get).Pointer()).Name(), ",parent->", parentPrefix)
            if len(cn.children) > 0 {
                queue = append(queue, cn.children...)
            }
            queue = queue[1:len(queue)]
        }
    }
    return nil
}

在注册路由后,我们可以用上面的代码去遍历打印路由树,可以得到结果

----------------level----------------
kind: 0 ,lable: / ,prefix: / ,ppath: / ,pnames: [] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> 
----------------level----------------
kind: 0 ,lable: u ,prefix: u ,ppath:  ,pnames: [] ,handler:  ,parent-> /
----------------level----------------
kind: 0 ,lable: s ,prefix: sers/ ,ppath: /users/ ,pnames: [] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> u
kind: 0 ,lable: p ,prefix: ps/ ,ppath: /ups/ ,pnames: [] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> u
----------------level----------------
kind: 0 ,lable: n ,prefix: new ,ppath: /users/new ,pnames: [] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> u
kind: 1 ,lable: : ,prefix: : ,ppath: /users/:name ,pnames: [name] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> sers/
kind: 0 ,lable: 1 ,prefix: 1/files/ ,ppath:  ,pnames: [] ,handler:  ,parent-> sers/
----------------level----------------
kind: 2 ,lable: * ,prefix: * ,ppath: /users/1/files/* ,pnames: [*] ,handler: github.com/labstack/echo.(*Echo).Add.func1 ,parent-> 1/files/

根据结果我们可以还原出,我们这颗路由树的图形,


路由树

了解了整个路由树的构造之后,我们来看看这颗树是怎么构建的。

// 注册三要素 方法类型,路由path,handler函数
func (r *Router) Add(method, path string, h HandlerFunc) {
    // 校验是否空路径
    if path == "" {
        panic("echo: path cannot be empty")
    }
    // 规范路径
    if path[0] != '/' {
        path = "/" + path
    }
    pnames := []string{} // 路径参数
    ppath := path        // 原始路径
    // 按字符挨个遍历
    for i, l := 0, len(path); i < l; i++ {
        // 参数路径
        if path[i] == ':' {
            j := i + 1

            r.insert(method, path[:i], nil, skind, "", nil)
            // 找到参数路径的参数
            for ; i < l && path[i] != '/'; i++ {
            }
            // 把参数路径存入 pnames
            pnames = append(pnames, path[j:i])
            // 拼接路径 继续查找是否还有 参数路径
            path = path[:j] + path[i:]
            i, l = j, len(path)
            // 已经结束 插入参数路径节点
            if i == l {
                r.insert(method, path[:i], h, pkind, ppath, pnames)
                return
            }
            r.insert(method, path[:i], nil, pkind, "", nil)
        // 全量路径
        } else if path[i] == '*' {
            r.insert(method, path[:i], nil, skind, "", nil)
            // 全量参数都是"*"
            pnames = append(pnames, "*")
            r.insert(method, path[:i+1], h, akind, ppath, pnames)
            return
        }
    }
    // 普通路径
    r.insert(method, path, h, skind, ppath, pnames)
}

// 核心函数,构建字典树
func (r *Router) insert(method, path string, h HandlerFunc, t kind, ppath string, pnames []string) {
    // 调整最大参数
    l := len(pnames)
    if *r.echo.maxParam < l {
        *r.echo.maxParam = l
    }

    cn := r.tree // 当前节点 root
    if cn == nil {
        panic("echo: invalid method")
    }
    search := path

    for {
        sl := len(search)
        pl := len(cn.prefix)
        l := 0

        // LCP
        max := pl
        if sl < max {
            max = sl
        }
        // 找到共同前缀的位置 例如 users/ 和 users/new 的共同前缀为 users/ 
        for ; l < max && search[l] == cn.prefix[l]; l++ {
        }

        if l == 0 {
            // root 节点处理
            cn.label = search[0]
            cn.prefix = search
            if h != nil {
                cn.kind = t
                cn.addHandler(method, h)
                cn.ppath = ppath
                cn.pnames = pnames
            }
        } else if l < pl {
            // 分离共同前缀 users/ 和 users/new 创建一个 prefix为new 的节点 
            n := newNode(cn.kind, cn.prefix[l:], cn, cn.children, cn.methodHandler, cn.ppath, cn.pnames)

            // 重置父节点 prefix 为 users
            cn.kind = skind
            cn.label = cn.prefix[0]
            cn.prefix = cn.prefix[:l]
            // 清空该节点的孩子节点
            cn.children = nil
            cn.methodHandler = new(methodHandler)
            cn.ppath = ""
            cn.pnames = nil
            // 给该节点加上 prefix 为new的节点
            cn.addChild(n)

            if l == sl {
                // 如果是
                cn.kind = t
                cn.addHandler(method, h)
                cn.ppath = ppath
                cn.pnames = pnames
            } else {
                // 创建子节点
                n = newNode(t, search[l:], cn, nil, new(methodHandler), ppath, pnames)
                n.addHandler(method, h)
                cn.addChild(n)
            }
        } else if l < sl {
            search = search[l:]
            // 找到 lable 一样的节点,用 lable 来判断共同前缀
            c := cn.findChildWithLabel(search[0])
            if c != nil {
                // 找到共同节点 继续
                cn = c
                continue
            }
            // 创建子节点
            n := newNode(t, search, cn, nil, new(methodHandler), ppath, pnames)
            n.addHandler(method, h)
            cn.addChild(n)
        } else {
            // 节点已存在
            if h != nil {
                cn.addHandler(method, h)
                cn.ppath = ppath
                if len(cn.pnames) == 0 { // Issue #729
                    cn.pnames = pnames
                }
            }
        }
        return
    }
}

从上面来看,整个节点是根据新增的路由不断调整而生成的radix tree。了解了整个结构之后,再来看看如何查找路由的handler。

请求如何匹配上 handler

回顾之前的 http server 的代码,

serverHandler{c.server}.ServeHTTP(w, w.req)

通过这个函数我们知道,其实是调用ServeHTTP 接口来具体处理请求。

echo.go

// ServeHTTP 实现了 `http.Handler` 接口, 该接口用来处理请求
func (e *Echo) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    // 获取 context,这里为啥用pool实现?据文档说是为了节省内存
    c := e.pool.Get().(*context)
    c.Reset(r, w)

    h := NotFoundHandler

    // 没有请求前需要调用的 http 中间件
    if e.premiddleware == nil {
        // 查找 handler的方法
        e.router.Find(r.Method, getPath(r), c)
        h = c.Handler()
        h = applyMiddleware(h, e.middleware...)
    } else {
        h = func(c Context) error {
            e.router.Find(r.Method, getPath(r), c)
            h := c.Handler()
            h = applyMiddleware(h, e.middleware...)
            return h(c)
        }
        h = applyMiddleware(h, e.premiddleware...)
    }

    // 执行处理逻辑 
    if err := h(c); err != nil {
        e.HTTPErrorHandler(err, c)
    }

    // 释放 context
    e.pool.Put(c)
}

// 通过 method 和 path 查找注册的 handler,解析 URL 参数并把参数装进 context
func (r *Router) Find(method, path string, c Context) {
    ctx := c.(*context)
    ctx.path = path
    fmt.Println(ctx.path, ctx.query, ctx.pvalues, method, path, "ctx....")
    cn := r.tree // 当前节点

    var (
        search  = path
        child   *node         // 子节点
        n       int           // 参数计数器
        nk      kind          // 下一个节点的 Kind
        nn      *node         // 下一个节点
        ns      string        // 下一个 search 字符串
        pvalues = ctx.pvalues 
    )

    // 搜索顺序 static > param > any
    for {
        if search == "" {
            break
        }

        pl := 0 // Prefix length
        l := 0  // LCP length

        if cn.label != ':' {
            sl := len(search)
            pl = len(cn.prefix)

            // LCP
            max := pl
            if sl < max {
                max = sl
            }
            // 找到共同前缀的起始点
            for ; l < max && search[l] == cn.prefix[l]; l++ {
            }
        }

        if l == pl {
            // 重合 继续搜索
            search = search[l:]
        } else {
            cn = nn
            search = ns
            if nk == pkind {
                goto Param
            } else if nk == akind {
                goto Any
            }
            // 没有找到子节点 直接返回
            return
        }

        if search == "" {
            break
        }

        // Static 节点
        if child = cn.findChild(search[0], skind); child != nil {
            // Save next
            if cn.prefix[len(cn.prefix)-1] == '/' { // Issue #623
                nk = pkind
                nn = cn
                ns = search
            }
            cn = child
            continue
        }

    // Param 节点
    Param:
        if child = cn.findChildByKind(pkind); child != nil {
            // Issue #378
            if len(pvalues) == n {
                continue
            }

            // Save next
            if cn.prefix[len(cn.prefix)-1] == '/' { // Issue #623
                nk = akind
                nn = cn
                ns = search
            }

            cn = child
            i, l := 0, len(search)
            for ; i < l && search[i] != '/'; i++ {
            }
            pvalues[n] = search[:i]
            n++
            search = search[i:]
            continue
        }

    // Any 节点
    Any:
        if cn = cn.findChildByKind(akind); cn == nil {
            if nn != nil {
                cn = nn
                nn = cn.parent // Next (Issue #954)
                search = ns
                if nk == pkind {
                    goto Param
                } else if nk == akind {
                    goto Any
                }
            }
            // Not found
            return
        }
        pvalues[len(cn.pnames)-1] = search
        break
    }

    ctx.handler = cn.findHandler(method)
    ctx.path = cn.ppath
    ctx.pnames = cn.pnames

    // NOTE: Slow zone...
    if ctx.handler == nil {
        ctx.handler = cn.checkMethodNotAllowed()

        // Dig further for any, might have an empty value for *, e.g.
        // serving a directory. Issue #207.
        if cn = cn.findChildByKind(akind); cn == nil {
            return
        }
        if h := cn.findHandler(method); h != nil {
            ctx.handler = h
        } else {
            ctx.handler = cn.checkMethodNotAllowed()
        }
        ctx.path = cn.ppath
        ctx.pnames = cn.pnames
        pvalues[len(cn.pnames)-1] = ""
    }

    return

整个过程可以通过把路由树画出来,然后一个一个case去跑下来,可以理解整个构建和查找的过程。

再回过头来看看之前的问题

  • 官方 http 包已经提供了server的功能,为什么要用框架?
    从echo这个框架的角度来说,提供了支持更多类型以及效率更高的路由,路由分组,http中间件,更优化的内存使用,请求参数上下文处理等等。从性能和功能的角度都极大的提升了开发效率。
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容

  • 个人学习批处理的初衷来源于实际工作;在某个迭代版本有个BS(安卓手游模拟器)大需求,从而在测试过程中就重复涉及到...
    Luckykailiu阅读 4,678评论 0 11
  • echo框架的使用. 见wiki https://echo.labstack.com/guide echo....
    个00个阅读 1,612评论 0 2
  • 概要 64学时 3.5学分 章节安排 电子商务网站概况 HTML5+CSS3 JavaScript Node 电子...
    阿啊阿吖丁阅读 9,072评论 0 3
  • 前端开发面试题 面试题目: 根据你的等级和职位的变化,入门级到专家级,广度和深度都会有所增加。 题目类型: 理论知...
    怡宝丶阅读 2,568评论 0 7
  • 佛洛依德——名言 1、笑话给予我们快感,是通过把一个充满能量和紧张度的有意识过程转化为一个轻松的无意识过程。 2、...
    文藝聯盟阅读 454评论 2 3