笔记:学习go语言的网络基础库,并尝试搭一个简易Web框架

2022-12-22,,,,

在日常的 web 开发中,后端人员常基于现有的 web 框架进行开发。但单纯会用框架总感觉不太踏实,所以有空的时候还是看看这些框架是怎么实现的会比较好,万一要排查问题也快一些。

最近在学习 go 语言,也尝试学习一下 go 语言当前最流行的 web 框架 gin,于是有了这篇学习笔记。看完这篇文章应该能理解 gin 最基础的操作了,但由于 gin 是基于 go 原生的 http 服务器搭建的,所以会先从 go 网络基础库中的 http 服务器开始说起。

目录
1. 认识请求处理器
2. 服务器启动概览
3. 搭建一个 web 框架
框架雏形
分组
上下文
中间件
4. 结束

1. 认识请求处理器

我们先启动一个 http 服务器,然后再分析它是怎么跑起来的。

func hello(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("hello"))
} func main() {
http.HandleFunc("/hello", hello)
http.ListenAndServe(":8888", nil)
}

在上面的代码中,我们先是定义了一个处理方法,然后在8888端口上进行监听。当服务器启动后,每当收到访问/hello的请求,就会返回一个 hello。

我们先看http.HandleFunc()的源代码:

func HandleFunc(pattern string, handler func(ResponseWriter, *Request)) {
DefaultServeMux.HandleFunc(pattern, handler)
}

我们可以看到它实际调用的是一个叫DefaultServeMux的东西,它是一个ServeMux结构体,根据源码的解释,这是一个请求多路复用器,也就是说,我们在这里注册请求的路径和请求的方法,它会帮助我们匹配请求。它的结构是这样的:

type ServeMux struct {
mu sync.RWMutex
m map[string]muxEntry
es []muxEntry // slice of entries sorted from longest to shortest.
hosts bool // whether any patterns contain hostnames
}

这里比较引人注目的是那个 map,我们的请求路径和处理方法会被封装成一个 muxEntry,然后以请求路径为键值存放到这个 map 中(这个过程可以在Handle方法中看到)。

到这里我们心里应该有点点底了,至少知道了我们自己写的请求路径和处理方法是以键值对的形式存放在映射表中的,这样当请求来到的时候就可以根据路径取出相应的方法进行处理了。

那么接下来我们的疑问是:服务器是如何启动的,都做了哪些事情。

2. 服务器启动概览

接下来我们要分析的就是这行代码了:http.ListenAndServe(":8888", nil),它的源码如下:

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

可以看到它创建了一个 Server,并且调用了 Server.ListenAndServe()方法。根据源码的介绍,这个方法会在指定的端口上进行监听,返回一个 Listener,并调用Server.Serve()方法监听该端口上的连接请求,源码如下:

func (srv *Server) ListenAndServe() error {
if srv.shuttingDown() {
return ErrServerClosed
}
addr := srv.Addr
if addr == "" {
addr = ":http"
}
ln, err := net.Listen("tcp", addr) //在指定的端口上进行监听
if err != nil {
return err
}
return srv.Serve(ln) //监听该端口上的连接请求
}

我们进一步看Server.Serve()方法,根据介绍,Serve()方法会接受 Listener 上的连接,并且为每个连接创建一个 goroutine,这些 goroutine 会根据请求选择对应的处理方法进行处理。由于代码较多,我们只看核心部分:

func (srv *Server) Serve(l net.Listener) error {

    //······

	ctx := context.WithValue(baseCtx, ServerContextKey, srv)

    //通过一个无限循环不停地接收请求
for {
rw, err := l.Accept() //······ connCtx := ctx //······ //为每个请求创建一个连接
c := srv.newConn(rw)
c.setState(c.rwc, StateNew) // before Serve can return //为每个连接创建一个 goroutine 进行处理
go c.serve(connCtx)
}
}

从源码中可以看到 Server 会通过一个无限循环不停地接收请求,为每个请求创建一个连接,并为每个连接创建一个 goroutine 进行处理。

接着我们进一步看连接对象的conn.serve()方法,看看新创建的 goroutine 是怎样处理的。这个方法传入了连接的上下文作为参数,同样由于代码比较多,这里只给出核心部分:

