从零实现系列|web 框架

本文通过回答关键问题的方式,记录阅读 gee 代码过程中的思考,并做出补充和改进,其中有所借鉴 gin 框架。

参考 gee 的方式,共设置七个模块,每个模块相互独立并依次迭代,源码在 implement-from-scratch。下面从如何设计框架的角度,提出了一些问题。

0.序言

net/http 可以干什么

提供基础的 web 功能:

  • 监听端口
  • 映射静态路由
  • 请求处理(解析 HTTP 报文)
  • 响应处理(生成和发送HTTP报文)
1
2
3
4
5
6
7
8
func main() {
http.HandleFunc("/", handler)
http.ListenAndServe(":9999", nil))
}

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

Web 框架需要干什么

换句话说,需要 web 框架在 net/http 的基础上实现什么?

  • 路由(Routing):将请求映射到函数,支持动态路由(例如'/hello/:name)、支持路由分组
  • 上下文(Context):Context 随着每一个请求的出现而产生,请求的结束而销毁,和当前请求强相关的信息都应由 Context 承载。为请求封装上下文,可以简化接口调用(不然需要带上 http.Request、http.ResponseWriter;还可以保存中间件产生的中间信息)。
  • 中间件(Midddleware):提供通用的中间件、为用户自定义中间件提供插入点。
  • 模板(Templates):使用内置模板引擎提供模板渲染机制。
  • 错误恢复(Panic Recover):提供错误处理机制,防止 panic 导致服务宕掉。

1.HTTP 基础

如何接管 HTTP 请求

http.ListenAndServe(":9999", nil)) 中第二个参数,表示处理所有 HTTP 请求的实例。如果传入 nil,则使用默认的 http.DefaultServeMux。

1
2
3
4
5
6
// net/http/server.go
// 传入的第二个参数,会被用于初始化 Server 这个结构体的 Handler 字段。
type Server struct {
Addr string

Handler Handler // handler to invoke, http.DefaultServeMux if nil // 注意这里的官方注释

只需要不传入 nil,传入我们自己的实例,就可以接管所有 HTTP 请求,开始构建我们的 web 框架。

那该传入什么呢?先看看 net/http 是如何实现的。第二个参数的类型是 Handler 接口类型,接口定义如下。

1
2
3
4
// net/http/server.go
type Handler interface {
ServeHTTP(ResponseWriter, *Request)
}

在 net/http/server.go 中,HandlerFunc 实现了这个 Handler 接口。实现如下。

注:这里插一下,HandlerFunc 是一个函数类型,在 go 中,函数是第一公民,也是可以实现接口的。第一次看这个写法可能比较奇怪,函数类型 HandlerFunc 调用一个函数 ServeHTTP,竟然最后还是调用自己 f(w, r)。为什么要这么做?可以阅读一下这篇文章《Go 接口型函数的使用场景》。这里总结一下,**为了既可以传入任何函数,也可以传入结构体,**以 HandlerFunc 为例:

  • 参数类型是 Handler 接口类型,普通函数/匿名函数只需要强制转换为 HandlerFunc,就可以作为参数传入)
  • 只要实现了 ServeHTTP 方法的结构体,也可以作为参数传入。
1
2
3
4
5
6
7
8
9
10
11
// net/http/server.go
// The HandlerFunc type is an adapter to allow the use of
// ordinary functions as HTTP handlers. If f is a function
// with the appropriate signature, HandlerFunc(f) is a
// Handler that calls f.
type HandlerFunc func(ResponseWriter, *Request)

// ServeHTTP calls f(w, r).
func (f HandlerFunc) ServeHTTP(w ResponseWriter, r *Request) {
f(w, r)
}

回到主线,也就是说,只要我们像 HandlerFunc 一样,实现了 Handler 接口,就可以作为 http.ListenAndServe 第二个参数,从而接管监听到的所有 HTTP 请求。

定义 Engine,并实现结构体函数 ServeHTTP。将 http.ListenAndServe 的参数传递,封装到 Run 函数中,完成。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// gee/gee.go
type Engine struct {}

func New() *Engine {
return &Engine{}
}

func (engine *Engine) Run(addr string) error {
return http.ListenAndServe(addr, engine) // 传入 Handler 接口类型,只要实现了 ServeHTTP(ResponseWriter, *Request) 就实现了 Handler 接口。
}

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
fmt.Println(req)
// 处理请求
}


// main.go
func main() {
engine := gee.New()
_ = engine.Run(":9999")
}

为什么 ServeHTTP 参数分别是指针传递/值传递

1
2
3
4
5
// gee/gee.go

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// ...
}

req 接收的是结构体,可能有大量数据,使用指针可以节省内存。

http.ResponseWriter 是一个接口类型,w 是接口类型变量,不能使用指针。

目的是,不管接收的是什么,只要它实现了 Header()Write([]byte)WriteHeader(statusCode int) 这三个函数,它就可以保存在 w 这个变量中。(也没必要使用指针,这里需要得是 w 的三个方法。)

1
2
3
4
5
6
7
8
// net/http/server.go

