Web框架的设计方案和Go源码实现

  1. 为何要用web框架
  2. 注册路由和路由发现
  3. 上下文Context
  4. 分组路由
  5. 中间件
  6. 错误恢复

为何要用web框架

最有名的web框架莫过于Java的SpringBoot,Go的Gin。本篇以Go语言为例。其他语言或其他主流的web框架基于的设计方案基本都遵循这个。

其实不用web框架也可以进行web后端开发,比如下面的最简单的例子:

package main

import (
	"fmt"
	"log"
	"net/http"
)

func main() {
    // http.HandleFunc 实现了路由和Handler的映射
	http.HandleFunc("/", indexHandler)
	http.HandleFunc("/hello", helloHandler)
    // http.ListenAndServe 启动一个http服务,第一个参数是ip和端口号,第二个参数是http包里的Handler接口
	log.Fatal(http.ListenAndServe(":9999", nil))
}

// handler echoes r.URL.Path
func indexHandler(w http.ResponseWriter, req *http.Request) {
	fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
}

// handler echoes r.URL.Header
func helloHandler(w http.ResponseWriter, req *http.Request) {
	for k, v := range req.Header {
		fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
	}
}

测试得到下面的结果:

$ curl http://localhost:9999/
URL.Path = "/"
$ curl http://localhost:9999/hello
Header["Accept"] = ["*/*"]
Header["User-Agent"] = ["curl/7.54.0"]

那么为何要用web框架,或者说现在的主流web后端开发都要选定一个框架,然后再开发,就是为了提高效率,共通的业务以外的逻辑都由框架实现了,有了框架,开发只需要专注业务逻辑。

那么设计web框架的目的就很明确了,解决非业务的共通需求。那么有哪些此类的需求呢?就是框架要解决的问题。

  1. 注册路由和路由发现
  2. 快速路由算法(是框架理解上最复杂的地方,参考之前的两篇文章 http前缀树路由算法和Go源码分析 http基数树路由算法和Go源码分析,本篇略过。路由的性能非常重要,是框架间竞争的主要指标)
  3. 上下文Context
  4. 分组路由
  5. 中间件(比如日志中间件,校验中间件)
  6. 模板Template(现在开发都是前后端分离,模板很少实际开发中使用,所以这部分本篇略过)
  7. 错误恢复

所以一个好的web框架的核心在于:决定性能的路由算法。社区活跃度。特色功能。易用度。等等。

掌握的这些问题的解决方案,就可以自己设计web框架,或者在现有框架的基础上定制框架。下面逐一介绍:

注册路由和路由发现

在拥有框架之前,是通过http.HandleFunc关联URL和处理函数handler,再调用http.ListenAndServe(“:9999”, nil),http.ListenAndServe第二个参数留空。

第二个参数是一个Handler接口,需要实现方法 ServeHTTP,第二个参数也是基于net/http标准库实现Web框架的入口。

http.Handler接口的源码:

package http

type Handler interface {
    ServeHTTP(w ResponseWriter, r *Request)
}

func ListenAndServe(address string, h Handler) error

除了调用http.HandleFunc,也可以通过下面的方式实现相同的目的。通过下面的Engine结构体来实现接口方法ServeHTTP,再将Engine传入http.ListenAndServe的第二个参数。

// 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结构体中创建一个map用于保存路由和Handler的映射。之后的调用就出现了我们用主流web框架的雏形调用样式。

func main() {
	r := gee.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(":9999")
}
package gee

import (
	"fmt"
	"net/http"
)

// HandlerFunc defines the request handler used by gee
type HandlerFunc func(http.ResponseWriter, *http.Request)

// Engine implement the interface of ServeHTTP
type Engine struct {
	router map[string]HandlerFunc
}

// New is the constructor of gee.Engine
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 {
		fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
	}
}

上下文Context

为何要有上下文:

  1. 对Web服务来说,无非是根据请求*http.Request,构造响应http.ResponseWriter。要构造一个完整的响应,需要考虑消息头(Header)和消息体(Body),而 Header 包含了状态码(StatusCode),消息类型(ContentType)等几乎每次请求都需要设置的信息。因此,如果不进行有效的封装,那么框架的用户将需要写大量重复,繁杂的代码,而且容易出错。针对常用场景,能够高效地构造出 HTTP 响应是一个好的框架必须考虑的点。现在前后端分离的web开发,返回的结构体往往是json数据类型,所以要对返回体作json数据格式的封装。
  2. 提供和当前请求强相关的信息的存放位置。比如:解析动态路由/hello/:name,参数:name的值。中间件。Context 就像一次会话的百宝箱,可以找到任何东西。

代码实现上:

将router map[string]HandlerFunc的HandlerFunc,从type HandlerFunc func(http.ResponseWriter, *http.Request)切换成type HandlerFunc func(*Context)