func (c *conn) serve(ctx context.Context) {
//......
defer func(){...}() //tls...... //...... for{
//获取一个请求,并创建一个响应对象
w, err := c.readRequest(ctx) //...... serverHandler{c.server}.ServeHTTP(w, w.req) //处理请求
w.cancelCtx() //......
}
}

在新的 goroutine 中会通过一个无限循环处理本次连接,不停地获取请求。每读取到一个请求就创建一个响应对象,并对这次请求进行处理。

处理请求的方法是ServeHTTP(),它的源码如下:

func (sh serverHandler) ServeHTTP(rw ResponseWriter, req *Request) {
handler := sh.srv.Handler
if handler == nil {
handler = DefaultServeMux
}
if req.RequestURI == "*" && req.Method == "OPTIONS" {
handler = globalOptionsHandler{}
}
handler.ServeHTTP(rw, req)
}

由此可见,默认情况下会调用我们开头提到的 DefaultServeMux 的ServeHTTP()方法,这个方法会通过请求处理器获取相应的处理方法,并进行处理,源码如下:

func (mux *ServeMux) ServeHTTP(w ResponseWriter, r *Request) {
if r.RequestURI == "*" {
if r.ProtoAtLeast(1, 1) {
w.Header().Set("Connection", "close")
}
w.WriteHeader(StatusBadRequest)
return
}
h, _ := mux.Handler(r) //通过请求处理器获取相应的处理方法
h.ServeHTTP(w, r) //进行处理
}

这样一来,我们整个服务器的运行逻辑就串联起来了!

3. 搭建一个 web 框架

go 原生提供的 http 服务器,结合其协程的特性,已经具有较高的性能。但是在真正的开发中,我们往往不会直接基于这个内置的 http 服务器进行开发,因为它还缺少一些易用性。

接下来,我们将参考当前 go 生态中最流行的 web 框架 gin,进行一次简单的仿写。

在进行开发之前,我们得思考一下我们的框架得补足哪些功能。由于之前实习的时候用 java 的 spring 框架写过业务,我的第一反应要添加这两的功能:

    分组。比如说我们得设置一个“User”组,然后我们在 UserController 中编写和 “User” 相关的功能,比如说 login,logout等,用户访问时则需要通过 /User/login,/User/logout 来进行访问。
    过滤器的功能。可能这么描述不太准确,我想说的是像 java spring 框架中 AOP 这样的功能,在 go 语言中被叫做中间件。反正通俗来说就是我们可以给一组或多组方法的前后添加拦截,鉴权等功能。

下面开始边搭边记录。

框架雏形

在搭框架之前,我们得把 go 原生提供的 http 服务器调用封装一下,以便我们自己后续添加功能。我们先把文章最开始那个 hello 的功能跑起来。

首先我们创建一个包,在包内创建自己的框架,我把这个框架起名叫 jun(其实叫啥都行)。然后创建一个名为 Engine 的结构体,Engine 就是我们框架的总控制了。

type Engine struct {
} //通过 Default() 我们可以创建一个默认的 http 服务器
func Default() *Engine {
engine := New()
return engine
} func New() *Engine {
engine := &Engine{}
return engine
} //为了能够让 Engine 作为服务器运行起来,我们需要让它实现 Handler 接口,也就是实现 ServeHTTP 方法
//将来我们会在这个方法中编写框架处理请求的逻辑
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("hello"))
} //封装了http.ListenAndServe,如果用户没有给出端口,我们就用8080作为默认端口
func (engine *Engine) Run(addr ...string) error {
address := resolveAddress(addr)
return http.ListenAndServe(address, engine)
}

为了能够让 Engine 作为服务器运行起来,我们需要让它实现 Handler 接口,也就是实现 ServeHTTP 方法,将来我们会在这个方法中编写框架处理请求的逻辑。

同时我们提供了一个启动框架的 Run 方法,里面封装了http.ListenAndServe,如果用户没有给出端口,我们就用8080作为默认端口。

下面我们完善处理请求的部分,我们要提供一些方法,让用户可以把请求处理器注册到框架中。

如果你看了文章的开头部分,应该会记得 go 内置的 http 服务器是通过一个 map 映射表记录这些请求处理器的。这么做能够完成静态路由的功能,但是无法完成动态路由的功能。想要完成动态路由,通常都会用前缀树实现。

