探索 Hertz 中间件:用法、生态及实现原理

原创
2023/07/26 10:58
阅读数 327

CloudWeGo Study Group 是由 CloudWeGo 社区发起的学习小组,开展以 30 天为一期的源码解读和学习活动,帮助新成员融入社区圈子,和社区 Committer 互动交流,并学习上手 CloudWeGo 几大框架项目。目前 CSG 第五期—— Hertz 中间件实战已经圆满结束!

本期活动期间安排了 3 期直播分享,主题分别为:

  • 探索 Hertz 中间件:用法、生态及实现原理
  • Hertz-Session:解读 Hertz Middleware
  • 快速掌握适合自己业务的 Hertz 中间件

本文为 CSG 第五期第一场直播中字节跳动基础架构服务框架团队研发工程师 范广宇分享内容。

回放链接:https://meetings.feishu.cn/s/1iz6pbraqy51e?src_type=2

01 嘉宾介绍

针对 Hertz 中间件进行介绍,包括 Hertz 中间件的分类、梳理、简介与使用场景,分享 Server 端与 Client 端中间件链的实现原理。

本期分享的内容主要分为以下四个部分:

  1. Hertz 中间件
  2. Hertz 拓展的中间件
  3. Hertz 中间件的实现原理
  4. 总结

02 Hertz 中间件

今天的分享主要将全面介绍 Hertz 中间件。首先,我会带领大家了解 Hertz 中间件的整体概述。接下来,我会讲解如何拓展核心中间件,以及如何自定义一个满足我们需求的中间件。最后,我将介绍 Hertz 中间件的实现原理,帮助大家更好地开发一个属于自己的通用中间件。

Hertz 介绍

在介绍 Hertz 中间件之前,我先简单地介绍一下 Hertz 项目。目前, Hertz 是字节跳动内部使用最广泛的高性能 Golang HTTP 框架,具有高易用性、高性能和高拓展性等特点。 Hertz 的架构采用分层设计,在保证了各个层级功能内聚的同时,通过层间之间的接口实现灵活拓展的目的。

从下到上,大家可以看下面这张图,依次是 Hertz 的传输层、协议层、路由层以及应用层。

其中:

  • 传输层:负责与网络通信相关的工作,目前 Hertz 使用的是 CloudWeGo 自研的高性能网络库 netpoll。此外,我们还支持无缝切换到其他网络库,如 go-net 等,用户甚至可以自定义一个网络库并实现我们的接口,然后将其嵌入到 Hertz 项目中。
  • 协议层:目前 Hertz 内置了 HTTP/1.1协议的实现。此外,我们还以插件的形式拓展了 HTTP/2 以及HTTP/3 的协议。这部分如果大家有使用兴趣的话,可以在官网上找到使用案例。
  • 路由层:除了支持基本的路由能力之外,同时还支持优先级路由、参数路由等多种功能。
  • 应用层:负责直接与用户交互,提供了大量丰富且易用的 API。

Hertz 中间件就位于应用层。先简单解释一下中间件:中间件是一种常见的软件设计模式,它可以帮助快速地将应用程序的功能分解成可复用部分,提高代码的可维护性和可拓展性。

什么是中间件?

Hertz 利用洋葱模型的机制来实现中间件,将多个中间件按添加顺序组成一条链,中间件链调用的顺序是从前到后,再到用户的业务逻辑,执行完业务逻辑后,还会从后到前执行一个后置逻辑。这种执行逻辑形象地类比为一根针插入到了一颗洋葱里,会从外到内依次经过洋葱的每一层。并且在穿过最内层之后,还会由内到外的穿出去,从而使得每一层洋葱被穿透两次。这映射到 Hertz 中间件的前置逻辑和后置逻辑,因此实现模型又叫洋葱模型。

下图中,按照洋葱模型的执行顺序,在到达最终的业务逻辑之前,请求可能会经过注册管理、重定向等操作,最终进行业务处理。业务处理之后,还会走一个后置逻辑,相当于将之前的中间件按照反向的顺序又执行一遍。

  • 中间件设计模式:功能分解,提高可维护性和可扩展性。
  • 洋葱模型:中间件处理机制,逐层处理请求和响应,自定义拓展
  • 执行顺序:RM->SCR->EH->CM->SM->RM->PA->“业务逻辑”->PA->RM->SM->CM->EH->SCR->RM

Hertz 中间件的使用

Hertz 框架中间件主要作用是在处理 HTTP 的请求过程中,

