[Go 语言学习] 从零构建Web框架

869 阅读11分钟

本文主要是对大佬geektutu7-days-golang下面的gee-web项目进行总结学习,方便大家理解。

1. 为什么需要Web框架

go提供的内置包net/http,实现了一些基础的Web功能,即监听端口,映射静态路由,解析HTTP报文。但这项相当于仅仅提供了一块一块积木,如果我们想要去真正实现一个Web服务需要考虑的更多,比如动态路由、鉴权、模版等功能,如果没有Web框架,我们就需要自己去将这些积木给搭建起来,这样每次新开一个项目都需要重新搭建一遍积木。有没有一种办法将这些积木先组合成模块,然后再给我们调用,我们仅仅需要组装模块即可,这样可以大大加快开发速度,这就有了Web框架的出现。Web框架将一些通用功能打包到框架里面,使用者就可以专注于业务逻辑即可。

2. net/http库

2.1 简单使用

go语言的net/http库使用起来非常简单,比如下面代码,定义了两个访问url,分别是//hello。然后仅需定义两个方法分别处理这两个url的访问即可。

func main() {
    // 绑定 url 和对应的处理方法
	http.HandleFunc("/", indexHandler)
	http.HandleFunc("/hello", helloHandler)
	// 在9999端口监听
	log.Fatal(http.ListenAndServe(":9999", nil))
}

// 访问 / 的处理方法
func indexHandler(w http.ResponseWriter, req *http.Request) {
	_, err := fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
	if err != nil {
		w.WriteHeader(http.StatusInternalServerError)
	}
}

// 访问 /hello 的处理方法
func helloHandler(w http.ResponseWriter, req *http.Request) {
	for k, v := range req.Header {
		_, err := fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
		if err != nil {
			w.WriteHeader(http.StatusInternalServerError)
		}
	}
}

2.2 自定义handler

上面是net/http最简单的应用,只需要定义url以及对应处理的方法即可,将处理方法的调用全都交给框架处理。实际上还可以自定义一个http.Handler,将调用处理方法这些流程也放在自己手中控制。

如下,定义一个Engine结构体,其实现了ServeHTTP方法,在go语言中没有显式的接口实现,只有对应的结构体实现了某一接口定义的全部方法,那么就可以将该结构体成为那个接口的实现,同时在调用的过程中,go语言还可以对结构体进行隐式转换,将其转换成对应的接口类型。

ServeHTTP方法里面,判断访问的URL,从而选择对应的方法,实现了跟1.1一样的功能。但是这种实现可以给予我们更多自由发挥的空间,比如我们可以自定义访问失败的返回。

type Engine struct{}

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
	switch req.URL.Path {
	case "/":
		indexHandler(w, req)
	case "/hello":
		helloHandler(w, req)
	default:
		_, err := fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
		if err != nil {
			w.WriteHeader(http.StatusInternalServerError)
		}
	}
}

func main() {
	engine := new(Engine)
	log.Fatal(http.ListenAndServe(":9999", engine))
}

2.3 封装handler

上述实现虽然更灵活了一点,但是带了一个问题,如果我们每次要加新的URL访问都需要修改代码,并且所有的访问处理都在ServeHTTP接口中。所有需要对新加的http.handler进行抽象,让其成为一个routers,根据访问URL选择对应的处理方法。

首先封装处理函数HandlerFuncURL对应的处理方法应该为该类型。

然后定义Engine结构体,Engine实现了ServeHTTP方法,在该方法中根据访问路径选择对应的处理方法。同时在Engine中定义一个map,存放URL和对应处理方法之间的映射。

最后定义一个方法,创建一个Engine实例。

type HandlerFunc func(http.ResponseWriter, *http.Request)

type Engine struct {
	router map[string]HandlerFunc
}

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 {
		fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
	}
}

func New() *Engine {
	return &Engine{router: make(map[string]HandlerFunc)}
}