// http.ResponseWriter 接口的定义
type ResponseWriter interface {
Header() Header
Write([]byte) (int, error)
WriteHeader(statusCode int)
}

如何管理静态路由及请求处理函数

框架使用者想要什么?想要用这样的方式注册静态路由。

1
2
3
4
5
6
7
8
9
10
11
// main.go
func main() {
engine := gee.New()
engine.GET("/", Index) // 注册静态路由
_ = engine.Run(":9999")

}

func Index(w http.ResponseWriter, req *http.Request) {
// ...
}

我们在 Engine 内部维护一个 map,用来映射路由地址和处理函数。

1
2
3
4
5
6
7
8
9
10
11
12
13
// gee/gee.go

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

type Engine struct {
router map[string]HandlerFunc // 内部维护一个map 做路由映射
}

func New() *Engine {
return &Engine{
router: make(map[string]HandlerFunc), // 初始化 Engine 时初始化 map
}
}

在 map 中,使用 method + "-" + pattern 作为 key,HandlerFunc 作为 value。提供 addRoute 函数增加 map 中的路由映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// gee/gee.go

func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
key := method + "-" + pattern
engine.router[key] = handler // 增加路由映射
}

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

最后需要改写 ServeHTTP 的逻辑。收到请求后,用请求方法和请求路径构造 key,并执行对应的请求处理函数。

1
2
3
4
5
6
7
8
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)
}
}

完整代码

1
2
3
4
5
6
7
# 代码结构

version_1_http
├── gee
│   └── gee.go
├── go.mod
└── main.go

2. 上下文

需要 context 做什么

context 随着每一个请求的出现而产生,请求的结束而销毁,和当前请求强相关的信息都应由 Context 承载。将扩展性和复杂性留在了 context 内部,而对外提供简化的接口。

  • 简化接口参数:
    • context 储存 http.Request、http.ResponseWriter,让请求处理函数的参数、中间件的参数,均使用 context 实例。
  • 封装常用方法:
    • http.Request、http.ResponseWriter 提供的接口粒度太细,用起来繁琐。
    • 封装获取请求参数的方法。
    • 封装快速构造 String/Data/JSON/HTML 响应的方法。
    • 封装设置响应的 header(状态码 StatusCode 和消息类型 ContentType)的方法。
  • 储存上下文信息
    • 储存动态路由的参数。
    • 储存中间件产生的信息。

如何简化接口参数

  • 先构造 context
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// gee/context.go

type Context struct { // 暂且保存常用的参数
Req *http.Request
Writer http.ResponseWriter
Path string
Method string
StatusCode int
}

func NewContext(writer http.ResponseWriter, req *http.Request) *Context {
return &Context{
Req: req,
Writer: writer,
Path: req.URL.Path,
Method: req.Method,
}
}
  • 将请求处理函数的参数,由原来的 http.ResponseWriter、*http.Request 改成 context。

如何封装常用方法

主要是封装 http 的原生功能。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
// gee/context.go

// 获取请求参数的方法
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)
}

// 设置响应的 header 的消息类型 ContentType 的方法。
func (c *Context) SetHeader(key string, value string) {
c.Writer.Header().Set(key, value)
}

// 设置响应的 header 的状态码 StatusCode 的方法。
func (c *Context) Status(code int) {
c.StatusCode = code
c.Writer.WriteHeader(code)
}

// 快速构造 String 响应的方法
func (c *Context) String(code int, format string, values ...interface{}) {
c.SetHeader("Content-Type", "text/plain")
c.Status(code)
if _, err := c.Writer.Write([]byte(fmt.Sprintf(format, values...))); err != nil {
http.Error(c.Writer, err.Error(), 500)
}
}

// 快速构造 JSON 响应的方法
func (c *Context) JSON(code int, obj interface{}) {
c.SetHeader("Context-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)
}
}

// 快速构造 Data 响应的方法
func (c *Context) Data(code int, date []byte) {
c.Status(code)
if _, err := c.Writer.Write(date); err != nil {
http.Error(c.Writer, err.Error(), 500)
}
}

// 快速构造 HTML 响应的方法
func (c *Context) HTML(code int, html string) {
c.SetHeader("Context-Type", "text/html")
c.Status(code)
if _, err := c.Writer.Write([]byte(html)); err != nil {
http.Error(c.Writer, err.Error(), 500)
}
}

完整代码

注:为便于动态路由的处理,我们将 router 的这个 map,单独提取出来。

1
2
3
4
5
6
7
8
9
# 代码结构

version_2_context
├── gee
│   ├── context.go
│   ├── gee.go
│   └── router.go # 新增
├── go.mod
└── main.go

3.前缀树路由

为什么需要前缀树路由

之前使用 map 存储路由表,但是请求路径都是确定的,无法支持动态路由的匹配,比如用 /hello/:name 匹配 /hello/aimtao

使用前缀树结构后,可以将一个请求地址 /hello/:name,以 “/” 为分割符分为不同段 helloaimtao,依次进行匹配判断。

如何实现前缀树

在实现前缀树路由之前,我们先回顾一下,如何实现一颗前缀树。比如在要插入 AB、ABC、DF、DH、XY 五个字符串。

