文档:模块API:原生类型支持

Valkey 模块可以通过调用 Valkey 命令在高层级访问 Valkey 内置数据结构,也可以通过直接操作数据结构在低层级访问。

通过利用这些能力,在现有 Valkey 数据结构之上构建新的抽象,或者利用字符串DMA将模块数据结构编码为字符串,可以创建出感觉像是导出了新数据类型的模块。然而,对于更复杂的问题,这还不够,需要在模块内部实现新的数据结构。

我们将 Valkey 模块实现感觉像是 Valkey 原生数据结构的新数据结构的能力称为原生类型支持。本文档描述了 Valkey 模块系统导出的 API,以便创建新的数据结构并处理 RDB 文件中的序列化、AOF 中的重写过程、通过 TYPE 命令进行类型报告等等。

原生类型概述

导出原生类型的模块由以下主要部分组成

  • 某种新数据结构的实现以及对新数据结构操作的命令。
  • 一组回调函数,用于处理:RDB 保存、RDB 加载、AOF 重写、释放与键关联的值,以及计算与 DEBUG DIGEST 命令一起使用的值摘要(哈希)。
  • 一个9字符的名称,每个模块原生数据类型都必须是唯一的。
  • 一个编码版本,用于将模块特定的数据版本持久化到 RDB 文件中,以便模块能够从 RDB 文件加载旧的表示形式。

虽然处理 RDB 加载、保存和 AOF 重写乍一看可能很复杂,但模块 API 提供了非常高级的函数来处理所有这些,无需用户处理读/写错误,因此实际上,为 Valkey 编写新的数据结构是一个简单的任务。

Valkey 发行版中 /modules/hellotype.c 文件中提供了一个非常易懂但完整的原生类型实现示例。建议读者通过查看此示例实现来阅读文档,以了解其在实践中如何应用。

注册新的数据类型

为了在 Valkey 核心中注册新的原生类型,模块需要声明一个全局变量来保存数据类型的引用。注册数据类型的 API 将返回一个数据类型引用,该引用将存储在全局变量中。

static ValkeyModuleType *MyType;
#define MYTYPE_ENCODING_VERSION 0

int ValkeyModule_OnLoad(ValkeyModuleCtx *ctx) {
ValkeyModuleTypeMethods tm = {
    .version = VALKEYMODULE_TYPE_METHOD_VERSION,
    .rdb_load = MyTypeRDBLoad,
    .rdb_save = MyTypeRDBSave,
    .aof_rewrite = MyTypeAOFRewrite,
    .free = MyTypeFree
};

    MyType = ValkeyModule_CreateDataType(ctx, "MyType-AZ",
	MYTYPE_ENCODING_VERSION, &tm);
    if (MyType == NULL) return VALKEYMODULE_ERR;
}

如您从上面的示例中看到的,注册新类型只需要一个 API 调用。然而,许多函数指针作为参数传递。某些是可选的,而有些是强制性的。上述方法集必须传递,而 .digest.mem_usage 是可选的,并且目前模块内部实际上不支持,因此现在您可以直接忽略它们。

ctx 参数是我们在 OnLoad 函数中接收到的上下文。类型 name 是一个9字符的名称,其字符集包括 A-Za-z0-9,以及下划线 _ 和连字符 -

请注意,在 Valkey 生态系统中,此名称对于每种数据类型都必须是唯一的,因此请发挥创意,如果合适,可以同时使用小写和大写字母,并尝试遵循将类型名称与模块作者名称混合的约定,以创建9字符的唯一名称。

注意:名称必须恰好是9个字符,否则类型注册将失败。阅读更多内容以了解原因。

例如,如果我正在构建一个 b-tree 数据结构,而我的名字是 antirez,我将我的类型命名为 btree1-az。该名称在保存类型时会转换为64位整数并存储在 RDB 文件中,当加载 RDB 数据时将用于解析哪个模块可以加载该数据。如果 Valkey 没有找到匹配的模块,该整数将转换回名称,以便向用户提供关于缺少哪个模块才能加载数据的线索。

当使用持有已注册类型的键调用 TYPE 命令时,类型名称也用作其回复。

encver 参数是模块用于在 RDB 文件中存储数据的编码版本。例如,我可以从编码版本0开始,但当我发布模块2.0版本时,我可以切换到更好的编码。新模块将注册一个编码版本1,因此当它保存新的 RDB 文件时,新版本将存储在磁盘上。然而,当加载 RDB 文件时,即使找到了不同编码版本的数据(并且编码版本作为参数传递给 rdb_load),模块的 rdb_load 方法仍将被调用,这样模块仍然可以加载旧的 RDB 文件。

最后一个参数是一个结构体,用于将类型方法传递给注册函数:rdb_loadrdb_saveaof_rewritedigestfree 以及 mem_usage 都是回调函数,具有以下原型和用途

