Golang | Gee 框架的设计和实现

HTTP

原生 HTTP 处理

Go 语言原生自带 HTTP 的处理能力,可以使用 http 包实现简单的 http 请求处理。

原生 HTTP 处理请求方式:

/**
 * @author Real
 * @since 2023/11/2 22:18
 */
package main

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

func main() {
	http.HandleFunc("/", indexHandler)
	http.HandleFunc("/hello", helloHandler)
	log.Fatal(http.ListenAndServe(":9999", nil))
}

func indexHandler(w http.ResponseWriter, r *http.Request) {
	_, err := fmt.Fprintf(w, "index hanlder, URL.path = %q\n", r.URL.Path)
	if err != nil {
		return
	}
}

func helloHandler(w http.ResponseWriter, r *http.Request) {
	headers := r.Header
	for index, _ := range headers {
		_, err := fmt.Fprintf(w, "header[%s] = %s\n", index, headers[index])
		if err != nil {
			return
		}
	}
}

启动之后,可以访问 localhost:9999localhost:9999/hello 访问对应的服务。

$ curl http://localhost:9999/
index hanlder, URL.path = "/"

$ curl http://localhost:9999/hello
header[User-Agent] = [curl/8.1.2]
header[Accept] = [*/*]

实现 Http.handler 接口

package main

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

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

// Engine is the uni handler for all requests
type Engine struct{}

// ServeHTTP implements the http.Handler interface
func (e *Engine) ServeHTTP(w http.ResponseWriter, r *http.Request) {
	switch r.URL.Path {
	case "/":
		fmt.Fprintf(w, "URL.Path = %q\n", r.URL.Path)
	case "/hello":
		for k, v := range r.Header {
			fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
		}
	default:
		fmt.Fprintf(w, "404 NOT FOUND: %s\n", r.URL)
	}

}

在这个里面,定义了一个 Engine 空结构体,实现了 HTTP 接口的 ServeHTTP 接口。这样就可以处理所有的请求了。第一个参数是 ResponseWriter,可以利用 ResponseWriter 构造针对该请求的响应。第二个参数是 Request,该对象包含了该 HTTP 请求的所有信息,比如请求地址、Header、Body 等信息。

在 main 函数中,给 ListenAndServe 方法的第二个参数传入了刚才创建的 engine 实例。这样就将所有的 HTTP 请求转向了我们自己的处理逻辑。在实现 Engine 之前,可以调用 http.HandleFunc 实现了路由和 Handler 的映射,也就是只能针对具体的路由写处理逻辑。但是在实现 Engine 之后就可以拦截所有的 HTTP 请求,拥有了统一的控制入口。在这里我们可以自由定义路由映射的规则,也可以统一添加一些处理逻辑,例如日志、异常处理等。这里的代码的运行结果与之前的是一致的。

Gin 的处理方式

import (
	"fmt"
	"net/http"
)

// HandlerFunc defines the request handler used by gee_module
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)
	}
}
  • 首先定义了类型 HandlerFunc,这是提供给框架用户的,用来定义路由映射的处理方法。我们在 Engine 中添加了一张路由映射表 router,key 由请求方法和静态路由地址构成,例如 GET-/、GET-/hello、POST-/hello,这样针对相同的路由,如果请求方法不同,可以映射不同的处理方法 Handler,value 是用户映射的处理方法。
  • 当用户调用 (*Engine).GET() 方法时,会将路由和处理方法注册到映射表 router 中,(*Engine).Run() 方法是 ListenAndServe 的包装。
  • Engine 实现的 ServeHTTP 方法的作用就是,解析请求的路径,查找路由映射表。如果查到,就执行注册的处理方法。如果查不到,就返回 404 NOT FOUND。

Context

封装前的请求

如果不对 Gee 的请求方法做封装,那么每次使用的时候,都是直接将 request 和 Response 作为匿名函数的参数。

func main() {
    engine := gee.New()
    engine.GET("/", func(writer http.ResponseWriter, request *http.Request) {

    })
}

这样的使用看起来会很臃肿,而且和使用原生的 http 包提供的方法,没有什么太大的区别。这个时候就需要将请求和返回参数做一些封装,顺带也支持更强大的功能。

因此,封装 Context 有这些必要:

  1. 简化接口调用:request 和 response 提供的接口粒度太细,需要每次请求都设置相同的 Header 等信息,这在构造完整的响应时需要写大量重复的代码;
  2. 支持额外功能:如果需要解析动态路由、需要支持中间件时,直接使用 Request 和 Response 接口,对于全局的请求来说不具备复用性。

Context 实现

封装 Context 的具体实现如下。

package gee

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

const ContentType = "Content-Type"

// H 构造 JSON 数据的别名
type H map[string]interface{}

type Context struct {
	// 原始对象
	Writer  http.ResponseWriter
	Request *http.Request

	// 请求信息
	Path   string
	Method string

	// 返回信息
	StatusCode int
}

func NewContext(w http.ResponseWriter, r *http.Request) *Context {
	return &Context{
		Writer:  w,
		Request: r,
		Path:    r.URL.Path,
		Method:  r.Method,
	}
}

// ---------- 获取请求值 ----------

func (c *Context) GetPostFormVal(key string) string {
	return c.Request.FormValue(key)
}

func (c *Context) GetQueryVal(key string) string {
	return c.Request.URL.Query().Get(key)
}

// ---------- 设置返回值 ----------

func (c *Context) SetStatusCode(code int) {
	c.StatusCode = code
	c.Writer.WriteHeader(code)
}

func (c *Context) SetHeader(key, value string) {
	c.Request.Header.Set(key, value)
}

// ---------- 设置返回值类型 ----------

func (c *Context) String(code int, format string, values ...interface{}) {
	c.SetHeader(ContentType, "text/plain")
	c.SetStatusCode(code)
	_, err := c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
	if err != nil {
		log.Printf("Response String Error: %v", err)
	}
}

func (c *Context) JSON(code int, data interface{}) {
	c.SetHeader(ContentType, "application/json")
	c.SetStatusCode(code)
	encoder := json.NewEncoder(c.Writer)
	if err := encoder.Encode(data); err != nil {
		log.Printf("Response JSON Error: %v", err)
		http.Error(c.Writer, err.Error(), 500)
	}
}

func (c *Context) HTML(code int, html string) {
	c.SetHeader(ContentType, "text/html")
	c.SetStatusCode(code)
	if _, err := c.Writer.Write([]byte(html)); err != nil {
		log.Printf("Response HTML Error: %v", err)
	}
}

func (c *Context) Data(code int, data []byte) {
	c.SetStatusCode(code)
	if _, err := c.Writer.Write(data); err != nil {
		log.Printf("Response Data Error: %v", err)
	}
}

Context 的封装实现,主要提供了这些特性:

  • 提供了快速构造 Map 类型数据的方法别名;
  • 提供了接收 Request 和 Response 两个参数的快速构造方法;
  • 提供了获取请求中携带的参数的方法、设置返回值以及 Header 属性;
  • 提供了向返回值中写入常见的 String、JSON、Data、HTML 的方法;

Router

前面参考 Gin 的实现方式,编写了一个简单的 Gee 框架的入口。之前是将 Router、Engine 等功能全部耦合在一起,如果要在路由阶段提供更多功能,将 Router 独立出来会是更好的选择。

抽离 Router

将之前实现的 Router 相关的功能单独剥离,得到下面的实现。

package gee

import (
	"log"
	"net/http"
)

type Router struct {
	Handlers map[string]HandlerFunc
}

func NewRouter() *Router {
	return &Router{
		Handlers: make(map[string]HandlerFunc),
	}
}

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

func (r *Router) handle(c *Context) {
	key := c.Method + "-" + c.Path
	if handler, ok := r.Handlers[key]; ok {
		handler(c)
		return
	}

	// not found, return 404
	c.String(http.StatusNotFound, "[Route]: 404 NOT FOUND: %s\n", c.Path)
}

处理 Gee 文件

抽离了 Router 之后,原文件 Gee 中的一些方法也需要同步修改。

package gee

import (
	"net/http"
)

// HandlerFunc defines the request handler used by gee
type HandlerFunc func(c *Context)

// Engine implement the interface of ServeHTTP
type Engine struct {
	router *Router
}

// New is the constructor of gee.Engine
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) {
	context := NewContext(w, req)
	engine.router.handle(context)
}

这样处理之后,Gee 框架的结构就更趋合理了。

go.mod
├─gee
│      context.go
│      gee.go
│      router.go
├─gee_context
│  └─main
│          main.go
└─http_base
    ├─gee_module
    │  └─main
    │          main.go
    ├─http_handler
    │      http_base.go
    └─serve_http
            serve_http.go

测试使用

添加测试 case 以及测试的实现代码。

package gee_context

import (
	"gee/gee"
	"net/http"
	"testing"
)

func TestContext(t *testing.T) {
	r := gee.New()
	r.GET("/", func(c *gee.Context) {
		c.HTML(http.StatusOK, "<h1>Hello Gee</h1>")
	})
	r.GET("/hello", func(c *gee.Context) {
		// expect /hello?name=Real
		c.String(http.StatusOK, "hello %s, you're at %s\n", c.GetQueryVal("name"), c.Path)
	})

	r.POST("/login", func(c *gee.Context) {
		c.JSON(http.StatusOK, gee.H{
			"username": c.GetPostFormVal("username"),
			"password": c.GetPostFormVal("password"),
		})
	})

	if err := r.Run(":9999"); err != nil {
		t.Fatalf("Error, exit. error: %v", err)
	}
}

运行结果:

  1. 测试 /hello 接口,可以得到正常响应。

  1. 测试 /login 接口,同样可以得到正常响应。

  1. 测试其他接口,会正常返回 404 的结果。

前缀树路由 Router

Trie 树定义

至此为止,Gee 使用的是简单的 map 结构存储路由表。使用 map 存储键值对索引非常高效,但是缺点也很明显,只能用来索引静态路由。如果想要索引动态路由,需要借助一些其他方式。

动态路由:一条路由规则可以匹配某一类型而非某一条固定单一的路由。例如 /hello/:name,可以匹配 /hello/tom/hello/jack 等。

动态路由有很多种实现方式,支持的规则、性能等有很大的差异。例如开源的路由实现 gorouter 支持在路由规则中嵌入正则表达式,例如 /p/[0-9A-Za-z]+,即路径中的参数仅匹配数字和字母;另一个开源实现 httprouter 就不支持正则表达式。gin 在早期的版本并没有实现自己的路由,而是直接使用了 httprouter,但是后期放弃了 httprouter 转而自己实现了一个版本。

实现动态路由最常用的数据结构,是前缀树 Trie。

HTTP 请求的路径恰好是由/分隔的多段构成的,因此每一段可以作为前缀树的一个节点。我们通过树结构查询,如果中间某一层的节点都不满足条件,那么就说明没有匹配到的路由,查询结束。

实现动态路由之前,我们需要定义好动态路由需要支持的功能:

  • 参数匹配:例如 /p/:lang/doc,可以匹配 /p/python/doc/p/go/doc
  • 支持通配符:例如 /static/*filepath,可以匹配 /static/fav.ico,也可以匹配 /static/js/jQuery.js。用于静态服务器的时候,需要支持递归地匹配子路径。

Trie 树实现

路由最重要的功能是注册和匹配。Trie 树中每个节点,我们定义为下面这种结构。

type Node struct {
	Pattern  string  // 待匹配的路由,例如 /p/:lang,将参数进行上下文传递
	Part     string  // 待匹配路由的一部分,例如 :lang
	Children []*Node // 子节点,例如 :lang 的可选项 [go, java, php] 等
	IsFuzzy  bool    // 是否模糊匹配,part 含有 : 或者 * 时为 true
}

/p/:lang/doc 路由为例,我们需要在路由 Trie 树中插入这个路由。服务开发时,我们注册路由规则映射 Handler;服务访问时匹配路由规则,找到对应的 Handler。因此 Trie 树需要支持节点的插入与查询。

Trie 树的插入功能很简单,只需要递归查找每一层的节点,如果没有匹配到当前 part 的节点,新建一个即可。/p/:lang/doc 只有在第三层节点,即 doc 节点,pattern 才会设置为 /p/:lang/doc。p 和 :lang 节点的 pattern 属性皆为空。因此匹配结束时可以用 n.Pattern == "" 来判断路由规则是否匹配成功。例如,/p/python 虽能成功匹配到 :lang,但 :lang 的 pattern 值为空,因此匹配失败。查询功能,同样也是递归查询每一层的节点,退出规则是,匹配到了 *,匹配失败,或者匹配到了第 len(parts) 层节点。

按照这个思路,完整的 Trie 路由树的实现如下。

package gee

import (
	"fmt"
	"strings"
)

type Node struct {
	Pattern  string  // 待匹配的路由,例如 /p/:lang,将参数进行上下文传递
	Part     string  // 待匹配路由的一部分,例如 :lang
	Children []*Node // 子节点,例如 :lang 的可选项 [go, java, php] 等
	IsFuzzy  bool    // 是否模糊匹配,part 含有 : 或者 * 时为 true
}

// MatchChild 获取下一层第一个匹配的路由
func (n *Node) MatchChild(part string) *Node {
	for _, child := range n.Children {
		if child.Part == part || child.IsFuzzy {
			return child
		}
	}

	return nil
}

// MatchChildren 匹配下一层中所有匹配的子路由
func (n *Node) MatchChildren(part string) []*Node {
	nodes := make([]*Node, 0)
	for _, child := range n.Children {
		if child.Part == part || child.IsFuzzy {
			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, IsFuzzy: part[0] == ':' || part[0] == '*'}
		n.Children = append(n.Children, child)
	}

	child.Insert(pattern, parts, height+1)
}

// Search 搜索节点下低于此高度的、且符合 Part 的部分
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)
	}
}

func (n *Node) String() string {
	return fmt.Sprintf("Node{Pattern=%s, part=%s, isWild=%t}", n.Pattern, n.Pattern, n.IsFuzzy)
}

使用 Trie 树实现的 Router

有了 Trie 树,接下来就可以使用 Trie 来代替 map 完成动态路由了。

这里使用 roots 来存储每种请求方式的 Trie 树根节点,使用 handlers 来存储每种请求方式的 HandlerFunc。此外,还提供了 getRoute 函数用来解析 :* 两种通配符的参数,返回一个 map[string]string 类型的数据。

补充实现之后,router.go 文件如下。

package gee

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

type Router struct {
	Roots    map[string]*Node       // 根节点,由方法确定
	Handlers map[string]HandlerFunc // 对应的 HandlerFunc
}

// roots key eg, roots['GET'] roots['POST']
// handlers key eg, handlers['GET-/p/:lang/doc'], handlers['POST-/p/book']

func NewRouter() *Router {
	return &Router{
		Roots:    make(map[string]*Node),
		Handlers: make(map[string]HandlerFunc),
	}
}

// parsePattern 只允许路由中有一个 * 通配符,解析出每一块 / 分割的值
func parsePattern(pattern string) []string {
	patternPaths := strings.Split(pattern, "/")

	parts := make([]string, 0, len(patternPaths))
	for _, part := range patternPaths {
		// 路径为空
		if len(part) == 0 {
			continue
		}

		parts = append(parts, part)
		if strings.HasPrefix(part, "*") {
			break
		}
	}

	return parts
}

func (r *Router) addRoute(method string, pattern string, handler HandlerFunc) {
	method = strings.ToUpper(method)
	log.Printf("[Route]: %4s - %s", method, pattern)

	parts := parsePattern(pattern)

	key := method + "-" + pattern
	if _, ok := r.Roots[method]; !ok {
		// 没有该类型的方法,新建节点
		r.Roots[method] = &Node{}
	}

	// 添加路由与 HandlerFunc
	r.Roots[method].Insert(pattern, parts, 0)
	r.Handlers[key] = handler
}

func (r *Router) getRoute(method string, path string) (node *Node, params map[string]string) {
	method = strings.ToUpper(method)
	root, ok := r.Roots[method]
	if !ok {
		return nil, nil
	}

	searchParts := parsePattern(path)
	node = root.Search(searchParts, 0)
	if node == nil {
		return nil, nil
	}

	params = make(map[string]string)

	parts := parsePattern(node.Pattern)
	for index, part := range parts {
		if strings.HasPrefix(part, ":") {
			params[part[1:]] = searchParts[index]
		}

		if strings.HasPrefix(part, "*") && len(part) > 1 {
			params[part[1:]] = strings.Join(searchParts[index:], "/")
			break
		}
	}

	return node, params
}

func (r *Router) handle(c *Context) {
	node, params := r.getRoute(c.Method, c.Path)
	if node == nil {
		// not found, return 404
		c.String(http.StatusNotFound, "[Route]: 404 NOT FOUND: %s\n", c.Path)
	}

	key := c.Method + "-" + c.Path
	c.Params = params
	handlerFunc := r.Handlers[key]
	handlerFunc(c)
}

里面主要的改动是新增 addRoute 以及 getRoute 方法,修改了 handle 方法。

  • 新增 addRoute 方法:新增 /path 的同时往 tire 中插入路径;
  • 新增 getRoute 方法:获取节点和解析出的请求参数;
  • 修改 handle 方法:路由查找的过程交给了 trie 处理;

单元测试

可以在同一个文件夹下创建 router 的单元测试。

package gee

import (
	"fmt"
	"reflect"
	"testing"
)

func newTestRouter() *Router {
	r := NewRouter()
	r.addRoute("GET", "/", nil)
	r.addRoute("GET", "/hello/:name", nil)
	r.addRoute("GET", "/hello/b/c", nil)
	r.addRoute("GET", "/hi/:name", nil)
	r.addRoute("GET", "/assets/*filepath", nil)
	return r
}

func TestParsePattern(t *testing.T) {
	ok := reflect.DeepEqual(parsePattern("/p/:name"), []string{"p", ":name"})
	ok = ok && reflect.DeepEqual(parsePattern("/p/*"), []string{"p", "*"})
	ok = ok && reflect.DeepEqual(parsePattern("/p/*name/*"), []string{"p", "*name"})
	if !ok {
		t.Fatal("test parsePattern failed")
	}
}

func TestGetRoute(t *testing.T) {
	r := newTestRouter()
	n, ps := r.getRoute("GET", "/hello/Real")

	if n == nil {
		t.Fatal("nil shouldn't be returned")
	}

	if n.Pattern != "/hello/:name" {
		t.Fatal("should match /hello/:name")
	}

	if ps["name"] != "Real" {
		t.Fatal("name should be equal to 'Real'")
	}

	fmt.Printf("matched path: %s, params['name']: %s\n", n.Pattern, ps["name"])

}

测试通过,即表示没有问题。

分组路由 Group

分组分析

分组路由功能是现在的 web 框架普遍支持的功能之一。

如果没有路由分组,我们需要针对每一个路由进行控制,会非常冗余。有了路由分组,就能以 group 为单位对路由进行控制。真实的使用场景中,也往往都是使用分组对路由进行控制的,这样能使得路由控制更加简单。

大部分情况下的路由分组,是以相同的前缀来区分的。中间件 middleware 的应用,可以为分组控制带来更明显的收益,并非共享相同的路由前缀那么简单。

中间件是应用在分组上的,所以分组路由还需要存储应用在该分组上的中间件 middlewares。分组路由更多是为了控制路由,那么使用 engine 来对分组内路由控制会更方便,所以分组路由中应该有 engine 对象。

分组结构

根据以上分析得出,分组路由的结构如下。

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

为了进一步抽象,顶级分组路由应该是 /,也是整个项目的最顶级部分。所以 engine 也会拥有分组路由的能力。

// Engine implement the interface of ServeHTTP
type Engine struct {
	*RouterGroup
	router *Router
	groups []*RouterGroup
}

接下来就可以将所有和路由有关的函数,都交给 RouterGroup 来实现了。

package gee

import "log"

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

// Group is defined to create a new RouterGroup
// all groups share the same Engine instance
func (group *RouterGroup) Group(prefix string) *RouterGroup {
	engine := group.engine
	nextGroup := &RouterGroup{
		prefix: group.prefix + prefix,
		parent: group,
		engine: engine,
	}
	engine.groups = append(engine.groups, nextGroup)
	return nextGroup
}

func (group *RouterGroup) addRoute(method string, comp string, handler HandlerFunc) {
	pattern := group.prefix + comp
	log.Printf("[RouteGroup] %4s - %s", method, pattern)
	group.engine.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)
}

使用案例

根据上面的实现,我们可以按照下面这种方式使用路由分组功能。

package main

import (
	"gee/gee"
	"net/http"
)

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=Real
			c.String(http.StatusOK, "hello %s, you're at %s\n", c.GetQueryVal("name"), c.Path)
		})
	}
	v2 := r.Group("/v2")
	{
		v2.GET("/hello/:name", func(c *gee.Context) {
			// expect /hello/Real
			c.String(http.StatusOK, "hello %s, you're at %s\n", c.GetParamVal("name"), c.Path)
		})
		v2.POST("/login", func(c *gee.Context) {
			c.JSON(http.StatusOK, gee.H{
				"username": c.GetPostFormVal("username"),
				"password": c.GetPostFormVal("password"),
			})
		})

	}

	r.Run(":9999")
}

直接测试:

  1. http://localhost:9999/v1/hello?name=Real

  1. 测试 POST 请求:
curl --request POST \
  --url http://localhost:9999/v2/login \
  --header 'content-type: multipart/form-data' \
  --form username=real \
  --form password=123456

中间件 Middleware

中间件 Middleware 是非业务的技术类组件。

Web 框架本身不可能去理解所有的业务,因而不可能实现所有的功能。但是优秀的框架应该提供扩展的机制,允许用户自定义一些扩展,嵌入到框架中执行。这种独立于业务之外的支持内嵌的组件,就是 web 框架的中间件。

中间件设计

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

我们希望最终能够支持如下定义的中间件,c.Next() 表示等待执行其他的中间件或用户的 Handler。

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

中间件在 web 框架中的机制,有点类似于 Java 中的 AOP 增强。

  • 两者同样可以决定对目标进行前置、后置、环绕增强。对于目标函数的调用,在 Java 中使用 proceed 函数,在 go 的 web 框架中通常会定义一个 Next 方法。
  • 不同点在于,Go 的 web 框架要实现环绕增强,需要定义一个处理器切片,在 Next 中执行目标函数前后,分别定义不同的中间件,实际上是多个中间件。

具体实现,可以在目标函数的调用前后,执行一些增强的逻辑。

中间件实现

为了方便实现,给 Context 加了两个参数。

type Context struct {
	// 原始对象
	Writer  http.ResponseWriter
	Request *http.Request

	// 请求信息
	Path   string
	Method string
	Params map[string]string

	// 返回信息
	StatusCode int

	// 中间件
	handlers []HandlerFunc
	index    int
}

func NewContext(w http.ResponseWriter, r *http.Request) *Context {
	return &Context{
		Writer:  w,
		Request: r,
		Path:    r.URL.Path,
		Method:  r.Method,
		Params:  make(map[string]string),
		index:   -1,
	}
}

func (c *Context) Next() {
	c.index++

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

func (c *Context) Fail(code int, err string) {
	c.index = len(c.handlers)
	c.JSON(code, H{"message": err})
}

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

新增的 index 是记录当前执行到第几个中间件,当在中间件中调用 Next 方法时,控制权交给了下一个中间件,直到调用到最后一个中间件;然后再从后往前,调用每个中间件在 Next 方法之后定义的部分。

因为中间件通常用于路由分组,因此 group 也需要同步修改。

// Use is defined to add middleware to the group
func (group *RouterGroup) Use(middlewares ...HandlerFunc) {
	group.middlewares = append(group.middlewares, middlewares...)
}

对于原有的 route 调用,方式也有一些变化。handle 函数中,将从路由匹配得到的 Handler 添加到 c.handlers 列表中,执行 c.Next()

func (r *Router) handle(c *Context) {
	node, params := r.getRoute(c.Method, c.Path)

	if node != nil {
		key := c.Method + "-" + node.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()
}

使用案例

有了中间件 Middleware,就可以在定义 Engine 之后,将事先定义好的中间件使用到 Engine 上。

定义一个 Logger 中间件,用于在路由处理前后打印一些关键日志。

package gee

import (
	"log"
	"time"
)

// Logger global middleware
func Logger() HandlerFunc {
	return func(c *Context) {
		// Start timer
		t := time.Now()
		// Process request
		c.Next()
		// Calculate resolution time
		log.Printf("[%d] %s in %v", c.StatusCode, c.Request.RequestURI, time.Since(t))
	}
}

对于 Logger 的使用很简单,只需要 Engine 调用 Use 方法即可。

package main

import (
	"gee/gee"
	"log"
	"net/http"
	"time"
)

func onlyForV2() gee.HandlerFunc {
	return func(c *gee.Context) {
		// Start timer
		t := time.Now()
		// if a server error occurred
		c.Fail(500, "Internal Server Error")
		// Calculate resolution time
		log.Printf("[%d] %s in %v for group v2", c.StatusCode, c.Request.RequestURI, time.Since(t))
	}
}

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

	v2 := r.Group("/v2")
	v2.Use(onlyForV2()) // v2 group middleware
	{
		v2.GET("/hello/:name", func(c *gee.Context) {
			// expect /hello/Real
			c.String(http.StatusOK, "hello %s, you're at %s\n", c.GetParamVal("name"), c.Path)
		})
	}

	r.Run(":9999")
}

模版 Template

模版的功能,是需要实现静态资源服务、支持 HTML 模版渲染。

现在前后端分离的开发模式中,大多数渲染功能交由前端浏览器实现。前后端渲染的一大问题是页面都是在客户端渲染的,这对于浏览器的 SEO 并不友好。在一段时间内,服务器直接渲染的 HTML 页面仍然是市面上的主流。

静态文件

要做到服务端渲染,第一步便是要支持 JS、CSS 等静态文件。之前设计的动态路由,需要支持通配符*匹配多级子路径。这一点其实就是将静态文件完成映射,路径上的文件,匹配预先设置好的服务器上存储的路径。

找到文件之后,将文件返回,这一步 net/http 库已经实现了。因此 gee 框架要做的,仅仅是解析请求的地址,映射到服务器上文件的真实地址,交给 http.FileServer 处理就好了。

// create static handler
func (group *RouterGroup) createStaticHandler(relativePath string, fs http.FileSystem) HandlerFunc {
	absolutePath := path.Join(group.prefix, relativePath)
	fileServer := http.StripPrefix(absolutePath, http.FileServer(fs))

	return func(c *Context) {
		file := c.GetParamVal("filepath")
		// Check if file exists and/or if we have permission to access it
		if _, err := fs.Open(file); err != nil {
			c.StatusCode = http.StatusNotFound
			return
		}

		fileServer.ServeHTTP(c.Writer, c.Request)
	}
}

// Static serve static files
func (group *RouterGroup) Static(relativePath string, root string) {
	handler := group.createStaticHandler(relativePath, http.Dir(root))
	urlPattern := path.Join(relativePath, "/*filepath")
	// Register GET handlers
	group.GET(urlPattern, handler)
}

模版渲染

Go 语言内置了 text/templatehtml/template 两个模板标准库,其中 html/template 为 HTML 提供了较为完整的支持,包括普通变量渲染、列表渲染、对象渲染等。gee 框架的模板渲染直接使用了 html/template 提供的能力。

为了支持模版渲染,可以在 Engine 中添加对模版支持的元素。

// Engine implement the interface of ServeHTTP
type Engine struct {
	*RouterGroup
	router *Router
	groups []*RouterGroup

	htmlTemplates *template.Template // for html render
	funcMap       template.FuncMap   // for html render
}

首先为 Engine 示例添加了 *template.Templatetemplate.FuncMap 对象,前者将所有的模板加载进内存,后者是所有的自定义模板渲染函数。

另外,给用户分别提供了设置自定义渲染函数 funcMap 和加载模板的方法。

func (engine *Engine) SetFuncMap(funcMap template.FuncMap) {
	engine.funcMap = funcMap
}

func (engine *Engine) LoadHTMLGlob(pattern string) {
	engine.htmlTemplates = template.Must(template.New("").
		Funcs(engine.funcMap).ParseGlob(pattern))
}

此外,还需要对原来的 (*Context).HTML() 方法做了些小修改,使之支持根据模板文件名选择模板进行渲染。在 Context 中定义好 Engine 这样的成员变量,就能通过 Context 访问 Engine 中的 HTML 模版了。

type Context struct {
	// 原始对象
	Writer  http.ResponseWriter
	Request *http.Request

	// 请求信息
	Path   string
	Method string
	Params map[string]string

	// 返回信息
	StatusCode int

	// 中间件
	handlers []HandlerFunc
	index    int

	// 引擎指针
	engine *Engine
}

func (c *Context) HTML(code int, name string, data interface{}) {
	c.SetHeader(ContentType, "text/html")
	c.SetStatusCode(code)
	if err := c.engine.htmlTemplates.ExecuteTemplate(c.Writer, name, data); err != nil {
		log.Printf("Response HTML Error: %v", err)
		c.Fail(http.StatusInternalServerError, err.Error())
	}
}

Context 中的 engine 元素同样需要在适当的时候赋值,于是对 gee 中的 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)
}

错误修复 Recovery

panic

Go 语言中,比较常见的错误处理方法是返回 error,由调用者决定后续如何处理。但是如果是无法恢复的错误,可以手动触发 panic,panic 会中止当前执行的程序。

defer

panic 会导致程序被中止,但是在退出前,会先处理完当前协程上已经 defer 的任务,执行完成后再退出。效果类似于 Java 中的 try...catch...finally语句。

recover

Go 语言还提供了 recover 函数,可以避免因为 panic 发生而导致整个程序终止,recover 函数只在 defer 中生效。

// hello.go
func test_recover() {
	defer func() {
		fmt.Println("defer func")
		if err := recover(); err != nil {
			fmt.Println("recover success")
		}
	}()

	arr := []int{1, 2, 3}
	fmt.Println(arr[4])
	fmt.Println("after panic")
}

func main() {
	test_recover()
	fmt.Println("after recover")
}

错误处理机制

对一个 Web 框架而言,错误处理机制是非常必要的。

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

为了使框架更加健壮,需要在 gee 中添加错误处理机制。

错误机制设计很简单,即在 panic 发生时,向用户返回 Internal Server Error,并且在日志中打印必要的错误信息,方便进行错误定位。

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

		c.Next()
	}
}

可以很简单的利用之前的 Middleware 机制来实现错误的处理。使用 defer 挂载上错误恢复的函数,在这个函数中调用 recover() 方法来捕获 panic,并且将堆栈信息打印在日志中,向用户返回 Internal Server Error。

这里的 trace() 函数实现如下。

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

trace() 中,调用了 runtime.Callers(3, pcs[:]),Callers 用来返回调用栈的程序计数器, 第 0 个 Caller 是 Callers 本身,第 1 个是上一层 trace,第 2 个是再上一层的 defer func。因此,为了日志简洁一点,我们跳过了前 3 个 Caller。

接下来,通过 runtime.FuncForPC(pc) 获取对应的函数,在通过 fn.FileLine(pc) 获取到调用该函数的文件名和行号,打印在日志中。至此,gee 框架的错误处理机制就完成了。

其他

为了方便测试和使用,通常会定义一个默认的 Engine 提供使用。

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

可以用 Default 方法很轻易构造出一个最基础的 Engine 对象。

package main

import (
	"gee/gee"
	"net/http"
)

func main() {
	r := gee.Default()
	r.GET("/", func(c *gee.Context) {
		c.String(http.StatusOK, "Hello Real\n")
	})
	// index out of range for testing Recovery()
	r.GET("/panic", func(c *gee.Context) {
		names := []string{"Real"}
		c.String(http.StatusOK, names[100])
	})

	r.Run(":9999")
}

访问对应的 URL,可以看到访问结果。

对应的在控制台中,可以看到相同的错误的 trace 日志。

错误的发生位置与打印的日志完全吻合,符合开发的需求。

Reference

评论 4
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值