HTTP框架 | 青训营笔记

88 阅读9分钟

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

HTTP协议

HTTP协议简介

image-20220601114543252

HTTP:超文本传输协议(Hypertext Transfer Protocol)

协议需要提供一种标准:
需要明确的边界:

能够携带什么信息:

请求行:
方式、路径URL、协议版本号
请求头
请求体

方法名
HTTP GET
HTTP HEAD、POST
HTTP1.1 从PUT到TRACE以及PATCH

put完全更新,是幂等的
patch部分更新,不是幂等的



响应行
协议版本号、状态码、状态码描述
响应头
响应体

请求流程

image-20220601114750085

不足与展望

image-20220601114801801

HTTP1
由于是基于TCP实现的,所以会存在队头阻塞问题,也就是后续的分片需要等待前面分片的到来,才能继续发送后续的数据
传输效率比较低,简单的传输会附带上很多无用的信息
明文传输不安全

HTTP2
使用了多路复用提高传输效率,HTTP1中不支持多路复用,也就是一个连接中,如果一个请求没有结束是不会接收其他请求的
使用头部压缩提高传输效率,也就是将公用的头部的信息在两边都缓存起来,这样可以减少传输的信息中头部的数据量
二进制协议,解析起来很高效


QUIC
基于UDP实现
解决了TCP中的队头阻塞
优化了加密的算法减少了握手的次数
也支持快速启动

HTTP框架的设计与实现

分层设计

image-20220601165915613

image-20220601170014906

image-20220601171118732

分层设计:
高内聚低耦合
易用性
高扩展性

应用层:应用层就是跟用户直接打交道的一层,这一层会对请求进行一个抽象,包括像request respeonse context等等。这一层也会提供丰富易用的API。
中间层:可以对请求有一些预处理和后置处理的逻辑,比如说可以在请求前后打一些accesslog,打一些耗时的点,其他中间件比如说Reacovery中间件用于捕获panic。
路由层:路由层会有一个原生的路由实现来提供大家类似于注册、路由寻址的一些操作
协议层:现在一些http1.1已经不能满足我们所有的需求了,我们需要支持H2、Quic等等,甚至在TLS握手之后的ALPN协商升级操作,这次都需要能够方便的支持
网络层:不同网络库使用的场景并不相同,那也需要一个灵活替换网络库的能力。
Common层:主要是存放一些公共逻辑,这一部分可能每一层都会使用,

应用层设计

提供合理的API:
可理解性:接口名称简单易懂,不要视图在文档中说明,尽量做到见名知意,比如说ctx.Body()或是ctx.GetBody()
简单性:若是常用的接口尽量要提供给用户最简单的调用方式,常用的API放到上层,误用/低频的API放到下层,比如说ctx.GetHeader(),ctx.Request.Header.Peek()
冗余性:不要使用多套函数来实现一个功能,不需要冗余或能通过其他API组合得到的API
兼容性:对接口的实现不能轻易改变,要符合开闭原则
可测性:接口可测试的
可见性:最小暴露原则,不需要暴露的API不暴露,可以抽象为接口

中间件设计

image-20220601214205418

对应到Java中的filter

中间件需求:
配合Handler实现一个完整的请求处理生命周期
拥有预处理逻辑与后处理逻辑
可以注册多中间件
对上层模块用户逻辑模块易用

洋葱模型的一个示意图,首先经过一个日志的中间件的预处理之后,在经过metrics中间件的预处理,在处理完了之后请求才能进入一个真正的业务逻辑。
当请求执行完业务逻辑之后,还可能会有一些后处理,比如先经过metrics中间件的后处理,再经过日志中间件的后处理,请求才能返回
这个中间件的核心就是可以将业务代码与增强逻辑代码分离,也就是AOP的效果,适用的场景比如说日志记录、性能统计、安全控制、事务处理、异常处理等


这里filter跳转到下一个filter是通过next函数实现的,也就是认为各个中间件以及最终业务的函数指针都封装在一个数组里,通过调用next函数就可以将调用链中的所有函数都进行一次调用
next函数:
func (ctx *RequestContext) Next(){
		ctx.index++
		for ctx.index < int8(len(ctx.handlers)){
				ctx.handler[ctx.index]()
				ctx.index++
		}
}


当函数中有异常产生时,需要直接将整个调用链终止掉的时候,可以通过将index的值置为最后一个值,代表着调用链结束
abort函数:
func (ctx *RequestContext) Abort() {
		ctx.index = IndexMax
}

路由设计

image-20220601212840447

image-20220601214011856

对应到Java中的@RequestMapper

