在使用 Redis 的過程中經常會好奇,在 Redis-Cli 中鍵入 SET KEY MSG
並回車之後,Redis 客戶端和服務是如何對命令進行解析處理的,而在內部的實現過程是什麼樣的。
這兩篇文章會分別介紹 Redis 客戶端和服務端分別對命令是如何處理的,本篇文章介紹的是 Redis 客戶端如何處理輸入的命令、向服務傳送命令以及取得服務端回覆並輸出到終端等過程。
文章中會將 Redis 服務看做一個輸入為 Redis 命令,輸出為命令執行結果的黑箱,對從命令到結果的過程不做任何解釋,只會著眼於客戶端的邏輯,也就是上圖中的 1 和 4 兩個過程。
從 main 函式開始
與其它的 C 語言框架/服務類似,Redis 的客戶端 redis-cli
也是從 main
函式開始執行的,位於 redis-cli.c
檔案的最後:
int main(int argc, char **argv) {
...
if (argc == 0 && !config.eval) {
repl();
}
...
}複製程式碼
在一般情況下,Redis 客戶端都會進入 repl
模式,對輸入進行解析;
Redis 中有好多模式,包括:Latency、Slave、Pipe、Stat、Scan、LRU test 等等模式,不過這些模式都不是這篇文章關注的重點,我們只會關注最常見的 repl 模式。
static void repl(void) {
char *line;
int argc;
sds *argv;
...
while((line = linenoise(context ? config.prompt : "not connected> ")) != NULL) {
if (line[0] != '\0') {
argv = cliSplitArgs(line,&argc);
if (argv == NULL) {
printf("Invalid argument(s)\n");
continue;
}
if (strcasecmp(argv[0],"???") == 0) {
...
} else {
issueCommandRepeat(argc, argv, 1);
}
}
}
exit(0);
}複製程式碼
在上述程式碼中,我們省略了大量的實現細節,只保留整個 repl
中迴圈的主體部分,方便進行理解和分析,在 while
迴圈中的條件你可以看到 linenoise
方法的呼叫,通過其中的 prompt
和 not connected>
可以判斷出,這裡向終端中輸出了提示符,同時會呼叫 fgets
從標準輸入中讀取字串:
127.0.0.1:6379>複製程式碼
全域性搜一下 config.prompt
不難發現這一行程式碼,也就是控制命令列提示的 prompt
:
anetFormatAddr(config.prompt, sizeof(config.prompt),config.hostip, config.hostport);複製程式碼
接下來執行的 cliSplitArgs
函式會將 line
中的字串分割成幾個不同的引數,然後根據字串 argv[0]
的不同執行的命令,在這裡省略了很多原有的程式碼:
if (strcasecmp(argv[0],"quit") == 0 ||
strcasecmp(argv[0],"exit") == 0)
{
exit(0);
} else if (argv[0][0] == ':') {
cliSetPreferences(argv,argc,1);
continue;
} else if (strcasecmp(argv[0],"restart") == 0) {
...
} else if (argc == 3 && !strcasecmp(argv[0],"connect")) {
...
} else if (argc == 1 && !strcasecmp(argv[0],"clear")) {
} else {
issueCommandRepeat(argc, argv, 1);
}複製程式碼
在遇到 quit
、exit
等跟客戶端狀態有關的命令時,就會直接執行相應的程式碼;否則就會將命令和引數 issueCommandRepeat
函式。
追蹤一次命令的執行
Redis Commit:
790310d89460655305bd615bc442eeaf7f0f1b38
lldb: lldb-360.1.65
macOS 10.11.6
在繼續分析 issueCommandRepeat
之前,我們先對 Redis 中的這部分程式碼進行除錯追蹤,在使用 make
編譯了 Redis 原始碼,啟動 redis-server
之後;啟動 lldb 對 Redis 客戶端進行除錯:
$ lldb src/redis-cli
(lldb) target create "src/redis-cli"
Current executable set to 'src/redis-cli' (x86_64).
(lldb) b redis-cli.c:1290
Breakpoint 1: where = redis-cli`repl + 228 at redis-cli.c:1290, address = 0x0000000100008cd4
(lldb) process launch
Process 8063 launched: '~/redis/src/redis-cli' (x86_64)
127.0.0.1:6379>複製程式碼
在 redis-cli.c:1290
也就是下面這行程式碼的地方打斷點之後:
-> 1290 if (line[0] != '\0') {複製程式碼
執行 process launch
啟動 redis-cli
,然後輸入 SET KEY MSG
回車以及 Ctrl-C:
在 lldb 中除錯時,回車的輸入經常會有問題,在這裡輸入 Ctrl-C 進入訊號處理器,在通過 continue 命令進入斷點:
127.0.0.1:6379> SET KEY MSG
^C
8063 stopped
* thread #1: tid = 0xa95147, 0x00007fff90923362 libsystem_kernel.dylib`read + 10, stop reason = signal SIGSTOP
frame #0: 0x00007fff90923362 libsystem_kernel.dylib`read + 10
libsystem_kernel.dylib`read:
-> 0x7fff90923362 <+10>: jae 0x7fff9092336c ; <+20>
0x7fff90923364 <+12>: movq %rax, %rdi
0x7fff90923367 <+15>: jmp 0x7fff9091c7f2 ; cerror
0x7fff9092336c <+20>: retq
(lldb) c
Process 8063 resuming
Process 8063 stopped
* thread #1: tid = 0xa95147, 0x0000000100008cd4 redis-cli`repl + 228 at redis-cli.c:1290, queue = 'com.apple.main-thread', stop reason = breakpoint 1.1
frame #0: 0x0000000100008cd4 redis-cli`repl + 228 at redis-cli.c:1290
1287
1288 cliRefreshPrompt();
1289 while((line = linenoise(context ? config.prompt : "not connected> ")) != NULL) {
-> 1290 if (line[0] != '\0') {
1291 argv = cliSplitArgs(line,&argc);
1292 if (history) linenoiseHistoryAdd(line);
1293 if (historyfile) linenoiseHistorySave(historyfile);
(lldb)複製程式碼
輸入兩次 n
之後,列印 argv
和 argc
的值:
(lldb) p argc
(int) $1 = 3
(lldb) p *argv
(sds) $2 = 0x0000000100106cc3 "SET"
(lldb) p *(argv+1)
(sds) $3 = 0x0000000100106ce3 "KEY"
(lldb) p *(argv+2)
(sds) $4 = 0x0000000100106cf3 "MSG"
(lldb) p line
(char *) $5 = 0x0000000100303430 "SET KEY MSG\n"複製程式碼
cliSplitArgs
方法成功將 line
中的字串分隔成字串引數,在多次執行 n
之後,進入 issueCommandRepeat
方法:
-> 1334 issueCommandRepeat(argc-skipargs, argv+skipargs, repeat);複製程式碼
對輸入命令的處理
上一階段執行 issueCommandRepeat
的函式呼叫棧中,會發現 Redis 並不會直接把所有的命令傳送到服務端:
issueCommandRepeat
cliSendCommand
redisAppendCommandArgv
redisFormatCommandArgv
__redisAppendCommand複製程式碼
而是會在 redisFormatCommandArgv
中對所有的命令進行格式化處理,將字串轉換為符合 RESP 協議的資料。
RESP 協議
Redis 客戶端與 Redis 服務進行通訊時,會使用名為 RESP(REdis Serialization Protocol) 的協議,它的使用非常簡單,並且可以序列化多種資料型別包括整數、字串以及陣列等。
對於 RESP 協議的詳細介紹可以看官方文件中的 Redis Protocol specification,在這裡對這個協議進行簡單的介紹。
在將不同的資料型別序列化時,會使用第一個 byte 來表示當前資料的資料型別,以便在客戶端或伺服器在處理時能恢復原來的資料格式。
舉一個簡單的例子,字串 OK
以及錯誤Error Message
等不同種類的資訊的 RESP 表示如下:
在這篇文章中我們需要簡單瞭解的就是 RESP “資料格式”的第一個位元組用來表示資料型別,然後邏輯上屬於不同部分的內容通過 CRLF(\r\n)分隔。
資料格式的轉換
在 redisFormatCommandArgv
方法中幾乎沒有需要刪減的程式碼,所有的命令都會以字串陣列的形式傳送到客戶端:
int redisFormatCommandArgv(char **target, int argc, const char **argv, const size_t *argvlen) {
char *cmd = NULL;
int pos;
size_t len;
int totlen, j;
totlen = 1+intlen(argc)+2;
for (j = 0; j < argc; j++) {
len = argvlen ? argvlen[j] : strlen(argv[j]);
totlen += bulklen(len);
}
cmd = malloc(totlen+1);
if (cmd == NULL)
return -1;
pos = sprintf(cmd,"*%d\r\n",argc);
for (j = 0; j < argc; j++) {
len = argvlen ? argvlen[j] : strlen(argv[j]);
pos += sprintf(cmd+pos,"$%zu\r\n",len);
memcpy(cmd+pos,argv[j],len);
pos += len;
cmd[pos++] = '\r';
cmd[pos++] = '\n';
}
assert(pos == totlen);
cmd[pos] = '\0';
*target = cmd;
return totlen;
}複製程式碼
SET KEY MSG
這一命令,經過這個方法的處理會變成:
*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$3\r\nMSG\r\n複製程式碼
你可以這麼理解上面的結果:
*3\r\n
$3\r\nSET\r\n
$3\r\nKEY\r\n
$3\r\nMSG\r\n複製程式碼
這是一個由三個字串組成的陣列,陣列中的元素是 SET
、KEY
以及 MSG
三個字串。
如果在這裡打一個斷點並輸出 target
中的內容:
到這裡就完成了對輸入命令的格式化,在格式化之後還會將當前命令寫入全域性的 redisContext
的 write
緩衝區 obuf
中,也就是在上面的緩衝區看到的第二個方法:
int __redisAppendCommand(redisContext *c, const char *cmd, size_t len) {
sds newbuf;
newbuf = sdscatlen(c->obuf,cmd,len);
if (newbuf == NULL) {
__redisSetError(c,REDIS_ERR_OOM,"Out of memory");
return REDIS_ERR;
}
c->obuf = newbuf;
return REDIS_OK;
}複製程式碼
redisContext
再繼續介紹下一部分之前需要簡單介紹一下 redisContext
結構體:
typedef struct redisContext {
int err;
char errstr[128];
int fd;
int flags;
char *obuf;
redisReader *reader;
} redisContext;複製程式碼
每一個 redisContext
的結構體都表示一個 Redis 客戶端對服務的連線,而這個上下文會在每一個 redis-cli 中作為靜態變數僅儲存一個:
static redisContext *context;複製程式碼
obuf
中包含了客戶端未寫到服務端的資料;而 reader
是用來處理 RESP 協議的結構體;fd
就是 Redis 服務對應的檔案描述符;其他的內容就不多做解釋了。
到這裡,對命令的格式化處理就結束了,接下來就到了向服務端傳送命令的過程了。
向伺服器傳送命令
與對輸入命令的處理差不多,向伺服器傳送命令的方法也在 issueCommandRepeat
的呼叫棧中,而且藏得更深,如果不仔細閱讀原始碼其實很難發現:
issueCommandRepeat
cliSendCommand
cliReadReply
redisGetReply
redisBufferWrite複製程式碼
Redis 在 redisGetReply
中完成對命令的傳送:
int redisGetReply(redisContext *c, void **reply) {
int wdone = 0;
void *aux = NULL;
if (aux == NULL && c->flags & REDIS_BLOCK) {
do {
if (redisBufferWrite(c,&wdone) == REDIS_ERR)
return REDIS_ERR;
} while (!wdone);
...
} while (aux == NULL);
}
if (reply != NULL) *reply = aux;
return REDIS_OK;
}複製程式碼
上面的程式碼向 redisBufferWrite
函式中傳遞了全域性的靜態變數 redisContext
,其中的 obuf
中儲存了沒有向 Redis 服務傳送的命令:
int redisBufferWrite(redisContext *c, int *done) {
int nwritten;
if (sdslen(c->obuf) > 0) {
nwritten = write(c->fd,c->obuf,sdslen(c->obuf));
if (nwritten == -1) {
if ((errno == EAGAIN && !(c->flags & REDIS_BLOCK)) || (errno == EINTR)) {
} else {
__redisSetError(c,REDIS_ERR_IO,NULL);
return REDIS_ERR;
}
} else if (nwritten > 0) {
if (nwritten == (signed)sdslen(c->obuf)) {
sdsfree(c->obuf);
c->obuf = sdsempty();
} else {
sdsrange(c->obuf,nwritten,-1);
}
}
}
if (done != NULL) *done = (sdslen(c->obuf) == 0);
return REDIS_OK;
}複製程式碼
程式碼的邏輯其實十分清晰,呼叫 write
向 Redis 服務代表的檔案描述符傳送寫緩衝區 obuf
中的資料,然後根據返回值做出相應的處理,如果命令傳送成功就會清空 obuf
並將 done
指標標記為真,然後返回,這樣就完成了向伺服器傳送命令這一過程。
獲取伺服器回覆
其實獲取伺服器回覆和上文中的傳送命令過程基本上差不多,呼叫棧也幾乎完全一樣:
issueCommandRepeat
cliSendCommand
cliReadReply
redisGetReply
redisBufferRead
redisGetReplyFromReader
cliFormatReplyRaw
fwrite複製程式碼
同樣地,在 redisGetReply
中獲取伺服器的響應:
int redisGetReply(redisContext *c, void **reply) {
int wdone = 0;
void *aux = NULL;
if (aux == NULL && c->flags & REDIS_BLOCK) {
do {
if (redisBufferWrite(c,&wdone) == REDIS_ERR)
return REDIS_ERR;
} while (!wdone);
do {
if (redisBufferRead(c) == REDIS_ERR)
return REDIS_ERR;
if (redisGetReplyFromReader(c,&aux) == REDIS_ERR)
return REDIS_ERR;
} while (aux == NULL);
}
if (reply != NULL) *reply = aux;
return REDIS_OK;
}複製程式碼
在 redisBufferWrite
成功傳送命令並返回之後,就會開始等待服務端的回覆,總共分為兩個部分,一是使用 redisBufferRead
從服務端讀取原始格式的回覆(符合 RESP 協議):
int redisBufferRead(redisContext *c) {
char buf[1024*16];
int nread;
nread = read(c->fd,buf,sizeof(buf));
if (nread == -1) {
if ((errno == EAGAIN && !(c->flags & REDIS_BLOCK)) || (errno == EINTR)) {
} else {
__redisSetError(c,REDIS_ERR_IO,NULL);
return REDIS_ERR;
}
} else if (nread == 0) {
__redisSetError(c,REDIS_ERR_EOF,"Server closed the connection");
return REDIS_ERR;
} else {
if (redisReaderFeed(c->reader,buf,nread) != REDIS_OK) {
__redisSetError(c,c->reader->err,c->reader->errstr);
return REDIS_ERR;
}
}
return REDIS_OK;
}複製程式碼
在 read
從檔案描述符中成功讀取資料並返回之後,我們可以列印 buf
中的內容:
剛剛向 buf
中寫入的資料還需要經過 redisReaderFeed
方法的處理,擷取正確的長度;然後存入 redisReader
中:
int redisReaderFeed(redisReader *r, const char *buf, size_t len) {
sds newbuf;
if (buf != NULL && len >= 1) {
if (r->len == 0 && r->maxbuf != 0 && sdsavail(r->buf) > r->maxbuf) {
sdsfree(r->buf);
r->buf = sdsempty();
r->pos = 0;
assert(r->buf != NULL);
}
newbuf = sdscatlen(r->buf,buf,len);
if (newbuf == NULL) {
__redisReaderSetErrorOOM(r);
return REDIS_ERR;
}
r->buf = newbuf;
r->len = sdslen(r->buf);
}
return REDIS_OK;
}複製程式碼
最後的 redisGetReplyFromReader
方法會從 redisContext
中取出 reader
,然後反序列化 RESP 物件,最後列印出來。
當我們從終端的輸出中看到了 OK 以及這個命令的執行的時間時,SET KEY MSG
這一命令就已經處理完成了。
總結
處理命令的過程在客戶端還是比較簡單的:
- 在一個
while
迴圈中,輸出提示符; - 接收到輸入命令時,對輸入命令進行格式化處理;
- 通過
write
傳送到 Redis 服務,並呼叫read
阻塞當前程式直到服務端返回為止; - 對服務端返回的資料反序列化;
- 將結果列印到終端。
用一個簡單的圖表示,大概是這樣的:
References
Follow: Draveness · GitHub
Source: draveness.me/redis-cli