post方法就反回了一个string字符串前台怎么接_Golang Web入门(2):如何实现一个RESTful风格的路由...

摘要

在上一篇文章中,我们聊了聊在Golang中怎么实现一个Http服务器。但是在最后我们可以发现,固然DefaultServeMux可以做路由分发的功能,但是他的功能同样是不完善的。

DefaultServeMux做路由分发,是不能实现RESTful风格的API的,我们没有办法定义请求所需的方法,也没有办法在API路径中加入query参数。其次,我们也希望可以让路由查找的效率更高。

所以在这篇文章中,我们将分析httprouter这个包,从源码的层面研究他是如何实现我们上面提到的那些功能。并且,对于这个包中最重要的前缀树,本文将以图文结合的方式来解释。

1 使用

我们同样以怎么使用作为开始,自顶向下的去研究httprouter。我们先来看看官方文档中的小例子:

package main

import (
    "fmt"
    "net/http"
    "log"

    "github.com/julienschmidt/httprouter"
)

func Index(w http.ResponseWriter, r *http.Request, _ httprouter.Params) {
    fmt.Fprint(w, "Welcome!\n")
}

func Hello(w http.ResponseWriter, r *http.Request, ps httprouter.Params) {
    fmt.Fprintf(w, "hello, %s!\n", ps.ByName("name"))
}

func main() {
    router := httprouter.New()
    router.GET("/", Index)
    router.GET("/hello/:name", Hello)

    log.Fatal(http.ListenAndServe(":8080", router))
}


其实我们可以发现,这里的做法和使用Golang自带的net/http包的做法是差不多的。都是先注册相应的URI和函数,换一句话来说就是将路由和处理器相匹配。

在注册的时候,使用router.XXX方法,来注册相对应的方法,比如GETPOST等等。

注册完之后,使用http.ListenAndServe开始监听。

至于为什么,我们会在后面的章节详细介绍,现在只需要先了解做法即可。

2 创建

我们先来看看第一行代码,我们定义并声明了一个Router。下面来看看这个Router的结构,这里把与本文无关的其他属性省略:

type Router struct {
    //这是前缀树,记录了相应的路由
    trees map[string]*node

    //记录了参数的最大数目
    maxParams  uint16

}


在创建了这个Router的结构后,我们就使用router.XXX方法来注册路由了。继续看看路由是怎么注册的:

func (r *Router) GET(path string, handle Handle) {
    r.Handle(http.MethodGet, path, handle)
}

func (r *Router) POST(path string, handle Handle) {
    r.Handle(http.MethodPost, path, handle)
}

...

在这里还有一长串的方法,他们都是一样的,调用了

r.Handle(http.MethodPost, path, handle)


这个方法。我们再来看看:

func (r *Router) Handle(method, path string, handle Handle) {
    ...
    if r.trees == nil {
        r.trees = make(map[string]*node)
    }

    root := r.trees[method]
    if root == nil {
        root = new(node)
        r.trees[method] = root

        r.globalAllowed = r.allowed("*", "")
    }

    root.addRoute(path, handle)
    ...
}

在这个方法里,同样省略了很多细节。我们只关注一下与本文有关的。我们可以看到,在这个方法中,如果tree还没有初始化,则先初始化这颗前缀树

然后我们注意到,这颗树是一个map结构。也就是说,一个方法,对应了一颗树。然后,对应这棵树,调用addRoute方法,把URI和对应的Handle保存进去。

3 前缀树

3.1 定义

又称单词查找树,Trie树,是一种树形结构,是一种哈希树的变种。典型应用是用于统计,排序和保存大量的字符串(但不仅限于字符串),所以经常被搜索引擎系统用于文本词频统计。它的优点是:利用字符串的公共前缀来减少查询时间,最大限度地减少无谓的字符串比较,查询效率比哈希树高。

简单的来讲,就是要查找什么,只要跟着这棵树的某一条路径找,就可以找得到。

比如在搜索引擎中,你输入了一个

39ef84bbe30f7fa96b56854f83bf130e.png

他会有这些联想,也可以理解为是一个前缀树。

再举个例子:

4637eb193b9061d7a11b7ae38f4bc9be.png

在这颗GET方法的前缀树中,包含了以下的路由:

  • /wow/awesome

  • /test

  • /hello/world

  • /hello/china

  • /hello/chinese  

说到这里你应该可以理解了,在构建这棵树的过程中,任何两个节点,只要有了相同的前缀,相同的部分就会被合并成一个节点

3.2 图解构建

上面说的addRoute方法,就是这颗前缀树的插入方法。假设现在数为空,在这里我打算以图解的方式来说明这棵树的构建。

假设我们需要插入的三个路由分别为:

  • /hello/world

  • /hello/china

  • /hello/chinese  

(1)插入/hello/world  

因为此时树为空,所以可以直接插入:

d8e0a145114b50a60c8dc73095c92d66.png

(2)插入/hello/china  

此时,发现/hello/world/hello/china有相同的前缀/hello/

b196882fa01ce5ebda728b54a1aa1fdb.png

那么要先将原来的/hello/world结点,拆分出来,然后将要插入的结点/hello/china,截去相同部分,作为/hello/world的子节点。

9562c521b7882c2e7617386a89329268.png

(3)插入/hello/chinese  

此时,我们需要插入/hello/chinese,但是发现,/hello/chinese和结点/hello/有公共的前缀/hello/,所以我们去查看/hello/这个结点的子节点。

注意,在结点中有一个属性,叫indices。它记录了这个结点的子节点的首字母,便于我们查找。比如这个/hello/结点,他的indices值为wc。而我们要插入的结点是/hello/chinese,除去公共前缀后,chinese的第一个字母也是c,所以我们进入china这个结点。

682212fd89bab23419eb0840f9a29bd4.png

这时,有没有发现,情况回到了我们一开始插入/hello/china时候的局面。那个时候公共前缀是/hello/,现在的公共前缀是chin

所以,我们同样把chin截出来,作为一个结点,将a作为这个结点的子节点。并且,同样把ese也作为子节点。

cb02c5f95481c95f3b0c3865834100c6.png

3.3 总结构建算法

到这里,构建就已经结束了。我们来总结一下算法。

具体带注释的代码将在本文最末尾给出,如果想要了解的更深可以自行查看。在这里先理解这个过程:

(1)如果树为空,则直接插入  
(2)否则,查找当前的结点是否与要插入的URI有公共前缀
(3)如果没有公共前缀,则直接插入
(4)如果有公共前缀,则判断是否需要分裂当前的结点  
(5)如果需要分裂,则将公共部分作为父节点,其余的作为子节点  
(6)如果不需要分裂,则寻找有无前缀相同的子节点  
(7)如果有前缀相同的,则跳到(4)  
(8)如果没有前缀相同的,直接插入  
(9)在最后的结点,放入这条路由对应的Handle

但是到了这里,有同学要问了:怎么这里的路由,不带参数的呀?  

其实只要你理解了上面的过程,带参数也是一样的。逻辑是这样的:在每次插入之前,会扫描当前要插入的结点的path是否带有参数(即扫描有没有/或者*)。如果带有参数的话,将当前节点的wildChild属性设置为true,然后将参数部分,设置为一个新的子节点

4 监听

在讲完了路由的注册,我们来聊聊路由的监听。

在上一篇文章的内容中,我们有提到这个:

type serverHandler struct {
    srv *Server
}

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
    handler := sh.srv.Handler
    if handler == nil {
        handler = DefaultServeMux
    }
    if req.RequestURI == "*" && req.Method == "OPTIONS" {
        handler = globalOptionsHandler{}
    }
    handler.ServeHTTP(rw, req)
}

当时我们提到,如果我们不传入任何的Handle方法,Golang将使用默认的DefaultServeMux方法来处理请求。而现在我们传入了router,所以将会使用router来处理请求。

因此,router也是实现了ServeHTTP方法的。我们来看看(同样省略了一些步骤):

