本文给我们的框架提供静态路由功能,因为不涉及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手动设置路由,极为麻烦:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
func (*myHandler) ServeHTTP(w httpd.ResponseWriter,r *httpd.Request){
	switch r.URL.Path {
	case "/":
		//do something
	case "/index":
		//do something
	case "/photo":
		//do something
	//....	
	}	
}

如果以后想增加路由项的话,需要频繁修改ServeHTTP方法,极为不方便且增加了出错的可能。因此标准库提供了更为方便的写法:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
func main() {
	serveMux := http.NewServeMux()
	serveMux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
		//do something	
	})
	serveMux.HandleFunc("/index", func(w http.ResponseWriter, r *http.Request) {
		//do something	
	})
	serveMux.HandleFunc("/photo", func(w http.ResponseWriter, r *http.Request) {
		//do something	
	})
	svr := &http.Server{
		Addr: "127.0.0.1:80",
		Handler: serveMux,
	}	
	svr.ListenAndServe()
}

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

我相信这个是让很多新手头疼的问题,几个概念容易混淆,我们这里一回解释清楚。

HandlerHandlerFunc本质是同一个东西,都可以理解为回调函数,只是说表现形式不同。它们都用来告诉我们的框架如何处理一个http请求,我们的业务代码就写在这两者中。

有了回调函数还不行,你还得将它们注册到server上,那么HandleHandleFunc就用于注册。Handle注册HandlerHandleFunc注册HandlerFunc

看下这四者的签名:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
//Handler
type Handler interface {
	ServeHTTP(ResponseWriter, *Request)		
}

//HandlerFunc
type HandlerFunc func(ResponseWriter, *Request)

//Handle,一个对pattern路径的http请求,会交给handler中的ServeHTTP处理
func (mux *ServeMux) Handle(pattern string, handler Handler)

//HandleFunc,一个对pattern路径的http请求,会交给callback处理
func (mux *ServeMux) HandleFunc(pattern string, callback HandlerFunc) 

Handler和HandlerFunc基本是等价的,它们都代表了如何处理一个http请求,拿到一个Handler以及HandlerFunc,只是在使用上略微不同:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
var(
	w http.ResponseWriter
    r *http.Request
    handler http.Handler
    handlerFunc http.HandlerFunc
)

// //typeof(handler.ServeHTTP) = HandlerFunc
//使用handler
handler.ServeHTTP(w,r)
//使用handlerFunc
handlerFunc(w,r)

//使用HanldeFunc注册路由
http.HandleFunc("/index",handlerFunc)
//使用Handle注册路由
http.Handle("/index",handler)

所以使用Handle和HandleFunc来注册路由,完全看哪个方便以及个人喜好。

代码实现

我们新建一个ServeMux结构体,由它来完成路由寻址的工作:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
type HandlerFunc func(ResponseWriter, *Request)

type Handler interface {
	ServeHTTP(w ResponseWriter, r *Request)
}

type ServeMux struct {
	m map[string]HandlerFunc	//利用map存取路由
}

func NewServeMux() *ServeMux {
	return &ServeMux{
		m: make(map[string]HandlerFunc),
	}
}

像一个路径对应一个处理函数的1:1的方式,很自然就联想到用一个map存储路由表项。用户通过Handle以及HandleFunc方法插入表项:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
func (sm *ServeMux) HandleFunc(pattern string, cb HandlerFunc) {
	if sm.m == nil {
		sm.m = make(map[string]HandlerFunc)
	}
	sm.m[pattern] = cb
}

func (sm *ServeMux) Handle(pattern string, handler Handler) {
	if sm.m == nil {
		sm.m = make(map[string]HandlerFunc)
	}
	sm.m[pattern] = handler.ServeHTTP
}

我们的ServeMux要整合入当前框架,就需要实现Handler接口:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
func (sm *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
    // 查看路由表项是否存在对应entry
	handler, ok := sm.m[r.URL.Path]
	if !ok {
		if len(r.URL.Path) > 1 && r.URL.Path[len(r.URL.Path)-1] == '/' {
			handler, ok = sm.m[r.URL.Path[:len(r.URL.Path)-1]]
		}
		if !ok {
			w.WriteHeader(StatusNotFound)
			return
		}
	}
	handler(w, r)
}

