HTTP框架的修炼之道 | 青训营

71 阅读10分钟

一、再谈HTTP协议:

1.HTTP协议是什么?

HTTP是超文本传输协议,具有无状态、可缓存、简单快速、灵活的特点。

为什么需要协议?

信息在网络上传输的实际上是01数据流,因此我们需要知道数据流的开始与结束,即明确信息的边界。同时我们还要明确元数据对信息的描述,明确消息的类型、大小等。

一个常见的POST请求在协议层究竟做了什么?
  1. 建立TCP连接。
  2. 客户端向服务器发送HTTP请求报文,请求报文中包含请求方法、请求头、请求正文等信息。
  3. 服务器收到请求报文后,对其进行处理,并将处理结果封装成HTTP响应报文,返回给客户端。
  4. 客户端收到响应报文后,对其进行解析和处理,得到响应结果。
  5. 关闭TCP连接。

2.协议里面有什么?

  • 请求报文:包括请求方式、资源路径URL、状态码、远程地址、引荐信息、请求头、请求正文。

3.请求流程:

请求流程包括业务层、服务治理层和中间层、路由层、协议编解码层、传输层。其中业务层是业务相关的逻辑,中间层是常说的熔断、限流等。而服务治理层依托中间件层,它对每个请求可有先处理逻辑和后处理逻辑,是和请求级别绑定的。对client来说经过上两层就进入编解码层,就是协议编码和解析,最后通过传输层完成传输。而server来说就多个路由层,它根据URL选择对应的执行的handler。

4.不足和展望:

HTTP1

  1. 首先对于HTTP1来说,因为它是基于 TCP 。基于 TCP 的都会有一个这头阻塞的问题,后续的分片必须要等待前面的分片的到来才能继续发送后面的数据,否则的话会一直等待。
  2. 第二个是他的传输效率很低,就像刚刚我只想传输Let's watch a movie together  但是这里面的无用的信息其实非常的多,存在很多重复的头部什么的。
  3. 除此之外,HTTP1也不支持多路复用,这个请求没结束之前是不能再发送其他请求的。最后是他的明文传输不安全,也能看到刚刚我和小姐姐的沟通完全就是明文沟通,想隐藏也不行。

HTTP2

  1. HTTP2 解决了HTTP1一部分,但没有完全解决。
  2. 比如说可以多路复用... 二进制协议解析起来更加高效。
  3. 但是由于 HTTP2 还是基于 tcp 的并没有解决对头阻塞的问题,而且握手的开销也没有优化。

于是出现了 QUIC 在 UDP 就是基础上解决得上刚刚才说的两个问题。

二、HTTP框架的设计与实现:

1.分层设计:

  • 专注性
  • 扩展性
  • 复用性

屏幕截图 2023-08-24 235023.png

在进行分层设计时我们需要考虑的几个point:

  • 高内聚 低耦合
  • 复用性
  • 扩展性

这个是我们进行的一个分层实践。这个架构的话其实从整体上来看的话,我们从上往下总共分为了五层,层与层之前使用接口解耦。

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

屏幕截图 2023-08-24 235547.png 接口设计时考虑的点:设计之前可以参考一下业界成熟的方案,做好充分的调研,结合自己的场景,先设计出一版可以使用的接口,之后如果有需求/瓶颈再慢慢优化,也是OK的。

屏幕截图 2023-08-24 235725.png

2.应用层设计:

提供合理的 API:

  • 可理解性:如ctx.Body(), ctx.GetBody(),不要用 ctx.BodyA()
  • 简单性:如ctx.Request.Header.Peek(key) / ctx.GetHeader(key)
  • 冗余性:不需要冗余或能通过其他API组合得到的API
  • 兼容性:尽管避免break change,做好版本管理
  • 可测性:写的接口是需要可测试的
  • 可见性:最小暴露原则,不需要暴露的API不暴露,可以抽象为接口。

3.中间件设计:

中间件需求:

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

屏幕截图 2023-08-25 000006.png

