CloudWeGo Study Group 是由 CloudWeGo 社区发起的学习小组,开展以 30 天为一期的源码解读和学习活动,帮助新成员融入社区圈子,和社区 Committer 互动交流,并学习上手 CloudWeGo 几大框架项目。目前 CSG 第二期——Hertz 框架篇已经正式启动!
本期活动期间将会安排 4 期直播分享,主题分别为:
- HTTP 框架初识;
- 如何利用命令行工具 Hz 快速开发 Hertz 服务;
- CSG 一期源码解读优秀成员分享“如何进行源码解读”;
- 社区 Committer 和 Go 夜读作者分享,如何规划自己的代码学习和提升路径
本文为 CSG 第二期第一场直播中字节跳动架构研发工程师尹旭然分享内容。
01 讲师及内容介绍
本期内容主要是介绍 HTTP 框架的入门,分为以下三个部分:
-
为什么需要 HTTP 协议;
-
协议里面包括什么;
-
协议的对比与展望。
02 为什么需要 HTTP 协议
前言
起初尹师傅每天把自己烤羊肉串的心得体会记录到电脑上,与自己的电脑进行交流。此时记录的只是一些纯文本。
后来村里通网了之后,尹师傅想把这个信息放到网上供大家进行交流和分享,这也是我们做开源的目的之一,共同促进整个社区的进步。我们把两台电脑连起来之后,通过在网线上传输 01 二进制流,就可以进行分享了。
再后来尹师傅也想宣传一下自己的烤肉,让更多的小伙伴来吃,但这个时候只有纯文本已经不足以满足需求了,尹师傅想放一些烤羊肉串的图片、烤羊肉串的教程等等。此时尹师傅开始发愁,文本流传输的都是明文,大家看到之后可以猜测大概含义,那图片应该如何传输呢?对方怎么能将 0101 联想成为一张图片呢?
于是尹师傅查阅了很多资料,发现了 HTTP 并对其加以深入研究。什么是 HTTP 呢?
HTTP 全称叫做超文本传输协议。从名字进行解读,首先它是一个传输协议,其次它的传输数据是超文本。超文本是什么呢?就是超越文本以外的图片、视频等,这些正是尹师傅所需要的。
举个例子,只有我们按照语法造出来的句子对方才能理解,协议也是如此。我们在网络上传输的都是一些 01 数据流,需要按照一定的编码规范进行编码才能够让通信双方理解。
明确协议边界
首先,HTTP 协议需要一个明确的边界。因为协议在网络上是以二进制流的形式进行传输,我们并不知道协议何时开始以及何时结束,因此需要一个明确的边界。
协议元数据
协议元数据需要描述我们传输数据的一些信息。比如我们携带 Text 类型的数据,或者携带一些图片类型的数据等等。
数据部分
这里的数据就是真正要传输的数据,比如 Text 。除了 Text 之外,也可以放入羊肉串 JPG、烤羊肉串教程 MP4 等等这些数据。
03 协议里面包括什么
举一个简单的例子,一个常见的 POST 请求在协议层究竟做了什么?(POST 是一个 HTTP 的请求方法,之后会介绍到)
如图是一个已经完成编码的 HTTP1.1 协议,我们能够很清晰地看到这是一个明文协议,具有较高的可读性,而且大概可以猜出含义。对于我们的请求可以设想一个场景,就是你邀请一个小姐姐去烧烤,我们把这句话编成 HTTP 协议的一个报文。
请求
我们的请求是由三部分组成的,分别是请求行、请求头和请求体。
- 请求行
请求行是由一个 HTTP 的请求方法 POST 、一个 URL 、一个协议版本的描述以及最后的一个换行符组成。
- 请求头
第一行是请求行,紧跟着请求行的就是请求头,请求头大概描述了这个包的一些元数据,它的长度是不固定的,以两个换行符表明协议结束。
请求头这里分为协议相关的请求头和业务相关的请求头。我们图中的请求头具体含义如下:
第一个请求头写的是 Who,表明是谁邀请了这个小姐姐。这里设定邀请人为 Xiaoming,因此当这个请求传送给小姐姐的时候,她就明白了这是来自 Xiaoming 的一个请求。
第二个请求头 Content-Type 表明数据要按照怎样的格式进行解析。这里 Text/Plain 说明是按照纯文本格式进行解析。
请求头中有一个非常关键的字段 Content-Length,它描述了我们下一部分的请求体的长度。如果 Content-Length 标注有误,比如 Content-Length 标注过短,小姐姐就无法理解请求的含义;如果 Content-Length 标注过长,小姐姐会以为请求没有讲完,因此可能会一直等待下半部分。
- 请求体
请求体就是数据部分。如图所示,“How about we go to a barbecue this weekend?” 就是请求体。
完成编码后,小姐姐按照编码规范进行解码就可以获得请求的具体含义。下面是小姐姐的回复,看上去是和请求非常相似的,这就是与请求所对应的响应。
响应
响应是由响应行、响应头和响应体组成的。
- 响应行
响应行和请求行非常类似,但是也有一些略微的区别。首先,它是以一个 HTTP 协议版本开头的,不需要再放一个 HTTP 方法;其次,区别在于状态码,如图所示是一个 200 OK 的状态码,后面也会具体介绍一下不同状态码对应的不同含义。
- 响应头
响应头描述了响应的元数据。图中的 Server 是 CloudWeGo 开源的 HTTP 框架 Hertz,Date 是做出响应的时间。
- 响应体
这里的响应体就是“小姐姐愿意一起烧烤”。同样地,响应头里也有一个非常关键的字段 Content-Length , 这个和请求头里的 Content-Length 意义是一样的,都是描述这个数据的具体长度。
总结
请求和响应其实都是由请求行、状态行以及请求头、响应头和最后的请求体、响应体这三部分组成。请求行和状态行是固定的一行,请求头和响应头的行数不固定,以两个换行符作为结束,之后就是请求体和响应体。
- 请求行是由三部分: HTTP 方法名、URL和协议版本组成。
常见的方法名比如 GET 是 HTTP 0.9 (HTTP 协议的第一个版本是从 0.9 开始的)中唯一一个方法;HTTP 1.0 扩充了 HEAD 和 POST ;HTTP 1.1 又陆续进行了扩充。最后的 PATCH 其实是在 HTTP 1.1 之后进行的一个扩充,但是因为它使用的比较广泛,所以也列在了这里。
不同的方法名其实是有一些不同的语义的,比如 GET 是获取,POST 是上传, PUT 是更新, DELETE 是删除等等。
- 状态行也是有类似的三个部分:协议版本、状态码、和状态码描述。
协议版本现在用的最广泛的就是 H1 协议。状态码在上述例子当中小姐姐回到的是一个 200 的状态码,200 就是我们最想见到的一个状态码,表明我们的请求是成功的。同时这个状态码也是比较少见的,因为定义成功之后,网页可能不会给大家就展示一个 200,而是会把里面的内容展示出来,相反大家可能经常会见到 4 开头的错误,比如 404 not found 表示所在资源没有找到。
- 请求头和响应头都分为两种:协议约定和业务相关。
“协议约定”类中,在上述例子提到了一个非常重要的字段 Content-Length;
“业务相关”类中,比如刚刚提到的 Who,即“是谁发送的这个请求呢”,是 Xiaoming。
如何用 Hertz 实现
针对上述 HTTP 协议的例子,如果要用一个软件或者一个网页来模拟是什么样子的呢?这里选用了一个非常常用的调试工具叫 Postman 进行一个模拟的请求。
首先在下图第一个红框位置,选好我们的 HTTP Method,就是 POST。在这里写好 URL 和请求地址,以及我们的数据所在的对应位置。之后我们可以点击 Send 按钮进行发送。发送完毕后就可以看到我们收到了小姐姐的一个响应,这就是来自 Server 的一个响应。
如果我们选择使用 Hertz 实现这个代码,只需短短几行就可以实现(如下图所示)。
第一步,初始化 Hertz 的 Engine;
第二步,选择一个对应的 HTTP Method 和注册的路由地址;
第三步,将真正的处理函数注册到这个路由上。
这个项目就可以运行了。
那么如此简单的一个代码背后隐藏了什么东西呢?这就涉及到请求背后的流程与运行逻辑。
请求流程
1. 业务层。业务层的作用是使用框架提供的 API 完成业务逻辑,框架提供 API 的易用性很大程度上决定了开发体验。
2. 服务治理层/中间件层。在编写完业务逻辑之后,会进入到服务治理层,服务治理层包括熔断、限流等等。具体举例如下:
- 熔断:比如说你经常邀请小姐姐去吃烧烤,但是邀请了几次对方没有回应,于是你下一次选择不再进行邀请,这个就是熔断。对应到微服务体系,如果某一个下游一直出错,我们可以选择不请求这个下游。
- 限流:对于 Server,也就是小姐姐,如果你的请求又要吃烧烤,吃完烧烤还要去看电影等等,小姐姐为了防止压力过大,会先拒绝几个请求,这就叫做限流。
服务治理层其实是依托于中间件层的,中间件层是和请求级别绑定的,有前处理逻辑和后处理逻辑。它的存在是为了将一些核心逻辑和通用逻辑区分开。除了服务治理相关的逻辑外,中间件还可以有很多其他的用处,比如 Recovery 捕获异常的中间件、Metrics 组件获得请求的耗时、以及链路追踪。
那么链路追踪具体是什么呢?比如小明由于腼腆不敢直接邀请小姐姐吃烧烤,于是找了朋友老王帮忙询问,但老王一直没有回应。那么是老王没有帮忙询问还是询问之后小姐姐没有回应呢?此时对于小明而言无法得知的。因此我们可以用链路追踪查看具体情况,现在 Hertz 也集成了 OpenTelemetry 和 OpenTracing 两种社区中非常主流的链路追踪实践。
3. 协议编解码层。经过中间件层后,就可以进入协议编解码层。协议编解码层就是上述例子当中的明文协议,除此之外,还可能会涉及到其他的一些协议。例子中使用了 H1 协议,基于 H1 的不足,现在逐步出现了 H2、H3 等。
4. 传输层。编码完成后即可传输编码后的数据 Server,小姐姐按照对应的流程进行解码就完成了。
5. 路由层。相比请求的流程,小姐姐这边会多一个路由层。小姐姐可能会同时处理多个请求,针对不同的事情要用不同的优先级和不同的处理方式,这个时候可以通过路由判断针对不同的请求采用怎样的处理方式。
基于此,我们的框架需要提供以下方面的支持:
- 业务层,提供丰富好用的 API ,帮助用户更快地去构造出代码。
- 中间件层,除了 Server 的中间件支持外,Client 也希望能够支持中间件,由此 Client 和 Server 的服务治理能力和一些可观测性的能力都会有很大提升。同时中间件能够将通用逻辑和业务逻辑分开,用户只需要专注于自己的业务逻辑。
- 路由层,能够提供丰富的路由功能,如:静态路由、参数路由(/:name)等。
- 协议编解码层,满足多协议的支持,如 H1、 H2 等等。
- 传输层,不同的网络库适用的场景不一样,能够灵活地替换网络库。
Hertz 概述
Hertz 在框架服务方面有一些设计优势。具体如下:
- Hertz 是一个超大规模的企业级 HTTP 框架,峰值 QPS 超过了 4000 万,最近这个数字仍在提高。除了业务在用之外,还有很多其他的基础组件也在用 Hertz ,比如 Mesh 的控制面、压测平台、函数及服务 FaaS 等等,这验证了 Hertz 很强的稳定性。可以应对以下情景:
- 我们写代码时,发现了与一期不一致的结果,此时可能会想是代码出了问题。但是如果发现自己的代码没有问题,反而这个问题来自于框架,这种体验是非常不好的。
- 如果用户随便改 API,可能会导致框架不兼容。但是对于 Hertz 这样一个超大规模企业级的框架,包括字节内部也采用了套壳的形式,版本管理就会非常地严格,不会出现很严重的不兼容情况,这是我们拥有的一些其他框架无法比拟的稳定性优势。
- 业界领先的吞吐和超低的时延。
- 重点突出“超低的时延”是因为现在时延对标的是用户体验。比如抖音 APP,如果一个视频刷了一分钟还没有呈现,那用户就会放弃使用。相反时延越低,用户体验会越好。
- “业界领先的吞吐”没有重点突出是因为如果存在吞吐差的问题,其实可以通过一些横向水平扩展的、加机器的手段进行解决,这并不是一个瓶颈。但是水平扩展的方法没有办法解决时延问题。
- Hertz 是一个开箱即用的框架,开箱即用代表 Hertz 的易用性。
- Hertz 具有稳定性和时延的优越性,那如何让用户使用起来呢?如果框架用起来非常麻烦,用户还是难以接受的。降低用户使用框架的成本,才是优先级更高的事情。
- Hertz 提供了一个一键生成脚手架的工具 Hz,大家可以在 CloudWeGo 官网上找到相关的介绍,源码解读活动第二场直播也会主要对 Hz 的使用做一些实践分享。
- 高扩展性、高应用性和高性能。
- 三高是 CloudWeGo 所有项目都具有的一些特点,这里就不再详细展开。
- 用户在使用中希望能够提供一个大而全的框架,Hertz 也是提供了一些很多默认的实践,比如链路追踪提供了 OpenTracing 和 OpenTelemetry 两套实践,对于很多用户来说已经能够满足其大部分的需求了。如果用户觉得不能够满足他们的某些需求,他们也可以通过框架提供的接口进行注入,这就是高扩展性的体现。
Hertz 快速上手
下面举一个简单的例子,以此说明如何用 Hz 快速上手。
- 定义 IDL 文件
首先在项目目录下定义好 Thrift 文件、 Request 和 Response ,以及定义好 Service。这里的 Service 是 get 方法,它的 URL 是 "/hello"
,这样就定义完成了一个 Thrift 的 IDL。
- 执行命令
首先生成代码,其次是将这个代码进行一些包的拉取和验证。
如图就是现在 Hertz 默认生成了一个脚手架的目录,如果这个目录不能够满足用户的需求,也可以自定义模版。在生成脚手架之后,只需要填充我们的业务逻辑就可以了。
- 填充业务逻辑
比如我们返回 hello,${Name}
,如图所示 req
之后带的 Name
可以通过这样的代码,返回 hello, name
。
- 编译并运行项目
使用 Go 的编译命令 Go View 启动。
在下面我们可以进行一个测试,图中使用的是 cURL 进行了测试,也可以使用 Postman 进行测试。如果能够看到我们以下的返回,就说明服务已经正常启动。
04 对比与展望
H1 协议:
- 基于 TCP 传输存在一些问题,比如队头阻塞,就比如我前面的包没有到,反而后面的包先到的话,这里就会一直阻塞,等待前面的包到达。
- 传输效率低。这体现在以下两个方面:第一,它是一个 Ping-Pong 的模型,如果没有把 Response 返回的话,连接是不能够进行复用的;第二,头部冗余字段比较多,像刚才邀请小姐姐去吃烧烤例子中,可以看到很短的一句话,但是上面的头部字段其实是非常多的。
- 明文传输不安全。对需要隐藏的信息不能起到加密作用。
H2 协议,相对 H1 做了一些优化:
- 多路复用。针对 H1 的传输效率低,H2 采用多路复用的方式,即在一条连接上可以传输很多的请求,通过 Stream ID 进行区分,而不用等待 Response 返回。
- 头部压缩,相同的头部无需再传。
- 二进制协议。代替了 H1 的明文协议。
但是 H2 依旧是基于 TCP 进行的实现,并没有解决队头阻塞问题。
QUIC 协议:
- 基于 UDP 实现的,不是基于 TCP 实现的,因此从根本上解决了队头阻塞的问题。
- QUIC 的加密,可以减少一些握手次数。H1 是明文传输,大家觉得不安全,又衍生出了 TLS 协议。但是像 TLS 1.2 协议的第一次建立连接需要 7 次握手,因此传输的时延是非常高的。
- 支持快速恢复和启动。
这三种协议的适用场景有所不同,具体如下:
H1 协议是现在应用最为广泛的协议,也是非常好用的一个协议。从诞生至今已经近 30 年,足以体现它的生命力。但是它确实存在传输效率低的问题,主要体现在浏览器端的传输。因为 Server 如果认为建一条连接不够,就可以多建几条连接。但是浏览器的最大连接数是固定的,不能建立很多的连接。这个时候如果要传输很多的资源,比如网站上传几百几千张图片,后面的请求就需要排队,此时时延非常高。
因此在浏览器这种场景如果需要传输大量资源,还是比较推荐 H2 协议,这也是 H2 用得比较多的一个地方。除了浏览器之外,还有一些对连接数要求非常敏感的其他场景。
QUIC 协议主要应用场景在于解决一些弱网的问题,提高弱网环境下上传的成功率。
目前 Hertz 只开源了 H1 部分, H2 部分在内部也有使用,慢慢地也会开源出来。QUIC 也是目前正在支持的一个协议,相信在不久的未来,这部分也会开放给社区。
相关文档
- HTTP 协议:developer.mozilla.org/zh-CN/docs/…
- Hertz:github.com/cloudwego/h…
- Hertz 文档:www.cloudwego.io/zh/docs/her…
05 直播问题收集 Q & A
Q1:为什么 Hertz 的性能比 Gin 优越这么多?
A: 主要原因有以下几个方面:
- 网络库的优化
- Hertz 采用了一个高性能网络库 Netpoll ,它的触发模型区别于我们的标准网络库。Netpoll 采用的是协程池,标准的网络库是为每一个连接新起了一个协程,如果连接数非常多,协程是会爆炸的。
- Netpoll 是 LT 的触发模式,经过我们的优化,它的时延能够做到比我们的标准库 ET 低将近 20%。
- 我们在 Netpoll 上也做了很多减少拷贝的努力,数据从底层缓冲区读到网络库中只需要进行一次拷贝。
- 协议的优化
- 在协议上我们尽量地去减少一些拷贝。这个拷贝主要指像 Golang 当中 Byte 到 String 这样的一个转换。因为 Byte 是一个可变的数组,String 是不可变的,我们每进行一次转化,就需要发生一次拷贝。
- Hertz 在解析协议的头部上进行了优化。H1 协议一个比较大的问题,就是它的 Header 头部是变长的。因为它的第二部分请求头部分是不知道长度的,直到解析到两个换行符才结束。这样对底层的缓冲区分配是非常不友好的。针对这个问题,Hertz 和 Netpoll 采用了一个深度配合的方式,能够动态地调整底层缓冲区的大小,使得我们底层的 Buffer 能够放下整个包,然后再进行解析,这样就可以避免一些 Byte 数组扩容的拷贝。
- 在协议解析找
\r\n
的时候,我们是使用 SIMD 加速的。SIMD 加速经过测试,SIMD 的性能会有几十倍的提高。
- Sonic 解析库
这是我们的大杀器,Sonic 解析库也是用了 SIMD 加速,这个是 Golang 现在性能表现最好的一个 JSON 解析库。在真正业务使用当中,JSON 编码是一种很常见的编码,像我们的 Request Body 的 Unmarshal 和我们 Response Data 的 Marshal 都是默认的采用的 Sonic ,会比其他框架快好几个量级。
Q2:关于 Hertz 的使用性能相关的分享?
A: CloudWeGo/Hertz 有一个 Benchmark 的仓库,里面有详细的性能整体测试和对比。之前也对比了有关 Gin 的框架,还有一些其他的框架,比如 Fasthttp 框架,大家可以关注一下 Benchmark 仓库。
Q3:Hertz 的路由数相比 Gin 有优化吗?
A: 在字节内部的场景中 Gin 路由的功能是不太够用的。Hertz 的路由树,是可以支持注册任何路由,包括静态路由、参数路由等。这样就会带来另一个问题,就是路由树在找的时候是需要有优先级的。如果优先去匹配静态路由,但没有匹配到,就会再匹配冒号类型的路由。如果也没有匹配到冒号类型的路由,再去看有没有匹配到星号的路由。因此 Hertz 的路由树在功能方面要完善很多,给了用户很大的自由度。
Q4:可以给基础库提供优化吗?Netpoll 网络底层触发的方式是什么?
A:可以,Netpoll 也是在 CloudWeGo 下开源的一个项目。现在 Hertz 和 Kitex 两个框架都是默认配置 Netpoll 的,当然也可以修改相关的配置。Hertz 针对不同场景支持了 Golang 的标准网络库和 Netpoll ,一般情况下大家用高性能和低延迟 Netpoll 就可以满足需求,如果有其他需求可以看一下 Hertz 里的一篇文档——网络库如何选择,具体地选择应用哪一种网络库。另外 Hertz 是可以在 Windows 上运行的,这部分对于用户来说是无感切换的。
Q5:Hertz 框架和 Gin 相比有哪些较大的优势?
A: 首先,Hertz 是一个企业级的框架,在字节内部也是使用量最大的一个框架,稳定性有保证,同时 Hertz 是包含周边生态的,是一个开箱即用的框架。而 Gin 只是一个裸框架。其次,Hertz 和 Kitex 都是以高性能著称的。CloudWeGo 公众号之前发过有关 Hertz 开源的一些文章,从底层讲述了高性能架构 Hertz 是如何去设计和完成的。详见公众号文章字节跳动开源 Go HTTP 框架 Hertz 设计实践。
Q6:Hertz 的 Request 为什么不是标准库的 Request ?
A: 主要是高性能的需要。如果需要标准库的 Request 和 Response 的话,Hertz 在框架内部也提供了一个从 Hertz 的 Request 和 Response 到标准库的 Request 和 Response 的转换。从代码里可以看到函数是怎么用的,如果为了接近社区生态也可以用那个转换器进行一些适配。
Q7:为什么没有给 Go 原生网络库提相关的 PR?
A: 这个是 Netpoll 性能优化的考虑。可以关注 CloudWeGo/Netpoll 仓库里有 Readme,它详细地讲了为什么 Netpoll 没有给 Go 的网络库提 PR ,为什么要做底层的优化。其实是因为 Go 底层库的深度耦合问题,导致它没有办法做更多性能的优化支持,所以才单独写了一个网络连接池和网络库。
项目地址
GitHub:github.com/cloudwego
【CSG 第二期】CloudWeGo 源码解读活动 ——“Hertz 框架篇”开始啦!