Redis源码-BFS方式浏览main函数(转载)

文章目录
前言
看代码的方式
Redis 服务器的 main 函数
main 函数分段解释
函数名及参数
启动测试程序
程序环境初始化
初始化配置信息
存储参数信息
根据参数确定启动方式
处理并加载命令行参数
打印启动和警告信息
守护模式和初始化
哨兵模式判断启动并加载持久化数据
打印内存警告并启动事件监听
彩蛋
总结
redis-server

前言
欠下的技术债慢慢还,继续为去年吹过的牛而努力。去年年末的时候意识到自己掌握的知识还不够深入,决定开始看一些开源项目的源码,因为当时 Redis 的兴起,所以瞄准了准备从它下手,之后确实看了一部分内容,比如跳表、网络事件库等等,后来过年就鸽了。今年开始一直熟悉新的业务,比较懒没跟进,最近间歇性踌躇满志又发作了,准备抽时间再捋顺一遍,老规矩,还是从 main() 函数下手。

对于 C/C++ 程序一定是从 main() 函数开头的,这是我们切入的一个点,至于怎么找到 main 函数,每个人有不同的方法,最暴力的方法当然就是全文搜索了,不过较为成熟的项目一般搜索出来都不止一个 main 函数,因为整个项目完整构建下来不止一个程序。

像 redis 这个项目最起码有服务器和客户端两个程序,源码中至少包含了两个 main 函数,再加上一些测试程序,main 函数在源码中会有很多。再比如 Lua 的源代码中包含和解释器和编译器,如果直接搜索至少会找到两个 main 函数。

redis 服务器程序的 main 函数在文件 src/server.c 中,之前好像是在 redis.c 文件中后来改名了,这都不重要,反正你需要从搜索出来的 main 函数中找到一个开始的地方,这个花不了多少时间。

看代码的方式
标题中提到了 BFS 方式看代码,而 BFS 指的是广度优先搜索,与之相对应的是 DFS 深度优先搜索,对于不含异步调用的单线程程序来说,执行代码是以深度优先搜索的方式,遇到一个函数就调用进去,在函数中又遇到另一个函数再调用进去,当函数执行完成返回到上一层。

为什么选择 BFS 方式看代码呢?因为这样可以在短时间内更全面的了解代码结构,我们先看第一层,当第一层浏览完成之后再进入到第二层,比如我们先看 main 函数,即使 main 函数调用了很多不认识的函数也不要去管,从名字大概判断一些作用就可以了,不用纠结具体的实现内容,当 main 函数全部看完了再进入到第二层去了解它调用的那些函数。

总之使用 BFS 方式看代码就要有一种“不懂装懂”的态度,不然容易陷入细节,无法整体把握。

Redis 服务器的 main 函数
redis 服务器的 main 函数代码量不是很大,总共 200 行左右,我选择了 6.0.6 这个版本 7bf665f125a4771db095c83a7ad6ed46692cd314,因为只是学习源码,没有特殊情况就不更新版本了,保证环境的统一,我先把代码贴一份在这,后面再来慢慢看。