实现前缀树需要做三件事:设计 Node 节点、实现前缀树的插入、实现前缀树的搜索。

每个节点应该存哪些内容呢?

  • 需要一个 bool 型 isEnd 来标记,当前节点的字符是否是一个单词的结尾。
  • 每个节点都使用 map 存子节点,便于快速查找下一个字符的 Node 节点。
  • 其实并不需要储存当前节点代表哪个字符,因为父节点的 map 中已保存。
1
2
3
4
5
6
7
8
9
// gee/draft/tire.go

type Node struct {
isEnd bool // 当前字符是否是单词结尾
next map[rune]*Node // 子节点储存后续的字符
}
type Trie struct {
root *Node
}

所以在前缀树中插入 AB、ABC、DF、DH、XY 五个字符串,实际上是一个这样的树。

确定好 Node 结构,再来看看如何插入字符串。

1
2
3
4
5
6
7
8
9
10
11
12
// gee/draft/tire.go

func (trie *Trie) Insert(word string) {
cur := trie.root
for _, char := range []rune(word) { // 依次遍历字符,如果不存在 char 这个字符,则将 char 加入 map 中。
if _, ok := cur.next[char]; !ok {
cur.next[char] = &Node{next: make(map[rune]*Node)}
}
cur = cur.next[char] // cur 指针指向 char 这个 node。
}
cur.isEnd = true // 说明当前字符是结尾字符
}

查询的时候,和插入流程是一样的,区别在于,插入时,map 中没有则创建,查询时,map 中没有则返回 false。

1
2
3
4
5
6
7
8
9
10
11
12
// gee/draft/tire.go

func (trie *Trie) Search(word string) bool {
cur := trie.root
for _, char := range []rune(word) { // 依次遍历字符,如果不存在 char 这个字符,则返回 false
if _, ok := cur.next[char]; !ok {
return false
}
cur = cur.next[char]
}
return cur.isEnd // 注意,这里即使存在路经可以遍历 word,仍需判断当前字符是否是结尾字符。例如前缀树里只存在 ABC,查询 AB,应该返回 fasle
}

测试代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// gee/draft/trie_test.go

package draft

import (
"fmt"
"testing"
)

func TestTrie(t *testing.T) {
trie := Trie{
root: &Node{next: make(map[rune]*Node)},
}

trie.InsertMore("AB", "ABC", "DF", "DH", "XY")
Print(trie.root)
}

func Print(node *Node) {
fmt.Printf("Node{isEnd:%t, next:[", node.isEnd)
n := len(node.next)
i := 0
for k, v := range node.next {
fmt.Printf("'%c': %p", k, v)
i++
if i < n {
fmt.Printf(", ")
}
}
fmt.Println("]}")
for _, v := range node.next {
Print(v)
}
}

如何实现前缀树路由

路由地址由 / 进行分隔,例如注册三个 GET 请求 “/hello”、“/hello/:name”、“/assets/*filepath”,一个POST 请求 “/hello”,前缀树应该是这样的。

和实现前缀树一样,实现前缀树路由需要做三件事:设计 Node 节点、实现前缀树路由的插入,实现前缀树路由的查找。

前缀树路由 Node 节点,需要保存四个字段,各有作用。

1
2
3
4
5
6
7
8
// gee/trie.go

type node struct {
path string // 匹配上的完整的路由地址,只有最后节点才能保存 path
part string // 当前节点的 URL 片段
children map[string]*node // 储存后续片段的节点
isWild bool // 是否是通配符节点
}

例如配置路由地址 “/hello”、“/hello/:name”、“/assets/*filepath” 时,实际上的前缀树如图。

将路由地址按 / 进行分隔。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// gee/trie.go

func parsePath(pattern string) []string {
patterns := strings.Split(pattern, "/")
/* 注意:
pattern 是 "/hello",前面会被分出两个空字符串,需要删除
pattern 是 "hello/", 后面会被分出一个空字符串,需要删除
pattern 是 "/",前后会被分出两个空字符串,需要删除
所以,直接删除前面的空格和后面的空格。
*/
if len(patterns) > 0 && patterns[0] == "" { // 删除前面的空格
patterns = patterns[1:]
}
if len(patterns) > 0 && patterns[len(patterns)-1] == "" { // 删除后面的空格
patterns = patterns[:len(patterns)-1]
}
return patterns
}

插入

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// gee/trie.go

// 路由注册,将路由地址插入到前缀树中
func (root *node) insert(pattern string) {
cur := root
patterns := parsePath(pattern) // 提前将路由地址,分割成片段保存在数组中

// 依次遍历路由地址的片段,不存在则创建保存该片段的节点。
for _, part := range patterns {
if _, ok := cur.children[part]; !ok {
cur.children[part] = &node{
part: part,
children: make(map[string]*node),
isWild: part[0] == ':' || part[0] == '*',
}
}
cur = cur.children[part] // cur 指向保存当前片段的节点,后面的片段保存在 cur 当前节点的子节点中
}

cur.path = pattern // 当遍历完路由地址,在最后一个节点中,保存完整的路由地址,其他节点不保存完整的路由地址。
}

查询

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// gee/trie.go

