“7天用Go从零实现Web框架Gee”学习记录

本文最后更新于 2022年11月21日 晚上

开源项目“七天用 Go 从零实现”系列之 Gee-web 学习记录

“7天用Go从零实现Web框架Gee”学习记录

Day0 序言

  • 目的:标准库 net/http 仅提供了了端口监听(ListenAndServe)、映射静态路由(HandleFunc)、解析 HTTP 报文的基本功能。Web 框架能够更方便的实现 Web 开发中的一些需求。
  • Gee-web 的参考对象:Gin

Day1 HTTP 基础

标准库启动 Web 服务

  • 利用 handleFunc 设置路由

  • 启动 Web 服务,前者代表端口,后者 nil 代表使用标准库中的实例处理(该实例处理所有的HTTP请求):

    1
    http.ListenAndServe(":9999", nil)
  • 测试工具:curl

实现 http.Handler 接口

  • 接口的目的:引入一个新的中间层,避免上下游的耦合,从而实现多态

  • net/http 中的 ListenAndServe

    1
    2
    3
    4
    5
    6
    7
    8
    9
    package http

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

    // Handler 是一个接口,需要实现方法 ServeHTTP
    // 只要传入实现了 ServeHTTP 接口的实例,所有的 HTTP 请求都交给该实例处理
    func ListenAndServe(address string, h Handler) error
  • 构造一个实现了 ServeHTTP 接口的实例,让所有的 HTTP 请求由实例处理。这样相当于拦截了所有 HTTP 请求,能够进行统一控制,进而自定义处理逻辑(不仅仅局限于之前具体的路由)。

Gee 框架的雏形

  • 代码目录结构

    1
    2
    3
    4
    5
    gee/
    |--gee.go
    |--go.mod
    main.go
    go.mod
  • 在 go.mod 中使用 replace gee => ./gee

    • 从 go 1.11 版本开始,引用相对路径的 package 需要使用上述方式。
  • 实现了路由映射表,提供了注册静态路由的方法,包装了启动服务的函数

    • 定义 HandlerFunc 用来定义路由映射处理方法,供用户注册静态路由。路由映射表 router 为字典,key 遵循“请求方式-静态路由地址”格式,value 对应具体的 HandlerFunc 实例。
    • Engine 实现的 Run 方法:对 http.ListenAndServe 包装。
    • Engine 实现的 ServeHTTP 方法:解析请求的路径,查找路由映射表,如果查到,就执行注册的 HandlerFunc。如果查不到,就返回 404 NOT FOUND

Day2 上下文 Context

使用效果

  • Handler 参数变成 gee.Context,提供查询 Query/PostForm 参数的功能。
  • gee.Context 封装了 HTML/String/JSON 函数,能够快速构造 HTTP 响应。

设计 Context

必要性

  • 如果直接使用 Hhttp.ResponseWriter, *http.Request,粒度太细,用户需要些大量重复的代码。
  • 此外,Context 随着每一个请求的出现而产生,请求的结束而销毁,和当前请求强相关的信息都应由 Context 承载。设计 Context 结构能将将扩展性和复杂性留在内部,对外简化接口,这样才能支撑额外的功能(解析动态路由 /hello/:hello 中参数:hello等等)。

具体实现

  • 使用 map[string]interface{} 保存 JSON 数据,因为空接口类型可以保存任何值,也可以从空接口中取出原值,一种非常灵活的数据抽象保存和使用的方法。
  • json.NewEncoder中进行编码,json.Marshal[]byte 进行编码。

路由(Router)

  • 将路由相关的方法的结构提取出来,方便下一次对 router 功能进行增强。
  • handler 的参数变成了 Context

框架入口

  • 通过独立 Router 相关的代码,使 gee.go 精简。

Day3 前缀树路由 Router

  • 通过 Trie 树实现动态路由解析。
    • 动态路由支持 :name*filepath 两种模式 。

Trie 树简介

  • 之前使用的 map 结构存储路由表仅能用来索引静态路由,而动态路由需要路由规则匹配某一类型路由而非某一固定路由,因此需要调整存储结构。
  • Trie 树:每个节点的所有子节点都具有相同的前缀。
    • HTTP 的请求路径恰好是用 / 分隔,可以每段作为前缀树的一个节点。

