Redis 是如何處理命令的(客戶端)

在使用 Redis 的過程中經(jīng)常會(huì)好奇错忱,在 Redis-Cli 中鍵入 SET KEY MSG 并回車之后谈秫,Redis 客戶端和服務(wù)是如何對命令進(jìn)行解析處理的绕沈,而在內(nèi)部的實(shí)現(xiàn)過程是什么樣的良蛮。

這兩篇文章會(huì)分別介紹 Redis 客戶端和服務(wù)端分別對命令是如何處理的,本篇文章介紹的是 Redis 客戶端如何處理輸入的命令然走、向服務(wù)發(fā)送命令以及取得服務(wù)端回復(fù)并輸出到終端等過程援制。

redis-client-serve

文章中會(huì)將 Redis 服務(wù)看做一個(gè)輸入為 Redis 命令,輸出為命令執(zhí)行結(jié)果的黑箱芍瑞,對從命令到結(jié)果的過程不做任何解釋晨仑,只會(huì)著眼于客戶端的邏輯,也就是上圖中的 1 和 4 兩個(gè)過程啄巧。

從 main 函數(shù)開始

與其它的 C 語言框架/服務(wù)類似,Redis 的客戶端 redis-cli 也是從 main 函數(shù)開始執(zhí)行的掌栅,位于 redis-cli.c 文件的最后:

int main(int argc, char **argv) {
    ...
    if (argc == 0 && !config.eval) {
        repl();
    }
    ...
}

在一般情況下秩仆,Redis 客戶端都會(huì)進(jìn)入 repl 模式,對輸入進(jìn)行解析猾封;

Redis 中有好多模式澄耍,包括:Latency、Slave晌缘、Pipe齐莲、Stat、Scan磷箕、LRU test 等等模式选酗,不過這些模式都不是這篇文章關(guān)注的重點(diǎn),我們只會(huì)關(guān)注最常見的 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);
}

在上述代碼中芒填,我們省略了大量的實(shí)現(xiàn)細(xì)節(jié),只保留整個(gè) repl 中循環(huán)的主體部分空繁,方便進(jìn)行理解和分析殿衰,在 while 循環(huán)中的條件你可以看到 linenoise 方法的調(diào)用,通過其中的 promptnot connected> 可以判斷出盛泡,這里向終端中輸出了提示符闷祥,同時(shí)會(huì)調(diào)用 fgets 從標(biāo)準(zhǔn)輸入中讀取字符串:

127.0.0.1:6379> 

全局搜一下 config.prompt 不難發(fā)現(xiàn)這一行代碼,也就是控制命令行提示的 prompt

anetFormatAddr(config.prompt, sizeof(config.prompt),config.hostip, config.hostport);

接下來執(zhí)行的 cliSplitArgs 函數(shù)會(huì)將 line 中的字符串分割成幾個(gè)不同的參數(shù)傲诵,然后根據(jù)字符串 argv[0] 的不同執(zhí)行的命令凯砍,在這里省略了很多原有的代碼:

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);
}

在遇到 quitexit 等跟客戶端狀態(tài)有關(guān)的命令時(shí)拴竹,就會(huì)直接執(zhí)行相應(yīng)的代碼果覆;否則就會(huì)將命令和參數(shù) issueCommandRepeat 函數(shù)。

追蹤一次命令的執(zhí)行

Redis Commit: 790310d89460655305bd615bc442eeaf7f0f1b38

lldb: lldb-360.1.65

macOS 10.11.6

在繼續(xù)分析 issueCommandRepeat 之前殖熟,我們先對 Redis 中的這部分代碼進(jìn)行調(diào)試追蹤局待,在使用 make 編譯了 Redis 源代碼,啟動(dòng) redis-server 之后;啟動(dòng) lldb 對 Redis 客戶端進(jìn)行調(diào)試:

$ 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 也就是下面這行代碼的地方打斷點(diǎn)之后:

-> 1290         if (line[0] != '\0') {

執(zhí)行 process launch 啟動(dòng) redis-cli钳榨,然后輸入 SET KEY MSG 回車以及 Ctrl-C:

在 lldb 中調(diào)試時(shí)舰罚,回車的輸入經(jīng)常會(huì)有問題,在這里輸入 Ctrl-C 進(jìn)入信號處理器薛耻,在通過 continue 命令進(jìn)入斷點(diǎn):

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 之后营罢,打印 argvargc 的值:

(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 中的字符串分隔成字符串參數(shù),在多次執(zhí)行 n 之后饼齿,進(jìn)入 issueCommandRepeat 方法:

-> 1334                     issueCommandRepeat(argc-skipargs, argv+skipargs, repeat);

對輸入命令的處理

上一階段執(zhí)行 issueCommandRepeat 的函數(shù)調(diào)用棧中饲漾,會(huì)發(fā)現(xiàn) Redis 并不會(huì)直接把所有的命令發(fā)送到服務(wù)端:

issueCommandRepeat
    cliSendCommand
        redisAppendCommandArgv
            redisFormatCommandArgv
            __redisAppendCommand

而是會(huì)在 redisFormatCommandArgv 中對所有的命令進(jìn)行格式化處理,將字符串轉(zhuǎn)換為符合 RESP 協(xié)議的數(shù)據(jù)缕溉。

RESP 協(xié)議

Redis 客戶端與 Redis 服務(wù)進(jìn)行通訊時(shí)考传,會(huì)使用名為 RESP(REdis Serialization Protocol) 的協(xié)議,它的使用非常簡單证鸥,并且可以序列化多種數(shù)據(jù)類型包括整數(shù)僚楞、字符串以及數(shù)組等。

對于 RESP 協(xié)議的詳細(xì)介紹可以看官方文檔中的 Redis Protocol specification枉层,在這里對這個(gè)協(xié)議進(jìn)行簡單的介紹泉褐。

在將不同的數(shù)據(jù)類型序列化時(shí),會(huì)使用第一個(gè) byte 來表示當(dāng)前數(shù)據(jù)的數(shù)據(jù)類型鸟蜡,以便在客戶端或服務(wù)器在處理時(shí)能恢復(fù)原來的數(shù)據(jù)格式膜赃。

redis-resp-data-byte

舉一個(gè)簡單的例子,字符串 OK 以及錯(cuò)誤Error Message 等不同種類的信息的 RESP 表示如下:

redis-resp-type-and-examples

在這篇文章中我們需要簡單了解的就是 RESP “數(shù)據(jù)格式”的第一個(gè)字節(jié)用來表示數(shù)據(jù)類型揉忘,然后邏輯上屬于不同部分的內(nèi)容通過 CRLF(\r\n)分隔病梢。

數(shù)據(jù)格式的轉(zhuǎn)換

redisFormatCommandArgv 方法中幾乎沒有需要?jiǎng)h減的代碼若债,所有的命令都會(huì)以字符串?dāng)?shù)組的形式發(fā)送到客戶端:

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 這一命令,經(jīng)過這個(gè)方法的處理會(huì)變成:

*3\r\n$3\r\nSET\r\n$3\r\nKEY\r\n$3\r\nMSG\r\n

你可以這么理解上面的結(jié)果:

*3\r\n
    $3\r\nSET\r\n
    $3\r\nKEY\r\n
    $3\r\nMSG\r\n

這是一個(gè)由三個(gè)字符串組成的數(shù)組,數(shù)組中的元素是 SET缨睡、KEY 以及 MSG 三個(gè)字符串边涕。

如果在這里打一個(gè)斷點(diǎn)并輸出 target 中的內(nèi)容:

redis-lldb-cmd

到這里就完成了對輸入命令的格式化蚊逢,在格式化之后還會(huì)將當(dāng)前命令寫入全局的 redisContextwrite 緩沖區(qū) obuf 中聂宾,也就是在上面的緩沖區(qū)看到的第二個(gè)方法:

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

再繼續(xù)介紹下一部分之前需要簡單介紹一下 redisContext 結(jié)構(gòu)體:

typedef struct redisContext {
    int err;
    char errstr[128];
    int fd;
    int flags;
    char *obuf;
    redisReader *reader;
} redisContext;

每一個(gè) redisContext 的結(jié)構(gòu)體都表示一個(gè) Redis 客戶端對服務(wù)的連接,而這個(gè)上下文會(huì)在每一個(gè) redis-cli 中作為靜態(tài)變量僅保存一個(gè):

static redisContext *context;

obuf 中包含了客戶端未寫到服務(wù)端的數(shù)據(jù)虚倒;而 reader 是用來處理 RESP 協(xié)議的結(jié)構(gòu)體美侦;fd 就是 Redis 服務(wù)對應(yīng)的文件描述符;其他的內(nèi)容就不多做解釋了魂奥。

到這里菠剩,對命令的格式化處理就結(jié)束了,接下來就到了向服務(wù)端發(fā)送命令的過程了耻煤。

向服務(wù)器發(fā)送命令

與對輸入命令的處理差不多具壮,向服務(wù)器發(fā)送命令的方法也在 issueCommandRepeat 的調(diào)用棧中准颓,而且藏得更深,如果不仔細(xì)閱讀源代碼其實(shí)很難發(fā)現(xiàn):

issueCommandRepeat
    cliSendCommand
        cliReadReply
            redisGetReply
               redisBufferWrite

Redis 在 redisGetReply 中完成對命令的發(fā)送:

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 函數(shù)中傳遞了全局的靜態(tài)變量 redisContext棺妓,其中的 obuf 中存儲了沒有向 Redis 服務(wù)發(fā)送的命令:

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;
}

代碼的邏輯其實(shí)十分清晰攘已,調(diào)用 write 向 Redis 服務(wù)代表的文件描述符發(fā)送寫緩沖區(qū) obuf 中的數(shù)據(jù),然后根據(jù)返回值做出相應(yīng)的處理怜跑,如果命令發(fā)送成功就會(huì)清空 obuf 并將 done 指針標(biāo)記為真样勃,然后返回,這樣就完成了向服務(wù)器發(fā)送命令這一過程性芬。

redis-lldb-nwritten

獲取服務(wù)器回復(fù)

