web框架有啥难的(1)

177 阅读11分钟

前言

在背八股/转码(java转golang)的时候遇到过最尴尬的问题就是,要重新去学一套新的框架或者去底层去了解一个框架,这个系列是作者经过大半年的应用和思考的之后总结的一些web框架通用的思想(方法),希望能够帮助大家从实际应用方面去思考/学习一些市面上常见的web框架

在本文中,我们主要以golang中的gin框架为例,因为gin的代码容易理解+Java的web框架封装太多层,不便于大家跟着作者思路去理解

后续会带大家手动去讲作者实现的一个goweb框架,感兴趣的也可以看看

github地址:github.com/wangshiben/… (求个star)

也可以观看对应的视频: www.bilibili.com/video/BV1Ea…

web框架面向的角色有哪些

如果是想快速掌握框架八股的读者可以跳转至下面的 处理函数的接入

在我们开始理解web框架之前,不妨我们先以框架开发者的角度来看,这个框架所支持的应用面对的角色有哪些,以及,各个角色需要考虑的东西有哪些

提示: 有三类角色

没错,就是有用户/应用开发者/框架开发者三类,大概的对应的关系如下

image.png

上图解释如下:框架开发者提供的外部函数直接面向程序员,而程序员则利用框架提供的接口来搭建出符合用户需求的实际应用

当然了,如果从程序(数据)的角度来看的话那应该是这样:

image.png

红色表示框架进行一系列处理,开发者和用户感受不到数据的过程,或者说这就是我们接下来要讨论的点

那接下来大家可以想一想,如果说让你来设计一个可用的web框架,那么你会从哪些方面来思考呢?

个人觉得可以从以下四个方面来思考:

  1. 拓展性/自由度

基于我们的框架未来面对的应用可能涉及到各个场景,可能是论坛,可能是购物网站,也可能是网盘......

这就需要我们框架要对开发者提供一套基础的调用对象,并且需要在很多地方做出一种可自定义的选择,比如说,开发者可以自定义端口,可以自定义处理函数,可以自定义是否采用TLS支持等等

  1. 健壮性

由于可能开发者在应用代码中会做出一些无意识的危险行为从而导致开发者的代码崩溃,比如说空指针,数组越界等等,那么我们在框架设计的时候就应该考虑在语言进行兜底之前我们需要把这些异常情况进行简单兜底并且保证不能影响整个框架的运行

举个很简单的场景:假设某个框架,在执行某个http请求的时候遇到了开发者不小心忘记处理的空指针异常,那么整个http服务就挂掉了

上述场景显然在现代的web框架是不会存在的,就是因为现代web框架一般都会在框架上有一层兜底措施并且不会影响到整个框架运行

  1. 对于HTTP协议的规范处理

因为目前我们讨论的只是web框架,所以涉及到的核心还是http协议,但重点是http协议头相关的内容

比如说,500是服务器内部报错,这种状态是我们在进行兜底代码编写的时候需要框架实现的

  1. 语法糖/便利性/安全性

这三个我觉得可以联合起来说,首先是语法糖和便利性

这两者在web框架中就是为开发者提供便利的,以便开发者快速开发出一,个符合预期的应用程序,而不需要自己从头开始处理一些很原始的数据(比如说http二进制包转换为http的对象),并且由于大部分框架都是基于语言的httpServer进行编写的,那么我们的框架也需要对这个server进行封装和转换,将每个http请求对象进行一些处理和封装再暴露给开发者

然后我们再来看看安全性

安全性我觉得大家应该都或多或少有一些自己的想法,这里就不过多赘述

我认为的安全性其实很简单,就是应用出问题了把锅甩给开发(bushi)

基于以上的四点,再结合作者使用过的几个框架来讲,我们可以从以下几个方面去深入了解这个框架的运行原理/实现一个框架

1. 处理函数的接入(Controller/Handler的接入)

无论哪个web框架都会提供一个给开发者构建应用的接入点(函数),这个一般是开发者和框架交互最多的地方,但也是最不好设计和处理的地方之一

在这个片段中,开发者一般需要按照框架的要求填写处理函数(当然,如果你使用SpringBoot等Java框架那这块确实开发者需要做的就很少了,毕竟Java的反射和注解太香了)