int main(int argc, char **argv) {
struct timeval tv;
int j;

#ifdef REDIS_TEST
if (argc == 3 && !strcasecmp(argv[1], “test”)) {
if (!strcasecmp(argv[2], “ziplist”)) {
return ziplistTest(argc, argv);
} else if (!strcasecmp(argv[2], “quicklist”)) {
quicklistTest(argc, argv);
} else if (!strcasecmp(argv[2], “intset”)) {
return intsetTest(argc, argv);
} else if (!strcasecmp(argv[2], “zipmap”)) {
return zipmapTest(argc, argv);
} else if (!strcasecmp(argv[2], “sha1test”)) {
return sha1Test(argc, argv);
} else if (!strcasecmp(argv[2], “util”)) {
return utilTest(argc, argv);
} else if (!strcasecmp(argv[2], “endianconv”)) {
return endianconvTest(argc, argv);
} else if (!strcasecmp(argv[2], “crc64”)) {
return crc64Test(argc, argv);
} else if (!strcasecmp(argv[2], “zmalloc”)) {
return zmalloc_test(argc, argv);
}

    return -1; /* test not found */
}

#endif

/* We need to initialize our libraries, and the server configuration. */

#ifdef INIT_SETPROCTITLE_REPLACEMENT
spt_init(argc, argv);
#endif
setlocale(LC_COLLATE,"");
tzset(); /* Populates ‘timezone’ global. */
zmalloc_set_oom_handler(redisOutOfMemoryHandler);
srand(time(NULL)^getpid());
gettimeofday(&tv,NULL);
crc64_init();

uint8_t hashseed[16];
getRandomBytes(hashseed,sizeof(hashseed));
dictSetHashFunctionSeed(hashseed);
server.sentinel_mode = checkForSentinelMode(argc,argv);
initServerConfig();
ACLInit(); /* The ACL subsystem must be initialized ASAP because the
              basic networking code and client creation depends on it. */
moduleInitModulesSystem();
tlsInit();

/* Store the executable path and arguments in a safe place in order
 * to be able to restart the server later. */
server.executable = getAbsolutePath(argv[0]);
server.exec_argv = zmalloc(sizeof(char*)*(argc+1));
server.exec_argv[argc] = NULL;
for (j = 0; j < argc; j++) server.exec_argv[j] = zstrdup(argv[j]);

/* We need to init sentinel right now as parsing the configuration file
 * in sentinel mode will have the effect of populating the sentinel
 * data structures with master nodes to monitor. */
if (server.sentinel_mode) {
    initSentinelConfig();
    initSentinel();
}

/* Check if we need to start in redis-check-rdb/aof mode. We just execute
 * the program main. However the program is part of the Redis executable
 * so that we can easily execute an RDB check on loading errors. */
if (strstr(argv[0],"redis-check-rdb") != NULL)
    redis_check_rdb_main(argc,argv,NULL);
else if (strstr(argv[0],"redis-check-aof") != NULL)
    redis_check_aof_main(argc,argv);

if (argc >= 2) {
    j = 1; /* First option to parse in argv[] */
    sds options = sdsempty();
    char *configfile = NULL;

    /* Handle special options --help and --version */
    if (strcmp(argv[1], "-v") == 0 ||
        strcmp(argv[1], "--version") == 0) version();
    if (strcmp(argv[1], "--help") == 0 ||
        strcmp(argv[1], "-h") == 0) usage();
    if (strcmp(argv[1], "--test-memory") == 0) {
        if (argc == 3) {
            memtest(atoi(argv[2]),50);
            exit(0);
        } else {
            fprintf(stderr,"Please specify the amount of memory to test in megabytes.\n");
            fprintf(stderr,"Example: ./redis-server --test-memory 4096\n\n");
            exit(1);
        }
    }

    /* First argument is the config file name? */
    if (argv[j][0] != '-' || argv[j][1] != '-') {
        configfile = argv[j];
        server.configfile = getAbsolutePath(configfile);
        /* Replace the config file in server.exec_argv with
         * its absolute path. */
        zfree(server.exec_argv[j]);
        server.exec_argv[j] = zstrdup(server.configfile);
        j++;
    }

    /* All the other options are parsed and conceptually appended to the
     * configuration file. For instance --port 6380 will generate the
     * string "port 6380\n" to be parsed after the actual file name
     * is parsed, if any. */
    while(j != argc) {
        if (argv[j][0] == '-' && argv[j][1] == '-') {
            /* Option name */
            if (!strcmp(argv[j], "--check-rdb")) {
                /* Argument has no options, need to skip for parsing. */
                j++;
                continue;
            }
            if (sdslen(options)) options = sdscat(options,"\n");
            options = sdscat(options,argv[j]+2);
            options = sdscat(options," ");
        } else {
            /* Option argument */
            options = sdscatrepr(options,argv[j],strlen(argv[j]));
            options = sdscat(options," ");
        }
        j++;
    }
    if (server.sentinel_mode && configfile && *configfile == '-') {
        serverLog(LL_WARNING,
            "Sentinel config from STDIN not allowed.");
        serverLog(LL_WARNING,
            "Sentinel needs config file on disk to save state.  Exiting...");
        exit(1);
    }
    resetServerSaveParams();
    loadServerConfig(configfile,options);
    sdsfree(options);
}

serverLog(LL_WARNING, "oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo");
serverLog(LL_WARNING,
    "Redis version=%s, bits=%d, commit=%s, modified=%d, pid=%d, just started",
        REDIS_VERSION,
        (sizeof(long) == 8) ? 64 : 32,
        redisGitSHA1(),
        strtol(redisGitDirty(),NULL,10) > 0,
        (int)getpid());

if (argc == 1) {
    serverLog(LL_WARNING, "Warning: no config file specified, using the default config. In order to specify a config file use %s /path/to/%s.conf", argv[0], server.sentinel_mode ? "sentinel" : "redis");
} else {
    serverLog(LL_WARNING, "Configuration loaded");
}

server.supervised = redisIsSupervised(server.supervised_mode);
int background = server.daemonize && !server.supervised;
if (background) daemonize();

initServer();
if (background || server.pidfile) createPidFile();
redisSetProcTitle(argv[0]);
redisAsciiArt();
checkTcpBacklogSettings();

if (!server.sentinel_mode) {
    /* Things not needed when running in Sentinel mode. */
    serverLog(LL_WARNING,"Server initialized");
#ifdef __linux__
    linuxMemoryWarnings();
#endif
    moduleLoadFromQueue();
    ACLLoadUsersAtStartup();
    InitServerLast();
    loadDataFromDisk();
    if (server.cluster_enabled) {
        if (verifyClusterConfigWithData() == C_ERR) {
            serverLog(LL_WARNING,
                "You can't have keys in a DB different than DB 0 when in "
                "Cluster mode. Exiting.");
            exit(1);
        }
    }
    if (server.ipfd_count > 0 || server.tlsfd_count > 0)
        serverLog(LL_NOTICE,"Ready to accept connections");
    if (server.sofd > 0)
        serverLog(LL_NOTICE,"The server is now ready to accept connections at %s", server.unixsocket);
    if (server.supervised_mode == SUPERVISED_SYSTEMD) {
        if (!server.masterhost) {
            redisCommunicateSystemd("STATUS=Ready to accept connections\n");
            redisCommunicateSystemd("READY=1\n");
        } else {
            redisCommunicateSystemd("STATUS=Waiting for MASTER <-> REPLICA sync\n");
        }
    }
} else {
    InitServerLast();
    sentinelIsRunning();
    if (server.supervised_mode == SUPERVISED_SYSTEMD) {
        redisCommunicateSystemd("STATUS=Ready to accept connections\n");
        redisCommunicateSystemd("READY=1\n");
    }
}

/* Warning the user about suspicious maxmemory setting. */
if (server.maxmemory > 0 && server.maxmemory < 1024*1024) {
    serverLog(LL_WARNING,"WARNING: You specified a maxmemory value that is less than 1MB (current value is %llu bytes). Are you sure this is what you really want?", server.maxmemory);
}

redisSetCpuAffinity(server.server_cpulist);
aeMain(server.el);
aeDeleteEventLoop(server.el);
return 0;

}