// search 获取路由树节点以及请求地址中的变量
func (root *node) search(pattern string) (*node, map[string]string) {
params := make(map[string]string)

cur := root
patterns := parsePath(pattern)

// 依次遍历路由地址的片段,无法匹配上则退出
for _, part := range patterns {
if cur.children[part] == nil { // 无法匹配上,开始尝试通配符匹配
for k, v := range cur.children { // 遍历当前片段下的所有子节点,排查有通配符的节点。
if v.isWild == true && k[0] == '*' { // 找到*,保存该片段及该片段后的所有内容做参数
params[k[1:]] = pattern[strings.Index(pattern, part):]
return v, params
} else if v.isWild == true && k[0] == ':' { // 找到:,保存该片段做参数
params[k[1:]] = part
cur = v
break
} else { // 没有通配符节点
return nil, nil
}
}
} else { // 当前片段准确匹配上,继续匹配后面的片段
cur = cur.children[part]
}
}

// 所有请求路经片段均匹配完毕,检查当前节点是否有完整的路由地址。比如,路由注册了 /a/b,请求路经是 /a,虽然也匹配上了,但 a 这个节点未保存完整的路经,只有最后的节点 b 节点会保存。
if cur.path != "" {
return cur, params
}
return nil, nil
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
# 代码结构

version_3_router
├── gee
│   ├── context.go
│   ├── gee.go
│   ├── router.go
│   ├── trie.go # 新增:前缀树路由
│   └── draft # 草稿:实现一个前缀树
│      ├── tire.go
│      └── trie_test.go
├── go.mod
└── main.go

4.分组控制

为什么需要路由分组

便于统一处理某一组路由。比如这类需求,

  • /post开头的路由匿名可访问。
  • /admin开头的路由需要鉴权。
  • /api开头的路由是 RESTful 接口,可以对接第三方平台,需要三方平台鉴权。

这里补充两点:

  • 常见的路由分组方式,就是以相同前缀来区分不同的路由。
  • 如何统一处理某一组路由,需要使用中间件,下文第五章会详细叙述。

如何设计路由分组功能

以相同前缀来区分不同的路由。我们先看下,用户想如何使用,

1
2
3
4
5
6
r := gee.New()
v1 := r.Group("/v1")
user := v1.Group("/user")
user.GET("/hello", Hello)

// curl http://127.0.0.1:9999/v1/user/hello

为了实现上述功能,需要做两件事:

  1. 首先,我们使用 RouterGroup 来保存分组的数据,比如前缀、作用在这个分组上的中间件。

  2. 其次,RouterGroup 需要增加路由的能力(GET、POST),我们使用 组合(Composite) 来实现,在 RouterGroup 的肚子里,放一个全局的 Engine 指针变量。

1
2
3
4
type RouterGroup struct {
prefix string // 该分组的完整前缀,例如 user 分组保存的是 "/v1/user/",从最顶层的 group 到当前的 group
engine *Engine // 组合的方式
}

通过该指针变量,间接使用 Engine 的各种能力,比如封装自己的 GET、POST 方法。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
func (group *RouterGroup) Group(prefix string) *RouterGroup {
newGroup := &RouterGroup{
prefix: group.prefix + prefix,
engine: group.engine,
}
group.engine.groups = append(group.engine.groups, newGroup) // Engine 保存所有的 Group,后面查照中间件会用上,此处可以不 care
return newGroup
}

func (group *RouterGroup) GET(pattern string, handler HandlerFunc) {
pattern = group.prefix + pattern // 拼接路径,比如上面例子中的 "/v1/user" 和 "/hello"
group.engine.GET(pattern, handler) // 间接使用 Engine 增加路由的能力
}

func (group *RouterGroup) POST(pattern string, handler HandlerFunc) {
pattern = group.prefix + pattern
group.engine.POST(pattern, handler)
}

为什么将 Engine 抽象为最顶层的 RouterGroup

上述方式可以达到目的,但是我们会发现一个问题,

  • Engine 可以通过 GET/POST 方法增加路由,RouterGroup 也需要。
  • Engine 可以通过 中间件 增加请求处理行为,RouterGroup 也需要。

在这两个功能上,Engine 和 RouterGroup 是一样的,没必要写两套同样的逻辑。可以将 Engine 抽象成最顶层的 RouterGroup。

如何将 Engine 抽象为最顶层的 RouterGroup

需要做两件事情:

  1. 将这上述的两个功能(增加路由、使用中间件)全交给 RouterGroup 来做。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// gee/gee.go

func (group *RouterGroup) addRoute(method string, pattern string, handler HandlerFunc) {
pattern = group.prefix + 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)
}
  1. 通过 嵌入(Embeding)的方式,让 Engine 拥有 RouterGroup 全部能力。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// gee/gee.go

type Engine struct {
*RouterGroup // 拥有 RouterGroup 的能力,可以隐形地调用 RouterGroup 的方法
router *router
groups []*RouterGroup // 所有分组路径
}

func New() *Engine {
engine := &Engine{router: newRouter()}
engine.RouterGroup = &RouterGroup{engine: engine}
engine.groups = []*RouterGroup{engine.RouterGroup}
return engine
}

