简易实现 Go 的 Web 框架(下) | 青训营

52 阅读6分钟

书接上回:juejin.cn/post/726555…

四、分组控制Group

1.分组的目的

在我前一篇文章介绍的 singo 框架中,有这么一段代码

v1 := r.Group("/api/v1")
	{
		v1.POST("ping", api.Ping)

		// 用户注册
		v1.POST("user/register", api.UserRegister)

		// 用户登录
		v1.POST("user/login", api.UserLogin)

		// 需要登录保护的
		auth := v1.Group("")
		auth.Use(middleware.AuthRequired())
		{
			// User Routing
			auth.GET("user/me", api.UserMe)
			auth.DELETE("user/logout", api.UserLogout)
		}
	}

可以看到它给路由进行了一个分组,处于v1这个分组下面的路由都拥有共同的/api/v1前缀,而在auth组中的路由,则全部需要经过登录才能访问。所以我们得到了分组的目的:(1)统一管理;(2)权限控制。

2.Group对象

分析完分组的目的以后,现在来探讨以下Group这个对象应该具有的属性。在上面的代码中看出,它首先要有前缀,例如/api/v1;接着,它可以使用一些例如AuthRequired的中间件,需要有中间件属性;如果要支持分组嵌套,它还必须知道当前分组的parent是谁;最后,Group对象还需要有访问Router的能力。因此,我们在/gee/gee.go中对Group的定义如下:

type RouterGroup struct {
	prefix      string
	middlewares []HandlerFunc // support middleware
	parent      *RouterGroup  // support nesting
	engine      *Engine       // all groups share a Engine instance
}

接着,将Engine作为最顶层的分组,其代码修改为如下:

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,
		parent: group,
		engine: engine,
	}
	engine.groups = append(engine.groups, newGroup)
	return newGroup
}

func (group *RouterGroup) addRoute(method string, comp string, handler HandlerFunc) {
	pattern := group.prefix + comp
	log.Printf("Route %4s - %s", method, pattern)
	group.engine.router.addRoute(method, pattern, handler)
}

func (group *RouterGroup) GET(pattern string, handler HandlerFunc) {
	group.addRoute("GET", pattern, handler)
}

func (group *RouterGroup) POST(pattern string, handler HandlerFunc) {
	group.addRoute("POST", pattern, handler)
}

最后,main.go

func main() {
	r := gee.New()
	r.GET("/index", func(c *gee.Context) {
		c.HTML(http.StatusOK, "<h1>Index Page</h1>")
	})
	v1 := r.Group("/v1")
	{
		v1.GET("/", func(c *gee.Context) {
			c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
		})

		v1.GET("/hello", func(c *gee.Context) {
			c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
		})
	}
	v2 := r.Group("/v2")
	{
		v2.GET("/hello/:name", func(c *gee.Context) {
			c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
		})
		v2.POST("/login", func(c *gee.Context) {
			c.JSON(http.StatusOK, gee.H{
				"username": c.PostForm("username"),
				"password": c.PostForm("password"),
			})
		})
	}
	r.Run(":9999")
}

五、中间件Middleware

1.中间件的设计

参照Gin框架的设计,中间件的定义与路由映射的Handler 一致,处理的输入是Context对象。插入点是框架接收到请求初始化Context对象后,允许用户使用自己定义的中间件做一些额外的处理,例如记录日志等,以及对Context进行二次加工。另外通过调用(*Context).Next()函数,中间件可等待用户自己定义的 Handler处理结束后,再做一些额外的操作,例如计算本次处理所用时间等。

因此我们需要给gee/context.go添加额外参数,并新增Next()方法:

type Context struct {
	Writer     http.ResponseWriter
	Req        *http.Request
	Path       string
	Method     string
	StatusCode int
	Params     map[string]string
	// 中间件部分
	handlers []HandlerFunc
	index    int
}

func newContext(w http.ResponseWriter, req *http.Request) *Context {
	return &Context{
		Writer: w,
		Req:    req,
		Path:   req.URL.Path,
		Method: req.Method,
		index:  -1,
	}
}

func (c *Context) Next() {
	c.index++
	s := len(c.handlers)
	for ; c.index < s; c.index++ {
		c.handlers[c.index](c)
	}
}

在这段代码中,index记录了当前执行到的第几个中间件,在中间件调用Next()后,权限就交给下一个中间件控制,直到调用到最后一个中间件,然后再从后往前,依次调用每个中间件在Next()方法后定义的部分。让我们来举例说明一下:

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.index++变为0,0<len(c.handlers)=3,调用c.handlers[0]即A
  • A首先执行part1,接着调用c.Next(),此时c.index++变为1,1<3,调用c.handlers[1]即B
  • B执行part3,接着调用c.Next(),此时c.index++变为2,2<3,调用c.handlers[2]即Handler
  • Handler调用完毕以后,回到B中执行part4,part4执行完毕后,回到A中执行part2,part2执行完毕,结束。

