(四)Http框架修炼之道 | 青训营

89 阅读14分钟

走进Http协议

HTTP协议是什么

超文本:不仅仅是文本

为什么需要协议

就像平时说话一样,你的语言必须遵循一定的格式别人才能理解。 数据在网络中是01序列,只有按照一定的规律组织,机器之间才能互相识别传递的信息。

协议里面有什么

一个常见的post请求在协议层究竟做了什么?

你:

  • 请求行
  • key-value对
  • 空行
  • payload
  • 空行

小姐姐:

  • 状态行
  • key-value对
  • 空行
  • responsebody
  • 空行

patch是在1.1之后新增的方法。

demo参考文档:https://www.cloudwego.io/zh/docs/hertz/getting-started/

飘红原因:gdk版本不对,hertz目前支持的是1.16版本 github.com/cloudwego/h…

并设置从go modules里面读取依赖。

C:\Users\mask-007\go\pkg\mod\github.com\henrylee2cn\ameda@v1.4.10\int.go:13:2: constant 9223372036854775807 overflows int
C:\Users\mask-007\go\pkg\mod\github.com\henrylee2cn\ameda@v1.4.10\uint.go:13:2: constant 18446744073709551615 overflows uint

原因:下载了gdk16 x86(32未)的安装包 ,导致int变小。9223372036854775807 64位int的最大值

Demo

hertz是字节开发的http框架

package main

import (
	"context"

	"github.com/cloudwego/hertz/pkg/app"
	"github.com/cloudwego/hertz/pkg/app/server"
)

func main() {
	h := server.New()

	h.POST("/sis", func(c context.Context, ctx *app.RequestContext) {
		//text/plain是值纯文本
		ctx.Data(200, "text/plain;charset=utf-8", []byte("ok"))
	})

	h.Spin()
}

不足与展望

http1:

  1. 队头阻塞:在一个连接上,如果某个请求阻塞了(比如下载一个大文件),后续请求也会被阻塞,因为 HTTP/1.0 和 HTTP/1.1 都是串行处理请求。
  2. 传输效率低:基于TCP的。 在 HTTP/1 中,每个请求和响应都需要在一个独立的连接上完成,每次连接的建立和关闭都会消耗时间,同时每个连接都需要携带相同的头部信息,导致数据传输效率较低。此外,由于一个连接上只能发送一个请求,无法有效利用带宽,也限制了并发请求的效率。
  3. 明文传输不安全

http2:

HTTP/2 是对之前的 HTTP/1.1 协议进行了重大改进,引入了多个新特性来提高性能、效率和安全性。

gpt3.5解释:

  1. 多路复用(Multiplexing): HTTP/2 引入了多路复用技术,允许在同一个 TCP 连接上同时传输多个请求和响应。在 HTTP/1 中,每个请求都需要一个独立的连接,而 HTTP/2 则可以在一个连接上并行传输多个请求和响应,避免了队头阻塞问题,提高了性能和效率。
  2. 头部压缩(Header Compression): 在 HTTP/1 中,每个请求和响应的头部信息都需要重复传输,造成了不小的开销。HTTP/2 使用 HPACK 压缩算法对头部信息进行压缩,减少了传输开销,提高了效率。这使得头部信息的传输更加紧凑,减少了网络传输的负担。
  3. 二进制协议(Binary Protocol): HTTP/2 使用二进制分帧传输,而不是像 HTTP/1 那样使用文本协议。这意味着在传输过程中使用二进制编码,更加高效。每个数据帧都有一个帧头,用于标识帧的类型、长度等信息,这种二进制的帧结构更加紧凑,降低了传输开销。

quic

gpt3.5解释

QUIC(Quick UDP Internet Connections)是一种基于 UDP(User Datagram Protocol)的网络传输协议,由谷歌开发,旨在提供更快、更安全的网络连接。以下是关于 QUIC 的几个主要特点:

  1. 基于UDP实现: QUIC 是建立在 UDP 协议之上的,这使得它可以充分利用 UDP 的高性能、低延迟的特点,而不受 TCP 的一些限制。

  2. 解决队头阻塞: QUIC 使用多路复用技术,允许多个数据流在一个连接上同时传输,从而解决了 HTTP/1 和 HTTP/2 中的队头阻塞问题。这意味着即使一个数据流阻塞了,其他数据流仍然可以继续传输,提高了性能和并发性。

  3. 加密减少握手次数: QUIC 在连接建立阶段使用 0-RTT 握手和 1-RTT 握手,这些握手过程包括数据的加密和解密,从而减少了连接建立的延迟。这对于网页加载等场景能够带来明显的性能提升。

  4. 支持快速启动: QUIC 支持快速启动,这意味着在连接建立时可以立即开始传输数据,而不需要等待拥塞窗口逐渐增大。这有助于在连接刚建立时就实现更高的传输速度。

