稀土掘金文章链接
课程背景
如图是前后端分离的HTTP流程图,HTTP协议是最广泛使用的协议之一。本文主要包括HTTP协议、HTTP框架的设计与实现、性能优化、企业实践等,而如何设计与实现一个HTTP框架是本文的重点,在此之前先了解HTTP协议
1 HTTP协议
1.1HTTP协议是什么?
http协议是超文本传输协议(Hypertext Transfer Protocol),是text文本类型资源的扩充。为何需要协议呢?直接传输不就行了?
1.2为什么需要协议呢?
协议是交互双方的约定规则,这样才能互相理解,实际上网络上传输的是01数据流,因此协议首先需要明确信息的边界,即明确信息什么时候开始与结束,其次需要元数据对信息的描述,明确消息的类型、大小等
1.3协议里都有什么?
总结HTTP协议如下图,提下PUT和PATCH的区别:PUT完整更新而PATCH部分更新,因此PUT幂等而PATCH非幂等,状态行是经典的三段式:协议班吧、状态码、状态码描述
1.4请求流程
完整的请求流程包括业务层、服务治理层和中间层、路由层、协议编解码层、传输层。其中业务层是业务相关的逻辑,中间层是常说的熔断、限流等。而服务治理层依托中间件层,它对每个请求可有先处理逻辑和后处理逻辑,是和请求级别绑定的。对client来说经过上两层就进入编解码层,就是协议编码和解析,最后通过传输层完成传输。而server来说就多个路由层,它根据URL选择对应的执行的handler
1.5不足与展望
首先HTTP1基于TCP,基于TCP的都有队头阻塞的问题,即后续分片须等待前面的分片的到来才继续发后面的数据,否则一直等待。其次传输效率很低,比如只传输一个字节,传输的无效信息非常多,存在很多头部信息。也不支持多路复用,即一个请求没结束前不能再发送其他请求。最后是明文传输不安全。HTTP2解决部分。如可多路复用,协议解析更高效。但HTTP2仍基于tcp,未解决队头阻塞,而目握手开销也没优化。于是出现了QUIC协议,在UDP基础上解决这两个问题,但应用不广,如有些路由器甚至都不转发udp协议。
2.HTTP框架的设计与实现
2.1分层设计
分层设计可简化系统设计,让不同层专注做某一层次的事,只需通过接口,专注特定层开发即可,不需关注底层实现。其次分层更容易横向扩展。最后分层可做到很高的复用。如图是协议的分层
同样http框架设计也应分层设计,考虑高内聚低耦合、易复用、高扩展性等,如图是字节内部的分层实践,类似上面的请求完整流程图:
主要包括应用层(直接跟用户打交道,对请求抽象,包括request response context等,会提供丰富易用API)、中间层(对请求有预处理和后处理的逻辑,如accesslog、recovery中间件捕获panic等)、路由层(实现类似注册、路由寻址的操作)、协议层(Websocket、HTTP2、QUIC协议的支持)、网络层(网络库)、common层(公共逻辑)
2.2应用层设计
首先不要试图在文档中说明,因为很多用户不看文档,因此需要在应用层序提供合理的API,包括:
- 可解释性:使用主流的概念方便理解,如选择使用ctx.GetBody()或ctx.Body()的时候,选择ctx.GetBody(),因为其能一眼看出来这个请求是要干什么
- 简单性:常用API放到上层,易误用/低频AP放下层
- 可见性:最小暴露原则,不需暴露API就不暴露,可抽象为接口
- 冗余性:不需要冗余或能通过其他API组合得到的API
- 兼容性:尽量避免break change做好版本管理
- 可测性:写出来的接口保证是可测试的
2.3中间层设计
中间件需配合handler实现完整请求处理生命周期,有预处理和后处理逻辑,可注册多中间件,对上层模块易用。常用的洋葱模型,核心是将核心逻辑与通用逻辑分离,如图:
先经过日志中间件预处理后经过metrics中间件预处理,之后进行真正业务逻辑,最后退出业务逻辑得到后处理,先经过metric中间件后处理,其次经过日志中间件后处理,再将响应返回给用户。适用场景包括:日志记录、性能统计、安全控制、事务处理、异常处理等
func Middleware(param){
//预处理
Next()
//后处理
}
中间件调用有点像函数调用,同时也可满足请求级别有效,只需将Middleware设计为业务和Handler相同即可,就不用区分是中间件还是业务逻辑,统一为直接调用下一个处理函数,抽象为Next()方法,对服务治理易用
若用户不主动调用下一个处理函数,核心是保持中间件的handler的索引递增
func (ctx *RequestContext) Next(){
ctx.index++
for ctx.index<len(ctx.handlers){
ctx.handlers[ctx.index]()
ctx.index++
}
}
若出现异常,直接将Index设置为最大值,直接跳出上述所说的让handler索引递增的循环就可以了
func (ctx *RequestContext) Abort() {
cts.index = IndexMax
}
中间件的调用链:
中间件A调用下一个中间件B,中间件B不调用next,则返回,中间件A调用中间件C,中间件C调用next执行业务Handler,调用完了再返回
2.4路由设计
路由实际是为URL匹配的处理函数,包括静态路由和动态路由,对于静态路由可使用map,key是URL,value是其handler,动态路由则需前缀树,每个节点用list存储handler
2.5协议层设计
抽象出合适的接口,实现Serve的接口,传入标准context(注意不要将context存储在结构体)和读写的连接,返回error
type server interface{
Serve(c context.Context,conn network.Conn) error
}
2.6网络层设计
阻塞IO(BIO)和非阻塞IO(NIO),前者是每次accept获取一个连接后,开一个goroutine单独处理,读完后处理业务逻辑再写会响应,若读数据时读到一半就读到这里啥也干不了。在NIO中,连接在被接受后不会立即创建一个线程来处理,而是将连接注册到一个选择器(selector)上。读取数据时,应用程序可以通过轮询选择器来检查哪些连接是可读的,只有当连接有数据可读时,才会分配线程去处理。
type Conn interface{
Read(b []byte)(n int,err error)
Write(b []byte)(n int,err error)
...
}
go func(){
for{
conn,_:=listener.Accept()
go func(){
conn.Read(request)
handle...
conn.Write(response)
}
}
}
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
}
go func(){
for{
readableConns,_:=Monitor(conns)
for conn:=range readableConns{
go func(){
conn.Read(request)
handle...
conn.Write(response)
}
}
}
}
3.性能修炼之道
3.1针对网络库的优化-Buffer设计
- 存下全部header,http头部没有length,所以需存下全部header才能进行下一个解析
- 减少系统调用次数,系统调用设计内核态和用户态切换
- 复用内存,提高资源利用率
- 针对header的处理可多次读
基于此考虑在标准库接口封装一层buffer,调研发现大部分包都在4k以下,所以可分配4k缓存,其次读时读指针不动即Peek,让指针移动Discard,最后回收内存Release,分配足够大的buffer保证将header和body都分配到同个节点
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)
...
}
type Writer interface{
Write(p []byte)
Size() int
Flush() error
...
}
netpool相对官方包,对中大包性能高,时延低
3.2针对协议层的优化
header解析
相比于使用遍历找到\n再看前一个是否是\r的遍历算法,使用SIMD,用一组指令对多组数据进行并行操作,提高header解析效率。
针对协议相关的headers快速解析,通过key首字母筛掉完全不可能的key;解析对应value到独立字段;使用byte slice管理对应header存储,方便复用。
将所有header的key统一规范化,如aaa-bbb->Aaa-Bbb
热点资源池化
每个请求都有requestcontext的资源,贯穿请求的完整生命周期,包括Request、Response、conn等,直到响应给client,与请求一一对应,高并发时,内存分配与释放对GC是非常大的压力,使用请求池,请求来时从中取出,做初始化并进行响应返回,处理完又放回池子,明显减少GC或runtime压力
优点是减少内存分配、提高内存复用、减少GC压力、性能提升,缺点是额外放回池中需做复杂的Reset逻辑,因为这块内存会被下次请求复用,不做Reset会给下次请求造成影响,其次超出请求生命周期该context不在可靠,里面数据不保证什么周期外可靠,最后这两缺点带来数据不一致的问题,导致定位困难