【字节青训营-3】:初探HTTP协议及HTTP框架设计与实现

一、HTTP协议介绍

HTTP协议:超文本传输协议(Hypertext Transfer Protocol)。超文本协议就是传输多种数据在网络上(比如图片、视频等)。是一种用于分布式、协作式超媒体信息系统的应用层协议,主要用于在互联网上实现客户端与服务器之间的通信。以下是关于HTTP协议的详细介绍:

前后端分离的时候比较经典的是HTTP协议及框架。

在这里插入图片描述

为什么需要协议呢,因为首先传输的时候,需要知道一个明确的边界,也就是信息Text是什么时候开始的,又是什么时候结束的。

另外就是需要明确这个协议能够携带什么信息:消息类型、什么消息等。

在这里插入图片描述

HTTP1.1协议

在HTTP/1.1中,队头阻塞是一个关键问题。由于HTTP/1.1是基于TCP协议的,每个请求和响应都需要在TCP连接上按顺序处理。如果一个请求被阻塞(例如,由于服务器处理延迟或网络问题),后续的所有请求也会被延迟。这种现象称为“队头阻塞”。也就是HTTP/1.1的请求和响应是基于文本的,且每个请求必须等待前一个请求完成才能发送。

另外就是传输效率低。请求头冗余:每个请求都包含大量的重复信息(如Host、User-Agent等),这些信息在多个请求中重复传输,浪费了带宽。连接管理:尽管HTTP/1.1引入了持久连接(Keep-Alive),但仍然无法避免连接的频繁建立和关闭,增加了开销。串行处理:由于队头阻塞,多个请求无法并行处理,导致资源加载时间延长。

HTTP/1.1默认使用明文传输,这意味着数据在传输过程中可能会被窃听或篡改(数据泄露、中间人攻击等安全问题。)。虽然可以通过HTTPS(HTTP + TLS)来解决安全问题,但HTTP/1.1本身并不支持加密。

HTTP/1.1不支持多路复用,即一个TCP连接在同一时间内只能处理一个请求。这导致了多个请求需要排队等待处理,进一步加剧了队头阻塞问题。

HTTP2协议

HTTP/2引入了多路复用技术,允许在同一个TCP连接上并行传输多个请求和响应。这意味着多个资源可以同时加载,而不会相互阻塞。HTTP/2将数据分割成多个帧(Frame),每个帧可以独立传输,从而实现并行处理。

HTTP/2采用了HPACK算法对请求和响应头进行压缩,减少了头信息的冗余传输。

HTTP/2使用二进制格式而不是文本格式来传输数据。二进制协议的解析更加高效,减少了解析错误的可能性。相比HTTP/1.1的文本协议,二进制协议更加紧凑,解析速度更快。

但是依然存在下面的这些问题:
队头阻塞:虽然HTTP/2在应用层解决了队头阻塞问题,但TCP协议本身的队头阻塞问题依然存在。
握手开销:TCP的三次握手和TLS的握手过程仍然会引入延迟,尤其是在高延迟网络环境下。

HTTP3与QUIC

HTTP/3 是基于 QUIC 协议实现的下一代HTTP协议。

它继承了HTTP/2的多路复用、头部压缩等特性,并通过QUIC解决了TCP的队头阻塞和握手延迟问题。

HTTP/3的目标是进一步提升网络传输效率和用户体验,尤其是在高延迟和不稳定的网络环境中。

QUIC在协议层面集成了TLS加密,减少了握手的开销。它通过预共享密钥(PSK)等机制,实现了快速连接建立。QUIC支持零往返时间(0-RTT)连接,这意味着客户端可以在第一次发送请求时就携带数据,而无需等待服务器的响应。

二、一个常见的POST请求

这里我们可以看看常见的POST请求在协议层到底做了什么?

POST /sis HTTP/1.1
Who:Alex
Content-Type:text/plain
HOST:127.0.0.1:8888
Content-Length:28

This is Content.