可以对请求进行预处理请求或者在请求处理请求之后,对HTTP 响应进行统一的处理,以实现某些特定的功能。主要包括以下四点:

  1. 预处理请求:中间件可以在请求到达业务逻辑之前对请求进行预处理,例如解析请求参数、验证请求头、权限等。
  2. 后处理响应:中间件可以在请求处理完成后对响应进行后处理,例如添加响应头、修改响应数据、记录日志等。
  3. 实现逻辑复用:中间件可以将某些通用逻辑封装起来,以达到复用的目的。例如,认证中间件可以用于多个业务逻辑,避免在每个业务逻辑中都写认证逻辑。
  4. 实现功能扩展:中间件可以用于实现框架本身不具备的功能,例如跨域资源共享、请求解压缩、性能分析等。

全局中间件

注册在 Hertz 引擎;对所有请求均生效

使用 Hertz 中间件需要了解全局中间件和路由中间件概念。全局中间件会对所有打到 server 的请求进行处理,全局中间件直接注册在 Hertz 引擎上。

大家可以观察下图的示例。在使用 new 方法创建一个 Hertz 引擎后,我们可以使用 use 方法来注册中间件。在此示例中,我们添加了一个 recover 中间件和一个 accesslog 中间件,它们将对所有请求进行处理。recover 中间件负责在执行过程中,如果遇到意外的panic,将恢复应用程序的运行,避免 server 崩溃。通常,recover 中间件作为第一个全局中间件添加到 Hertz 引擎中。如果代码中的 server.new 替换为 server.default ,引擎也将自动为你注册一个 recover 中间件。

第二个中间件是 accesslog ,用于记录 server 的访问日志,包括请求的URL、HTTP方法等。这些信息有助于对server进行性能调试和问题排查。

组中间件

注册在路由组上;对该路由组的所有请求进行处理。

接下来,介绍一下 Hertz 组中间件的使用。组中间件是注册在 Hertz 路由组上的中间件,相对于全局中间件,其作用范围更小,只会对匹配当前路由组下的请求进行处理。下图的示例展示了组中间件的使用方法,与全局中间件类似,只需在 Hertz 引擎上创建一个路由分组即可。

在这个例子中,我们创建了一个名为 v1 的路由组,并添加了 Basic 和 Auth 中间件。如果请求的路由 以 v1 开头,那么这个请求将会通过 Basic 和 Auth 中间件进行处理。

BasicAuth 中间件是一种最基本的认证中间件,允许用户通过提供用户名和密码来访问后端资源。

Client 中间件

注册在 Hertz client 上;该 client 发的请求均生效

此外,还要介绍 Hertz 的 Client 的中间件。 Hertz 除了在 server 端提供的中间件之外,我们还在 Client 端引入了中间件的概念。 Client 端中间件的使用方式与 server 端全局中间件类似,它们也会注册在 Hertz 的 Client 端上,并对该 Client 端发出的所有请求生效。

我们的 Client 端中间件还提供了一个名为 addLast 的方法,它允许注册一些总结性的中间件。这些总结性的中间件将始终在请求处理流程的最后执行。通常情况下,我们会在在发请求之前执行一次该中间件,会进行一些最终请求的校验等操作。通过这种方法,我们可以提高 client 中间件的灵活性。

03 Hertz 拓展的中间件

Hertz 自定义一个 server 中间件

接下来,我们来了解一下 Hertz 扩展的中间件。首先,我们需要知道如何定义 Hertz 中间件,这是拓展中间件的第一步。

看下图, Hertz 中间件的函数签名与handler函数相同,它们都接收一个标准的context和 Hertz 的 request context,然后在中间件中对这些 context 进行进一步处理。

  1. 中间件函数签名:app.HandlerFunc
  2. 前处理逻辑:c.Next() 之前的处理逻辑,业务handler之前的处理逻辑在这里进行
  3. 后处理逻辑:c.Next() 之后的处理逻辑,业务handler处理之后的处理逻辑在这里进行
  4. c.Next(): 调用下一个中间件或者业务handler

中间件的执行逻辑分为两部分:第一部分是前置处理逻辑,在 c.next 执行之前处理,这些逻辑会在业务handler执行前被执行。第二部分是后置处理逻辑,在 c.next 之后处理,这些逻辑会在业务handler处理后执行。

大家注意,示例中还有一个 c.next 方法,它非常重要,负责调用下一个合作的中间件,是 Hertz 中间件洋葱调用模型的核心方法。稍后我们会进一步讲解 c.next 的实现原理。现在,我们先回答两个关于定义和使用中间件时常见的问题:

