“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
9package 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
5gee/
|--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 的请求路径恰好是用
/
分隔,可以每段作为前缀树的一个节点。
- HTTP 的请求路径恰好是用
Trie 树实现
- 为了实现动态路由匹配,引入
isWild
参数,是为了让/go/:name1/:name2
这种路径中的/:name1
被模糊匹配后视为/go
,继续下一层匹配。 - 路由服务 = 注册 + 匹配
- 对应 Trie 树节点的插入和查询
- 插入:递归查找节点,如果没有当前
part
的节点,则新建一个。 - 查询:递归查找节点,匹配到
*
或者匹配到第len(parts)
层节点。
- 插入:递归查找节点,如果没有当前
- 对应 Trie 树节点的插入和查询
Router
roots
存储每种匹配方式的 Tier 树根节点,handlers
存储每种请求方式的HandlerFunc
。getRoute
函数用来解析:name
和*filepath
两种通配符的参数。
Context 与 handle 的变化
Context
对象新增对路由参数访问的支持,存储在Params
中。
单元测试
-
单元测试文件以
_test.go
结尾。 -
测试用例名称一般命名为 Test + 待测试的方法名。
-
测试用的参数有且只有一个,在这里是
*testing.T
。- benchmark 的参数是
*testing.B
类型,TestMain 的参数是*testing.M
类型。
- benchmark 的参数是
-
命令行:
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 |
|
Engine
作为最顶层的分组,拥有RouteGroup
的所有能力
1 |
|
Day5 中间件 Middleware
中间件是什么
- 中间件是框架提供的插口,用于用户自己定义功能
- 需要考虑的点:
- 插入点如果太底层,中间件的逻辑会非常复杂;如果离用户太近,和用户直接定义一组函数直接在
Handler
中调用区别不大。 - 中间件的输入决定了其扩展能力。暴露的参数太少,扩展能力有限。
- 插入点如果太底层,中间件的逻辑会非常复杂;如果离用户太近,和用户直接定义一组函数直接在
中间件设计
- 中间件的定义和路由映射的
Handler
一致,输入均为Context
对象。插入点是框架接收到请求初始化Context
后,允许用户使用自定义的中间件做一些额外处理。 (*Context).Next()
函数用于用户自己定义的Handler
处理结束后,再进行额外操作。- 综合上述,设计的中间件支持用户在请求被处理的前后,做出额外的操作。
- 框架设计:当接收到请求后,匹配路由,查找应作用于该路由的中间件,该请求的所有信息都保存在
Context
中(因为处理结束后还可以调用)。
1 |
|
-
index
记录当前执行到第几个中间件,当中间件调用Next
方法时,控制权交给下一个中间件,直到调用到最后一个中间件,再从后往前,调用各个中间件在Next
方法之后定义的部分。- 例如:定义中间件 A、B 和路由映射 Handler,
c.handlers
=[A,B,Handler ],c.index
= -1,调用c.Next()
。对应的流程为:part1 -> part3 -> Handler -> part 4 -> part2
1
2
3
4
5
6
7
8
9
10func A(c *Context) {
part1
c.Next()
part2
}
func B(c *Context) {
part3
c.Next()
part4
} - 例如:定义中间件 A、B 和路由映射 Handler,
代码实现
- 定义
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/template
和html/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
则是能够将普通的函数类型/结构体进行转换。