简单来说,POST代表了HTTP请求方法。
/sis表示的是URL请求路径,表示客户端请求的资源位置。服务器会根据这个路径找到对应的资源或者处理逻辑,也就是可以理解成一个特定的接口或者页面路径。
HTTP1.1是目前比较广泛的版本,支持持久连接、管道化等特性。

协议元数据就是从Who开始的这一行到Content-Length这一行结束,协议元数据结束后加一个换行,就到了真正的传输内容,传输内容结束之后,再加一个换行,就代表POST请求结束。

据此得到的回复可以如下:

HTTP/1.1 200 OK
Server:hertz
Data:Thu,21,Apr 2022 11:46 GMT
Content-Type:text/plain;charset=utf-8
Content-Length:2
Upstream-Caught:165052492834329

OK

所以可以总结就是,第一行是 请求行/状态行,然后就是对应的请求头/响应头。最后就是 请求体/响应体

  • 请求行:方法名(POST、GET、HEAD、PUT、DELETE、CONTENT、OPTIONS、TRACE、PATCH等)、URL、协议版本。

PATCH是HTTP1.1后新增的,PUT请求通常用于完全替换目标资源的内容。客户端需要提供完整的资源表示,而不是部分更新。PUT是幂等的,即多次执行相同的操作结果相同。例如,多次发送相同的PUT请求,资源的最终状态是相同的。PATCH请求用于对资源进行部分更新。客户端只需要发送需要修改的部分数据,而不是完整的资源表示。PATCH通常是非幂等的,因为多次执行相同的操作可能会导致不同的结果。例如,多次对一个字段进行增量更新可能会导致不同的最终值。

  • 状态码:1xx: 信息类、2xx:成功、3xx:重定向、4xx:客户端错误、5xx:服务端错误。

三、简单demo实现

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) {
		ctx.Data(200, "text/plain;charset=UTF-8", []byte("ok"))
	})
	h.Spin()
}
  • github.com/cloudwego/hertz/pkg/app/server: 提供创建服务器实例的功能,允许开发者定义路由规则并启动HTTP服务
  • github.com/cloudwego/hertz/pkg/app: 定义了处理请求上下文的核心接口 RequestContext 和 Context 接口,用于访问 HTTP 请求/响应对象以及操作头部信息等。

然后我们run这个go代码,并用apipost接口工具进行请求,可以完成请求并得到对应的返回内容ok。

在这里插入图片描述

接下来我们详细看看请求流程。
在这里插入图片描述
首先在业务层,业务方使用提供的框架去完成业务逻辑,(比如定义好返回什么东西之类的)。然后接着就会进入服务治理逻辑与中间件层,这一层就是熔断、限流,这一层对于请求可以有先处理或者后处理的逻辑,和请求的优先级。

在服务端server会多一个路由层,也就是根据url去决定一个对应的处理。

四、分层设计

分层设计的时候需要注意三个特性:专注性、拓展性、复用性。

下面是OSI七层模型、TCP\IP四层模型。
在这里插入图片描述在这里插入图片描述

对应到框架设计的时候就转化为几个对应的点:高内聚、低耦合、易复用、高拓展性等。

从上到下依次是应用层、中间件层、路由层、协议层、网络层,在最右边时Common,放每一层公共使用的逻辑。

在这里插入图片描述

4.1 Application应用层设计

应用层是直接和用户打交道的,需要提供合理的API,这也就需要满足“可理解性”,比如ctx.Body()、ctx.GetBody(),不要用ctx.BodyA()这样的命名方式进行提供API,要让大家一眼知道这个接口是干什么的。

第二个是简单性,比如ctx.Request.Header,Peek(key) -> ctx.GetHeader(key)。

第三个是冗余性,就是做同样的事情的话,不要有两个接口,或者说一个功能需要两个接口拼起来完成。

后面还有兼容性、可测性、可见性(主要是安全方面、还有接口的使用难度,比如某种说法“不要试图在文档中进行说明,很多用户不看文档”)等。