总的来说,QUIC 旨在解决传统协议中存在的一些问题,如队头阻塞和连接建立延迟,从而提供更快、更稳定、更安全的网络传输体验。虽然 QUIC 在一些情况下能够带来明显的性能提升,但它也需要服务器和客户端都支持才能发挥其优势。

HTTP框架的设计与实现

分层设计

分层设计的特点:

专注性:对应的人员只需要关注对应层次的开发。 扩展性:分层更方便扩展。 复用性:不同的层次和不同的模块是可以复用的。

  1. 应用层
  2. 中间件层
  3. 路由层
  4. 协议层
  5. 网络层

应用层设计

提供合理的api

  1. 可理解性:如ctx.Body(), ctx.GetBody() 不要用ctx.BodyA()
  2. 简单性:如获取某个headerValue,ctx.Request.Header.Peek(key) 可以简写成ctx.GetHeader(key)
  3. 冗余性:有了ctx.Body()就不要ctx.GetBody()了
  4. 兼容性:不要轻易的删除接口,新的版本要对旧的版本兼容。
  5. 可测性:写出来的接口要保证是可以测试的。
  6. 可见性:可见性控制,具体就是指使用者可以对框架的修改到哪一步,为了安全性。不要把核心的代码暴露出去给使用者修改,防止框架被玩崩了。

不要试图在文档中说明,很多使用者不看文档

中间件设计

中间件需求:

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

image-20230802203958663.png转存失败,建议直接上传图片文件

Request进来经过日志中间件的前处理,然后经过Metrics中间件的前处理,然后执行Handler Response它通过Metrics后处理,再经过日志中间件后处理,最后返回处理好的response 常用于:核心逻辑和通用逻辑分离。

举例:打印每个请求的request和response 参考文档:www.cloudwego.io/zh/docs/her…

package main

import (
	"context"
	"github.com/cloudwego/hertz/cmd/hz/util/logs"
	"github.com/cloudwego/hertz/pkg/app"
	"github.com/cloudwego/hertz/pkg/app/server"
)

func main() {
	//返回一个hertz实例
	hertz := server.New()

	//放入中间件
	//打印每个请求的request和response
	hertz.Use(func(c context.Context, ctx *app.RequestContext) {
		//print request
		str := ctx.Request.Header.String()
		bytes, _ := ctx.Body()
		body := string(bytes)
		str += body
		logs.Infof("Receive RawRequest:\n%s", str)

		//nextHandler
		ctx.Next(c)

		//print response
		strResp := ""
		strResp += string(ctx.Response.Header.Header())
		strResp += string(ctx.Response.Body())
		logs.Infof("Send RawResponse:\n%s", strResp)
	})

	hertz.POST("/login", func(c context.Context, ctx *app.RequestContext) {
		//some biz logic
		ctx.JSON(200, "ok")
	})

	hertz.POST("/logout", func(c context.Context, ctx *app.RequestContext) {
		ctx.JSON(200, "ok")
	})

	//run server
	hertz.Spin()

}

将Middleware设计为和业务逻辑相同的handler,这样就不用区别Midlleware和Handler了

func Middleware(param){
    //预处理
    
    Next()
    
    //后处理
}

如果用户不主动调用下一个处理函数怎么办? 解决办法:在任何场景下保证handler数组的index递增

出现异常想停止怎么办? index直接增加到最大

context.go

func (engine *Engine) Use(middleware ...app.HandlerFunc) IRoutes {
	engine.RouterGroup.Use(middleware...)
	engine.rebuild404Handlers()
	engine.rebuild405Handlers()
	return engine
}//路由上可以注册多个中间件

// Next should be used only inside middleware.
// It executes the pending handlers in the chain inside the calling handler.
func (ctx *RequestContext) Next(c context.Context) {
	ctx.index++
	for ctx.index < int8(len(ctx.handlers)) {
		ctx.handlers[ctx.index](c, ctx)
		ctx.index++
	}
}