// 删除 Engine 的 GET、POST、addRouter 方法。

为什么要使用 嵌入的方式

当把路由能力交给 routerGroup来完成后,Engine 可以隐性地使用 RouterGroup 的方法。

1
2
3
engine.GET("hello", hello)

// 使用组合的话,就需要这样使用 engine.RouterGroup.GET("hello", hello)

完整代码

1
2
3
4
5
6
7
8
9
10
# 代码结构

version_4_group
├── gee
│   ├── context.go # 未更改,与上一节代码相同
│   ├── gee.go
│   ├── router.go # 未更改,与上一节代码相同
│   └── trie.go # 未更改,与上一节代码相同
├── go.mod
└── main.go

5.中间件

为什么需要中间件

可以在不入侵业务逻辑的情况下,在业务开始前/结束后完成一些功能,比如 logger、recover。web 框架需要提供一个插入点,允许用户自定义功能。

中间件的执行顺序

例如 group v1、user 分别有中间件 middlewareA,middlewareB。则 /v1/user/hello 需要执行两个中间件,middlewareA、middlewareB。

执行的过程,和函数调用栈是一样的,

  1. middlewareA 前半部分(middlewareA 入栈,执行)
  2. middlewareB 前半部分(middlewareB 入栈,执行)
  3. 真正的请求处理函数 handle(handle 入栈,执行,出栈)
  4. middlewareB 后半部分(middlewareB 继续执行,出栈)
  5. middlewareA 后半部分(middlewareA 继续执行,出栈)

如何设计中间件

用户会如何使用?

  • 用户传入一个 func(ctx *gee.Context) 函数作为中间件。
  • 中间件分为三部分,上半部分、执行下一个中间件/请求处理函数、下班部分。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// main.go

r := gee.New()

v1 := r.Group("/v1")
v1.Use(func(ctx *gee.Context) { // 在 v1 group 上使用中间件
// 中间件上半部分
now := time.Now()

// 执行下一个中间件或者真正的请求处理函数
ctx.Next()

// 中间件的下半部分
fmt.Println(time.Since(now))
})

为了实现上述功能,需要做三件事:

  • 储存中间件。
  • 处理请求时,明确该执行哪些中件间。
  • 实现一种机制,让中间件依次执行。

下面依次说明。

  1. 储存中间件。

首先中间件的类型是 func(ctx *gee.Context),也就是 HandlerFunc 类型。

既然中间件和 RouterGroup 绑定的,我们就在 RouterGroup 中保存一个 HandlerFunc 数组,用来储存这个分组下的中间件。(一个分组可能有多个中间件)

1
2
3
4
5
6
7
8
9
10
11
// gee/gee.go

type RouterGroup struct {
prefix string
middlewares []HandlerFunc // 储存该分组下的中间件
engine *Engine
}

func (group *RouterGroup) Use(handlerFunc ...HandlerFunc) { // 用户为该分组增加中间件
group.middlewares = append(group.middlewares, handlerFunc...)
}
  1. 处理请求时,明确该执行哪些中间件。

思路:每个 RouterGroup 都保存了各自的中间件,我们在处理请求时,查看该请求,属于哪些分组,并将这些分组的中间件,均保存下来,交给 context 依次执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// gee/gee.go

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {

var middlewares []HandlerFunc
for _, group := range engine.groups { // 遍历所有分组
prefix := group.prefix + "/" // 为什么要加 "/"?防止匹配错误,详见 https://github.com/geektutu/7days-golang/pull/77
if strings.HasPrefix(req.URL.Path, prefix) { // 查看当前请求,是否属于该分组
middlewares = append(middlewares, group.middlewares...) // 属于该分组,将该分组的中间件保存下来
}
}

context := NewContext(w, req)
context.handlers = middlewares // 将保存下来的中间件,交给 context 依次执行
engine.router.handle(context)
}
  1. 实现一种机制,让中间件依次执行。

上一步提到,将保存下来的中间件,交给 context 依次执行。一个小疑问,为什么要交给 context 执行?

  • 中间件也是 HandlerFunc 类型,可以和请求处理函数统一起来,让请求处理函数跟在中间件后面,按顺序执行。
  • context 信息相当于全局信息,将 HandlerFunc 数组放在 context 中,无论执行哪个函数,均可知道下一个该执行哪个函数。(用 index 记录执行到哪个函数)

所以我们在 context 中新建两个变量 handlers 和 index。将 index 初始化为 -1,表示还没开始执行函数,下一个执行 handlers[0]。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
// gee/context.go

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

// middleware
handlers []HandlerFunc // 保存中间件和请求处理函数
index int // 标志当前执行到第几个函数
}

func NewContext(writer http.ResponseWriter, req *http.Request) *Context {
return &Context{
Req: req,
Writer: writer,
Path: req.URL.Path,
Method: req.Method,

index: -1, // 初始化为 -1,表示还没开始执行函数,下一个执行 handlers[0]
}
}

并提供执行下一个中间件/请求处理函数的方法。

1
2
3
4
5
6
7
8
9
// gee/context.go

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

