一、标准库启动 Web 服务
- Go 语言内置了
net/http
库,封装了 HTTP 网络编程的基础的接口
1 | package main |
http.ListenAndServe(":9999", nil)
第一个参数是地址,表示在 9999 端口监听;第二个参数代表处理所有 HTTP 请求的实例,nil
代表使用标准库中的实例处理- 第二个参数就是基于
net/http
标准库实现 Web 框架的入口,类型是Handler
,是一个接口,有一个方法ServeHTTP
,源码如下:
1 | package http |
- 因此只要传入任何实现了
ServerHTTP
接口的实例,所有 HTTP 请求就会交给该实例处理了
1 | package main |
- 这里定义了一个空的结构体
Engine
,实现了方法ServeHTTP
。这个方法有两个参数,第二个参数是Request
,包含了该 HTTP 请求的所有的信息,比如请求地址、Header 和 Body 等信息;第一个参数是ResponseWriter
,可以用其构造针对该请求的响应 - 此时,所有的 HTTP 请求转向了自定义的处理逻辑,拥有了统一的控制入口。在这里可以自由定义路由映射的规则,也可以统一添加一些处理逻辑,例如日志、异常处理等
二、搭建框架基础雏形
- 最终的代码目录结构是这样的:
1 | gee/ |
1. gee.go
1 | package gee |
- 类型
HandlerFunc
是提供给框架用户的,用来定义路由映射的处理方法 - 在
Engine
中添加了一张路由映射表router
:key 由请求方法和静态路由地址构成,例如GET-/
、GET-/hello
、POST-/hello
;value 是用户映射的处理方法 (*Engine).GET()
会将路由和处理方法注册到映射表router
中(*Engine).Run()
方法是ListenAndServe
的包装(*Engine).ServeHTTP()
用来解析请求的路径,查找路由映射表,如果查到,就执行注册的处理方法。如果查不到,就返回 404
2. main.go
1 | package main |
- 使用
New()
创建 gee 的实例,使用GET()
添加路由,最后使用Run()
启动 Web 服务 - 这里的路由,只是静态路由,暂时不支持
/hello/:name
这样的动态路由
三、设计 Context
- 封装请求和响应:对 Web 服务来说,无非是根据请求
*http.Request
,构造响应http.ResponseWriter
。但是这两个对象提供的接口粒度太细,比如要构造一个完整的响应,需要考虑消息头(Header)和消息体(Body),而 Header 包含了状态码(StatusCode),消息类型(ContentType)等几乎每次请求都需要设置的信息 - 支撑额外的功能:比如解析动态路由
/hello/:name
,或者支持中间件 - 因此,如果不进行有效的封装,那么框架的用户将需要写大量重复,繁杂的代码,而且容易出错。针对常用场景,能够高效地构造出 HTTP 响应是一个好的框架必须考虑的点
- Context 随着每一个请求的出现而产生,请求的结束而销毁,和当前请求强相关的信息都应由 Context 承载。因此,设计 Context 结构,扩展性和复杂性留在了内部,而对外简化了接口。路由的处理函数,以及将要实现的中间件,参数都统一使用 Context 实例
案例:返回 JSON 数据,比较封装前后的差距
封装前:
1
2
3
4
5
6
7
8
9
10 obj = map[string]interface{}{
"name": "geektutu",
"password": "1234",
}
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusOK)
encoder := json.NewEncoder(w)
if err := encoder.Encode(obj); err != nil {
http.Error(w, err.Error(), 500)
}封装后:
1
2
3
4 c.JSON(http.StatusOK, gee.H{
"username": c.PostForm("username"),
"password": c.PostForm("password"),
})
1. context.go
1 | package gee |
- 首先给
map[string]interface{}
起了一个别名gee.H
,构建 JSON 数据时,显得更简洁 Context
目前只包含了http.ResponseWriter
和*http.Request
,另外提供了对 Method 和 Path 这两个常用属性的直接访问- 提供了访问 Query 和 PostForm 参数的方法
- 提供了快速构造 String/Data/JSON/HTML 响应的方法
2. router.go
1 | package gee |
- 将路由相关的方法和结构提取了出来,放到了一个新的文件中
router.go
,方便后续对 router 的功能进行增强,例如提供动态路由的支持 - router 的 handle 方法作了一个重要的调整,即 handler 的参数变成了 Context
3. gee.go
1 | package gee |
- 将 router 相关的代码独立后,gee.go 简单了不少。最重要的还是通过实现了 ServeHTTP 接口,接管了所有的 HTTP 请求
4. main.go
1 | package main |
四、前缀树路由
- 当前是使用
map
结构存储路由表,索引非常高效,但是只能用来索引静态路由,无法支持类似于/hello/:name
这样的动态路由 - 动态路由有很多种实现方式,支持的规则、性能等有很大的差异。例如开源的路由实现
gorouter
支持在路由规则中嵌入正则表达式,例如/p/[0-9A-Za-z]+
,即路径中的参数仅匹配数字和字母;另一个开源实现httprouter
就不支持正则表达式。gin
在早期的版本并没有实现自己的路由,而是直接使用了httprouter
,后来自己实现了一个版本
1. trie.go
- 对于路由来说,最重要的当然是注册与匹配了。开发服务时,注册路由规则,映射 handler;访问时,匹配路由规则,查找到对应的 handler
- 实现动态路由最常用的数据结构,被称为前缀树(Trie 树)。每一个节点的所有的子节点都拥有相同的前缀。这种结构非常适用于路由匹配
- HTTP 请求的路径恰好是由
/
分隔的多段构成的,因此,每一段可以作为前缀树的一个节点。通过树结构查询,如果中间某一层的节点都不满足条件,那么就说明没有匹配到的路由,查询结束- 参数匹配
:
:例如/p/:lang/doc
,可以匹配/p/c/doc
和/p/go/doc
- 通配
*
:例如/static/*filepath
,可以匹配/static/fav.ico
和/static/js/jQuery.js
,这种模式常用于静态服务器,能够递归地匹配子路径
- 参数匹配
1 | package gee |
2. router.go
- 上面实现了 Trie 树的插入与查找,接下来需要将其应用到路由中去
1 | package gee |
- 使用 roots 来存储每种请求方式的 Trie 树根节点。使用 handlers 存储每种请求方式的 HandlerFunc
- getRoute 函数中,还解析了
:
和*
两种匹配符的参数,返回一个 map。例如/p/go/doc
匹配到/p/:lang/doc
,解析结果为:{lang: "go"}
;/static/css/geektutu.css
匹配到/static/*filepath
,解析结果为{filepath: "css/geektutu.css"}
router.go
的变化比较小,比较重要的一点是,在调用匹配到的handler
前,将解析出来的路由参数赋值给了c.Params
。这样就能够在handler
中,通过Context
对象访问到具体的值了
3. context.go(+)
1 | type Context struct { |
- 在 HandlerFunc 中,希望能够访问到解析的参数。因此,需要对 Context 对象增加一个属性和方法,来提供对路由参数的访问。将解析后的参数存储到
Params
中,通过c.Param("lang")
的方式获取到对应的值
4. router_test.go
1 | package gee |
5. main.go
1 | package main |
五、分组控制
- 分组控制是 Web 框架应提供的基础功能之一。分组指的是路由的分组,往往某一组路由需要相似的处理,如鉴权、日志、对接第三方平台等
- 大部分情况下的路由分组,是以相同的前缀来区分的,并且支持分组的嵌套。例如
/post
是一个分组,/post/a
和/post/b
可以是该分组下的子分组。作用在/post
分组上的中间件,也都会作用在子分组,子分组还可以应用自己特有的中间件
1. gee.go
1 | package gee |
Engine
从某种意义上继承了RouterGroup
的所有属性和方法,且(*Engine).engine
是指向自己的,因此可以将和路由有关的函数,都交给RouterGroup
实现了
2. main.go
1 | package main |
六、中间件
- 中间件简单来说就是非业务的技术类组件。Web 框架本身不可能去理解所有的业务,因而不可能实现所有的功能。因此,框架需要有一个插口,允许用户自己定义功能,嵌入到框架中,仿佛这个功能是框架原生支持的一样。因此,对中间件而言,需要考虑两个比较关键的点:
- 插入点在哪:使用框架的人并不关心底层逻辑的具体实现,如果插入点太底层,中间件逻辑就会非常复杂。如果插入点离用户太近,那和用户直接定义一组函数,每次在 Handler 中手工调用没有多大的优势了
- 中间件的输入是什么:中间件的输入,决定了扩展能力。暴露的参数太少,用户发挥空间有限
- Gee 中间件的定义与路由映射的 Handler 一致,处理的输入是
Context
对象。插入点是框架接收到请求初始化Context
对象后,允许用户使用自己定义的中间件做一些额外的处理,例如记录日志等,以及对Context
进行二次加工。另外通过调用(*Context).Next()
函数,等待执行其他的中间件或用户的Handler
。中间件可等待用户自己定义的Handler
处理结束后,再做一些额外的操作,例如计算本次处理所用时间等。即 Gee 的中间件支持用户在请求被处理的前后,做一些额外的操作,另外支持设置多个中间件,依次进行调用
1. context.go(+)
- 当前框架接收到请求后,匹配路由,该请求的所有信息都保存在
Context
中。中间件也不例外,接收到请求后,应查找所有作用于该路由的中间件,保存在Context
中,依次进行调用
1 | type Context struct { |
- 当在中间件中调用
Next
方法时,控制权交给了下一个中间件,直到调用到最后一个中间件,然后再从后往前,调用每个中间件在Next
方法之后定义的部分 - 假设应用了如下中间件 A 和 B,和路由映射的 Handler。
c.handlers
是这样的[A, B, Handler]
,调用c.Next()
,最终的顺序是part1 -> part3 -> Handler -> part 4 -> part2
1 | func A(c *Context) { |
2. gee.go(+)
1 | // Use is defined to add middleware to the group |
3. router.go(+)
1 | // 将从路由匹配得到的 Handler 添加到 c.handlers 列表中,执行 c.Next() |
4. logger.go
1 | package gee |
5. main.go
1 | package main |
七、模板
- 现在流行前后端分离的开发模式,即 Web 后端提供 RESTful 接口,返回结构化的数据(通常为 JSON 或者 XML)。前端使用 AJAX 技术请求到所需的数据,利用 JavaScript 进行渲染。这种开发模式前后端解耦,优势非常突出
- 前后分离的一大问题在于,页面是在客户端渲染的,比如浏览器,这对于爬虫并不友好。Google 爬虫已经能够爬取渲染后的网页,但是爬取服务端直接渲染的 HTML 页面仍是主流
1. 静态文件访问
- 要做到服务端渲染,第一步便是要支持 JS、CSS 等静态文件。之前设计动态路由时,支持通配符
*
匹配多级子路径,如果将静态文件放在某个目录下,就可以根据路由匹配的值映射到真实的文件,返回给客户端
1 | // serve static files |
- 用户可以将磁盘上的某个文件夹
root
映射到路由relativePath
,例如:
1 | r := gee.New() |
- 用户访问
localhost:9999/assets/css/main.css
,最终返回/opt/src/static/css/main.css
2. HTML 模板渲染
- Go 语言内置了
text/template
和html/template
两个模板标准库,其中 html/template 为 HTML 提供了较为完整的支持。包括普通变量渲染、列表渲染、对象渲染等
1 | Engine struct { |
- 对原来的
(*Context).HTML()
方法做了些修改,使之支持根据模板文件名选择模板进行渲染
1 | type Context struct { |
- 测试
1 | package main |
八、错误恢复
1. panic
- Go 语言中,比较常见的错误处理方法是返回 error,由调用者决定后续如何处理。但是如果是无法恢复的错误,可以手动触发 panic,如果在程序运行过程中出现了类似于数组越界的错误,panic 也会被触发。panic 会中止当前执行的程序
1 | func main() { |
- 数组越界触发的 panic
1 | func main() { |
2. defer
- panic 会导致程序被中止,但是在退出前,会先处理完当前协程上已经 defer 的任务,执行完成后再退出
- 可以 defer 多个任务,在同一个函数中 defer 多个任务,会逆序执行。即先执行最后 defer 的任务
1 | func main() { |
3. recover
- Go 语言提供了 recover 函数,可以避免因为 panic 发生而导致整个程序终止,recover 函数只在 defer 中生效
1 | func test_recover() { |
4. 框架的错误处理机制
- 对一个 Web 框架而言,错误处理机制是非常必要的。可能是框架本身没有完备的测试,导致在某些情况下出现空指针异常等情况。也有可能用户不正确的参数,触发了某些异常,例如数组越界,空指针等。如果因为这些原因导致系统宕机,必然是不可接受的
- 错误处理可以作为一个中间件,增强框架的能力
1 | package gee |
- 使用 defer 挂载上错误恢复的函数,在这个函数中调用 recover(),捕获 panic,并且将堆栈信息打印在日志中,向用户返回 Internal Server Error
trace()
函数用来获取触发 panic 的堆栈信息。调用了runtime.Callers(3, pcs[:])
,Callers 用来返回调用栈的程序计数器。第 0 个 Caller 是 Callers 本身,第 1 个是上一层 trace,第 2 个是再上一层的defer func
。因此跳过了前 3 个 Caller- 然后通过
runtime.FuncForPC(pc)
获取对应的函数,在通过fn.FileLine(pc)
获取到调用该函数的文件名和行号,打印在日志中 - 测试
1 | package main |