处理处理请求,Engine还需要具有新加route的能力,因此有了如下三个方法,GET是新建一个GET类型的routePOST是新建一个POST类型的route

func (engine *Engine) GET(pattern string, handler HandlerFunc) {
	engine.addRoute("GET", pattern, handler)
}

func (engine *Engine) POST(pattern string, handler HandlerFunc) {
	engine.addRoute("POST", pattern, handler)
}

func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
	key := method + "-" + pattern
	log.Printf("Route %4s - %s", method, pattern)
	engine.router[key] = handler
}

除此之外,Engine还封装了net/http的启动方法。

func (engine *Engine) Run(addr string) (err error) {
	return http.ListenAndServe(addr, engine)
}

3. Context功能

上述的代码还仅仅实现了net/http包所提供的基本功能,并没有提供一些更强大的功能,这个时候应该想一想如果需要一个更强大的功能,应该怎样去做,比如想要实现路由分组功能以及中间件功能。

首先路由(router)是一个独立的概念,可以独立出来,方便后续对路由功能进行增强,然后如果想要支持中间件功能,就需要在一个请求当中共享上下文信息,因此我们还需要有一个上下文(Context)模块。

首先定义Context的结构体,结构体中包含三类元素。首先是origin objecthttp.ResponseWriter*http.Request),在之前我们已经知道这是一个route的处理函数所必须的输入参数;然后是跟请求有关的信息request infoPathMethod都是从http.ResponseWriter取出的信息;最后是跟响应有关的信息response info,StatusCode即响应码。

type Context struct {
	// origin objects
	Writer http.ResponseWriter
	Req    *http.Request
	// request info
	Path   string
	Method string
	// response info
	StatusCode int
}

然后为了简化接口,封装了一些http.Request方法以供使用,

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

封装一些http.ResponseWriter方法使用,为了方便对于JSON、HTML等返回类型的支持,这些返回类型都是非常常见的,因此封装起来,减少调用的代码量。

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

4. 动态路由

所谓动态路由,一条路由规则可以匹配某一类型而非某一条固定的路由,例如/hello/:name,可以匹配任何以/hello/为前缀的URL。前面使用map结构存储路由表,只能匹配静态路由,无法匹配动态路由。因此我们要使用前缀(Trie)树实现一个匹配动态路由的功能。 首先前缀树路由每个节点由以下四个部分构成:

  1. pattern存放完整的路由路径,只有在某一个匹配路由规则最后一个节点才有值
  2. part存放路由路径中的某一部分
  3. children当前节点的子节点集合
  4. isWild是否精准匹配,如果part中含有:或者*那么该值为true
type node struct {
	pattern  string
	part     string
	children []*node
	isWild   bool
}

然后对于一个路由来说,最重要的两个功能就是插入新的路由,和查询已有路由了。 如下为插入新路由的方法,采用递归的方式插入

func (n *node) insert(pattern string, parts []string, height int) {
	if len(parts) == height {
		n.pattern = pattern
		return
	}

	// 拿出一个height位置的part
	part := parts[height]
	// 是否有与这个part相等的child
	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 {
	// 查完parts或者遇到一个通配符,那么看一下这个node是不是有pattern,如果有的话,说明存在这样一条路径,如果没有说明没有这样一条路径
	if len(parts) == height || strings.HasPrefix(n.part, "*") {
		if n.pattern == "" {
			return nil
		}
		return n
	}

	// 当前part
	part := parts[height]
	// 查询当前node与part相等的所有children节点
	children := n.matchChildren(part)

	// 遍历符合条件的子节点,递归查询
	for _, child := range children {
		result := child.search(parts, height+1)
		if result != nil {
			return result
		}
	}

	return nil
}

5. 路由分组功能

除了动态路由功能,与路由相关的最重要的一个功能应该是路由分组功能了。想要实现路由分组功能,首先需要抽象出一个RouterGroup的类型出来,这个类型应该能满足如下几个功能:

  1. 正确的分组
  2. 存储group的相关信息
  3. 满足多层分组的需要(可以在group里面再新建group
  4. 能够对某一类group加中间件进行处理

因此要实现上面四个功能,对engine重新设计,首先RouterGroup代表分组类型,包含四部分信息,prefix当前group的前缀;middlewares当前分组需要执行的中间件处理函数;parent当前group的父groupengine所有的group共享一个engine对象,为了便于操作,可以直接在group中获取engine中的信息。

然后将group相关的信息也加入到engine中,这里需要注意的时候,一个engine就相当于一个没有前缀的分组,所有在engine中也支持group相关的所有方法,同时有一个groups存放所有的group信息。

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

	Engine struct {
		*RouterGroup
		router *router
		groups []*RouterGroup // store all groups
	}
)

上面也说了Engine相当于一个没有前缀的group,那么就可以将之前所有绑定在engine上,与路由相关的方法都可以绑定到RouterGroup上,这样就不需要在RouterGroup上再实现一遍路由相关的方法了。同时增加一个新建分组的方法,具体代码如下:

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
}

