codec
模块设计:codec
codec做什么
网络通信过程中,数据都是以二进制数据的形式进行发送的,这里的二进制数据是由我们内存中的数据结构经过一定的序列化、压缩、协议编码来生成的。数据发送方、接收方必须采取匹配的序列化方式、压缩解压缩算法、协议,才能正确地完成通信。
为了说清楚这个过程,我们不妨举个例子来说明下这里的逻辑。这里将使用google protocolbuffers(以下简称pb或protobuf)作为IDL(Interface Definition Language)来说明我们的服务接口,如果读者对pb的使用还有些陌生的话,可以参考pb的官方文档。
syntax = "proto3";
package helloworld;
message HelloRequest {
string msg = 1;
}
message HelloResponse {
int32 err_code = 1;
string err_msg = 2;
}
service HelloService {
rpc Hello(HelloRequest) returns(HelloResponse);
}
以上pb中定义了一个服务接口Hello,该接口的请求参数为HelloRequest,响应为HelloResponse。实际编码过程中,HelloRequest、HelloResponse不过是内存中定义的一种struct数据结构,在真正的网络通信开始前,必须将这种内存struct数据类型转换为二进制表示的数据流,然后才能发送;服务端发送响应结果之前亦如是。
- 序列化/反序列化:pb是一种数据交换格式,protoc编译器会将pb文件转换为go代码,对应的message定义会被转换为struct数据类型,而且该类型还包括了Marshal/Unmarshal方法,以完成序列化、反序列化操作;
- 数据压缩/解压缩:在使用浏览器访问web页面时我们有时可以看到开启了gzip压缩,这是为了提高数据传输效率对数据进行了压缩,当然压缩、解压缩会耗点服务端、浏览器端的cpu,但是这点开销可以接受。类似http,如果我们希望能够提高数据传输效率,开启数据压缩也是可以考虑的;
- 应用层协议:只会讲中文的同学和只会讲英文的同学几乎是无法沟通的,两个人要想能够正常通信,必须在交流的语言上达成统一。网络通信双方要能正确理解双方发送的数据,也必须采用相同的应用层协议,才能从网络通信介质收取到的数据中正确地提取出信息;
严谨一点,序列化/反序列化、压缩/解压缩与应用层协议编解码是不同的层面,但是从通信数据的组织、操作而言,他们有着比较强的相关性,所以为了描述、实现方便,我们在模块codec中对serialization、compression一起进行描述。
序列化/反序列化
序列化/反序列化过程,本质上是内存数据对象如何在内存中用二进制数据表示,以及如何通过这些二进制数据还原为内存数据对象的问题。常见的技术也有很多了,像应用比较广泛的json、xml、protobuf、flatbuffer、thrift等等。
关于有哪些序列化/反序列化方式,以及它们各自适用的场景、性能的对比(速度和压缩率),可以参考维基百科以及相关文章,这里就不过多展开了。
我们只阐述下选择pb作为待开发微服务框架默认序列化方式的原因:
- 消息自描述:pb是自描述性很强的消息格式,用来描述服务接口以及接口请求、响应参数非常合适;
- 成熟工具链:pb提供了专门的protoc编译器,用以解析pb并将其转换为编程语言对应的桩代码,而且提供了插件机制允许为定制化代码生成插件,如protoc-gen-go;
- 极佳的效率:在序列化、反序列化操作方面,pb有着极高的操作效率;
- 合适的压缩率:由于采用了合理的编码技术,如varint、zigzag等对小整数、负整数进行了更短的编码;对string采用了utf8编码,每个字符1~4个字节,也是变长编码;其他的就不深究了;
- 协议可扩展:后续做协议调整时,如请求体里面增加一个新参数,只要保持字段的tag number保持递增,就不会引发协议不兼容问题,双方协议升级后即可正常访问新增字段;
pb现在也是很多微服务框架首选的数据交换格式,如google出品的gRPC自不必多说了,micro、百度brpc、字节kitex、腾讯goneat、trpc也是做了相同选择。
压缩/解压缩
在完成序列化之后,为了进一步减少网络通信数据传输,还可以考虑对序列化后的数据进一步压缩。当然如果开启了压缩之后,对端也需要采用相对应的解压缩算法来完成数据的解压缩。
数据压缩/解压缩算法,考虑的也无非这两点:
- 数据压缩率:这个关系到对数据压缩后可以节省多少传输的数据量;
- 压缩、解压缩的速度:这个关系到给通信延迟额外带来多少开销;
RPC通信中常用的压缩/解压缩算法包括:
- gzip,其实gzip也支持不同级别的压缩,如gzip -3 或 gzip -4分别代表了两种不同压缩等级;
- snappy,google从lz77思想中设计而来,在google内部系统、RPC中应用广泛,重速度而非压缩率;
- lz4;
- lz4_fragmented;
- 其他;
这里就不详细展开了,感兴趣地读者可以自行了解相关算法的更多信息。
下面简单介绍下框架将默认支持snappy、gzip的原因:
- snappy在算法速度、压缩率方面平衡的不错,我们会考虑优先予以支持;
- 另外,gzip算法由于支持不同的压缩等级,在RPC通信场景中也能通过灵活调整压缩等级来对算法速度、压缩率做出调整、平衡,而且其应用广泛,框架也会支持;
- 其他压缩/解压缩算法,框架将允许通过插件的形式来支持;
通信协议设计
通信协议的目的是什么呢?就像汉语规定了句子如何组织一样,通信协议规定了数据如何组织才是能被理解的,只有能被理解的数据才是有效的数据。协议设计好之后,编解码逻辑就应该按照这个规范去实现编码、解码的动作。
通信协议设计时要注意什么?
- 协议中必须包含包体长度信息,这样解码动作才能正确地从收到的数据中解包;
- 协议中必须包含请求唯一标识,如request id,这样在tcp连接复用时(同一个tcp连接多发多收)才能正确地识别response和request的对应关系;
- 协议中必须包含错误信息,如errtype、errcode、errmsg,以方便区分是否发生了错误、发生了何种类型的错误、错误具体类型及描述是什么;
- rpc通信超时控制,timeout控制rpc多久超时,或者deadline控制rpc必须何时结束;
- 安全校验用的魔数(幻数),攻击者精心构造的包是有可能被识别为正常请求响应的,协议上增加魔数校验环节,一定程度上可以降低风险;
- 要支持流式呢,那可能还要支持流id、流操作等相关的字段,参考gRPC协议设计;
- alignment & padding,协议对应内存数据类型,为了方便访问要注意alignment & padding的问题;
- reserved bytes/bits,如果将来有打算对协议做能力上的扩展,也可以设置保留字段/bits;
- 其他;
这算是一个相对比较完整的协议设计的checklist了,大家进行应用层协议设计时可以参考。
模块设计
理解了上述内容之后,我们继续来看下codec模块的设计,如下图所示:
从上图不难看出,其包括核心接口Codec、Compressor、Serializer、Session、SessionBuilder,以及将其整合在一起的MessageReader,我们详细解释这么设计的原因及工作过程。
参考内容
- google protocolbuffers, https://developers.google.com/protocol-buffers
- comparison of data serialization formats, https://en.wikipedia.org/wiki/Comparison_of_data-serialization_formats
- Cap'n Proto, FlatBuffers, and SBE, https://capnproto.org/news/2014-06-17-capnproto-flatbuffers-sbe.html
- gRPC compression, https://grpc.github.io/grpc/core/md_doc_compression.html
- RPC compression, https://github.com/scylladb/seastar/blob/master/doc/rpc-compression.md
- snappy compression, https://en.wikipedia.org/wiki/Snappy_(compression))