这是我在字节跳动后端青训营的第五篇笔记,包含走进HTTP协议部分的内容。
HTTP 协议
前后端分离
前后端通过 HTTP 进行通讯
HTTP框架负责HTTP请求的解析,根据对应的路由选择对应的后端逻辑
再谈 HTTP 协议
最早大规模使用 HTTP 0.9 从 1991 年开始使用
现在还有更新的版本,如 HTTP/2 和 Quick
HTTP: Hypertext Transfer Protocol 超文本传输协议
超文本,超在哪里?
两台电脑通过网络传输 文本,但是文本没有办法满足传输需求,图片视频超链接,对text 进行了扩充,所以成了超文本。对应的传输协议就是 超文本传输协议
1.1 为什么需要协议
遵照一定的规则才能让传输的信息被理解
协议需要明确的边界,知道什么时候开始,什么收结束
需要能够携带信息,也就是协议元数据,从而知道消息的类型。
一个POST请求究竟在协议层做了什么?
1.2 协议里面有什么
看图片:
协议第一行 POST 接 URL 空格 HTTP版本 也就是请求行
四行是 键值对,是协议的元数据。 空行之后是body 然后换行符,协议结束。
检测到 请求行,就开始接收协议了,协议的结束时是 body(消息体) 和换行符
元数据也包含了对内容长度的描述,Server可以根据Content-length决定接收多少字节
来自Server的响应包括
HTTP版本 状态码 状态描述 metadata
所以HTTP协议包含的内容有:
请求行/状态行: 请求行包含:
- 方法名
- URL
- 协议版本
状态行包含:
- 协议版本
- 状态码
- 状态码描述
请求头/响应头
请求体/响应体
常见方法名
- GET
- HEAD
- POST
- PUT 完整更新,幂等
- DELETE
- CONNECT
- OPTIONS
- TRACE
- PATCH 部分更新,不是幂等的
状态码包括: 1xx:信息类 2xx:成功 3xx:重定向 4xx:客户端错误 5xx:服务端错误
实现简单的 HTTP 响应:
package main
import (
"context"
"code.byted.org/middleware/hertz/pkg/app"
"code.byted.org/middleware/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()
}
背后的处理流程:
业务层:业务方使用框架的API,完成逻辑 完成业务逻辑之后,进入服务治理逻辑。 服务治理层:依托于中间件层,对每个请求可以有一些先后处理的逻辑,比如,在进入业务层之前即使,业务层执行完毕后再次即使,进行上报,就可以知道整个业务逻辑的耗时。 对于Client,之后会进入协议的编解码。根据协议内容编译成Server能理解的协议,之后通过传输层传输给Server。
Server会多一个路由曾,根据 URI 选择对应的 handler,也就是选择对应的服务。
HTTP1:
- 基于 TCP,存在队头阻塞问题,后续分片必须等待前面分片的到来才能继续发送后面的数据,否则就会一直等待
- 传输效率低,哪怕只发送一句话,也要附带大量额外消息
- 不支持多路复用,在上一个请求结束前,不支持在发送其它请求
- 明文传输,不安全
HTTP2:
- 支持多路复用
- 头部压缩,把重复的 header 缓存起来,减少重复数据发送
- 二进制协议,解析更高效
- 还是基于TCP,没有解决队头阻塞问题。
- 为了加密使用TLS,握手的开销没有优化
QUIC 基于UDP,解决TCP队头阻塞 加密算法,减少握手次数 支持快速启动
2.1 分层设计
简化了系统的设计,让不同的人专注于某一层的事情。 HTTP框架聚焦于应用层
分层设计还可以提高扩展性和复用性
HTTP 框架的设计也应该采用分层设计 需要考虑高内聚,低耦合,复用性和扩展性。
框架分为 5 层 之间使用接口解耦
应用层直接和用户打交道,会对请求进行一些抽象
中间件层对用户有一些预处理和后处理的逻辑
路由曾提供路由来提供类似注册,寻址相关的操作
协议层:支持特定的网络协议,包括 HTTP2 Quic
传输层: 提供灵活替换网络库的能力
Common:存放公共库。
盖尔定律:切实可行的复杂系统从切实可行的简单系统发展而来
2,2 应用层设计:
提供合理的 API
- 可理解性: 如 ctx.Body() ctx.GetBody(),不要用 ctx.BodyA()
- 简单些:如 ctx.Request.Header.Peek(key) / ctx.getHeader(key),减少用户的工作量
- 冗余性 ctx.Body 和 ctx.GetBody() 可以做一样的是,不要有两个功能重复的接口
- 兼容性 接口实现不能轻易改变或弃用
- 可测性 可测试
- 可见性 为了安全性,不要暴露随便框架的核心,但对于能力强的用户,也要能够找到该接口如何使用
不要试图在文档中说明,很多用户不看文档
2.3 中间件设计
中间件需求:
- 配合 Handler 实现一个完整的处理生命周期
- 拥有预处理逻辑和后处理逻辑
- 可以注册多中间件
- 对上层模块用户逻辑模块易用
洋葱模型:
有请求过来了,通过日志中间件预处理,然后执行业务逻辑,业务逻辑退出,日志进行后处理,再将真正响应返回给用户。
中间件的合金就是它可以将核心逻辑于通用逻辑分离
适用场景包括:
- 日志记录
- 性能统计
- 安全控制
- 事务处理
- 异常处理
具体例子: 打印每个请求的request和response
没有中间件:需要在每个接口开始和结束分别加上打印内容。 有了中间件,只要添加一个中间件就行了。
- 预处理和后处理,很像调用函数,可以统一为一个函数 Next(),不需要区分到底是中间件还是业务逻辑,统一为直接调用下一个处理函数。只需要一个同样的函数签名就可以写中间件。
- 路由上可以注册多 Middleware,同时也可以满足请求级别有效。只需要将 Middleware 设计为和业务,和 Handler相同即可
- 用户如果不主动调用下一个处理函数怎么办?可以帮用户主动去调用之后的中间件,核心是任何场景下 index 保持递增
func (ctx * RequestContext) Next() {
ctx.index++
for ctx.index < int8(len(ctx.handlers)) {
ctx.handlers[ctx.index]()
ctx.index++
}
}
- 出现异常想停止怎么办?将index设为最大值,就跳出了循环
func (ctx *RequestContext) Abort() {
ctx.index = IndexMax
}
调用链:
有没有什么坑?
对于 Go,它们不再一个调用栈上。
比如 recovery 中间件,只能捕获本携程或者说本调用栈的 handler
适用场景:
- 不调用 Next: 初始化逻辑且不需要在统一调用栈
- 调用 Next: 后处理逻辑或者需要在同一调用栈上
2.4 路由设计 框架路由实际上就是为 URL 匹配对于的处理函数 (Handlers)
- 静态路由 /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 方法
- 多处理函数:方便添加中间件
- ...
例如:
xuexi.cn 是一个页面
xuexi.cn/cc72a........一长串 是另一个页面
这就是一个路由
路由可以根据 URL 选择不同的页面,这个页面可以是一个前端路由,也可以由后端直接把页面返回给前端
路由设计: 青铜:map[string]handlers
很快,很简单,但只对静态路由有效
黄金: 前缀匹配树
对参数路由怎么处理? 对匹配树进行改进,对冒号进行匹配,然后添加fullpath
如何匹配 HTTP 方法:
可以构造很多课路由树,外层直接是一个 Map,根据 Method 进行初步筛选。
如何实现多处理函数: 在每个节点上使用 List处理 Handler
2.5 协议层设计: 抽象出合适的接口:
- 根据Golang官方推荐,不要把COntext 写到 Struct 里面,而是显式通过函数的第一个参数传递
- 需要在连接上读写数据,需要把连接也传递出来
- 如果有 error 需要抛给上层
2.6 网络层设计
BIO 和 NIO
BIO:Block IO:每次接收一个连接回开一个go routing,数据读取完了再response,如果数据都一般,就会卡在这里 NIO:注册一个监听器,监听到有足够数据,再唤醒 func,没有阻塞
go 的标准库 go net 是典型的 BIO,由用户管理 Buffer
如果底层没有数据,还要调用接口,就会卡在这里。
netpoll 是字节自研的网络库,采用NIO编程模式,由网络库管理 Buffer