上个月月末我按照 Geektutu 的教程,实现了 Gee 这个基于 Golang 的简单 Web 框架,但是一直没有进行复盘总结。学习 Gee 的八篇文章的链接如下:
现在让我们按照与回顾 Zinx 框架类似的套路,对 Gee 框架进行总结。
现在我们来对 Gee 项目进行总结。
和 Zinx 类似,我们从使用了 Gee 的一个 main 函数出发,来理解:
一个使用 Gee 框架的简单 web 应用如下,这个应用只包含 HTTP 的 GET 方法,即:将静态的数据展示在前端的页面当中。
package main
import (
"gee/gee"
"net/http"
)
func main() {
r := gee.New()
r.Use(gee.Logger(), gee.Recovery())
r.GET("/", func(c *gee.Context) {
c.String(http.StatusOK, "Hello Geektutu\n")
})
// index out of range for testing Recovery()
r.GET("/panic", func(c *gee.Context) {
names := []string{"geektutu"}
c.String(http.StatusOK, names[100])
})
v1 := r.Group("/v1")
{
v1.GET("/hello", func(c *gee.Context) {
c.String(http.StatusOK, "A simple hello string to test Group")
})
v1.GET("/hello/:name", func(c *gee.Context) {
// expect /localhost:9999/v1/hello/yggp
c.String(http.StatusOK, "hello from %s, you're at %s now. \n", c.Param("name"), c.Path)
})
v1.GET("/hello2", func(c *gee.Context) {
// expect /localhost:9999/v1/hello2?name=yggp
c.String(http.StatusOK, "Another hello from %s, you're at %s now. \n", c.Query("name"), c.Path)
})
v1.GET("/json", func(c *gee.Context) {
c.JSON(http.StatusOK, gee.H{
"username": c.PostForm("username"),
"password": c.PostForm("password"),
})
})
}
v2 := r.Group("/v2")
{
v2.GET("/assets/*filepath", func(c *gee.Context) {
c.JSON(http.StatusOK, gee.H{"filepath": c.Param("filepath")})
})
}
r.Run(":9999")
}
从这个 main 函数入手,我们可以看到,首先使用 r := gee.New()
这个语句应该是生成了一个 Gee 框架的 web 引擎,之后可以通过 Use
的方式将中间件集成到框架当中。之后,与 GIN 框架类似,通过使用 GET
方法,可以为 Gee 注册一个相应的 handler,用于在访问指定 URL 的时候,触发相应的事件。
当然,和 GIN 类似,Gee 也支持分组,上面的应用创建了两个 Gee 的分组,分别是 v1 和 v2。Gee 还支持模糊查询,我们用到了:
和*
,并且 Gee 还支持解析带有参数的 URL,通过 Query 方法来完成。
此外,还要提到的一点是 Gee 支持以多种格式返回 HTTP 响应,上例当中包含了 String 和 JSON 等格式。Gee 还支持 HTML 格式。下面我们深入 Gee 的源码,来理解 Gee 的设计思想,并在这个过程中回答我们最初提出的三个问题。
在出发之前,我们需要看清楚,为什么我们需要踏上这一趟旅程。
一个最直接的问题就是,Golang 已经具备了基础的 web 功能,为什么还需要设计 web 框架?
要回答这个问题,我们需要先回顾,原生的 Golang web 库是如何实现一个 web 功能开发的。
我们将 web 功能开发做最大程度的简化,即:当我们得到一个 request 的时候,不进行业务处理,直接回显一些数据字段作为 response。一个简单的实现如下,这里我直接复制 Geektutu 在序言当中给出的经典案例:
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/count", counter)
log.Fatal(http.ListenAndServe("localhost:8000", nil))
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "URL.Path = %q\n", r.URL.Path)
}
上面这个语句段实现的功能是:在 main.go
中注册了 /
和 /count
两个 URL,并通过 HandlerFunc 类型的 handler 和 counter 赋予两个 URL 以行为,也就是说,当服务开启后,我们在浏览器访问指定的 URL,即可看到 HandlerFunc 对应的行为执行的结果。最后通过 http.ListenAndServe
开启 web 服务的 IP 和 port。
它和我们刚才使用的 Gee 相比,有诸多的弊端。首先,它不支持中间件,无法在开启 web 服务的前后以及 web 服务的过程中做一些必要的工作,比如日志记录、错误恢复等。其次,原生的 web 功能显然不支持 URL 的模糊查询,也就是说我们必须给出精准的 URL 才能正常执行功能。再次,原生的 web 功能不支持分组,不便于管理,需要人工记录具体的 URL 前缀。最后,原生的 web 没有对 HandlerFunc 进行封装,也就是说我们实现每一个业务功能时,都需要重写一个以 http.ResponseWriter
和 *http.Request
为形参的 HandlerFunc。
显然上述实现对 web 应用的开发者来说非常不友好,对 web 功能进行更好的封装,形成一个 web 框架,有助于提高效率。在深入 Gee 之前,我们先来对比两个 main 函数当中业务注册的部分,看看二者有什么不同。
在使用原生 web 库的时候,我们是通过 http.HandlerFunc
进行 URL 和业务的绑定。而 Gee 框架中是通过:
r.GET("/", func(c *gee.Context){
// ... ... ...
})
来完成业务的绑定。后者将 ResponseWriter
和 *http.Request
封装到了 Context
当中,在二者的基础上,Context 还进行了进一步的拓展,其承载的功能远多于原生的 web 库,比如 Context 可以保存 URL 路径、HTTP Method、StatusCode、URL 当中的参数等。
综上所述,通过使用和不使用框架的对比,我们已经基本理解了 web 框架的必要性。
我们刚才已经提到了 Gee 实现的几乎所有功能,因此针对这个问题,我们将深入 Gee 框架的源码,对 Gee 进行剖析。
仍然从 main.go
出发,首先我们使用 r := gee.New()
创建了一个 Gee 的句柄。gee.New()
的返回值是一个 *Engine
。那么我们为什么需要 Engine 呢?
要回答这个问题,我们仍然需要回到 Golang 原生的 web 库当中去。当我们调用 http.ListenAndServe 时,需要指定两个参数,第一个是服务开启的地址,而第二个参数是一个 Handler,我们在最初使用原生 web 库的时候将其设置为 nil,实际上可以通过实现 Handler 来完成业务的注册:
package main
import (
"fmt"
"log"
"net/http"
)
// Engine is the uni handler for all requests
type Engine struct{}
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/":
fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
case "/hello":
for k, v := range req.Header {
fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
}
default:
fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
}
}
func main() {
engine := new(Engine)
log.Fatal(http.ListenAndServe(":9999", engine))
}
因此这个 Engine
就是我们实现 web 框架的入口。
所以我们自然要看一下 Engine
的实现:
// Engine implements the interface of ServeHTTP
type Engine struct {
*RouterGroup // embed a RouterGroup pointer
router *router
groups []*RouterGroup // store all groups
htmlTemplates *template.Template // for html render
funcMap template.FuncMap // for html render
}
Engine
结构本身并不复杂,它包含五个成员,分别是*RouterGroup
、router
、group
、htmlTemplates
和 funcMap
,后两个是用于渲染 HTML 模板的,鉴于前后端分离的开发模式是目前 web 开发的主流,我们一般不会在后端直接对 HTML 进行渲染,而是应该将专业的事情交给前端,后端只需要处理好业务逻辑并按照 RESTful 接口返回 json 给前端即可,因此在总结 Gee 的过程中我不会深入探讨后两个成员及与 HTML 渲染相关的方法,而重点关注如何实现 web 框架。
Engine 嵌入了一个 *RouterGroup
,而之后我们又将看到 RouterGroup 中具有一个 *Engine
成员,实际上这就是 Engine 和 RouterGroup 相互引用的体现。Engine 本身就是一个 RouterGroup,它的URL是 /
,即“根”,因此在其之上拓展出来的 Group 都是它的子集。鉴于我们有可能要在 root 上使用一些中间件,因此嵌入 RouterGroup 可以使 Engine 获得 RouterGroup 的一些行为,而不需要重复实现。
router 成员是路由指针 *router
类型,每一个 Engine 只具有一个 router,router 的作用也非常简单,就是对添加的路由进行管理。比如我在根 URL 目录下添加了 /hello
和 /users/:name
,那么 router 就应该帮助我将 HandlerFunc 与目录绑定,并记录 :name
这类模糊前缀。
groups 是一个 *RouterGroups
类型的 slice,这里要理清 groups 和内嵌的 *RouterGroups
之间的区别。内嵌一个 *RouterGroups
使得 Engine 获得了 RouterGroup 的行为,而 groups 保存的是 Engine 根目录下辖的其它 Group,比如 v1
等。
Engine 的工厂函数如下:
func New() *Engine {
engine := &Engine{router: newRouter()}
engine.RouterGroup = &RouterGroup{engine: engine}
engine.groups = []*RouterGroup{engine.RouterGroup}
return engine
}
它首先创建了一个 *Engine
,并创建了一个 *RouterGroup
赋给 engine,同时需要将 engine 赋值给 RouterGroup,原因在于二者是相互引用的,互相绑定的是指针。groups 同样需要初始化,它最初包含的成员就是 engine 这个根 RouterGroup。完成初始化后,返回新创建的 *Engine
,就可以开始使用了。
由于 Engine 需要实现 http.Handler 这个接口,因此我们需要实现接口的方法 ServeHTTP,其实现是:
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var middlewares []HandlerFunc
for _, group := range engine.groups {
if strings.HasPrefix(req.URL.Path, group.prefix) {
middlewares = append(middlewares, group.middlewares...)
}
}
c := newContext(w, req)
c.handlers = middlewares
c.engine = engine
engine.router.handle(c)
}
ServeHTTP 方法要做的事情其实就是处理一次 HTTP Request。在 ServeHTTP 方法当中,首先创建了一个 HandlerFunc 类型的 slice,之后对 engine 包含的 groups 进行遍历,如果当前请求的 URL 包含 group 的前缀,那么就需要使用这个 group 所注册的中间件对这个请求进行处理,将中间件对应的 HandlerFunc 追加到 middlewares slice 当中。处理好中间件 HandlerFunc 之后,我们要做的是新建一个有关本次 HTTP Request 的 Context,将与本次请求相关的信息保存到 Context 当中。
Context 指的就是上下文,它的结构定义如下:
type Context struct {
// origin objects
// 封装了 http.ResponseWriter 和 *http.Request
Writer http.ResponseWriter
Req *http.Request
// request info
Path string
Method string
Params map[string]string
// response info
StatusCode int
// middleware
handlers []HandlerFunc
index int
// engine pointer
engine *Engine
}
Context 对 http.ResponseWriter
和 *http.Request
进行了封装,其中还保存了一些额外的请求信息,比如本次请求的 URL 路径、方法(GET/POST)以及模糊查询的参数。此外,Context 中还保存了相应信息,比如 HTTP 状态码。保存了中间件对应的 HandlerFunc slice 以及执行与 HandlerFunc slice 对应的 index,index 用于控制当前执行到哪个 HandlerFunc。最后,Context 还引用了 *Engine
,每一个 Context 绑定的都是同一个 Engine,便于 Context 快速对 Engine 进行访问。
ServeHTTP 当中新建一个 Context 调用的工厂函数如下:
func newContext(w http.ResponseWriter, req *http.Request) *Context {
return &Context{
Writer: w,
Req: req,
Path: req.URL.Path,
Method: req.Method,
index: -1,
}
}
可以看到此处的 index 成员被初始化为 -1
,原因我们将在后面提到 Next()
的时候讲到。
回到 ServeHTTP,它新建一个 Context 之后,将 middlewares 和 engine 赋值给 Context 对应的字段,便可以开始处理具体的业务了。这里我需要再强调一下,ServeHTTP 这个方法做的事情就是当一个 Request 到来时,处理这个 Request,并返回具体的 Response。一个例子就是当服务开启后,用户在浏览器输入 URL 并进入,此时 ServeHTTP 开始工作,结束后将 Response 反馈到浏览器上(如果有数据要反馈的话)。
具体的业务函数以及中间件通过 engine.router.handle(c)
来进行工作。即通过调用 engine 的 router 成员的 handle 方法来开始工作。所以我们现在来看一下 router 的作用。
router 的结构定义如下:
type router struct {
roots map[string]*node
handlers map[string]HandlerFunc
}
顾名思义,router 所做的就是保存注册的 URL,并将具体的业务与 URL 相绑定。当 ServeHTTP 工作时,就需要来到 router 查找具体的 HandlerFunc,调用 HandlerFunc 完成业务处理。
router 的结构中包含两个字段,分别是 roots 和 handlers,二者都是 map 类型,roots 是 map[string]*node
,而 handlers 是 map[string]HandlerFunc
。
我们先来谈 roots。显然 roots 的作用是保存注册的 URL 路径,通过 router 的 addRoute 方法来完成。addRoute 方法的定义如下:
func (r *router) addRoute(method, pattern string, handler HandlerFunc) {
parts := parsePattern(pattern)
key := method + "-" + pattern
_, ok := r.roots[method]
if !ok {
r.roots[method] = &node{}
}
r.roots[method].insert(pattern, parts, 0)
r.handlers[key] = handler
}
它的输入是 method 和 pattern 两个 string,method 对应的是 GET 或 POST,而 pattern 就是具体的 URL,最后一个输入是 HandlerFunc 类型的 handler,即要绑定的业务函数。
在 addRouter 当中,首先通过 parsePattern 方法将 URL 以 /
为单位进行分割,得到每一个部分。roots 这个 map 的 key 由 method 和 pattern 共同组成,它的 value 是 *node
。通过调用 node 的 insert 方法,将 pattern 插入到 router 当中,最后将 handler 与 method + pattern 构成的 key 相绑定。
重点在于,我们如何将 pattern 插入到 router 当中,node 的 insert 方法背后做了什么。
这就引出了 Trie 树实现的路由字典。Trie 树是一种字典树,它以字符串当中共同的前缀作为树中的节点,向下不断地拓展,直到整个字符串结束。由于 URL 当中天然使用 /
对字符串进行分割,因此非常适合使用 Trie 树来对 URL 进行保存。基于 Trie 树的路由详见:【Gee】Day3:前缀树路由,此处不再重复。
成功将 URL 及其对应的业务函数注册到 router 当中之后,当我们真正处理业务时,我们需要根据用户输入的 URL 找到对应的业务函数。由于 roots 是一个以 string 为 key,以 *node
为 value 的字典,而我们可以利用 *node
当中保存的 pattern 加上 method 的方法恢复出保存着 HandlerFunc 的 key,因此我们需要实现一个查找路由的方法,它的返回值应该是 *node
。与此同时,我们实现的 Gee 应该能够进行模糊查询,因此这个方法应该能够将输入 URL 当中的参数与注册 URL 当中的模糊参数进行绑定,因此便有了 getRoute 方法:
func (r *router) getRoute(method, path string) (*node, map[string]string) {
searchParts := parsePattern(path)
params := make(map[string]string)
root, ok := r.roots[method]
if !ok {
return nil, nil
}
n := root.search(searchParts, 0)
if n != nil {
parts := parsePattern(n.pattern)
for index, part := range parts {
if part[0] == ':' {
params[part[1:]] = searchParts[index]
}
if part[0] == '*' && len(part) > 1 {
params[part[1:]] = strings.Join(searchParts[index:], "/")
break
}
}
return n, params
}
return nil, nil
}
简单描述一下 getRoute 的工作流程。如果 roots 中没有注册这个 URL 对应的 method(GET/POST),那么直接返回 nil 即可。如果有 method 的注册,才进一步寻找输入的 URL path 是否注册到了 router 当中。此处调用 node 的 search 方法进行查找,它将会递归地在 Trie 树中查找匹配的 URL。如果能够找到,那么处理模糊参数,保存到 params 这个字典当中,处理完之后将 *node
类型的 n 和 params 返回即可。
根据输入的 URL,查找到 router 中保存的 node 之后,我们就可以开始调用 HandlerFunc 进行业务处理了。我们刚才已经提到,ServeHTTP 方法的最后正是调用了 router 的 handle 方法进行最终的业务处理,它的输入是 *Context
,因此 router 需要实现 handle 这个方法来完成最终的业务处理:
func (r *router) handle(c *Context) {
n, params := r.getRoute(c.Method, c.Path)
if n != nil {
key := c.Method + "-" + n.pattern
c.Params = params
c.handlers = append(c.handlers, r.handlers[key])
} else {
c.handlers = append(c.handlers, func(c *Context) {
c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
})
}
c.Next()
}
首先,通过 getRoute 我们可以得到 node 和模糊参数字典 params,如果 node 不为 nil,我们就根据 method 和 n.pattern 恢复出 handlers 这个字典的 key,通过 key 可以访问到这个 URL 对应的注册业务函数。我们需要将这个函数追加到 Context 的 handlers slice 当中。Context 的 handlers 已经保存了中间件的业务函数,中间件和具体的业务都需要执行,其具体的顺序通过 c.Next()
来控制。
最后,调用 c.Next()
,即可开始整个业务的处理。至此,我们梳理清楚了业务的注册以及当一个 HTTP Request 到来时,Gee 是如何 URL 注册的业务函数及中间件对 Request 进行处理的。
我们来看一下 c.Next()
的实现,它是 Context 类型的方法:
func (c *Context) Next() {
c.index++
s := len(c.handlers)
for ; c.index < s; c.index++ {
c.handlers[c.index](c)
}
}
还记得嘛?之前我们已经提到,index 的初始值为 -1
,因此当调用 c.Next()
的时候,会将 index 递增为 0
,此时遍历 c.handlers
开始执行对应的业务函数即可。在 c.handlers
当中,保存的不仅仅是具体的业务处理函数,还保存着当前分组下的中间件。中间件的执行顺序与 c.handlers
及下标 index 的变动有关。一个简单的例子是,如果我们想要业务函数先执行,中间件的 index 为 0,而业务函数为 1,那么在中间件的 HandlerFunc 中先调用 c.Next()
即可。
根据 demo 当中给出的 main.go
实现,我们几乎已经讲解完了 Gee 的工作原理。最后我们来谈一下,如何用 Gee 来构建 web 项目。
我们已经提到,目前主流的 web 应用开发模式是前后端分离的,即:前端将输入数据传给后端,后端再将业务处理后的输出结果返回给前端,在前端负责数据渲染与用户交互,而后端专注于业务处理。因此一个 web 框架重要的是如何解析和返回不同格式的数据。
此处我们将这个问题大大地简化,虽然有失一般性,但是却是极具代表性的应用场景,即 Gee 如何处理 JSON 格式的数据?
在 demo 当中,我们使用了:
v2 := r.Group("/v2")
{
v2.GET("/assets/*filepath", func(c *gee.Context) {
c.JSON(http.StatusOK, gee.H{"filepath": c.Param("filepath")})
})
}
上述代码通过 GET 方法在客户端显示 JSON 格式的数据,那么我们来看看 c.JSON
究竟做了什么:
func (c *Context) JSON(code int, obj interface{}) {
c.SetHeader("Content-Type", "application/json")
c.Status(code)
encoder := json.NewEncoder(c.Writer)
if err := encoder.Encode(obj); err != nil {
http.Error(c.Writer, err.Error(), 500)
}
}
实际上 c.JSON
就是对 Gee 发送 JSON 格式数据的封装,它会通过 c.SetHeader
设置请求头(实际上是对 c.Writer
的 Header
进行设置),并设置状态码,最后通过 json 的 Encoder 对 JSON 数据进行编码。传递进来的数据是一个空接口类型,意味着只要符合 JSON 数据的格式,就可以通过 json 的 Encoder 将空接口转为 JSON 结构。
通过新建的 json Encoder 将空接口结构编码并发送,即可完成从后端向前端 JSON 数据的传递。
最后,通过调用:
r.Run(":9999")
即可开启基于 Gee 的 web 服务,服务的地址是 localhost:9999
。Run 方法其实就是对 http 的 ListenAndServe 的封装。
至此,我们基本完成了对 Gee 项目的总结,从底层全面回顾了 Gee 的实现,并分析了 Gee 设计模式的理由与合理性。通过 Gee,我加深了自己对 GIN 的理解。原来开发一个 web 框架,甚至说开发轮子,其实并没有那么难。