框架则需要确保,将http请求的参数传递到这个处理函数中

接下来我们就以gin框架为例,来看看这个框架是怎么做的

示例代码如下:


func GinTest() {
   // 初始化Gin引擎
   r := gin.Default()
   group := r.Group("/a")

   group.Use(func(context *gin.Context) {
      println("hello")
   })
   // 定义路由和处理函数
   group.GET("/welcome", func(c *gin.Context) {
     
      all, err := io.ReadAll(c.Request.Body)
      if err != nil {
         return
      }
      all = []byte("hello world")
      c.Writer.Write(all)

      c.Writer.Header().Set("Set-Cookie", "quic=aaaaaaa")
   })

   r.GET("/a/weelcome", func(c *gin.Context) {


      all, err := io.ReadAll(c.Request.Body)
      if err != nil {
         return
      }
      all = []byte("hello world")
      c.Writer.Write(all)


   })
   // 启动HTTP服务器,默认监听8080端口
   r.Run() // 相当于 r.Run(":8080")
}

在上述代码中,我们首先来看:


r.GET()

这个函数就是gin和开发者编写的http请求处理函数交互的地方了,来我们进去看看


// GET is a shortcut for router.Handle("GET", path, handlers).
func (group *RouterGroup) GET(relativePath string, handlers ...HandlerFunc) IRoutes {
   return group.handle(http.MethodGet, relativePath, handlers)
}


可以看到这个函数有两个类型的参数:第一个就是relativePath,表示处理的是哪个路径下的函数,第二个是可变长数组类型的HandlerFunc,表示的是处理这个路径下的函数的数组,那为什么是数组呢?(这块我们先不过多讨论,稍后会介绍到)

那么我们继续往下,前往group.handle,在这里我们看到有如下的函数

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
   absolutePath := group.calculateAbsolutePath(relativePath)//得到绝对路径
   handlers = group.combineHandlers(handlers)//链式指针结合
   group.engine.addRoute(httpMethod, absolutePath, handlers)//在engine中进行注册,http请求实际处理的注册
   return group.returnObj()
}

首先前两行表示的是路由器组的一些字符串处理以及路由器组的handler结合,这块我们不做过多讨论,我们来看函数体里的第三行:

我们通过engine结构体或者说启动示例代码进行debug可以发现,这个engine在全局是统一的(地址的唯一)

然后我们再进入这个addRoute函数,这个就是

func (engine *Engine) addRoute(method, path string, handlers HandlersChain) {
   assert1(path[0] == '/', "path must begin with '/'")
   assert1(method != "", "HTTP method can not be empty")
   assert1(len(handlers) > 0, "there must be at least one handler")

   debugPrintRoute(method, path, handlers)

   root := engine.trees.get(method)//root数组,是按照method进行分割的
   if root == nil {
      root = new(node)
      root.fullPath = "/"
      engine.trees = append(engine.trees, methodTree{method: method, root: root})
   }
   root.addRoute(path, handlers)//添加到路由树内

   if paramsCount := countParams(path); paramsCount > engine.maxParams {
      engine.maxParams = paramsCount
   }

   if sectionsCount := countSections(path); sectionsCount > engine.maxSections {
      engine.maxSections = sectionsCount
   }
}

然后我们可以看到,最核心的依旧是addRoute函数,14行,这个就是具体的handler如何添加进树的了

关于root.addRoute函数的解析这里暂时省略,因为确实很长,感兴趣的读者可以自行查阅

简单来说就是维护了一个前缀树,然后这个树每个节点存放的是handlerchain,以及是否为全匹配的bool值, addRoute就是将这个handlers以节点的方式添加进node中

node结构如下:

type node struct {
   path      string
   indices   string
   wildChild bool
   nType     nodeType
   priority  uint32
   children  []*node // child nodes, at most 1 :param style node at the end of the array
   handlers  HandlersChain
   fullPath  string
}

至此,处理函数的接入这一点我们就算是看完了,那总结这个流程图就大致如下:

image.png

2. Filter/AOP/中间件

在web框架我们讨论的范围内,这三个概念其实是一个东西,本质上都是在某个函数(Target)进行处理之前有一个函数(Filter),先执行的是filter函数,再由filter函数来决定是否执行Target函数

