Go语言动手写Web框架 - Gee第二天 上下文Context

写在前面:本项目参考 7天用Go从零实现Web框架Gee教程,下面的内容全部参考自该网站。主要是记录我在学习过程中的所思所想,如有存在问题还请多多指教。

接下来我们继续优化 Gee,接下来要做的事情是设计 Context

设计 Context 的必要性

  1. 屏蔽底层 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.RequestResponseWriter,就像每次用水都要自己接水管,步骤繁琐且容易出错;Context 的引入,相当于提供了标准水龙头,将复杂、易错的底层细节封装起来,只暴露简单、稳定的使用方式。


  1. 作为请求生命周期的统一载体,承载框架扩展能力

针对使用场景,封装*http.Requesthttp.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
}

这里的执行流程非常关键:

  1. json.Marshal(v)

    1. 把 Go 对象转换成 JSON 字节数组
  2. enc.w.Write(data)

    1. 直接把 JSON 数据写入到 io.Writer
  3. 返回 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 参数是原始的 wreq,每次请求都需要查询 map[method-path],这样设计有下面这些缺点:

  • Engine 里面直接管理路由,职责耦合
  • 不支持动态路由 /user/:id
  • 扩展中间件或统一参数处理时,路由逻辑很难插手

正好我们前面也引入了 Context,使得 Handler 不直接操作 wreq,而是操作 *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
相关推荐
码luffyliu4 小时前
Go 实战: “接口 + 结构体” 模式
后端·go
码luffyliu4 小时前
Go 中的深浅拷贝:从城市缓存场景讲透指针与内存操作
后端·go·指针·浅拷贝·深拷贝
宾燕哥哥20 小时前
Go 语言基础学习文档
go
Way2top20 小时前
Go语言动手写Web框架 - Gee第一天
go
探索云原生1 天前
Buildah 简明教程:让镜像构建更轻量,告别 Docker 依赖
linux·docker·云原生·go·cicd
越千年1 天前
工作中常用到的二进制运算
后端·go
踏浪无痕1 天前
信不信?一天让你从Java工程师变成Go开发者
后端·go
卡尔特斯2 天前
Go 语言入门核心概念总结
go
代码扳手2 天前
从0到1揭秘!Go语言打造高性能API网关的核心设计与实现
后端·go·api