写在前面:本项目参考 7天用Go从零实现Web框架Gee教程,下面的内容全部参考自该网站。主要是记录我在学习过程中的所思所想,如有存在问题还请多多指教。
接下来我们继续优化 Gee,接下来要做的事情是设计 Context。
设计 Context 的必要性
- 屏蔽底层 HTTP 细节,统一高频且易错的操作
在 Day1 中,我们的 Handler 是:
go
func(w http.ResponseWriter, req *http.Request)
也就意味着,如果我们获取请求报文中的一些字段,比如:
go
method := req.Method // 获取请求方式
path := req.URL.Path // 获取请求资源路径
name := req.URL.Query().Get("name") // 获取 Query 参数
age := req.URL.Query().Get("age") // 获取 Query 参数
// 获取请求头
ua := req.Header.Get("User-Agent")
token := req.Header.Get("Authorization")
// 获取 POST 表单参数
err := req.ParseForm()
if err != nil {
// error handling
}
username := req.FormValue("username")
password := req.FormValue("password")
确实都可以通过 req 拿到,但是问题在于 *http.Request 提供的颗粒度太细了。对Web服务来说,无非是根据请求*http.Request,构造响应http.ResponseWriter。对于开发者来说,如果我想要构造一个完整的响应,要考虑很多东西,例如消息头(Header)和消息体(Body),而 Header 又包含了状态码(StatusCode)和消息类型(ContentType)等几乎每次都需要设置的信息。所以如果不进行有效的封装,那么使用 Day1 的 Gee 框架进行开发的开发者将需要写大量重复且繁杂的代码,而且容易出错。因此,针对常用场景,能够高效构造出 HTTP 响应是一个好的框架必须考虑的点。
举个生活中的例子,想象一下你家要接水用,但是没有水龙头,那么每次想用水,你都需要:
- 自己拧开总阀
- 判断水压
- 调节冷热水比例
- 接一根临时管子
- 用完再关阀、放压
这就相当于:
scss
req.URL.Query()
req.ParseForm()
w.Header().Set()
w.WriteHeader()
w.Write()
每次都要重复一套机械的动作,而且一步出错就出问题。
而在安装了水龙头之后,事情就方便的多了,你只需要:
- 拧一下
水压、水温、出水路径全部被预先封装好。
这就相当于:
javascript
c.JSON(200, obj)
c.Query("name")
在 Day1 中,handler 直接操作 http.Request 和 ResponseWriter,就像每次用水都要自己接水管,步骤繁琐且容易出错;Context 的引入,相当于提供了标准水龙头,将复杂、易错的底层细节封装起来,只暴露简单、稳定的使用方式。
- 作为请求生命周期的统一载体,承载框架扩展能力
针对使用场景,封装*http.Request和http.ResponseWriter的方法,简化相关接口的调用,只是设计 Context 的原因之一。对于框架来说,还需要支撑额外的功能,比如说动态路由。
动态路由是一个框架必须支持的功能。假设有这样一种情况,我们做一个用户系统:
sql
GET /user/1
GET /user/2
GET /user/3
对于每一个新创建的用户,都有一个独立的 URL,而在 Day1 的 Gee 中,对于路由的处理是这样的:
go
// gee/gee.go
key := req.Method + "-" + req.URL.Path
handler := engine.router[key]
拼接出 URL 之后,会去查 Engine 中的路由表,看 URL 是否注册。这样的设计显然存在问题:
- 不可扩展:每增加一个用户,就要增加一个路由,这显然不现实。
- 参数提取困难 :假设想在 handler 中获取用户 ID,你必须自己解析
req.URL.Path:
go
parts := strings.Split(req.URL.Path, "/")
userID := parts[2]
这个时候就需要动态路由。我们可以把路由设计为 /user/:id,其中 :id 可以匹配任意用户 ID,并且请求 /user/123、/user/456 都能匹配到同一个 handler。
Day1 的静态路由只能匹配固定路径,无法支持路径中带变量的 URL。
再比如,框架需要支持中间件,那中间件产生的信息放在哪呢?Context 随着每一个请求的出现而产生,请求的结束而销毁,和当前请求强相关的信息都应由 Context 承载。因此,设计 Context 结构,扩展性和复杂性留在了内部,而对外简化了接口。路由的处理函数,以及将要实现的中间件,参数都统一使用 Context 实例, Context 就像一次会话的百宝箱,可以找到任何东西。
如果你熟悉 Python,那么我们 Day2 要做的事情就有点·11类似于把 httpx 这个包升级到 request 这个包。Day1 就像直接用低级库写请求,Day2 的 Context 就像给它加了一层高级封装,让你专注业务而不用管协议细节。
具体实现
go
// gee/context.go
package gee
import (
"encoding/json"
"fmt"
"net/http"
)
type H map[string]interface{}
type Context struct {
Writer http.ResponseWriter
Req *http.Request
Path string // 请求资源路径
Method string // 请求方式
StatusCode int // 状态码
}
func newContext(w http.ResponseWriter, req *http.Request) *Context {
return &Context{
Writer: w,
Req: req,
Path: req.URL.Path,
Method: req.Method,
}
}
// PostForm 从 POST 表单中获取指定字段的值
func (c *Context) PostForm(key string) string {
return c.Req.URL.Query().Get(key)
}
// Query 从 URL 查询参数中获取指定字段的值。
func (c *Context) Query(key string) string {
return c.Req.URL.Query().Get(key)
}
// Status 设置响应状态码并写入 HTTP Header
func (c *Context) Status(code int) {
c.StatusCode = code
c.Writer.WriteHeader(code)
}
// SetHeader 设置响应头指定字段和值
func (c *Context) SetHeader(key string, value string) {
c.Writer.Header().Set(key, value)
}
// String 以纯文本格式构造 HTTP 响应并写入客户端
func (c *Context) String(code int, format string, values ...interface{}) {
c.SetHeader("Content-Type", "text/plain")
c.Status(code)
c.Writer.Write([]byte(fmt.Sprintf(format, values...)))
}
// JSON 将对象序列化为 JSON 格式big返回给客户端,同时设置 Content-Type
func (c *Context) JSON(code int, obj interface{}) {
c.SetHeader("Content-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, data []byte) {
c.Status(code)
c.Writer.Write(data)
}
// HTML 将 HTML 字符串作为响应返回,同时设置 Content-Type 为 text/html
func (c *Context) HTML(code int, html string) {
c.SetHeader("Content-Type", "text/html")
c.Status(code)
c.Writer.Write([]byte(html))
}
这部分是实现 Context 的核心逻辑。在代码的开头给 map[string]interface{} 起了一个别名叫 H,这是为了后续在构建 JSON 数据的时候显得更加简洁。
然后我们可以看到 Context 的结构,包含了 http.ResponseWriter和*http.Request,另外提供了对 Method 和 Path 这两个常用属性的直接访问。
剩下的部分,包括提供了访问 Query 和 PostForm 参数的方法,以及提供了快速构造 String/Data/JSON/HTML 响应的方法。
这里提一下 JSON 部分的实现细节:
go
func (c *Context) JSON(code int, obj interface{}) {
c.SetHeader("Content-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)
}
}
这段代码的关键点并不在 if err := ...,而在这一行:
css
encoder := json.NewEncoder(c.Writer)
我们点进去看 json.NewEncoder 的源码:
go
// NewEncoder returns a new encoder that writes to w.
func NewEncoder(w io.Writer) *Encoder {
return &Encoder{w: w, escapeHTML: true}
}
可以看到,NewEncoder 接受一个 io.Writer,并将其保存到 Encoder 结构体内部的 w 字段中。
也就是说,此时已经建立了这样一个关系:
ini
enc.w === c.Writer
而 c.Writer 本质上就是 http.ResponseWriter。
接下来重点看 JSON 实现部分的这一行:
scss
encoder.Encode(obj)
这句话比较容易误解为:只有出错时才会写响应。
实际上恰恰相反。
Encode 的主要逻辑是:先 序列化 ,再写响应,错误只是返回值而已。其简化后的源码逻辑如下:
go
func (enc *Encoder) Encode(v any) error {
data, err := json.Marshal(v)if err != nil {return err
}
data = append(data, '\n')
_, err = enc.w.Write(data)return err
}
这里的执行流程非常关键:
-
json.Marshal(v)- 把 Go 对象转换成 JSON 字节数组
-
enc.w.Write(data)- 直接把 JSON 数据写入到
io.Writer
- 直接把 JSON 数据写入到
-
返回
error(如果有)
注意这一点:
真正把响应体写出去的,是
enc.w.Write(data),而不是if err != nil这一行
而此时:
ini
enc.w == c.Writer == http.ResponseWriter
因此:
scss
encoder.Encode(obj)
这一行本身就已经完成了 HTTP 响应体的写入。
为什么 http.Error 在这里"看起来没什么用"?
因为在调用 Encode 之前,已经执行过:
css
c.Status(code) // => Writer.WriteHeader(code)
这一步已经往 HTTP 响应缓冲区写入了状态行和响应头。一个关键的规则是:
- 状态码只能写一次
- 响应体可以在之后多次写入
- 第一次
Write发生时,如果还没调用WriteHeader,Go 会自动补一个200
因此,这里的 http.Error 试图在发现错误(将内容转换为 JSON 格式失败)后试图修改响应行来进行挽救,实际上是没有作用的,这也是框架目前存在的一个缺点。
路由(Router)
在设计完 Context 之后,对于之前写在 Engine 中的 router 我们也有了一些新的思考:
go
type HandlerFunc func(w http.ResponseWriter, r *http.Request)
type Engine struct {
router map[string]HandlerFunc // 路由表
}
之前的 Router 设计很简单,直接写在 Engine 的内部,handler 参数是原始的 w 和 req,每次请求都需要查询 map[method-path],这样设计有下面这些缺点:
- Engine 里面直接管理路由,职责耦合
- 不支持动态路由
/user/:id - 扩展中间件或统一参数处理时,路由逻辑很难插手
正好我们前面也引入了 Context,使得 Handler 不直接操作 w 和 req,而是操作 *Context,来让 Context 承载生命周期信息(Query、Form、Json 响应等)。
于是引出了一个设计思路:把路由相关逻辑从 Engine 独立出来,形成一个 Router 模块。
我们希望:
-
Engine 只做"框架管控 + ServeHTTP 接口实现"
-
Router 只管"路由注册 + 请求匹配"
-
Context 只管"请求生命周期数据封装"
接下来就是考虑如何将 Router 从 Engine 中隔离出来。我们修改 Engine,不再直接存 map,改为:
go
type Engine struct {
router *router
}
Router 新增方法:
scss
func (r *router) addRoute(...) // 注册路由
func (r *router) handle(c *Context) // 根据 Context 查找 handler 并调用
具体的实现如下:
go
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
}
// 根据 Context 查找 handler 并调用
func (r *router) handle(c *Context) {
key := c.Method + "-" + c.Path
if handler, ok := r.handlers[key]; ok {
handler(c)
} else {
c.String(http.StatusNotFound, "404 NOT FOUND: %s\n", c.Path)
}
}
那么把 Engine 的 Router 逻辑隔离出来之后,gee.go 也应该做一个修改,具体如下:
go
package gee
import (
"net/http"
)
// HandlerFunc 定义了Gee框架使用的请求处理函数类型
type HandlerFunc func(*Context)
// Engine 声明一个 Engine 结构体,这个结构体会实现 ServeHTTP 接口
type Engine struct {
router *router
}
// New 创建 Engine 实例,初始化空的路由表
func New() *Engine {
return &Engine{router: newRouter()}
}
// 工具函数,后续 GET 和 POST 会使用这个函数给 engine 的路由表添加路由
func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
engine.router.addRoute(method, pattern, handler)
}
// GET 提供给用户注册 GET 请求的便捷方法
func (engine *Engine) GET(pattern string, handler HandlerFunc) {
engine.addRoute("GET", pattern, handler)
}
// POST 提供给用户注册 POST 请求的便捷方法
func (engine *Engine) POST(pattern string, handler HandlerFunc) {
engine.addRoute("POST", pattern, handler)
}
func (engine *Engine) Run(addr string) (err error) {
return http.ListenAndServe(addr, engine)
}
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
c := newContext(w, req)
engine.router.handle(c)
}
这时候我们的框架已经修改好了,对应的我们也该修改一下 main.go,用新的框架来进行测试开发:
go
package main
import (
"gee"
"net/http"
)
func main() {
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=ryan
c.String(http.StatusOK, "hello %s, you're at %s\n", c.Query("name"), c.Path)
})
r.POST("/login", func(c *gee.Context) {
c.JSON(http.StatusOK, gee.H{
"username": c.PostForm("username"),
"password": c.PostForm("password"),
})
})
r.Run(":9999")
}
这里可以看到,Handler 的参数变成了 gee.Context,同时提供了 Query/PostForm 参数的功能。同时 gee.Context 封装了 HTML/String/JSON 函数,能够快速构造 HTTP 响应。
运行后,效果一致。
less
coding/GoProject/Gee via v1.24.3
❯ curl http://localhost:9999/hello
hello , you're at /hello
coding/GoProject/Gee via v1.24.3
❯ curl http://localhost:9999/
<h1>Hello Gee</h1>%
coding/GoProject/Gee via v1.24.3
❯ curl http://localhost:9999/login
404 NOT FOUND: /login