HTTP框架的设计与实践| 青训营笔记

175 阅读21分钟

这是我参与「第三届青训营 -后端场」笔记创作活动的的第14篇笔记

一、 本堂课重点内容

1.1   再谈HTTP协议

1.2   HTTP框架的设计与实践

1.3   性能修炼之道

1.4   企业实践

二、 详细知识点介绍

2.1 再谈HTTP协议

2.1.1 HTTP协议是什么:HTTP:超文本传输协议(Hypertext Transfer Protocol)

为什么需要协议?

我们其实可以举一个不太恰当的例子,就好像我们说话,我们需要按照一定的语法,按照一定的主谓宾去造句,那这样的话对方才能够理解我们说的是啥,才能够表达清楚我们的意思,那协议其实也是这样。在网线上传输的都是一些01数据流,需要一定的规则才能让对方理解。那首先,我们需要协议给我们明确一个信息的边界,我们需要明确的知道信息从什么时候开始以及到什么时候结束,我们不能一直接收对吧?那这就是我们协议的第一个要素,我们需要明确的边界。有了明确的边界之后,其实非常容易想到,我们需要用元数据进行消息的描述。我们描述这个消息它是什么类型的,包括我们刚刚看到的以图片对吧,音频视频以及超链接等等,就可以把它塞到这样的一个消息对应的地方,我们就进行一个传输了,这就是我们为什么需要协议。

2.1.2 协议里有什么

image.png

image.png

举例一个场景,你要和小姐姐交流去请她看一个电影。然后就是我们把这个请小姐姐看电影的这句话,我们把它转换成了一个真实的一个http协议。你们可以看到这个协议里面的第一行是从post开始打头,然后一个空格之后接上的URL然后接下来又是一个空格,然后接上一个HTTP1.1这看着像是一个HD当前版本的一个描述。这就是我们协议里面的一个firstnine,也就是请求行。那除了除开firstnine之后,我们剩下往后看有行都是以冒号风格的一个KV对这个就是我们的一个协议的源数据,对应到刚刚我们看到的源数据的1描述。然后之后在一个大空行之后,我们可以看到我们真实的一个想说的话,也就是body部分:lets watch a movie together tonight那,之后这个协议结束了。那我们可以看到那我们的协议的开始,那其实就是我们的这个0st这一行开始,只要我们对端检测到这一行的内容之后,我们就可以开始接收我们的协议了。那协议的结束就是我们这Let's watch a movie together tonight最后我们再加上一个换行之后就结束掉了我们的这个协议。然后同时我们可以看到源数据里面有一个叫做contenLength的一个描述,这个是协议的关键的一个header它描述的是我们的body到底有多少个字节。所以我们的server端就是我们的小姐姐端就能根据这个字节来指定自去接收多少个字节的数据,这样就能拿到我们完整的一个消息了。OK这是我们请求的一个真实的场景。那我们小姐姐肯定会给我们回复对吧?那回复其实可以看到非常类似,我们的回复其实也是有一个first line.first line的话它是由我们的一个hdp1.1是一个版本开始,然后一个空格,然后200是一个状态码,然后再一个空格,然后又是一个OK,OK的话就是一个我们对应200的一个状态码的一个文字描述,然后最后是一些源数据,然后最后是小姐姐回复的一个OK,这就是小姐姐响应的完整的协议了

image.png

