数据序列化
9.1 序列化的必要性
到目前为止,响应都是一个字符串,但一些Redis命令会返回其他数据类型。例如,dbsize
命令返回的键的数量是一个整数,keys
命令返回一个字符串列表,一些有序集合命令返回一个由(字符串,浮点数)对组成的列表。将这些数据编码为字节的过程称为序列化,这是协议的一部分。
流行的序列化格式包括XML、JSON、MessagePack、Protobuf、Thrift。
源代码在文章末尾
简单数据类型
简单数据类型包括字符串、整数、浮点数、布尔值、空值。
虽然所有这些数据类型都可以简单地表示为字符串,但这并不是一个好主意。字符串具有歧义性,并且需要额外的解析工作。在序列化格式中,有必要区分不同的数据类型。除了XML之外,许多常见的序列化格式都定义了除字符串之外的数据类型。这就是XML不再流行的原因。
复杂数据类型
复杂数据类型包括数组、映射、结构体。它们是其他数据类型的集合。
并非所有的序列化格式都支持映射或结构体。一个结构体可以编码为一个映射或一个数组,一个映射可以编码为一个键值对数组。
有模式或无模式
像XML或JSON这样的格式是自描述的;它们可以在没有额外信息的情况下被完全解析。而Protobuf将数据类型描述分开存储,序列化后的数据可能不包含足够的类型信息。这种分开存储的描述被称为模式。
encode
schema + input ──────────► bytes
decode
schema + bytes ──────────► output
模式确保数据具有一定的结构,但它也有成本:
- 1. 模式是额外的组成部分,必须与数据的使用者保持同步。
- 2. 模式需要额外的库或工具,例如代码生成器。
自描述意味着模式是可选的;它们仍然可以被添加,例如:JSON Schema。虽然Thrift是自描述的,但它通常与模式一起使用。
以JSON作为参考模型
JSON通常是一种足够好的格式;它具有以下理想的特性:
- 1. 它定义了在大多数语言中都能找到的简单数据类型:字符串、数字、布尔值、空值。
- 2. JSON数组和对象可以轻松地转换为原生的数组、映射、结构体。
- 3. JSON是自描述的。模式是可选的。
我们可以将JSON作为理解序列化要求的参考。但它也有很多缺点。序列化格式可以既更简单又更好。
9.2 序列化格式
分隔符格式
分隔符用于确定字符串、数组、映射的长度。JSON也使用不同的分隔符来标识不同的数据类型。
- 1. JSON字符串,用引号分隔:"foo"。
- 2. JSON数组,用方括号和逗号分隔:[1, 2, 3]。
- 3. JSON数组,用花括号、冒号、逗号分隔:{"k":"v", "foo":"bar"}。
分隔符不是处理可变长度数据的唯一方法,但它们使格式可以在文本编辑器中由人编写。JSON是最简单的实用文本格式。解析JSON并不难,但它仍然比必要的情况更复杂。由于文本格式的复杂性,许多JSON解析器都存在错误:
- 1. 可选的空白字符。
- 2. 由于分隔符而产生的复杂转义规则。
- 3. 数字的字符串表示具有歧义性。
幸运的是,通过不使用文本格式,所有这些复杂性都可以避免。
标签-长度-值(Tag-Length-Value,TLV)
大多数二进制序列化格式都遵循标签-长度-值(TLV)的模式。
- 1. “标签”或“类型”放在首位,它使数据具有自描述性。
- 2. “长度”用于字符串、数组、映射,它消除了对分隔符的需求。
- 3. “值”是编码后的数据。
示例:以TLV格式表示的[123, "foo"]。
array 2 int str 3
┌─────┬─────┬─────┬─────┬─────┬─────┬─────┐
│ tag │ len │ tag │ 123 │ tag │ len │ foo │
└─────┴─────┴─────┴─────┴─────┴─────┴─────┘
你大概明白了。剩下的细节就是将TLV元组编码为二进制位和字节。
流行序列化格式的比较
你应该阅读流行格式的详细信息,比较它们的优缺点,并从中吸取经验教训。但我在这里只做一个总结。
名称 | 文本格式 | 自描述 | 简单类型 | 复杂类型 |
XML | 是 | 是 | 字符串 | 标记 |
JSON | 是 | 是 | 空值、布尔值、数字、字符串 | 数组、映射 |
MessagePack | 否 | 是 | 空值、布尔值、整数、浮点数、字符串 | 数组、映射 |
Thrift | 否 | 是 | 布尔值、整数、浮点数、字符串 | 数组、映射、结构体 |
Protobuf | 否 | 否 | 布尔值、整数、浮点数、字符串 | 结构体 |
XML输给JSON是因为它不能干净利落地转换为常见的数据类型(数组、映射、结构体)。一个XML元素可以有一个map<string, string>
作为属性,或者有一个子元素数组,或者有文本内容,或者是这些的组合,或者什么都没有。XML试图无所不能,但最终却什么都做不好。
MessagePack只是带有一些编码优化的二进制JSON。它还添加了一些JSON所缺少的有用数据类型:整数、浮点数、二进制字节。
Thrift类似于MessagePack,但增加了结构体。它旨在与模式一起使用,在这种情况下,结构体可以替代一些映射。与映射的键不同,结构体的字段名不会被序列化,它们会被整数字符串ID所取代。Thrift在数组元素和映射键值对仅限于相同类型这一点上更具“静态类型”特性。它没有空值,但结构体字段可以是可选的。
Protobuf与Thrift类似,因为它们都使用模式定义的结构体。但它有很多奇怪的地方,它没有数组,而是允许结构体字段重复。一个只重复一次的重复字段与非重复字段无法区分。并且不同的数据类型共享相同的“标签”。所以它不是自描述的;需要模式来解释Protobuf数据。
大多数二进制格式都使用TLV,只是编码方式不同。我们要做的是实现最简单的TLV编码。许多项目,包括真正的Redis,都开发了自己的序列化格式,因为这非常容易。
9.3 一种简单的序列化格式
我们将支持以下数据类型:
enum {
TAG_NIL = 0, // 空值
TAG_ERR = 1, // 错误码 + 消息
TAG_STR = 2, // 字符串
TAG_INT = 3, // int64
TAG_DBL = 4, // 双精度浮点数
TAG_ARR = 5, // 数组
};
一个响应就是这些类型中的一种。与JSON类似,每个数组元素可以是任何类型,包括嵌套数组。我们目前不需要映射类型。
二进制格式
标签占1个字节,长度占4个字节。字符串可以是任意字节。数组长度是数组元素的数量。
nil int64 str array
┌─────┐ ┌─────┬─────┐ ┌─────┬─────┬─────┐ ┌─────┬─────┬─────┐
│ tag │ │ tag │ int │ │ tag │ len │ ... │ │ tag │ len │ ... │
└─────┘ └─────┴─────┘ └─────┴─────┴─────┘ └─────┴─────┴─────┘
1B 1B 8B 1B 4B ... 1B 4B ...
整数和长度以小端序编码,在所有相关平台上这只是简单地复制值。
输出序列化数据
我们已经放弃了结构体Response
,并将序列化后的响应直接输出到Conn::outgoing
。
static void do_get(std::vector<std::string> &cmd, Buffer &out) {
// ...
return out_str(out, val.data(), val.size()); // 字符串值
}
static void do_set(std::vector<std::string> &cmd, Buffer &out) {
// ...
return out_nil(out); // 空值
}
static void do_del(std::vector<std::string> &cmd, Buffer &out) {
// ...
return out_int(out, node ? 1 : 0); // 删除的键的数量
}
static void out_nil(Buffer &out) {
buf_append_u8(out, TAG_NIL);
}
static void out_str(Buffer &out, const char *s, size_t size) {
buf_append_u8(out, TAG_STR);
buf_append_u32(out, (uint32_t)size);
buf_append(out, (const uint8_t *)s, size);
}
static void out_int(Buffer &out, int64_t val) {
buf_append_u8(out, TAG_INT);
buf_append_i64(out, val);
}
static void out_arr(Buffer &out, uint32_t n) {
buf_append_u8(out, TAG_ARR);
buf_append_u32(out, n);
}
如果在第6b章中你没有用更好的东西替换它,Buffer
就是std::vector
。这些辅助函数是处理字节序的地方。
typedef std::vector<uint8_t> Buffer;
static void buf_append_u8(Buffer &buf, uint8_t data) {
buf.push_back(data);
}
static void buf_append_u32(Buffer &buf, uint32_t data) {
buf_append(buf, (const uint8_t *)&data, 4); // 假设为小端序
}
处理消息头
现在我们将响应体输出到Conn::outgoing
。响应的大小事先是不知道的。所以我们必须为响应头预留4个字节的空间。
static bool try_one_request(Conn *conn) {
// ...
size_t header_pos = 0;
response_begin(conn->outgoing, &header_pos);
do_request(cmd, conn->outgoing);
response_end(conn->outgoing, header_pos);
// ...
}
预留的消息头的位置保存在header_pos
中,以便稍后更新消息头。
static void response_begin(Buffer &out, size_t *header) {
*header = out.size(); // 消息头位置
buf_append_u32(out, 0); // 预留空间
}
static size_t response_size(Buffer &out, size_t header) {
return out.size() - header - 4;
}
static void response_end(Buffer &out, size_t header) {
size_t msg_size = response_size(out, header);
if (msg_size > k_max_msg) {
out.resize(header + 4);
out_err(out, ERR_TOO_BIG, "response is too big.");
msg_size = response_size(out, header);
}
// 消息头
uint32_t len = (uint32_t)msg_size;
memcpy(&out[header], &len, 4);
}
添加keys
命令
hm_foreach()
函数会对每个元素调用回调函数。如果你真的自己编写了哈希表代码,这很简单。
static bool cb_keys(HNode *node, void *arg) {
Buffer &out = *(Buffer *)arg;
const std::string &key = container_of(node, Entry, node)->key;
out_str(out, key.data(), key.size());
return true;
}
static void do_keys(std::vector<std::string> &, Buffer &out) {
out_arr(out, (uint32_t)hm_size(&g_data.db));
hm_foreach(&g_data.db, &cb_keys, (void *)&out);
}
添加这个命令是为了说明数组类型。现在你可以添加更多的命令,键值对中的“值”部分不仅仅是字符串,它可以是其他数据结构,如哈希表、有序集合。在下一章中,我们将专注于排序数据结构。
源代码: