geektutu / blog

极客兔兔的博客,Coding Coding 创建有趣的开源项目。
https://geektutu.com
Apache License 2.0
166 stars 21 forks source link

Go语言动手写Web框架 - Gee第三天 路由Router | 极客兔兔 #43

Open geektutu opened 4 years ago

geektutu commented 4 years ago

https://geektutu.com/post/gee-day3.html

7天用 Go语言 从零实现Web框架教程(7 days implement golang web framework from scratch tutorial),用 Go语言/golang 动手写Web框架,从零实现一个Web框架,从零设计一个Web框架。本文介绍了如何用 Trie 前缀树实现路由。支持简单的参数解析和通配符的场景。

demo007x commented 4 years ago

very good!

zaneyang123 commented 4 years ago

it's cool

ghost commented 4 years ago

开始看不懂了。。。。

ReviveKwan commented 4 years ago

这里有问题, getRoutes 的时候有用到的 root.travel 并没有说出来 ,测试的时候有点懵逼,后来看github源码才发现

Arbusz commented 4 years ago

只有第二个参数接受:匹配吗

zjfsdnu commented 4 years ago

这一节难度大了不止一点半点 建议分两段 放后面讲

geektutu commented 4 years ago

@Arbusz @zjfsdnu 为了降低代码量,这一节实现的路由算法很粗糙。因为整个系列的核心还是想介绍一个框架的最基础的构成,所以都只能是浅尝辄止,不过7天就打不住了。

haochen233 commented 4 years ago

Trie树有点懵了,那两个mathch函数和interst、search函数注释能多点就好了,看起来太费劲了。

6613974 commented 4 years ago

这章确实看的懵逼了,注释太少,跨度太大。在慢慢研究了。

jianghui568 commented 3 years ago

大佬写的太棒了,very cool

wilgx0 commented 3 years ago

感谢大佬让我 如沐春风 如淋甘露 飘飘欲仙 让我走出人生低谷 让我让重新开始热爱生活

liweiforeveryoung commented 3 years ago

兔兔你好,发现了两个小bug

第一个 bug

trie.go 里的

func (n *node) insert(pattern string, parts []string, height int) {
    if len(parts) == height {
        n.pattern = pattern
        return
    }
    part := parts[height]
    child := n.matchChild(part)
    if child == nil {
        child = &node{part: part, isWild: part[0] == ':' || part[0] == '*'}
        n.children = append(n.children, child)
    }
    child.insert(pattern, parts, height+1)
}

如果第一次插入的pattern/:agemethodGEThandlefunchandleAge()

那么会生成一个这样的 node

nodeAge = node{
    pattern:  ":age",
    part:     ":age",
    children: nil,
    isWild:   true,
}
handlers["GET-:age"] = handleAge

第二次插入的 pattern/18 ,method 与第一次相同,仍然为 GEThandlefunchandle18(),此时并不会修改之前的 node,而是修改了之前 nodeAgepattern

nodeAge = node{
    pattern:  "18",
    part:     ":age",
    children: nil,
    isWild:   true,
}
handlers["GET-:age"] = handleAge
handlers["GET-18"] =  handle18

接下来看看handle()函数

func (r *router) handle(c *Context) {
    n, params := r.getRoute(c.Method, c.Path)
    if n != nil {
        c.Params = params
        key := c.Method + "-" + n.pattern
        r.handlers[key](c)
    } else {
        c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
    }
}

当有一个 /19 的请求到来时,将会匹配到 nodeAge,但是由于 nodeAgepattern 变成了 18,因此将会被 handle18() 处理,这不太合适。

GIN的做法是将冲突的路由直接panic了。

第二个bug

routerhandle 方法貌似不是协程安全的

假设此时只有一个/:agerouter

nodeAge = node{
    pattern:  ":age",
    part:     ":age",
    children: nil,
    isWild:   true,
}
handlers["GET-:age"] = handleAge

此时有两个请求地址分别为 /18/19的请求到达。

handle() 内的 getRoute() 函数得到的 params 分别为 key:age value:18key:age value:19,它们会对同一个 context 进行写入,因此不太安全。

