Gin的中间件
AOP和洋葱模型
面向切面编程(AOP)
什么是面向切面编程AOP? - 知乎 AOP(Aspect-Oriented Programming,面向切面编程)是一种编程范式,用于将横切关注点(Cross-cutting Concerns)与主要业务逻辑分离。横切关注点是指那些存在于应用程序中多个不同部分的功能,例如日志,鉴权等,它们跨越多个模块和对象。
编程范式还有很多,接口,函数式编程都是比较常见的范式。
总而言之,AOP 的核心思想是通过将横切关注点从主要业务逻辑中分离出来,然后以模块化的方式进行管理和重用。这样可以提高代码的可维护性、可扩展性和可重用性。绝大多数的现代框架的中间件模块都是基于AOP的思想来设计的。
洋葱模型
洋葱模型(Onion Architecture)是一种软件架构模式,旨在实现松耦合、可维护和可测试的应用程序。该模式的名称来源于洋葱的结构,意味着应用程序的不同层次围绕核心业务逻辑进行层层包裹。洋葱模型的核心思想是将应用程序分为多个层次,每个层次具有不同的职责和依赖关系。这些层次按照一定的规则进行组织,以便实现解耦和可测试性。
这些层次按照特定的顺序进行组织,形成了一个层层包裹的结构,其中内部层次依赖于外部层次。应用程序的请求从外部层逐渐向内传递,直到达到领域层,然后再通过相同的层次返回结果。
- 外部层(Outermost Layer):这是应用程序的最外层,包含与外部系统进行交互的接口和适配器。这些层次处理与用户界面、外部服务和数据存储等的交互。
- 应用服务层(Application Services Layer):这一层次包含应用程序的核心业务逻辑。它通过调用领域层来实现具体的业务操作,同时负责协调不同的领域对象。
- 领域层(Domain Layer):领域层包含了应用程序的领域模型和业务规则。它是应用程序的核心,包含了实体、值对象、聚合根和领域服务等。领域层通常是最内层的一层,不依赖于任何其他层次。
- 基础设施层(Infrastructure Layer):基础设施层提供了与外部系统的交互和数据访问等基础设施功能。它包含了与数据库、消息队列、缓存、文件系统等的交互,同时也包含了日志记录、异常处理和配置管理等功能。
洋葱模型的优点包括:
- 解耦性:洋葱模型本质上依然是加一层的思想,只是它的加一层非常优雅,非常的轻量。层级之间没有较多的关联,也不存在侵入的问题。
- 可测试性:这也是洋葱模型最有魅力的一个特点。每个模块的职责领域划分清楚之后,只需要单独测试本模块的功能,每个模块都符合要求,之后再进行集成测试即可。
- 可维护性:清晰的分层结构和职责分离使得代码更易于理解和维护。无论是新增功能还是优化原有功能都会变得非常简单。大改小动,小改不动。
洋葱模型是目前市面上常见的中间件的首选方案,也是比较好用的方案。
当然了,洋葱模型也可以用来做其他的组件或者功能。我们在微服务里会用到领域驱动模型(DDD),洋葱模型是其中一种具体的实践。
Gin的中间件
源码阅读
Gin的中间件源码非常少,并且散落在多个地方。总的来讲,gin的中间件主要是函数链+洋葱模型来实现的。
这一块的代码学习会和路由器相关的代码有重复的地方。
函数链
// HandlerFunc 定义了一个中间件函数的标准方法,入参必须带有Context,这是我们前面讲过得gin框架自己定义的Context
type HandlerFunc func(*Context)
// HandlersChain 核心,通过切片的方式存储函数链
type HandlersChain []HandlerFunc
// Last 函数链的唯一方法。获取链上最后一个函数,通常情况下最后一个函数就是我们自己定义的真正要处理业务的函数。
func (c HandlersChain) Last() HandlerFunc {
if length := len(c); length > 0 {
return c[length-1]
}
return nil
}
//另外需要注意的是,函数链作为RouterGroup的一个属性,存储在路由器的节点上。
//后续我们调用的use方法,都是RouterGroup的所提供的方法。
type RouterGroup struct {
Handlers HandlersChain //函数链
basePath string
engine *Engine
root bool
}
函数链的构成非常简单,底层就是一个切片,对函数链的操作直接变成了对切片的操作。当我们新增一个中间件时:
func (group *RouterGroup) Use(middleware ...HandlerFunc) IRoutes {
//非常简单的操作,将中间件方法Append到Handlers中
group.Handlers = append(group.Handlers, middleware...)
return group.returnObj()
}
// Group 该方法会返回一个全新的RouterGroup,并且会继承父Group的一些属性
func (group *RouterGroup) Group(relativePath string, handlers ...HandlerFunc) *RouterGroup {
return &RouterGroup{
//将多个中间件按照顺序组合到一起,底层使用的是内存copy,我们讲到中间件时会展开
Handlers: group.combineHandlers(handlers),
//将父级的路径组合到一起
basePath: group.calculateAbsolutePath(relativePath),
//把主引擎的指针继续传递下去
engine: group.engine,
}
}
//这里需要重点说下,父Group的中间件是如何传递给子Group的
// combineHandlers 将一个函数链和父级的函数链结合,返回一个新的函数链
func (group *RouterGroup) combineHandlers(handlers HandlersChain) HandlersChain {
//计算当前函数链的总长度
finalSize := len(group.Handlers) + len(handlers)
//判断函数链的总长度是否大于最大长度,默认是63
assert1(finalSize < int(abortIndex), "too many handlers")
//定义一个新的切片,直接设置需要的切片容量,后续不会再触发扩容操作(切片使用的一个小技巧)
mergedHandlers := make(HandlersChain, finalSize)
//先将父级的函数链复制进来,注意这里用的是copy,既完成内存复制还能保证顺序
copy(mergedHandlers, group.Handlers)
//再将新的函数链复制到父级函数链之后
copy(mergedHandlers[len(group.Handlers):], handlers)
//如此一来,新的函数链内存是新的,顺序也符合预期。
return mergedHandlers
}
至于新增一个路由,则会在当前group的函数链的基础上再加上业务方法构成新的函数链:
// handle 新增一个路由方法时会调用这个方法,它把最终生成的函数链保存在路由树的叶子接点上
func (group *RouterGroup) handle(httpMethod, relativePath string, handlers HandlersChain) IRoutes {
//获取当前的绝对路径
absolutePath := group.calculateAbsolutePath(relativePath)
//将业务方法 handlers 和Group中的中间件函数链组合起来
handlers = group.combineHandlers(handlers)
//将方法 ,路径,函数链添加到路由树里
group.engine.addRoute(httpMethod, absolutePath, handlers)
return group.returnObj()
}
洋葱模型
gin 框架的洋葱模型主要是通过Context中的 Next() 和 Abort() 这两个方法来实现的:
//我们在路由中讲到过,请求过来后会经过handleHTTPRequest方法,当匹配到一个路由时,会走下面这段逻辑:
func (engine *Engine) handleHTTPRequest(c *Context) {
...
//匹配到一个路由后,如果函数链不为空
if value.handlers != nil {
//把函数链赋值给当前Context
c.handlers = value.handlers
//把绝对路径赋值给当前Context
c.fullPath = value.fullPath
//核心,开始按照函数链中的顺序执行,Context可以参与到函数链的每一步操作中
//另外的,Gin框架中的核心几乎都离不开Context。
c.Next()
//丰富一下Response的Header
c.writermem.WriteHeaderNow()
return
}
...
}
// Next 函数链操作的核心,也是洋葱模式的一种实现方式
func (c *Context) Next() {
//index 初始值为-1,第一次后为0,为函数链第一个
c.index++
//遍历执行所有的函数链
for c.index < int8(len(c.handlers)) {
//核心,c.handlers[c.index]其实就是函数链里的第c.index个函数,(c)则是将Context作为参数传值
//正是这一步在执行中间件方法和业务方法。
//如果某个中间件中调用了Abort(),会直接终止for循环
c.handlers[c.index](c)
//将索引向后移
c.index++
}
}
// Abort 用来将函数链的索引设置为最大值,会终止函数链的执行
func (c *Context) Abort() {
c.index = abortIndex
}
运行流程
我们举一个例子来理解下中间件的运行:
func TestMid() {
r := gin.Default()
//在这里使用了三个中间件
r.Use(mid1(), mid2(), mid3())
r.GET("/abc", func(context *gin.Context) {
fmt.Println("关注香香编程喵喵喵")
})
_ = r.Run(":8081")
}
func mid1() gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println("mid1 before")
//先执行next,后执行after
c.Next()
fmt.Println("mid1 after")
}
}
func mid2() gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println("mid2 before")
c.Next()
fmt.Println("mid2 after")
}
}
func mid3() gin.HandlerFunc {
return func(c *gin.Context) {
fmt.Println("mid3 before")
//这里不执行next
//c.Next()
fmt.Println("mid3 after")
}
}
其打印结果为:
mid1 before
mid2 before
mid3 before
mid3 after
关注香香编程喵喵喵
mid2 after
mid1 after
如果放开 mid3() 的c.Next() 打印结果为:
mid1 before
mid2 before
mid3 before
关注香香编程喵喵喵
mid3 after
mid2 after
mid1 after
画个图来理解下整个中间件的构成:
Gin自带的中间件
Gin自带的中间件有两个,当我们使用 Default() 方法时会默认使用 Log 和 Recover 中间件。而且这两个中间件非常典型的中间件用法。
Log
// LoggerWithConfig 这个方法时Gin Log中间件的底层实现。
func LoggerWithConfig(conf LoggerConfig) HandlerFunc {
//这里省略了大量加载配置信息的代码
...
//log 中间件的核心流程
return func(c *Context) {
// 记录开始时间、请求路径、参数 后续打印用
start := time.Now() //收到请求的时间
path := c.Request.URL.Path
raw := c.Request.URL.RawQuery
//开始执行后续的函数
c.Next()
// 根据配置信息,跳过特定的路径
if _, ok := skip[path]; !ok {
//初始化日志参数
param := LogFormatterParams{
Request: c.Request,
isTerm: isTerm,
Keys: c.Keys,
}
//完善日志参数
param.TimeStamp = time.Now() //请求结束时间
param.Latency = param.TimeStamp.Sub(start) //请求执行了多久
param.ClientIP = c.ClientIP() //IP
param.Method = c.Request.Method //请求类型
param.StatusCode = c.Writer.Status() //返回的状态码
param.ErrorMessage = c.Errors.ByType(ErrorTypePrivate).String() //错误信息
param.BodySize = c.Writer.Size() //返回内容的大小
if raw != "" {
path = path + "?" + raw
}
param.Path = path //路径
//最后打印的日志信息,out就是格式,formatter用来编排日志参数
//gin log的格式和内容与nginx在思路上有一些相似的地方
fmt.Fprint(out, formatter(param))
}
}
}
它最后打印出来的东西,是我们最为熟悉的:
Recover
// CustomRecoveryWithWriter gin框架默认的recover中间件底层的方法。大多数的框架都会有一个最外层的recover中间件,以确保出现panic时,服务还能正常使用。
func CustomRecoveryWithWriter(out io.Writer, handle RecoveryFunc) HandlerFunc {
//入参中的 handle 就是下面的 defaultHandleRecovery() 方法
//这里预先定义好了错误日志的格式
var logger *log.Logger
if out != nil {
logger = log.New(out, "\n\n\x1b[31m", log.LstdFlags)
}
return func(c *Context) {
defer func() {
//panic 和 recover 的标准用法,没啥聊的
if err := recover(); err != nil {
//检查一下当前链接是否断开,无论是什么原因,客户端主动端或者服务端异常断的,都会走这里
var brokenPipe bool
//net.OpError 是net包里自定义的错误类型,也是net请求流程中常用的错误类型
if ne, ok := err.(*net.OpError); ok {
//os.SyscallError 系统调用时的常见错误
var se *os.SyscallError
if errors.As(ne, &se) {
//这里做了下判断 net的错误是否是系统级的错误
seStr := strings.ToLower(se.Error())
if strings.Contains(seStr, "broken pipe") ||
strings.Contains(seStr, "connection reset by peer") {
brokenPipe = true
}
}
}
if logger != nil {
//记录错误信息
...
}
if brokenPipe {
//如果链接已经断开了,就没必要返回任何东西,直接终止掉后续的所有请求。
c.Error(err.(error))
c.Abort()
} else {
//如果链接没有断,允许服务端做点操作,给了一个挽救的机会。
//默认情况下,什么事都不做。直接调了defaultHandleRecovery 返回500
handle(c, err)
}
}
}()
//这里是next的另一种情况,前置有逻辑,后置什么也没有。直接就进入函数链的下一个执行
c.Next()
}
}
// defaultHandleRecovery recover的默认处理方法,逻辑很简单,直接结束函数链,并且返回500错误。根本不处理错误
func defaultHandleRecovery(c *Context, err any) {
c.AbortWithStatus(http.StatusInternalServerError)
}
其他框架中的中间件
中间件有一个发展过程,一开始大家采用的方式不尽相同,最后都普遍选择了洋葱结构。在古早时期,那时候框架发展的还不是特别完善,比如早期的PHP框架会使用 _before 和 _after 搭配面对对象中的继承和多态来实现中间件的功能,这种思想类似拦截器。
到了最近几年,基本上所有的框架都选择使用函数链+洋葱结构的方式来实现中间件,只是具体的实现方式有一些差别:
- Gin框架使用的是函数链,在通过索引遍历的方式逐个执行中间件。结构简单,便于理解和维护。
- Kratos则是使用匿名函数注入的方式,将函数一层套一层的执行。有很强的抽象性,代码量很少。需要理解匿名函数的特征和调用方式。
总结
正常情况下,实现一个可靠的中间件大约有四种思路:链式调用,拦截器,注册中心,AOP。实际上,前三种是方案,第四种更偏向思想。
Gin使用的是链式调用+洋葱结构的方式,并且默认引擎会自动加载Log和Recover这两个非常典型的中间件。在具体业务中,我们可以利用中间件实现非常多的业务功能:
- 用户登录,验签,鉴权等。
- 入参的安全校验。
- 访问日志和链路追踪。
- 限流,降级或者其他安全策略等。
转载自:https://juejin.cn/post/7243357900940329018