一、走进HTTP协议
HTTP协议是前端,客户端,服务端之间通信的基础协议。下面这张图是前后端分离的流程图,前后端之间通过HTTP协议进行通信。HTTP框架负责的就是对HTTP请求的解析,根据对应的路由选择对应的后端的逻辑:
首先我们来简单介绍一下对HTTP协议。
1、什么是HTTP协议
HTTP:超文本传输协议(Hypertext Transfer Protocol)。“超文本”到底“超”在哪里?
最早的时候,人们与PC交互就是通过Text(文本),没有什么其它花里胡哨的东西。后来,“村里通网了”,除了人和一台计算机的交互,两台不同的PC之间也有需求去交流分享点什么。我们就把两台PC用网线连接起来,在网线上传输01代码以分享这些Text信息。但是很快人们发现,传输的需求除了文本Text,还有图片、音乐、超链接等,单纯的Text已经没有办法满足人们对于网络传输的需求了。
这些文本以外的资源(图片、音乐、超链接等),是对原有的Text的扩充,所以就被称为Hypertext(超文本)。那么传输超文本的协议就被称为Hypertext Transfer Protocol,也就是常见的超文本传输协议HTTP。
通俗来说,超文本就是一种通过链接把不同的东西(文字、图片、音频、视频等)连接在一起的方式,让你可以不按顺序地点点点,看你想看的内容。比如,你在一个网页上看到一个词,点一下它,就可以跳到另一个页面或者文件,那里可能有更多关于那个词的解释或者信息。这就像是在信息的世界里自由穿梭,不必按照一页一页的顺序来看。
2、为什么需要协议?
我们说话,需要按照一定的语法,用主谓宾来造句,这样对方才能理解我们说的是啥,也只有这样我们才能清晰表达出自己的意思。协议其实也是这样。
我们在网上传输的其实都是一些01数据流,需要按照一定的规则才能让对方理解。协议就充当了这个“规则”。
协议包含以下几个要素:
- 需要明确的边界。我们需要协议来明确信息的边界:信息是从什么时候开始的,又是什么时候结束的。
- 能携带信息。传输的消息有多种类型(文本、超链接、图片、音乐等等),首先需要明确传输的消息是什么类型的,然后,把这些消息的具体内容都塞到消息队列中,再进行传输。
3、HTTP协议里有什么?
一个HTTP协议应该包含的内容:
4、实现一个HTTP协议demo
这里以常见的 POST 请求为例。比如,你要“请求”小姐姐一起看电影。如何把“Let's watch a movie together”这句话转化为真实的HTTP协议?示例如下:
小姐姐作为接收方,在看到第一行的POST开始(只要检测到POST这一行),她就可以开始接收这个协议了。而协议的结束部分就是最后POST请求body的 Let's watch a movie together\n
在POST协议中,Content-Length是很关键的字段,它描述的是请求的body一共有多少个字节。Server端也就是小姐姐端,可以根据这个总的字节数来指定自己要去接收多少个字节的数据。这样就能拿到一个完整的消息了。
拿到消息后,小姐姐还会给一个回复:
我们来把这个demo在Goland中,用简单的Go语言代码来实现:
package main
import (
"context"
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
)
func main() {
h := server.New()
//h.POST() 选择对应的方法
//"/sls"是url
h.POST("/sls", func(c context.Context, ctx *app.RequestContext) {
ctx.Data(200, "text/plain;charset=utf-8", []byte("OK")) //业务逻辑
})
h.Spin()
}
这里有一点要说明,尹旭然讲师课上给出的代码的导包如下:
我尝试了之后发现,无法找到这两个包的路径(怀疑是路径在视频出了之后有更新,但是视频还是旧的路径,访问不到了)。经过搜索后,我找到了可以导入且正常运行的包:
"github.com/cloudwego/hertz/pkg/app"
"github.com/cloudwego/hertz/pkg/app/server"
运行程序后,在postman中输入url和request body信息:
点击send,可以获得response:
5、处理流程
6、HTTP协议的不足与展望
HTTP1
- 基于TCP,有队头阻塞的问题。
- 传输效率低。比如上面,只是想发一句话,但是协议中无用信息非常多,甚至已经要超过实际要表达的信息了。
- 不支持多路复用。这个请求没结束之前,这个连接上就不能再发送其它的请求了。
- 明文传输不安全。
HTTP2
- 可以多路复用。
- 可以头部压缩。把重复的header在两边都先缓存起来,这样可以减少头部的数据量。
- 二进制协议。解析起来更加高效。
- 仍然基于TCP,没有解决队头阻塞的问题(基于TCP的都会有队头阻塞的问题)。
- H1中为了加密引入了TLS(一种用于保护网络通信安全的协议)。这份握手的开销也没有优化。
TLS(Transport Layer Security,安全传输层协议)是一种确保网络通信安全的协议,主要是在传输层完成对网络连接的加密,确保数据在传输过程中不会被窃取、篡改、伪造等攻击,提高了网络通信的保密性、完整性和可靠性。
QUIC
QUIC(Quick UDP Internet Connection)是谷歌制定的一种基于UDP的低时延的互联网传输层协议。QUIC已开始它的标准化过程,成为新一代传输层协议。
-
基于UDP实现。可以解决TCP的队头阻塞。
-
优化了加密的算法,能减少握手的次数,支持快速启动。
二、HTTP框架的设计与实现
1、分层设计的设计思路
分层设计是很常见的一种设计思路。
比如分层网络模型就采用了这样一种设计思路。在写代码的时候,我们似乎并没有太关心TCP重传、01编码是怎么通过路由器和交换机一步一步到达对端等这些细节。这其实就得益于分层设计。分层设计可以简化系统的设计,让从事不同层次开发的人专注于做某一层次的事情。设想一下,如果我们写一个程序的时候还要操心物理设备是否正常、网络是否堵塞、tcp是否在超时重传,那就太痛苦了。而有了分层设计,我们只需要使用下一层提供给上一层的接口,专注于特定层的开发就可以了。至于接口的底层是如何实现的,并不需要关心。并且,分层架构还可以很好地做到项目扩展(便于模块复用)。比如,在设计模块A的时候,发现模块A具有一定的通用性,那么就可以把模块A的内容抽取出来,在设计系统B的时候使用。
分层设计的这些优势是普适的,因此我们在自己设计HTTP框架时,也应该采用这种分层设计的方式。并且,在进行设计时,还要充分考虑高内聚、低耦合,易复用和高扩展性。
下图是HTTP框架设计的一个分层实践案例。它从上到下分为5层,层与层之间使用接口解耦。
- 第一层是应用层,和用户直接交互。
- 第二层是中间件层,主要负责与用户交互前的预处理。
- 第三层是路由层,提供类似于注册,寻址相关的操作。
- 第四层是协议层。
- 第五层是网络层。
- Common中存放一些公共的逻辑,在每一层中都会使用。
它与前面提到的HTTP请求的处理流程是相对应的。
接下来我们就来看看,每一层的具体实现思路是什么。
2、应用层设计
3、中间件设计
下面来看一个经典的中间件模型。
中间件模型-洋葱模型
当有请求request时,首先经过日志中间件的预处理,然后经过Metrics中间件的预处理,最后再执行业务逻辑;在业务逻辑退出之后,还会有后处理,首先经过Metrics的后处理,最后经过日志中间件的后处理,然后再将真正的响应response返回给用户。
在 Web 开发中,中间件是一种用于处理 HTTP 请求和响应的组件,可以在请求到达路由处理之前或响应发送之前执行一些额外的逻辑。中间件可以将核心逻辑与通用逻辑分离,用来执行日志记录,性能统计,安全控制,事务处理等操作。
Metrics(度量指标)是用于衡量和评估软件系统、代码质量以及开发过程的标准和指标。Metrics 可以帮助开发团队评估软件的性能、可维护性、稳定性等方面的特征,从而更好地理解系统的状态并做出优化和改进。
Biz handler表示处理与业务逻辑相关的操作或事件的组件,在这里可以理解为业务逻辑处理程序。
举个例子来说明洋葱模型:
Use()方法用于向服务器实例添加中间件。
如何进行中间件的设计?
- 使用中间件来实现预处理和后处理,很像调用了一个函数;
- 路由上可以注册多个中间件,同时也可以满足请求级别有效,只需要将中间件设计为和业务和 Handler 相同即可。即把调用业务的接口和调用其它中间件的接口统一命名;
- 如果用户不主动调用下一个处理函数,我们可以帮助用户自动调用以下之后的中间件。也就是说,可以帮助用户去实现这样一个逻辑:
核心是在任何场景下,index都保持递增。
- 中间件中出现了异常,需要停止继续调用怎么办?可以将index设置为最大值,直接跳出循环即可。
中间件的调用链如下:
中间件B不调用Next中间件,而中间件C调用Next中间件。
图中,中间件A首先去调用中间件B,由于中间件B不调用Next,在执行后就直接返回了。然后再有中间件A去调用下一个中间件,即中间件C,中间件C会显式地调用Next,因此它会接着去调用下一个业务handler,调用完了之后再返回。
有一点要注意,上述调用中间件B和调用中间件C并不是在同一个栈上的。
4、路由设计
- 参数路由:分为冒号(:)形式的和星号(*)形式的。冒号形式路由只匹配冒号所在的两个斜杠中间的一段,/*all表示能匹配这个/之后的任意路由。
如何设计一个路由?
- 可以使用
map[string]handlers。但map只对静态路由有效,无法实现参数路由。优点是快且简单。 - 更好的方式是采用前缀匹配树。假设来了一个请求的url是/a/b/c,根据以下的前缀匹配树,会先匹配上/a/,然后匹配b/,最后匹配c。都匹配上之后,再将当前处理函数返回给下一层,让下一层执行。
对于参数路由,也可以构建下面的前缀匹配树:
如何匹配HTTP方法?
如何实现添加多处理函数?
在数据结构上可以直接用一个list来存储handler:
5、协议层设计
这层的关键在于抽象出合适的接口。我们可以直接将协议层抽象为下面的这个接口:
入参指定了一个上下文参数和一个连接参数。我们需要Context来进行上下文的传递,而且同时,官方不推荐将Context写到结构体中,而是推荐将上下文参数作为方法的第一个参数进行传入,因此入参的第一个参数是Context;另外,我们肯定需要在连接上读写数据,因此需要一个连接参数Conn。
6、传输层设计
BIO和NIO
BIO是(Blocking I/O),即阻塞IO。设想一个场景,你正在给物流客服打电话,客服问你的快递单号是多少,恰好你忘了单号,就和客服说你找一下,那这时客服电话就一直在占线,等待你找到快递单号,期间不能做别的事情,直到等待超时。这种编程模型就是BIO。
一个BIO的案例如下:
在go func(){}中维护一个 listener。每次Accept()了一个连接之后,会单独开一个协程去处理。先读数据,再处理业务逻辑,再把response写回。在读数据的期间如果数据量不够,应用会阻塞,别的什么也干不了。
与BIO对应的是NIO。NIO指的是Non-blocking I/O。是非阻塞的 I/O 操作。可以用NIO改善BIO的阻塞问题。
设定一个监听器。当监听器监听到有足够的数据之后,再去唤醒协程。
总结一下NIO和BIO
- BIO(Blocking I/O):
在 BIO 模型中,I/O 操作是阻塞的,这意味着当应用程序执行一个 I/O 操作时,它会被阻塞,直到操作完成或发生错误。在阻塞期间,应用程序的执行被挂起,不能继续处理其他任务。这种模型在很多情况下可以很简单地使用,但会导致系统资源浪费,因为每个阻塞 I/O 操作都需要一个线程来处理,如果 I/O 操作耗时较长,系统会面临线程耗尽的问题。
- NIO(Non-blocking I/O):
NIO 模型引入了非阻塞 I/O 操作的概念。在 NIO 中,应用程序可以向操作系统请求一个 I/O 操作,但不需要等待操作完成。相反,应用程序可以继续执行其他任务,然后定期检查操作的状态。NIO 提供了一些机制,如选择器(Selector)和通道(Channel),使得一个线程可以管理多个 I/O 操作,从而减少了线程的使用,提高了系统的扩展性和资源利用率。
比较:
-
BIO:
-
阻塞式,操作需要等待。
-
每个 I/O 操作需要一个线程。
-
简单易用,但资源浪费和可伸缩性问题。
-
适用于连接数较小的场景。
-
NIO:
-
非阻塞式,操作不会等待。
-
一个线程可以处理多个 I/O 操作。
-
较复杂,但提高了资源利用率和可伸缩性。
-
适用于高并发、大连接数的场景。