序言
在Go语言中,net/http标准库提供了基础的Web功能,如下:
func main() {
http.HandleFunc("/", handler)
log.Fatal(http.ListenAndServe(":8090", nil))
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "URL.Path = %q\n", r.URL.Path)
}
即监听端口,映射静态路由,解析HTTP报文。
但是一些Web开发中简单的需求并不支持,需要自己手工实现。
- 动态路由:例如hello/:name,hello/*这类的规则。
- 鉴权:没有分组/统一鉴权的能力,需要在每个路由映射的handler中实现。
- 模板:没有统一简化的HTML机制。
- 错误恢复:客户端异常请求可能导致Web服务崩溃退出
zgin框架
我们会使用Go语言实现一个简单的Web框架,起名叫做zgin,没错它是参照模仿大名鼎鼎的Gin来实现的,Gin的代码总共是14K,其中测试代码9K,也就是说实际代码量只有5K。Gin是我非常喜欢的一个框架,与Python中的Flask很像,小而美。
HTTP封装
Go语言内置了 net/http库,封装了HTTP网络编程的基础的接口,我们实现的Gee Web 框架便是基于net/http的。我们接下来通过一个例子,简单介绍下这个库的使用。
func main() {
http.HandleFunc("/", handler)
http.HandleFunc("/header", zheader)
log.Fatal(http.ListenAndServe(":9080", nil))
}
func handler(w http.ResponseWriter, r *http.Request) {
fmt.Fprintf(w, "URL.PATH = %q\n", r.URL.Path)
}
func zheader(w http.ResponseWriter, r *http.Request) {
for k, v := range r.Header {
fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
}
}
我们设置了2个路由,/和/header,分别绑定 handler和 zheader , 根据不同的HTTP请求会调用不同的处理函数。访问/,响应是URL.Path = /,而/hello的响应则是请求头(header)中的键值对信息。
测试:
curl localhost:9080/
URL.PATH = "/"
curl localhost:9080/zhangxh
URL.PATH = "/zhangxh"
curl localhost:9080/header
Header["User-Agent"] = ["curl/7.79.1"]
Header["Accept"] = ["*/*"]
main 函数的最后一行,是用来启动 Web 服务的,第一个参数是地址,:9080表示在 9080 端口监听。而第二个参数则代表处理所有的HTTP请求的实例,nil 代表使用标准库中的实例处理。第二个参数,则是我们基于net/http标准库实现Web框架的入口。
http.Handler接口
package http
type Handler interface {
ServeHTTP(w ResponseWriter, r *Request)
}
func ListenAndServe(address string, h Handler) error
ListenAndServe函数的第二个参数的类型是什么呢?通过查看net/http的源码可以发现,Handler是一个接口,该接口拥有一个ServeHTTP 方法,也就是说,只要传入任何实现了 ServerHTTP 方法的实例,所有的HTTP请求,就都交给了该实例处理了。我们可以测试一下:
package main
import (
"fmt"
"log"
"net/http"
)
type Engine struct {}
func (e *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
switch req.URL.Path {
case "/":
fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
case "/header":
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(":9080", engine))
}
- 我们定义了一个空的结构体Engine,实现了方法ServeHTTP。这个方法有2个参数,第二个参数是 Request ,该对象包含了该HTTP请求的所有的信息,比如请求地址、Header和Body等信息;第一个参数是 ResponseWriter ,利用 ResponseWriter 可以构造针对该请求的响应。
- 在 main 函数中,我们给 ListenAndServe 方法的第二个参数传入了刚才创建的engine实例。至此,我们走出了实现Web框架的第一步,即,将所有的HTTP请求转向了我们自己的处理逻辑。还记得吗,在实现Engine之前,我们调用 http.HandleFunc 实现了路由和Handler的映射,也就是只能针对具体的路由写处理逻辑。比如/hello。但是在实现Engine之后,我们拦截了所有的HTTP请求,拥有了统一的控制入口。在这里我们可以自由定义路由映射的规则,也可以统一添加一些处理逻辑,例如日志、异常处理等。
- 运行代码结果与测试与之前是一致的响应
zgin框架的雏形
zgin
├── go.mod
├── main.go
└── zgin
└── zgin.go
zgin/main.go
package main
import (
"fmt"
"net/http"
"zgin/zgin"
)
func main() {
r := zgin.New()
r.GET("/", func(w http.ResponseWriter, req *http.Request) {
fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
})
r.GET("/hello", func(w http.ResponseWriter, req *http.Request) {
for k, v := range req.Header {
fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
}
})
r.Run(":9080")
}
看到这里,如果你使用过gin框架的话,肯定会觉得无比的亲切。gee框架的设计以及API均参考了gin。使用New()创建 gee 的实例,使用 GET()方法添加路由,最后使用Run()启动Web服务。这里的路由,只是静态路由,不支持/hello/:name这样的动态路由,动态路由将在后面实现。
zgin/zgin/zgin.go
package zgin
import (
"fmt"
"net/http"
)
// 定义HandlerFunc为func(w http.ResponseWriter,r *http.Request)函数类型
type HandlerFunc func(w http.ResponseWriter, r *http.Request)
// Engine implement the interface of ServeHTTP
type Engine struct {
router map[string]HandlerFunc
}
func New() *Engine {
return &Engine{
router: make(map[string]HandlerFunc),
}
}
func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
key := method + "-" + pattern
engine.router[key] = handler
}
// GET defines the method to add GET request
func (engine *Engine) GET(pattern string, handler HandlerFunc) {
engine.addRoute("GET", pattern, handler)
}
// POST defines the method to add POST request
func (engine *Engine) POST(pattern string, handler HandlerFunc) {
engine.addRoute("POST", pattern, handler)
}
// Run defines the method to start a http server
func (engine *Engine) Run(addr string) (err error) {
return http.ListenAndServe(addr, engine)
}
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
key := req.Method + "-" + req.URL.Path
if handler, ok := engine.router[key]; ok {
handler(w, req)
} else {
w.WriteHeader(http.StatusNotFound)
fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
}
}
那么zgin.go就是重头戏了。我们重点介绍一下这部分的实现。
首先定义了类型HandlerFunc,这是提供给框架用户的,用来定义路由映射的处理方法。我们在Engine中,添加了一张路由映射表router,key 由请求方法和静态路由地址构成,例如GET-/、GET-/hello、POST-/hello,这样针对相同的路由,如果请求方法不同,可以映射不同的处理方法(Handler),value 是用户映射的处理方法。
当用户调用(*Engine).GET()方法时,会将路由和处理方法注册到映射表 router 中,(*Engine).Run()方法,是 ListenAndServe 的包装。
Engine实现的 ServeHTTP 方法的作用就是,解析请求的路径,查找路由映射表,如果查到,就执行注册的处理方法。如果查不到,就返回 404 NOT FOUND 。
执行go run main.go,再用 curl 工具访问,结果与最开始的一致。