路由设计:考虑清除要解决什么问题,有哪些需求
明确需求:业界中都有哪些解决方案可供参考
方案权衡:思考不同方案的取舍
方案评审:相关同学对不同的方案做评审
确定开发:确定最合适的方案进行开发

静态路由:可以使用使用map存储
动态路由:前缀匹配树

匹配HTTP方法:使用map存储kv对,一个方法对应着一个前缀树


协议层设计

将协议抽象为一个接口
type Server interface {
		Server(c context.Context, conn network.Conn) error
}
需要传递一个context进入,并且需要一个连接并在上面读写数据

网络层设计

BIO阻塞式IO

NIO非阻塞式IO


BIO:go net
type Conn interface {
		Read(b []byte) (n int, err error)
		Write(b []byte) (n int, err error)
		...
}
这个连接相当于阻塞式的调用,也就是说当调用Read的时候,只有当有数据的时候或者是出现BUG的时候才会返回,没有数据的时候就会一直阻塞在这里。需要用户自己管理buffer,也就是传入buffer让内核进行读写的拷贝操作,用户自己需要对用户态中的buffer进行管理

NIO:netpoll
type Reader interface {
		Peek(n int) ([]byte, error)
		...
}
type Writer interface {
		Malloc(n int) (buf []byte, err error)
		Flush() error
		...
}
type conn interface {
		net.Conn
		Reader
		Writer
}

这里读数据的时候调用Peek是将buffer的拷贝方式交由网络库来进行管理,返回一个拷贝好的buffer数组,用户可以直接使用。写操作同理,先向网络库申请一块大小为n的空间,然后用户向这块申请到的空间内写数据,最后调用网络库的Flush函数将数据发送出去
这样将数组交由网络库来管理的好处就在于,网络库可以实现一套高效的读写方式,比如零拷贝这种方式,避免用户态内核态切换时候造成的性能损失


HTTP框架性能

针对网络库的优化

image-20220602114405199

对于go net的优化:
希望可以一次存下全部的header,而不是一部分header
减少系统调用次数
能用复用内存
能够多次读,如果一个header由于太大了一次读不完,希望可以再一次从头读
type Reader interface {
		Peek(n int) ([]byte, error)
		Discard(n int) (discarded int, err error)
		Release() error
		Size() int
		Read(b []byte) (l int, err error)
		...
}
go net with bufio
在每一个连接上面绑定一个缓冲区。根据内部的一个调研,大部分包都是在4k以下的,也就是可以绑定一块大小为4k左右的缓冲区
Peek()表示在读的时候指针不动,每次都是从头读
Discard()表示移动读数据的指针,每次可以从指针位置处读
Release()表示释放内存,下一次继续使用这个内存来读

type Writer interface {
		Write(p []byte)
		Size() int
		Flush() error
		...
}

image-20220602114332139

对于netpoll:
为了减少锁的竞争,netpoll采用了一种链表的设计方式。这样会导致一种跨节点的问题
由于linkbuffer是采用链表形式的,如果链表每一个节点大小太小的话,就会导致header会被分装到两个链表中,这时候就需要再分配一块足够大的buffer将两部分的header拼到一起,返回给框架进行解析。
这时候为了解决这种跨节点的问题,可以将每个链表节点分配较大的空间,对于这个较大的空间的定义,可以取历次最大的包的大小。这样就可以分配足够大的内存了

image-20220602114850699

针对协议的优化


找到Header Line的边界:\r\n
先找到\n再看它前一个是不是\r
SIMD(Single Instruction Multiple Data),指单指令多数据流技术,可以用一组指令对多组数据进行并行操作。
使用SIMD加速的json解析库sonic

Headers解析:
只对核心字段进行解析,舍弃解析不常用的字段,并且将高频字段存储在byte slice中


Header规范化:
也就是将ToUpper函数从一个一个字符进行加减法改变为查表的方式,不需要CPU的计算就可以直接得到最后的结果,牺牲了内存但是可以省下宝贵的CPU的计算资源


热点资源池化:
一般来说,每一个连接都会对应有一个RequestContext这样的大对象,当请求来的时候需要申请内存,当请求结束之后该内存又要释放。所以对于这样的热点资源进行一个池化处理,先分配一个RequestContext池,当请求来的时候直接从池中取内存,结束之后归还内存
取:
	减少了内存分配,提高内存复用
舍:
	在归还内存时会增加一个Reset逻辑
	请求内有效,当超出一个请求生命周期的话,这个requestcontext就变得不再可靠
	问题定位难度增加,会导致数据不一致的问题

企业实践

追求性能,提升框架性能

追求易用,减少误用,在提升性能的同时,向用户提供友好的使用逻辑

打通内部生态,减少重复造轮子

文档建设、用户群建设,提供全面的用户友好接口