我们来举个具体点例子,比如说我们想打印出来每个请求的 request 和 response 那平时如果没有中间件,我们需要怎么办呢?

没加中间件之前:

屏幕截图 2023-08-25 000245.png

需要在每一个业务逻辑的代码当中去加上头和尾加上两句话去打印出来我们的一个 request 和 response

加了中间件之后:

屏幕截图 2023-08-25 000255.png

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

①. 既然要实现预处理和后处理,那这个就很像调用了一个函数

屏幕截图 2023-08-25 000613.png

屏幕截图 2023-08-25 000705.png

②. 路由上可以注册多个Middleware,同时也可以满足请求级别有效只需要将Middleware 设计为和业务和Handler相同即可。

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

屏幕截图 2023-08-25 000840.png 如果用户只有预处理逻辑,没有后处理逻辑怎么办呢?比如只想完成一些初始化。考虑到用户真正希望执行的是业务逻辑,那我们可以主动帮用户调用一下之后的中间件。 屏幕截图 2023-08-25 000848.png 核心:在任何场景下index保证递增

④. 出现异常想停止怎么办?

屏幕截图 2023-08-25 001005.png

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

  • 不调用Next:初始化逻辑且不需要在同一个调用栈
  • 调用Next:后处理逻辑或需要在同一调用栈上

其他实现中间件的方式:

package middleware  
  
import "net/http"  
  
// Middleware 接口定义了中间件需要实现的方法  
type Middleware interface {  
Process(http.Handler) http.Handler  
}  
  
// HTTPHandler 是处理HTTP请求的基本接口  
type HTTPHandler interface {  
ServeHTTP(http.ResponseWriter, *http.Request)  
}  
  
// SimpleMiddleware 是一个简单的中间件实现示例  
type SimpleMiddleware struct {  
next http.Handler  
}  
  
// Process 方法实现了Middleware接口,用于对http.Handler进行处理  
func (sm *SimpleMiddleware) Process(next http.Handler) http.Handler {  
return sm.Wrap(next)  
}  
  
// Wrap 方法用于包装http.Handler,将其传递给下一个中间件或最终处理程序  
func (sm *SimpleMiddleware) Wrap(next http.Handler) http.Handler {  
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {  
// 在处理请求之前执行一些操作  
// ...  
  
// 调用下一个处理程序  
next.ServeHTTP(w, r)  
  
// 在处理请求之后执行一些操作  
// ...  
})  
}  
  
// NewSimpleMiddleware 创建一个新的SimpleMiddleware实例  
func NewSimpleMiddleware(next http.Handler) *SimpleMiddleware {  
return &SimpleMiddleware{next: next}  
}

上述伪代码中,我们定义了一个Middleware接口,其中只有一个Process方法。该方法接受一个http.Handler作为参数,并返回一个经过处理的http.Handler。这样,我们就可以通过实现该接口来创建自定义的中间件。

SimpleMiddleware结构体中,我们实现了Middleware接口,并提供了Wrap方法来包装http.Handler。在包装过程中,我们可以执行一些自定义的操作,例如日志记录、身份验证、请求转换等。然后,我们调用下一个处理程序(通过next.ServeHTTP(w, r)),最后还可以执行一些操作,例如响应缓存、响应转换等。

通过这种方式,我们可以创建多个不同的中间件,每个中间件都可以根据自己的需求对请求进行不同的处理。在实际应用中,我们可以通过组合和嵌套不同的中间件来实现复杂的逻辑和功能。

4.路由层设计:

