序列化之 TLV
Posted binarylei
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了序列化之 TLV相关的知识,希望对你有一定的参考价值。
序列化之 TLV
通信协议可以理解两个节点之间为了协同工作实现信息交换,协商一定的规则和约定,例如规定字节序,各个字段类型,使用什么压缩算法或加密算法等。常见的有 tcp,udo,http,sip 等常见协议。协议有流程规范和编码规范。流程如呼叫流程等信令流程,编码规范规定所有信令和数据如何打包/解包。
编码规范就是我们通常所说的编解码,序列化。不光是用在通信工作上,在存储工作上我们也经常用到。如我们经常想把内存中对象存放到磁盘上,就需要对对象进行数据序列化工作。
本文采用先循序渐进,先举一个例子,然后不断提出问题-解决完善,这样一个迭代进化的方式,介绍一个协议逐步进化和完善,最后总结。看完之后,大家以后在工作就很容易制定和选择自己的编码协议。
1. 紧凑模式
本文例子是 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,连内存和带宽都伤不起。
2. 可扩展性
有一天,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 就松一口气,以后就可以很方便的扩展。增加字段也很方便。这种方法即使在现在,应该还有不少人使用。
3. 更好的可扩展性
过了一段较长时间,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 编码。
每个字段编码后内存组织如下
Tag | Length | Value
字段可以是结构,即可以嵌套
A 和 B 使用 TLV 打包协议后,数据内存组织大概如下:
TLV 具备了很好可扩展性,很简单易学。同时也具备了缺点,因为其增加了 2 个额外的冗余信息,tag 和 len,特别是如果协议大部分是基本数据类型 int ,short, byte. 会浪费几倍存储空间。另外 Value 具体是什么含义,需要通信双方事先得到描述文档,即 TLV 不具备结构化和自解释特性。
4. 自解释性
当 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 | 2 | 字符串或二进制序列 |
struct | 13 | 自定义的结构,嵌套使用 |
list | 14 | 有序列表 |
map | 15 | 无序列表 |
按照 ttlv 序列化后,内存组织如下
改完后,A 和 B 发现,的确带来很多好处,不光可以随心所以的增删字段,还可以修改数据类型,例如把 cmd 改成 int cmd;可以无缝兼容。真是太给力了。
5. 跨语言特性
有一天来了一个新的同事 C,他写一个新的服务,需要和 A 通信,但是 C 是用 java 或 php 的语言,没有无符号类型,导致负数解析失败。为了解决这个问题,A 重新规划一下协议类型,做了有些剥离语言特性,定义一些共性,对使用类型做了强制性约束。虽然带来了约束,但是带来通用型和简洁性,和跨语言性,大家表示都很赞同,于是有了一个类型(type)规范。
类型 | Type值 | 类型描述 |
---|---|---|
bool | 1 | 布尔值 |
int8 | 2 | 带符号的一个字符 |
int16 | 3 | 16位有符号整型 |
int32 | 4 | 32位有符号整型 |
... | ||
string | 2 | 字符串或二进制序列 |
struct | 13 | 自定义的结构,嵌套使用 |
list | 14 | 有序列表 |
map | 15 | 无序列表 |
6. 代码自动化: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
7. 总结
大家看到这里,是不是觉得很面熟。是的,协议讲到最后,其实就是和 facebook 的 thrift 和 google protocol buffer 协议大同小异了。包括公司无线使用的 jce 协议。咋一看这些协议的 idl 文件,发现几乎是一样的。只是有些细小差异化。
这些协议在一些细节上增加了一些特性:
压缩,这里压缩不是指 gzip 之类通用压缩,是指针对整数压缩,如 int 类型,很多情况下值是小于 127(值为 0 的情况特别多),就不需要占用 4 个字节,所以这些协议做了一些细化处理,把 int 类型按照情况,只使用 1/2/3/4 字节,实际上还是一种 ttlv 协议。
reuire/option 特性: 这个特性有两个作用,一是压缩,有时候一个协议很多字段,有些字段可以带上也可以不带上,不赋值的时候不是也要带一个缺省值打包,这样很浪费,如果字段是 option 特性,没有赋值的话,就不用打包。二是约束功能,规定哪些字段必须有,加强校验。
序列化是通信协议的基础,不管是信令通道还是数据通道,还是 rpc,都需要使用到。在设计协议早期就考虑到扩展性和跨语言特性。会为以后省去不少麻烦。
Ps
本篇主要介绍二进制通信协议序列化,没有讲文本协议。从某种意义来讲,文本协议天生具有兼容和可扩展性。不像二进制需要考虑那么多问题。文本协议易于调试(如抓包就是可见字符,telnet 即可调试,数据包可以手工生成不借助特殊工具),简单易学是其最强大的优势。二进制协议优势就是性能和安全性。但是调试麻烦。
两者各有千秋,按需选择
参考:
本文转发至 https://www.jianshu.com/p/fb183509f14d
https://www.jianshu.com/p/73c9ed3a4877
https://www.jianshu.com/p/72108f0aefca
每天用心记录一点点。内容也许不重要,但习惯很重要!
以上是关于序列化之 TLV的主要内容,如果未能解决你的问题,请参考以下文章
华为OD机试 - TLV解码(Java) | 机试题+算法思路+考点+代码解析 2023