那我们既然看到协议里面有什么了之后,我们可以把刚刚我们看到的东西把它总结下来,首先是一个请求行,状态行,就是我们对应的first line.然后之后就是一些元数据请求头响应头。最后就是一个再往后的话就是一个请求体响应体。那针对我们的请求行。其实刚刚我们也看到了,它是由方法名、URL和协议版本组成,那方法名其实就比较多了,我们常见的方法名就get get就是我们htp0.9里面唯一的一个方法。然后,之后我们在1.0里面补充了header和post然后1.1里面又陆陆续续扩充了5个,然后从put开始到trace然后最终到一个patch。patch的话其实它是在1.1之后额外新增的个方法名。但是它因为使用得比较广泛,我们把它列在了这里,patch的话其实它的语义跟我们的put其实是非常类似的。patch的话就是我们的部分更新以及我们的put它的语义是完整地更新,所以这就是它的一个比较细微的一个区别在这里了(PUT是幂等的,而PUTCH不是幂等的。)。那状态行的话其实刚刚也看到了,也是个比较经典的三段式,就是我们的协议版本状态码以状态码描述。这边就不再过多地赘述了,相信大家对这个已经非常的熟悉了。然后接下来是我的请求头响应头其实也是一个非常请晰的一个划分。我们主要是分为协议约定的相关的。比如说我们刚刚看到的content就是我们指定我们的body有多少字节,然后以及业务相关的,那就是我们自己定义的需要传输传递的一些源数据了。好的,然后最后是请强响应体响应体,这就不再继续在这里展开去描述了。

一个Demo

image.png

那我们把刚刚的那个demo用一个例子展示出来,大概是这样的一个场是。我们在地址栏输入小姐姐的UR和请求路径,选择好请求方法。之后呢将我们想说的话,也就是Iet's watch a movie together,填到body当中点发送就可以了。

然后最后我们收到了一个小姐姐的response就是OK那一个简单的这样的一个回复。那实现一个这样的功能需要几行代码呢,我们一起来看一下。

image.png

实现这样的一个功能仅仅需要5行代码,核心呢就是三行。我们将对应的路由注册到server上,选择对应的方法,正如图上红圈所示。接下来实现我们的业务逻辑,回复一个OK。那这短短5行代码实现这样的一个功能,其背后肯定隐含了大量的处理,那下面我就来说一下这背后经过了哪些的处理流程

2.1.3 请求流程

image.png

那在讲完了协议里有什么之后,再来看一下一次完整的请求发生了什么呢?首先在业务层,我们业务方的使用框架提供的API完成业务逻辑,也就是刚刚的那个小哥哥他想和小姐姐去看电影,那这个就是它的一个业务逻辑。那它的这个业务逻辑想要传递给小姐姐,其实还是有很多工作要去做的。完成了我们的业务逻辑之后,会进入到一些服务服务治理的逻辑。也就是大家经常说的比如熔断、限流等等。服务治理层,是依托于中间件层的。它对每个请求可以有一些先处理逻辑和后处理逻辑,是和请求级别绑定的。比如说我要打一个计时,在进入业务层之前,我记录一下当前的时间,在业务层执行完毕之后,我们再记录一下业务执行完毕的时间,那这样的话就可以记录整个的业务逻辑的耗时。对于client来说,之后就可以进入一个协议的编解码。协议的编解码层就是刚刚上文里说的协议里有什么编译成一个小姐姐能够看懂的一个协议,最后是通过传输层传输给小姐姐。之前王庆老师在《计算机网络基本概念与实际应用》讲了HTTP和TCP的关系,比如HTTP是第七层,TCP是第四层,这里就不再重复说了。那小姐姐这边,她其实处理逻辑也大概如此,但是会多一个路由层,它是根据UI选择对应的执行的handler,比如说我这个好像比如说我这个地方有很多个小姐姐,那我要根据小姐姐的名字去选择我想要约的哪一个?

2.1.4 不足与展望

image.png

2.2 HTTP框架的设计与实现

2.2.1 分层设计

image.png

image.png