//出现异常调用这个函数
func (ctx *RequestContext) Abort() {
	ctx.index = rConsts.AbortIndex
}

中间件B不调用next 中间件C调用next 中间件A先调用中间件B,再调用中间件C 后处理逻辑或需要在同一调用堆栈上 同一调用堆栈的例如:panic()和recovery() 后处理逻辑例如:

	//打印每个请求的response
	hertz.Use(func(c context.Context, ctx *app.RequestContext) {


		//nextHandler
		ctx.Next(c)
		//print resonse
		strResp := ""
		strResp += string(ctx.Response.Header.Header())
		strResp += string(ctx.Response.Body())
		logs.Infof("Send RawResponse:\n%s", strResp)
	})

思考:有没有其他实现中间件的方式

路由设计

路由实际是为URL匹配的处理函数,包括静态路由和动态路由,对于静态路由可使用map,key是URL,value是其handler,动态路由则需前缀树,每个节点用list存储handler

使用前缀树[tri树]可以统一静态路由和动他路由。 右下角多了一个/(或者可能是路由修复) 举个例子(在路径匹配不发生冲突的情况下):/a/m/d 左边的分支走不了,只能走右边的分数/a/被匹配 m被冒号匹配 /被匹配 d被匹配 从而选的/a/:b/d的handler进行处理。

多处理函数:在 Web 开发中,路由处理中的多处理函数通常用于将多个处理逻辑(多个handler)组合到一个路由上,以实现更复杂的业务需求。

思考:如何查找路由?

2:站在巨人的肩膀上前行。

协议层设计

Do not store Contexts inside a struct type; instead ,pass a Context explicitly to each funcion that needs it. 不要将上下文存储在结构类型内;相反,将Context显式传递给每个需要它的函数。

explicitly : 明确地

个人理解:不要把上下文放到struct中作为属性,而是直接显示的传递。

hertz框架的protocol包:

package protocol

import (
	"context"

	"github.com/cloudwego/hertz/pkg/network"
)

type Server interface {
	Serve(c context.Context, conn network.Conn) error
}

type StreamServer interface {
	Serve(c context.Context, conn network.StreamConn) error
}

再来一个显示传递的例子

type User struct {
    Username string
    Password string
}

func (u *User) Login() {
    // 使用 u.Username 和 u.Password 进行登录验证
    // ...
}

//显示传递
func LoginWithContext(username, password string) {
    // 使用传递的 username 和 password 进行登录验证
    // ...
}

这样第二种方式不仅User类能用,其他类也能用。 如果是多个类共有的方法建议是抽象成接口。

网络层设计

​ bio/nio/aio

同步阻塞IO和同步非阻塞IO。 前者是每次accept获取一个连接后,开一个goroutine单独处理,读完后处理业务逻辑再写会响应,若读数据时读到一半就读到这里啥也干不了。

解决办法是引入通知机制,每次accept但拿到连接后,把它加到一个监听器中,另外一部分去轮询monitor即监听器,查看空闲的通道(链接),找到空闲的通道或者链接让其去处理业务,而不是让其干等。

go net库 bio 由使用者自己来管理buffer

type Conn interface{
    //读取bytes数组,当没有数据你调用这个接口就会阻塞,直达超时或者链接关闭
    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)
        }
    }
}

netpoll库 nio模型 网络库管理buffer

参考网址:www.cloudwego.io/zh/docs/net…

type Reader interface{
    //n 代表需要的数据 当有了足够的数据 才会执行下一步
    //如果没有准备好,nio模型就去做别的事情
    //因为不知道什么时候能读取到这么多数据,所以nio什么时候把数据发出去是不确定的
    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)
            }
        }
    }
}

bio 一个协程或者进程对应一个链接,当Read方法读不到数据时会阻塞。 nio(non-block io): 一些协程或者进程对应多个通道,每个通道必须读取够一定的数据才会执行一系列action,所以不会阻塞。

总结


性能修炼之道

针对网络库的优化

net

image-20230805121545894.png转存失败,建议直接上传图片文件

type Conn interface{
    Read(b []byte)(n int,err error)
    Write(b []byte)(n int,err error)
    ...
}

需要存下全部的HTTP header 减少系统调用次数:减少内核态和用户态的切换。 复用内存,提高资源利用率。 针对header的处理多次读

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

在read()上封装一层成为reader实际上就是多加了一个缓冲区