typedef void *(*ValkeyModuleTypeLoadFunc)(ValkeyModuleIO *rdb, int encver);
typedef void (*ValkeyModuleTypeSaveFunc)(ValkeyModuleIO *rdb, void *value);
typedef void (*ValkeyModuleTypeRewriteFunc)(ValkeyModuleIO *aof, ValkeyModuleString *key, void *value);
typedef size_t (*ValkeyModuleTypeMemUsageFunc)(void *value);
typedef void (*ValkeyModuleTypeDigestFunc)(ValkeyModuleDigest *digest, void *value);
typedef void (*ValkeyModuleTypeFreeFunc)(void *value);
  • 从 RDB 文件加载数据时会调用 rdb_load。它以与 rdb_save 生成的相同格式加载数据。
  • 将数据保存到 RDB 文件时会调用 rdb_save
  • 当 AOF 正在重写时会调用 aof_rewrite,模块需要告诉 Valkey 重新创建给定键内容的命令序列是什么。
  • 当执行 DEBUG DIGEST 并找到持有此模块类型的键时会调用 digest。目前尚未实现此功能,因此该函数可以留空。
  • MEMORY 命令请求特定键消耗的总内存时会调用 mem_usage,用于获取模块值使用的字节数。
  • 当通过 DEL 或任何其他方式删除具有模块原生类型的键时,会调用 free,以便让模块回收与该值关联的内存。

好的,但是模块类型为什么需要一个9字符的名称?

噢,我知道您需要了解这一点,所以这里有一个非常具体的解释。

当 Valkey 持久化到 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位,这10位用于存储类型的编码版本,这样相同的类型可以在未来发展并为 RDB 文件提供不同且更高效或更新的序列化格式。

因此,存储在每个模块值之前的64位前缀如下所示

6|6|6|6|6|6|6|6|6|10

前9个元素是6位字符,最后10位是编码版本。

当 RDB 文件重新加载时,它读取64位值,掩盖最后10位,并在模块类型缓存中搜索匹配的模块。找到匹配项后,会调用加载 RDB 文件值的方法,并以10位编码版本作为参数,以便模块知道要加载哪种版本的数据布局(如果支持多个版本)。

现在所有这一切的有趣之处在于,如果模块类型无法解析,因为没有已加载的模块具有此签名,我们可以将64位值转换回一个9字符的名称,并向用户打印包含模块类型名称的错误!这样她或他就能立即意识到出了什么问题。

设置和获取键

ValkeyModule_OnLoad() 函数中注册了新的数据类型后,我们还需要能够设置以我们原生类型作为值的 Valkey 键。

这通常发生在将数据写入键的命令上下文中。原生类型 API 允许设置和获取模块原生数据类型的键,并测试给定键是否已与特定数据类型的值关联。

API 使用常规模块的 ValkeyModule_OpenKey() 低级键访问接口来处理此问题。这是一个将原生类型私有数据结构设置为 Valkey 键的示例

ValkeyModuleKey *key = ValkeyModule_OpenKey(ctx,keyname,VALKEYMODULE_WRITE);
struct some_private_struct *data = createMyDataStructure();
ValkeyModule_ModuleTypeSetValue(key,MyType,data);

函数 ValkeyModule_ModuleTypeSetValue() 与一个已打开用于写入的键句柄一起使用,并接收三个参数:键句柄、在类型注册期间获得的原生类型引用,以及最后指向包含实现模块原生类型的私有数据的 void* 指针。

请注意,Valkey 对您的数据包含什么一无所知。它只会调用您在方法注册期间提供的回调函数来对类型执行操作。

同样,我们可以使用此函数从键中检索私有数据

struct some_private_struct *data;
data = ValkeyModule_ModuleTypeGetValue(key);

我们还可以测试一个键是否以我们的原生类型作为值

if (ValkeyModule_ModuleTypeGetType(key) == MyType) {
    /* ... do something ... */
}

然而,为了让调用正常工作,我们需要检查键是否为空,是否包含正确类型的值等等。因此,实现写入我们原生类型的命令的典型代码大致如下

ValkeyModuleKey *key = ValkeyModule_OpenKey(ctx,argv[1],
    VALKEYMODULE_READ|VALKEYMODULE_WRITE);
int type = ValkeyModule_KeyType(key);
if (type != VALKEYMODULE_KEYTYPE_EMPTY &&
    ValkeyModule_ModuleTypeGetType(key) != MyType)
{
    return ValkeyModule_ReplyWithError(ctx,VALKEYMODULE_ERRORMSG_WRONGTYPE);
}

然后,如果我们成功验证键不是错误类型,并且我们打算写入它,通常我们希望在键为空时创建一个新的数据结构,或者如果键已存在值,则检索与键关联的值的引用。

/* Create an empty value object if the key is currently empty. */
struct some_private_struct *data;
if (type == VALKEYMODULE_KEYTYPE_EMPTY) {
    data = createMyDataStructure();
    ValkeyModule_ModuleTypeSetValue(key,MyTyke,data);
} else {
    data = ValkeyModule_ModuleTypeGetValue(key);
}
/* Do something with 'data'... */

释放方法

如前所述,当 Valkey 需要释放持有原生类型值的键时,它需要模块的帮助来释放内存。这就是我们在类型注册期间传递 free 回调函数的原因