func (r *Router) ServeHTTP(w http.ResponseWriter, req *http.Request) {
    ...
    path := req.URL.Path

    if root := r.trees[req.Method]; root != nil {
        if handle, ps, tsr := root.getValue(path, r.getParams); handle != nil {
            if ps != nil {
                handle(w, req, *ps)
                r.putParams(ps)
            } else {
                handle(w, req, nil)
            }
            return
        } 
    }
    ...
    // Handle 404
    if r.NotFound != nil {
        r.NotFound.ServeHTTP(w, req)
    } else {
        http.NotFound(w, req)
    }
}

在这里,我们选择请求方法所对应的前缀树,调用了getValue方法。

简单解释一下这个方法:在这个方法中会不断的去匹配当前路径与结点中的path,直到找到最后找到这个路由对应的Handle方法。

注意,在这期间,如果路由是RESTful风格的,在路由中含有参数,将会被保存在Param中,这里的Param结构如下:

type Param struct {
    Key   string
    Value string
}

如果未找到相对应的路由,则调用后面的404方法。

5 处理

到了这一步,其实和以前的内容几乎一样了。

在获取了该路由对应的Handle之后,调用这个函数。

唯一和之前使用net/http包中的Handler不一样的是,这里的Handle,封装了从API中获取的参数。

type Handle func(http.ResponseWriter, *http.Request, Params)

6 写在最后

谢谢你能看到这里~

至此,httprouter介绍完毕,最关键的也就是前缀树的构建了。在上面我用图文结合的方式,模拟了一次前缀树的构建过程,希望可以让你理解前缀树是怎么回事。当然,如果还有疑问,也可以留言或者在微信中与我交流~

当然,如果你不满足于此,可以看看后面的附录,有前缀树的全代码注释

当然了,作者也是刚入门。所以,可能会有很多的疏漏。如果在阅读的过程中,有哪些解释不到位,或者理解出现了偏差,也请你留言指正。

再次感谢~

PS:如果觉得公众号阅读代码困难,可以点击阅读原文去掘金阅读。

7 源码阅读

7.1 树的结构

type node struct {

    path      string    //当前结点的URI
    indices   string    //子结点的首字母
    wildChild bool      //子节点是否为参数结点
    nType     nodeType  //节点类型
    priority  uint32    //权重
    children  []*node   //子节点
    handle    Handle    //处理器
}

7.2 addRoute

func (n *node) addRoute(path string, handle Handle) {

    fullPath := path
    n.priority++

    // 如果这是个空树,那么直接插入
    if len(n.path) == 0 && len(n.indices) == 0 {

        //这个方法其实是在n这个结点插入path,但是会处理参数
        //详细实现在后文会给出
        n.insertChild(path, fullPath, handle)
        n.nType = root
        return
    }

    //设置一个flag
walk:
    for {
        // 找到当前结点path和要插入的path中最长的前缀
        // i为第一位不相同的下标
        i := longestCommonPrefix(path, n.path)

        // 此时相同的部分比这个结点记录的path短
        // 也就是说需要把当前的结点分裂开
        if i len(n.path) {
            child := node{

                // 把不相同的部分设置为一个切片,作为子节点
                path:      n.path[i:],
                wildChild: n.wildChild,
                nType:     static,
                indices:   n.indices,
                children:  n.children,
                handle:    n.handle,
                priority:  n.priority - 1,
            }

            // 将新的结点作为这个结点的子节点
            n.children = []*node{&child}
            // 把这个结点的首字母加入indices中
            // 目的是查找更快
            n.indices = string([]byte{n.path[i]})
            n.path = path[:i]
            n.handle = nil
            n.wildChild = false
        }

        // 此时相同的部分只占了新URI的一部分
        // 所以把path后面不相同的部分要设置成一个新的结点
        if i len(path) {
            path = path[i:]

            // 此时如果n的子节点是带参数的
            if n.wildChild {
                n = n.children[0]
                n.priority++

                // 判断是否会不合法
                if len(path) >= len(n.path) && n.path == path[:len(n.path)] &&
                    n.nType != catchAll &&
                    (len(n.path) >= len(path) || path[len(n.path)] == '/') {
                    continue walk
                } else {
                    pathSeg := path
                    if n.nType != catchAll {
                        pathSeg = strings.SplitN(pathSeg, "/", 2)[0]
                    }
                    prefix := fullPath[:strings.Index(fullPath, pathSeg)] + n.path
                    panic("'" + pathSeg +
                        "' in new path '" + fullPath +
                        "' conflicts with existing wildcard '" + n.path +
                        "' in existing prefix '" + prefix +
                        "'")
                }
            }

            // 把截取的path的第一位记录下来
            idxc := path[0]

            // 如果此时n的子节点是带参数的
            if n.nType == param && idxc == '/' && len(n.children) == 1 {
                n = n.children[0]
                n.priority++
                continue walk
            }

            // 这一步是检查拆分出的path,是否应该被合并入子节点中
            // 具体例子可看上文中的图解
            // 如果是这样的话,把这个子节点设置为n,然后开始一轮新的循环
            for i, c := range []byte(n.indices) {
                if c == idxc {
                    // 这一部分是为了把权重更高的首字符调整到前面
                    i = n.incrementChildPrio(i)
                    n = n.children[i]
                    continue walk
                }
            }

            // 如果这个结点不用被合并
            if idxc != ':' && idxc != '*' {
                // 把这个结点的首字母也加入n的indices中
                n.indices += string([]byte{idxc})
                child := &node{}
                n.children = append(n.children, child)
                n.incrementChildPrio(len(n.indices) - 1)
                // 新建一个结点
                n = child
            }
            // 对这个结点进行插入操作
            n.insertChild(path, fullPath, handle)
            return
        }

        // 直接插入到当前的结点
        if n.handle != nil {
            panic("a handle is already registered for path '" + fullPath + "'")
        }
        n.handle = handle
        return
    }
}