我本来想只是简单地借用原生的请求处理器完成静态路由,但是发现原生的处理器有个地方设计得比较简洁,就是我们在注册请求处理方法的时候不需要指定这个请求是 POST 请求还是 GET 请求。

虽然理论上来说我们只要在开发的时候明白这是一个 POST 或是 GET 请求就行,但是为了代码的可读性以及规范性,我想还是对 GET / POST 请求做一下标记比较好。

所以我打算重新实现这个请求处理器,一来是添加上 GET / POST 请求标识,二来为了以后扩展成动态路由作准备(gin 框架也是重新实现了路由部分,而且自己实现了动态路由)。

实现原理和原生的一样,用一个映射表保存请求路径和请求处理器,只不过在路径前面增加一个 GET / POST 标识。(原生的静态路由实现要健壮很多,不过这不是本文的重点了)

增加和修改的部分如下:

//对请求处理环节,我们使用一个映射表进行匹配,完成最简单的静态路由
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
key := req.Method + "-" + req.RequestURI
if handler, ok := engine.router[key]; ok {
handler(w, req)
} else {
http.NotFound(w, req)
}
} //添加路由的方法
func (engine *Engine) addRouter(method string, pattern string, handlerFunc http.HandlerFunc) {
if engine.router == nil {
engine.router = make(map[string]http.HandlerFunc)
}
key := method + "-" + pattern
engine.router[key] = handlerFunc
} func (engine *Engine) Get(pattern string, handlerFunc http.HandlerFunc) {
engine.addRouter("GET", pattern, handlerFunc)
} func (engine *Engine) Post(pattern string, handlerFunc http.HandlerFunc) {
engine.addRouter("Post", pattern, handlerFunc)
}

完成以上修改后,我们框架的启动代码就变成了这样子:

func main() {
engine := jun.Default()
engine.Get("/hello", func(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("hello!"))
})
engine.Run()
}

到这里,我们的框架已经能够支持原生 http 服务器的基本功能了,我们可以在此基础上进行设计,让框架变得更易用,更强大。

所以接下来我们着手实现上面提到的两个功能:分组和中间件。

分组

首先是分组,在思考分组的实现时,我们很容易就想到这个分组的结构一定要有一个前缀的字段,比如像这样:

type RouterGroup struct {
basePath string
}

然而在我阅读 gin 的源码时,我发现 RouterGroup 中有一个 Engine 的字段,Engine 中也有一个 RouterGroup 字段。虽然目前我说不清楚这样设计的道理,但是这样设计确实会让框架变得比较好用,就暂且这样理解吧:web框架最基本核心的功能就是路由请求,所以可以把整个框架都抽象成一个大的路由组,我们可以在这个大路由组里面分组创建路由。

于是 RouterGroup 的设计暂时可以是这样,(如果你体会不到这样设计的好处,那么一定要手动操作一下 gin 框架,使用一下分组功能):

type RouterGroup struct {
basePath string
engine *Engine
} type Engine struct {
RouterGroup
router map[string]http.HandlerFunc
}

既然已经对 Engine 进行了抽象,那么我们添加路由的一系列方法也应该针对 RouterGroup,而不是 Engine,于是添加路由的方法变成了这样:

//多了一层 RouterGroup 封装
func (group *RouterGroup) addRouter(method string, pattern string, handlerFunc http.HandlerFunc) {
if group.engine.router == nil {
group.engine.router = make(map[string]http.HandlerFunc)
}
pattern = group.basePath + pattern
key := method + "-" + pattern
group.engine.router[key] = handlerFunc
} func (group *RouterGroup) Get(pattern string, handlerFunc http.HandlerFunc) {
group.addRouter("GET", pattern, handlerFunc)
} func (group *RouterGroup) Post(pattern string, handlerFunc http.HandlerFunc) {
group.addRouter("POST", pattern, handlerFunc)
}

当然我们创建 RouterGroup 的方法也要稍作调整,同时我们要提供创建 RouterGroup 的函数Group

func New() *Engine {
engine := &Engine{
RouterGroup: RouterGroup{
basePath: "",
},
}
engine.RouterGroup.engine = engine
return engine
} func (group *RouterGroup) Group(relativePath string) *RouterGroup {
return &RouterGroup{
basePath: relativePath,
engine: group.engine,
}
}

然后我们对分组功能进行测试:

func main() {
router := jun.Default()
userGroup := router.Group("/user") userGroup.Post("/login", func(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("login successfully!"))
}) userGroup.Post("/logout", func(w http.ResponseWriter, req *http.Request) {
w.Write([]byte("logout successfully!"))
}) router.Run()
}

分组功能初步完成!

上下文

接下来在实现中间件功能之前,我们先对框架进行一个小的改良,引入一个上下文的概念,这个概念能方便我们对框架进行封装。

举个例子:目前我们写请求处理方法的时候,参数都是 http.ResponseWriter 和 http.Request,也就是请求和响应,这两个东西构成了一次 http 服务最基本的要素。

但在我们实际开发的时候,我们有可能响应给客户端 json 数据,也可能响应一个 html 页面,响应这两种不同的东西是要设置不同的响应头的。

在没有上下文概念时,我们想要响应 json 数据需要这样做:

func func1(w http.ResponseWriter, req *http.Request) {
w.WriteHeader(http.StatusOK)
w.Header().Set("Content-Type", "application/json")
encoder := json.NewEncoder(w) message := map[string]interface{}{
"message": "pong",
}
if err := encoder.Encode(message); err != nil {
http.Error(w, err.Error(), 500)
}
}

也就是说我们每次都要手动设置一些和 json 相关的响应内容,如果我们用一个结构把响应与请求封装起来,那么我们就可以基于这个结构对一些常用的操作进行封装,我们可以看一下对于同样的内容,用 gin 框架会怎么写:

func func2(c *gin.Context) {
c.JSON(200, gin.H{
"message": "pong",
})
}

(⊙ˍ⊙)没错,就是这么简单。

所以下面我们仿照 gin 引入这个上下文的结构,并编写响应 JSON 的方法:

//用 H 来封装这个用于转换为 json 的 map,减少代码量
type H map[string]interface{} type Context struct {
Writer http.ResponseWriter
Request *http.Request
engine *Engine
} //新建一个 Context
func newContext(w http.ResponseWriter, req *http.Request) *Context {
return &Context{
Writer: w,
Request: req,
}
} //封装的响应 JSON 的方法
func (c *Context) JSON(code int, obj interface{}) {
c.Writer.WriteHeader(code)
c.Writer.Header().Set("Content-Type", "application/json") encoder := json.NewEncoder(c.Writer)
if err := encoder.Encode(obj); err != nil {
http.Error(c.Writer, err.Error(), http.StatusInternalServerError)
}
} //我们还可以封装一系列方法,比如获取参数,响应 html 等等······

当然我们的主框架也要做一些相应的修改:

func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
context := newContext(w, req)
context.engine = engine key := req.Method + "-" + req.RequestURI
if handler, ok := engine.router[key]; ok {
handler(context)
} else {
http.NotFound(w, req)
}
}

并把一系列注册路由方法的参数改为 Context,为此我们还要重新封装一下处理方法,不能再用 http.HandlerFunc了。

//自己定义一个 HandlerFunc
type HandlerFunc func(*Context) func (group *RouterGroup) Get(pattern string, handlerFunc HandlerFunc) {
group.addRouter("GET", pattern, handlerFunc)
} //······

最后我们进行一个简单的测试:

func main() {
router := jun.Default() router.Get("/ping", func(c *jun.Context) {
c.JSON(http.StatusOK, jun.H{
"message": "pong",
})
}) router.Run()
}

上下文功能初步完成!

中间件

这里废话可能有点多

接下来我们考虑中间件的功能,也就是拦截器,这应该是整个框架最精彩的部分了。它的使用场景很广,比如限制一些接口登录后才能访问。

既然是对一些接口做定制,中间件功能就应该作用于分组之上,所以我们要考虑对已经实现的分组功能做一些改进。

考虑到我们有可能有多个中间件,各个中间件和业务逻辑之间会按照一定的顺序执行,我们可以利用责任链模式的思想进行开发。(以前入门学习责任链的时候写过一篇文章,虽然当时写的有点幼稚,但是如果你之前没接触过的话,说不定会有点帮助,文章链接)

得补充一点,中间件这个名字听起来很高级,其实可以理解成一个函数,这个函数在请求处理器之前或之后执行,所以给它取个名字叫中间件(●'◡'●)

