Go语言动手写Web框架 - Gee第一天

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

Day1

标准库启动 Web 服务

Go 内置了 net/http 库,封装了HTTP网络编程的基础的接口,我们实现的Gee Web 框架便是基于net/http的。我们接下来通过一个例子,简单介绍下这个库的使用。

go 复制代码
package main

import (
        "fmt"
        "log"
        "net/http"
)

func main() {
        http.HandleFunc("/", indexHandler)
        http.HandleFunc("/hello", helloHandler)
        log.Fatal(http.ListenAndServe(":9999", nil))
}

// handler echoes r.URL.Path
func indexHandler(w http.ResponseWriter, req *http.Request) {
        fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
        // 等价于
        // data := []byte(`URL.Path = "/"\n`)
        // w.Write(data)
}

// handler echoes r.URL.Header
func helloHandler(w http.ResponseWriter, req *http.Request) {
        for k, v := range req.Header {
                fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
        }
}

http.HandleFunc 的作用是注册路由+注册处理函数,这里简单来说就是:

  • 当请求命中 / 的时候执行indexHandler

  • 当请求命中 /hello 的时候执行 helloHandler

对于注册的处理函数(这里就是 indexHandlerhelloHandler)也是有要求的,它的签名必须是:

go 复制代码
func(w http.ResponseWriter, r *http.Request)

否则编译都过不了。

程序运行后,终端输入命令有如下结果:

less 复制代码
coding/GoProject/Gee via  v1.24.3 
❯ curl http://localhost:9999/
URL.Path = "/"

coding/GoProject/Gee via  v1.24.3 
❯ curl http://localhost:9999/hello
Header["Accept"] = ["*/*"]
Header["User-Agent"] = ["curl/8.7.1"]

我们现在就来分析一下,从 curl ``http://localhost:9999/URL.Path = "/" 的完整流程。

当用户输入 curl ``http://localhost:9999/ 的时候,程序监听到了这个请求,发现请求路径为 /,于是触发与 / 绑定的处理函数 indexHandler

我们发现这个 indexHandler 包含两个传参 wr,但实际上这两个传参完全不需要我们手动操作或者在某个地方手动传入。当收到请求报文的时候,net/http 会自动帮我们解析请求报文,并从中解析出 w http.ResponseWriterreq *http.Request 这两个参数,然后自动传入 indexHandler 中。于是接下来就直接走到了函数内部的 fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path) 中了。

在讲接下来的数据流向之前,有必要讲清楚 http.ResponseWriter 以及 fmt.Fprintf。首先,http.ResponseWriter 是一个接口,接口结构如下:

go 复制代码
type ResponseWriter interface {
        Header() Header
        Write([]byte) (int, error)
        WriteHeader(statusCode int)
}

通过 net/http 解析出来的 w 传参就是一个实现了 ResponseWriter 接口的对象。w 在源码层面是一个接口类型变量,但运行时它持有的是一个实现了 ResponseWriter 接口的具体结构体实例。

w 不是抽象的"接口对象",它是一个具体结构体示例。 在标准库里大概是这种结构(简化代码):

go 复制代码
type response struct {
    conn    *conn          // TCP 连接
    buf     *bufio.Writer  // 写缓冲
    header  Header
    status  int
}

那么也就是说,传入 indexHandler 的参数 w 实现了 Write 方法。

接下来看到 fmt.Fprintf。我们来看 fmt.Fprintf 的定义:

go 复制代码
func Fprintf(w io.Writer, format string, a ...any) (n int, err error)

这里,只要 w 实现了 io.Writer,那么 Fprintf 内部就会调用 w.Write。而 http.ResponseWriter 实现了 io.Writer 所以:

arduino 复制代码
fmt.Fprintf(w, "hello")

实际执行的是:

lua 复制代码
w.Write([]byte("hello")) // 至于为什么是 []byte("hello") 而不是 "hello",是因为实际上在 Write 之前还有一个格式化的步骤,具体可以去看看 Fprintf 的源码

到这里我们就可以看懂示例代码中的这句了:

perl 复制代码
fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)

它等价于:

ini 复制代码
data := []byte(`URL.Path = "/"\n`)
w.Write(data)

这里之前还需要简单说一下 req,你可以简单理解为,通过 req 就可以拿到请求报文中的所有东西,例如:

scss 复制代码
req.Method        // "GET"
req.URL.Path      // "/hello"
req.URL.RawQuery  // "name=ryan"
req.Proto         // "HTTP/1.1"
req.URL.Query().Get("name")
req.URL.Query()["name"]

所以这里是把请求报文中的请求路径拿过来做了一个拼接,然后作为 x.Write 的传参写入。


至此我们已经捋清楚了从请求报文发过来一直到将其中的请求路径作为 x.Write 传参写入的过程了,接下来的问题就是,x.Write 是啥?它写入到了哪里?写完之后我又是怎么在终端看到最终结果的呢?