Trie 树实现

  • 为了实现动态路由匹配,引入 isWild 参数,是为了让 /go/:name1/:name2 这种路径中的 /:name1 被模糊匹配后视为 /go ,继续下一层匹配。
  • 路由服务 = 注册 + 匹配
    • 对应 Trie 树节点的插入和查询
      • 插入:递归查找节点,如果没有当前 part 的节点,则新建一个。
      • 查询:递归查找节点,匹配到 * 或者匹配到第 len(parts) 层节点。

Router

  • roots 存储每种匹配方式的 Tier 树根节点,handlers 存储每种请求方式的 HandlerFunc
  • getRoute 函数用来解析 :name*filepath 两种通配符的参数。

Context 与 handle 的变化

  • Context 对象新增对路由参数访问的支持,存储在 Params 中。

单元测试

  • 单元测试文件以 _test.go 结尾。

  • 测试用例名称一般命名为 Test + 待测试的方法名

  • 测试用的参数有且只有一个,在这里是 *testing.T

    • benchmark 的参数是 *testing.B 类型,TestMain 的参数是 *testing.M 类型。
  • 命令行:

    1
    2
    3
    4
    5
    6
    # 测试该 package 下所有的测试用例
    go test
    # -v 参数查看每个用例测试结果,-cover 参数查看覆盖率
    go test -v -cover
    # -run 指定特定用例,支持通配符和部分正则表达式
    go test -run TestName
  • reflect.DeepEqual 可以用来比较结构体

Day4 分组控制 Group

分组的意义

  • 分组控制能够让某组路由以及其子分组具有相似的处理,例如对 /post 开头的路由匿名可访问。对于子分组 /post/a,它在路由匿名可访问的基础上,还可以应用自己特有的中间件。

分组嵌套

  • 一个 Group 对象应该具备的属性:
    • 前缀 /api
    • 分组的父亲 parent(为了支持分组嵌套)
    • 中间件 middlewares
    • 指向 Engine 的指针,因为 Engine 是框架统一入口,这样可以间接访问其他各种接口
  • Group 的定义:
1
2
3
4
5
6
RouterGroup struct {
prefix string
middlewares []HandlerFunc // support middleware
parent *RouterGroup // support nesting
engine *Engine // all groups share a Engine instance
}
  • Engine 作为最顶层的分组,拥有 RouteGroup所有能力
1
2
3
4
5
Engine struct {
*RouterGroup
router *router
groups []*RouterGroup // store all groups
}

Day5 中间件 Middleware

中间件是什么

  • 中间件是框架提供的插口,用于用户自己定义功能
  • 需要考虑的点:
    • 插入点如果太底层,中间件的逻辑会非常复杂;如果离用户太近,和用户直接定义一组函数直接在 Handler 中调用区别不大。
    • 中间件的输入决定了其扩展能力。暴露的参数太少,扩展能力有限。

中间件设计

  • 中间件的定义和路由映射的 Handler 一致,输入均为 Context 对象。插入点是框架接收到请求初始化 Context,允许用户使用自定义的中间件做一些额外处理。
  • (*Context).Next() 函数用于用户自己定义的 Handler 处理结束后,再进行额外操作。
  • 综合上述,设计的中间件支持用户在请求被处理的前后,做出额外的操作。
  • 框架设计:当接收到请求后,匹配路由,查找应作用于该路由的中间件,该请求的所有信息都保存在 Context 中(因为处理结束后还可以调用)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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
handlers []HandlerFunc
index int
}

// 对设置多个中间件依次进行调用
func (c *Context) Next() {
c.index++
s := len(c.handlers)
for ; c.index < s; c.index++ {
c.handlers[c.index](c)
}
}
  • index 记录当前执行到第几个中间件,当中间件调用 Next 方法时,控制权交给下一个中间件,直到调用到最后一个中间件,再从后往前,调用各个中间件Next 方法之后定义的部分

    • 例如:定义中间件 A、B 和路由映射 Handlerc.handlers=[A,B,Handler ],c.index = -1,调用 c.Next()。对应的流程为:part1 -> part3 -> Handler -> part 4 -> part2
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    func A(c *Context) {
    part1
    c.Next()
    part2
    }
    func B(c *Context) {
    part3
    c.Next()
    part4
    }

