You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

23 KiB

31 | 从Module的实现学习动态扩展功能

你好,我是蒋德钧。

Redis本身已经给我们提供了丰富的数据类型和数据读写功能而且Redis实现了基于IO复用的网络框架、数据主从复制和故障恢复机制以及数据切片集群这些功能通常都是后端系统所需的核心功能。

那么当我们在实际应用中既希望能用上Redis已经实现的核心功能又需要新增一些额外的命令或是数据类型时该怎么办呢

其实Redis从4.0版本开始就提供了扩展模块Module的功能。这些扩展模块以动态链接库so文件的形式加载到Redis中我们可以基于Redis来新增功能模块。这些模块通常包括了新增的命令和数据类型与此同时这些数据类型对应的数据会保存在Redis数据库中从而保证了应用程序对这些数据的高性能访问。

新增功能模块是后端系统开发过程中经常会遇到的问题那么今天这节课我就带你学习Redis是如何实现新增一个功能模块的。掌握了今天的课程内容你就可以参考Redis的实现方案给自己的系统添加相应的功能模块扩展框架从而增加系统的灵活性。

下面我们就先来了解下Redis的扩展模块框架的初始化操作。因为和Redis扩展模块框架相关的功能主要是在redismodule.hmodule.c文件中定义和实现的,你可以在这两个文件中查找接下来要介绍的数据结构或函数。

模块框架的初始化

在Redis的入口main函数的执行流程中调用moduleInitModulesSystem函数在module.c文件中初始化扩展模块框架如下所示

int main(int argc, char **argv) {
   …
   moduleInitModulesSystem();
…}

这个moduleInitModulesSystem函数主要是创建和初始化扩展模块框架运行所需的数据结构。这其中比较重要的初始化操作包括

  • 创建保存待加载模块的列表这对应了全局变量server的loadmodule_queue成员变量
  • 创建保存扩展模块的全局哈希表modules
  • 调用moduleRegisterCoreAPI函数注册核心API。

这些操作的代码如下所示:

void moduleInitModulesSystem(void) {
   …
   server.loadmodule_queue = listCreate();
   modules = dictCreate(&modulesDictType,NULL);
   …
   moduleRegisterCoreAPI();
   …
}

这里,我们先来看下其中的moduleRegisterCoreAPI函数的作用。

这个函数先是在全局变量server中创建两个哈希表成员变量moduleapi和sharedapi它们是分别用来保存模块向外暴露的API以及模块之间共享的API的。紧接着这个函数会调用REGISTER_API宏注册模块的核心API函数。

下面的代码展示了moduleRegisterCoreAPI函数的部分执行逻辑你可以看到其中就包含了调用REGISTER_API宏注册Alloc、CreateCommand、ReplyWithLongLong、RepyWithError这些API函数。

void moduleRegisterCoreAPI(void) {
    server.moduleapi = dictCreate(&moduleAPIDictType,NULL); //创建哈希表保存模块核心API
    server.sharedapi = dictCreate(&moduleAPIDictType,NULL); //创建哈希表保存模块共享API
    REGISTER_API(Alloc);   //注册Alloc API函数
    …
	REGISTER_API(CreateCommand);  //注册CreateCommand API函数
	…
	REGISTER_API(ReplyWithLongLong); //注册ReplyWithLongLong API函数
	REGISTER_API(ReplyWithError);  //注册ReplyWithError API函数
    ...
    }

这些API函数其实是Redis扩展模块框架自身已经实现好的我们在开发扩展模块时都会用到它们。举个例子当我们在开发新的扩展模块时就会调用框架的CreateCommand API来创建新增的命令以及调用ReplyWithLongLong API来给客户端返回结果。
那么接下来,我们再来具体看下REGISTER_API宏的实现,它其实是由moduleRegisterApi函数来实现的。moduleRegisterApi函数会把“RedisModule_”开头的API函数转换成“RM_”开头的API函数并通过dictAdd函数将API函数添加到全局的moduleapi哈希表中。

而在这个哈希表中哈希项的key是API的名称value是这个API对应的函数指针。这样一来当我们开发模块要用到这些API时就可以通过moduleapi哈希表查找API名称然后获得API函数指针并进行使用了。

下面的代码展示了REGISTER_API宏定义和moduleRegisterApi函数的实现你可以看下。