http 包内的 server.go 文件中的 Serve 函数内,有这样一段注释

// Serve accepts incoming connections on the Listener l, creating a
// new service goroutine for each. The service goroutines read requests and
// then call srv.Handler to reply to them.
//
// HTTP/2 support is only enabled if the Listener returns *tls.Conn
// connections and they were configured with "h2" in the TLS
// Config.NextProtos.
//
// Serve always returns a non-nil error and closes l.
// After Shutdown or Close, the returned error is ErrServerClosed.
func (srv *Server) Serve(l net.Listener) error

可见,http.ListenAndServe() 是有可能开启多个协程的。

geektutu commented 3 years ago

@liweiforeveryoung 非常感谢,指出了非常关键的问题。

第一个bug,存在覆盖的问题,gin 的做法才是对的,应该把问题暴露给用户。 第二个问题,http 请求是并发的,但每一个请求都会调用 ServeHTTP ,这个方法中,context 每次都创建新的,不会对同一个context进行写入。

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    c := newContext(w, req)
    engine.router.handle(c)
}
liweiforeveryoung commented 3 years ago

@geektutu @liweiforeveryoung 非常感谢,指出了非常关键的问题。

第一个bug,存在覆盖的问题,gin 的做法才是对的,应该把问题暴露给用户。 第二个问题,http 请求是并发的,但每一个请求都会调用 ServeHTTP ,这个方法中,context 每次都创建新的,不会对同一个context进行写入。

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  c := newContext(w, req)
  engine.router.handle(c)
}

嗯,我刚刚看发现了ServeHTTP每次都创建了一个新的context,刚准备修改评论的,结果看见你回复了。

liweiforeveryoung commented 3 years ago

@geektutu @liweiforeveryoung 非常感谢,指出了非常关键的问题。

第一个bug,存在覆盖的问题,gin 的做法才是对的,应该把问题暴露给用户。 第二个问题,http 请求是并发的,但每一个请求都会调用 ServeHTTP ,这个方法中,context 每次都创建新的,不会对同一个context进行写入。

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  c := newContext(w, req)
  engine.router.handle(c)
}

@geektutu @liweiforeveryoung 非常感谢,指出了非常关键的问题。

第一个bug,存在覆盖的问题,gin 的做法才是对的,应该把问题暴露给用户。 第二个问题,http 请求是并发的,但每一个请求都会调用 ServeHTTP ,这个方法中,context 每次都创建新的,不会对同一个context进行写入。

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
  c := newContext(w, req)
  engine.router.handle(c)
}

非常感谢作者的付出!!

yufeifly commented 3 years ago

问一个比较蠢的问题。search函数中调用了matchChildren,matchChildren什么情况下会有多个匹配返回呢?

givetimetolife commented 3 years ago

建议这一章先把代码写一遍 大概清楚每一个方法是做什么的,然后按照 addRouter -> getRouter -> handle 在看一遍代码 走一遍流程就明了多了, 说白了就是 用前缀树结构存 前缀树结构取, 用GET /a/asd/c || GET a/s/c 匹配到路由(GET-/a/:param/c)对应的HandlerFunc 并把asd || s 存在context的Params里.

感谢楼主

BigYoungs commented 3 years ago
func (n *node) travel(list *([]*node)) {
    if n.pattern != "" {
        *list = append(*list, n)
    }
    for _, child := range n.children {
        child.travel(list)
    }
}

func (n *node) String() string {
    return fmt.Sprintf("node{pattern=%s, part=%s, isWild=%t}", n.pattern, n.part, n.isWild)
}

以上两个方法,在文章中没有提到,但是用到了,看了GitHub源码,才知道,建议后期这种不重要的代码可以提一下,要不会以为自己哪里漏了。

geektutu commented 3 years ago

@BigYoungs 感谢建议~

limaoxiaoer commented 3 years ago

其实自己在关键的地方多加点fmt就看懂怎么回事儿了 不过还是建议作者多讲讲 毕竟是从零实现系列。。。

xlban163 commented 3 years ago

感谢兔兔,从这里开始的4个方法matchChild,matchChildren,insert,search,设计方法和应用场景能简单说明下就更好了