准备完毕,最后需要让 context.handlers 执行。

原本逻辑是 ServeHTTP -> router.handle 执行 请求处理函数,现在需要把请求处理函数也加在context.handlers 后面,一并执行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// gee/router.go

func (r *router) handle(c *Context) {
node, params := r.getRouter(c.Method, c.Path)
if node != nil {
c.Params = params
key := c.Method + "-" + node.path
c.handlers = append(c.handlers, r.handlers[key]) // 将找到的 请求处理函数 加在 c.handlers 的后面
} else {
fmt.Fprintf(c.Writer, "404 NOT FOUND: %s\n", c.Req.URL)
}
c.Next() // 依次执行 c.handlers 的函数。 // c.Next() 一定要放在最后,不能放在 if node != nil 中,因为即使找不到请求处理函数,也是要执行中间件
}

为什么需要遍历 handlers

1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 写法一
func (c *Context) Next() {
c.index++
s := len(c.handlers)
for ; c.index < s; c.index++ { // 中间件会自己调用 c.Next(),为什么这里还需要遍历?
c.handlers[c.index](c)
}
}

// 写法二
func (c *Context) Next() {
c.index++
c.handlers[c.index](c)
}

不是所有的 handler 都会调用 Next()。手动调用 Next(),一般用于在请求前后各实现一些行为。如果中间件只作用于请求前,写法一可以省略调用Next(),兼容性更好。

Gin 中是如何设计中间件的

和本文思路一致,区别在于 “明确该执行哪些中间件” 这一步,

  • 本文是执行请求时,才开始查找要执行哪些中间件。在 ServeHTTP 中遍历所有 RouterGroup。
  • Gin 是在 addRouter 时,将中间件保存在 Router 的 map 中。

主要看三个关键代码:

  1. **RouterGroup.Group。**每个中间件都保存该组执行的所有中间件。
1
2
3
4
5
6
7
8
9
10
11
// 举个例子
v1 := engine.Group("v1")
v1.Use(FuncA)

user := v1.Group("user")
user.Use(FuncB)

/*
v1.Handlers = [FuncA]
user.Handlers = [FuncA, FuncB] // user 中会保存该组需要执行的所有中间件。而本文的实现,只会保存 FuncB,要使用的时候,再依次查找。
*/

Gin 如何实现这一步骤?

1
2
3
4
5
6
7
8
9
// routergroup.go

func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup {
return &RouterGroup{
Handlers: group.combineHandlers(handlers), // 合并当前组的中间件和上层 RouterGroup 的中间件,该函数下文会提到。
basePath: group.calculateAbsolutePath(relativePath), // 保存绝对路径
engine: group.engine,
}
}
  1. **RouterGroup.GET -> RouterGroup.handle。**合并路径,合并中间件和请求处理函数到同一队列中。
1
2
3
4
5
6
7
8
// routergroup.go

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
absolutePath := group.calculateAbsolutePath(relativePath) // 保存当前请求的绝对路径,相对路径 + group 路径
handlers = group.combineHandlers(handlers) // handlers = 当前请求处理函数 + 中间件处理函数
group.engine.addRoute(httpMethod, absolutePath, handlers)
return group.returnObj()
}
  1. **RouterGroup.handle -> RouterGroup.combineHandlers。**合并中间件和请求处理函数到同一队列中的具体实现。(该函数也用于合并当前组的中间件和上层 RouterGroup 的中间件,原理一样。)
1
2
3
4
5
6
7
8
9
10
// routergroup.go

func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
finalSize := len(group.Handlers) + len(handlers) // 计算长度
assert1(finalSize < int(abortIndex), "too many handlers")
mergedHandlers := make(HandlersChain, finalSize) // 创建新的数组
copy(mergedHandlers, group.Handlers) // 先拷贝中间件,在数组前面,先执行
copy(mergedHandlers[len(group.Handlers):], handlers) // 再拷贝请求处理函数,在数组后面,后执行
return mergedHandlers
}

完整代码

1
2
3
4
5
6
7
8
9
10
# 代码结构

version_5_middleware
├── gee
│   ├── context.go
│   ├── gee.go
│   ├── router.go
│   └── trie.go # 未更改,与上一节代码相同
├── go.mod
└── main.go

6.模版

模板可以干什么

同一个 HTML 模板,样式是一样的,但是根据后端返回的数据不同,页面展示的数据不同。

举个例子,用户个人中心页面,虽然页面布局大致相同,但是不同用户的页面上显示的用户名是不同的。这就是根据后端返回的不同数据,基于同一个模板来渲染的。

如何实现 HTML 渲染

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

我们需要做的就是,将 html/template 封装为 Engine 的能力。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
// gee/gee.go

type Engine struct {
*RouterGroup
router *router
groups []*RouterGroup

// for html render
htmlTemplates *template.Template // 将所有模板加载进内存
funcMap template.FuncMap // 模板的渲染函数(可自定义多个)
}

// SetFuncMap 设置渲染函数,可以在模板中指定,某个数据使用某个渲染函数
// 传入的 template.FuncMap,一个 map,保存了渲染函数对应的名称,在模板中使用名称即可指定渲染函数
func (engine *Engine) SetFuncMap(funcMap template.FuncMap) {
engine.funcMap = funcMap
}