bufio.go的Reader数据结构

// Reader implements buffering for an io.Reader object.
type Reader struct {
	buf          []byte
	rd           io.Reader // reader provided by the client
	r, w         int       // buf read and write positions
	err          error
	lastByte     int // last byte read for UnreadByte; -1 means invalid
	lastRuneSize int // size of last rune read for UnreadRune; -1 means invalid
}

netpoll

足够大的buffer,根据历史请求分配一个足够大的buffer,减少拷贝次数。 当一个大的请求把这个buffer撑大之后,小的请求也会分配这样一个大的内存。 这样是不行的,所以我们需要限制最大的buffer size

对比

go net :

  1. 流式友好:由用户来调用来读取,用户不读取数据永远在TCP的缓冲区里面。
  2. 小包性能高:直接绑定了一个buffer

netpoll:

  1. 中大包性能高:底层维护了一组buffer
  2. 实延低

针对协议的优化

Headers 解析

找到Header Line 边界:\r\n

先找到\n看其前一个是不是\r,复杂度是O(n)

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

更快的办法:simd(Single Instruction Multiple Datastream),一组指令对多组数据进行匹配 例如查找Header边界时,同时比较十六个字符。

使用byte slice管理对应的header存储,方便复用。例如User-Agent等常用的key都用byte slice来存储。

取:

  1. 核心字段快速解析,通过首字母
  2. 使用byte slice存储对应的header方便复用
  3. 将常用的header的value存储到成员变量中。

舍: 普通header性能较低,没有map结构

Header Key规范化

toUpperTable和toLowerTable是两个hash表,转换是O(1)的时间复杂度。

取:

  • 超高的转换效率
  • 比net.http提高40倍(相比assii码)

舍:

  • 额外的内存开销
  • 哈希表随着assii码表的变更会发生变更

热点资源池化

每个请求都有requestcontext的资源,贯穿请求的完整生命周期,包括Request、Response、conn等,直到响应给client,与请求一一对应,高并发时,内存分配与释放对GC是非常大的压力,使用请求池,请求来时从中取出,做初始化并进行响应返回,处理完又放回池子,明显减少GC或runtime的压力。

gpt3.5

Runtime 压力:在这里,"runtime" 可能指的是编程语言的运行时环境,比如Java的JVM、Python的解释器等。频繁的内存分配和释放也会导致运行时环境的压力增大,可能引发性能瓶颈或不稳定的行为。通过使用请求池,您可以减少对运行时环境的资源需求,降低了因频繁内存操作而导致的运行时压力。

优点是减少内存分配、提高内存复用、减少GC压力、性能提升,缺点是额外放回池中需做复杂的Reset逻辑,因为这块内存会被下次请求复用,不做Reset会给下次请求造成影响,其次超出请求生命周期该context不在可靠,里面数据不保证什么周期外可靠,最后这两缺点带来数据不一致的问题,导致定位困难。

总结

针对网络库的优化:buffer设计(为什么要针对header进行一个特殊的优化,因为header必须全部拿到才能进行下一步解析)

针对协议的优化:header解析、热点资源池化

企业实践

这部分是作者在字节内部的实践分享,最开始一味追求性能就是王道,确实性能是属于一个非常非常重要的。但是后面发现,还有其他工作比性能优先级更高。比如易用性减少误用。性能和易用性两个是矛盾的,因此给设计带来难度,面临非常多的取舍。发现很多做业务的开发者在用的时候不能正确使用框架。而且很多错误都是在高并发才显现的并发问题。问题非常的难查。核心是让业务方能快涑写出对的代码。在此之上再去做性能优化。第三是打通内部生态,框架毕竟只做了部分,但生态其实有很多,如果每个业务开发者都去做同样或相似的生态,开销非常大。第四关于文档建设和用户群建设,主要是为减少双方的成本,把常见的问题总结成文档,追求让每个开发工程师变成能直接从文档中复制粘贴代码就可以用的cv工程师。

keyword:

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

补充

就是看了一会儿同步 异步 阻塞 非阻塞。 感觉异步本身就是非阻塞的。 同步非阻塞和同步阻塞的区别在于任务等待资源的时候你是去干别的还是一直等着。 同步非阻塞假设调用了一个函数去处理任务,无所谓是否立即拿到函数的结果,就是轮询任务是否需要的资源全部准备好了,准备好了我就执行。