7.3 insertChild

func (n *node) insertChild(path, fullPath string, handle Handle) {
    for {
        // 这个方法是用来找这个path是否含有参数的
        wildcard, i, valid := findWildcard(path)
        // 如果不含参数,直接跳出循环,看最后两行
        if i 0 {
            break
        }

        // 条件校验
        if !valid {
            panic("only one wildcard per path segment is allowed, has: '" +
                wildcard + "' in path '" + fullPath + "'")
        }

        // 同样判断是否合法
        if len(wildcard) 2 {
            panic("wildcards must be named with a non-empty name in path '" + fullPath + "'")
        }

        if len(n.children) > 0 {
            panic("wildcard segment '" + wildcard +
                "' conflicts with existing children in path '" + fullPath + "'")
        }

        // 如果参数的第一位是`:`,则说明这是一个参数类型
        if wildcard[0] == ':' {
            if i > 0 {
                // 把当前的path设置为参数之前的那部分
                n.path = path[:i]
                // 准备把参数后面的部分作为一个新的结点
                path = path[i:]
            }

            //然后把参数部分作为新的结点
            n.wildChild = true
            child := &node{
                nType: param,
                path:  wildcard,
            }
            n.children = []*node{child}
            n = child
            n.priority++

            // 这里的意思是,path在参数后面还没有结束
            if len(wildcard) len(path) {
                // 把参数后面那部分再分出一个结点,continue继续处理
                path = path[len(wildcard):]
                child := &node{
                    priority: 1,
                }
                n.children = []*node{child}
                n = child
                continue
            }

            // 把处理器设置进去
            n.handle = handle
            return

        } else { // 另外一种情况
            if i+len(wildcard) != len(path) {
                panic("catch-all routes are only allowed at the end of the path in path '" + fullPath + "'")
            }

            if len(n.path) > 0 && n.path[len(n.path)-1] == '/' {
                panic("catch-all conflicts with existing handle for the path segment root in path '" + fullPath + "'")
            }

            // 判断在这之前有没有一个/
            i--
            if path[i] != '/' {
                panic("no / before catch-all in path '" + fullPath + "'")
            }

            n.path = path[:i]

            // 设置一个catchAll类型的子节点
            child := &node{
                wildChild: true,
                nType:     catchAll,
            }
            n.children = []*node{child}
            n.indices = string('/')
            n = child
            n.priority++

            // 把后面的参数部分设置为新节点
            child = &node{
                path:     path[i:],
                nType:    catchAll,
                handle:   handle,
                priority: 1,
            }
            n.children = []*node{child}

            return
        }
    }

    // 对应最开头的部分,如果这个path里面没有参数,直接设置
    n.path = path
    n.handle = handle
}