我们框架允许/index/映射到/index/以及/index,当然两者都存在时,前者优先。

在readRequest以及setupResponse后,conn.go的46行中,会触发ServeMux的ServeHTTP方法,在这个方法中根据请求的路径找出对应的HandlerFunc,将w和r作为参数传入即可。实现极为简单,这时我们写出的代码就和本文开头标准库示例一样了:

1
2
3
4
5
6
7
8
9
sm := httpd.NewServeMux()
sm.HandleFunc("/a",callback1)
sm.HandleFunc("/b",callback2)
// ...
svr := &httpd.Server{
    Addr:addr,
    Handler:sm,
}
panic(svr.ListenAndServe())

DefaultServeMux

你可能还是觉得有点不方便,还是需要调用NewServeMux生成一个ServeMux实例,然后给Server创建实例并初始化,依旧比较繁琐。我们通过在框架内初始化一个全局ServeMux实例的方式改进:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
var defaultServeMux ServeMux

var DefaultServeMux = &defaultServeMux

func HandleFunc(pattern string, cb HandlerFunc) {
	DefaultServeMux.HandleFunc(pattern, cb)
}

func Handle(pattern string, handler Handler) {
	DefaultServeMux.Handle(pattern, handler)
}

func ListenAndServe(addr string, handler Handler) error {
    // 如果handler为nil,则默认使用DefaultServerMux
	if handler == nil {
		handler = DefaultServeMux
	}
	svr := &Server{
		Addr:    addr,
		Handler: handler,
	}
	return svr.ListenAndServe()
}

是不是突然解决了你的某些困惑,为什么标准库能直接通过http.HandleFunc以及http.Handle这两个函数注册路由?就是因为它们是对一个全局的变量DefaultServeMux进行了操作。

ListenAndServe函数中,如果用户传入的handler为nil,我们使用DefaultServeMux作为handler。

测试

main.go代码如下:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
package main

import (
	"example/httpd"
	"io"
)

//foo2Handler实现了Handler接口
type foo2Handler struct{}

func(*foo2Handler) ServeHTTP(w httpd.ResponseWriter, r *httpd.Request){
    io.WriterString(w, "/foo2")
}

func main() {
	httpd.HandleFunc("/foo1", func(w httpd.ResponseWriter, r *httpd.Request) {
		io.WriteString(w, "/foo1")
	})
    //使用Handle方式
    httpd.Handle("/foo2", new(foo2Handler))
	httpd.HandleFunc("/foo1/bar1", func(w httpd.ResponseWriter, r *httpd.Request) {
		io.WriteString(w, "/foo1/bar1")
	})
	httpd.ListenAndServe("127.0.0.1:80", nil)
}

利用curl测试:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
$ curl -i http://127.0.0.1/foo1
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 5

/foo1

$ curl -i http://127.0.0.1/foo2
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 5

/foo2

$ curl -i http://127.0.0.1/foo3
HTTP/1.1 404 Not Found
Content-Length: 0

$ curl -i http://127.0.0.1/foo1/bar1
HTTP/1.1 200 OK
Content-Type: text/plain; charset=utf-8
Content-Length: 10

/foo1/bar1

至此为止,该系列的全部内容已经完成了,其实还有很多可以增加的内容,比如静态文件系统、https tls的支持等。

在写这篇博客前,其实我已经把静态文件系统的代码给实现了一遍,但想了想这只是一个框架基础上的功能拓展工作,已经偏离了我们的主题——网络框架的核心设计以及http协议的解析。而对于https,如果要完全讲清楚,就不只是单单一两篇文章就能搞定了,所以我们的系列就在此浅尝则止,待以后突发奇想或者来兴致了,再来补坑。

说在最后,其实看懂net/http标准库的源码,我只花了三天时间,自己查资料、阅读文档以及代码实现也总共陆陆续续用了五天左右,但如何将这个过程通过文字的方式表达出来,将思路过程一步步呈现,还是伤了我不少脑筋,花费了成倍于前面的精力。创作极为不易,如果觉得对您有帮助,欢迎您的赞赏,同时也希望您能将这个系列分享给更多的朋友,一起学习一起进步!

系列目录