前言
通信协议是两个节点为协同工作、交换信息而约定的规则,包括字节序、字段类型、压缩或加密方法等。常见的前端协议如 TCP、UDP、HTTP、SIP 等,都包含流程规范(如信令流程)和编码规范(即数据打包/解包规则)。 编码规范也称作序列化或编解码,不仅用于通信,也常用于存储场景,比如将内存中的对象保存到磁盘。
本文通过一个个简单逐步演进的示例,展示一个协议从简单到完善的设计过程,帮助你理解编码协议。
1. 紧凑模式
一开始,我们定义一个简单的用户基本信息结构:
c
struct userbase {
unsigned short cmd; // 1-get, 2-set
unsigned char gender; // 1-man, 2-woman
char name[8]; // 定长姓名
}
这种方式几乎无需编码是一种row数据,数据直接从内存拷贝、调整字节序后发送。接收方也能正确解析。
其编码格式如下(每格一字节):
scss
| cmd (2) | gender (1) | name (8) |
这就是"紧凑模式":除了原始数据,没有额外信息。在早期资源紧张的时代很常见,你想扩展或者做点其他事情几乎不可能,你被限制在仅有的空间内,位置不能移,大小不能动。
2. 可扩展性
当我们需要增加一个新字段时,问题出现了:接收方无法正常读取,协议无法正常通信。
于是早期引入版本号概念:
c
struct userbase {
unsigned short version;
unsigned short cmd;
unsigned char gender;
unsigned int birthday;
char name[8];
}
这样通过版本号区分新旧结构,实现了基本扩展能力。
但是弊端也是看得见的,协议版本无数多,你要维护大量的版本分支,随着版本迭代的的这种难度大到已经足以让大家放弃。重新寻找更合适的方案也就迫在眉睫。
3. 更灵活的扩展性
但仅靠版本号管理,随着版本增多,就如上面说的代码中会出现大量分支判断,难以维护。 我们为每个字段增加一个标签(tag),形成类似键值对的结构:
| version | cmd | gender | birthday | name |
虽然增加了冗余,但换来了更好的灵活性和可读性。
4. 变长字段支持
现实中很多字段是变长的,比如姓名可能超过8字节。固定长度既浪费也不灵活。
这里可以借鉴 ASN.1 的 BER 编码方式,使用 TLV 格式:
css
[Tag][Length][Value]
例如用户信息可编码为:
| tag_cmd | len_cmd | cmd | tag_gender | len_gender | gender | tag_name | len_name | name... |
TLV具备了很好可扩展性,很简单易学。同时也具备了缺点,因为其增加了2个额外的冗余信息,tag 和len,特别是如果协议大部分是基本数据类型int ,short, byte. 会浪费几倍存储空间。另外Value具体是什么含义,需要通信双方事先得到描述文档,即TLV不具备结构化和自解释特性。
5. 加入自解释性
为了不依赖外部文档就能理解数据含义,我们在 TLV 基础上增加类型信息,形成 TTV:
css
[Tag][Type][Value]
其中定义了基本类型(如 int、short)可省略 Len,因为长度是已知固定的。
我们定义一套类型值:
go
1: int16, 2: int32, 3: string, ...
编码后数据类似:
| tag_cmd | type_cmd | cmd | tag_name | type_name | len_name | name... |
这样即使没有协议文档,也能解析出字段类型和含义。
6. 跨语言支持
当多语言协作时(如 Java/PHP 不支持无符号类型),需统一类型系统,避免兼容问题。
我们定义一套跨语言通用类型(即:交集类型),约束使用范围,保证各语言解析一致性。
7. 代码自动化
手动编解码枯燥易错,我们引入 IDL 来描述协议,通过工具自动生成不同语言的代码:
gen_cpp sample.idl → sample.cpp, sample.h
gen_java sample.idl → sample.java
提升效率,减少错误。
总结
从上文的介绍大致可以了解到协议设计是一个逐步完善的过程:
- 从紧凑模式 出发,逐步考虑扩展性
- 引入标签 和类型实现自解释
- 通过统一类型系统支持跨语言
- 借助 IDL 和代码生成提升开发效率
- 最终形成支持压缩、可选字段等特性的成熟协议
最后这篇文章大部分的知识来源互联网,在自己理解的基础上进行编写没有很生涩难懂的技术细节,本人知识的广度不足以支撑更多,如大佬略过即可。