最关键的几个方法到这里就全部结束啦,先给看到这里的你鼓个掌!

这一部分理解会比较难,可能需要多看几遍。

如果还是有难以理解的地方,欢迎留言交流~

PS:如果觉得公众号阅读代码困难,可以点击阅读原文去掘金阅读。

本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若转载,请注明出处:http://www.mzph.cn/news/445969.shtml

如若内容造成侵权/违法违规/事实不符,请联系多彩编程网进行投诉反馈email:809451989@qq.com,一经查实,立即删除!

相关文章

FFmpeg源代码简单分析-通用-avcodec_open2()

参考链接 FFmpeg源代码简单分析:avcodec_open2()_雷霄骅的博客-CSDN博客 avcodec_open2() 该函数用于初始化一个音视频编解码器的AVCodecContextavcodec_open2()的声明位于libavcodec\avcodec.h,如下所示。 /*** Initialize the AVCodecContext to use…

统计MySQL中某数据库硬盘占用量大小

放码过来 select TABLE_NAME, concat(truncate(data_length/1024/1024,2), MB) as data_size, concat(truncate(index_length/1024/1024,2), MB) as index_size from information_schema.tables where TABLE_SCHEMA your_db_name order by data_length desc;运行结果 参考…

FFmpeg源代码简单分析-解码-打开媒体的函数avformat_open_input

参考链接 图解FFMPEG打开媒体的函数avformat_open_input_雷霄骅的博客-CSDN博客_avformat_open_input 使用FFmpeg源代码简单分析:avformat_open_input()_雷霄骅的博客-CSDN博客_avformat_open_input() avformat_open_input FFmpeg打开媒体的的过程开始于avformat_…

redis session java获取attribute_面试题:给我说说你能想到几种分布式session实现?...

作者:yanglbme 来源:https://github.com/doocs/advanced-java/blob/master/docs/distributed-system/distributed-session.md# 面试官心理分析面试官问了你一堆 dubbo 是怎么玩儿的,你会玩儿 dubbo 就可以把单块系统弄成分布式系统&#xff0…

FFmpeg源代码简单分析-解码-avformat_find_stream_info()

参考链接 FFmpeg源代码简单分析:avformat_find_stream_info()_雷霄骅的博客-CSDN博客_avformat_find_stream_info avformat_find_stream_info() ​该函数可以读取一部分视音频数据并且获得一些相关的信息avformat_find_stream_info()的声明位于libavformat\avform…

Tail Recursion尾递归

什么是尾递归 Tail Recursion /teɪl rɪˈkɜːrʒn/ In traditional recursion, the typical model is that you perform your recursive calls first, and then you take the return value of the recursive call and calculate the result. In this manner, you don’t g…

FFmpeg源代码简单分析-解码-av_read_frame()

参考链接 ffmpeg 源代码简单分析 : av_read_frame()_雷霄骅的博客-CSDN博客_ffmpeg frame av_read_frame() ffmpeg中的av_read_frame()的作用是读取码流中的音频若干帧或者视频一帧。例如,解码视频的时候,每解码一个视频帧,需要…

FFmpeg源代码简单分析-解码-avformat_close_input()

参考链接 FFmpeg源代码简单分析:avformat_close_input()_雷霄骅的博客-CSDN博客_avformat_close_input avformat_close_input() 本文简单分析FFmpeg的avformat_close_input()函数。该函数用于关闭一个AVFormatContext,一般情况下是和avformat_open_inp…

FFmpeg源代码简单分析-编码-avformat_alloc_output_context2()

参考链接 FFmpeg源代码简单分析:avformat_alloc_output_context2()_雷霄骅的博客-CSDN博客_avformat_alloc_context avformat_alloc_output_context2() 在基于FFmpeg的视音频编码器程序中,该函数通常是第一个调用的函数(除了组件注册函数av…

FFmpeg源代码简单分析-编码-avformat_write_header()

参考链接 FFmpeg源代码简单分析:avformat_write_header()_雷霄骅的博客-CSDN博客_avformat_write_header avformat_write_header() FFmpeg写文件用到的3个函数:avformat_write_header(),av_write_frame()以及av_write_trailer()其中av_writ…

《深入理解JVM.2nd》笔记(二):Java内存区域与内存溢出异常

文章目录概述运行时数据区域程序计数器Java虚拟机栈本地方法栈Java堆方法区运行时常量池直接内存HotSpot虚拟机对象探秘对象的创建第一步第二步第三步第四步最后一脚对象的内存布局对象头Header第一部分第二部分实例数据Instance对齐填充Padding对象的访问定位句柄直接指针对象…

《深入理解JVM.2nd》笔记(三):垃圾收集器与垃圾回收策略

文章目录概述对象已死吗引用计数算法可达性分析算法再谈引用finalize():生存还是死亡回收方法区垃圾收集算法标记-清除算法复制算法标记-整理算法分代收集算法HotSpot的算法实现枚举根结点安全点安全区域垃圾收集器SerialParNewParallel ScavengeSerial OldParallel…

FFmpeg源代码简单分析-编码-av_write_frame()

参考链接 FFmpeg源代码简单分析:av_write_frame()_雷霄骅的博客-CSDN博客_av_write_frame av_write_frame() av_write_frame()用于输出一帧视音频数据,它的声明位于libavformat\avformat.h,如下所示。 /*** Write a packet to an output me…

《深入理解JVM.2nd》笔记(四):虚拟机性能监控与故障处理工具

文章目录概述JDK的命令行工具jps:虚拟机进程状况工具jstat:虚拟机统计信息监视工具jinfo:Java配置信息工具jmap:Java内存映像工具jhat:虚拟机堆转储快照分析工具jstack:Java堆栈跟踪工具HSDIS:J…

FFmpeg源代码简单分析-编码-av_write_trailer()

参考链接: FFmpeg源代码简单分析:av_write_trailer()_雷霄骅的博客-CSDN博客_av_malloc av_write_trailer() av_write_trailer()用于输出文件尾,它的声明位于libavformat\avformat.h,如下所示 /*** Write the stream trailer to…

FFmpeg源代码简单分析-其他-日志输出系统(av_log()等)

参考链接 FFmpeg源代码简单分析:日志输出系统(av_log()等)_雷霄骅的博客-CSDN博客_ffmpeg源码分析 日志输出系统(av_log()等) 本文分析一下FFmpeg的日志(Log)输出系统的源代码。日志输出部分的…

FFmpeg源代码简单分析-其他-AVClass和AVoption

参考链接 FFmpeg源代码简单分析:结构体成员管理系统-AVClass_雷霄骅的博客-CSDN博客FFmpeg源代码简单分析:结构体成员管理系统-AVOption_雷霄骅的博客-CSDN博客 概述 AVOption用于在FFmpeg中描述结构体中的成员变量。它最主要的作用可以概括为两个字&a…

FFmpeg源代码简单分析-其他-libswscale的sws_getContext()

参考链接 FFmpeg源代码简单分析:libswscale的sws_getContext()_雷霄骅的博客-CSDN博客 libswscale的sws_getContext() FFmpeg中类库libswsscale用于图像处理(缩放,YUV/RGB格式转换)libswscale是一个主要用于处理图片像素数据的类…

FFmpeg源代码简单分析-其他-libswscale的sws_scale()

参考链接 FFmpeg源代码简单分析:libswscale的sws_scale()_雷霄骅的博客-CSDN博客_bad dst image pointers libswscale的sws_scale() FFmpeg的图像处理(缩放,YUV/RGB格式转换)类库libswsscale中的sws_scale()函数。libswscale是一…

FFmpeg源代码简单分析-其他-libavdevice的gdigrab

参考链接 FFmpeg源代码简单分析:libavdevice的gdigrab_雷霄骅的博客-CSDN博客_gdigrab libavdevice的gdigrab GDIGrab用于在Windows下屏幕录像(抓屏)gdigrab的源代码位于libavdevice\gdigrab.c。关键函数的调用关系图如下图所示。图中绿色背…