//将moduleRegisterApi函数定义为REGISTER_API宏
#define REGISTER_API(name) \
	moduleRegisterApi("RedisModule_" #name, (void *)(unsigned long)RM_ ## name)
	 
int moduleRegisterApi(const char *funcname, void *funcptr) {
	return dictAdd(server.moduleapi, (char*)funcname, funcptr); //将API名称和对应的函数指针添加到moduleapi哈希表中
	}

这样我们也就了解了扩展模块框架初始化时的工作它主要是完成了运行所需数据结构的初始化并把框架提供的API的名称和实现函数添加到moduleapi哈希表中。

那么接下来,我们就具体来看下如何实现一个模块,并看看这个模块是如何工作的。

模块的实现和工作过程

我们先来看一个简单的模块实现例子。假设我们要新增一个模块“helloredis”这个模块包含一个命令“hello”而这个命令的作用就是返回“hello redis”字符串。

那么简单来说要开发这个新增模块我们需要开发两个函数一个是RedisModule_OnLoad函数它会在模块加载时被调用初始化新增模块并向Redis扩展模块框架注册模块和命令。另一个是新增模块具体功能的实现函数我们在这里把它命名为Hello_NewCommand。

我们先来看初始化和注册新增模块的过程。

新增模块的初始化与注册

在Redis的入口main函数的执行流程中在调用完moduleInitModulesSystem函数完成扩展模块框架初始化后实际上main函数还会调用moduleLoadFromQueue函数来加载扩展模块。

moduleLoadFromQueue函数会进一步调用moduleLoad函数而moduleLoad函数会根据模块文件所在的路径、模块所需的参数来完成扩展模块的加载如下所示

void moduleLoadFromQueue(void) {
...
//加载扩展模块
if (moduleLoad(loadmod->path,(void **)loadmod->argv,loadmod->argc)
            == C_ERR)
{...}
}

那么在moduleLoad函数中它会在我们自行开发的模块文件中查找“RedisModule_OnLoad”函数并执行这个函数。然后它会调用dictAdd函数把成功加载的模块添加到全局哈希表modules中如下所示

int moduleLoad(const char *path, void **module_argv, int module_argc) {
...
//在模块文件中查找RedisModule_OnLoad函数
onload = (int (*)(void *, void **, int))(unsigned long) dlsym(handle,"RedisModule_OnLoad");
...
//执行RedisModule_OnLoad函数
if (onload((void*)&ctx,module_argv,module_argc) == REDISMODULE_ERR) {...}
 
...
dictAdd(modules,ctx.module->name,ctx.module); //把加载的模块添加到全局哈希表modules
}

我在这里画了张图展示了main函数加载新模块的过程你可以再回顾下。

图片

从刚才介绍的main函数加载新增模块的过程中你可以看到模块框架会在模块文件中会查找RedisModule_OnLoad函数。**RedisModule_OnLoad是每个新增模块都必须包含的函数它是扩展模块框架加载新增模块的入口。**通过这个函数我们可以把新增的模块命令注册到Redis的命令表中从而可以在Redis中使用新增命令。这个函数的原型如下所示

int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc)

而当我们要实现RedisModule_OnLoad函数时就要用到刚才介绍的扩展模块框架提供的API函数了。

首先,我们要调用RedisModule_Init函数在redismodule.h文件中来注册新增的模块它的函数原型如下所示

static int RedisModule_Init(RedisModuleCtx *ctx, const char *name, int ver, int apiver)

其中,第一个参数ctx是RedisModuleCtx结构体类型变量这个结构体记录了模块的指针、执行模块命令的客户端以及运行时状态等信息。第二个参数name表示的新增模块的名称而第三和第四个参数表示的是API版本。

然后对于我们刚才要实现的“helloredis”模块我们就可以按如下代码来调用RedisModule_Init函数实现模块的注册。

if (RedisModule_Init(ctx,"helloredis",1,REDISMODULE_APIVER_1)
   == REDISMODULE_ERR) return REDISMODULE_ERR;

而具体的注册过程我们可以看下RedisModule_Init函数的实现。这个函数的主要工作可以分成三步。

第一步是设置RedisModule_GetApi函数让它等于RedisModuleCtx结构体中的函数指针getapifuncptr。

第二步是调用REDISMODULE_GET_API宏来获得扩展模块框架提供的API函数。这样一来新增模块中就可以使用框架的API了。

这里你需要注意下REDISMODULE_GET_API宏的定义这个宏定义其实是使用了RedisModule_GetApi函数指针如下所示