// LoadHTMLGlob 指定模板的路径,将模板加载到内存中
func (engine *Engine) LoadHTMLGlob(pattern string) {
engine.htmlTemplates = template.New("")
engine.htmlTemplates.Funcs(engine.funcMap)
engine.htmlTemplates = template.Must(engine.htmlTemplates.ParseGlob(pattern))
}

最后在 context 中,重新改造 HTML 函数。让 htmlTemplates 来响应请求。

1
2
3
4
5
6
7
8
9
10
11
12
// gee/context.go

func (c *Context) HTML(code int, name string, data interface{}) {
c.SetHeader("Context-Type", "text/html")
c.Status(code)

// context 需要保存 Engine 指针,以便可以访问 htmlTemplates
err := c.engine.htmlTemplates.ExecuteTemplate(c.Writer, name, data)
if err != nil {
c.Fail(500, err.Error())
}
}

模板格式如下,其中,{{.title}} 表示 title 变量填充在这个位置,{{.now | FormatAsDate}} 表示 now 变量数据经过 FormatAsDate 函数处理之后填充在这个位置。

1
2
3
4
5
6
7
8
<!-- templates/custom_func.tmpl>

<html>
<body>
<p>hello, {{.title}}</p>
<p>Date: {{.now | FormatAsDate}}</p>
</body>
</html>

最后,main 函数中设置自定义函数、传入 gee.H。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
// main.go

func FormatAsDate(t time.Time) string { // 传入的数据按照这个方式渲染
year, month, day := t.Date()
return fmt.Sprintf("%d-%02d-%02d", year, month, day)
}

func main() {
r := gee.New()

r.SetFuncMap(template.FuncMap{ // 设置自定义渲染函数
"FormatAsDate": FormatAsDate,
})
r.LoadHTMLGlob("templates/*") // 加载模板路径

r.GET("/date", func(c *gee.Context) {
c.HTML(http.StatusOK, "custom_func.tmpl", gee.H{
"title": "gee",
"now": time.Date(2019, 8, 17, 0, 0, 0, 0, time.UTC),
})
})

r.Run(":9999")
}

/*
curl 127.0.0.1:9999/date
<html>
<body>
<p>hello, gee</p>
<p>Date: 2019-08-17</p>
</body>
</html>%
*/

如何在模板中使用静态文件

在渲染 html 文件的同时,还会需要加载一些静态文件,比如 css、js 文件。

需要提供给用户 Static 方法,使用 Static 方法配置完成后,用户访问 localhost:9999/assets/css/index.css,最终返回 /usr/static/css/index.css。

1
2
3
// main.go

r.Static("/assets", "/usr/static/")

Static 方法需要做什么工作?

  1. 通过拼接得到路由路径 “/assets/*filepath”。
  2. 创建路由处理函数 handler。
  3. 添加路由映射 RouterGroup.GET。
1
2
3
4
5
6
7
8
// gee/gee.go

//Static 例如 r.Static("/assets", "./static")
func (group *RouterGroup) Static(relativePath string, root string) {
urlPattern := path.Join(relativePath, "/*filepath") // 1.拼接路径,例如得到 "/assets/*filepath"
handler := group.createStaticHandler(relativePath, http.Dir(root)) // 2.得到路由处理函数 handler
group.GET(urlPattern, handler) // 3.添加路由映射,例如将路由地址 "/assets/*filepath" 和处理函数 handler 相绑定。
}

这个 handler 应该具有什么能力?

  1. 先去掉所有前缀,只剩下文件路径。例如 localhost:9999/assets/css/index.css 去掉前缀后是,css/index.css
  2. 在 /usr/static/ 的目录下,建立文件系统,在文件系统中打开 css/index.css。

其中,第一点,去掉所有路径可以由 http.StripPrefix 完成;第二点,建立文件系统打开文件可以由 http.FileServer 完成。我们只需要封装一个这样的 handler。

如何封装?

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// gee/gee.go

// createStaticHandler 例如获取路由地址 "/assets/*filepath" 的处理函数
func (group *RouterGroup) createStaticHandler(relativePath string, fs http.FileSystem) HandlerFunc {
absolutePath := path.Join(group.prefix, relativePath) // 拼接上路由分组的前缀, 例如 "/user" + "/assets", 即得到完整前缀,不包含实际文件路径

/* fileServer 是一个handler 接口类型变量,它的功能是:
1.将请求的 URL 中的前缀 absolutePath 去掉得到文件路径
2.将文件路径交给 http.FileServer(fs) 这个handler 来打开。
*/
fileServer := http.StripPrefix(absolutePath, http.FileServer(fs))

return func(ctx *Context) {
file := ctx.Param("filepath")
if _, err := fs.Open(file); err != nil {
ctx.Status(http.StatusNotFound)
return
}
fileServer.ServeHTTP(ctx.Writer, ctx.Req) // 这里调用 fileServer.ServeHTTP,实际在调用 fileServer 函数本身。
}
}

有趣的函数式编程