4.2 middleware中间件层设计

中间件层的需求如下:
1、配合Handler实现一个完整的请求处理生命周期(在Web开发中,请求处理器Request Handler是处理HTTP请求的函数或模块。它接收请求、处理逻辑,并返回响应)。
2、用于预处理逻辑和后处理逻辑。
3、可以注册多中间件。
4、对上层模块用户逻辑模块易用。

中间件设计:
这里我们可以看看洋葱模型。当一个Request请求进来之后,首先经过日志中间件预处理,然后经过Metrics中间件预处理,最后再去执行业务逻辑,然后经过Metrics的后处理,还有日志的后处理,然后把一个真正的response响应返回给用户。

这个使用场景是:日志记录、性能统计、安全控制、事务处理、异常处理等。

在这里插入图片描述

接下来我们看看一个代码案例的说明:

在这里插入图片描述

既然我们需要实现预处理还有后处理,这个就很像调用一个函数。

func Middleware(some param){
	// some logic for pre-handle
	//比如日志记录、身份验证、性能监控等
	...
	
	//将请求传递给下一个中间件或者最终的业务
	next Middleware() /bizlogic()
	//等同于 
	Next()
	
	// some logic after-handle
	//包括日志记录、清理资源、错误捕捉等
	...
}

路由上可以注册多Middleware,同时也可以满足请求级别有效,只需要将Middleware设计为和业务、和Handler相同即可,也就是统一调用下一个函数。

package main

import (
    "log"
    "net/http"
    "time"
)

// Middleware 是一个中间件函数,接收一个 http.Handler 并返回一个新的 http.Handler
func Middleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // 预处理逻辑
        start := time.Now()
        log.Printf("Request received: %s %s", r.Method, r.URL.Path)

        // 调用下一个中间件或业务逻辑
        next.ServeHTTP(w, r)

        // 后处理逻辑
        duration := time.Since(start)
        log.Printf("Request completed in %v", duration)
    })
}

// BizLogic 是一个简单的业务逻辑处理函数
func BizLogic(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("Hello, Middleware!"))
}

func main() {
    // 创建一个业务逻辑处理器
    bizLogic := http.HandlerFunc(BizLogic)

    // 将中间件包装到业务逻辑处理器上
    handler := Middleware(bizLogic)

    // 启动HTTP服务器
    http.ListenAndServe(":8080", handler)
}

但是需要考虑一个问题,如果用户不主动调用下一个处理函数怎么办。比方说用户只调用了中间件,那就只能我们帮助用户去实现这样一个逻辑。核心就是保证index在任何场景递增。

中间件框架的核心逻辑,用于确保中间件链中的每个处理器(handler)都能被正确调用。

//RequestContext 是一个上下文对象,用于管理中间件链的执行。
func (ctx *RequestContext) Next() {
	ctx.index++
	for ctx.index< int8(len(ctx.handlers)){
		ctx.handlers[ctx.index]()
		ctx.index++
	}
}

当中间件出现异常,可以通过这样进行处理

func (ctx *RequestContext) Abort(){
	ctx.index = IndexMax
	//让index为最大值跳出最大值。
}

可以看看下面的调用链,但是存在一个问题,就是不在一个调用栈上。

比如某中间件,只能捕获自己协程的panic或者自己调用栈上的panic,不能捕获别人协程的。

在这里插入图片描述

为了解决这个问题,一般有以下几种方法进行对应的解决:
1、在协程内捕获panic,比如用defer和recover。
2、避免在中间件启动新的协程。
3、使用上下文context传递错误,

4.3 Route路由设计