具体来说:

  • wnet/http 内部的 response 对象

  • Write 把字节写入 HTTP 响应缓冲区

  • handler 返回后

  • net/http 把这些字节通过 TCP 发给浏览器

http.Handler 接口

现在我们回头看看一开始的示例代码,就应该很清晰了:

go 复制代码
package main

import (
        "fmt"
        "log"
        "net/http"
)

func main() {
        http.HandleFunc("/", indexHandler)
        http.HandleFunc("/hello", helloHandler)
        log.Fatal(http.ListenAndServe(":9999", nil))
}

// handler echoes r.URL.Path
func indexHandler(w http.ResponseWriter, req *http.Request) {
        fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
        // 等价于
        // data := []byte(`URL.Path = "/"\n`)
        // w.Write(data)
}

// handler echoes r.URL.Header
func helloHandler(w http.ResponseWriter, req *http.Request) {
        for k, v := range req.Header {
                fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
        }
}

在示例代码中,我们通过 http.HandleFunc 注册了两个函数:

  • "/" 路径对应 indexHandler

  • "/hello" 路径对应 helloHandler

当浏览器或 curl 发起请求时,net/http 会自动调用我们注册的函数,并将请求信息封装成 req,响应的写入器封装成 w,传入函数内部。

也就是说,每一个请求都会被自动交给一个"处理单元"来处理,我们不需要自己去解析报文、管理 TCP 连接或构造响应。

这种把请求和处理逻辑绑定起来,并由框架负责调用的机制,就是 Go 语言中所谓的 handler 模型。

官方定义上,handler 是一个接口:

go 复制代码
type Handler interface {
    ServeHTTP(w http.ResponseWriter, r *http.Request)
}

任何实现了 ServeHTTP 方法的对象,都是一个 handler。我们注册的函数其实是通过 http.HandlerFunc 适配器被自动包装成了 Handler,从而可以被 net/http 调用。

这里你可能有一个疑问,函数怎么可以实现一个接口呢?不都是结构体实现接口吗?这是怎么适配的?

实际上,GO 允许你为自定义类型添加方法,不管这个类型是结构体、内置类型还是函数类型。

在标准库中就定义了一个类型:

go 复制代码
type HandlerFunc func(http.ResponseWriter, *http.Request)

然后给他添加一个方法:

scss 复制代码
func (f HandlerFunc) ServeHTTP(w http.ResponseWriter, r *http.Request) {
    f(w, r)
}

所以 函数类型也实现了 http.Handler 接口

总结来说:

  • handler 是处理单个 HTTP 请求的逻辑单元
  • 可以是函数,也可以是实现 ServeHTTP 的结构体
  • 它让我们只需关注业务逻辑,不必关心底层网络和报文处理

手动实现 Engine 统一处理请求

示例代码的最后一行是用来启动 Web 服务的:

lua 复制代码
log.Fatal(http.ListenAndServe(":9999", nil))

通过查看源码可以知道,ListenAndServe 的第二个传参是一个 Handler 类型,默认为空:

go 复制代码
func ListenAndServe(addr string, handler Handler) error {
        server := &Server{Addr: addr, Handler: handler}
        return server.ListenAndServe()
}

前面我们已经介绍过了 Handler 类型,如果 ListenAndServe 不传入 Handler 类型参数,那么使用全局默认的路由映射表处理请求(http.DefaultServeMux),如果我们传入了参数,那么就按照我们自己传入的 Handler 类型来处理 HTTP 请求,更准确的说,是根据我们传入的 Handler 类型的 ServeHTTP 方法来处理 HTTP 请求。

这一小节我们就来自己构建一个实现了 Handler 接口的结构体 Engine

具体代码实现如下:

go 复制代码
package main

import (
        "fmt"
        "log"
        "net/http"
)

// Engine is the uni handler for all requests
type Engine struct{}

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
        switch req.URL.Path {
        case "/":
                fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
        case "/hello":
                for k, v := range req.Header {
                        fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
                }
        default:
                fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
        }
}

func main() {
        engine := new(Engine)
        log.Fatal(http.ListenAndServe(":9999", engine))
}

首先我们定义了一个 Engine 类型的结构体。这里解释一下为什么是一个空的结构体:因为我们并不需要该结构体来存储任何数据,我们只是希望构建一个实现了 Handler 接口的(也就是实现了 ServeHTTP 方法)的结构体,那么使用空结构体来实现就是一种很合适的方式,我们希望 Engine 只是一种"行为承载体",方法集就是它的存在意义。另外,Go 中的空结构体还不会占用任何内存。

当然,这里只是简单实现 ServeHTTP 方法,后续版本中我们会慢慢完善 Engine,到后面 Engine 就不再是空结构体了,还需要包含一些数据类型用于存储。只是在这一部分,使用空结构体是没有问题的。

