Contents
  1. 1. 一, 紧凑时代
  2. 2. 二, Version的出现
  3. 3. 三. Tag的出现
  4. 4. 四, TLV和TTLV

众所周知, 在Protobuf中使用的编码方式是TLV, 也就是Tag-Length-Value, 但是在单纯的理解TLV是什么之前, 我认为需要去理解一下编码规范的历史, 因此写了这篇文章:

一, 紧凑时代

时代背景是刚刚有类似DOS的时候, 这个时候的显著特征就是各方面都很原始, 带宽很低, 因此在传输数据的时候要省之又省, 因此协议的编码规范以紧凑为特点.

假设武大和华师要搞一个数据传输协议, 目的是传输一本书的信息, 然后两个学校商量好了:

1
2
3
4
5
struct BookInfo {
unsigned short cmd; // 代表不同的操作
char[8] num;
char[60] name;
}

学校觉得应该是可以的, 这个协议可以很好的沟通数据了! 也非常的节省空间!

1
2
3
4
5
+---+---+----+
|cmd|num|name|
+---+---+----+
| 2 | 8 | 60 |
+---+---+----+

这种编码规范的特点就是紧凑, 对于空间的利用率高, 导致这样的特征出现是因为当时的时代环境所限制, 带宽是非常珍贵的, 因此能省则省, 不允许冗余字段的产生.

二, Version的出现

在两三年后, 武大想在这个数据传输协议中增加一个字段: 入馆日期. 我们可以叫它date, 然后我们的结构体可以这样去写:

1
2
3
4
5
6
struct BookInfo {
unsigned short cmd; // 代表不同的操作
char[8] num;
char[60] name;
char[8] date; // 日期存储的形式为: 19990101这种
}

嗯, 这样安排应该也挺紧凑的, 但是问题很快就凸显了出来: 接收方无法判断应该按照旧的方式去解析数据帧还是按照新的方式去解析数据帧, 因为不可能一夜之间所有的服务都按照新的编码规范来传输数据. 因此一个叫做版本号的字段就这样诞生了:

1
2
3
4
5
6
7
struct BookInfo {
char version; // 版本字段, 用于标识当前的版本号
unsigned short cmd;
char[8] num;
char[60] name;
char[8] date;
}
1
2
3
4
5
+-------+---+---+----+----+
|version|cmd|num|name|date|
+-------+---+---+----+----+
| 1 | 2 | 8 | 60 | 4 |
+-------+---+---+----+----+

这样根据版本号就可以使用不同的解析方式, 也就解决了之前的问题, 在这里其实版本号就是一个冗余的字段, 但是由于这个字段本身占用的空间不大, 而且当时的带宽已经比之前宽松了一点. 这样的方式现在还有不少协议都在使用.

三. Tag的出现

虽然有了版本号能够让我们在协议的构建的时候能够拥有一定的可扩展性, 但是一个协议仅有一个冗余字段来维护还是略显单薄了一些: 当我们拥有很多个版本的协议的时候, 意味着我们的代码里将会出现相当多的case, 然后我们的代码就会变得非常的臃肿, 所以我们尝试加入更多的冗余信息: tag.

tag的作用是用来标识一个字段的解析方式, 一个特定的tag可以用于标识一个字段, 这个字段的解析方式完全可以通过tag来判断, 解析数据帧的时候就可以通过遇见的tag来解析接下来一个或几个字节的数据. 这样就可以拥有更好的可扩展性:

1
2
3
4
5
+-------+---+---+---+---+---+----+---+----+
|version|tag|cmd|tag|num|tag|name|tag|date|
+-------+---+---+---+---+---+----+---+----+
| 1 | 1 | 2 | 1 | 8 | 1 | 60 | 1 | 4 |
+-------+---+---+---+---+---+----+---+----+

四, TLV和TTLV

1. TLV

在使用了Tag之后, 我们程序的可扩展性好了很多, 但是有时候会出现这样的情况: 我们的书名可能只占4个或者5个字节, 但是仍然分配了60个字节的空间, 这显然有点浪费吧! 所以人们就想着加上后续的长度这类冗余信息会不会让协议变得更加高效?

好在这种形式早在ASN.1中的BER(Basic Encoding Rule)中提出了:

BER的数据由三个域组成: 标识域(tag), 长度域(length)和值域(value), 又叫做TLV

也就是说, 一个数据段由三个部分组成:

1
2
3
+---+------+-----+
|tag|length|value|
+---+------+-----+

tag的作用是用于标识字段是什么, length用于告知解析程序后续的值域的字节长度, value用于存储真正的数据.

而且TLV的模式还可以嵌套使用.

这样的TLV其实已经够棒了, 对吗? 因为我们已经牺牲了足够多的冗余空间来帮助我们达成数据传输协议的高可扩展性, 当数据传输中不定长的字段比较多的时候这样往往是很有效的, 但是如果字段都是占用空间比较小的时候(比如说字符和一些数值), TLV并没有很大的提升整体解析的效率. 并且TLV这样一种模式不具有自解释性.

所谓自解释性, 是指可以通过阅读代码或者数据包本身来知晓其结构和特征, 而不需要依靠特定的文档来实现.

比如我们希望随便抓一个数据包, 就能知道每个字段的键, 类型等等信息.

2. TTLV

所谓的TTLV, 是指Tag, Type, Length, Value, 在这其中加上了Type, 就能够在数据中直接知晓一个数据段的数据类型, 相应的, 在一些确定长度的类型下, 我们还可以删减掉长度这个辅助信息[所以其实是TT(L)V], 这样接收方就能够只看数据包而知晓数据传输协议的编码规范.

比如我们定义了几个类型: int81表示, int322表示, string3表示, 这样的话int8int32的值域其实是定长的, 而只有string类型的字段是需要长度域的, 比如:

1
2
3
4
5
type Info struct {
One int8
Two int32
Three string
}

这样一个Go语言中的结构体, 如果我们传输他的话, 按照TTLV的形式, 数据帧的编码规范应该是这样的:

1
2
3
4
5
+---+----+-----+---+----+-----+---+----+------+-----+
|tag|type|value|tag|type|value|tag|type|length|value|
+---+----+-----+---+----+-----+---+----+------+-----+
| | 1 |1-bit| | 2 |4-bit| | 3 | x |x-bit|
+---+----+-----+---+----+-----+---+----+------+-----+

编码规范的演化, 一方面受着物理环境因素的影响(指带宽), 另一方面受人类需求的影响, 所以在使用TLV或者TTLV之前一定要了解整个编码规范的历史进程, 这样才能更好的理解为什么会出现TLV和TTLV.

Contents
  1. 1. 一, 紧凑时代
  2. 2. 二, Version的出现
  3. 3. 三. Tag的出现
  4. 4. 四, TLV和TTLV