注: Filter和中间件(middlware)作者认为只是在Java和golang中一样概念的不同说法,本质上都是一种类似于AOP的东西,所以就放在一起讲了

在gin中,一般都是以中间件的形式来进行请求的拦截/添加的,我们可以从示例代码的第七行,也就是

group.Use(func(context *gin.Context) {
   println("hello")
})

首先我们进入到group.Use函数中

// Use adds middleware to the group, see example code in GitHub.
func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
   group.Handlers = append(group.Handlers, middleware...)
   return group.returnObj()
}

可以发现,在这个Use函数中,只是将middleware函数添加进自身的handlers里,那么什么时候才添加进我们第一点的tree呢,结合上面第一点的分析,我们不难猜出,是在engine.addRoute的时候,接下来我们结合源码一步一步分析

既然Use函数不会注册进tree里,那么我们再来看看示例代码中,有个GET函数:

  group.GET("/welcome", func(c *gin.Context) {
     
      all, err := io.ReadAll(c.Request.Body)
      if err != nil {
         return
      }
      all = []byte("hello world")
      c.Writer.Write(all)

      c.Writer.Header().Set("Set-Cookie", "quic=aaaaaaa")
   })

那我们就去GET函数中看看,可以发现,整个流程是和第一点我们分析的是一模一样的,那么,什么时候路由器组把内容添加进route tree呢?

记得第一点的时候我们留了一点吗?没错,就是combineHandlers函数

func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
   ...
   handlers = group.combineHandlers(handlers)//链式指针结合
  ...
}

进入combineHandlers,我们看看

func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
   finalSize := len(group.Handlers) + len(handlers)
   assert1(finalSize < int(abortIndex), "too many handlers")
   mergedHandlers := make(HandlersChain, finalSize)
   copy(mergedHandlers, group.Handlers)
   copy(mergedHandlers[len(group.Handlers):], handlers)
   return mergedHandlers
}

解释一下,这段代码大致就是,将这个group的handlers与将要添加的handlers结合,group的handlers添加至首部,添加的handlers在尾部然后返回

从这里我们不难看出,其实在gin中,是没有显式区分middleware和handler的,只是按照函数执行的先后顺序进行区别,你在handler链的前面,那你就属于middleware,否则就是handler

3. 上下文管理

在web框架中,上下文一直都是一个很重要的对象,从JSP时代,上下文的概念就叫做域,有各种各样的域:request域,session域,application域......到golang中,以context(上下文)来替代域的概念,但二者本身概念十分相似:都是在http请求中,以上下文对象来在一个高度内聚的模块之间来传递消息

类似如图:

image.png

从上图可以看出来,在框架内,一个上下文的生命周期最短也必须是一次http请求,那么,既然Context有生命周期,那么这个生命周期的生成和销毁(当然还有其他不同状态,但最少得有这两个状态)就应该是框架所需要考虑的事情了

在gin框架中,是以golang原生的sync.pool来进行上下文管理的,这点我们可以看到gin文件中,有个叫ServeHttp的函数,这个就是gin框架开始处理一个http请求的地方

源码如下:

// ServeHTTP conforms to the http.Handler interface.
func (engine *Engine) ServeHTTP(w http.ResponseWriter, req *http.Request) {
   c := engine.pool.Get().(*Context)//从池中拿去一个context
   c.writermem.reset(w)//设置httpWriter
   c.Request = req//设置context中的request对象
   c.reset()//重置context中的其他内容

   engine.handleHTTPRequest(c)//交由路由树处理httprequest

   engine.pool.Put(c)//将上下文放回pool
}

其中*Context 就是gin中的context,我们可以从上面代码中的注释可以看出来,在gin框架中,context的管理是直接交给golang底层的pool来进行管理的,并且context逻辑上的生命周期在下一次被取出的时候才算被销毁,但是在其他框架,比如SpringBoot中,上下文的管理是由IOC来进行管理的

总结

以上我们分析了如何从一个框架的底层去查看和思考这个框架,也了解了各个框架的底层基本逻辑和数据结构,希望对大家学习和理解框架的内容有启发

以上包含了部分作者的主观理解和目前的技术思路,如有错误请大家多多包涵和指出 orz