HTTP框架的设计也应该采用分层设计。在进行分层设计时,我们需要考虑一些点,比如高内聚低耦合,复用性、扩展性等等。这个是我们进行的一个分层实践。这个架构的话其实从整体上来看的话,我们从上往下总共分为了五层,层与层之前使用接口解耦。那我们从上开始的话就是我们的应用层,这一层的话其实就是跟用户直接打交道的一层,这一层会对请求进行一个抽象,包括像request response context等等。这一层也会提供一些丰富的易用的API。然后下一层就是中间件层,可以对请求有一些预处理和后处理的逻辑,像我们可以打一些accesslog,打一些耗时的点。其他中间件比如Reacovery中间件用于捕获Panic。.之后是我们的路由层,路由层的话就是我们会有一个原生的路由实现来提供大家类似于跟注册、路由寻址的一些操作。这一块的话在我们下一部分会具体进行详细地展开。然后再往下的话就是我们的协议层。我们知道现在tp1.1已经不能够满足我们所有的需求了,我们需要支持H2、Quic等等,甚至是在TLS握手之后的ALPN协商升级操作,那这些都需要能够很方便的支持。最后一层的话就是我们的网络层,不同的网络库使用的场景并不相同,这部分在之后我也会讲。那我们也需要一个灵活替换网络库的能力。Common层主要放一些公共逻辑,这一部分可能每一层都会使用。

image.png

2.2.2 应用层设计

易用性:首先是体现在我们要提供一些合理的api。这里大概列了一些在设计api时需要考虑的点。

可理解性:使用主流的概念,如ctx.Body0),ctx,GetBody0,不要用ctx.BodyA0

简单性:常用的API放到上层,误用/低颇API放到下层,如ctx.Request.Header.Peek(key)/ctx.GetHeader(key)

可见性:最小暴露原则,不需要暴露的API不暴露,可以抽象为接口。

冗余性:不需要冗余或能通过其他API组合得到的API

兼容性:尽量避免break change,做好版本管理。

2.2.3 中间件设计

中间件需求

配合Handler实现一个完整的请求处理生命周期

拥有预处理逻辑与后处理逻辑

可以注册多中间件

对上层模块用户逻辑模块易用

image.png

先经过一个日志的日志中间件的预处理之后经过metrics中间件的预处理,在处理完了之后我们再进行执行一个真正的业务逻辑。那最后我们再退出业务逻辑之后,会有一个后处理。首先经过一个metrics中间件的后处理,最后是经过一个日志中间件的后处理。然后再将真正的响应再将一个完整的响应返回给用户。那这个罗这这个的中间件的核心它是能够将核心逻辑与通用逻辑分离。那它的适用的场是包括说像日志记录、性能统计、安全控制、事务外理像导机导常朴理等等。

举例:打印每个请求的request和response

image.png

既然要实现预处理和后处理,那这个就很像调用了一个函数。路由上可以注册多Handler,同时也可以满足请求级别有效,只需要将Middleware设计为和业务和Handler相同即可。那这样是不是第5行的代码不就不用区分是中间件还是业务逻辑了,统一为直接调用下一个处理函数,我们抽象为Next()方法。

image.png

用户如果不主动调用下一个处理函数怎么办?

image.png

核心:在任何场景下index保证递增

如果用户只有预处理逻辑,没有后处理逻辑怎么办呢?比如只想完成一些初始化。考虑到用户真正希望执行的是业务逻辑,那我们可以主动帮用户调用一下之后的中间件。

image.png

2.2.3 中间件设计

调用链:

image.png

适用场景:

不调用Next:初始化逻辑且不需要在同一调用栈

调用Next:后处理逻辑或需要在同一调用栈上

用Next和不调用Next的适用场景:我们看一下这张图,这是一个一次注册了ABC三个中间件和最后一个业务handler的调用链图,其中B中间件中不调用next对,中间件C调用next。那我们的调用顺序就是首先中间件A去调用中间件B,返回了之后中间件A去调用中间件C,然后中间件C去调用业务Handler,.最后返回,也就是按照图上的标号调用。

2.2.4 路由设计

框架路由实际上就是为UL匹配对应的处理函数

(Handlers)

静态路由:/a/b/c、/a/b/d