Context上下文
对Web服务来说,无非是根据请求*http.Request,构造响应http.ResponseWriter。但是这两个对象提供的接口粒度太细,比如我们要构造一个完整的响应,需要考虑消息头(Header)和消息体(Body),而 Header 包含了状态码(StatusCode),消息类型(ContentType)等几乎每次请求都需要设置的信息。因此,如果不进行有效的封装,那么框架的用户将需要写大量重复,繁杂的代码,而且容易出错。针对常用场景,能够高效地构造出 HTTP 响应是一个好的框架必须考虑的点。
用返回 JSON 数据作比较,感受下封装前后的差距。封装前:
obj = map[string]interface{}{
"name": "geektutu",
"password": "1234",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
if err := encoder.Encode(obj); err != nil {
http.Error(w, err.Error(), 500)
}
VS 封装后:
c.JSON(http.StatusOK, gee.H{
"username": c.PostForm("username"),
"password": c.PostForm("password"),
})
针对使用场景,封装*http.Request和http.ResponseWriter的方法,简化相关接口的调用,只是设计 Context 的原因之一。对于框架来说,还需要支撑额外的功能。例如,将来解析动态路由/hello/:name,参数:name的值放在哪呢?再比如,框架需要支持中间件,那中间件产生的信息放在哪呢?Context 随着每一个请求的出现而产生,请求的结束而销毁,和当前请求强相关的信息都应由 Context 承载。因此,设计 Context 结构,扩展性和复杂性留在了内部,而对外简化了接口。路由的处理函数,以及将要实现的中间件,参数都统一使用 Context 实例, Context 就像一次会话的百宝箱,可以找到任何东西。
具体代码实现:
zgin/zgin/context.go
type H map[string]interface{}
type Context struct {
//http对象
Writer http.ResponseWriter
Req *http.Request
//请求信息
Path string //请求路由
Method string //方法
//响应信息
StatusCode int
}
func newContext(w http.ResponseWriter, req *http.Request) *Context {
return &Context{
Writer: w,
Req: req,
Path: req.URL.Path,
Method: req.Method,
}
}
func (c *Context) PostForm(key string) string {
return c.Req.FormValue(key)
}
func (c *Context) Query(key string) string {
return c.Req.URL.Query().Get(key)
}
func (c *Context) Status(code int) {
c.StatusCode = code
c.Writer.WriteHeader(code)
}
func (c *Context) SetHeader(key string, value string) {
c.Writer.Header().Set(key, value)
}
func (c *Context) String(code int, format string, values ...interface{}) {
c.SetHeader("Content-Type", "text/plain")
c.Status(code)
c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
}
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)
}
}
func (c *Context) Data(code int, data []byte) {
c.Status(code)
c.Writer.Write(data)
}
func (c *Context) HTML(code int, html string) {
c.SetHeader("Content-Type", "text/html")
c.Status(code)
c.Writer.Write([]byte(html))
}
//代码最开头,给map[string]interface{}起了一个别名gee.H,构建JSON数据时,显得更简洁。
//Context目前只包含了http.ResponseWriter和*http.Request,另外提供了对 Method 和 Path 这两个常用属性的直接访问。
//提供了访问Query和PostForm参数的方法。
//提供了快速构造String/Data/JSON/HTML响应的方法。
路由Router
我们将和路由相关的方法和结构提取了出来,放到了一个新的文件中router.go,方便我们下一次对 router 的功能进行增强,例如提供动态路由的支持。 router 的 handle 方法作了一个细微的调整,即 handler 的参数,变成了 Context。
zgin/zgin/router.go
type router struct {
handlers map[string]HandlerFunc
}
func newRouter() *router {
return &router{
handlers: make(map[string]HandlerFunc),
}
}
// 方法和路由拼接成key,存入map中
func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {
log.Printf("Route %4s - %s", method, pattern)
key := method + "-" + pattern
r.handlers[key] = handler
}
//根据请求的方法和路径拼接成key查找handlers中是否存在,存在执行HandlerFunc类型的函数
func (r *router) handle(c *Context) {
key := c.Method + "-" + c.Path
if handler, ok := r.handlers[key]; ok {
handler(c)
} else {
c.Status(http.StatusNotFound)
c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
}
}
优化框架入口
zgin/zgin/zgin.go
// 定义HandlerFunc
type HandlerFunc func(*Context)
// Engine implement the interface of ServeHTTP
type Engine struct {
router *router
}
func New() *Engine {
return &Engine{
router: newRouter(),
}
}
func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
engine.router.addRoute(method, pattern, handler)
}
// GET defines the method to add GET request
func (engine *Engine) GET(pattern string, handler HandlerFunc) {
engine.addRoute("GET", pattern, handler)
}
// POST defines the method to add POST request
func (engine *Engine) POST(pattern string, handler HandlerFunc) {
engine.addRoute("POST", pattern, handler)
}
// Run defines the method to start a http server
func (engine *Engine) Run(addr string) (err error) {
return http.ListenAndServe(addr, engine)
}
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := newContext(w, req)
engine.router.handle(c)
}
将router相关的代码独立后,zgin.go简单了不少。最重要的还是通过实现了 ServeHTTP 接口,接管了所有的 HTTP 请求。相比上一节的代码,这个方法也有细微的调整,在调用 router.handle 之前,构造了一个 Context 对象。这个对象目前还非常简单,仅仅是包装了原来的两个参数,下面我们会慢慢地给Context插上翅膀。
下面改造下man.go函数,测一下效果
func main() {
r := zgin.New()
r.GET("/", func(c *zgin.Context) {
c.HTML(http.StatusOK, "<h1>Hello Zgin</h1>")
})
r.GET("/hello", func(c *zgin.Context) {
// expect /hello?name=zhangxh
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
})
r.POST("/login", func(c *zgin.Context) {
c.JSON(http.StatusOK, zgin.H{
"username": c.PostForm("username"),
"password": c.PostForm("password"),
})
})
r.Run(":9080")
}
go run main.go启动服务,借助curl测试:
curl localhost:9080/
<h1>Hello Zgin</h1>%
curl "http://localhost:9080/hello?name=zhangxh"
hello zhangxh, you're at /hello
curl "http://localhost:9080/login" -X POST -d 'username=geektutu&password=1234'
{"password":"1234","username":"geektutu"}
上面内容程序逻辑的大致流程图:

前缀树路由
Trie树
之前,我们用了一个非常简单的map结构存储了路由表,使用map存储键值对,索引非常高效,但是有一个弊端,键值对的存储的方式,只能用来索引静态路由。那如果我们想支持类似于/hello/:name这样的动态路由怎么办呢?所谓动态路由,即一条路由规则可以匹配某一类型而非某一条固定的路由。例如/hello/:name,可以匹配/hello/geektutu、hello/jack等。
动态路由有很多种实现方式,支持的规则、性能等有很大的差异。例如开源的路由实现gorouter支持在路由规则中嵌入正则表达式,例如/p/[0-9A-Za-z]+,即路径中的参数仅匹配数字和字母;另一个开源实现httprouter就不支持正则表达式。著名的Web开源框架gin 在早期的版本,并没有实现自己的路由,而是直接使用了httprouter,后来不知道什么原因,放弃了httprouter,自己实现了一个版本。

实现动态路由最常用的数据结构,被称为前缀树(Trie树)。看到名字你大概也能知道前缀树长啥样了:每一个节点的所有的子节点都拥有相同的前缀。这种结构非常适用于路由匹配,比如我们定义了如下路由规则:
- /:lang/doc
- /:lang/tutorial
- /:lang/intro
- /about
- /p/blog
- /p/related

