原生类型的模块 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_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
执行时被调用,并且找到一个持有此模块类型的键。目前这尚未实现,因此该函数可以留空。mem_usage
在MEMORY
命令请求特定键消耗的总内存时被调用,用于获取模块值使用的字节数。free
在通过DEL
或其他方式删除具有模块原生类型的键时被调用,以便让模块回收与此类值相关的内存。
为什么模块类型需要九个字符的名称
当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将会直接崩溃。
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()
。