对框架的调用也从r.GET("/hello", func(w http.ResponseWriter, req *http.Request)变成r.GET("/hello", func(c *gee.Context)

创建Context结构体,保存上下文(Context目前只包含了http.ResponseWriter和*http.Request,另外提供了对 Method 和 Path 这两个常用属性的直接访问。):

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

提供了访问Query和PostForm参数的方法:

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

提供了快速构造String/Data/JSON/HTML响应的方法:

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

分组路由

web框架一般都提供分组路由和多层分组嵌套功能。分组路由的好处有:

  • 提取共通的部分作为分组,可以减少框架使用者URL的输入长度。
  • 真实的业务场景中,往往某一组路由需要相似的处理。可以按分组配置中间件。

分组路由和嵌套分组的代码实现:

// Engine implement the interface of ServeHTTP
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
	}
)

// New is the constructor of gee.Engine
func New() *Engine {
	engine := &Engine{router: newRouter()}
	engine.RouterGroup = &RouterGroup{engine: engine}
	engine.groups = []*RouterGroup{engine.RouterGroup}
	return engine
}

// Group is defined to create a new RouterGroup
// remember all groups share the same Engine instance
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
}

再addRoute, GET, POST原来放在Engine结构体的方法,现在放到RouterGroup结构体上

框架调用方式现在变为:

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) {
			// expect /hello?name=geektutu
			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) {
			// expect /hello/geektutu
			c.String(http.StatusOK, "hello %s, you're at %s\n", c.Param("name"), c.Path)
		})
	}
	r.Run(":9999")
}

中间件

中间件(middlewares),简单说,就是非业务的技术类组件。一般中间件加在某一个路由分组上或者总的分组上,即应用在代码的RouterGroup上,中间件可以给框架提供无限的扩展能力。例如/admin的分组,可以应用鉴权中间件;/分组应用日志中间件。

中间件的设计思路:

没有中间件的框架设计是这样的,当接收到请求后,匹配路由,该请求的所有信息都保存在Context中。中间件也不例外,接收到请求后,应查找所有应作用于该路由的中间件,保存在Context中,依次进行调用。为什么依次调用后,还需要在Context中保存呢?因为在设计中,中间件不仅作用在处理流程前,也可以作用在处理流程后,即在用户业务的 Handler 处理完毕后,还可以执行剩下的操作。

具体的中间件框架的代码设计如下:

一部分是对Context的设计,增加中间件相关代码:

type Context struct {
    ...
	// 新增middleware相关的两个参数
	handlers []HandlerFunc
	index    int
}

func newContext(w http.ResponseWriter, req *http.Request) *Context {
	return &Context{
        ...
        // index是含有所有中间件和用户handler的数组下标,初始值为-1,因为首次调用Next()会第一句代码会+1
		index:  -1,
	}
}

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

一部分是对handle以及调用handle的ServeHTTP(实现net/http标准库接口方法),增加中间件相关代码:

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

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

这两部分要结合起来看,Context结构体新增middleware相关的两个参数,handler数组和handler数组下标index,index初始值为-1,因为首次调用Next()会第一句代码会+1,即首次会执行注册的第一个中间件。

首次Next()调用是由ServeHTTP方法调用,下次调用Next(),是由框架的使用者在编写业务所需的中间件的代码中调用。

用户路由对应的handler不需要写Next(),因为是该handler是handler数组最后一个。

比如:

//用户编写的中间件A
func A(c *Context) {
    part1
    c.Next()
    part2
}
//用户编写的中间件B
func B(c *Context) {
    part3
    c.Next()
    part4
}

执行的顺序是:part1 -> part3 -> Handler -> part 4 -> part2

错误恢复

对一个 Web 框架而言,错误处理机制是非常必要的。可能是框架本身没有完备的测试,导致在某些情况下出现空指针异常等情况。也有可能用户不正确的参数,触发了某些异常,例如数组越界,空指针等。如果因为这些原因导致系统宕机,必然是不可接受的。

代码实现:

是通过编写错误恢复中间件,并将该中间件注册到Engine上实现的。

错误恢复中间件:

// print stack trace for debug
func trace(message string) string {
	var pcs [32]uintptr
	n := runtime.Callers(3, pcs[:]) // skip first 3 caller

	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()
	}
}
// New is the constructor of gee.Engine
func New() *Engine {
	engine := &Engine{router: newRouter()}
	engine.RouterGroup = &RouterGroup{engine: engine}
	engine.groups = []*RouterGroup{engine.RouterGroup}
	return engine
}

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

调用New()是没有日志中间件和错误恢复中间件的,只有调用Default()才有这两个中间件,且加在了Engine,即加在全局上。


转载请注明来源,欢迎指出任何有错误或不够清晰的表达。可以邮件至 backendcloud@gmail.com