HTTP请求的路径恰好是由/分隔的多段构成的,因此,每一段可以作为前缀树的一个节点。我们通过树结构查询,如果中间某一层的节点都不满足条件,那么就说明没有匹配到的路由,查询结束。
接下来我们实现的动态路由具备以下两个功能。
- 参数匹配:。例如 /p/:lang/doc,可以匹配 /p/c/doc 和 /p/go/doc。
- 通配 *。例如 /static/*filepath,可以匹配/static/fav.ico,也可以匹配/static/js/jQuery.js,这种模式常用于静态服务器,能够递归地匹配子路径。
Trie树实现
首先我们需要设计树节点上应该存储那些信息。
zgin/zgin/trie.go
type node struct {
pattern string //待匹配路由,/a/:lang/hello
part string //路由中的一部分,例如 :lang, hello
children []*node //子节点,例如[doc,tourl,introl]
isWild bool // 是否精确匹配,part 含有 : 或 * 时为true
}
对于路由来说,最重要的当然是注册与匹配了。开发服务时,注册路由规则,映射handler;访问时,匹配路由规则,查找到对应的handler。因此,Trie 树需要支持节点的插入与查询。插入功能很简单,递归查找每一层的节点,如果没有匹配到当前part的节点,则新建一个,有一点需要注意,/p/:lang/doc只有在第三层节点,即doc节点,pattern才会设置为/p/:lang/doc。p和:lang节点的pattern属性皆为空。因此,当匹配结束时,我们可以使用n.pattern == ""来判断路由规则是否匹配成功。例如,/p/python虽能成功匹配到:lang,但:lang的pattern值为空,因此匹配失败。查询功能,同样也是递归查询每一层的节点,退出规则是,匹配到了*,匹配失败,或者匹配到了第len(parts)层节点。
// 匹配节点是否已注册,用于插入
func (n *node) matchChild(part string) *node {
for _, child := range n.children {
if child.part == part || child.isWild {
return child
}
}
return nil
}
// 返回匹配成功的节点,用于查找
func (n *node) matchChildren(part string) []*node {
nodes := make([]*node, 0)
for _, child := range n.children {
if child.part == part || child.isWild {
nodes = append(nodes, child)
}
}
return nodes
}
// 路由插入
func (n *node) insert(pattern string, parts []string, height int) {
if len(parts) == height {
n.pattern = pattern
return
}
part := parts[height]
child := n.matchChild(part)
if child == nil {
child = &node{
part: part,
isWild: part[0] == ':' || part[0] == '*',
}
n.children = append(n.children, child)
}
child.insert(pattern, parts, height+1)
}
// 路由查询
func (n *node) search(parts []string, height int) *node {
if len(parts) == height || strings.HasPrefix(n.part, "*") {
if n.pattern == "" {
return nil
}
return n
}
part := parts[height]
children := n.matchChildren(part)
for _, child := range children {
result := child.search(parts, height+1)
if result != nil {
return result
}
}
return nil
}
//遍历节点信息,用于测试
func (n *node) travel(list *([]*node)) {
if n.pattern != "" {
*list = append(*list, n)
}
for _, child := range n.children {
child.travel(list)
}
}
// 打印node具体信息,用于测试
func (n *node) String() string {
return fmt.Sprintf("node{pattern=%s, part=%s, children=%v, isWild=%t}", n.pattern, n.part, n.children, n.isWild)
}
Router路由
Trie 树的插入与查找都成功实现了,接下来我们将 Trie 树应用到路由中去吧。我们使用 roots 来存储每种请求方式的Trie 树根节点。使用 handlers 存储每种请求方式的 HandlerFunc 。getRoute 函数中,还解析了:和*两种匹配符的参数,返回一个 map 。例如/p/go/doc匹配到/p/:lang/doc,解析结果为:{lang: “go”},/static/css/geektutu.css匹配到/static/*filepath,解析结果为{filepath: “css/geektutu.css”}。
zgin/zgin/router.go
type router struct {
handlers map[string]HandlerFunc
roots map[string]*node
}
func newRouter() *router {
return &router{
handlers: make(map[string]HandlerFunc),
roots: make(map[string]*node),
}
}
// 切割路由字段
func parsePattern(pattern string) []string {
vs := strings.Split(pattern, "/")
parts := make([]string, 0)
for _, item := range vs {
if item != "" {
parts = append(parts, item)
if item[0] == '*' {
break
}
}
}
return parts
}
// 方法和路由拼接成key,存入map中,初始化node树节点
func (r *router) addRoute(method string, pattern string, handler HandlerFunc) {
log.Printf("Route %4s - %s", method, pattern)
//router /hello/zhangxh -> [hello zhangxh]
parts := parsePattern(pattern)
// GET-/hello/zhangxh
key := method + "-" + pattern
_, ok := r.roots[method]
if !ok {
r.roots[method] = &node{}
}
r.roots[method].insert(pattern, parts, 0)
r.handlers[key] = handler
}
// 路由查询
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:], "/")
}
}
return n, params
}
return nil, nil
}
// 打印注册节点路由信息,用于测试
func (r *router) getRoutes(method string) []*node {
root, ok := r.roots[method]
if !ok {
return nil
}
nodes := make([]*node, 0)
root.travel(&nodes)
return nodes
}
Context与handle的变化
在 HandlerFunc 中,希望能够访问到解析的参数,因此,需要对 Context 对象增加一个属性和方法,来提供对路由参数的访问。我们将解析后的参数存储到Params中,通过c.Param(“lang”)的方式获取到对应的值。
zgin/zgin/context.go
type Context struct {
// origin objects
Writer http.ResponseWriter
Req *http.Request
// request info
Path string
Method string
Params map[string]string
// response info
StatusCode int
}
func (c *Context) Param(key string) string {
value, _ := c.Params[key]
return value
}
router中另一重要的点是,在调用匹配到的handler前,将解析出来的路由参数赋值给了c.Params。这样就能够在handler中,通过Context对象访问到具体的值了。
// 根据请求的方法和路径拼接成key查找handlers中是否存在,存在执行HandlerFunc类型的函数
func (r *router) handle(c *Context) {
n, params := r.getRoute(c.Method, c.Path)
if n != nil {
c.Params = params
key := c.Method + "-" + n.pattern
r.handlers[key](c)
} else {
c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
}
}
Demo
更新一下main函数的注册路由,看一下使用效果吧
func main() {
r := zgin.New()
r.GET("/", func(c *zgin.Context) {
c.HTML(http.StatusOK, "<h1>Hello Zgin</h1>")
})
r.GET("/hello", func(c *zgin.Context) {
// expect /hello?name=zhangxh
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
})
r.GET("/hello/:name", func(c *zgin.Context) {
// expect /hello/zhangxh
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
})
r.GET("/hi/:name/good", func(c *zgin.Context) {
// expect /hello/zhangxh
c.String(http.StatusOK, "hi %s good, you're at %s\n", c.Param("name"), c.Path)
})
r.GET("/assets/*filepath", func(c *zgin.Context) {
c.JSON(http.StatusOK, zgin.H{"filepath": c.Param("filepath")})
})
r.Run(":9080")
}
使用curl工具,测试结果:
curl "http://localhost:9080/"
<h1>Hello Zgin</h1>%
curl "http://localhost:9080/hello/zhangxh"
hello zhangxh, you're at /hello/zhangxh
curl "http://localhost:9080/hi/zhangxh/good"
hi zhangxh good, you're at /hi/zhangxh/good
curl "http://localhost:9080/assets/css/goodness.css"
{"filepath":"css/goodness.css"}
分组控制
分组的意义
分组控制(Group Control)是 Web 框架应提供的基础功能之一。所谓分组,是指路由的分组。如果没有路由分组,我们需要针对每一个路由进行控制。但是真实的业务场景中,往往某一组路由需要相似的处理。例如:
以/post开头的路由匿名可访问。
以/admin开头的路由需要鉴权。
以/api开头的路由是 RESTful 接口,可以对接第三方平台,需要三方平台鉴权。
大部分情况下的路由分组,是以相同的前缀来区分的。因此,我们今天实现的分组控制也是以前缀来区分,并且支持分组的嵌套。例如/post是一个分组,/post/a和/post/b可以是该分组下的子分组。作用在/post分组上的中间件(middleware),也都会作用在子分组,子分组还可以应用自己特有的中间件。
中间件可以给框架提供无限的扩展能力,应用在分组上,可以使得分组控制的收益更为明显,而不是共享相同的路由前缀这么简单。例如/admin的分组,可以应用鉴权中间件;/分组应用日志中间件,/是默认的最顶层的分组,也就意味着给所有的路由,即整个框架增加了记录日志的能力。
提供扩展能力支持中间件的内容,我们将在下一节中间件当中介绍。
一个 Group 对象需要具备哪些属性呢?首先是前缀(prefix),比如/,或者/api;要支持分组嵌套,当然了,按照我们一开始的分析,中间件是应用在分组上的,那还需要存储应用在该分组上的中间件(middlewares)。还记得,我们之前调用函数(*Engine).addRoute()来映射所有的路由规则和 Handler 。如果Group对象需要直接映射路由规则的话,比如我们想在使用框架时,这么调用:
r := gee.New()
v1 := r.Group("/v1")
v1.GET("/", func(c *gee.Context) {
c.HTML(http.StatusOK, "<h1>Hello Zgin</h1>")
})
那么Group对象,还需要有访问Router的能力,为了方便,我们可以在Group中,保存一个指针,指向Engine,整个框架的所有资源都是由Engine统一协调的,那么就可以通过Engine间接地访问各种接口了。
所以,最后的 Group 的定义是这样的:
zgin/zgin/zgin.go
type RouterGroup struct {
prefix string // 分组路由,例如 /v1
middlewares []HandlerFunc // support middleware
engine *Engine // all groups share a Engine instance
}
我们还可以进一步地抽象,将Engine作为最顶层的分组,也就是说Engine拥有RouterGroup所有的能力。
type Engine struct {
*RouterGroup
router *router
groups []*RouterGroup // store all groups
}
那我们就可以将和路由有关的函数,都交给RouterGroup实现了。
func New() *Engine {
engine := &Engine{router: newRouter()}
engine.RouterGroup = &RouterGroup{engine: engine}
engine.groups = []*RouterGroup{engine.RouterGroup}
return engine
}
func (group *RouterGroup) Group(prefix string) *RouterGroup {
engine := group.engine
newGroup := &RouterGroup{
prefix: group.prefix + prefix, //支持分组嵌套
engine: engine,
}
engine.groups = append(engine.groups, newGroup)
return newGroup
}
func (group *RouterGroup) addRoute(method string, path string, handler HandlerFunc) {
pattern := group.prefix + path
log.Printf("Route %4s - %s", method, pattern)
group.engine.router.addRoute(method, pattern, handler)
}
// GET defines the method to add GET request
func (group *RouterGroup) GET(pattern string, handler HandlerFunc) {
group.addRoute("GET", pattern, handler)
}
// POST defines the method to add POST request
func (group *RouterGroup) POST(pattern string, handler HandlerFunc) {
group.addRoute("POST", pattern, handler)
}
可以仔细观察下addRoute函数,调用了group.engine.router.addRoute来实现了路由的映射。由于Engine从某种意义上继承了RouterGroup的所有属性和方法,因为 (*Engine).engine 是指向自己的。这样实现,我们既可以像原来一样添加路由,也可以通过分组添加路由。
Demo
更新一下main函数的注册路由,添加一下分组路由,看一下使用效果吧
func main() {
r := zgin.New()
r.GET("/", func(c *zgin.Context) {
c.HTML(http.StatusOK, "<h1>Hello Zgin</h1>")
})
v1 := r.Group("/v1")
v1.GET("/hello", func(c *zgin.Context) {
// expect /hello?name=zhangxh
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
})
v1.GET("/hello/:name", func(c *zgin.Context) {
// expect /hello/zhangxh
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
})
v2 := r.Group("/v2")
v3 := v2.Group("/app")
v2.GET("/hi/:name/good", func(c *zgin.Context) {
// expect /hello/zhangxh
c.String(http.StatusOK, "hi %s good, you're at %s\n", c.Param("name"), c.Path)
})
v3.GET("/assets/*filepath", func(c *zgin.Context) {
c.JSON(http.StatusOK, zgin.H{"filepath": c.Param("filepath")})
})
r.Run(":9080")
}
通过 curl 简单测试:
curl "http://localhost:9080/v1/hello/zhangxh"
hello zhangxh, you're at /v1/hello/zhangxh
curl "http://localhost:9080/v1/hello?name=lris"
hello lris, you're at /v1/hello
curl "http://localhost:9080/v2/hi/zhangxh/good"
hi zhangxh good, you're at /v2/hi/zhangxh/good
curl "http://localhost:9080/v2/app/assets/hellochina"
{"filepath":"hellochina"}
中间件
中间件是什么
中间件(middlewares),简单说,就是非业务的技术类组件。Web 框架本身不可能去理解所有的业务,因而不可能实现所有的功能。因此,框架需要有一个插口,允许用户自己定义功能,嵌入到框架中,仿佛这个功能是框架原生支持的一样。因此,对中间件而言,需要考虑2个比较关键的点:
插入点在哪?使用框架的人并不关心底层逻辑的具体实现,如果插入点太底层,中间件逻辑就会非常复杂。如果插入点离用户太近,那和用户直接定义一组函数,每次在 Handler 中手工调用没有多大的优势了。
中间件的输入是什么?中间件的输入,决定了扩展能力。暴露的参数太少,用户发挥空间有限。
那对于一个 Web 框架而言,中间件应该设计成什么样呢?接下来的实现,基本参考了 Gin 框架。
中间件设计
中间件的定义与路由映射的 Handler 一致,处理的输入是Context对象。插入点是框架接收到请求初始化Context对象后,允许用户使用自己定义的中间件做一些额外的处理,例如记录日志等,以及对Context进行二次加工。另外通过调用(*Context).Next()函数,中间件可等待用户自己定义的 Handler处理结束后,再做一些额外的操作,例如计算本次处理所用时间等。即 zgin 的中间件支持用户在请求被处理的前后,做一些额外的操作。举个例子,我们希望最终能够支持如下定义的中间件,c.Next()表示等待执行其他的中间件或用户的Handler:
zgin/zgin/logger.go
func Logger() HandlerFunc {
return func(c *Context) {
// start timer
t := time.Now()
//
c.Next()
//calculate resolution time
log.Printf("[%d] %s in %v", c.StatusCode, c.Req.RequestURI, time.Since(t))
}
}
另外,支持设置多个中间件,依次进行调用。
我们在上节分组控制 Group Control中讲到,中间件是应用在RouterGroup上的,应用在最顶层的 Group,相当于作用于全局,所有的请求都会被中间件处理。那为什么不作用在每一条路由规则上呢?作用在某条路由规则,那还不如用户直接在 Handler 中调用直观。只作用在某条路由规则的功能通用性太差,不适合定义为中间件。
我们之前的框架设计是这样的,当接收到请求后,匹配路由,该请求的所有信息都保存在Context中。中间件也不例外,接收到请求后,应查找所有应作用于该路由的中间件,保存在Context中,依次进行调用。为什么依次调用后,还需要在Context中保存呢?因为在设计中,中间件不仅作用在处理流程前,也可以作用在处理流程后,即在用户定义的 Handler 处理完毕后,还可以执行剩下的操作,另外我们也可以通过中间件的执行结果控制后续流程是否执行,比如认证中间件中如果认证失败,那后面的资源响应也就无需执行了。
为此,我们给Context添加了3个参数,定义了Next方法:
zgin/zgin/context.go
type Context struct {
//http对象
Writer http.ResponseWriter
Req *http.Request
//请求信息
Path string //请求路由
Method string //方法
Params map[string]string
//响应信息
StatusCode int
//中间件
handlers []HandlerFunc
Pass bool
index int
}
func newContext(w http.ResponseWriter, req *http.Request) *Context {
return &Context{
Writer: w,
Req: req,
Path: req.URL.Path,
Method: req.Method,
Pass: true,
index: -1,
}
}
func (c *Context) Next() {
c.index++
s := len(c.handlers)
for ; c.index < s; c.index++ {
if c.Pass {
c.handlers[c.index](c)
}
}
}
Pass是中间件的执行结果,用户可以通过Pass控制中间件的执行流程,默认为true,index是记录当前执行到第几个中间件,当在中间件中调用Next方法时,控制权交给了下一个中间件,直到调用到最后一个中间件,然后再从后往前,调用每个中间件在Next方法之后定义的部分。如果我们将用户在映射路由时定义的Handler添加到c.handlers列表中,结果会怎么样呢?想必你已经猜到了。
func A(c *Context) {
part1
c.Next()
part2
}
func B(c *Context) {
part3
c.Next()
part4
}
假设我们应用了中间件 A 和 B,和路由映射的 Handler。c.handlers是这样的[A, B, Handler],c.index初始化为-1。调用c.Next(),接下来的流程是这样的:
c.index++,c.index 变为 0
0 < 3,调用 c.handlers[0],即 A
执行 part1,调用 c.Next()
c.index++,c.index 变为 1
1 < 3,调用 c.handlers[1],即 B
执行 part3,调用 c.Next()
c.index++,c.index 变为 2
2 < 3,调用 c.handlers[2],即Handler
Handler 调用完毕,返回到 B 中的 part4,执行 part4
part4 执行完毕,返回到 A 中的 part2,执行 part2
part2 执行完毕,结束。
一句话说清楚重点,最终的顺序是part1 -> part3 -> Handler -> part 4 -> part2。恰恰满足了我们对中间件的要求,接下来看调用部分的代码,就能全部串起来了。
代码实现
定义Use函数,将中间件应用到某个 Group 。
zgin/zgin/zgin.go
// 定义中间件
func (group *RouterGroup) Use(middlewares ...HandlerFunc) {
group.middlewares = append(group.middlewares, middlewares...)
}
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
engine.router.handle(c)
}
ServeHTTP 函数也有变化,当我们接收到一个具体请求时,要判断该请求适用于哪些中间件,在这里我们简单通过 URL 的前缀来判断。得到中间件列表后,赋值给 c.handlers。
handle 函数中,将从路由匹配得到的 Handler 添加到 c.handlers列表中,执行c.Next()。
zgin/zgin/router.go
func (r *router) handle(c *Context) {
n, params := r.getRoute(c.Method, c.Path)
if n != nil {
c.Params = params
key := c.Method + "-" + n.pattern
c.handlers = append(c.handlers, r.handlers[key])
//r.handlers[key](c)
} else {
c.handlers = append(c.handlers, func(c *Context) {
c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
})
}
c.Next()
}
使用 Demo
func main() {
r := zgin.New()
//全局加载日志中间件
r.Use(zgin.Logger())
r.GET("/", func(c *zgin.Context) {
c.HTML(http.StatusOK, "<h1>Hello Zgin</h1>")
})
v1 := r.Group("/v1")
// v1加载认证中间件
v1.Use(authV1())
v1.GET("/hello", func(c *zgin.Context) {
// expect /hello?name=zhangxh
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
})
v1.GET("/hello/:name", func(c *zgin.Context) {
// expect /hello/zhangxh
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
})
r.Run(":9080")
}
func authV1() zgin.HandlerFunc {
return func(c *zgin.Context) {
if c.Query("name") == "zhangxh" || c.Param("name") == "zhangxh" {
fmt.Println("认证通过。。。")
} else {
c.Pass = false
//c.Writer.WriteHeader(http.StatusNotFound)
c.JSON(http.StatusNotFound, zgin.H{
"message": "认证失败",
})
}
}
}
zgin.Logger()即我们一开始就介绍的中间件,我们将这个中间件和框架代码放在了一起,作为框架默认提供的中间件。在这个例子中,我们将zgin.Logger()应用在了全局,所有的路由都会应用该中间件。authV1()是用来测试认证功能的,仅在v1对应的 Group 中应用了。
curl测试:
curl "http://localhost:9080/v1/hello?name=zhangxh"
hello zhangxh, you're at /v1/hello
curl "http://localhost:9080/v1/hello?name=jack"
{"message":"认证失败"}
curl "http://localhost:9080/"
<h1>Hello Zgin</h1>
日志输出:


5万+

被折叠的 条评论
为什么被折叠?



