为了加快进度,这里暂时先跳过前面不是特别重要或自己比较熟悉的内容,后续再总结
背景和介绍
如图是前后端分离的HTTP流程图,HTTP协议是最广泛使用的协议之一。本文主要包括HTTP协议、HTTP框架的设计与实现、性能优化、企业实践等,而如何设计与实现一个HTTP框架是本文的重点,在此之前先了解HTTP协议
HTTP协议
http协议是超文本传输协议,是text文本类型资源的扩充。为何需要协议呢?直接传输不就行了?
为什么需要协议
协议是交互双方的约定规则,这样才能互相理解,实际上网络上传输的是01数据流,因此协议首先需要明确信息的边界,即明确信息什么时候开始与结束,其次需要元数据对信息的描述,明确消息的类型、大小等
协议里有什么
如图是http协议的内容,包括请求行、请求头字段和请求主体
总结HTTP协议如下图,提下PUT和PATCH的区别:PUT完整更新而PATCH部分更新,因此PUT幂等而PATCH非幂等,状态行是经典的三段式:协议班吧、状态码、状态码描述
请求流程
完整的请求流程包括业务层、服务治理层和中间层、路由层、协议编解码层、传输层。其中业务层是业务相关的逻辑,中间层是常说的熔断、限流等。而服务治理层依托中间件层,它对每个请求可有先处理逻辑和后处理逻辑,是和请求级别绑定的。对client来说经过上两层就进入编解码层,就是协议编码和解析,最后通过传输层完成传输。而server来说就多个路由层,它根据URL选择对应的执行的handler
不足与展望
首先HTTP1基于TCP,基于TCP的都有队头阻塞的问题,即后续分片须等待前面的分片的到来才继续发后面的数据,否则一直等待。其次传输效率很低,比如只传输一个字节,传输的无效信息非常多,存在很多头部信息。也不支持多路复用,即一个请求没结束前不能再发送其他请求。最后是明文传输不安全。HTTP2解决部分。如可多路复用,协议解析更高效。但HTTP2仍基于tcp,未解决队头阻塞,而目握手开销也没优化。于是出现了QUIC协议,在UDP基础上解决这两个问题,但应用不广,如有些路由器甚至都不转发udp协议。
HTTP框架的设计与实现、
分层设计
一个切实可行的复杂系统势必是从一个切实可行的简单系统发展而来的。从头开始设计的复杂系统根本不切实可行,无法修修补补让它切实可行。你必须由一个切实可行的简单系统重新开始。------盖尔定律
分层设计可简化系统设计,让不同层专注做某一层次的事,只需通过接口,专注特定层开发即可,不需关注底层实现。其次分层更容易横向扩展。最后分层可做到很高的复用。如图是协议的分层
同样http框架设计也应分层设计,考虑高内聚低耦合、易复用、高扩展性等,如图是字节内部的分层实践,类似上面的请求完整流程图:
主要包括应用层(直接跟用户打交道,对请求抽象,包括request response context等,会提供丰富易用API)、中间层(对请求有预处理和后处理的逻辑,如accesslog、recovery中间件捕获panic等)、路由层(实现类似注册、路由寻址的操作)、协议层(Websocket、HTTP2、QUIC协议的支持)、网络层(网络库)、common层(公共逻辑)
应用层设计
首先不要试图在文档中说明,因为很多用户不看文档,因此需要在应用层序提供合理的API,包括:
- 可解释性:使用主流的概念方便理解,如ctx.GetBody()或ctx.Body()而不是ctx.BodyA()
- 简单性:常用API放到上层,易误用/低频AP放下层
- 可见性:最小暴露原则,不需暴露API就不暴露,可抽象为接口
- 冗余性:不需要冗余或能通过其他API组合得到的API
- 兼容性:尽量避免break change做好版本管理
中间层设计
中间件需配合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++
}
}
中间件调用链:
路由设计
路由实际是为URL匹配的处理函数,包括静态路由和动态路由,对于静态路由可使用map,key是URL,value是其handler,动态路由则需前缀树,每个节点用list存储handler
如何做设计
- 明确需求:考虑清楚要解决什么问题,有哪些需求
- 业界调研:业界有哪些解决方案可供参考
- 方案权衡:思考不同方案的取舍
- 方案评审:相关人员对不同方案评审
- 确定开发:确定最合适的方案进行开发
协议层设计
抽象出合适的接口,实现Serve的接口,传入标准context(注意不要将context存储在结构体)和读写的连接,返回error
type server interface{
Serve(c context.Context,conn network.Conn) error
}
网络层设计
阻塞IO和非阻塞IO,前者是每次accept获取一个连接后,开一个goroutine单独处理,读完后处理业务逻辑再写会响应,若读数据时读到一半就读到这里啥也干不了,解决办法是引入通知机制,每次accept但拿到连接后,把它加到一个监听器中,另外一部分去轮询monitor即监听器,搜索可读连接数并开协程处理,没阻塞
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)
}
}
}
}
性能修炼之道
针对网络库的优化-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相对官方包,对中大包性能高,时延低
针对协议的优化
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不在可靠,里面数据不保证什么周期外可靠,最后这两缺点带来数据不一致的问题,导致定位困难
企业实践
这部分是作者在字节内部的实践分享,最开始一味追求性能就是王道,确实性能是属于一个非常非常重要的。但是后面发现,还有其他工作比性能优先级更高。比如易用性减少误用。性能和易用性两个是矛盾的,因此给设计带来难度,面临非常多的取舍。发现很多做业务的开发者在用的时候不能正确使用框架。而且很多错误都是在高并发才显现的并发问题。问题非常的难查。核心是让业务方能快涑写出对的代码。在此之上再去做性能优化。第三是打通内部生态,框架毕竟只做了部分,但生态其实有很多,如果每个业务开发者都去做同样或相似的生态,开销非常大。第四关于文档建设和户群建设,主要是为减少双方的成本,把常见的问题总结成文档,追求让每个开发工程师变成能直接从文档中复制粘贴代码就可以用的cv工程师。