其實(shí)獲取服務(wù)器回復(fù)和上文中的發(fā)送命令過程基本上差不多峡眶,調(diào)用棧也幾乎完全一樣:

issueCommandRepeat
    cliSendCommand
        cliReadReply
            redisGetReply
                redisBufferRead
                redisGetReplyFromReader
            cliFormatReplyRaw
            fwrite

同樣地,在 redisGetReply 中獲取服務(wù)器的響應(yīng):

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 成功發(fā)送命令并返回之后植锉,就會(huì)開始等待服務(wù)端的回復(fù)辫樱,總共分為兩個(gè)部分,一是使用 redisBufferRead 從服務(wù)端讀取原始格式的回復(fù)(符合 RESP 協(xié)議):

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 從文件描述符中成功讀取數(shù)據(jù)并返回之后汽煮,我們可以打印 buf 中的內(nèi)容:

redis-lldb-read

剛剛向 buf 中寫入的數(shù)據(jù)還需要經(jīng)過 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 方法會(huì)從 redisContext 中取出 reader暇赤,然后反序列化 RESP 對象,最后打印出來宵凌。

process-end

當(dāng)我們從終端的輸出中看到了 OK 以及這個(gè)命令的執(zhí)行的時(shí)間時(shí)鞋囊,SET KEY MSG 這一命令就已經(jīng)處理完成了。

總結(jié)

處理命令的過程在客戶端還是比較簡單的:

  1. 在一個(gè) while 循環(huán)中瞎惫,輸出提示符溜腐;
  2. 接收到輸入命令時(shí),對輸入命令進(jìn)行格式化處理瓜喇;
  3. 通過 write 發(fā)送到 Redis 服務(wù)挺益,并調(diào)用 read 阻塞當(dāng)前進(jìn)程直到服務(wù)端返回為止;
  4. 對服務(wù)端返回的數(shù)據(jù)反序列化乘寒;
  5. 將結(jié)果打印到終端望众。

用一個(gè)簡單的圖表示,大概是這樣的:

redis-client-process-commands

References

Follow: Draveness · GitHub

Source: http://draveness.me/redis-cli

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末伞辛,一起剝皮案震驚了整個(gè)濱河市烂翰,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌蚤氏,老刑警劉巖甘耿,帶你破解...
    沈念sama閱讀 207,113評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件,死亡現(xiàn)場離奇詭異竿滨,居然都是意外死亡佳恬,警方通過查閱死者的電腦和手機(jī)捏境,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,644評論 2 381
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來殿怜,“玉大人典蝌,你說我怎么就攤上這事⊥访眨” “怎么了骏掀?”我有些...
    開封第一講書人閱讀 153,340評論 0 344
  • 文/不壞的土叔 我叫張陵,是天一觀的道長柱告。 經(jīng)常有香客問我截驮,道長,這世上最難降的妖魔是什么际度? 我笑而不...
    開封第一講書人閱讀 55,449評論 1 279
  • 正文 為了忘掉前任葵袭,我火速辦了婚禮,結(jié)果婚禮上乖菱,老公的妹妹穿的比我還像新娘坡锡。我一直安慰自己,他們只是感情好窒所,可當(dāng)我...
    茶點(diǎn)故事閱讀 64,445評論 5 374
  • 文/花漫 我一把揭開白布鹉勒。 她就那樣靜靜地躺著,像睡著了一般吵取。 火紅的嫁衣襯著肌膚如雪禽额。 梳的紋絲不亂的頭發(fā)上,一...
    開封第一講書人閱讀 49,166評論 1 284
  • 那天皮官,我揣著相機(jī)與錄音脯倒,去河邊找鬼。 笑死捺氢,一個(gè)胖子當(dāng)著我的面吹牛藻丢,可吹牛的內(nèi)容都是我干的。 我是一名探鬼主播摄乒,決...
    沈念sama閱讀 38,442評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼悠反,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了缺狠?” 一聲冷哼從身側(cè)響起问慎,我...
    開封第一講書人閱讀 37,105評論 0 261
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎挤茄,沒想到半個(gè)月后如叼,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 43,601評論 1 300
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡穷劈,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,066評論 2 325
  • 正文 我和宋清朗相戀三年笼恰,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了踊沸。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,161評論 1 334
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡社证,死狀恐怖逼龟,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情追葡,我是刑警寧澤腺律,帶...
    沈念sama閱讀 33,792評論 4 323
  • 正文 年R本政府宣布,位于F島的核電站宜肉,受9級特大地震影響匀钧,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜谬返,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,351評論 3 307
  • 文/蒙蒙 一之斯、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧遣铝,春花似錦佑刷、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,352評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至梁沧,卻和暖如春檀何,著一層夾襖步出監(jiān)牢的瞬間蝇裤,已是汗流浹背廷支。 一陣腳步聲響...
    開封第一講書人閱讀 31,584評論 1 261
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留栓辜,地道東北人恋拍。 一個(gè)月前我還...
    沈念sama閱讀 45,618評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像藕甩,于是被迫代替她去往敵國和親施敢。 傳聞我的和親對象是個(gè)殘疾皇子,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 42,916評論 2 344

推薦閱讀更多精彩內(nèi)容