Appearance
序列化规范
本文档定义蓝牙协议 V2 中 Payload 的序列化与反序列化规范。
概述
协议 V2 支持三种序列化格式,根据传输通道和设备能力选择:
格式选择
| 格式 | 适用场景 | 特点 |
|---|---|---|
| Protobuf | 蓝牙通道、支持 Protobuf 的设备 | 体积小、解析快、类型安全 |
| JSON | MQTT 通道、浏览器/Web 应用 | 可读性好、生态成熟、易于调试 |
| 原始字节 | 资源受限的嵌入式设备 | 无依赖、最小体积、手动解析 |
通道与格式对应
| 通道 | 推荐格式 | 说明 |
|---|---|---|
| 蓝牙 BLE | Protobuf | 带宽有限,需要高效编码 |
| MQTT | JSON | 便于服务端处理和日志记录 |
| HTTP/WebSocket | JSON | 浏览器原生支持 |
| 资源受限设备 | 原始字节 | 无法引入 Protobuf 库时使用 |
格式协商
通信双方应在连接建立时协商使用的序列化格式。蓝牙通道通过特征值区分,MQTT 通过 Topic 后缀区分。
Protobuf 规范
版本要求
- 使用 proto3 语法
- 字段编号规范:1-15 用于高频字段(单字节编码),16+ 用于低频字段
基本类型映射
| Protobuf 类型 | 说明 | 示例 |
|---|---|---|
uint32 | 无符号 32 位整数 | 用户ID、凭证ID |
uint64 | 无符号 64 位整数 | 时间戳、事件ID |
int32 | 有符号 32 位整数 | 偏移量、差值 |
bool | 布尔值 | 开关状态 |
string | UTF-8 字符串 | 名称、标签 |
bytes | 原始字节 | 密钥、哈希值 |
enum | 枚举类型 | 状态码、类型标识 |
字段规则
- 所有字段都是可选的(proto3 默认行为)
- 默认值不会被序列化,以减小消息体积
- 未知字段会被保留,确保向前兼容
序列化流程
发送方
原始数据 → Protobuf Message → 序列化 → 二进制数据 → 加密 → 传输- 构造 Protobuf Message 对象
- 调用
SerializeToString()或等效方法 - 对序列化后的二进制数据进行加密(参见加密规范)
- 通过蓝牙通道发送
接收方
传输 → 二进制数据 → 解密 → 反序列化 → Protobuf Message → 原始数据- 从蓝牙通道接收数据
- 对数据进行解密
- 调用
ParseFromString()或等效方法 - 访问 Message 对象中的字段
默认值处理
Proto3 中,未设置的字段使用类型默认值:
| 类型 | 默认值 |
|---|---|
uint32 / int32 | 0 |
uint64 / int64 | 0 |
bool | false |
string | "" (空字符串) |
bytes | 空字节序列 |
enum | 第一个枚举值(通常为 0) |
message | null / 未设置 |
默认值语义
由于默认值不会被序列化,接收方无法区分"字段未设置"和"字段值为默认值"。协议设计时已考虑此特性:
- 枚举类型的 0 值定义为
UNKNOWN或无意义状态 - 时间戳 0 表示"未设置"或"永久"
- ID 为 0 表示"无关联"
枚举处理
定义规范
protobuf
enum UserRole {
USER = 0; // 默认值,普通用户
ADMIN = 1; // 管理员
OWNER = 2; // 拥有者
}- 第一个枚举值必须为 0
- 0 值应为最常见或默认状态
未知枚举值
当接收到未知的枚举值时(如新版本添加的值):
- 保留原始数值,不丢弃
- 应用层可将其视为
UNKNOWN处理 - 转发时保持原值不变
嵌套消息
定义示例
protobuf
message Credential {
uint32 credential_id = 1;
CredentialType type = 2;
ValidityPeriod validity = 3; // 嵌套消息
}
message ValidityPeriod {
uint64 valid_from = 1;
uint64 valid_until = 2;
Schedule schedule = 3;
}处理规则
- 嵌套消息未设置时,整个字段不存在(非空消息)
- 判断嵌套消息是否存在:使用
has_xxx()方法(语言相关) - 空嵌套消息(所有字段为默认值)与未设置不同
repeated 字段
定义示例
protobuf
message ListUsersResponse {
uint32 code = 1;
repeated User users = 2; // 用户列表
}处理规则
- 空列表不会被序列化
- 接收方将未设置的 repeated 字段视为空列表
- 列表元素顺序会被保留
JSON 兼容
当使用 JSON 格式时,遵循 Proto3 JSON 映射 规范:
类型映射
| Protobuf | JSON | 示例 |
|---|---|---|
uint32 | number | 123 |
uint64 | string | "1735689600000" |
bool | boolean | true |
string | string | "hello" |
bytes | base64 string | "SGVsbG8=" |
enum | string (枚举名) | "ADMIN" |
message | object | {"field": value} |
repeated | array | [1, 2, 3] |
uint64 使用字符串
由于 JavaScript 的 Number 类型无法精确表示 64 位整数,uint64 和 int64 在 JSON 中使用字符串表示。
JSON 示例
Protobuf 定义:
protobuf
message User {
uint32 user_id = 1;
UserRole role = 2;
uint64 created_at = 3;
string nickname = 4;
}JSON 表示:
json
{
"userId": 1,
"role": "ADMIN",
"createdAt": "1735689600000",
"nickname": "张三"
}字段命名
- Protobuf 使用
snake_case - JSON 使用
camelCase - 序列化库自动转换
原始字节序列化
对于资源受限、无法引入 Protobuf 库的嵌入式设备,可使用原始字节序列化格式。
适用场景
- Flash/RAM 极度受限的 MCU
- 无法移植 nanopb 等轻量库的平台
- 需要最小化代码体积的 bootloader
编码规则
采用定长 + 变长混合编码,按字段顺序紧密排列:
| 类型 | 编码方式 | 长度 |
|---|---|---|
uint8 | 直接存储 | 1 字节 |
uint16 | 小端序 | 2 字节 |
uint32 | 小端序 | 4 字节 |
uint64 | 小端序 | 8 字节 |
bool | 0x00 或 0x01 | 1 字节 |
enum | uint8 或 uint16 | 1-2 字节 |
string | 长度(uint16) + UTF-8 数据 | 2 + N 字节 |
bytes | 长度(uint16) + 原始数据 | 2 + N 字节 |
字节序
所有多字节整数使用小端序(Little-Endian),与大多数嵌入式平台一致。
消息结构
每条消息的原始字节格式:
+----------+----------+----------+----------+
| 字段1 | 字段2 | 字段3 | ... |
+----------+----------+----------+----------+- 字段按 Protobuf 定义中的编号顺序排列
- 无字段标签,完全依赖位置
- 可选字段通过"存在标志位"处理
存在标志位
对于可选字段,在消息开头使用位图标记哪些字段存在:
+----------+----------+----------+----------+
| 标志位 | 字段1? | 字段2? | ... |
+----------+----------+----------+----------+- 每 8 个可选字段使用 1 字节标志位
- bit0 = 字段1存在,bit1 = 字段2存在,以此类推
- 标志位为 0 的字段不占用空间
示例
User 消息定义:
protobuf
message User {
uint32 user_id = 1; // 必填
UserRole role = 2; // 必填
uint64 created_at = 3; // 可选
string nickname = 4; // 可选
}原始字节布局:
+--------+----------+------+------------+----------+
| flags | user_id | role | created_at | nickname |
| 1 byte | 4 bytes | 1 B | 8 bytes? | 2+N B? |
+--------+----------+------+------------+----------+flags: bit0 = created_at 存在, bit1 = nickname 存在- 必填字段始终存在,不计入 flags
编码示例(user_id=1, role=ADMIN, nickname="test"):
flags: 0x02 (bit1=1, nickname存在; bit0=0, created_at不存在)
user_id: 01 00 00 00 (uint32 小端序)
role: 01 (ADMIN=1)
nickname: 04 00 (长度=4, 小端序)
74 65 73 74 ("test" UTF-8)
完整: 02 01 00 00 00 01 04 00 74 65 73 74 (12 字节)C 语言示例
c
// 序列化
int encode_user(uint8_t *buf, const User *user, bool has_created_at, bool has_nickname) {
int offset = 0;
// 标志位
uint8_t flags = 0;
if (has_created_at) flags |= 0x01;
if (has_nickname) flags |= 0x02;
buf[offset++] = flags;
// user_id (必填, uint32 小端序)
buf[offset++] = user->user_id & 0xFF;
buf[offset++] = (user->user_id >> 8) & 0xFF;
buf[offset++] = (user->user_id >> 16) & 0xFF;
buf[offset++] = (user->user_id >> 24) & 0xFF;
// role (必填, uint8)
buf[offset++] = user->role;
// created_at (可选, uint64 小端序)
if (has_created_at) {
for (int i = 0; i < 8; i++) {
buf[offset++] = (user->created_at >> (i * 8)) & 0xFF;
}
}
// nickname (可选, 长度前缀字符串)
if (has_nickname) {
uint16_t len = strlen(user->nickname);
buf[offset++] = len & 0xFF;
buf[offset++] = (len >> 8) & 0xFF;
memcpy(&buf[offset], user->nickname, len);
offset += len;
}
return offset;
}
// 反序列化
int decode_user(const uint8_t *buf, User *user, bool *has_created_at, bool *has_nickname) {
int offset = 0;
// 标志位
uint8_t flags = buf[offset++];
*has_created_at = (flags & 0x01) != 0;
*has_nickname = (flags & 0x02) != 0;
// user_id
user->user_id = buf[offset] | (buf[offset+1] << 8) |
(buf[offset+2] << 16) | (buf[offset+3] << 24);
offset += 4;
// role
user->role = buf[offset++];
// created_at
if (*has_created_at) {
user->created_at = 0;
for (int i = 0; i < 8; i++) {
user->created_at |= ((uint64_t)buf[offset++]) << (i * 8);
}
}
// nickname
if (*has_nickname) {
uint16_t len = buf[offset] | (buf[offset+1] << 8);
offset += 2;
memcpy(user->nickname, &buf[offset], len);
user->nickname[len] = '\0';
offset += len;
}
return offset;
}与 Protobuf 互转
服务端或网关设备负责格式转换:
设备端 (原始字节) <---> 网关 <---> 服务端 (Protobuf/JSON)网关需要:
- 了解消息类型(通过指令码判断)
- 按照消息定义解析原始字节
- 构造对应的 Protobuf 消息
- 反向转换时同理
版本兼容
向前兼容(新版本读旧数据)
- 新增字段使用新的字段编号
- 新字段在旧数据中不存在,使用默认值
- 枚举新增值时,旧版本将其保留为数字
向后兼容(旧版本读新数据)
- 未知字段会被保留(proto3 默认行为)
- 未知枚举值保留原始数值
- 删除的字段编号不应重用
禁止操作
- ❌ 修改已有字段的编号
- ❌ 修改已有字段的类型
- ❌ 重用已删除字段的编号
- ❌ 修改枚举值的数值
代码示例
C(nanopb)
c
#include "message.pb.h"
// 序列化
User user = User_init_zero;
user.user_id = 1;
user.role = UserRole_ADMIN;
uint8_t buffer[128];
pb_ostream_t stream = pb_ostream_from_buffer(buffer, sizeof(buffer));
pb_encode(&stream, User_fields, &user);
size_t length = stream.bytes_written;
// 反序列化
User decoded = User_init_zero;
pb_istream_t istream = pb_istream_from_buffer(buffer, length);
pb_decode(&istream, User_fields, &decoded);JavaScript
javascript
import { User, UserRole } from './message_pb.js';
// 序列化
const user = new User();
user.setUserId(1);
user.setRole(UserRole.ADMIN);
const bytes = user.serializeBinary();
// 反序列化
const decoded = User.deserializeBinary(bytes);
console.log(decoded.getUserId()); // 1
// JSON 互转
const json = user.toObject();Python
python
from message_pb2 import User, UserRole
# 序列化
user = User()
user.user_id = 1
user.role = UserRole.ADMIN
data = user.SerializeToString()
# 反序列化
decoded = User()
decoded.ParseFromString(data)
print(decoded.user_id) # 1
# JSON 互转
from google.protobuf.json_format import MessageToJson, Parse
json_str = MessageToJson(user)最佳实践
字段编号规划
- 1-15:高频字段(如 ID、状态)
- 16-2047:普通字段
- 预留编号空间给未来扩展
枚举设计
- 0 值表示未知或默认状态
- 按功能分组,预留数值空间
消息设计
- 保持消息结构扁平,避免过深嵌套
- 通用字段(如 code、message)放在前面
性能优化
- 复用 buffer 减少内存分配
- 批量处理时使用 repeated 而非多次请求