所以执行顺序总结为part1->part3->Handler->part4->part2

下面要在gee/gee.go中定义Use()函数,将中间件应用到Group里面:

func (group *RouterGroup) Use(middlewares ...HandlerFunc) {
	group.middlewares = append(group.middlewares, middlewares...)
}

func (e *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	var middlewares []HandlerFunc
	for _, group := range e.groups {
		if strings.HasPrefix(req.URL.Path, group.prefix) {
			middlewares = append(middlewares, group.middlewares...)
		}
	}
	c := newContext(w, req)
	c.handlers = middlewares
	e.router.handle(c)
}

接着修改gee/router.go

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)
		})
	}
}

为了测试,在gee/logger.go编写一个打印日志的中间件:

func Logger() HandlerFunc {
	return func(c *Context) {
		t := time.Now()
		c.Next()
		log.Printf("[%d] %s in %v", c.StatusCode, c.Req.RequestURI, time.Since(t))
	}
}

最后,开始main.go测试!

func main() {
	r := gee.New()
	r.Use(gee.Logger())
	r.GET("/", func(c *gee.Context) {
		c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
	})
	r.Run(":9999")
}

六、模板Template

由于现在开发基本都是前后端分离的模式,采用Go去渲染Template已经不太常用了,所以这一部分跳过。

七、错误恢复

前面几个模块的代码已经是较为完善的Web框架了,但唯有一点没有考虑全,就是对于错误的处理。如果用户输入了一些不正常的参数,导致数组越界、空指针等,服务就会因为错误直接宕机,这显然不是我们想看到的情况。

因此,需要添加一个错误处理机制,在发生错误时,向用户返回Internet Server Error,并且在日志中打印必要的错误信息。由于已经实现了中间件机制,所以可以把对于错误的处理当作中间件使用。

下面编写gee/recovery.go文件,实现错误处理:

func trace(message string) string {
	var pcs [32]uintptr
	n := runtime.Callers(3, pcs[:])

	var str strings.Builder
	str.WriteString(message + "\nTraceback:")
	for _, pc := range pcs[:n] {
		fn := runtime.FuncForPC(pc)
		file, line := fn.FileLine(pc)
		str.WriteString(fmt.Sprintf("\n\t%s:%d", file, line))
	}
	return str.String()
}

func Recovery() HandlerFunc {
	return func(c *Context) {
		defer func() {
			if err := recover(); err != nil {
				message := fmt.Sprintf("%s", err)
				log.Printf("%s\n\n", trace(message))
				c.Fail(http.StatusInternalServerError, "Internal Server Error")
			}
		}()
		c.Next()
	}
}

反正我是看不懂这个trace(),不妨来看看chatgpt的解释:

这段代码实现了一个 golang web 框架中的异常恢复中间件,也就是 Recovery() 函数。当 web 请求处理函数在执行过程中出现 panic (恐慌)时,该函数会从 panic 中恢复,并记录下导致 panic 的错误信息,以及调用栈中每个函数所在的代码文件和行数,并打印到日志中。最后,函数会将返回的 http 状态码设为500,表示服务器出现异常错误。

函数 trace(message string) string 的作用是返回当前的调用栈信息,以便后续的日志记录使用。该函数会调用 runtime 包和 strings 包实现,首先通过 runtime.Callers 方法获取到当前调用栈中的所有函数地址,然后通过遍历这些地址可以得到每个地址所对应的函数信息,从而获取到函数所在的代码文件名和行号。最后,函数将这些信息拼接成一个字符串返回。

函数 Recovery() HandlerFunc 返回的是一个中间件函数,该函数在 web 请求处理函数执行过程中起到拦截和恢复作用。该函数会在 defer 关键字中启用一个匿名函数,该匿名函数包含了异常恢复的逻辑。具体来说,如果在 web 请求处理函数的执行过程中出现了 panic(恐慌)异常,那么该函数会从 panic 中恢复并调用 log.Printf() 方法打印日志,以记录导致 panic 的错误信息以及调用栈信息。最后,该函数将 http 的响应状态设置为500,表示服务器出现异常错误。

接着,给gee/gee.go添加一个Default()方法,默认配置好设置的LoggerRecovery中间件:

func Default() *Engine {
	engine := New()
	engine.Use(Logger(), Recovery())
	return engine
}

至此,Gee框架编写完成,让我们main.go测试一下!

func main() {
	r := gee.Default()
	r.GET("/", func(c *gee.Context) {
		c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
	})
	r.GET("/panic", func(c *gee.Context) {
		names := []string{"sanjin"}
		c.String(http.StatusOK, names[100])
	})
	r.Run(":9999")
}

感想

跟着大神的博客一路敲下来,惊讶于实现一个简易的 Web 框架竟然这么简单,满打满算没有多少代码。但是自己确实没有真正吸收多少,基本都是看一行抄一行,这篇博客就当做个笔记吧,我也会经常回头来看看,好好体会这里面的设计思想,以及如何利用代码实现这个框架。