6. 中间件功能

6.1 中间件功能实现

在上面实现分组功能的时候,预留了添加中间件的位置。为什么需要中间件功能?Web框架本身不可能理解所有的业务,因而不可能实现所有的功能。因此,框架需要有一个插口,允许用户自己定义功能,嵌入到框架当中。然后对于中间件来说,需要考虑2个比较关键的点:

  • 插入位置,使用框架的人并不关心底层逻辑的具体实现,如果插入点太底层,中间间的逻辑就会变的复杂,如果插入点离用户太近,那用户完全可以自己调用,不需要框架的参与。
  • 中间件的输入是什么?中间件的输入,决定了扩展能力。暴露的参数太少,用户发挥空间有限。 因此在Web框架中,中间件的输入应该是Context对象,这样可以基本满足所有的功能调用需要,插入点是框架接收到请求初始化Context对象后,允许用户使用自定义的中间件做一些额外处理,如记录日志或者对Context进行加工。

在实现方面,中间件应该与Group对象绑定,因为需要中间件的时候,肯定是要对一类路由进行处理。如果仅仅单个路由需要,那完全可以将逻辑放入到对应路由的处理函数里面。如下方法实现将中间件添加到group中去。

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

在具体流程中,也需要做一定调整,在之前没有添加中间件的处理流程里,当一个新的请求进来之后,直接就调用对应的处理函数即可。而加了中间件之后,整个处理流程就变成了,当一个新的请求进来之后,首选需要查出本次请求需要调用哪些中间件之后,然后保持下来放到context中,之后再查出本次请求对应的处理函数,然后再依次开始请求。

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

因此我们需要在context对象中间件所需要的一些信息。

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
	// middleware
	// 所有需要实现的handler方法
	handlers []HandlerFunc
	// 当前执行位置
	index int
}

除此之外,还需要实现一个next方法,因为有一类中间件需要处理流程开始之前执行,在处理流程结束之后才结束,比如实现一个记录处理时间的中间件。

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

如下实现一个简单的记录处理时间的中间件函数

func recordTime() gee.HandlerFunc {
	return func(c *gee.Context) {
		// Start timer
		start := time.Now()
		// 调用后续处理函数
		c.Next()
		// Calculate resolution time
		end := time.Now()
		log.Printf("[%d] %s start in %s end in %s", c.StatusCode, c.Req.RequestURI, start, end)
	}
}

6.2 错误恢复(Panic Recover)

上面我们已经实现了中间件功能,现在我们可以利用中间件功能给框架加上错误恢复功能,handle那些出错的情况,保证服务不会因为错误而停止。

Recovery函数的处理逻辑也非常简单,使用defer关键字定义一段在所有处理函数处理完成之后的逻辑。使用内置的recover函数判断是否错误,如果出错的话就调用 context上的fail方法。

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

将该中间件挂在Engine所在的group上,这样就可以让所有的路由都实现错误恢复的功能了。

References