注:本文为“通信协议之序列化 | TLV 编码应用” 相关文章合辑。
通信协议之序列化
2012-07-07 15:15:34 stevenrao 于深圳
通信协议可以理解两个节点之间为了协同工作实现信息交换,协商一定的规则和约定,例如规定字节序,各个字段类型,使用什么压缩算法或加密算法等。常见的有 tcp,udo,http,sip 等常见协议。协议有流程规范和编码规范。流程如呼叫流程等信令流程,编码规范规定所有信令和数据如何打包 / 解包。
编码规范就是我们通常所说的编解码,序列化。不光是用在通信工作上,在存储工作上我们也经常用到。如我们经常想把内存中对象存放到磁盘上,就需要对对象进行数据序列化工作。
本文采用先循序渐进,先举一个例子,然后不断提出问题 - 解决完善,这样一个迭代进化的方式,介绍一个协议逐步进化和完善,最后总结。看完之后,大家以后在工作就很容易制定和选择自己的编码协议。
一、紧凑模式
本文例子是 A 和 B 通信,获取或设置基本资料,一般开发人员第一步就是定义一个协议结构:
struct userbase
{
unsigned short cmd;//1-get, 2-set, 定义一个 short,为了扩展更多命令 (理想那么丰满)
unsigned char gender; //1 – man , 2-woman, 3 - ??
char name [8]; // 当然这里可以定义为 string name;或 len + value 组合,为了叙述方便,就使用简单定长数据
}
在这种方式下,A 基本不用编码,直接从内存 copy 出来,再把 cmd 做一下网络字节序变换,发送给 B。B 也能解析,一切都很和谐愉快。
这时候编码结果可以用图表示为 (1 格一个字节)
这种编码方式,我称之为紧凑模式,意思是除了数据本身外,没有一点额外冗余信息,可以看成是 Raw Data。在 dos 年代,这种使用方式非常普遍,那时候可是内存和网络都是按 K 计算,cpu 还没有到 1G。如果添加额外信息,不光耗费捉襟见肘的 cpu,连内存和带宽都伤不起。
二、可扩展性
有一天,A 在基本资料里面加一个生日字段,然后告诉 B
struct userbase
{
unsigned short cmd;
unsigned char gender;
unsigned int birthday;
char name [8];
}
这是 B 就犯愁了,收到 A 的数据包,不知道第 3 个字段到底是旧协议中的 name 字段,还是新协议中 birthday。这是后 A,和 B 终于从教训中认识到一个协议重要特性 —— 兼容性和可扩展性。
于是乎,A 和 B 决定废掉旧的协议,从新开始,制定一个以后每个版本兼容的协议。方法很简单,就是加一个 version 字段。
struct userbase
{
unsigned short version;
unsigned short cmd;
unsigned char gender;
unsigned int birthday;
char name [8];
}
这样,A 和 B 就松一口气,以后就可以很方便的扩展。增加字段也很方便。这种方法即使在现在,应该还有不少人使用。
二、更好的可扩展性
过了一段较长时间,A 和 B 发现又有新的问题,就是没增加一个字段就改变一下版本号,这还不是重点,重点是这样代码维护起来相当麻烦,每个版本一个 case 分支,到了最好,代码里面 case 几十个分支,看起来丑陋而且维护起来成本高。
A 和 B 仔细思考了一下,觉得光靠一个 version 维护整个协议,不够细,于是觉得为每个字段 增加一个额外信息 ——tag, 虽然增加内存和带宽,但是现在已经不像当年那样,可以容许这些冗余,换取易用性。
struct userbase
{
unsigned short version;
unsigned short cmd;
unsigned char gender;
unsigned int birthday;
char name [8];
}
制定完这些协议后,A 和 B 很得意,觉得这个协议不错,可以自由的增加和减少字段。随便扩展。
现实总是很残酷的,不久就有新的需求,name 使用 8 个字节不够,最大长度可能会达到 100 个字节,A 和 B 就愁怀了,总不能即使叫 “steven” 的人,每次都按照 100 个字节打包,虽然不差钱,也不能这样浪费。
于是 A 和 B 寻找各方资料,找到了 ANS.1 编码规范,好东西啊… ASN.1 是一种 ISO/ITU-T 标准。其中一种编码 BER(Basic Encoding Rules)简单好用,它使用三元组编码,简称 TLV 编码。
每个字段编码后内存组织如下
字段可以是结构,即可以嵌套
A 和 B 使用 TLV 打包协议后,数据内存组织大概如下:
TLV 具备了很好可扩展性,很简单易学。同时也具备了缺点,因为其增加了 2 个额外的冗余信息,tag
和 len
,特别是如果协议大部分是基本数据类型 int ,short, byte
. 会浪费几倍存储空间。另外 Value 具体是什么含义,需要通信双方事先得到描述文档,即 TLV 不具备结构化和自解释特性。
三、自解释性
当 A 和 B 采用 TLV 协议后,似乎问题都解决了。但是还是觉得不是很完美,决定增加自解释特性,这样抓包就能知道各个字段类型,不用看协议描述文档。这种改进的类型就是 TT [L] V(tag,type,length,value)
,其中 L 在 type 是定长的基本数据类型如 int, short, long, byte
时候,因为其长度是已知的,所以 L 不需要。
于是定义了一些 type 值如下
类型 | Type 值 | 类型描述 |
---|---|---|
bool | 1 | 布尔值 |
int8 | 2 | 带符号的一个字符 |
uint8 | 3 | 带符号的一个字符 |
int16 | 4 | 16 位有符号整型 |
uint16 | 5 | 16 位无符号整型 |
int32 | 6 | 32 位有符号整型 |
uint32 | 7 | 32 位无符号整型 |
… | ||
string | 12 | 字符串或二进制序列 |
struct | 13 | 自定义的结构,嵌套使用 |
list | 14 | 有序列表 |
map | 15 | 无序列表 |
按照 TTLV 序列化后,内存组织如下
改完后,A 和 B 发现,的确带来很多好处,不光可以随心所以的增删字段,还可以修改数据类型,例如把 cmd 改成 int cmd;可以无缝兼容。真是太给力了。
三、跨语言特性
有一天来了一个新的同事 C,他写一个新的服务,需要和 A 通信,但是 C 是用 java 或 PHP 的语言,没有无符号类型,导致负数解析失败。为了解决这个问题,A 重新规划一下协议类型,做了有些剥离语言特性,定义一些共性。对使用类型做了强制性约束。虽然带来了约束,但是带来通用型和简洁性,和跨语言性,大家表示都很赞同,于是有了一个类型 (type) 规范。
类型 | Type 值 | 类型描述 |
---|---|---|
bool | 1 | 布尔值 |
int8 | 2 | 带符号的一个字符 |
int16 | 3 | 16 位有符号整型 |
int32 | 4 | 32 位有符号整型 |
… | ||
string | 12 | 字符串或二进制序列 |
struct | 13 | 自定义的结构,嵌套使用 |
list | 14 | 有序列表 |
map | 15 | 无序列表 |
四、代码自动化 —— IDL 语言的产生
但是 A 和 B 发现了新的烦恼,就是每搞一套新的协议,都要从头编解码,调试,虽然 TLV 很简单,但是写编解码是一个毫无技术含量的枯燥体力活,一个非常明显的问题是,由于大量 copy/past, 不管是对新手还是老手,非常容易犯错,一犯错,定位排错非常耗时。于是 A 想到使用工具自动生成代码。
IDL(Interface Description Language),它是一种描述语言,也是一个中间语言,IDL 一个使命就是规范和约束,就像前面提到,规范使用类型,提供跨语言特性。通过工具分析 idl 文件,生成各种语言代码
Gencpp.exe sample.idl 输出 sample.cpp sample.h
Genphp.exe sample.idl 输出 sample.php
Genjava.exe sample.idl 输出 sample.java
是不是简单高效 J
四、总结
大家看到这里,是不是觉得很面熟。是的,协议讲到最后,其实就是和 facebook 的 thrift 和 google protocol buffer 协议大同小异了。包括公司无线使用的 JCE 协议。咋一看这些协议的 idl 文件,发现几乎是一样的。只是有些细小差异化。
这些协议在一些细节上增加了一些特性:
1、压缩,这里压缩不是指 gzip 之类通用压缩,是指针对整数压缩,如 int 类型,很多情况下值是小于 127(值为 0 的情况特别多),就不需要占用 4 个字节,所以这些协议做了一些细化处理,把 int 类型按照情况,只使用 1/2/3/4 字节,实际上还是一种 ttlv 协议。
2、reuire/option 特性:这个特性有两个作用,1、还是压缩,有时候一个协议很多字段,有些字段可以带上也可以不带上,不赋值的时候不是也要带一个缺省值打包,这样很浪费,如果字段是 option 特性,没有赋值的话,就不用打包。2、有点逻辑上约束功能,规定哪些字段必须有,加强校验。
序列化是通信协议的基础,不管是信令通道还是数据通道,还是 rpc,都需要使用到。在设计协议早期就考虑到扩展性和跨语言特性。会为以后省去不少麻烦。
Ps
本篇主要介绍二进制通信协议序列化,没有讲文本协议。从某种意义来讲,文本协议天生具有兼容和可扩展性。不像二进制需要考虑那么多问题。文本协议易于调试(如抓包就是可见字符,telnet 即可调试,数据包可以手工生成不借助特殊工具),简单易学是其最强大的优势。
二进制协议优势就是性能和安全性。但是调试麻烦。
两者各有千秋,按需选择。(stevenrao)
看懂通信协议:自定义通信协议设计之 TLV 编码应用
原创 maxid 2014/03/09 11:45
因为之前从事过电信信令类工作,接触较多的则是 ASN.1 中的 BER、PER 编码,其中 BER 是基于 TLV 方式进行编码,本文主要介绍一下 TLV 在自定义协议中的应用。
通过该文章,你可以肉眼看懂一些类似二进制通信协议,并可以尝试封装自己的通信协议
1. 通信协议
协议可以使双方不需要了解对方的实现细节的情况下进行通信,因此双方可以是异构的,server 可以是 c++,client 可以是 java,基于相同的协议,我们可以用自己熟识的语言工具来实现。
协议一般由一个或多个消息组成,简单的来说,消息就像是一个 Table,由表头 (消息的字段定义,包括名称与数据类型) 与行 (字段值) 组成。
2. 自定义通信协议
约定好双方交换数据的编解码方式,包括一致的基本数据类型,业务类型,字节序、消息内容等。
3. 编码方式
可以跟据业务需要进行定制,如对编解码速度、网络带宽、用户量等进行考量
3.1. 基于字符串编码
报头 (4 字节描述数据体长度)+ 数据 (字符串 + 分隔符或直接使用 JSON),该方式实现简单,在编解码阶段成本低、但在数据类型转时成本较高,同时可能会较占用带宽。
3.2. 基于二进制编码
将协议以特定格式编码为字节数组,该种方式相较字符串编码方式实现要求要高一些,但带宽占用相对小一些,本文主要介绍其中一种较常用的编码方式 TLV,即 Tag\Length\Value。
4. TLV 编码介绍 (其中一种实现介绍)
TLV:TLV 是指由数据的类型 Tag,数据的长度 Length,数据的值 Value 组成的结构体,几乎可以描任意数据类型,TLV 的 Value 也可以是一个 TLV 结构,正因为这种嵌套的特性,可以让我们用来包装协议的实现。
以下将分别针对 Tag、Length、Value 进行解说:
4.1. Tag 描述 Value 的数据类型,TLV 嵌套时可以用于描述消息的类型
Tag 由一个或多个字节组成,上图描述首字节 0~7 位的具体含义
1) Tag 首节字说明
- 第 6~7 位:表示 TLV 的类型,00 表示 TLV 描述的是基本数据类型 (Primitive Frame, int,string,long…),01 表示用户自定义类型 (Private Frame,常用于描述协议中的消息)。
- 第 5 位:表示 Value 的编码方式,分别支持 Primitive 及 Constructed 两种编码方式,Primitive 指以原始数据类型进行编码,Constructed 指以 TLV 方式进行编码,0 表示以 Primitive 方式编码,1 表示以 Constructed 方式编码。
- 第 0~4 位:当 Tag Value 小于 0x1F (31) 时,首字节 0~4 位用来描述 Tag Value,否则 0~4 位全部置 1,作为存在后续字节的标志,Tag Value 将采用后续字节进行描述。
2) Tag 后续字节说明
后续字节采用每个字节的 0~6 位(即 7bit)来存储 Tag Value, 第 7 位用来标识是否还有后续字节。
- 第 7 位:描述是否