但不得不承认,这部分内容对我来说有点难,花了好大功夫才看懂,所以我打算先捋一下我的思路:

    由于我们的 Context 上下文是用来存放一次请求的相关信息的,所以我们要在 Context 中添加一个集合,用来存储这次请求需要被哪些中间件处理。
    我们的中间件是作用在一个分组上的,所以 RouterGroup 中也要有一个集合存放中间件。
    分组要提供一个 Use 方法,把中间件存放到对应的 RouterGroup 里。
    在我们处理一次请求的时候,我们先判断这个请求属于哪个分组,然后把对应分组的中间件添加到上下文中,然后在上下文中以责任链的形式依次执行所有中间件。

下面按照这个思路开始动手:

    在 Context 中添加一个集合,用来存储这次请求需要被哪些中间件处理。
type Context struct {
Writer http.ResponseWriter
Request *http.Request
engine *Engine //这次请求需要被哪些中间件处理
handlers []HandlerFunc
//这是一个用于依次调用中间件的值
index int
} //这个是整个责任链的核心方法,每当这个方法被调用,就执行下一个中间件函数
func (c *Context) Next() {
c.index++
for c.index < len(c.handlers) {
//执行下一个中间件函数
c.handlers[c.index](c)
c.index++
}
}
    中间件是作用在一个分组上的,所以 RouterGroup 中也要有一个集合存放中间件。
type RouterGroup struct {
basePath string
engine *Engine
//存放分组需要执行的中间件
Handlers []HandlerFunc
}
    分组要提供一个 Use 方法,把中间件存放到对应的 RouterGroup 里。
func (group *RouterGroup) Use(middlewares ...HandlerFunc) *RouterGroup {
group.Handlers = append(group.Handlers, middlewares...)
return group
}
    在我们处理一次请求的时候,我们先判断这个请求属于哪个分组,然后把对应分组的中间件添加到上下文中,然后在上下文中以责任链的形式依次执行所有中间件。由于 gin 框架实现了动态路由,这部分实现的代码看起来会比较多;我的代码比较简陋,封装得没那么好,但思路是相似的。
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
context := newContext(w, req)
context.engine = engine // 根据路径判断这个请求属于哪个组,然后获取这个组上安装的中间件,把这些中间件放到上下文中
for _, group := range engine.groups {
if strings.HasPrefix(req.URL.Path, group.basePath) {
context.handlers = append(context.handlers, group.Handlers...)
}
} // 把请求处理器也作为“中间件”放到上下文中
key := req.Method + "-" + req.RequestURI
if handler, ok := engine.router[key]; ok {
context.handlers = append(context.handlers, handler)
} else {
context.handlers = append(context.handlers, func(context *Context) {
http.Error(context.Writer, "404 page not found", http.StatusNotFound)
})
} //处理请求!
context.Next()
}

我个人觉得最难懂的地方就是把请求处理器也作为“中间件”放到上下文中,如果比较懵逼的话可以在处理请求的context.Next()这里打个断点,跟进看看整个处理流程。

下面是我简单的测试用例,给整体的大分组添加一个计时器中间件,计算处理一个请求需要花多少时间。

func main() {
router := jun.Default() //添加中间件
router.Use(Timer) router.Get("/ping", func(c *jun.Context) {
c.JSON(http.StatusOK, jun.H{
"message": "pong",
})
}) router.Run()
} func Timer(c *jun.Context) {
t := time.Now()
c.Next()
log.Printf("use time: %v\n", time.Since(t))
}

(ง •_•)ง能正常跑起来!

另外,在 gin 源码中,用默认情况启动 gin 框架的话会自动加上两个中间件:

// Default returns an Engine instance with the Logger and Recovery middleware already attached.
func Default() *Engine {
debugPrintWARNINGDefault()
engine := New()
engine.Use(Logger(), Recovery())
return engine
}

其中一个是记录日志的,一个是错误恢复的,这提醒了我在设计 web 服务的时候我们还得考虑它的错误恢复机制。

4. 结束

虽然只是对 gin 框架进行了简单的仿写,但是对整个框架的设计思想清晰了很多。当然 gin 框架绝不仅仅只有这点内容,它封装了很多功能让我们开发 web 服务更加方便,我们用到啥再去查文档就好啦!

笔记:学习go语言的网络基础库,并尝试搭一个简易Web框架的相关教程结束。

《笔记:学习go语言的网络基础库,并尝试搭一个简易Web框架.doc》

下载本文的Word格式文档,以方便收藏与打印。