框架路由实际上是位URL匹配对应的处理函数(Handlers):
1、静态路由:/ab/c
2、参数路由:/a/:id/c、/*all
3、路由修复:/a/b <-> /a/b/ (也就是最后一个/要不要敲)
4 、冲突路由以及优先级:/a/b 、 /:id/c
5、匹配HTTP方法
6、多处理函数:方便添加中间件

最开始设计路由,可能会联想到map,比如说:map[string]handlers,但是这种只对静态路由有效,优势就是比较快、比较简单。

进阶的话可以用前缀匹配树,一层一层匹配。

对于参数路由,也可以构建路由树,同时需要结合fullpath来标记自己到底是进入的哪个路由。

在这里插入图片描述所以总的来说,应该如何匹配HTTP方法?我们构建很多个路由树,比如下面的路由映射表。
在这里插入图片描述
外层是map,根据method进行初步筛选,然后再进行前缀树的匹配。

那么进一步如何实现多处理函数?也就是在某个节点上使用一个list存储handler。

node struct {
	prefix string
	parent *node
	children children
	handlers app.HandlersChain
	...
}

4.5 codec协议层设计

首先抽象出合适的接口,这里需要遵循一个Go语言社区的设计原则:
不要将Context存储在结构体中;相反,应该将Context显式地传递给每一个需要它的函数,并且Context应该是函数的第一个参数。(许多标准库函数,比如http.Handler也遵循了这种约定,将Context作为第一个参数。)

type Server interface {
	serve(c context.Context,conn network.Conn) error
	//第一个是context
	//第二个是conn,因为可能需要在连接上读写数据
	//返回error,就是感知到error之后抛给上层进行解决
}

一旦Context被存储在结构体中,它就很难被动态更新或替换。例如,你可能需要在运行时更改超时时间或取消信号,但存储在结构体中的Context很难做到这一点。同时多个协程可能会共享同一个结构体实例,从而导致对Context的竞争条件或状态不一致。最后就是如果Context被隐藏在结构体中,外部代码很难对其进行控制或模拟,这会给单元测试带来困难。

显式传递Context有以下好处:
明确性:通过将Context作为函数参数传递,可以清晰地表明哪些函数需要Context,以及Context的用途。这使得代码的可读性和可维护性更高。
灵活性:显式传递Context允许在调用函数时动态地提供不同的Context实例,例如在某些情况下使用超时Context,而在其他情况下使用无超时的Context。
易于测试:在单元测试中,可以轻松地传递一个模拟的Context,从而更好地控制测试环境。

4.6 transport网络层设计

首先我们要明确两种IO方式,分别是BIO,即Block IO阻塞IO和NIO,非阻塞IO。

BIO的示例代码如下:

go func(){
	for{
		conn,_ := listener.Accpet()
		go func(){
			conn.Read(request)
			handle...
			conn.Write(response)
		}()
	}
}()

NIO示例代码如下:

go func(){
	for {
		readbleconns,_:=Monitor(conns)
		//监听器,当监听到有足够的数据之后,才去唤醒func
		for conn := range readbleConns{
			go func() {
				conn.Read(request)
				handle...
				conn,Write(response)
			}()
		}
	}
}()

golang中的标准库是go net,也就是“BIO”,有两个关键的接口,这两个都是用户态去传入buffer进行操作。

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

字节自研了网络库netpoll,是NIO的,https://github.com/cloudwego/netpoll

type Reader interface{
	//peek传入的n是表示期望底层能够给我们传输这么多的数据
	//当底层有这么多数据的时候,才会唤醒func
	Peek(n int)([]byte,error)
	...
}

type Writer interface{
    // Malloc 分配一个大小为 n 的缓冲区,然后把数据提交到底层空间里面,后面再调用flush把数据发送出去,因此需要对buffer进行管理。
    // 如果分配成功,返回一个缓冲区和 nil 错误。
	Malloc(n int)(buf []byte,err error)
	 // Flush 将缓冲区中的数据刷新到目标存储中。
	Flush() error
	...
}

由于采用NIO的方式,并不知道数据什么时候被发出去。
因此需要将数据写入底层(或者说保证数据不变),所以需要网络库对buffer进行管理。

type Conn interface{
	net.Conn
	Reader
	Writer
}
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
红包 添加红包
表情包 插入表情
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值