main 函数分段解释
函数名及参数
int main(int argc, char **argv) {
struct timeval tv;
int j;

//...
//...
return 0

}

这就是一个标准的 main 函数,参数 argc 和 argv 对于一个命令行程序来说可以是重头戏,肯定会拿来做重度解析的,函数开头还定义了 tv 和 j 两个变量,不知道干嘛的,接着往下看吧。

启动测试程序
#ifdef REDIS_TEST
if (argc == 3 && !strcasecmp(argv[1], “test”)) {
if (!strcasecmp(argv[2], “ziplist”)) {
return ziplistTest(argc, argv);
} else if (!strcasecmp(argv[2], “quicklist”)) {
quicklistTest(argc, argv);
} else if (!strcasecmp(argv[2], “intset”)) {
return intsetTest(argc, argv);
} else if (!strcasecmp(argv[2], “zipmap”)) {
return zipmapTest(argc, argv);
} else if (!strcasecmp(argv[2], “sha1test”)) {
return sha1Test(argc, argv);
} else if (!strcasecmp(argv[2], “util”)) {
return utilTest(argc, argv);
} else if (!strcasecmp(argv[2], “endianconv”)) {
return endianconvTest(argc, argv);
} else if (!strcasecmp(argv[2], “crc64”)) {
return crc64Test(argc, argv);
} else if (!strcasecmp(argv[2], “zmalloc”)) {
return zmalloc_test(argc, argv);
}

    return -1; /* test not found */
}

#endif