框架路由实际上就是为URL匹配对应的处理函数(Hand lers)

  • 静态路由: /a/b/c、 /a/b/d
  • 参数路由: /a/: id/c (/a/b/c, /a/d/c)、 /*all
  • 路由修复: /a/b <-> /a/b/
  • 冲突路由以及优先级: /a/b、/: id/c
  • 匹配HTTP方法
  • 多处理函数:方便添加中间件

青铜:map[string]handlers

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

黄金:前缀匹配树

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

屏幕截图 2023-08-25 001449.png

屏幕截图 2023-08-25 001456.png 如何匹配HTTP方法?

屏幕截图 2023-08-25 001502.png

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

屏幕截图 2023-08-25 001508.png

如何做设计?

  1. 明确需求:考虑清楚要解决什么问题、有哪些需求
  2. 业界调研:业界都有哪些解决方案可供参考
  3. 方案权衡:思考不同方案的取舍
  4. 方案评审:相关同学对不同方案做评审
  5. 确定开发:确定最合适的方案进行开发

5.协议层设计:

屏幕截图 2023-08-25 001802.png

6.网络层设计:

屏幕截图 2023-08-25 001939.png

屏幕截图 2023-08-25 001928.png go net 是由用户管理的 buffer,这两个接口都是传入 buffer,进行读或者写,那它本身是不管理buffer的。

屏幕截图 2023-08-25 002234.png

netpoll是字节内部自研的网络库,目前已经开源。

netpoll:github.com/cloudwego/n…

屏幕截图 2023-08-25 002240.png

屏幕截图 2023-08-25 002244.png

三、性能修炼之道:

1.针对网络库的优化:

屏幕截图 2023-08-25 002716.png go net:

  • 存下全部Heaher
  • 减少系统调用次数
  • 能够复用内存
  • 能够多次读

go net with bufio:

绑定一块缓冲区

屏幕截图 2023-08-25 003008.png

屏幕截图 2023-08-25 002724.png netpoll:

  • 存下全部Header
  • 拷贝出完整的Body

屏幕截图 2023-08-25 002728.png

不同网络库优势:

go net:

  • 流式友好
  • 小包性能高

netpoll:

  • 中大包性能高
  • 时延低 屏幕截图 2023-08-25 002740.png

2.针对协议的优化 -- Headers 解析

找到Header Line 边界:\r\n

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

func index(b []byte, c byte) int {  
 for i := 0i < len(b); i++ {  
  if b[i] == c {  
   return i  
  }  
 }  
 return -1  
}

有没有更快的找到n的方法呢?SIMD

SIMD:全称Single Instruction Multiple Data,SIMD(单指令多数据流)是一种计算机指令集,用于加速处理多个数据项的任务,如向量运算和位操作等。通过SIMD指令,一台计算机可以同时处理多个数据项,从而提高处理速度和效率。

Sonic:github.com/bytedance/s…

使用SIMD加速和未使用比较:

屏幕截图 2023-08-25 004015.png 针对协议相关的Headers快速解析:

  1. 通过 Header key 首字母快速筛除掉完全不可能的key
  2. 解析对应value到独立字段
  3. 使用 byte slice 管理对应 header 存储,方便复用

请求体中同样处理的Key:

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

取:

  • 核心字段快速解析
  • 使用byte slice存储
  • 额外存储到成员变量中

舍:

  • 普通 header 性能较低
  • 没有map结构

3.针对协议的优化 -- Header key 规范化:

aaa-bbb —> Aaa-Bbb

这一部分的取舍:

取:

  • 超高的转换效率
  • 比net.http 提高40倍

舍:

  • 额外的内存开销
  • 变更困难

4.热点资源池化:

屏幕截图 2023-08-25 004428.png

屏幕截图 2023-08-25 004434.png

取:

  • 减少了内存分配
  • 提高了内存复用
  • 降低了GC压力
  • 性能提升

舍:

  • 额外的Reset逻辑
  • 请求内有效
  • 问题定位难度增加

四、企业实践:

目的:

  • 追求性能
  • 追求易用,减少误用
  • 打通内部生态
  • 文档建设、用户群建设

目前内部有HTTP框架:Hertz

1万+服务 3千万+QPS

总结:

HTTP框架是构建高效、可扩展的Web应用的关键,而了解其内部原理和优化技巧对于提升我们的开发能力和提高应用性能具有重要意义。

优化HTTP框架的方法有很多,以下是一些常见的策略:

  1. 优化请求:减少请求次数、合并CSS和JavaScript文件、使用CDN等。
  2. 处理状态码:合理使用状态码,如200、404、500等,以便更好地诊断问题和优化应用。