第一个问题:是否可以省略 next() 方法?

答案是可以,但需要注意两个问题。首先,省略next()方法后,中间件逻辑将全部变为前置处理逻辑,在中间件中,我们将无法对业务handler进行后置处理,比如添加一些header或修改响应输入数据等。其次,如果省略next()方法,当前中间件的调用会与整体调用的函数栈分离。

例如,在下图中,中间件B省略了next(),业务handler的函数调用链变为A到C,再到业务handler。这种分离可能导致一些问题,如无法执行recover中间件中的特殊操作。例如刚才提到,recover中间件负责恢复程序中的意外panic,如果recover中间件中的next()被省略,它将无法恢复当前链上的panic。因此,我们建议不要省略next()方法,因为加上它不会对后置逻辑产生影响。

  • 省略后,该中间件的逻辑全部为 “前处理逻辑”,无法在业务 handler 后对响应进一步处理
  • 省略后,当前中间件的调用与 handler 链的函数调用栈分离

第二个问题:如何提前终止中间件的调用?

有时候,我们需要对请求进行鉴权操作。当请求的用户权限不足时,我们可能需要禁止该请求进一步处理。因此,我们提供了一个 abort() 方法,用户在调用该方法后可以直接结束中间件调用,不再执行后续处理。此外,我们还提供了 abortWithStatus() 和 abortWithMsg() 等方法。它们除了可以终止后续中间件调用外,还能设置响应状态码和将错误信息设置到响应包等能力。

  • Abort(): 直接终止后续中间件的调用
  • AbortWithStatus():终止后续中间件的调用,并设置响应“状态码”
  • AbortWithMsg:终止后续中间件的调用,设置响应“状态码”,并将错误信息设置都响应 body

Hertz Server 中间件的拓展

现在让我们来看看 Hertz 中间件有哪些拓展,即 Hertz 中间件的生态。目前, Hertz 已经提供了一个非常丰富的中间件生态,这些中间件的实现都统一放在了一个 hertz-contrib 仓库下面。如果有需要,大家可以查看这个仓库以了解相关的源代码。

接下来,我将简要介绍一些常用中间件。首先是关于认证和鉴权相关的中间件,目前包括 JWT、TLS、Custom 等中间件。这些中间件在不同场景下可能有不同用途。例如:

  1. JWT 中间件:通过在请求 header 中传递 JWT token 来实现用户身份授权和验证。通常适用于前后端分离的程序。
  2. Casbin 中间件:根据用户的角色和权限控制用户的访问权限。通常适用于权限控制较复杂的程序。
  3. Passport 中间件:验证用户身份,使用更加安全的加密算法。相比于 JWT,它更适用于对安全性要求较高的应用程序。

此外,我们还提供了一些网络安全相关的中间件,如 CSRF、CORS 等。其中,CORS 中间件较为常用,它可以防止某些恶意程序发起攻击。我们还提供了许多与性能相关的中间件,它们可以帮助降低网络开销。

我们还将一些HTTP通用能力扩展到中间件中,例如 Session、Gzip和SSE 等。虽然这些功能属于HTTP范畴,但它们的实现不适合直接放在框架内,所以我们将它们扩展到了中间件中,其中主要包括 session 和 SSE 的能力。

此外,在可观测性方面,我们扩展了 OpenTelemetry 和 OpenTracing 等功能,方便业务人员更好地追踪和观测请求。Hertz 还集成了一些与服务治理相关的组件,如Opentracing、RateLimiter 等,以更好地进行服务治理工作。最后,我们还有一些通用功能,如 Recover 中间件和国际化中间件,可帮助在不同场景下快速完成通用任务。如果大家对中间件有需求的话,可以参考提供的表格来检索想要使用的中间件,也可以根据需求为 Hertz 贡献一些通用能力的中间件。我们非常欢迎大家参与Hertz 中间件的开发和建设。

04 Hertz 中间件的实现原理

Hertz server 中间件的实现原理

在介绍 Hertz 的中间件生态之后,接下来将带大家了解 Hertz server 端中间件的实现原理。在讲解 Server 端中间件的实现原理之前,我回顾一下中间件的使用方法和定义方式。

  1. 顺序注册
  2. 从外到内,从内到外

首先,它的使用方法是按照中间件的顺序进行注册。其次,中间件的执行顺序是按照定义顺序从前到后执行前置逻辑,然后在处理完业务逻辑之后,从后到前处理后置逻辑。之所以能实现这样一个洋葱模型的调用逻辑,主要源于我刚刚提到的 c.next() 这个方法。