当宏定义 REDIS_TEST 存在,并且参数合适的情况下启动测试程序,argv[0] 肯定是指 redis 服务器喽,那 argv[1] 的值如果是 test,而 argv[2] 的值是 ziplist,那么会调用 ziplist 的测试函数 ziplistTest,如果 argv[2] 的值是 zmalloc,那么会调用测试函数 zmalloc_test,为啥这里函数名命名规范不统一呢?挠头。

程序环境初始化
/* We need to initialize our libraries, and the server configuration. /
#ifdef INIT_SETPROCTITLE_REPLACEMENT
spt_init(argc, argv);
#endif
setlocale(LC_COLLATE,"");
tzset(); /
Populates ‘timezone’ global. */
zmalloc_set_oom_handler(redisOutOfMemoryHandler);
srand(time(NULL)^getpid());
gettimeofday(&tv,NULL);
crc64_init();

当 INIT_SETPROCTITLE_REPLACEMENT 这个宏存在的时候,调用 spt_init 函数来为设置程序标题做准备
setlocale() 用来设置地点信息,这一句应该是设置成依赖操作系统的地点信息,比如中国,韩国等等
tzset() 设置时区,这里可能影响到程序运行后,调整时区是否对程序产生影响
srand(time(NULL)^getpid()); 初始化随机种子
gettimeofday(&tv,NULL); 这里用到了函数开头定义的一个变量 tv,用来获取当前时间
crc64_init(); 循环冗余校验初始化,crc 神奇的存在
初始化配置信息
uint8_t hashseed[16];
getRandomBytes(hashseed,sizeof(hashseed));
dictSetHashFunctionSeed(hashseed);
server.sentinel_mode = checkForSentinelMode(argc,argv);
initServerConfig();
ACLInit(); /* The ACL subsystem must be initialized ASAP because the
basic networking code and client creation depends on it. */
moduleInitModulesSystem();
tlsInit();

定一个16字节的空间用来存放哈希种子
随机获取一段16字节数据作为种子
将刚刚获取的种子数据设置到hash函数中
分析命令行参数,判断是否是哨兵模式
初始化服务器配置
ACL 初始化,不用管它具体是什么,进入下一层时自然会看到
初始化模块系统
tls 初始化,存疑,好奇的话进去看看也可以,好吧,原来是 ssl 那一套,够喝一壶的
存储参数信息
/* Store the executable path and arguments in a safe place in order
* to be able to restart the server later. /
server.executable = getAbsolutePath(argv[0]);
server.exec_argv = zmalloc(sizeof(char
)*(argc+1));
server.exec_argv[argc] = NULL;
for (j = 0; j < argc; j++) server.exec_argv[j] = zstrdup(argv[j]);

这一小节比较简单,注释写的也很清楚,就是将命令行参数存储起来,方便重启 redis 服务

根据参数确定启动方式
/* We need to init sentinel right now as parsing the configuration file
* in sentinel mode will have the effect of populating the sentinel
* data structures with master nodes to monitor. */
if (server.sentinel_mode) {
initSentinelConfig();
initSentinel();
}

/* Check if we need to start in redis-check-rdb/aof mode. We just execute
 * the program main. However the program is part of the Redis executable
 * so that we can easily execute an RDB check on loading errors. */
if (strstr(argv[0],"redis-check-rdb") != NULL)
    redis_check_rdb_main(argc,argv,NULL);
else if (strstr(argv[0],"redis-check-aof") != NULL)
    redis_check_aof_main(argc,argv);

当启用哨兵模式的时候初始化额外的配置,啥是哨兵,现在还不用知道啊,从字面上来看就好了,反正知道命令行里如果指定了哨兵模式就要额外初始化一点东西。

下面这两个参数有点意思,简单扩展下,rdb 和 aof 是 redis 的两种数据落地的持久化方式,这里有意思的地方是判断了 argv[0] 这个参数,一般 argv[0] 是程序的名字,这个是固定不变的,而 redis 这里将程序名字作为参数来判断,也就是说你把可执行程序换个名字运行,它的行为就会发生变化。