geektutu commented 3 years ago

@limaoxiaoer @xlban163 感谢二位的建议,这里可能假定了大家是计算机专业,学习过二叉树、B+树等常用的数据结构了。以后的文章细节会多写一些。

wushh commented 3 years ago

没其他语言开发基础的, 估计已经懵了, 建议小白们, 多看几遍, 每个方法仔细推敲一下, 也很好理解, 不必完全明白, 懂流程就行了, 继续往前看

Howard0o0 commented 3 years ago

isWild为true时表示的是通配符匹配吧?所以isWild为true时应该是模糊匹配吧 通配符匹配==精准匹配吗

wangzukang commented 3 years ago

写的太好了,十分感谢大佬的付出!

geektutu commented 3 years ago

@Howard0o0 通配符匹是文中的模糊匹配,精准匹配是指每个字符都一致,而不是用通配符。

写的太好了,十分感谢大佬的付出!

@wushh @wangzukang 感谢支持,笔芯~

jeffmingup commented 3 years ago

受益匪浅,感谢前辈的付出!

feixintianxia commented 3 years ago

谢谢博主,我有小问题, 在Demo例子里 curl "http://localhost:9999/hello/geektutu/a/b", 匹配不了/hello/geektutu,但可以匹配到/hello 所有是否可以修改为:

func (n *node) search(parts []string, height int) *node {
    if len(parts) == height || strings.HasPrefix(n.part, "*") {
        if n.pattern == "" {
            return nil
        }
        return n
    }

    part := parts[height]
    children := n.matchChildren(part)

    for _, child := range children {
        result := child.search(parts, height+1)
        if result != nil {
            return result
        }
    }

   //应该新增, 判断当前节点是否满足其前缀

  if  n.pattern != nil {
       return n
 }

    return nil
}
GaloisZhou commented 3 years ago

很棒的学习资料!

有一个问题 get /a/:b get /a/c

/a/x 也是去到 /a/c


get /a/c get /a/:b 和 get /a/:b get /a/c 顺序不一样, 结果是两个不同的树

geektutu commented 3 years ago

@GaloisZhou 嗯,前面评论提到了,这一块路由有冲突时没有提示,直接覆盖了。gin 的做法是直接 panic 报失败。

ghost commented 3 years ago

麻烦请问下单元测试的时候出现 missing ... in args forwarded to printf-like function 地方在c.Writer.Write([]byte(fmt.Sprintf(format, values)))这句代码中 输出结果会出现 %!s(MISSING)

nengwu765 commented 3 years ago

@GaloisZhou 很棒的学习资料!

有一个问题 get /a/:b get /a/c

/a/x 也是去到 /a/c


get /a/c get /a/:b 和 get /a/:b get /a/c 顺序不一样, 结果是两个不同的树

学习中,有同样困惑,这个教程可能偏向思想。要想建立正确trie树,并且结果也正确可以这样处理:

1、 // 第一个匹配成功的节点,用于插入 func (n node) matchChild(part string) node { for _, child := range n.children { // 修改点:动态匹配做强校验,防止路由注册覆盖 if child.part == part || ((part[0] == ':' || part[0] == '*') && child.isWild) { return child } } return nil }

2、 // 所有匹配成功的节点,用于查找 func (n node) matchChildren(part string) []node { nodes := make([]node, 0) wildNodes := make([]node, 0) for _, child := range n.children { // 修改点:静态路由节点优先,动态路由节点延后 if child.part == part { nodes = append(nodes, child) } else if child.isWild { wildNodes = append(wildNodes, child) } } nodes = append(nodes, wildNodes...) return nodes }

morriszhao commented 3 years ago

大佬好、想问下 这种嵌套指针类型的结构体、 有什么好的办法 可以打印出结构体的具体数据内容呢

whitebluepants commented 3 years ago

@feixintianxia 谢谢博主,我有小问题, 在Demo例子里 curl "http://localhost:9999/hello/geektutu/a/b", 匹配不了/hello/geektutu,但可以匹配到/hello 所有是否可以修改为:

func (n *node) search(parts []string, height int) *node {
  if len(parts) == height || strings.HasPrefix(n.part, "*") {
      if n.pattern == "" {
          return nil
      }
      return n
  }

  part := parts[height]
  children := n.matchChildren(part)

  for _, child := range children {
      result := child.search(parts, height+1)
      if result != nil {
          return result
      }
  }

   //应该新增, 判断当前节点是否满足其前缀

  if  n.pattern != nil {
       return n
 }

  return nil
}

可以匹配到/hello是因为demo里面添加了/hello的路由,而不是因为/hello/geektutu

whitebluepants commented 3 years ago

@yufeifly 问一个比较蠢的问题。search函数中调用了matchChildren,matchChildren什么情况下会有多个匹配返回呢?

这个问题我也想知道,大概是添加了这两个路由/hello/:name, /hello/tutu. 然后访问/hello/tutu的时候,就会返回多个匹配了吧。 也就是前面评论说的路由冲突?(还没看gin的源码,会不会就是matchChildren返回多个的时候,直接panic呢?

ChrisLeejing commented 3 years ago

@whitebluepants

@yufeifly 问一个比较蠢的问题。search函数中调用了matchChildren,matchChildren什么情况下会有多个匹配返回呢?

这个问题我也想知道,大概是添加了这两个路由/hello/:name, /hello/tutu. 然后访问/hello/tutu的时候,就会返回多个匹配了吧。 也就是前面评论说的路由冲突?(还没看gin的源码,会不会就是matchChildren返回多个的时候,直接panic呢?

方法matchChildren中的判断在/hello/tutu和/hello/:name中都会append呢.(同样没看过gin源码,如果有看过这段源码的小伙伴,请指导一下,谢谢.) if child.part == part || child.isWild { nodes = append(nodes, child) }

xiaotuotuo321 commented 3 years ago

print大法有点好用

BrianQy commented 3 years ago

对比了一下github的代码,在trie.go里多了一个方法travel,在router.go里多了一个getRouters的方法,在文中并没有提到,可以麻烦介绍说明一下吗?

SsnAgo commented 3 years ago

@BrianQy 对比了一下github的代码,在trie.go里多了一个方法travel,在router.go里多了一个getRouters的方法,在文中并没有提到,可以麻烦介绍说明一下吗?

travel是遍历某个root下的所有存在pattern的route,getRouters里面就是通过传入一个指定的Method(比如GET,POST),调用travel这个方法来获取该Method作为root下的所有route,返回的nodes就是一个个已注册的route,比如/p/:lang/hello 等

puck1006 commented 3 years ago

这节看得脑壳痛 T T

tomjobs commented 3 years ago

感觉这个前缀树的实现太简单粗暴了,每个Part都直接作为一个节点,这样子插入是方便了,但是每次匹配子节点效率很低,也有点浪费空间。

luohaixiannz commented 3 years ago

前缀树路由看着确实有点吃力,因为缺少注释且使用到了递归,稍微有点抽象,可能劝退一些人,所以贴上我的注释,希望对一些新手有所帮助。

package gee

import (
    "fmt"
    "strings"
)

type node struct {
    pattern     string  // 是否一个完整的url,不是则为空字符串
    part        string  // URL块值,用/分割的部分,比如/abc/123,abc和123就是2个part
    children    []*node // 该节点下的子节点
    isWild      bool    // 是否模糊匹配,比如:filename或*filename这样的node就为true
}

func (n *node) String() string {
    return fmt.Sprintf("node{pattern=%s, part=%s, isWild=%t}", n.pattern, n.part, n.isWild)
}

// 找到匹配的子节点,场景是用在插入时使用,找到1个匹配的就立即返回
func (n *node) matchChild(part string) *node {
    // 遍历n节点的所有子节点,看是否能找到匹配的子节点,将其返回
    for _, child := range n.children {
        // 如果有模糊匹配的也会成功匹配上
        if child.part == part || child.isWild {
            return child
        }
    }
    return nil
}

// 一边匹配一边插入的方法
func (n *node) insert(pattern string, parts []string, height int) {
    if len(parts) == height {
        // 如果已经匹配完了,那么将pattern赋值给该node,表示它是一个完整的url
        // 这是递归的终止条件
        n.pattern = pattern
        return
    }

    part := parts[height]
    child := n.matchChild(part)
    if child == nil {
        // 没有匹配上,那么进行生成,放到n节点的子列表中
        child = &node{part: part, isWild: part[0] == ':' || part[0] == '*'}
        n.children = append(n.children, child)
    }
    // 接着插入下一个part节点
    child.insert(pattern, parts, height+1)
}

// 这个函数跟matchChild有点像,但它是返回所有匹配的子节点,原因是它的场景是用以查找
// 它必须返回所有可能的子节点来进行遍历查找
func (n *node) matchChildren(part string) []*node {
    nodes := make([]*node, 0)
    for _, child := range n.children {
        if child.part == part || child.isWild {
            nodes = append(nodes, child)
        }
    }
    return nodes
}

func (n *node) search(parts []string, height int) *node {
    if len(parts) == height || strings.HasPrefix(n.part, "*") {
        // 递归终止条件,找到末尾了或者通配符
        if n.pattern == "" {
            // pattern为空字符串表示它不是一个完整的url,匹配失败
            return nil
        }
        return n
    }

    part := parts[height]
    // 获取所有可能的子路径
    children := n.matchChildren(part)

    for _, child := range children {
        // 对于每条路径接着用下一part去查找
        result := child.search(parts, height+1)
        if result != nil {
            // 找到了即返回
            return result
        }
    }

    return nil
}

// 查找所有完整的url,保存到列表中
func (n *node) travel(list *([]*node)) {
    if n.pattern != "" {
        // 递归终止条件
        *list = append(*list, n)
    }
    for _, child := range n.children {
        // 一层一层的递归找pattern是非空的节点
        child.travel(list)
    }
}
rictt commented 3 years ago

跟着实现了一遍,有个疑问请教一下:既然最后都是根据method跟pattern作为key。。那好像跟我直接提取path作为key存储起来,然后匹配handle没啥区别?省去一大堆路由解析跟匹配

key := c.Method + "-" + n.pattern
r.handlers[key](c)
luohaixiannz commented 3 years ago

教一下:既然最后都是根据method跟pattern作为key。。那好像跟我直接提取path作为key存储起来,然后匹配handle没啥区别?省去一大堆路由解

因为需要模糊匹配

Mosphere commented 2 years ago

上面那个前缀树图中,最后右边两个节点应该是: /p/blog和 /p/related吧

LRQuan commented 2 years ago

接触Golang第三天, 单元测试那一段没有调用的地方啊

naifeitian-wow commented 2 years ago

有点小瑕疵哈,对于/get/name/test,/get/:name同时存在的话这种情况没有考虑哈,gin框架下是直接报错了。我是这样修改了一下,insert方法:

func (n node)insert(pattern string,parts []string,height int){ if len(parts)==height{ n.pattern=pattern return } part:=parts[height] child:=n.matchChild(part) if child==nil{ child=&node{part: part,isWild: part[0]==':'||part[0]==''} if child.isWild &&len(n.children)>0{ panic(part+"同级已经有路由") } if part[0]=='' && len(parts)>height+1{ panic(part+"不能出现在中间") } if (part[0]==''||part[0]==':')&&len(part)==1{ panic(part+"不能单独出现") } n.children=append(n.children,child) } child.insert(pattern,parts,height+1) }

matchChild方法: func (n node)matchChild(part string)node{ for _,child:=range n.children{ if child.isWild{ panic(part+"同级已经有"+child.part) } if child.part==part{ return child } } return nil } 这样的话/get/name/test,/get/:name无论哪个在前就都会报错了

dkl78167816 commented 2 years ago

@LRQuan 接触Golang第三天, 单元测试那一段没有调用的地方啊

这个从零开始不是Go语言学习从零开始。。。

huangnian130 commented 2 years ago

r.handlers[key] (c)

这括号c是什么意思,有点不明白

bestgopher commented 2 years ago

@huangnian130 r.handlers[key] (c)

这括号c是什么意思,有点不明白

r.handlers[key] 返回一个handerfunc,(c)是调用这个handlerfunc