器HTTP解析機(jī)制詳解:主從狀態(tài)機(jī)與緩沖區(qū)設(shè)計(jì))
拿到一份Web服務(wù)器源碼我建議你第一眼看HTTP解析模塊。別的什么線程池、epoll事件循環(huán)、定時(shí)器本質(zhì)上都在為同一件事服務(wù)——把一個(gè)HTTP請(qǐng)求正確地讀進(jìn)來(lái)、解析干凈、處理掉、再回一個(gè)像樣的響應(yīng)。很多朋友卡在TinyWebServer這類項(xiàng)目上不是看不懂C語(yǔ)法而是被HTTP報(bào)文的解析過(guò)程繞暈了一會(huì)兒主狀態(tài)機(jī)一會(huì)兒從狀態(tài)機(jī)緩沖區(qū)里一堆char*指來(lái)指去讀一半數(shù)據(jù)又返回繼續(xù)等。這篇博文就專門(mén)盯著HTTP機(jī)制這一塊從整體設(shè)計(jì)思路講到狀態(tài)機(jī)的每一段邏輯再補(bǔ)充實(shí)戰(zhàn)中容易踩的坑希望幫大家把這塊硬骨頭啃下來(lái)。適合正在讀小型Web服務(wù)器源碼的開(kāi)發(fā)者也適合準(zhǔn)備網(wǎng)絡(luò)編程方向面試的朋友作為復(fù)習(xí)提綱。1. 先搞清楚整體設(shè)計(jì)HTTP機(jī)制在服務(wù)器里到底管什么1.1 TinyWebServer的經(jīng)典分層結(jié)構(gòu)TinyWebServer是一個(gè)麻雀雖小五臟俱全的Linux C服務(wù)器項(xiàng)目常見(jiàn)的模塊劃分大致是epoll事件驅(qū)動(dòng)層負(fù)責(zé)監(jiān)聽(tīng)連接上的讀寫(xiě)事件線程池負(fù)責(zé)把業(yè)務(wù)處理任務(wù)分發(fā)到工作線程HTTP模塊負(fù)責(zé)解析請(qǐng)求和構(gòu)造響應(yīng)而存儲(chǔ)層或靜態(tài)文件訪問(wèn)模塊負(fù)責(zé)真正的資源讀取。整個(gè)鏈路可以簡(jiǎn)化為這樣客戶端 - accept - epoll 監(jiān)聽(tīng)可讀 - 線程池任務(wù) - HTTP解析 - 業(yè)務(wù)處理 - HTTP響應(yīng) - 寫(xiě)回客戶端HTTP機(jī)制就是這條鏈路里承上啟下的中間層。它不關(guān)心連接是怎么建立的也不關(guān)心文件系統(tǒng)里有什么內(nèi)容它只做兩件事把客戶端發(fā)來(lái)的字節(jié)流變成結(jié)構(gòu)化的請(qǐng)求數(shù)據(jù)再把處理結(jié)果變成符合協(xié)議格式的字節(jié)流返回去。理解了這個(gè)定位你再去讀源碼就會(huì)發(fā)現(xiàn)它的接口邊界非常清楚——對(duì)外暴露的往往就是process_read和process_write這兩個(gè)入口。1.2 HTTP協(xié)議格式是解析的“基準(zhǔn)線”要解析HTTP腦子里必須先有報(bào)文格式的完整圖景。一個(gè)標(biāo)準(zhǔn)的HTTP/1.1請(qǐng)求報(bào)文長(zhǎng)這樣POST /login HTTP/1.1\r\n Host: 192.168.1.101:8080\r\n Content-Type: application/x-www-form-urlencoded\r\n Content-Length: 27\r\n Connection: keep-alive\r\n \r\n usernamehellopasswordworld注意\r\n是HTTP協(xié)議的行分隔符每一行以回車(chē)換行結(jié)尾請(qǐng)求頭和請(qǐng)求體之間用一個(gè)空行也就是連續(xù)兩個(gè)\r\n分隔。這一行和空行的處理是整個(gè)解析邏輯的地基TinyWebServer的從狀態(tài)機(jī)就是在為這個(gè)“按行切割”服務(wù)的。理解了HTTP報(bào)文的這種文本結(jié)構(gòu)解析代碼看起來(lái)就不再是一堆神秘指針的亂舞而是一個(gè)標(biāo)準(zhǔn)的狀態(tài)流轉(zhuǎn)過(guò)程。1.3 為什么只靠“讀一行處理一行”不行初學(xué)者最容易產(chǎn)生的疑問(wèn)是既然HTTP報(bào)文是文本為什么不直接一行行讀、一行行解析問(wèn)題出在TCP流本身沒(méi)有邊界。你一次read讀到的數(shù)據(jù)可能只有半個(gè)請(qǐng)求行也可能包含兩個(gè)完整的請(qǐng)求還可能恰好卡在\r和\n之間。如果每次讀取都從頭完整解析數(shù)據(jù)不完整就很尷尬。所以TinyWebServer的思路是維護(hù)一個(gè)應(yīng)用層緩沖區(qū)把內(nèi)核里讀到的數(shù)據(jù)先暫存再通過(guò)狀態(tài)機(jī)逐字節(jié)、逐行地推進(jìn)。數(shù)據(jù)沒(méi)讀完就返回NO_REQUEST繼續(xù)等待下一次可讀事件數(shù)據(jù)完整就返回GET_REQUEST進(jìn)入業(yè)務(wù)處理。狀態(tài)機(jī)的好處就是天然適合這種“數(shù)據(jù)到達(dá)不完整”的流式場(chǎng)景這也是理解整個(gè)HTTP模塊最關(guān)鍵的一把鑰匙。2. 狀態(tài)機(jī)拆解主從狀態(tài)機(jī)各自的職責(zé)邊界2.1 兩個(gè)“狀態(tài)”分別代表什么TinyWebServer里常說(shuō)的主從狀態(tài)機(jī)實(shí)際上是兩個(gè)不同層面的狀態(tài)概念。主狀態(tài)機(jī)描述的是“當(dāng)前HTTP報(bào)文解析進(jìn)行到哪個(gè)部分”典型狀態(tài)是CHECK_STATE_REQUESTLINE // 正在解析請(qǐng)求行 CHECK_STATE_HEADER // 正在解析請(qǐng)求頭 CHECK_STATE_CONTENT // 正在解析請(qǐng)求體從狀態(tài)機(jī)描述的是“當(dāng)前正在處理的這一行數(shù)據(jù)是否完整”典型狀態(tài)是LINE_OK // 讀到了一個(gè)完整的行 LINE_BAD // 行格式有問(wèn)題比如沒(méi)有以\r\n結(jié)尾 LINE_OPEN // 數(shù)據(jù)還不完整需要繼續(xù)讀套用一句比較直白的話主狀態(tài)機(jī)在管“我該干什么”從狀態(tài)機(jī)在管“手上這一行能不能干活”。兩個(gè)狀態(tài)機(jī)配合起來(lái)整個(gè)HTTP解析就能做到邊讀邊解析來(lái)多少數(shù)據(jù)就消化多少數(shù)據(jù)。2.2 從狀態(tài)機(jī)是怎么切出“一行”的從狀態(tài)機(jī)最常見(jiàn)的實(shí)現(xiàn)是用一個(gè)指針遍歷緩沖區(qū)逐字符尋找\r\n。偽代碼如下LINE_STATUS parse_line(char* buffer, int checked_idx, int read_idx) { for (; checked_idx read_idx; checked_idx) { if (buffer[checked_idx] \r) { if (checked_idx 1 read_idx) { return LINE_OPEN; // \r 是緩沖區(qū)的最后一個(gè)字符還不能下結(jié)論 } if (buffer[checked_idx 1] \n) { buffer[checked_idx] \0; buffer[checked_idx] \0; return LINE_OK; } return LINE_BAD; // 只有\(zhòng)r后面不是\n格式錯(cuò)誤 } } return LINE_OPEN; }這里最值得品味的是LINE_OPEN的處理為什么\r出現(xiàn)在緩沖區(qū)末尾時(shí)不能立刻判斷因?yàn)橄乱淮蝦ead讀進(jìn)來(lái)的第一個(gè)字節(jié)可能就是\n所以必須留到下一次數(shù)據(jù)到達(dá)時(shí)再確認(rèn)。這就是流式解析的精髓——永遠(yuǎn)不要假設(shè)一次read能帶來(lái)完整的數(shù)據(jù)。2.3 主狀態(tài)機(jī)如何配合從狀態(tài)機(jī)推進(jìn)主狀態(tài)機(jī)的核心邏輯大致是每當(dāng)從狀態(tài)機(jī)切出一個(gè)完整行LINE_OK就根據(jù)當(dāng)前主狀態(tài)決定把這行交給哪個(gè)解析函數(shù)。請(qǐng)求行階段 - 解析請(qǐng)求行 - 成功后切換到請(qǐng)求頭階段 請(qǐng)求頭階段 - 解析請(qǐng)求頭 - 如果讀到空行說(shuō)明請(qǐng)求頭結(jié)束根據(jù)是否有正文切到請(qǐng)求體階段 請(qǐng)求體階段 - 直接讀取剩余字節(jié)作為請(qǐng)求體這種設(shè)計(jì)把每個(gè)階段拆得很干凈。請(qǐng)求行解析只關(guān)心方法、URL、版本號(hào)請(qǐng)求頭解析只關(guān)心Host、Connection、Content-Length這些關(guān)鍵字段請(qǐng)求體階段則把剩余的字節(jié)原樣保存。三個(gè)階段的邏輯互不干擾出問(wèn)題時(shí)也容易定位。3. 關(guān)鍵實(shí)現(xiàn)細(xì)節(jié)數(shù)據(jù)是怎么一步步變成結(jié)構(gòu)化請(qǐng)求的3.1 緩沖區(qū)與指針?biāo)饕呐浜献xTinyWebServer的解析代碼最頭疼的往往是那一堆索引變量。常見(jiàn)的命名有這么幾個(gè)m_read_buf // 讀緩沖區(qū) m_read_idx // 緩沖區(qū)里有效數(shù)據(jù)的末尾位置 m_checked_idx // 當(dāng)前解析到的位置 m_start_line // 當(dāng)前行的起始位置三個(gè)索引的關(guān)系可以畫(huà)成一條數(shù)軸m_start_line到m_checked_idx是已經(jīng)切出來(lái)但可能還沒(méi)被主狀態(tài)機(jī)處理的行m_checked_idx到m_read_idx是尚未解析的原始數(shù)據(jù)。每次從狀態(tài)機(jī)跑完m_checked_idx會(huì)推進(jìn)到一行的末尾當(dāng)整行被主狀態(tài)機(jī)消費(fèi)后再把m_start_line移動(dòng)到下一行的開(kāi)頭。這套索引配合起來(lái)即使數(shù)據(jù)分多次到達(dá)也不會(huì)亂套。需要注意的是讀取數(shù)據(jù)時(shí)如果緩沖區(qū)的剩余空間不足還要考慮把未解析的數(shù)據(jù)前移騰出空間繼續(xù)讀新數(shù)據(jù)。這個(gè)細(xì)節(jié)容易被忽略但它直接決定了緩沖區(qū)能不能應(yīng)對(duì)超大頭部。3.2 請(qǐng)求行解析三個(gè)核心字段怎么摳出來(lái)請(qǐng)求行是GET /index.html HTTP/1.1這種格式三個(gè)字段以空格分隔。解析思路通常是先找出第一個(gè)空格來(lái)切出方法再?gòu)氖S嗖糠掷镎页龅诙€(gè)空格來(lái)切出URL最后剩下的就是版本號(hào)。HTTP_CODE parse_request_line(char* text) { m_url strpbrk(text, \t); // 找第一個(gè)空格 if (!m_url) return BAD_REQUEST; *m_url \0; m_method text; // 此時(shí) text 就是方法名 m_version strpbrk(m_url, \t); // 找第二個(gè)空格 if (!m_version) return BAD_REQUEST; *m_version \0; if (strcasecmp(m_version, HTTP/1.1) ! 0) { return BAD_REQUEST; } if (strcasecmp(m_method, GET) 0) { // 處理GET請(qǐng)求 } else if (strcasecmp(m_method, POST) 0) { // 處理POST請(qǐng)求 } else { return BAD_REQUEST; } return NO_REQUEST; }這里有兩個(gè)容易踩的坑。第一strpbrk返回的地址在*m_url \0之后m_url已經(jīng)指向真正URL的起始位置但這一步會(huì)修改緩沖區(qū)內(nèi)容所以解析后的字符串不能再依賴原始的完整報(bào)文。第二URL可能帶上參數(shù)/index.html?namevalue需要考慮是整段解析還是剝掉參數(shù)。TinyWebServer這類小型項(xiàng)目通常會(huì)把?后面的內(nèi)容去掉或作為查詢串單獨(dú)保存具體策略影響后續(xù)靜態(tài)文件的查找路徑。3.3 請(qǐng)求頭解析只抓關(guān)鍵字段就夠了請(qǐng)求頭可以有很多行但其實(shí)對(duì)一個(gè)小型Web服務(wù)器來(lái)說(shuō)真正關(guān)心的字段沒(méi)幾個(gè)。常見(jiàn)實(shí)現(xiàn)會(huì)循環(huán)讀取請(qǐng)求頭直到遇到空行依次判斷Connection: keep-alive決定是否長(zhǎng)連接對(duì)應(yīng)m_linger標(biāo)志Host: xxx用于日志或虛擬主機(jī)存到m_hostContent-Length: 數(shù)字決定請(qǐng)求體要讀多少字節(jié)存到m_content_lengthHTTP_CODE parse_headers(char* text) { if (strncasecmp(text, Connection:, 11) 0) { text 11; while (*text ) text; if (strncasecmp(text, keep-alive, 10) 0) { m_linger true; } } else if (strncasecmp(text, Content-Length:, 15) 0) { text 15; while (*text ) text; m_content_length atol(text); } else if (strncasecmp(text, Host:, 5) 0) { text 5; while (*text ) text; m_host text; } return NO_REQUEST; }注意這些比較都用了strncasecmp因?yàn)镠TTP頭字段名是大小寫(xiě)不敏感的實(shí)際請(qǐng)求里可能出現(xiàn)content-length:這種寫(xiě)法。我在自己調(diào)試時(shí)遇到過(guò)真實(shí)的客戶端發(fā)送大寫(xiě)字段名如果代碼用strcmp就會(huì)漏掉這是協(xié)議實(shí)現(xiàn)時(shí)很容易踩的坑。3.4 請(qǐng)求體的特殊處理方式請(qǐng)求體解析和前面不太一樣。請(qǐng)求行、請(qǐng)求頭都是“按行”處理的而請(qǐng)求體是純粹的字節(jié)流根本沒(méi)有行概念。所以主狀態(tài)機(jī)進(jìn)入CHECK_STATE_CONTENT之后不能再依賴從狀態(tài)機(jī)切行而是直接檢查當(dāng)前緩沖區(qū)里剩余的數(shù)據(jù)量是否達(dá)到Content-Length聲明的長(zhǎng)度。這一段邏輯的關(guān)鍵點(diǎn)在于如果數(shù)據(jù)還沒(méi)收完返回NO_REQUEST如果收完了從緩沖區(qū)里把請(qǐng)求體指針記錄下來(lái)再返回GET_REQUEST表示整個(gè)請(qǐng)求已經(jīng)解析完整。我見(jiàn)過(guò)不少人在面試時(shí)被問(wèn)到“GET和POST解析的差異”核心答案之一就在這里——GET沒(méi)有請(qǐng)求體讀完請(qǐng)求頭遇到空行就結(jié)束了POST必須再用Content-Length確認(rèn)請(qǐng)求體是否完整。3.5 整體解析流程串起來(lái)看把上面的細(xì)節(jié)串成一個(gè)流程就是經(jīng)典的process_read邏輯循環(huán) { 從狀態(tài)機(jī)切出一行 如果 LINE_OPEN返回 NO_REQUEST等待更多數(shù)據(jù) 如果 LINE_BAD返回 BAD_REQUEST 如果 LINE_OK主狀態(tài)機(jī)按當(dāng)前狀態(tài)處理這一行 }每次read事件觸發(fā)后都會(huì)先往m_read_buf里追加數(shù)據(jù)再調(diào)用process_read。如果返回NO_REQUEST說(shuō)明數(shù)據(jù)還沒(méi)齊連接繼續(xù)掛在epoll上等下一次事件。這個(gè)過(guò)程是可以無(wú)狀態(tài)反復(fù)進(jìn)入的也正因?yàn)檫@個(gè)設(shè)計(jì)才能用非阻塞IO處理海量連接。4. 響應(yīng)構(gòu)建與連接生命周期從解析結(jié)果到完整回包4.1 響應(yīng)報(bào)文是怎么拼出來(lái)的解析完請(qǐng)求只是上半場(chǎng)下半場(chǎng)是根據(jù)業(yè)務(wù)結(jié)果生成響應(yīng)報(bào)文。一個(gè)最小但完整的HTTP響應(yīng)長(zhǎng)這樣HTTP/1.1 200 OK\r\n Content-Type: text/html\r\n Content-Length: 1024\r\n Connection: keep-alive\r\n \r\n html...TinyWebServer的常見(jiàn)做法是維護(hù)一個(gè)寫(xiě)緩沖區(qū)m_write_buf用一系列add_*函數(shù)往里逐行追加內(nèi)容。比如add_status_line寫(xiě)入狀態(tài)行add_headers寫(xiě)入通用頭add_content寫(xiě)入正文。這個(gè)過(guò)程本質(zhì)上是責(zé)任鏈一樣的組裝流程先寫(xiě)狀態(tài)行再寫(xiě)每個(gè)頭字段最后寫(xiě)入正文。代碼風(fēng)格通常長(zhǎng)這樣add_status_line(200, OK); add_headers(1024); add_content(body, 1024);4.2 狀態(tài)碼與錯(cuò)誤頁(yè)面的處理策略小型Web服務(wù)器不需要實(shí)現(xiàn)所有狀態(tài)碼但至少要區(qū)分幾類典型結(jié)果請(qǐng)求成功200、請(qǐng)求格式錯(cuò)誤400、資源不存在404、禁止訪問(wèn)403、服務(wù)器內(nèi)部錯(cuò)誤500、方法不支持501。每一類都對(duì)應(yīng)一個(gè)標(biāo)準(zhǔn)的響應(yīng)文本例如狀態(tài)碼含義典型場(chǎng)景200OK請(qǐng)求成功并返回內(nèi)容400Bad RequestHTTP格式錯(cuò)誤解析返回BAD_REQUEST403Forbidden文件存在但權(quán)限不足404Not Found請(qǐng)求的文件或目錄不存在500Internal Error服務(wù)器內(nèi)部邏輯錯(cuò)誤501Not Implemented遇到未支持的方法這些狀態(tài)碼的定義最好集中放在一個(gè)枚舉或常量區(qū)響應(yīng)時(shí)統(tǒng)一映射——實(shí)際項(xiàng)目里我習(xí)慣于把狀態(tài)碼和響應(yīng)文本做成一個(gè)映射表避免散落各處的魔數(shù)。這不僅改了這里忘了那里更重要的是日志里能直接輸出可讀的響應(yīng)結(jié)果排查問(wèn)題快很多。4.3 keep-alive與短連接的生命周期管理Connection: keep-alive會(huì)影響連接處理完當(dāng)前請(qǐng)求后的去向。如果m_linger為真處理完一個(gè)請(qǐng)求后連接不關(guān)閉繼續(xù)掛在epoll上等待下一個(gè)請(qǐng)求如果m_linger為假響應(yīng)寫(xiě)完就主動(dòng)關(guān)閉連接。這里有個(gè)很容易忽略的細(xì)節(jié)長(zhǎng)連接下同一個(gè)連接可以連續(xù)發(fā)送多個(gè)請(qǐng)求HTTP pipelining不常見(jiàn)但連續(xù)請(qǐng)求是很常見(jiàn)的。所以處理完一個(gè)請(qǐng)求后要么把緩沖區(qū)里殘留的數(shù)據(jù)交給下一輪解析要么在寫(xiě)完后直接重置連接狀態(tài)。我在閱讀實(shí)際代碼時(shí)見(jiàn)過(guò)有人在這里出現(xiàn)邏輯漏洞——長(zhǎng)連接處理完第一個(gè)請(qǐng)求后就傻等新數(shù)據(jù)實(shí)際上第二個(gè)請(qǐng)求已經(jīng)跟著到了緩沖區(qū)里導(dǎo)致請(qǐng)求積壓。4.4 連接超時(shí)與資源回收HTTP機(jī)制不止是解析和響應(yīng)還要關(guān)注連接長(zhǎng)時(shí)間不活動(dòng)的問(wèn)題。常見(jiàn)做法是配合定時(shí)器每個(gè)連接記錄最近活躍時(shí)間每次讀寫(xiě)后更新定時(shí)器定期掃描發(fā)現(xiàn)超過(guò)閾值比如60秒的連接就主動(dòng)關(guān)閉同時(shí)釋放fd、從epoll移除、清理對(duì)應(yīng)的線程池任務(wù)。超時(shí)關(guān)閉和keep-alive是有微妙的配合關(guān)系的keep-alive是想讓連接多活一會(huì)兒以減少握手開(kāi)銷(xiāo)但如果沒(méi)有超時(shí)機(jī)制惡意客戶端占著連接不放就會(huì)把fd和線程資源耗盡。所以“可以長(zhǎng)連接但不能無(wú)限長(zhǎng)”這條原則是服務(wù)器開(kāi)發(fā)里一條寫(xiě)進(jìn)所有項(xiàng)目的基本紀(jì)律。5. 常見(jiàn)問(wèn)題與排查技巧實(shí)錄5.1 問(wèn)題速查表我在學(xué)習(xí)和調(diào)試這類代碼的過(guò)程中積累了一批出現(xiàn)頻率極高的問(wèn)題這里整理成速查表方便大家對(duì)照排查?,F(xiàn)象可能原因排查方向請(qǐng)求頭解析后字段為空字段名比較時(shí)大小寫(xiě)不敏感沒(méi)處理好檢查是否用了strcasecmp/strncasecmp發(fā)完P(guān)OST請(qǐng)求服務(wù)器一直不響應(yīng)緩沖區(qū)里請(qǐng)求體沒(méi)收全一直卡在NO_REQUEST檢查Content-Length是否正確解析確認(rèn)請(qǐng)求體讀取邏輯響應(yīng)返回后客戶端還在等結(jié)果響應(yīng)頭缺少Content-Length或Connection未正確設(shè)置檢查響應(yīng)組裝里頭字段是否完整長(zhǎng)連接處理完第一個(gè)請(qǐng)求后第二個(gè)請(qǐng)求失效緩沖區(qū)殘留數(shù)據(jù)沒(méi)被正確處理檢查reset或下一輪解析的起始索引高并發(fā)下出現(xiàn)錯(cuò)亂的響應(yīng)多線程下多個(gè)連接共用了同一個(gè)緩沖區(qū)或解析狀態(tài)檢查連接對(duì)象是否每個(gè)fd獨(dú)立持有解析狀態(tài)文件內(nèi)容讀出來(lái)了但響應(yīng)亂碼二進(jìn)制文件在解析中遇到\0被截?cái)鄼z查讀取文件時(shí)是否以二進(jìn)制方式讀取并記錄真實(shí)長(zhǎng)度5.2 一個(gè)實(shí)際的“粘包”排查案例有一次我自己調(diào)試一個(gè)模擬項(xiàng)目時(shí)用Python腳本連續(xù)發(fā)送兩個(gè)請(qǐng)求到同一個(gè)連接上服務(wù)器總是只處理第一個(gè)第二個(gè)請(qǐng)求完全沒(méi)反應(yīng)。起初懷疑是epoll事件丟了花了不少時(shí)間查事件循環(huán)后來(lái)在緩沖區(qū)索引上定位到了問(wèn)題第一個(gè)請(qǐng)求解析完后m_checked_idx和m_read_idx沒(méi)有正確調(diào)整到下一個(gè)請(qǐng)求的起始位置導(dǎo)致第二個(gè)請(qǐng)求的數(shù)據(jù)被當(dāng)成殘余數(shù)據(jù)一直沒(méi)進(jìn)入解析流程。解決辦法是在每次請(qǐng)求處理完成后把緩沖區(qū)里未消費(fèi)的字節(jié)整體前移并用解析后的新起始位置作為下一輪掃描起點(diǎn)。這種問(wèn)題最難的不是修而是復(fù)現(xiàn)——單發(fā)一個(gè)請(qǐng)求永遠(yuǎn)測(cè)不出來(lái)必須構(gòu)造連續(xù)請(qǐng)求的腳本才行。所以我的經(jīng)驗(yàn)是調(diào)試HTTP解析器一定要準(zhǔn)備一個(gè)能發(fā)送連續(xù)請(qǐng)求、支持自定義報(bào)文的測(cè)試腳本而不是只用瀏覽器訪問(wèn)。5.3 用日志和斷言定位解析狀態(tài)讀這類源碼時(shí)最有效的調(diào)試手段其實(shí)不是打斷點(diǎn)而是日志。我習(xí)慣在三個(gè)關(guān)鍵位置打日志每次read后打印本次讀取的字節(jié)數(shù)和緩沖區(qū)總長(zhǎng)度每次從狀態(tài)機(jī)切出一行后打印這行的原始內(nèi)容每次主狀態(tài)機(jī)狀態(tài)切換時(shí)打印新的狀態(tài)有了這三行日志一個(gè)請(qǐng)求從進(jìn)入到處理完的完整鏈路就一目了然。遇到解析異常時(shí)對(duì)比日志里緩沖區(qū)內(nèi)容和HTTP報(bào)文的預(yù)期格式通常幾秒鐘就能看出是哪一步的問(wèn)題。這個(gè)習(xí)慣在我們面試討論項(xiàng)目時(shí)也經(jīng)常被問(wèn)到因?yàn)樗w現(xiàn)的不只是會(huì)用而是真的理解狀態(tài)流轉(zhuǎn)。6. 閱讀這份源碼后的幾點(diǎn)個(gè)人體會(huì)6.1 狀態(tài)機(jī)的設(shè)計(jì)比想象中更通用讀完TinyWebServer的HTTP解析代碼我發(fā)現(xiàn)狀態(tài)機(jī)的思路絕不只適用于HTTP。任何需要處理“流式輸入、分塊解析”的場(chǎng)景——比如JSON流式解析、自定義TCP協(xié)議拆包、WebSocket幀解析——都可以套用這套主從狀態(tài)機(jī)的設(shè)計(jì)。核心思想都是一樣解析方不假設(shè)一次拿到完整數(shù)據(jù)而是通過(guò)狀態(tài)記住當(dāng)前進(jìn)度來(lái)一段處理一段。6.2 緩沖區(qū)管理是隱藏的主角HTTP解析表面上是狀態(tài)機(jī)在驅(qū)動(dòng)實(shí)際上是緩沖區(qū)在兜底。所有的指針、索引、狀態(tài)轉(zhuǎn)移都在圍繞同一個(gè)問(wèn)題如何高效地管理一塊可能裝不滿、可能溢出、可能殘留多種含義數(shù)據(jù)的字節(jié)數(shù)組。當(dāng)時(shí)我做模擬項(xiàng)目時(shí)試著把緩沖區(qū)從1KB改成4KB發(fā)現(xiàn)解析邏輯完全不用動(dòng)這就是緩沖區(qū)與狀態(tài)機(jī)解耦帶來(lái)的好處。6.3 可以繼續(xù)往哪些方向擴(kuò)展這個(gè)HTTP機(jī)制讀懂了后面能擴(kuò)展的方向其實(shí)很多。最簡(jiǎn)單的把解析器擴(kuò)展成支持HEAD和OPTIONS方法稍微復(fù)雜點(diǎn)給響應(yīng)頭加上Cache-Control之類的緩存控制字段再往上走可以移植到支持HTTPS或者實(shí)現(xiàn)HTTP/2的幀解析。甚至可以把解析模塊單獨(dú)抽出來(lái)做成一個(gè)庫(kù)核心的狀態(tài)機(jī)框架完全不需要大改。我自己在后續(xù)做某個(gè)跨平臺(tái)系統(tǒng)時(shí)就把這套狀態(tài)機(jī)思路套到了一個(gè)自定義二進(jìn)制協(xié)議上省了非常多事。所以如果你還在為搞不懂這幾個(gè)狀態(tài)糾結(jié)放寬心敲兩遍、跑幾個(gè)用例這個(gè)坎過(guò)去了后面整個(gè)網(wǎng)絡(luò)編程的地基就算打牢了。