继续往下走,就到了具体实现 ServeHTTP 的部分了。显然,两个传参是必要的(因为 Handler 中的 ServeHTTP 就需要这两个类型的传参)。

接着我们可以看到,通过 switch 对 请求的地址(req.URL.Path)进行判断:

  • 如果是 "/",打印 req.URL.Path

  • 如果是 "/hello",打印 req.Header

  • 如果都不是,打印 "404 NOT FOUND: %s\n"

那么你会发现,我们把对于不同路由的处理,写到了我们自定义的实现了 Handler 接口的 Engine 结构体中。运行这个函数之后,在终端输入命令,同样会得到如下结果:

less 复制代码
coding/GoProject/Gee via  v1.24.3 
❯ curl http://localhost:9999/     
URL.Path = "/"

coding/GoProject/Gee via  v1.24.3 
❯ curl http://localhost:9999/hello
Header["User-Agent"] = ["curl/8.7.1"]
Header["Accept"] = ["*/*"]

coding/GoProject/Gee via  v1.24.3 
❯ curl http://localhost:9999/world
404 NOT FOUND: /world

main 函数中,我们给 ListenAndServe 方法的第二个参数传入了刚才创建的engine实例。至此,我们走出了实现Web框架的第一步,即,将所有的HTTP请求转向了我们自己的处理逻辑。还记得吗,在实现Engine之前,我们调用 http.HandleFunc 实现了路由和Handler的映射,也就是只能针对具体的路由写处理逻辑。比如/hello。但是在实现Engine之后,我们拦截了所有的HTTP请求,拥有了统一的控制入口。在这里我们可以自由定义路由映射的规则,也可以统一添加一些处理逻辑,例如日志、异常处理等。

代码的运行结果与之前的是一致的。

这里有必要解释一下,为什么说这算是迈出了实现 Web 框架的第一步。

在没有自定义 Engine 之前:

  • 每个路由都要单独注册 handler

  • 每个 handler 只处理自己对应的路径

  • 控制权分散在各个函数里

  • 想做统一操作(日志、异常处理、统一返回格式)就很麻烦,需要在每个 handler 里重复写

但是在定义了 Engine 之后,所有的请求都需要先经过 engine.ServeHTTP,那么在这个我们自己实现的 engine.ServeHTTP 内部,就可以做:

  • 路由匹配

  • 日志打印

  • 异常处理

  • 响应统一封装

核心变化:控制权从"分散在各个 handler 函数" → "集中在 Engine 里统一管理"。

当然了,这里的 Engine 只是为了引出实现 Web 框架的一个例子,实际上路由匹配肯定不是像现在这么写了,如果像现在这样写,那么每多一个路由都要改 Engine 的源代码,当然是不行的。

Engine 的核心价值是"统一入口 + 可扩展分发",路由匹配写在 Engine 里只是为了演示;实际框架中路由匹配由映射表管理,用户注册路由,Engine 只负责分发,这样就不会每次加路由都改 Engine 代码。

Gee 框架的雏形

下面,我们重新组织上面代码,搭建出整个框架的雏形。最终的代码目录结构是这样的:

go 复制代码
gee/
  |--gee.go
  |--go.mod
main.go
go.mod

gee/go.mod

ruby 复制代码
module Gee

go 1.24

gee/gee.go

go 复制代码
package gee

import (
        "fmt"
        "net/http"
)

// HandlerFunc 定义了Gee框架使用的请求处理函数类型
type HandlerFunc func(w http.ResponseWriter, r *http.Request)

// Engine 声明一个 Engine 结构体,这个结构体会实现 ServeHTTP 接口
type Engine struct {
        router map[string]HandlerFunc // 路由表
}

// New 创建 Engine 实例,初始化空的路由表
func New() *Engine {
        return &Engine{router: make(map[string]HandlerFunc)}
}

// 工具函数,后续 GET 和 POST 会使用这个函数给 engine 的路由表添加路由
func (engine *Engine) addRoute(method string, pattern string, handler HandlerFunc) {
        key := method + "-" + pattern
        engine.router[key] = 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) {
        key := req.Method + "-" + req.URL.Path
        fmt.Fprintf(w, "拼接出来的url:%s\n", key)
        if handler, ok := engine.router[key]; ok {
                handler(w, req)
        } else {
                w.WriteHeader(http.StatusNotFound) // 返回状态码
                fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
        }
}

