一、背景
在网络层中TCP协议是字节流的,而应用层协议大多是面对消息的,那么应用层的服务器需要从TCP提供的字节流中正确解析消息,这里就要求我们在设计应用层消息包的时候需要定义划分方案。
常见的划分方案
- 固定长度字段:在每个数据块前面添加一个表示数据长度的字段,这个字段的长度固定。
- 变长长度字段:在每个数据块前面添加一个表示数据长度的字段,这个字段的长度可以根据数据大小进行调整。
- 分隔符标记:使用特殊字段或者字节序作为数据快的结束标识。
- 消息边界:接收方可以通过预设的消息边界来识别单个消息
网络消息包划分方案的应用场景
- HTTP协议:采用“分割符标记”的划分方案。
在header和body之间,使用了一对特殊的字符序列"\r\n\r\n"(即两个连续的回车换行符)作为分隔符,标志着头部信息的结束和主体内容的开始。接收方在读取到这个分隔符后,就知道接下来的数据为主体内容。此外,HTTP消息的每个头部字段都是一个名/值对,字段名和字段值之间由冒号":"分隔,而不同的头部字段之间则由回车换行符"\r\n"分隔。
- HTTP流式传输:采用“变长长度字段”和“分隔符标记”结合的划分方案
流式传输主要是指数据能够以连续、非阻塞的方式发送和接收,尤其是对于大文件或者实时数据流的传输场景中很重要。其特点在于服务器可以一边生成数据一边发送,而无需等到所有数据准备好再一次性发送。客户端在接收到数据后也可以一边接收一边处理,而不是必须等待整个响应接收完毕
对每一个数据块(chunk),首先会发送一个包含该数据块大小的十六进制数,然后是"\r\n",接着是数据本身,最后再是一个"\r\n"。在这种模式下,长度字段和分隔符都在起作用。
当接收到的数据块大小为0时,表示所有的数据块都已经发送完了。然后可能会有一些额外的头信息(optional trailer),之后是最后的"\r\n",表示整个传输过程结束。
二、Jocko对消息的处理
1、消息包划分方案
jocko中使用固定长度字段来实现应用层通信协议中划分消息。
接下来阅读请求体和响应体的encode和decode代码。
2、处理消息的核心代码
2.1 封装消息
protocol/encoder.go 这个是封装消息的核心代码
func Encode(e Encoder) ([]byte, error) {
lenEnc := new(LenEncoder)
err := e.Encode(lenEnc) // 计算编码后的数据长度,传入LenEncoder的结构体
if err != nil {
return nil, err
}
b := make([]byte, lenEnc.Length) //
byteEnc := NewByteEncoder(b) // 这个是真正的编码过程 传入ByteEncoder的结构体
err = e.Encode(byteEnc)
if err != nil {
return nil, err
}
return b, nil
}
type Encoder interface {
Encode(e PacketEncoder) error
}
上面的代码采用两遍编码的策略,这个策略常常应用在需要预先知道数据长度的场景中。
第一次调用e.Encode(lenEnc)是为了计算编码后的数据长度,因为LenEncoder.Encode方法只统计字节长度而不真正存储数据,然后根据统计的字节字长创建了一个刚好容纳该请求内容大小的字节数组b。
第二次调用e.Encode(byteEnc)是真正的编码过程,ByteEncoder.Encode方法会真正地把数据编码并存储到刚分配好的b中。
这样的两步处理,可以避免一开始就分配一个可能过大的缓冲区(浪费内存),同时也不用处理动态扩展缓冲区的问题(可能影响执行效率)。这样的设计能在获取准确缓冲区大小的同时,实现了数据的有效编码。
下面是编码策略中的两个数据结构:LenEncoder、ByteEncoder的具体字段,
type LenEncoder struct {
Length int // 存储请求内容的总长度
stack []int
}
type ByteEncoder struct {
b []byte // 存储请求内容的字节数组
off int
stack []PushEncoder
}
2.2 解析消息
protocol/decoder.go 这个是解析消息的核心代码
func Decode(b []byte, in VersionedDecoder, version int16) error {
d := NewDecoder(b)
return in.Decode(d, version)
}
type VersionedDecoder interface {
Decode(d PacketDecoder, version int16) error
}
在实际调用中,每个具体的操作只要是实现上诉对应的interface即可。
3、消息处理的流程
接下来阅读消息的封装和解析在整个项目中的核心代码
3.1 请求体encode-封装请求消息的流程
func (c *Conn) writeRequest(body protocol.Body) error {
req := &protocol.Request{
CorrelationID: c.correlationID,
ClientID: c.clientID,
Body: body,
}
b, err := protocol.Encode(req) // 对请求进行编码操作
if err != nil {
return err
}
_, err = c.wbuf.Write(b) // 将编码之后生成的二进制放入到conn bufio.writer 中
if err != nil {
return err
}
return c.wbuf.Flush()
}
3.2 请求体decode-解析请求消息的流程
func (s *Server) handleRequest(conn net.Conn) {
defer conn.Close()
for {
// 首先从conn中读取4个字节
p := make([]byte, 4)
_, err := io.ReadFull(conn, p[:])
... ...
size := protocol.Encoding.Uint32(p) // 大端字节序的方式解析这个p 得到消息长度
... ...
// 根据长度构建 存放消息的数组,并将全部内容放入到该数组中
b := make([]byte, size+4)
copy(b, p)
if _, err = io.ReadFull(conn, b[4:]); err != nil {
... ...
}
// 解析请求head消息
d := protocol.NewDecoder(b)
header := new(protocol.RequestHeader)
if err := header.Decode(d); err != nil {
... ...
}
var req protocol.VersionedDecoder
// 根据head消息的APIKey判断这个请求属于什么类型的操作
// 从而制定对应结构体进行解析消息
switch header.APIKey {
case protocol.CreateTopicsKey:
req = &protocol.CreateTopicRequests{}
... ...
}
if err := req.Decode(d, header.APIVersion); err != nil {
... ...
}
// 最终将解析的结构封装到contex结构体中
reqCtx := &Context{
parent: ctx,
header: header,
req: req,
conn: conn,
}
// 发送到server结构体的requestCh channel中
s.requestCh <- reqCtx
}
上面的代码是从每次的连接中读取消息进行解析,解析的过程是按照消息长度大小进行的,其中这里面的消息分为两部分,一部分是请求头消息,一部分是请求的具体内容。
其中请求头消息的结构体如下:
type RequestHeader struct {
// Size of the request
Size int32
// ID of the API (e.g. produce, fetch, metadata)
APIKey int16
// Version of the API to use
APIVersion int16
// User defined ID to correlate requests between server and client
CorrelationID int32
// Size of the Client ID
ClientID string
}
解析消息的时候是通过APIKey来判断这次请求的类型,从而使用对应的结构体进行解析请求内容。
3.3 响应体encode—封装响应消息
在源码中的处理过程,涉及到server responseCh字段,这个是channel类型,生产者是往这个字段中加入处理完成请求的返回内容,消费者是将返回内容编码,然后生成字节数组,最后写入到Conn中。源码内容如下:
func (s *Server) Start(ctx context.Context) error {
... ...
go func() {
for {
select {
case <-ctx.Done():
break
case <-s.shutdownCh:
break
case respCtx := <-s.responseCh: //将返回结果编码成字节数组,然后写入到Conn中
... ...
if err := s.handleResponse(respCtx); err != nil {
... ...
}
}
}
}()
// 使用server的 responseCh 字段传递响应体的消息
go s.handler.Run(ctx, s.requestCh, s.responseCh)
}
处理请求,向server responseCh 字段生产消息
// 对requests 字段而言是消费者,同时对responses字段而言是生产者
func (b *Broker) Run(ctx context.Context, requests <-chan *Context,
responses chan<- *Context) {
for {
select {
case reqCtx := <-requests:
... ...
var res protocol.ResponseBody // 定义响应体字段
switch req := reqCtx.req.(type) { //根据请求的类型判断哪个响应体实现类来返回结果
case *protocol.CreateTopicRequests:
res = b.handleCreateTopic(reqCtx, req)
... ...
}
... ...
// 将返回的消息写入到responses字段
responses <- &Context{
parent: responseCtx,
conn: reqCtx.conn,
header: reqCtx.header,
res: &protocol.Response{
CorrelationID: reqCtx.header.CorrelationID,
Body: res,
},
}
}
}
}
从server responseCh 字段消费消息,并进行编码操作。
func (s *Server) handleResponse(respCtx *Context) error {
... ...
b, err := protocol.Encode(respCtx.res.(protocol.Encoder))
... ...
_, err = respCtx.conn.Write(b)
return err
}
3.4 响应体decoder-解析响应消息
从conn的读缓冲区peek前8个字节,注意是不从缓冲区移除这部分数据的。然后将缓冲区的前4个字节转成size,即定义的消息协议声明的包大小,以及将后4个字节转成id,即这次包的id。
func (c *Conn) waitResponse(d *connDeadline, id int32) (deadline time.Time, size int, lock *sync.Mutex, err error) {
for {
... ...
if rsz, rid, err = c.peekResponseSizeAndID(); err != nil {
... ...
}
if id == rid {
c.skipResponseSizeAndID()
size, lock = int(rsz-4), &c.rlock
return
}
... ...
}
}
func (c *Conn) peekResponseSizeAndID() (int32, int32, error) {
b, err := c.rbuf.Peek(8)
if err != nil {
return 0, 0, nil
}
size, id := protocol.MakeInt32(b[:4]), protocol.MakeInt32(b[4:])
return size, id, nil
}
根据上面代码中得到的size大小,来获取响应消息并进入protocol/encoder.go 进行解析。
func (c *Conn) readResponse(resp protocol.VersionedDecoder, size int, version int16) error {
b, err := c.rbuf.Peek(size)
if err != nil {
return err
}
err = protocol.Decode(b, resp, version)
c.rbuf.Discard(size)
return err
}
三、总结
Jocko中在应用层协议中定义一套固定长度的消息包划分方案,在编码消息中采用两次编码的方式实现数据的高效编码,在解析响应消息的时候,使用reader的peek和discard两种方法配合,从而有效地预览、决策并管理缓冲区的数据。