注意
本文档适用于 Ceph 的开发版本。
序列化(编码/解码)
当一个结构体通过网络发送或写入磁盘时,它会被编码成一串字节。通常(但并非总是如此——Ceph 中存在多种序列化设施),可序列化的结构体具有 encode 和 decode 方法,用于向表示字节串的 bufferlist 对象写入和读取数据。
术语
最好不要以守护程序和客户端的视角来思考,而应以编码器和解码器的视角。编码器将结构体序列化为 bufferlist,而解码器则执行相反的操作。
编码器和解码器可以统称为 dencoders(编解码器)。
编解码器(包括编码器和解码器)存在于守护程序和客户端中。例如,当 RBD 客户端发出 IO 操作时,它会准备一个 MOSDOp 结构的实例,并将其编码成一个 bufferlist,然后放在线路上。OSD 读取这些字节并将其解码回一个 MOSDOp 实例。这里的编码器由客户端使用,而解码器由 OSD 使用。然而,这些角色可以互换——想象一下响应的处理:OSD 编码 MOSDOpReply,而 RBD 客户端解码。
编码器和解码器根据程序员通过实现 encode 和 decode 方法所定义的格式进行操作。
格式变更原则
序列化格式的更改并不少见。这个过程在开发和评审过程中都需要仔细关注。
一般规则是,解码器必须理解编码器编码的内容。大多数困难出现在确保旧解码器与新编码器兼容性的连续性,以及确保新解码器与旧解码器兼容性的连续性。人们应该假设——除非另有说明——集群中任何旧版本和新版本的混合都是可能的。有两个主要担忧:
升级。 尽管有关于实体类型(mons/OSDs/clients)顺序的建议,但这不是强制性的,不应做出任何假设。
客户端版本的巨大可变性。 内核升级(以及随之而来的内核客户端)与 Ceph 升级脱钩一直如此。容器化甚至给
librbd带来了可变性——现在用户空间库存在于容器本身中。
有一些规则限制了编解码器之间的互操作程度:
守护程序之间的编解码为
n-2,客户端场景的硬性要求为
n-3,客户端场景的软性要求为
n-3..。理想情况下,每个客户端都应该能够与任何版本的守护程序通信。
由于根本原因相同,编解码器遵循的规则与我们的功能位弃用规则几乎相同。请参阅 src/include/ceph_features.h 中的 Notes on deprecation。
框架
目前存在多种编解码辅助工具:
encoding.h(最流行的),
denc.h(性能优化,主要出现在
BlueStore中),“Message” 层次结构。
尽管细节有所不同,但互操作性规则保持不变。
向结构体添加字段
你可以在 Ceph 代码中看到这方面的例子,但这里有一个例子:
class AcmeClass
{
int member1;
std::string member2;
void encode(bufferlist &bl)
{
ENCODE_START(1, 1, bl);
::encode(member1, bl);
::encode(member2, bl);
ENCODE_FINISH(bl);
}
void decode(bufferlist::iterator &bl)
{
DECODE_START(1, bl);
::decode(member1, bl);
::decode(member2, bl);
DECODE_FINISH(bl);
}
};
ENCODE_START 宏写入一个标头,指定一个 *version* 和一个 *compat_version*(两者初始值均为 1)。每当对编码进行更改时,消息版本都会递增。只有当更改会破坏现有解码器时,compat_version 才会递增——解码器对尾随字节具有容忍性,因此在结构体末尾添加字段的更改不需要递增 compat_version。
DECODE_START 宏接受一个参数,指定代码可以处理的最新消息版本。这会与消息中编码的 compat_version 进行比较,如果消息太新,则会抛出异常。由于 compat_version 的更改很少见,因此在添加字段时通常不必担心这个问题。
在实践中,编码的更改通常涉及简单地在 encode 和 decode 函数末尾添加所需的字段,并递增 ENCODE_START 和 DECODE_START 中的版本。例如,以下是如何向 AcmeClass 添加第三个字段:
class AcmeClass
{
int member1;
std::string member2;
std::vector<std::string> member3;
void encode(bufferlist &bl)
{
ENCODE_START(2, 1, bl);
::encode(member1, bl);
::encode(member2, bl);
::encode(member3, bl);
ENCODE_FINISH(bl);
}
void decode(bufferlist::iterator &bl)
{
DECODE_START(2, bl);
::decode(member1, bl);
::decode(member2, bl);
if (struct_v >= 2) {
::decode(member3, bl);
}
DECODE_FINISH(bl);
}
};
请注意,compat_version 没有更改,因为编码的消息仍然可以被只理解版本 1 的代码版本解码——它们只会忽略我们编码 member3 的尾随字节。
在 decode 函数中,新字段的解码是有条件的:这是因为我们可能仍然会传入没有该字段的旧版本消息。struct_v 变量是由 DECODE_START 宏设置的本地变量。
# 深入细节
我们的编解码器的追加可扩展性是 ENCODE_START 和 DECODE_FINISH 宏带来的向前兼容性的结果。
它们正在实现可扩展性设施。当编码器填充 bufferlist 时,它会在前面添加三个字段:当前格式的版本、与其兼容的最小解码器版本以及所有编码字段的总大小。
/**
* start encoding block
*
* @param v current (code) version of the encoding
* @param compat oldest code version that can decode it
* @param bl bufferlist to encode to
*
*/
#define ENCODE_START(v, compat, bl) \
__u8 struct_v = v; \
__u8 struct_compat = compat; \
ceph_le32 struct_len; \
auto filler = (bl).append_hole(sizeof(struct_v) + \
sizeof(struct_compat) + sizeof(struct_len)); \
const auto starting_bl_len = (bl).length(); \
using ::ceph::encode; \
do {
struct_len 字段允许解码器吃掉用户提供的 decode 实现中未解码的所有字节。类似地,解码器会跟踪用户提供的 decode 方法中已解码的输入量。
#define DECODE_START(bl) \
unsigned struct_end = 0; \
__u32 struct_len; \
decode(struct_len, bl); \
... \
struct_end = bl.get_off() + struct_len; \
} \
do {
解码器使用此信息来丢弃它不理解的额外字节。推进 bufferlist 至关重要,因为编解码器往往是嵌套的;如果保持不变,它只适用于嵌套结构中最后一次 decode 调用。
#define DECODE_FINISH(bl) \
} while (false); \
if (struct_end) { \
... \
if (bl.get_off() < struct_end) \
bl += struct_end - bl.get_off(); \
}
这个完整的、协作的机制允许编码器(其后续修订版)生成更多的字节流(例如,在末尾添加一个新字段),并且不必担心残留部分会使旧的解码器修订版崩溃。