那么gee.go就是重头戏了。我们重点介绍一下这部分的实现。

  • 首先定义了类型HandlerFunc,这是提供给框架用户的,用来定义路由映射的处理方法。我们在Engine中,添加了一张路由映射表router,key 由请求方法和静态路由地址构成,例如GET-/GET-/helloPOST-/hello,这样针对相同的路由,如果请求方法不同,可以映射不同的处理方法(Handler),value 是用户映射的处理方法。
  • 当用户调用(*Engine).GET()方法时,会将路由和处理方法注册到映射表 router 中,(*Engine).Run()方法,是 ListenAndServe 的包装。
  • Engine实现的 ServeHTTP 方法的作用就是,解析请求的路径,查找路由映射表,如果查到,就执行注册的处理方法。如果查不到,就返回 404 NOT FOUND

go.mod

ruby 复制代码
module Gee

go 1.24

require gee v0.0.0

replace gee => ./gee

main.go

go 复制代码
package main

import (
        "fmt"
        "gee"
        "net/http"
)

func main() {
        r := gee.New() // 创建 Engine 实例

        r.GET("/", func(w http.ResponseWriter, req *http.Request) {
                fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
        })

        r.GET("/hello", func(w http.ResponseWriter, req *http.Request) {
                for k, v := range req.Header {
                        fmt.Fprintf(w, "Header[%q] = %q\n", k, v)
                }
        })

        r.Run(":9999") // 启动 Web 服务
}

看到这里,如果你使用过gin框架的话,肯定会觉得无比的亲切。gee框架的设计以及API均参考了gin。使用New()创建 gee 的实例,使用 GET()方法添加路由,最后使用Run()启动Web服务。这里的路由,只是静态路由,不支持/hello/:name这样的动态路由,动态路由我们将在下一次实现。

最后,我们分析一下,用我们自定义的框架完成之后,从 curl ``http://localhost:9999/URL.Path = "/" 的完整流程。

当我们启动服务之后,在终端输入 curl ``http://localhost:9999/,此时 net/http 会收到请求报文并进行解析,解析出 w http.ResponseWriterreq *http.Request,并传入 gee/gee.go 中的 ServeHTTP 函数进行处理。

scss 复制代码
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
        key := req.Method + "-" + req.URL.Path
        // fmt.Fprintf(w, "拼接出来的url:%s\n", key)
        if handler, ok := engine.router[key]; ok {
                handler(w, req)
        } else {
                fmt.Fprintf(w, "404 NOT FOUND: %s\n", req.URL)
        }
}

然后通过 req.Methodreq.URL.Path 拼接出路由 key,然后从 engine 的路由表中进行查找,如果存在该路由,那么就把 wreq 交给对应的处理函数进行处理;如果不存在该路由,就返回 "404 NOT FOUND: %s\n"

这里,我们拼接出来的 key"GET-/"。在 main.go 中,通过 r.GET 方法已经将该路由注册到了 engine 的路由表中了:

go 复制代码
r.GET("/", func(w http.ResponseWriter, req *http.Request) {
                fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
        })

那么,就可以通过路由映射表找到对应的处理函数:

go 复制代码
func(w http.ResponseWriter, req *http.Request) {
                fmt.Fprintf(w, "URL.Path = %q\n", req.URL.Path)
        }

然后将 wreq 参数传入给这个处理函数,最后 fmt.Fprintf 来调用 w.Write,后面的"URL.Path = %q\n" 作为 w.Write 的传参写入 HTTP 响应缓冲区, handler 返回后,net/http 把这些字节通过 TCP 发给浏览器。

这就是基于我们自己编写的简单框架中数据的流向。


至此,整个Gee框架的原型已经出来了。实现了路由映射表,提供了用户注册静态路由的方法,包装了启动服务的函数。当然,到目前为止,我们还没有实现比net/http标准库更强大的能力,不用担心,很快就可以将动态路由、中间件等功能添加上去了。

相关推荐
研究司马懿17 小时前
【云原生】Gateway API高级功能
云原生·go·gateway·k8s·gateway api
梦想很大很大1 天前
使用 Go + Gin + Fx 构建工程化后端服务模板(gin-app 实践)
前端·后端·go
lekami_兰1 天前
MySQL 长事务:藏在业务里的性能 “隐形杀手”
数据库·mysql·go·长事务
却尘2 天前
一篇小白也能看懂的 Go 字符串拼接 & Builder & cap 全家桶
后端·go
ん贤2 天前
一次批量删除引发的死锁,最终我选择不加锁
数据库·安全·go·死锁
mtngt112 天前
AI DDD重构实践
go
Grassto4 天前
12 go.sum 是如何保证依赖安全的?校验机制源码解析
安全·golang·go·哈希算法·go module
Grassto5 天前
11 Go Module 缓存机制详解
开发语言·缓存·golang·go·go module
程序设计实验室6 天前
2025年的最后一天,分享我使用go语言开发的电子书转换工具网站
go
我的golang之路果然有问题6 天前
使用 Hugo + GitHub Pages + PaperMod 主题 + Obsidian 搭建开发博客
golang·go·github·博客·个人开发·个人博客·hugo