一起來寫web server 09 -- 最終版本

一起來寫web server 09 -- 最終版本


這次的代碼是在前一次代碼的基礎上做一些點滴的修改.實現(xiàn)更好的封裝,更漂亮的抽象.同時也是我們這個web server的最后一個版本了.這次的版本應該修改的幅度是最大的.

EPOLLONESHOT

即使我們使用ET模式,一個socket上的某個事件還是可能被觸發(fā)多次,這個并發(fā)程序中就會引起一個問題,比如說一個線程(或進程,下同)在讀取完某個socket上的數(shù)據(jù)后開始處理這些數(shù)據(jù),而在數(shù)據(jù)處理的過程中該socket上又有新數(shù)據(jù)可讀(EPOLLIN)再次被觸發(fā),此時另外一個線程被喚醒來讀取這些新的數(shù)據(jù),于是就出現(xiàn)了兩個線程同時操作一個socket的局面,這當然不是我們期望的,我們期望的是一個socket連接在任一時刻都只被一個線程處理,這一點可以使用epollEPOLLONESHOT事件實現(xiàn). --[linux高性能服務器編程]

對于EOLLONESHOT這個事件,你可以認為,一旦某個線程接收到了注冊了EPOLLONESHOT的文件描述符的信號,從這個時候器,這個文件描述符相當于從epoll的監(jiān)聽隊列中移除了,這樣的話,別的事件就不會來打攪你了.總之每次你處理完這個事件,你都要重置這個文件描述符上的EPOLLONESHOT事件,相當于重新注冊一遍這個文件描述符.這樣的話,當這個文件描述符上事件可讀或者別的什么,可以被再次觸發(fā).

代碼相當簡單,你可以查看我的源碼,這里就不貼了.

Buffer

前面的幾個版本中,我們直接在HttpHandle這個類中放置字符數(shù)組和index來處理輸入和輸出,這樣非常不便于管理,所以為了實現(xiàn)更好的抽象,我們重新設計了一個Buffer類.實現(xiàn)參考了muduo庫,然后針對我們這個類做了特別的改進.(這個類在linux多線程服務端編程這本書里有很詳細的介紹,你可以看一下.)

我們來看一下這個類:

class Buffer
{
public:
    static const size_t kCheapPrepend = 8;
    static const size_t kInitialSize = 1024;
private:
    std::vector<char> buffer_; /* 用于vector來存儲字符 */
    size_t readerIndex_; /* 讀指示器 */
    size_t writerIndex_; /* 寫指示器 */

    static const char kCRLF[]; /* 其實就是\r\n啦! */
};

看一下它的構造函數(shù):

explicit Buffer(size_t initialSize = kInitialSize)
        : buffer_(kCheapPrepend + initialSize)
        , readerIndex_(kCheapPrepend)
        , writerIndex_(kCheapPrepend)
    {

    }

需要注意的是buffer_是一個字符vector,initialSize指的是初始化的大小.于別處不同是,readerIndex_writeIndex_并不是從0開始的,而是在這前面留了一小撮的空間,在我們這個應用里可能沒有什么卵用,但是,別的應用中,用處還是挺大的.

接下來是返回可讀取的字節(jié)數(shù)的函數(shù):

// 不修改類的成員變量的值的函數(shù)都要用const修飾,這是一種好的習慣
    size_t readableBytes() const { /* 可讀的字節(jié)數(shù)目 */
        return writerIndex_ - readerIndex_;
    }

獲取可寫入的字節(jié)數(shù)目.

size_t writableBytes() const { /* 可寫的字節(jié)數(shù)目 */
        return buffer_.size() - writerIndex_;
    }

peek函數(shù)返回可以讀的地址,需要注意的是返回的是一個const對象,即可以讀但是不可以修改.

const char* peek() const { /* 偷看 */
        return begin() + readerIndex_; // 從這里開始讀
    }

為了方便request的處理,這個BUffer類設計了一個findEOF函數(shù),具體的功能是返回從當前可讀位置開始的第一個\n字符的地址.

const char* findEOF() const {
        const void* eol = memchr(peek(), '\n', readableBytes());
        return static_cast<const char*>(eol);
    }

基于這個函數(shù),我們可以實現(xiàn)getLine函數(shù):

bool getLine(char *dest, size_t len) { /* 讀取一行數(shù)據(jù) */
        const char* end = findEOL();
        if (end == 0) return false; /* 沒有找到換行符 */

        const char* start = peek();
        assert(end >= start); /* 保證size是一個正數(shù),然后下面static_cast轉換的時候才會正確 */
        ptrdiff_t size = end - start - 1;

        if (len < static_cast<size_t>(size)) {
            return false; /* 空間不夠 */
        }
        std::copy(start, end - 1, dest); /* 去掉\r\n */
        dest[size] = 0;
        retrieveUntil(end + 1); /* 丟棄掉包括\n在內的數(shù)據(jù),因為已經被讀了 */
        return true;
    }

這個函數(shù)很簡單,那就是讀取一行數(shù)據(jù)到dest所指的字符數(shù)組里面.并且將bufferreaderIndex_后移(通過函數(shù)retrieveUntil實現(xiàn)),如果沒有發(fā)現(xiàn)\n或者destlen不夠,那就返回false不會對緩沖區(qū)做任何修改.

同時為了方便寫緩沖區(qū)的輸入,特別添加了appendStr函數(shù):

void appendStr(const char* format, ...) { /* 格式化輸入 */
        char extralbuf[256];
        memset(extralbuf, 0, sizeof extralbuf);
        va_list arglist;
        va_start(arglist, format);
        vsnprintf(extralbuf, sizeof extralbuf, format, arglist);
        va_end(arglist);
        append(extralbuf, strlen(extralbuf));
    }

上面的函數(shù)調用append才正在將數(shù)據(jù)添加到緩沖區(qū)中:

void  append(const char* data, size_t len) {
         //mylog("before append");
        ensureWritableBytes(len);
        std::copy(data, data + len, beginWrite());
        hasWritten(len);
}

hasWrittern函數(shù)具體的作用是將writeIndex_指針后移.這里比較有意思的一個函數(shù)是ensureWritableBytes函數(shù).它可以實現(xiàn)緩沖區(qū)的動態(tài)擴展.

void ensureWritableBytes(size_t len) { /* 保證有足夠的寫入空間 */
        if (writableBytes() < len) {
            makeSpace(len);
        }
        assert(writableBytes() >= len);
}

如果可供寫入的空間不足夠的話,要makeSpace.

void makeSpace(size_t len) {
        if (writableBytes() + prependableBytes() < len + kCheapPrepend) {
            buffer_.resize(writerIndex_ + len); /* 重新分配存儲空間 */
        }
        else { /* 如果剩余的空間大小足夠了! */
            assert(kCheapPrepend < readerIndex_);
            size_t readable = readableBytes(); /* 可讀的字節(jié)數(shù)目 */
            std::copy(begin() + readerIndex_,
                begin() + writerIndex_,
                begin() + kCheapPrepend);
            readerIndex_ = kCheapPrepend;
            writerIndex_ = readerIndex_ + readable;
            assert(readable == readableBytes());
        }
    }

如果可供寫的空間不夠,就調用vectorresize函數(shù)重新分配空間,否則的話,空間是足夠了,我們要將前面的已經空閑的,但是未被使用的空間回收,具體而言,就是將實際有用的數(shù)據(jù)往前面挪.

好了,具體的一些細節(jié)你可以查看具體的代碼實現(xiàn),這里就不在贅述.

HttpRequest

為了更好地處理連接,我們將處理對方發(fā)送的request的部分拆分了出來,組成了一個HttpRequest類,我們來分析一下這個類:

class HttpRequest
{
public:
    enum HttpRequestParseState /* HttpRequest所處的狀態(tài) */
    {
        kExpectRequestLine,
        kExpectHeaders,
        kExperctBody,
        kGotAll,
        kError
    };
public:
    bool keepAlive_; /* 是否繼續(xù)保持連接 */
    bool sendFile_; /* 是否要發(fā)送文件 */
    bool static_; /* 是否為靜態(tài)頁面 */
    std::string method_; /* 方法 */
    std::string path_; /* 資源的路徑 */
    std::string fileType_; /* 資源的類型 */
private:
    static const char rootDir_[]; /* 網(wǎng)頁的根目錄 */
    static const char homePage_[]; /* 所指代的網(wǎng)頁 */
    ... ...
};

這里不得不提的是這幾個狀態(tài),剛開始的時候HttpRequest是處于 kExpectRequestLine狀態(tài),也就是渴求請求行,所謂的請求行,就是這個玩意:

GET / Http/1.1\r\n

獲得了請求行之后,立馬轉入kExpectHeaders,也就是渴求請求頭狀態(tài),這個玩意就是請求行之后的數(shù)據(jù),以一行\r\n作為終止符.我舉一個栗子:

Accept: image/gif.image/jpeg,*/*\r\n
Accept-Language: zh-cn\r\n
Connection: Keep-Alive\r\n
Host: localhost\r\nUser-Agent: Mozila/4.0(compatible;MSIE5.01;Window NT5.0)\r\n
Accept-Encoding: gzip,deflate\r\n
\r\n

注意,這里我將不可見字符都寫出來了.
如果parse成功了,,那么就要HttpRequest立馬轉入kGotAll狀態(tài),表示萬事俱備,否則,前面的kExpectHeaders以及kExpectRequestLine有任何一個出錯,都將進入kError狀態(tài).

這里寫圖片描述

你可能會有疑問,為什么我們要用狀態(tài)機的方式來處理連接?

很簡單,因為用戶的行為可能非常奇葩,他有可能不按套路出牌,最為重要的一點是,我們有的時候并不能一次性就全部讀取到用戶的request,他可能將一個request分成若干次來發(fā)送,每次我們讀取不到完整的一行數(shù)據(jù)的時候,我們便要返回,繼續(xù)去監(jiān)聽用戶發(fā)送數(shù)據(jù),然后繼續(xù)返回到我們的HttpRequest,繼續(xù)運行,這也就導致了我們必須記住之前執(zhí)行到了那個狀態(tài),以便繼續(xù)往下執(zhí)行.用狀態(tài)機是最好的解決辦法.

用于處理用戶發(fā)送的request的函數(shù):

HttpRequest::HttpRequestParseState HttpRequest::parseRequest(Buffer& buf)
{
    bool ok = true;
    bool hasMore = true;
    while (hasMore) {
        if (state_ == kExpectRequestLine) {
            const char* crlf = buf.findCRLF(); /* 找到回車換行符 */
            if (crlf) { /* 如果找到了! */
                ok = processRequestLine(buf);
            }
            else {
                hasMore = false; /* 沒有找到,可能表示對方還沒有發(fā)送完整的一行數(shù)據(jù),要繼續(xù)去監(jiān)聽客戶的寫事件 */
            }
            if (ok) /* 請求行parse沒有出錯 */
                state_ = kExpectHeaders;
            else {
                state_ = kError; /* 出現(xiàn)錯誤 */
                hasMore = false;
            }
        }
        else if (state_ == kExpectHeaders) { /* 處理頭部的信息 */
            if (true == (ok = processHeaders(buf))) {
                state_ = kGotAll;
                hasMore = false;
            }
            else {
/* 這里做了簡化處理,頭部不會出錯,只要沒有得到\r\n這樣的結尾行都表示用戶數(shù)據(jù)還沒有發(fā)送完畢 */
                hasMore = false;
            }   
        }
        else if (state_ == kExperctBody) { /* 暫時還未實現(xiàn) */
        }
    }
    return state_;
}

接下來的都是一些小魚小蝦,比如說processRequestLine函數(shù):

bool HttpRequest::processRequestLine(Buffer& buf)
{
    bool succeed = false;
    char line[256];
    char method[64], path[256], version[64];
    buf.getLine(line, sizeof line); 
    sscanf(line, "%s %s %s", method, path, version);
    setMethod(method, strlen(method));
    setPath(path, strlen(path));
    /* version就不處理了 */
    return true;
}

還比如說processHeaders函數(shù):

bool HttpRequest::processHeaders(Buffer& buf) /* 處理其余的頭部信息 */
{ /* 其余的玩意,我就不處理啦! */
    char line[1024];
    char key[256], value[256];
    while (buf.getLine(line, sizeof line)) {
        if (strlen(line) == 0) { /* 只有取到了最后一個才能返回true */
            return true;
        }
        if (strstr(line, "keep-alive")) {
            keepAlive_ = true; /* 保持連接 */
        }

    }
    return false;
}

難度不大,這里不再一一贅述.

HttpHandle

HttpHandle類也變成了一個狀態(tài)機.這個類的入口點只有一個,就是process函數(shù).

void HttpHandle::process()
{
    /*-
    * 在process之前,只有這么一些狀態(tài)kExpectRead, kExpectWrite
    */
    switch (state_)
    {
    case kExpectRead: { /* 既然希望讀,那就processRead */
        processRead(); 
        break;
    }
    case kExpectWrite: { /* 既然希望寫,那就processWrite */
        processWrite(); 
        break;
    }
    default: /* 成功,失敗,這些都需要關閉連接 */
        removefd(epollfd_, sockfd_);
        break;
    }
    /*- 
    * 程序執(zhí)行完成后,有這么一些狀態(tài)kExpectRead, kExpectWrite, kError, kSuccess
    */
    switch (state_)
    {
    case kExpectRead: {
        modfd(epollfd_, sockfd_, EPOLLIN, true); /* 繼續(xù)監(jiān)聽對方的輸入 */
        break;
    }
    case kExpectWrite: {
        modfd(epollfd_, sockfd_, EPOLLOUT, true); /* 監(jiān)聽TCP緩沖區(qū)可寫事件 */
        break;
    }
    default: {
        removefd(epollfd_, sockfd_); /* 其余的都關閉連接 */
        break;
    }
    }
}

對于連接的處理也變成了狀態(tài)的轉換,它的狀態(tài)有:

enum HttpHandleState {
        kExpectReset, /* 需要初始化 */
        kExpectRead, /* 正在處理讀 */
        kExpectWrite, /* 正在處理寫 */
        kError, /* 出錯 */
        kSuccess, /* 成功 */
        kClosed /* 對方關閉連接 */
    };

剛開始的時候,HttpHandle處于kExpectReset狀態(tài),當客戶端connect過來之后,主線程對HttpHandle進行初始化,初始化完成之后,進入kExpectRead狀態(tài),他要一直讀取到對方完整的request才能進入下一個狀態(tài)kExpectWrite,這期間,如果客戶端數(shù)據(jù)分多次發(fā)送,HttpHandle就在這個kExpectRead打轉,當然如果此時對方關閉了連接,我們進入kClose狀態(tài),在kExpectWrite狀態(tài)中,如果數(shù)據(jù)沒有發(fā)送完,一直處于這個狀態(tài),發(fā)送完成的話,進入kSuccess狀態(tài),整個連接過程中,一旦出錯,都會進入kError狀態(tài).

這里寫圖片描述

processRead過程中:

void HttpHandle::processRead()
{
    struct stat sbuf;

    if (!read()) { /* 讀取失敗,代表對方關閉了連接 */
        state_ = kClosed;
        return;
    }
    /*-
    * 試想這樣一種情況,對方并沒有一次性將request發(fā)送完畢,而是只發(fā)送了一部分,你應該如何來處理?正確的方式是繼續(xù)去讀,直到讀到結尾符為止.
    * 當然,我這里并沒有處理request是錯誤的情況,這里假設request都是正確的,否則的話,就要關閉連接了.
    */
    HttpRequest::HttpRequestParseState state = request_.parseRequest(readBuffer_);
    if (state == HttpRequest::kError) /* 如果處理不成功,就要返回 */
    {
        state_ = kError; /* 解析出錯 */
        return;
    }
    else if (state != HttpRequest::kGotAll){ /* 沒出錯的話,表明對方只發(fā)送了request的一部分,我們需要繼續(xù)讀 */
        return;
    }
    
    if (strcasecmp(request_.method_.c_str(), "GET")) {  /* 只支持Get方法 */         
        clientError(request_.method_.c_str(), "501", "Not Implemented",
            "Tiny does not implement this method");
        goto end;
    }
                                         
    if (request_.static_) { /* 只支持靜態(tài)網(wǎng)頁 */
        if (stat(request_.path_.c_str(), &sbuf) < 0) {
            clientError(request_.path_.c_str(), "404", "Not found",
                "Tiny couldn't find this file"); /* 沒有找到文件 */
            goto end;
        }

        if (!(S_ISREG(sbuf.st_mode)) || !(S_IRUSR & sbuf.st_mode)) {
            clientError(request_.path_.c_str(), "403", "Forbidden",
                "Tiny couldn't read the file"); /* 權限不夠 */
            goto end;
        }
        serveStatic(request_.path_.c_str(), sbuf.st_size);
        
    }
    else { /* Serve dynamic content */
        clientError(request_.method_.c_str(), "501", "Not Implemented",
            "Tiny does not implement this method");
        goto end;
    }
end:
    state_ = kExpectWrite;
    return processWrite();
}

processWrite過程中:

void HttpHandle::processWrite()
{
    int res;
    /*- 
    * 數(shù)據(jù)要作為兩部分發(fā)送,第1步,要發(fā)送writeBuf_里面的數(shù)據(jù).
    */
    size_t nRemain = writeBuffer_.readableBytes(); /* writeBuf_中還有多少字節(jié)要寫 */
    if (nRemain > 0) {
        while (true) {
            size_t len = writeBuffer_.readableBytes();
            //mylog("1. len = %ld", len);
            res = write(sockfd_, writeBuffer_.peek(), len);
            if (res < 0) {
                if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) { /* 資源暫時不可用 */
                    return;
                }
                state_ = kError;
                return;
            }
        
            writeBuffer_.retrieve(res);
            if (writeBuffer_.readableBytes() == 0) break;
        }
    }
        
    /*-
    * 第2步,要發(fā)送html網(wǎng)頁數(shù)據(jù).
    */
    if (sendFile_) {
        char *fileAddr = (char *)fileInfo_->addr_;
        size_t fileSize = fileInfo_->size_;
        while (true) {
            res = write(sockfd_, fileAddr + fileWritten_, fileSize - fileWritten_);
            if (res < 0) {
                if ((errno == EAGAIN) || (errno == EWOULDBLOCK)) { /* 資源暫時不可用 */
                    return;
                }
                state_ = kError; /* 出現(xiàn)了錯誤 */
                return;
            }
            fileWritten_ += res;
            if (fileWritten_ == fileInfo_->size_)
                break;  
        }
        
    }

    /* 數(shù)據(jù)發(fā)送完畢 */
    reset();
    if (keepAlive_) /* 如果需要保持連接的話 */
        state_ = kExpectRead;
    else
        state_ = kSuccess;
}

代碼都非常簡單,應該能夠讀懂.

尾聲

到了這里,我的代碼寫的就差不多了,還有一些小問題沒有解決,那就是如果對方一直占用著資源怎么辦?其實我們也有辦法,那就是時間輪或者時間堆,如果過了一段時間客戶端還沒有發(fā)送數(shù)據(jù)過來的話,我們強行關閉連接,至于這些代碼的實現(xiàn),當做一個小小的測驗,留給你吧.

其余的代碼問題都不是很大,可能會有一點錯誤,但會慢慢糾正的.和前面一樣的,參考代碼在這里:https://github.com/lishuhuakai/Spweb

實踐出真知

我讀過UNP,APUE,CSAPP等一大堆的書,我覺得我足夠聰穎,光讀一讀就能夠透知一切,然而某一天,我發(fā)現(xiàn)我錯了,讀了這些書沒多久,書里的只是只是在我的腦海里留下了一層印記,時光如同徐徐的長風,將這些印記一層層拂去,沒過多久,我就忘得差不多了,然后我突然想寫一些代碼了,某一天我欣喜地發(fā)現(xiàn),做過了一遍之后,這些東西長久地留存在我的腦海了,所以我想說的一句是,實踐才能出真知.

當然光寫也是挺傻逼的,有理論的指導,你才能寫出更加漂亮的代碼.程序員永遠都需要理論和實踐兩條腿走路.

我起先也不知道應該做些什么,有些玩意感覺難度太大,一些又沒多大意義,不過不管怎么樣,總比不做要好,下一次,我想寫一個ftp服務.

Over!

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯(lián)系作者
  • 序言:七十年代末置侍,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖滩报,帶你破解...
    沈念sama閱讀 207,248評論 6 481
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件扇苞,死亡現(xiàn)場離奇詭異,居然都是意外死亡烟号,警方通過查閱死者的電腦和手機坯认,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 88,681評論 2 381
  • 文/潘曉璐 我一進店門贸伐,熙熙樓的掌柜王于貴愁眉苦臉地迎上來钩乍,“玉大人,你說我怎么就攤上這事铺然〔牡牛” “怎么了尊剔?”我有些...
    開封第一講書人閱讀 153,443評論 0 344
  • 文/不壞的土叔 我叫張陵十减,是天一觀的道長宗弯。 經常有香客問我,道長淳附,這世上最難降的妖魔是什么晶渠? 我笑而不...
    開封第一講書人閱讀 55,475評論 1 279
  • 正文 為了忘掉前任凰荚,我火速辦了婚禮,結果婚禮上褒脯,老公的妹妹穿的比我還像新娘。我一直安慰自己缆毁,他們只是感情好番川,可當我...
    茶點故事閱讀 64,458評論 5 374
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著脊框,像睡著了一般颁督。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發(fā)上浇雹,一...
    開封第一講書人閱讀 49,185評論 1 284
  • 那天沉御,我揣著相機與錄音,去河邊找鬼昭灵。 笑死吠裆,一個胖子當著我的面吹牛,可吹牛的內容都是我干的烂完。 我是一名探鬼主播试疙,決...
    沈念sama閱讀 38,451評論 3 401
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼抠蚣!你這毒婦竟也來了祝旷?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 37,112評論 0 261
  • 序言:老撾萬榮一對情侶失蹤嘶窄,失蹤者是張志新(化名)和其女友劉穎怀跛,沒想到半個月后,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體柄冲,經...
    沈念sama閱讀 43,609評論 1 300
  • 正文 獨居荒郊野嶺守林人離奇死亡吻谋,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 36,083評論 2 325
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發(fā)現(xiàn)自己被綠了羊初。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片滨溉。...
    茶點故事閱讀 38,163評論 1 334
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖长赞,靈堂內的尸體忽然破棺而出晦攒,到底是詐尸還是另有隱情,我是刑警寧澤得哆,帶...
    沈念sama閱讀 33,803評論 4 323
  • 正文 年R本政府宣布脯颜,位于F島的核電站,受9級特大地震影響贩据,放射性物質發(fā)生泄漏栋操。R本人自食惡果不足惜闸餐,卻給世界環(huán)境...
    茶點故事閱讀 39,357評論 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望矾芙。 院中可真熱鬧舍沙,春花似錦、人聲如沸剔宪。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,357評論 0 19
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽葱绒。三九已至感帅,卻和暖如春,著一層夾襖步出監(jiān)牢的瞬間地淀,已是汗流浹背失球。 一陣腳步聲響...
    開封第一講書人閱讀 31,590評論 1 261
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留帮毁,地道東北人实苞。 一個月前我還...
    沈念sama閱讀 45,636評論 2 355
  • 正文 我出身青樓,卻偏偏與公主長得像作箍,于是被迫代替她去往敵國和親硬梁。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 42,925評論 2 344

推薦閱讀更多精彩內容