typedef void (*ValkeyModuleTypeFreeFunc)(void *value);

假设我们的数据结构由单个分配组成,则 free 方法的简单实现可以是这样的

void MyTypeFreeCallback(void *value) {
    ValkeyModule_Free(value);
}

然而,更真实的实现会调用某个函数来执行更复杂的内存回收,通过将空指针转换为某个结构体并释放构成值的所有资源。

RDB 加载和保存方法

RDB 保存和加载回调函数需要在磁盘上创建(并重新加载)数据类型的表示形式。Valkey 提供了一个高级 API,可以自动将以下类型存储到 RDB 文件中

  • 无符号64位整数。
  • 有符号64位整数。
  • 双精度浮点数。
  • 字符串。

模块的任务是使用上述基本类型找到可行的表示形式。然而请注意,虽然整数和双精度浮点数的值以与架构和字节序无关的方式存储和加载,但如果您使用原始字符串保存 API(例如,将结构体保存到磁盘),则必须自行处理这些细节。

这是执行 RDB 保存和加载的函数列表

void ValkeyModule_SaveUnsigned(ValkeyModuleIO *io, uint64_t value);
uint64_t ValkeyModule_LoadUnsigned(ValkeyModuleIO *io);
void ValkeyModule_SaveSigned(ValkeyModuleIO *io, int64_t value);
int64_t ValkeyModule_LoadSigned(ValkeyModuleIO *io);
void ValkeyModule_SaveString(ValkeyModuleIO *io, ValkeyModuleString *s);
void ValkeyModule_SaveStringBuffer(ValkeyModuleIO *io, const char *str, size_t len);
ValkeyModuleString *ValkeyModule_LoadString(ValkeyModuleIO *io);
char *ValkeyModule_LoadStringBuffer(ValkeyModuleIO *io, size_t *lenptr);
void ValkeyModule_SaveDouble(ValkeyModuleIO *io, double value);
double ValkeyModule_LoadDouble(ValkeyModuleIO *io);

这些函数不需要模块进行任何错误检查,模块可以始终假定调用成功。

例如,假设我有一个原生类型,它实现了一个双精度浮点数数组,其结构如下

struct double_array {
    size_t count;
    double *values;
};

我的 rdb_save 方法可能如下所示

void DoubleArrayRDBSave(ValkeyModuleIO *io, void *ptr) {
    struct dobule_array *da = ptr;
    ValkeyModule_SaveUnsigned(io,da->count);
    for (size_t j = 0; j < da->count; j++)
        ValkeyModule_SaveDouble(io,da->values[j]);
}

我们所做的是存储元素数量,然后是每个双精度浮点数的值。因此,当我们稍后必须在 rdb_load 方法中加载结构时,我们将这样做

void *DoubleArrayRDBLoad(ValkeyModuleIO *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 = ValkeyModule_Alloc(sizeof(*da));
    da->count = ValkeyModule_LoadUnsigned(io);
    da->values = ValkeyModule_Alloc(da->count * sizeof(double));
    for (size_t j = 0; j < da->count; j++)
        da->values[j] = ValkeyModule_LoadDouble(io);
    return da;
}

加载回调函数只是从我们存储在 RDB 文件中的数据重建数据结构。

请注意,虽然写入和读取磁盘的 API 没有错误处理,但如果读取的内容看起来不正确,加载回调函数仍可能在错误时返回 NULL。在这种情况下,Valkey 将直接 panic。

AOF 重写

void ValkeyModule_EmitAOF(ValkeyModuleIO *io, const char *cmdname, const char *fmt, ...);

内存分配

模块数据类型应尝试使用 ValkeyModule_Alloc() 函数族来分配、重新分配和释放用于实现原生数据结构的堆内存(有关详细信息,请参阅其他 Valkey 模块文档)。

这不仅有助于 Valkey 统计模块使用的内存,还有更多优点

  • Valkey 使用 jemalloc 分配器,它通常可以防止使用 libc 分配器可能导致的内存碎片问题。
  • 从 RDB 文件加载字符串时,原生类型 API 能够直接返回使用 ValkeyModule_Alloc() 分配的字符串,这样模块可以直接将此内存链接到数据结构表示中,避免不必要的数据复制。

即使您正在使用实现数据结构的外部库,模块 API 提供的分配函数也与 malloc()realloc()free()strdup() 完全兼容,因此转换库以使用这些函数应该很简单。

如果您有一个使用 libc malloc() 的外部库,并且您希望避免手动将所有调用替换为 Valkey 模块 API 调用,一种方法是使用简单的宏将 libc 调用替换为 Valkey API 调用。类似这样的方法可能可行

#define malloc ValkeyModule_Alloc
#define realloc ValkeyModule_Realloc
#define free ValkeyModule_Free
#define strdup ValkeyModule_Strdup

然而请记住,混合使用 libc 调用和 Valkey API 调用会导致问题和崩溃,因此如果您使用宏替换调用,您需要确保所有调用都已正确替换,并且替换后的代码永远不会(例如)尝试使用 libc malloc() 分配的指针调用 ValkeyModule_Free()