文章目录
- Gee 项目回顾
- Gee 项目总结
- Golang 已经具备基础的 web 功能,为什么还需要 web 框架?
- 作为 web 框架,Gee 框架完成了哪些功能?
- 如何用 Gee 来构建 web 项目?
Gee 项目回顾
上个月月末我按照 Geektutu 的教程,实现了 Gee 这个基于 Golang 的简单 Web 框架,但是一直没有进行复盘总结。学习 Gee 的八篇文章的链接如下:
- 【Gee】7天用 Go 从零实现 Web 框架 Gee
- 【Gee】Day1:HTTP 基础
- 【Gee】Day2:上下文
- 【Gee】Day3:前缀树路由
- 【Gee】Day4:分组控制
- 【Gee】Day5:中间件
- 【Gee】Day6:模板 Template
- 【Gee】Day7:错误恢复
现在让我们按照与回顾 Zinx 框架类似的套路,对 Gee 框架进行总结。
Gee 项目总结
现在我们来对 Gee 项目进行总结。
和 Zinx 类似,我们从使用了 Gee 的一个 main 函数出发,来理解:
- 在 Golang 的 web 开发项目中,为什么需要 web 框架?
- 作为 web 框架,Gee 框架完成了哪些功能?
- 如何使用 Gee 框架构建 web 项目?
一个使用 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 框架?
要回答这个问题,我们需要先回顾,原生的 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 框架的必要性。
作为 web 框架,Gee 框架完成了哪些功能?
我们刚才已经提到了 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 项目。
如何用 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 框架,甚至说开发轮子,其实并没有那么难。