接下来我们看一下 c.next() 方法具体实现了哪些功能以及它的执行过程。从下图可以看到,c.next() 方法非常简单,它只操作了 index 和 handlers 这两个变量。

其中 handlers 是一个 Hertz HandlerFunc 的切片,根据用户使用顺序注册的中间件都保存在其中,它表示用户定义的中间件和用户定义的handler组成的中间件处理函数链。其最后一个元素表示真实的业务处理逻辑 。

next 方法的执行过程,首先会对 index 进行自动操作,确保 index 调整到当前中间件的序号。之所以要首先进行一次自增操作,是因为我们的 index 和 next 的方法一般都是在上一个中间件里面发起调用,所以在上一个中间件里,其实 index 是上一个中间件的序号,所以首先要对它进行一次自增,才能让它正确定位到将要执行的中间件。

当获取下一个中间件的序号后,我们就可以从 handlers 中拿到对应的中间件函数并执行它。当下一个 handler 执行 next() 函数时,后续的中间件方法都可以被调用。当 handler 方法结束且回溯逻辑执行后,我们同样会对 index 进行自增操作,以判断是否还需要执行其他中间件。

从整个框架的角度来看,中间件链的调用顺序如下:

  1. 框架发起第一次 Next() 调用
  2. 中间件先执行前置逻辑
  3. 中间件发起 Next() 调用
  4. 中间件执行后置逻辑
  5. 中间件退出

首先,框架会发起第一次 next() 操作,此时 index 的值为-1。经过此次调用,会触发第一个中间件的执行,而第一个中间件会先执行它的前置逻辑。之后,第一个中间件也会调用它的next() 函数,使得 index 的值变为1,从而触发序号为1的中间件的执行。接下来,序号为1的中间件会继续调用 next 函数来拉起序号为2的中间件。

当所有中间件都执行完毕之后,业务 handler 结束后会执行回溯逻辑。这个回溯逻辑会依次执行所有后置逻辑,从而通过 next() 方法完成洋葱模型的调用。

再举一个例子来带大家详细了解一下 next 方法的执行过程。假设我们的中间件链上有两个中间件和一个 handler,这两个中间件的序号分别是0和1,而 handler 的序号是2。整个框架的执行过程如下:

  1. 框架发起第一次 next() 操作,对 index 进行自增,然后调用序号为0的中间件。
  2. 序号为0的中间件执行前置逻辑。此时在中间件内部,index 的值为0。
  3. 序号为0的中间件继续执行 next() 函数,对 index 进行自增,此时 index 变为1。
  4. 在经过第一次的************************next() ************************之后,执行序号为1的中间件的前置逻辑。
  5. 序号为1的中间件同样发起 next() 操作,此时会将 index 的值自增为2,并调用序号为2的中间件(业务 handler )。

当业务逻辑执行完后,代码会返回到中间件1的操作。注意,在回溯时,我们同样会对 index进行自增操作,以获取下一次需要执行的中间件的位置。在这次 index 自增操作时,index的值已经变成了3,对应了步骤7。此时它已经超过了 for 循环条件(序号要小于中间件的个数),因此序号为1的中间件的next方法结束,然后执行后置逻辑,并退出中间件1的执行。

同样,代码逻辑回到序号为0的中间件,它的 next() 方法也会再进行一次自增操作,此时index 的值改变,它也同样超过了循环条件,进而执行中间件0的后置逻辑,最后整个中间件0的执行结束。

最后,整个调用逻辑回到了框架,也就结束了整条中间件链的调用。我们通过这样的逻辑来实现了 Hertz 中间件的洋葱模型。

当我们介绍完了服务器中间件的简单实现过程后,实际上还有两个问题想留给大家思考:

  1. 如果中间件函数里没有调用 “Next()” ,那么其调用逻辑是什么样的?
  2. 如果使用 Hertz Client 端中间件,那么 Client 端中间件的调用过程是否还可以像Server 端中间件一样,通过 index 和 handler 值实现洋葱模型调用?

05 总结

总结一下今天分享的内容:

  • Hertz 中间件的作用、调用顺序以及洋葱模型
  • 如何定义并使用 Hertz 的中间件
  • Hertz 拓展的中间件的分类和介绍
  • Hertz 中间件的实现原理,Next() 如何串联起中间件执行链路

项目地址

GitHub:https://github.com/cloudwego

官网:www.cloudwego.io

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
0 收藏
0
分享
返回顶部
顶部