代码实现

  • 定义 use 函数,将中间件应用到某个 Group。
  • 修改 ServeHTTP:当收到一个具体请求时,通过 URL 前缀判断请求适用于哪些中间件,得到中间件列表后赋值给 c.handlers

Day6 模板 Template

服务端渲染

  • 日益流行的前后端分离的开发模式:Web 后端提供 RESTful 接口,返回结构化的数据(一般为 JSON 或者 XML)。前端使用 AJAX 技术请求到所需的数据,利用 JavaScript 进行渲染。
    • 这样能够前后端解耦,方便开发。
    • 但是由于页面是在客户端渲染的,对爬虫并不友好。
  • Day6 的内容即是如何让 Web 框架支持服务端渲染的场景。

静态文件 Serve Static Files

  • 要做到服务器渲染,第一步是要支持 JS、CSS 等静态文件。
  • 之前设计动态路由的时候,已支持 *filepath 模式,它能够获得文件的相对位置。如果我们将静态文件放到指定目录,那么映射到真实文件后,将文件返回则实现了静态服务器。
  • net/http 库已经实现了文件返回的功能。只需要把文件真实位置交给 http.FileServer 即可。

HTML 模板渲染

  • Go 语言内置 text/templatehtml/template 两个模板标准库,Gee -Web 直接使用 html/template 即可。

Day7 错误恢复 Panic Recover

  • 实现错误处理机制

panic

  • Go 中错误处理
    • 返回 Error,由调用者决定后续如何处理
    • 触发 panic,中止当前执行的程序,退出
      • 数组越界等错误,也是触发 panic

defer

  • panic 会导致程序中止,但是会先处理完当前协程上已经 defer 的任务,执行完成后再退出。
    • 类似于 try ... catch
  • 可以 defer 多个任务,在同一个函数中 defer 多个任务会逆序执行

recover

  • recover 函数可以避免因为 panic 发生导致整个程序终止
  • recover 函数只在 defer 中生效

Gee 的错误处理机制

  • 为 Gee-web 添加一个简单的错误处理机制:当错误发生时,向用户返回 Internal Server Error,并在日志中打印

  • 错误处理作为一个中间件 Recovery,使用 defer 挂载上错误恢复的函数。该函数调用 recover(),捕获 panic 并将堆栈信息打印到日志,向用户返回 Internal Server Error

    • 使用 trace() 函数:这个函数能够获取触发 panic 的堆栈信息
    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
    // 获取触发 panic 的堆栈信息
    func trace(message string) string {
    var pcs [32]uintptr

    // Callers 用来返回堆栈的程序计数器
    // 第 0 个 Callers 是 Callers 本身
    // 第 1 个是上一层 trace()
    // 第 2 个是 defer func
    // 为了日志简洁,跳过前三个 Callers
    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) {
    // 第 2 个 Caller
    defer func() {
    if err := recover(); err != nil {
    message := fmt.Sprintf("%s", err)
    // 第 1 个 Caller
    log.Printf("%s\n\n", trace(message))
    c.Fail(http.StatusInternalServerError, "Internal Server Error")
    }
    }()

    c.Next()
    }
    }

总结

  • 第一次看这种开源项目的源码,学习它的思路和写法,感觉还是收获颇多的。对我而言,Gee-web 让我理解了Web 框架的工作原理,学习了如何通过引入上下文和中间件实现框架的接口和扩展。一些语言使用方式,例如 Go test、Go 的错误恢复等,也了解到怎么将项目代码专业化。
  • 对整个项目印象最为深刻的还是接口型函数的广泛使用,它的使用更加灵活,可读性也更好,方便传入函数作为参数Handler 就是一个典型的接口型函数,而 HandleFunc 则是能够将普通的函数类型/结构体进行转换。

“7天用Go从零实现Web框架Gee”学习记录
https://justloseit.top/“7天用Go从零实现Web框架Gee”学习记录/
作者
Mobilis In Mobili
发布于
2022年7月3日
更新于
2022年11月21日
许可协议