处理并加载命令行参数
if (argc >= 2) {
j = 1; /* First option to parse in argv[] */
sds options = sdsempty();
char *configfile = NULL;

    /* Handle special options --help and --version */
    if (strcmp(argv[1], "-v") == 0 ||
        strcmp(argv[1], "--version") == 0) version();
    if (strcmp(argv[1], "--help") == 0 ||
        strcmp(argv[1], "-h") == 0) usage();
    if (strcmp(argv[1], "--test-memory") == 0) {
        if (argc == 3) {
            memtest(atoi(argv[2]),50);
            exit(0);
        } else {
            fprintf(stderr,"Please specify the amount of memory to test in megabytes.\n");
            fprintf(stderr,"Example: ./redis-server --test-memory 4096\n\n");
            exit(1);
        }
    }

    /* First argument is the config file name? */
    if (argv[j][0] != '-' || argv[j][1] != '-') {
        configfile = argv[j];
        server.configfile = getAbsolutePath(configfile);
        /* Replace the config file in server.exec_argv with
         * its absolute path. */
        zfree(server.exec_argv[j]);
        server.exec_argv[j] = zstrdup(server.configfile);
        j++;
    }

    /* All the other options are parsed and conceptually appended to the
     * configuration file. For instance --port 6380 will generate the
     * string "port 6380\n" to be parsed after the actual file name
     * is parsed, if any. */
    while(j != argc) {
        if (argv[j][0] == '-' && argv[j][1] == '-') {
            /* Option name */
            if (!strcmp(argv[j], "--check-rdb")) {
                /* Argument has no options, need to skip for parsing. */
                j++;
                continue;
            }
            if (sdslen(options)) options = sdscat(options,"\n");
            options = sdscat(options,argv[j]+2);
            options = sdscat(options," ");
        } else {
            /* Option argument */
            options = sdscatrepr(options,argv[j],strlen(argv[j]));
            options = sdscat(options," ");
        }
        j++;
    }
    if (server.sentinel_mode && configfile && *configfile == '-') {
        serverLog(LL_WARNING,
            "Sentinel config from STDIN not allowed.");
        serverLog(LL_WARNING,
            "Sentinel needs config file on disk to save state.  Exiting...");
        exit(1);
    }
    resetServerSaveParams();
    loadServerConfig(configfile,options);
    sdsfree(options);
}

这段内容很长,但是核心的内容不多,前一部分是判断特殊参数,用来显示程序使用方法,启动内存测试等等,中间部分是分析命令行参数保存到字符串中,最后几行是读取服务器配置文件,并使用字符串中的参数选项覆盖文件中的部分配置。

打印启动和警告信息
serverLog(LL_WARNING, “oO0OoO0OoO0Oo Redis is starting oO0OoO0OoO0Oo”);
serverLog(LL_WARNING,
“Redis version=%s, bits=%d, commit=%s, modified=%d, pid=%d, just started”,
REDIS_VERSION,
(sizeof(long) == 8) ? 64 : 32,
redisGitSHA1(),
strtol(redisGitDirty(),NULL,10) > 0,
(int)getpid());

if (argc == 1) {
    serverLog(LL_WARNING, "Warning: no config file specified, using the default config. In order to specify a config file use %s /path/to/%s.conf", argv[0], server.sentinel_mode ? "sentinel" : "redis");
} else {
    serverLog(LL_WARNING, "Configuration loaded");
}

打印 redis 服务器启动信息,比如版本号,pid,警告信息等等,没有实际修改数据。

守护模式和初始化
server.supervised = redisIsSupervised(server.supervised_mode);
int background = server.daemonize && !server.supervised;
if (background) daemonize();

initServer();
if (background || server.pidfile) createPidFile();
redisSetProcTitle(argv[0]);
redisAsciiArt();
checkTcpBacklogSettings();

根据守护进程配置和是否受监督来决定是否作为守护进程,什么是受监督,到现在还不知道,但是本着不懂装懂的方式看代码,可以认为我们懂了,后面自然还会有解释的地方。

接着就调用了 initServer(); 函数,这个初始化函数内容是比较长的,之前版本中很多 mian 函数中的内容都移到了这里面,初始化完成后创建 Pid 文件,设置进程名字,显示 redis 的Logo,检查一些配置,这个 backlog 参数之前面试的时候还被问到过,好奇的话可以提前了解一下。

哨兵模式判断启动并加载持久化数据
if (!server.sentinel_mode) {
/* Things not needed when running in Sentinel mode. */
serverLog(LL_WARNING,“Server initialized”);
#ifdef linux
linuxMemoryWarnings();
#endif
moduleLoadFromQueue();
ACLLoadUsersAtStartup();
InitServerLast();
loadDataFromDisk();
if (server.cluster_enabled) {
if (verifyClusterConfigWithData() == C_ERR) {
serverLog(LL_WARNING,
"You can’t have keys in a DB different than DB 0 when in "
“Cluster mode. Exiting.”);
exit(1);
}
}
if (server.ipfd_count > 0 || server.tlsfd_count > 0)
serverLog(LL_NOTICE,“Ready to accept connections”);
if (server.sofd > 0)
serverLog(LL_NOTICE,“The server is now ready to accept connections at %s”, server.unixsocket);
if (server.supervised_mode == SUPERVISED_SYSTEMD) {
if (!server.masterhost) {
redisCommunicateSystemd(“STATUS=Ready to accept connections\n”);
redisCommunicateSystemd(“READY=1\n”);
} else {
redisCommunicateSystemd(“STATUS=Waiting for MASTER <-> REPLICA sync\n”);
}
}
} else {
InitServerLast();
sentinelIsRunning();
if (server.supervised_mode == SUPERVISED_SYSTEMD) {
redisCommunicateSystemd(“STATUS=Ready to accept connections\n”);
redisCommunicateSystemd(“READY=1\n”);
}
}

这段代码看起来像是再做一些通知提醒,其中比较重要的几个函数是moduleLoadFromQueue()、 InitServerLast() 和 loadDataFromDisk() ,第一个函数是加载模块的,第二个函数是在模块加载完成之后才能初始化的部分内容,最后一个是从磁盘加载数据到内存,这也是 redis 支持持久化的必要保证。

打印内存警告并启动事件监听
/* Warning the user about suspicious maxmemory setting. /
if (server.maxmemory > 0 && server.maxmemory < 1024
1024) {
serverLog(LL_WARNING,“WARNING: You specified a maxmemory value that is less than 1MB (current value is %llu bytes). Are you sure this is what you really want?”, server.maxmemory);
}

redisSetCpuAffinity(server.server_cpulist);
aeMain(server.el);
aeDeleteEventLoop(server.el);
return 0;

看到这段代码我们就来到了 main 函数结尾的部分,redisSetCpuAffinity() 是要做些和 CPU 相关的设置或配置,aeMain() 是主逻辑,对于提供服务的程序来说里面大概率是一个死循环,再满足指定的条件下才会打断退出,而 aeDeleteEventLoop() 就是循环结束时清理事件的操作,到此为止 main 函数就执行完啦。

彩蛋
这个 main 函数的代码中有一个神奇的用法不知道大家有没有发现,就是下面这句话:

serverLog(LL_WARNING,
    "You can't have keys in a DB different than DB 0 when in "
    "Cluster mode. Exiting.");

是不是看起来有些奇怪,不用管这个函数的定义是怎样的,可以告诉大家这个函数的定义类似于 printf 函数,只不过在最前面加了一个整型参数,那么调用这个函数时传了几个参数呢?3个?2个?,这个地方很神奇的会把两个字符串拼接到一起,类似于下面的写法:

serverLog(LL_WARNING,
“You can’t have keys in a DB different than DB 0 when in Cluster mode. Exiting.”);

这样的字符串不仅可以分成两行,实际上可以分成任意行,最后都会拼接在一起,是不是很神奇。

总结
j 这个变量在 redis 的源码中经常出现,应该是作者的行为习惯吧,有些人爱用 i,而这个作者 antirez 爱用 j。
不能一口吃个胖子,看代码也是一样,不能期望一次性把所有的内容都看懂,一段时间后自己的代码都看不懂了,跟别说别人写的了。
redis 代码中频繁使用 server 这个变量,从 main 函数分析中也能看到,这个是个全局变量,代表了整个 redis 服务器程序数据。
不懂装懂或者说不求甚解是熟悉代码整体结构的一项优秀品质,这时候只要看个大概就可以了,真到熟悉细节的时候才是需要钻研的时候。
代码风格完全统一还是比较难实现的,从一个 main 函数中也可以看到,大部分函数是驼峰命名法,还要少量的下划线命名和帕斯卡命名。

原文链接:https://blog.csdn.net/albertsh/article/details/108030540?utm_medium=distribute.pc_feed.none-task-blog-personrec_tag-8.nonecase&depth_1-utm_source=distribute.pc_feed.none-task-blog-personrec_tag-8.nonecase&request_id=5f4396310388ae0b5643c61f

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值