功能实现后,进一步看原码,会发现这里函数式编程很有意思。

  • 首先, 明确流程

  • http.StripPrefix 返回一个 HandlerFunc 类型函数 到 handler 接口类型变量 fileServer 中, 当下文调用 fileServer.ServeHTTP 时,就会执行这个 handler。(http.StripPrefix 是包装作用,统一处理错误 也使用了这种用法)

  • 为什么?因为 fileServer 底层是 HandlerFunc 类型,HandlerFunc 实现了 ServeHTTP 函数,调用 ServeHTTP 时就是在调用自己 HandlerFunc。这里讲过:如何接管-HTTP-请求

  • 其次, fileServer 这个 handle 做什么工作?

    • 在请求地址中,删除完整的前缀 absolutePath(例如 “/user/assets”),剩下文件路径(例如:/css/index.css)

    • 用剩下的文件路径作为 URL 再构造一个 request,让 http.FileServer(fs) 来处理。(http.FileServer(fs)) 是一个 handler,也是一个文件系统)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// net/http/server.go

func StripPrefix(prefix string, h Handler) Handler {
if prefix == "" {
return h
}
return HandlerFunc(func(w ResponseWriter, r *Request) { // 返回一个 HandlerFunc 函数
p := strings.TrimPrefix(r.URL.Path, prefix)
rp := strings.TrimPrefix(r.URL.RawPath, prefix)
if len(p) < len(r.URL.Path) && (r.URL.RawPath == "" || len(rp) < len(r.URL.RawPath)) {
r2 := new(Request)
*r2 = *r
r2.URL = new(url.URL)
*r2.URL = *r.URL
r2.URL.Path = p
r2.URL.RawPath = rp
h.ServeHTTP(w, r2) // 构造新的请求 r2, 交给 h 去处理
} else {
NotFound(w, r)
}
})
}
  • 最后,http.FileServer(fs) 这个 handler 做什么工作?
    • 主要是打开文件,http.FileServer(fs) 底层是 fileHandler 类型,打开文件的具体实现在 fileHandler 的 ServeHTTP 方法里。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// net/http/fs.go

func FileServer(root FileSystem) Handler {
return &fileHandler{root}
}

func (f *fileHandler) ServeHTTP(w ResponseWriter, r *Request) {
upath := r.URL.Path
if !strings.HasPrefix(upath, "/") {
upath = "/" + upath
r.URL.Path = upath
}
serveFile(w, r, f.root, path.Clean(upath), true)
}

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
# 代码结构
version_6_template
├── gee
│   ├── context.go
│   ├── gee.go
│   ├── router.go # 未更改,与上一节代码相同
│   └── trie.go # 未更改,与上一节代码相同
├── static # 存放静态文件
│   └── css
│   └── index.css
├── templates # 存放模板文件
│   ├── array.tmpl
│   ├── css.tmpl
│   └── custom_func.tmpl
├── go.mod
└── main.go

7.错误恢复

为什么需要错误恢复

当处理请求时,出现 panic 等错误会导致整个程序宕掉。为了保证整个程序仍然为其他请求提供服务,就需要进行错误恢复。

如何做错误恢复

1
2
3
4
5
6
func main() {
defer fmt.Println("exit") // 可以输出
fmt.Println("begin") // 可以输出
panic("err")
fmt.Println("end") // 无法输出
}

上述代码需要明确几点:

  • 出现 panic 错误的位置之后的代码,是无法执行的,程序会直接结束。
  • 在结束之前依旧会执行 defer 程序。

defer 程序就我们错误恢复提供了时机。我们需要在 defer 中,捕获 panic 错误,解析 error 信息,写入 log,并给用户返回 Internal Server Error。

为了保证代码的通用性,我们使用中间件的形式加入 recovery。

1
2
3
4
5
6
7
8
9
10
11
12
13
// gee/recovery.go

func Recovery() HandlerFunc {
return func(c *Context) {
defer func() {
if err := recover(); err != nil { // 捕获 panic error
log.Printf("%s\n\n", trace(err)) // 解析 error
c.Fail(http.StatusInternalServerError, "Internal Server Error") // 给用户返回 500 错误
}
}()
c.Next() // 为了保证 defer 的正确执行顺序,务必需要写 c.Next
}
}

中间件中使用 defer 的注意点

中间件不手动调用 Next,函数执行顺序是 中间件开始->中间件结束->路由处理函数开始->路由处理函数结束。

中间件手动调用 Next,函数执行顺序是 中间件开始->路由处理函数开始->路由处理函数结束->中间件结束。

只有手动调用 Next,才能让中间件的 defer 最后执行。举个例子,观察一下 log 输出。

完整代码

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
# 代码结构
version_7_recovery
├── gee
│   ├── context.go
│   ├── gee.go
│   ├── recovery.go # 仅新增该文件
│   ├── router.go
│   └── trie.go
├── static
│   └── css
│   └── index.css
├── templates
│   ├── array.tmpl
│   ├── css.tmpl
│   └── custom_func.tmpl
├── go.mod
└── main.go

从零实现系列|web 框架
https://www.aimtao.net/7days-web/
Posted on
2023-02-25
Licensed under