04 从 struct 到 TLV——协议的演化历史

📅 2026/6/29 18:25:15
04 从 struct 到 TLV——协议的演化历史
假设现在 A 与 B 之间要传输一个关于用户信息的数据包可以将该数据包格式定义成如下形式#pragma pack(push, 1) struct userinfo { //命令号 int32_t cmd; //用户性别 char gender; //用户昵称 char name[8]; }; #pragma pack(pop)相信很多读者曾经都定义过这样的协议这种数据结构简单明了对端只要直接拷贝按字段解析就可以了。但是需求总是不断变化的某一天根据新的需求需要在这个结构中增加一个字段表示用户的年龄于是修改协议结构成#pragma pack(push, 1) struct userinfo { //命令号 int32_t cmd; //用户性别 char gender; //用户昵称 char name[8]; //用户年龄 int32_t age; }; #pragma pack(pop)问题并没有直接增加一个字段那么简单新修改的协议格式导致旧的客户端无法兼容旧的客户端已经分发出去这个时候我们升级服务器端的协议格式成新的会导致旧的客户端无法使用。所以我们在最初设计协议的时候我们需要增加一个版本号字段针对不同的版本来做不同的处理即/** * 旧的协议版本号是 1 */ #pragma pack(push, 1) struct userinfo { //版本号 short version; //命令号 int32_t cmd; //用户性别 char gender; //用户昵称 char name[8]; }; #pragma pack(pop) /** * 新的协议版本号是 2 */ #pragma pack(push, 1) struct userinfo { //版本号 short version; //命令号 int32_t cmd; //用户性别 char gender; //用户昵称 char name[8]; //用户年龄 int32_t age; }; #pragma pack(pop)这样我们可以用以下伪码来兼容新旧协议//从包中读取一个 short 型字段 short version 从包中读取一个 short 型字段; if (version 1) { //当旧的协议格式进行处理 } else if (version 2) { //当新的协议格式进行处理 }上述方法是一个兼容旧版协议的常见做法。但是这样也存在一个问题如果我们的业务需求变化快我们可能需要经常调整协议字段增、删、改这样我们的版本号数量会比较多我们的代码会变成类似下面这种形式//从包中读取一个 short 型字段 short version 从包中读取一个 short 型字段; if (version 版本号1) { //对版本号1格式进行处理 } else if (version 版本号2) { //对版本号2格式进行处理 } else if (version 版本号3) { //对版本号3格式进行处理 } else if (version 版本号4) { //对版本号4格式进行处理 } else if (version 版本号5) { //对版本号5格式进行处理 } ...省略更多...这只是仅考虑了协议顶层结构还没有考虑更多复杂的嵌套结构这样的代码会变得越来越难以维护。这里只是为了说明问题实际开发中建议读者在设计协议时尽量考虑周全避免反复修改协议结构。上述协议格式还存在另外一个问题对于 name 字段其长度为 8 个字节这种定长的字段长度大小不具有伸缩性太长很多情况都用不完则造成内存和网络带宽的浪费太短则某些情况下不够用。那么有没有什么方法来解决呢方法是有的对于字符串类型的字段我们可以在该字段前面加一个表示字符串长度length的标志那么上面的协议在内存中的状态可以表示成如下图示这种方法解决了定义字符串类型的太长浪费太短不够用的问题但是没有解决修改协议如新增字段需要兼容众多旧版本问题对于这个问题我们可以通过在每个字段前面加一个 type 类型也解决我们可以使用一个 char 类型来表示常用的类型规定如下类型Type值类型描述bool0布尔值char1char 型int16216 位整型int32332 位整型int64464 位整形string5字符串或二进制序列…那么对于上述协议其内存格式变成这样每个字段的类型就是自解释了。这就是所谓的TLVType-Length-Value格式。这种格式的协议我们可以方便地增删和修改字段类型程序解析时根据每个字段的 type 来得到字段的类型。这里再根据笔者的经验多说几句实际开发中 TLV 类型虽然易于扩展但是也存在如下缺点TLV 格式因为每个字段增加了一个 type 类型导致所占空间增大我们在解析字段时需要额外增加一些判断 type 的逻辑去判断字段的类型做相应的处理即//读取第一个字节得到 type if (type Type::BOOL) { //bool型处理 } else if (type Type::CHAR) { //char型处理 } else if (type Type::SHORT) { //short型处理 } ...更多类型省略...如上代码所示每个字段我们都需要有这样的逻辑判断这样的编码方式是非要麻烦的。即使我们知道了每个字段的技术类型相对业务来说每个字段的业务含义仍然需要我们制定文档格式也就是说 TLV 格式只是做到了技术上自解释。所以在实际的开发中完全遵循 TLV 格式的协议并不多尤其是针对一些整型类型的字段。在 TLV 格式的基础上还扩展了一种叫 TTLV 格式的协议即 Tag-Type-Length-Value每个字段前面在增加一个 Tag 类型Tag 的含义由协议双方协定好。协议设计工具虽然 TLV 很简单每搞一套新的协议都要从头编解码、调试但是写编解码是一个毫无技术含量的枯燥体力活。在大量复制粘贴过程中非常容易出错。因此出现了一种叫 IDLInterface Description Language的语言规范它是一种描述语言也是一个中间语言IDL 规范协议的使用类型提供跨语言特性。可以定义一个描述协议格式的 IDL 文件然后通过 IDL 工具分析 IDL 文件就可以生成各种语言版本的协议代码。Google Protobuf 库自带的工具 protoc 就是这样一个工具。