参数路由:/a/:id/c(/a/b/c,/a/d/c)、/*aIl

路由修复:/a/b<->/a/b/

冲突路由以及优先级:/a/b、/:id/c

匹配HTTP方法

多处理函数:方便添加中间件

image.png

青铜:map[string]hand l ers

a/b/c、/a/b/d/a/:id/c、/*all

黄金:前缀匹配树

a/b/c、/a/b/d

如何匹配HTTP方法?

路由映射表:

image.png

外层Map:根据method进行初步筛选

如何实现添加多处理函数?

在每个节点上使用一个Iist存储hander

image.png

2.2.4 如何做设计

1.明确需求:考虑清楚要解决什么问题、有哪些需求

2.业界调研:业界都有哪些解决方案可供参考

3.方案权衡:思考不同方案的取舍

4.方案评审:相关同学对不同方案做评审

5.确定开发:确定最合适的方案进行开发

2.2.5 协议层设计

抽象出合适的接口:

image.png

1.Do not store Contexts inside a struct type;instead,pass a Context explicitly to each

function that needs it.The Context should be the first parameter.

2.需要在连接上读写数据

2.2.6 网络层设计

image.png

在这里设想一个场景,就比如说你打客服电话,然后比如说,打一个客服电话,客服跟我说问我身份证号是多少,那这时候我忘了身份证号多少,我就必须说你等一下,我去找一下这个身份证,但我又没找着,那这个客服是不是占线,那占线的话他就什么都做不了,然后并在这里占住了,他等不下去了,就等到了超时。那这种编程模型在互联网界就叫block io,简称bio我们可以看下面这段代码,这段代码是go一个经典的connection处理,我们在一个go function里面维护一个listener那它listener每次accept获取一个连接之后,我们会开一个goroutine去单独处理它。这goroutine行为应该是先去读取数据,读取完,之后然后处理业务逻辑,然后再把这个response写回去。这是一个比较经典的go的写法,那它就是一种block io编程模型。因为假如说你在读数据的时候读到了一半,它就读在这里了,它啥也干不了。那有没有解决这种办法的方式也比较简单,我们在中间引入一种通知的机制,就是当他数据有一半的时候,我让客服小姐姐也去干别的事情。那当它后续把整个包都已经发完的时候,我们再去通知他去处理,这样的话就不会阻塞。是在互联网界就是non block IO的一种编程模式,它就是非阻塞的。我们可以看一下刚才那段伪代码改成这段伪代码之后是什么样子就是上面我们在第一个go function.里面还是维护这个连接,accept但是每次我们拿到这个连接之后,我们把它加到一个监听器里面,比如说add这个链接,然后我们在另外一个部分里面去轮间这个monitor就是监听器,我们搜索可读的连接数。因为这里monitor它已经知道有数据了,但我们这个服务方式去执行的时候可能是read这时候就能拿到完整的数据并处理,然后再返回,这个时候整个流程是没有阻塞的.

image.png

在用户态来看。那go net呢是由用户管理的buffer,这两个接口都是传入buffer,进行读或者写,但它本身是不管理buffer的。

2.3 性能修炼之道

2.3.1 针对网络库的优化

go net

存下全部Header

减少系统调用次数

能够复用内存

能够多次读

那基于此,我们可以对勾在勾标准库的接口上面封装一层buffer说也就是一个常用的一种优化手段。就是绑定在这个连接上面,绑定一块缓冲区。那根据我们在内部的一个调研,也发现大部分的包都是在4k以下的,所以我们可以绑定一块大小为4k左右的一个缓冲区,这样对内存的压力也不是很大。那这个还那我们再设计接那我们这个再设计接口。那首先需要一个我在读的时候让读指针不动,我下次还能够在这里进行读,也就是Peek;以及说我们既然就能够让读指针不动,那我们就需要一个接口,让读指针进行一个移动,也就是Discard。最后呢我们还需要回收这块内存,希望下一次请求能够复用之前的空间,也就是Release接口。

Netpoll

image.png

对于netpull我们是希望能够存下全部的header那第二个我们是希望能够拷贝出完整的body那对于netpull这种网络库管理底层,由于netpoll为了减少锁的竞争,采用了一个链表的设计方式,实现一个无所化。那这样一个链表带来的问题就是它可能会存在一个跨节点的问题。那比如说那比如说像下面的这张图,我们的header可能分布在两个节点当中,我们的body也可能分配在两个节点当中。那这样的话如果我们要进行一个使用,我们效我们就需要再分配一块足够大的buffer然后将两部分的header拼到一起,返回给框架来进行一个解析。那既然如此,我们为什么不将这个足够大的buffer直接分配到下层的底层的节点当中呢?也就是像下面这样一种情况,那我们可以根据D此请求当中的最大值来分配一个足够大的bufr来保证说所有的header和body都分配到同一个节点上

不同网络库优势

image.png

2.3.2 针对协议的优化——Headers 解析

找到Header Line边界:\r\n

先找到\n再看它前一个是不是\r

那能不能更快呢?SIMD

image.png

针对协议相关的Headers

快速解析:

1.通过Header key首字母快速筛除掉完全不可能的key

2.解析对应value到独立字段

3.使用byte slice管理对应header存储,方便复用

请求体中同样处理的Key:

User-Agent、Content-Type、Content-Length、Connection、Transfer-Encoding

image.png

2.3.3. 针对协议的优化——Header key规范化

例aaa-bbb —>Aaa-Bbb

header key规范化:可以看到我们这里提到了是一个问号,问号,我们转换成Aaa-Bbb这一类。转换的话其实就是我们所谓的header key的一个规范化,其实它就是想把我们的key转换成这种类似于手写这么大写的一个字符串,然后包括有一个中划线之后有一个接着首字母也会大写,这样就是我们的一个规范化。那我们在处理这类需求的时候,我们也是用了一个非常取巧的方式,可以看到我们的实现在我们左边,然后右边的话是我们的自己定义的两张表。这两张表一个叫做to lower table顶名思义就是把字母转成小写table第二个表就是把to upper table那就是把字母转成大写table那这两张表怎么用呢?其实是非常简单。我们可以看到左边我们直接把我们的对应的字母传递到我们的这个表里面来。这个表其实它就是一个byte数组,我们的字母其实也是可以理解为是一个byte那我们把byte传递到我们的这个表表格里面来,然后查阅到对应的返回,就是我们想要的一个大写或者小写了,那相当于我们直接是查表的方式。这个时间复杂度非常的低,应该是个O(1)的一个查间方式。OK这就是我们在做header key的规范化的时候做的一个优化。

image.png

2.3.4 热点资源池化

image.png

最后的话其实提到了是我们的一个热点资源池化设计,这个也是我们在做Golang开发过程中一个比较熟悉的手段。首先的话其实我们可以看到背景其实我们一个请求请求进来之后,我们有一个叫做requestcontext的一个资源,是需要去贯穿这个请求的一个完整生命周期,包括这个请求需要的资源像Request、.Response、conn等等,直到我们的一个响应回包给我们的client那这一块的话其实就伴随着说我们与请求是一一对应的。在高并发场景的话,那这块内存的分配以及释放对GC是一个非常大的一个压力。

image.png

对于我们的这个这种场景的话,我们有一个叫做context request context池,

我们把这个request放到池子里了。然后我们请求来的时候,我们池子里取

出来一个,我们做一些初始化,然后把它进行一个response的一个返回,之后我们处理完又把它放回去。按这个看起来是一个比较有意思的一个做法,就是我们这大家就共用一个池子里的资源,那这样的话就可以明显地减少我们整个GC或者是一个runtime的一个压力。

image.png

2.4 企业实践

追求性能

追求易用,减少误用

打通内部生态

文档建设、用户群建设

三、 课后个人总结

本堂课主要介绍了从零开始设计HTTP到实现全部功能,以及对性能提高的思考。

四、 引用参考

青训营官方课件