原生类型模块 API
如何在 Redis 模块中使用原生类型
Redis 模块可以通过调用 Redis 命令在高层访问 Redis 内置数据结构,也可以通过直接操作数据结构在低层访问。
通过使用这些功能在现有 Redis 数据结构之上构建新的抽象,或者通过使用字符串 DMA 将模块数据结构编码为 Redis 字符串,可以创建感觉像导出新数据类型的模块。但是,对于更复杂的问题,这还不够,需要在模块内部实现新的数据结构。
我们称 Redis 模块实现感觉像原生 Redis 数据结构的新数据结构的能力为原生类型支持。本文档介绍了 Redis 模块系统导出的 API,用于创建新的数据结构并处理 RDB 文件中的序列化、AOF 中的重写过程、通过 TYPE
命令进行的类型报告等等。
原生类型概述
导出原生类型的模块由以下主要部分组成
- 某种新数据结构以及对新数据结构进行操作的命令的实现。
- 一组回调,用于处理:RDB 保存、RDB 加载、AOF 重写、释放与键关联的值、计算与
DEBUG DIGEST
命令一起使用的值摘要(哈希)。 - 一个 9 个字符的名称,对于每个模块原生数据类型都是唯一的。
- 一个编码版本,用于将模块特定的数据版本持久化到 RDB 文件中,以便模块能够从 RDB 文件加载旧的表示形式。
虽然处理 RDB 加载、保存和 AOF 重写乍一看可能很复杂,但模块 API 提供了非常高级的函数来处理所有这些,而无需用户处理读取/写入错误,因此实际上,为 Redis 编写新的数据结构是一项简单的任务。
Redis 发行版中的 /modules/hellotype.c
文件中提供了一个非常容易理解但完整的原生类型实现的示例。鼓励读者通过查看此示例实现来阅读文档,以了解如何在实践中应用这些内容。
注册新的数据类型
为了将新的原生类型注册到 Redis 核心中,模块需要声明一个全局变量,该变量将保存对数据类型的引用。注册数据类型的 API 将返回一个数据类型引用,该引用将存储在全局变量中。
static RedisModuleType *MyType;
#define MYTYPE_ENCODING_VERSION 0
int RedisModule_OnLoad(RedisModuleCtx *ctx) {
RedisModuleTypeMethods tm = {
.version = REDISMODULE_TYPE_METHOD_VERSION,
.rdb_load = MyTypeRDBLoad,
.rdb_save = MyTypeRDBSave,
.aof_rewrite = MyTypeAOFRewrite,
.free = MyTypeFree
};
MyType = RedisModule_CreateDataType(ctx, "MyType-AZ",
MYTYPE_ENCODING_VERSION, &tm);
if (MyType == NULL) return REDISMODULE_ERR;
}
正如您从上面的示例中看到的那样,只需要一个 API 调用即可注册新类型。但是,许多函数指针作为参数传递。有些是可选的,有些是强制性的。必须传递上述方法集,而 .digest
和 .mem_usage
是可选的,并且目前实际上不受模块内部支持,因此现在您可以忽略它们。
ctx
参数是我们在 OnLoad
函数中收到的上下文。类型 name
是一个 9 个字符的名称,字符集包括 A-Z
、a-z
、0-9
,以及下划线 _
和减号 -
字符。
请注意,对于 Redis 生态系统中的每个数据类型,此名称必须是唯一的,因此请发挥创造力,如果合理,请同时使用小写和大写,并尝试使用将类型名称与模块作者姓名混合的约定,以创建一个 9 个字符的唯一名称。
注意: 非常重要的是,名称必须正好是 9 个字符,否则类型的注册将失败。阅读更多内容以了解原因。
例如,如果我正在构建一个 b-tree 数据结构,而我的名字是 antirez,我将我的类型称为 btree1-az。该名称转换为 64 位整数后,会在保存类型时存储在 RDB 文件中,并在加载 RDB 数据时用于解析哪个模块可以加载该数据。如果 Redis 找不到匹配的模块,则将整数转换回名称,以便向用户提供一些关于缺少什么模块才能加载数据的线索。
该类型名称也用作使用保存注册类型的键调用 TYPE
命令的回复。
encver
参数是模块用于将数据存储在 RDB 文件中的编码版本。例如,我可以从编码版本 0 开始,但稍后当我发布模块的 2.0 版本时,我可以将编码切换到更好的东西。新模块将使用编码版本 1 注册,因此当它保存新的 RDB 文件时,新版本将存储在磁盘上。但是,在加载 RDB 文件时,即使找到不同编码版本的数据,也会调用模块 rdb_load
方法(并且编码版本作为参数传递),以便模块仍然可以加载旧的 RDB 文件。
最后一个参数是一个结构,用于将类型方法传递给注册函数:rdb_load
、rdb_save
、aof_rewrite
、digest
和 free
以及 mem_usage
都是具有以下原型和用途的回调
typedef void *(*RedisModuleTypeLoadFunc)(RedisModuleIO *rdb, int encver);
typedef void (*RedisModuleTypeSaveFunc)(RedisModuleIO *rdb, void *value);
typedef void (*RedisModuleTypeRewriteFunc)(RedisModuleIO *aof, RedisModuleString *key, void *value);
typedef size_t (*RedisModuleTypeMemUsageFunc)(void *value);
typedef void (*RedisModuleTypeDigestFunc)(RedisModuleDigest *digest, void *value);
typedef void (*RedisModuleTypeFreeFunc)(void *value);
rdb_load
在从 RDB 文件加载数据时调用。它以与rdb_save
产生的格式相同的格式加载数据。rdb_save
在将数据保存到 RDB 文件时调用。aof_rewrite
在重写 AOF 时调用,模块需要告诉 Redis 重建给定键内容的一系列命令是什么。digest
在执行DEBUG DIGEST
并且找到保存此模块类型的键时调用。目前尚未实现,因此该函数可以留空。- 当
MEMORY
命令请求特定键使用的总内存时,会调用mem_usage
,并用于获取模块值使用的字节数。 - 当模块原生类型的键通过
DEL
或任何其他方式删除时,会调用free
,以便模块可以回收与该值关联的内存。
为什么模块类型需要九个字符的名称
当 Redis 持久化到 RDB 文件时,模块特定的数据类型也需要持久化。现在 RDB 文件是像下面这样的键值对序列
[1 byte type] [key] [a type specific value]
1 字节类型标识字符串、列表、集合等等。在模块数据的情况下,它被设置为 module data
的特殊值,但当然这还不够,我们需要信息将特定值与能够加载和处理它的特定模块类型链接起来。
因此,当我们保存关于模块的 type specific value
时,我们用一个 64 位整数作为前缀。64 位足够大,可以存储查找可以处理该特定类型的模块所需的信息,但又足够短,可以为存储在 RDB 中的每个模块值添加前缀,而不会使最终的 RDB 文件太大。同时,这种用 64 位签名为值添加前缀的解决方案不需要做奇怪的事情,例如在 RDB 标头中定义模块特定类型的列表。一切都非常简单。
那么,为了以可靠的方式识别给定的模块,您可以在 64 位中存储什么?好吧,如果您构建一个包含 64 个符号的字符集,您可以轻松地存储 9 个 6 位的字符,并且剩下 10 位,用于存储该类型的编码版本,以便相同的类型可以在未来发展并为 RDB 文件提供不同且更有效或更新的序列化格式。
因此,存储在每个模块值之前的 64 位前缀类似于以下内容:
6|6|6|6|6|6|6|6|6|10
前 9 个元素是 6 位字符,最后 10 位是编码版本。
当 RDB 文件被加载回来时,它读取 64 位值,屏蔽最后 10 位,并在模块类型缓存中搜索匹配的模块。 当找到匹配的模块时,将调用加载 RDB 文件值的方法,并将 10 位编码版本作为参数传递,以便模块知道要加载的数据布局的版本(如果它支持多个版本)。
现在,关于这一切有趣的是,如果无法解析模块类型(因为没有加载具有此签名的模块),我们可以将 64 位值转换回 9 个字符的名称,并向用户打印一个错误,其中包含模块类型名称! 这样她或他就能立即意识到哪里出错了。
设置和获取键
在 RedisModule_OnLoad()
函数中注册我们的新数据类型后,我们还需要能够设置 Redis 键,将其值设置为我们的本机类型。
这通常发生在向键写入数据的命令上下文中。 本机类型 API 允许设置和获取键到模块本机数据类型,并测试给定的键是否已与特定数据类型的值关联。
该 API 使用常规模块的 RedisModule_OpenKey()
底层键访问接口来处理此问题。 这是一个将本机类型私有数据结构设置为 Redis 键的示例:
RedisModuleKey *key = RedisModule_OpenKey(ctx,keyname,REDISMODULE_WRITE);
struct some_private_struct *data = createMyDataStructure();
RedisModule_ModuleTypeSetValue(key,MyType,data);
函数 RedisModule_ModuleTypeSetValue()
与为写入而打开的键句柄一起使用,并获取三个参数:键句柄、对本机类型的引用(在类型注册期间获得)以及最终包含实现模块本机类型的私有数据的 void*
指针。
请注意,Redis 完全不了解您的数据包含什么。 它只会调用您在方法注册期间提供的回调,以便对类型执行操作。
类似地,我们可以使用此函数从键中检索私有数据:
struct some_private_struct *data;
data = RedisModule_ModuleTypeGetValue(key);
我们还可以测试键是否具有我们的本机类型作为值:
if (RedisModule_ModuleTypeGetType(key) == MyType) {
/* ... do something ... */
}
但是,为了使调用执行正确的操作,我们需要检查键是否为空,是否包含正确类型的值等等。 因此,实现写入本机类型的命令的惯用代码如下:
RedisModuleKey *key = RedisModule_OpenKey(ctx,argv[1],
REDISMODULE_READ|REDISMODULE_WRITE);
int type = RedisModule_KeyType(key);
if (type != REDISMODULE_KEYTYPE_EMPTY &&
RedisModule_ModuleTypeGetType(key) != MyType)
{
return RedisModule_ReplyWithError(ctx,REDISMODULE_ERRORMSG_WRONGTYPE);
}
然后,如果我们成功验证了键不是错误的类型,并且我们将要写入它,我们通常希望在键为空时创建一个新的数据结构,或者检索与键关联的值的引用如果已经存在一个。
/* Create an empty value object if the key is currently empty. */
struct some_private_struct *data;
if (type == REDISMODULE_KEYTYPE_EMPTY) {
data = createMyDataStructure();
RedisModule_ModuleTypeSetValue(key,MyTyke,data);
} else {
data = RedisModule_ModuleTypeGetValue(key);
}
/* Do something with 'data'... */
释放方法
如前所述,当 Redis 需要释放持有本机类型值的键时,它需要模块的帮助才能释放内存。 这就是为什么我们在类型注册期间传递一个 free
回调的原因。
typedef void (*RedisModuleTypeFreeFunc)(void *value);
假设我们的数据结构由单个分配组成,那么 free 方法的一个简单实现可能是这样的:
void MyTypeFreeCallback(void *value) {
RedisModule_Free(value);
}
然而,更真实的实现会调用一些执行更复杂的内存回收的函数,通过将 void 指针转换为某个结构并释放组成该值的所有资源。
RDB 加载和保存方法
RDB 保存和加载回调需要在磁盘上创建(和加载回)数据类型的表示形式。 Redis 提供了一个高级 API,可以自动将以下类型存储在 RDB 文件中:
- 无符号 64 位整数。
- 带符号 64 位整数。
- 双精度浮点数。
- 字符串。
模块可以找到使用上述基本类型的可行表示形式。 但是请注意,虽然整数和双精度浮点值以与体系结构和*字节序*无关的方式存储和加载,但如果您使用原始字符串保存 API(例如,在磁盘上保存一个结构),则必须自己注意这些细节。
以下是执行 RDB 保存和加载的函数列表:
void RedisModule_SaveUnsigned(RedisModuleIO *io, uint64_t value);
uint64_t RedisModule_LoadUnsigned(RedisModuleIO *io);
void RedisModule_SaveSigned(RedisModuleIO *io, int64_t value);
int64_t RedisModule_LoadSigned(RedisModuleIO *io);
void RedisModule_SaveString(RedisModuleIO *io, RedisModuleString *s);
void RedisModule_SaveStringBuffer(RedisModuleIO *io, const char *str, size_t len);
RedisModuleString *RedisModule_LoadString(RedisModuleIO *io);
char *RedisModule_LoadStringBuffer(RedisModuleIO *io, size_t *lenptr);
void RedisModule_SaveDouble(RedisModuleIO *io, double value);
double RedisModule_LoadDouble(RedisModuleIO *io);
这些函数不需要模块进行任何错误检查,模块始终可以假定调用成功。
例如,假设我有一个本机类型,该类型实现一个双精度浮点值数组,其结构如下:
struct double_array {
size_t count;
double *values;
};
我的 rdb_save
方法可能如下所示:
void DoubleArrayRDBSave(RedisModuleIO *io, void *ptr) {
struct dobule_array *da = ptr;
RedisModule_SaveUnsigned(io,da->count);
for (size_t j = 0; j < da->count; j++)
RedisModule_SaveDouble(io,da->values[j]);
}
我们所做的是存储元素数,然后存储每个双精度浮点值。 因此,稍后当我们必须在 rdb_load
方法中加载结构时,我们将执行以下操作:
void *DoubleArrayRDBLoad(RedisModuleIO *io, int encver) {
if (encver != DOUBLE_ARRAY_ENC_VER) {
/* We should actually log an error here, or try to implement
the ability to load older versions of our data structure. */
return NULL;
}
struct double_array *da;
da = RedisModule_Alloc(sizeof(*da));
da->count = RedisModule_LoadUnsigned(io);
da->values = RedisModule_Alloc(da->count * sizeof(double));
for (size_t j = 0; j < da->count; j++)
da->values[j] = RedisModule_LoadDouble(io);
return da;
}
加载回调只是从我们存储在 RDB 文件中的数据中重建数据结构。
请注意,虽然在从磁盘写入和读取的 API 上没有错误处理,但如果它读取的内容看起来不正确,加载回调仍然可以返回 NULL 并出现错误。 在这种情况下,Redis 会直接 panic。
AOF 重写
void RedisModule_EmitAOF(RedisModuleIO *io, const char *cmdname, const char *fmt, ...);
分配内存
模块数据类型应尝试使用 RedisModule_Alloc()
函数系列来分配、重新分配和释放用于实现本机数据结构的堆内存(有关详细信息,请参阅其他 Redis 模块文档)。
这不仅有助于 Redis 能够统计模块使用的内存,而且还有更多优点:
- Redis 使用
jemalloc
分配器,该分配器通常可以防止因使用 libc 分配器而可能导致的碎片问题。 - 从 RDB 文件加载字符串时,本机类型 API 能够返回直接使用
RedisModule_Alloc()
分配的字符串,以便模块可以直接将此内存链接到数据结构表示形式中,从而避免了数据的无用复制。
即使您使用的是实现数据结构的外部库,模块 API 提供的分配函数也与 malloc()
、realloc()
、free()
和 strdup()
完全兼容,因此转换库以便使用这些函数应该很简单。
如果您有一个使用 libc malloc()
的外部库,并且您想避免手动替换所有带有 Redis 模块 API 调用的调用,一种方法是使用简单的宏来替换 libc 调用为 Redis API 调用。 类似这样的方法可能会奏效:
#define malloc RedisModule_Alloc
#define realloc RedisModule_Realloc
#define free RedisModule_Free
#define strdup RedisModule_Strdup
但是请记住,将 libc 调用与 Redis API 调用混合在一起会导致问题和崩溃,因此如果您使用宏替换调用,您需要确保所有调用都已正确替换,并且替换后的代码永远不会,例如,尝试使用 libc malloc()
分配的指针调用 RedisModule_Free()
。