#define REDISMODULE_GET_API(name) \
	RedisModule_GetApi("RedisModule_" #name, ((void **)&RedisModule_ ## name))

而RedisModule_GetApi函数指针是通过REDISMODULE_API_FUNC这个宏定义来实现的。在这里REDISMODULE_API_FUNC宏的作用是把它的参数设置为函数指针如下所示

#define REDISMODULE_API_FUNC(x) (*x) //设置x为函数指针

那么对于RedisModule_GetApi函数指针来说它又进一步指向了API函数它的参数就包括了API函数名称和指向API函数的指针。

int REDISMODULE_API_FUNC(RedisModule_GetApi)(const char *, void *); //设置RedisModule_GetApi为函数指针

我们再来看刚才介绍的REDISMODULE_GET_API宏如下所示

#define REDISMODULE_GET_API(name) \
	RedisModule_GetApi("RedisModule_" #name, ((void **)&RedisModule_ ## name))

你会发现这个宏会把传入的参数name传递给RedisModule_GetApi函数指针而RedisModule_GetApi函数指针会将参数name和“RedisModule_”字符串拼接起来这就组成了模块框架中以“RedisModule_”开头的API函数的名称了从而可以获得同名API函数的指针。

所以在RedisModule_Init函数的第一步和第二步都是通过RedisModule_GetApi来获得API函数的指针的。

那么在RedisModule_Init函数的第三步它会调用RedisModule_IsModuleNameBusy函数,检查当前注册的新增模块名称是否已经存在。

如果这个模块已经存在了,那么它就会报错返回。而如果模块不存在,它就调用RedisModule_SetModuleAttribs函数给新增模块分配一个RedisModule结构体并初始化这个结构体中的成员变量。而RedisModule结构体正是用来记录一个模块的相关属性的。

下面的代码展示了RedisModule_SetModuleAttribs函数的部分执行逻辑你可以看下。这里你要注意的是刚才我介绍的moduleRegisterCoreAPI函数它在模块框架初始化时已经把以“RedisModule_”开头的函数指向了以“RM_”开头的函数所以当你看到“RedisModule_”开头的函数时就需要在module.c文件中查找以“RM_”开头而后缀相同的函数。

void RM_SetModuleAttribs(RedisModuleCtx *ctx, const char *name, int ver, int apiver) {
    RedisModule *module;
 
    if (ctx->module != NULL) return;
    module = zmalloc(sizeof(*module));  //分配RedisModule结构体的空间
    module->name = sdsnew((char*)name); //设置模块名称
    module->ver = ver;  //设置模板版本
    …
    ctx->module = module; //在记录模块运行状态的RedisModuleCtx变量中保存模块指针
}

好了到这里RedisModule_Init函数针对一个新增模块的初始化流程就执行完成了。下面的代码也展示了RedisModule_Init函数的主要执行逻辑你可以再回顾下。

void *getapifuncptr = ((void**)ctx)[0];
RedisModule_GetApi = (int (*)(const char *, void *)) (unsigned long)getapifuncptr;
REDISMODULE_GET_API(Alloc);
…
REDISMODULE_GET_API(CreateCommand);
…
REDISMODULE_GET_API(ListPush);
REDISMODULE_GET_API(ListPop);
…
REDISMODULE_GET_API(CreateString);
…
//检查是否有同名的模块
if (RedisModule_IsModuleNameBusy && RedisModule_IsModuleNameBusy(name)) return REDISMODULE_ERR;
RedisModule_SetModuleAttribs(ctx,name,ver,apiver); //没有同名模块,则初始化模块的数据结构
return REDISMODULE_OK; 

其实从代码中你可以发现RedisModule_Init函数在初始化新增模块时会从框架中获得很多键值对常规操作的API函数比如List的Push和Pop操作、创建String操作等等。你可以进一步阅读RedisModule_Init函数来了解新增模块能获得的API。

那么当我们调用RedisModule_Init函数完成了新增模块的注册和初始化后我们就可以调用RedisModule_CreateCommand函数来注册模块的新增命令。下面,我们就来看下这个执行过程。

新增命令的注册

对于我们刚才开发的新增模块来说我们需要给它增加一个新命令“hello”这主要就是通过在RedisModule_OnLoad函数中调用RedisModule_CreateCommand函数来实现的。你可以先看看下面的代码这部分代码实现了新增命令的注册。

int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
…
if (RedisModule_CreateCommand(ctx,"hello", Hello_NewCommand, "fast",0, 0, 0) == REDISMODULE_ERR)
return REDISMODULE_ERR;
…}

从代码中你可以看到RedisModule_CreateCommand的参数包括了新增的命令名称“hello”、这个命令对应的实现函数Hello_NewCommand以及这个命令对应的属性标记“fast”。

那么现在我们就来看下RedisModule_CreateCommand的执行过程就像刚才我给你介绍的它实际对应的实现函数是以“RM_”开头的RM_CreateCommand

RM_CreateCommand函数的原型如下所示它的第二、三和四个参数就对应了刚才我提到的新增命令的名称、命令对应实现函数和命令标记。

int RM_CreateCommand(RedisModuleCtx *ctx, const char *name, RedisModuleCmdFunc cmdfunc, const char *strflags, int firstkey, int lastkey, int keystep)

而RM_CreateCommand函数的主要作用是创建一个RedisModuleCommandProxy结构体类型的变量cp。这个变量类似于新增命令的代理命令它本身记录了新增命令对应的实现函数与此同时它又创建了一个redisCommand结构体类型的成员变量rediscmd

这里你需要注意的是在Redis源码中redisCommand类型的变量对应了Redis命令表中的一个命令。当Redis收到客户端发送的命令时会在命令表中查找命令名称以及命令对应的redisCommand变量。而redisCommand结构体中的成员变量proc,就对应了命令的实现函数。

struct redisCommand {
    char *name;  //命令名称
	redisCommandProc *proc;  //命令对应的实现函数
	…
}

在刚才介绍的cp变量中它创建了redisCommand类型的成员变量rediscmd并把它的proc变量设置为RedisModuleCommandDispatcher函数。
然后RM_CreateCommand函数会把rediscmd添加到Redis的命令表中这样一来当客户端发送新增命令时Redis会先从命令表中查找到新增命令对应的执行函数是RedisModuleCommandDispatcher然后就会执行RedisModuleCommandDispatcher这个函数。而RedisModuleCommandDispatcher函数接着才会实际调用新增模块命令所对应的实现函数。

下图就展示了RM_CreateCommand函数添加代理命令时代理命令和模块新增命令之间的关系你可以看下。

图片

下面的代码也展示了RM_CreateCommand函数创建代理命令并在Redis命令表中添加代理命令的基本执行逻辑你可以再回顾下。

struct redisCommand *rediscmd; 
RedisModuleCommandProxy *cp;  //创建RedisModuleCommandProxy结构体变量
sds cmdname = sdsnew(name); //新增命令的名称
cp = zmalloc(sizeof(*cp));
cp->module = ctx->module;  //记录命令对应的模块
cp->func = cmdfunc;  //命令对应的实现函数
cp->rediscmd = zmalloc(sizeof(*rediscmd));  //创建一个redisCommand结构体对应Redis命令表中的命令
cp->rediscmd->name = cmdname; //命令表中的命令名称
cp->rediscmd->proc = RedisModuleCommandDispatcher; //命令表中命令对应的函数
dictAdd(server.commands,sdsdup(cmdname),cp->rediscmd);
…

这样我们在开发新模块的RedisModule_OnLoad函数时要完成的第二步操作也就是调用RedisModule_CreateCommand函数来完成新增命令在Redis命令表中的注册。

那么你可以再来看看下面的代码其中展示了到目前为止我们开发的新增模块的代码内容。到这里一个简单的RedisModule_OnLoad函数就开发完成了。

int RedisModule_OnLoad(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
  //初始化模块
  if (RedisModule_Init(ctx,"helloredis",1,REDISMODULE_APIVER_1)
   == REDISMODULE_ERR) return REDISMODULE_ERR;
   
  //注册命令
  if (RedisModule_CreateCommand(ctx,"hello", Hello_NewCommand, "fast",0, 0, 0) == REDISMODULE_ERR)
return REDISMODULE_ERR;
  
   return REDISMODULE_OK;
}

接下来,我们就需要开发新增命令实际对应的实现函数了。

开发新增命令的实现函数

开发新增命令的实现函数主要就是为了实现我们新增模块的具体功能逻辑。在刚才举的例子中新增模块“helloredis”的命令“hello”它的功能逻辑很简单就是返回一个“hello redis”的字符串。

而我们刚才在调用RedisModule_CreateCommand函数注册新命令的实现函数时注册的是Hello_NewCommand函数。所以,这里我们就是要实现这个函数。

下面的代码展示了Hello_NewCommand函数的逻辑你能看到它就是调用RedisModule_ReplyWithString向客户端返回“hello redis”字符串。

int Hello_NewCommand(RedisModuleCtx *ctx, RedisModuleString **argv, int argc) {
    return RedisModule_ReplyWithString(ctx, “hello redis”);
}

另外从代码中你还可以看到我们开发的模块可以调用扩展模块框架提供的API函数来完成一定的功能。比如在刚才的代码中Hello_NewCommand函数就是调用了RedisModule_ReplyWithString这个框架的API函数来向客户端返回String类型的结果。

好了到这里我们就完成了一个简单的新增模块的开发。这个过程包括了开发用来初始化模块和注册新增命令的函数RedisModule_OnLoad以及实际实现模块功能的Hello_NewCommand函数。

那么最后我们来看下当Redis收到模块命令后是如何执行的。

新增模块的命令执行

刚才我介绍过main函数在执行时会调用moduleLoadFromQueue函数加载扩展模块。那么当模块加载完成后就可以接受它新增的命令了。

我在第14讲中给你介绍过一个命令的执行流程,对于扩展模块的新增命令来说,它也是按照这个流程来执行的。所以,当收到扩展模块的命令时,processCommand函数会被调用然后这个函数会在命令表中查找收到的命令。如果找到这个命令processCommand函数就会调用call函数执行这个命令。

而call函数是会根据客户端发送的命令执行这个命令对应的redisCommand结构中的proc指针指向函数如下所示

void call(client *c, int flags) {
…
c->cmd->proc(c);
…
}

注意我刚才介绍的那个RM_CreateCommand函数在注册新命令时它在命令表中给新增命令注册的对应函数RedisModuleCommandDispatcher所以当收到新增模块的命令时也是执行RedisModuleCommandDispatcher函数。

而RedisModuleCommandDispatcher函数会先获得刚才我介绍的代表代理命令的RedisModuleCommandProxy结构体的变量cp并调用cp的func成员变量。这里的func成员变量在RM_CreateCommand函数执行时已经被赋值了新增命令的实际实现函数。这样一来通过RedisModuleCommandDispatcher函数新增模块的命令也就能正常执行了。

下面的代码展示了RedisModuleCommandDispatche函数的基本逻辑你可以看下。

void RedisModuleCommandDispatcher(client *c) {
	RedisModuleCommandProxy *cp = (void*)(unsigned long)c->cmd->getkeys_proc;
	…
	cp->func(&ctx,(void**)c->argv,c->argc);
	…
	}

好了到这里我们就了解了新增模块的命令是如何通过代理命令的实现函数RedisModuleCommandDispatcher来完成执行的了。这样一来我们也就清楚了从模块自身的实现开发到模块命令执行的整个过程。

小结

在今天的课程里我给你介绍了Redis扩展模块框架的工作机制。我以一个简单的扩展模块为例带你了解了扩展模块框架的初始化、新模块的初始化、新命令的注册与执行过程。那么在这个过程中你需要重点掌握以下三个关键点

一是新增模块的程序中必须包含RedisModule_OnLoad函数这是因为模块框架在加载模块时会通过动态链接库操作函数dlsym在新增模块编译后的动态链接文件so文件中查找RedisModule_OnLoad函数并会执行这个函数。所以我们开发扩展模块时就要在RedisModule_OnLoad函数中使用RedisModule_Init函数初始化模块以及使用RedisModule_CreateCommand函数注册命令。

二是扩展模块框架在Redis命令表中并没有直接添加新增命令的实现函数而是把新增命令的执行函数先设置为RedisModuleCommandDispatcher然后再由RedisModuleCommandDispatcher函数执行新增命令的实际实现函数。

三是扩展模块框架自身通过“RM_”开头的API函数封装了很多Redis现有的操作功能例如对不同数据类型的操作给客户端回复不同类型的结果等。这方便了我们在开发新增模块时复用Redis的已有功能。你可以进一步阅读module.c文件了解扩展框架提供的具体API函数。

最后,前面总结的这三点内容,可以说既对我们开发扩展模块,了解它们运行机制有帮助,也给我们自己开发扩展模块框架提供了参考实现,我希望你能掌握好它们。

每课一问

你使用过哪些Redis的扩展模块或者自行开发过扩展模块吗欢迎在评论分享些你的经验。