go语言从0实现net/http标准库(终):静态路由
本文给我们的框架提供静态路由功能,因为不涉及http协议的解析,以及各种Reader、Writer流的设置,只涉及最基础的逻辑思维以及抽象能力,因此实现极为简单。
项目结构
今天的项目结构如下:
7
|-- go.mod
|-- httpd
| |-- chunk.go
| |-- conn.go
| |-- header.go
| |-- multipart.go
| |-- request.go
| |-- response.go
| |-- server.go
| |-- status.go
|-- main.go
所有的更改都在server.go
中。代码新增数大概为60行。
什么是路由?
学过计算机网络的同学都知道,网络上不同网段的ip地址之间的数据包发送,需要借助路由器的作用。你交给它一个目的ip地址,它就能为你寻找到到达这个目的地的最佳传输路径,这个帮你寻径的功能就叫做路由。
WEB框架的路由虽然在概念上不尽相同但极为类似。你交给它一个url的PATH路径,它能帮你找到对应的处理函数(handlerFunc)。利用路由的方式处理问题,能够对代码进行解耦,特定的url路径用特定的函数处理,逻辑更为清晰。就以我们的框架来说,我们需要在handler的实现中switch手动设置路由,极为麻烦:
|
|
如果以后想增加路由项的话,需要频繁修改ServeHTTP方法,极为不方便且增加了出错的可能。因此标准库提供了更为方便的写法:
|
|
http.NewServeMux
会返回一个ServeMux
类型,它实现了Handler接口,也就是实现了ServeHTTP
方法。显而易见在这个ServeHTTP
方法内部,它会帮我们完成上述的手动swtich进行路径匹配的操作。
同时你会发现,它注册路由项也极为方便,通过HandlerFunc方法,就可以给指定的路径注册一个处理函数。以后增加路由项,也只需要增加代码,而不会变动已有的代码,各个路由项之间是完全独立的,更便于工程管理。
静态与动态路由
知道路由概念后,我们讨论什么是静态路由以及动态路由:
- 静态路由:功能比较单一,仅提供路径的精确匹配。如注册一个
/index
的路由项,则只能处理url路径为/index
的请求。 - 动态路由:功能更强大,提供路径的模糊匹配,有的甚至支持正则表达式。如在gin框架中,注册一个
/user/:id/photo
路由项,则只要请求的url路径满足/user/任意id值/photo
,则可使用这个路由项的处理函数处理。
我们平时使用到的各种go web框架,很大的一个卖点就是路由机制的功能以及性能,而往往这两者很难兼得。
静态路由虽然功能单一,只能提供路径到处理函数的精确匹配,但正是这种精确匹配的1比1的关系,能够很方便的使用哈希结构来存储,路由寻址的时间复杂度为O(1)。
动态路由虽然功能强大,但根据实现的不同,效率都天差地别。如果采用轮询所有表项的方式,则寻址时间复杂度为O(N);如果采用前缀树的方式,则寻址时间复杂度能降低到O(logN)。但不论是哪种方式,时间复杂度都会高于等于O(logN)。
我们平时使用的gin、beego、iris等出名框架,都是采用的动态路由的方式,能够让我们的开发更为灵活。而go的标准库,它的路由类型其实不太好界定。说它是静态路由,但它还提供了些许的动态匹配能力,如/index/
可以匹配/index/*
(*代表任意路径,即以/index/为前缀的路径);但说它是动态路由,也不太合适,它的模糊匹配是最长子串匹配的方式,不能达到像gin框架那么灵活的效果。
我更倾向于将其归类为静态路由,目前后端的api倡导rest风格,前端需要某个功能就向某个确切的url接口发送请求,像这种最长子串匹配的方式比较鸡肋,平时开发使用并不多。而引入这种最长子串匹配的方式,却需要通过轮询每个路由项进行逐次匹配来实现,却让时间复杂度提升为O(N)。尽管在go1.12
中进行了优化,用一个切片保存所有注册的路由路径,并通过从长到短的顺序进行了排序以此提升了路由寻址的效率,但时间复杂度依旧为O(N)。
这也是我个人认为标准库设计不太合理的地方,当然为了功能兼容性考虑,golang标准库关于这个地方的代码也很难会有所改变。
标准库的路由还提供了一些不太常用的功能,如可以限定对某个host的请求、url路径中可以存在.
以及..
。这些实现不难,但比较琐碎,所以除了删除最长子串匹配这个功能外,我们也会省略这些,实现会比标准库简单许多。
Handler、HandlerFunc、Handle、HandleFunc
我相信这个是让很多新手头疼的问题,几个概念容易混淆,我们这里一回解释清楚。
Handler
和HandlerFunc
本质是同一个东西,都可以理解为回调函数,只是说表现形式不同。它们都用来告诉我们的框架如何处理一个http请求,我们的业务代码就写在这两者中。
有了回调函数还不行,你还得将它们注册到server上,那么Handle
和HandleFunc
就用于注册。Handle
注册Handler
,HandleFunc
注册HandlerFunc
。
看下这四者的签名:
|
|
Handler和HandlerFunc基本是等价的,它们都代表了如何处理一个http请求,拿到一个Handler以及HandlerFunc,只是在使用上略微不同:
|
|
所以使用Handle和HandleFunc来注册路由,完全看哪个方便以及个人喜好。
代码实现
我们新建一个ServeMux
结构体,由它来完成路由寻址的工作:
|
|
像一个路径对应一个处理函数的1:1
的方式,很自然就联想到用一个map存储路由表项。用户通过Handle
以及HandleFunc
方法插入表项:
|
|
我们的ServeMux要整合入当前框架,就需要实现Handler接口:
|
|
我们框架允许/index/
映射到/index/
以及/index
,当然两者都存在时,前者优先。
在readRequest以及setupResponse后,conn.go
的46行中,会触发ServeMux的ServeHTTP方法,在这个方法中根据请求的路径找出对应的HandlerFunc,将w和r作为参数传入即可。实现极为简单,这时我们写出的代码就和本文开头标准库示例一样了:
|
|
DefaultServeMux
你可能还是觉得有点不方便,还是需要调用NewServeMux
生成一个ServeMux实例,然后给Server创建实例并初始化,依旧比较繁琐。我们通过在框架内初始化一个全局ServeMux实例的方式改进:
|
|
是不是突然解决了你的某些困惑,为什么标准库能直接通过http.HandleFunc
以及http.Handle
这两个函数注册路由?就是因为它们是对一个全局的变量DefaultServeMux
进行了操作。
ListenAndServe函数中,如果用户传入的handler为nil,我们使用DefaultServeMux作为handler。
测试
main.go代码如下:
|
|
利用curl测试:
|
|
至此为止,该系列的全部内容已经完成了,其实还有很多可以增加的内容,比如静态文件系统、https tls的支持等。
在写这篇博客前,其实我已经把静态文件系统的代码给实现了一遍,但想了想这只是一个框架基础上的功能拓展工作,已经偏离了我们的主题——网络框架的核心设计以及http协议的解析。而对于https,如果要完全讲清楚,就不只是单单一两篇文章就能搞定了,所以我们的系列就在此浅尝则止,待以后突发奇想或者来兴致了,再来补坑。
说在最后,其实看懂net/http标准库的源码,我只花了三天时间,自己查资料、阅读文档以及代码实现也总共陆陆续续用了五天左右,但如何将这个过程通过文字的方式表达出来,将思路过程一步步呈现,还是伤了我不少脑筋,花费了成倍于前面的精力。创作极为不易,如果觉得对您有帮助,欢迎您的赞赏,同时也希望您能将这个系列分享给更多的朋友,一起学习一起进步!
系列目录
- 01、go语言从0实现net/http标准库(序):概述
- 02、go语言从0实现net/http标准库(一):框架骨干
- 03、go语言从0实现net/http标准库(二):Request首部字段
- 04、go语言从0实现net/http标准库(三):Request设置Body
- 05、go语言从0实现net/http标准库(四):multipart表单
- 06、go语言从0实现net/http标准库(五):表单API封装
- 07、go语言从0实现net/http标准库(六):response
- 08、go语言从0实现net/http标准库(终):静态路由
- 原文作者:辜飞俊
- 原文链接:https://gufeijun.com/post/httpframe/8/
- 版权声明:本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。