HTTP GET客戶(hù)端:socket、超時(shí)與chunked解析)
簡(jiǎn)介面向C網(wǎng)絡(luò)編程入門(mén)者的輕量級(jí)HTTP GET請(qǐng)求實(shí)現(xiàn)示例聚焦在Windows環(huán)境下從指定URL拉取數(shù)據(jù)的最小可用路徑。代碼將請(qǐng)求發(fā)起、響應(yīng)獲取與耗時(shí)統(tǒng)計(jì)拆分組織借助頭文件聲明接口、源文件承載連接建立與數(shù)據(jù)讀取邏輯并以計(jì)時(shí)器記錄請(qǐng)求執(zhí)行時(shí)間便于理解一次HTTP GET從構(gòu)造到回收的基本流程也可作為課程實(shí)驗(yàn)或小型工具原型的基礎(chǔ)骨架并保留較清晰的調(diào)用關(guān)系。壓縮包共3個(gè)文件由2個(gè)頭文件和1個(gè)C源文件組成整體約2KB體量精簡(jiǎn)適合快速通讀與二次修改。目前已有268人學(xué)習(xí)說(shuō)明該主題對(duì)初學(xué)者具有一定參考價(jià)值。讀者可借此理解請(qǐng)求封裝、響應(yīng)讀取與性能計(jì)時(shí)并據(jù)此補(bǔ)充超時(shí)、重定向、HTTPS與代理等擴(kuò)展能力逐步形成更健壯的網(wǎng)絡(luò)通信模塊。1. 問(wèn)題往往出在請(qǐng)求根本沒(méi)發(fā)出去線(xiàn)上排查一個(gè)接口問(wèn)題服務(wù)端日志里干干凈凈一條請(qǐng)求都沒(méi)收到客戶(hù)端那邊只回了一句“超時(shí)”。我翻出很早以前寫(xiě)的一個(gè) http GET C 實(shí)現(xiàn)不到三百行只用系統(tǒng) socket 和標(biāo)準(zhǔn)庫(kù)兩分鐘就把問(wèn)題定在了域名解析上。這個(gè) http GET C 實(shí)現(xiàn)干的事很單一給一個(gè) URL拼出 GET 請(qǐng)求收完響應(yīng)把狀態(tài)碼、響應(yīng)頭和正文交回來(lái)。它適合三類(lèi)人想搞明白 HTTP 在網(wǎng)絡(luò)上究竟發(fā)了什么字節(jié)的初學(xué)者需要在命令行、嵌入式或者離線(xiàn)環(huán)境里做最小網(wǎng)絡(luò)訪問(wèn)的開(kāi)發(fā)者以及手里有老項(xiàng)目、不想為了一個(gè)下載功能就拖進(jìn)整套網(wǎng)絡(luò)庫(kù)的人。下面按我實(shí)際拆它的順序講先看邊界在哪再看目錄和編譯然后逐段過(guò)核心代碼最后落到怎么把它改成能進(jìn)項(xiàng)目的組件。2. 項(xiàng)目定位與技術(shù)選型只用標(biāo)準(zhǔn)庫(kù)和系統(tǒng) socket 的最小可用方案2.1 能力邊界先劃清楚這類(lèi)小項(xiàng)目最怕定位模糊什么都能干最后什么都干不好。我拆的這個(gè)版本把邊界壓得很死只支持明文 HTTP/1.1只支持 GET一次請(qǐng)求一條連接不自動(dòng)跟隨重定向不自動(dòng)解壓 gzip。這些不是能力缺失是為了讓主流程能被完整讀完——一個(gè)剛接觸網(wǎng)絡(luò)編程的人順著一個(gè)文件從頭讀到尾就能跑通。先把這個(gè)版本能做什么、不能做什么列清楚比后面看代碼更重要。很多人在這一步就誤解了項(xiàng)目的能力接上 HTTPS 域名調(diào)半天沒(méi)反應(yīng)回頭才發(fā)現(xiàn)協(xié)議壓根不對(duì)。能力項(xiàng)當(dāng)前實(shí)現(xiàn)備注協(xié)議HTTP/1.1 明文走 80 端口不涉及 TLS方法僅 GET加 POST 只需改請(qǐng)求行和補(bǔ) body連接一次一連接Connection: close不做 keep-alive 連接池重定向不跟隨301/302 直接原樣返回壓縮不聲明 gzip收到什么給什么請(qǐng)求頭里寫(xiě) Accept-Encoding: identity編碼原樣輸出不做轉(zhuǎn)碼中文頁(yè)面可能是 GBK依賴(lài)部分很關(guān)鍵Linux 和 macOS 上什么都不用鏈POSIX socket 在 libc 里Windows 上要額外鏈 ws2_32并且在程序啟動(dòng)時(shí)調(diào)一次 WSAStartup、退出時(shí)調(diào) WSACleanup這兩步漏了就是一堆找不到符號(hào)的鏈接錯(cuò)誤。2.2 目錄結(jié)構(gòu)與構(gòu)建方式源文件按職責(zé)切了四塊加上一個(gè)入口。這樣切的好處是每個(gè)文件都能單獨(dú)看懂改起來(lái)也方便想加 POST 只動(dòng) request.cpp想加 chunked 解碼只動(dòng) response.cpp。tiny-http/ ├── include/ │ └── http_client.h # 對(duì)外結(jié)構(gòu)體與函數(shù)聲明 ├── src/ │ ├── url.cpp # URL 拆解scheme / host / port / path │ ├── socket.cpp # DNS 解析、帶超時(shí)的 connect │ ├── request.cpp # 拼裝請(qǐng)求報(bào)文并發(fā)送 │ ├── response.cpp # 收數(shù)據(jù)、解析狀態(tài)行與響應(yīng)頭、chunked 解碼 │ └── main.cpp # 命令行入口 ├── CMakeLists.txt └── README.md對(duì)應(yīng)這份結(jié)構(gòu)的 CMakeLists.txt 我一般這么寫(xiě)靜態(tài)庫(kù)加可執(zhí)行文件兩段跨平臺(tái)時(shí)在 Windows 分支補(bǔ)鏈接項(xiàng)cmake_minimum_required(VERSION 3.10) project(tiny_http CXX) set(CMAKE_CXX_STANDARD 11) set(CMAKE_CXX_STANDARD_REQUIRED ON) add_library(tinyhttp STATIC src/url.cpp src/socket.cpp src/request.cpp src/response.cpp ) target_include_directories(tinyhttp PUBLIC include) add_executable(httpget src/main.cpp) target_link_libraries(httpget PRIVATE tinyhttp) # Windows 下 socket 相關(guān)符號(hào)在 ws2_32 里不鏈必然報(bào)未定義引用 if (WIN32) target_link_libraries(tinyhttp PUBLIC ws2_32) endif()add_library(... STATIC)把四個(gè)實(shí)現(xiàn)文件打成一個(gè)靜態(tài)庫(kù)target_include_directories用 PUBLIC 把 include 目錄透出去后面加別的可執(zhí)行文件比如測(cè)試程序時(shí)不用重復(fù)寫(xiě)路徑。編譯就兩條命令cmake -B build然后cmake --build build產(chǎn)物在 build 目錄下的 httpget。2.3 一次 GET 的完整鏈路把整個(gè)流程拆成六步每一步都有明確的失敗點(diǎn)排查的時(shí)候直接對(duì)著位置找解析 URL切出 host、port、path。失敗點(diǎn)端口沒(méi)填、路徑以//開(kāi)頭、URL 里帶空格沒(méi)轉(zhuǎn)義。調(diào) getaddrinfo 做 DNS 解析拿到 sockaddr 鏈表。失敗點(diǎn)域名寫(xiě)錯(cuò)、本地 DNS 不通、IPv6 優(yōu)先但目標(biāo)沒(méi) AAAA 記錄。建 socket 并 connect。失敗點(diǎn)目標(biāo)端口沒(méi)開(kāi)、防火墻攔截、連接超時(shí)沒(méi)設(shè)。拼請(qǐng)求報(bào)文send 出去。失敗點(diǎn)Host 頭漏了服務(wù)端直接 400只發(fā)了一半數(shù)據(jù)。recv 循環(huán)收滿(mǎn)響應(yīng)。失敗點(diǎn)只 recv 一次正文少一截。解析狀態(tài)行、響應(yīng)頭、正文。失敗點(diǎn)\r\n處理不干凈頭部鍵大小寫(xiě)沒(méi)歸一。這六步里前兩步占了我遇到問(wèn)題的絕大多數(shù)。尤其是第二步DNS 解析是同步阻塞的沒(méi)有超時(shí)參數(shù)網(wǎng)絡(luò)環(huán)境一差就卡在那兒進(jìn)程看著像死了其實(shí)是卡在 getaddrinfo 里面——這是真正的黑匣子環(huán)節(jié)日志里什么都看不到。3. 核心代碼拆解從 URL 解析到 socket 連接3.1 URL 解析把字符串切成四段一切從字符串開(kāi)始。這段代碼看著簡(jiǎn)單實(shí)際上 IPv6 地址和端口缺省這兩個(gè)坑能把人折騰很久我寫(xiě)的時(shí)候?qū)iT(mén)把方括號(hào)分支留了出來(lái)。#include string #include cctype #include cstdlib struct Url { std::string scheme; // http std::string host; // 域名或 IPIPv6 不帶方括號(hào) std::string port; // 缺省時(shí)按 scheme 補(bǔ) 80/443 std::string path; // 含查詢(xún)串至少是 / }; static bool parse_url(const std::string raw, Url out) { std::string s raw; // 1. 拆 scheme沒(méi)有就按 http 處理 auto pos s.find(://); if (pos ! std::string::npos) { out.scheme s.substr(0, pos); for (auto c : out.scheme) c (char)std::tolower(c); s s.substr(pos 3); } else { out.scheme http; } // 2. 第一個(gè) / 之后全是路徑?jīng)]寫(xiě)就補(bǔ) / auto slash s.find(/); std::string hostport (slash std::string::npos) ? s : s.substr(0, slash); out.path (slash std::string::npos) ? / : s.substr(slash); // 3. 拆 host 和 portIPv6 形如 [::1]:8080 if (!hostport.empty() hostport[0] [) { auto rb hostport.find(]); if (rb std::string::npos) return false; out.host hostport.substr(1, rb - 1); if (rb 1 hostport.size() hostport[rb 1] :) out.port hostport.substr(rb 2); } else { auto colon hostport.rfind(:); if (colon ! std::string::npos) { out.host hostport.substr(0, colon); out.port hostport.substr(colon 1); } else { out.host hostport; } } if (out.host.empty()) return false; if (out.port.empty()) out.port (out.scheme https) ? 443 : 80; return true; }幾個(gè)點(diǎn)值得單獨(dú)說(shuō)。path在沒(méi)寫(xiě)的時(shí)候必須補(bǔ)成/因?yàn)?HTTP 請(qǐng)求行里GET HTTP/1.1是非法請(qǐng)求很多服務(wù)端直接回 400我第一次跑就栽在這兒。IPv6 分支里我存的是去掉方括號(hào)的地址因?yàn)?getaddrinfo 認(rèn)的是::1而不是[::1]。端口用rfind而不是find是為了兼容host:port后面還可能跟別的東西的情況雖然這個(gè)版本用不上但改成rfind不增加成本。參數(shù)out是引用輸出函數(shù)只返回成功與否這樣調(diào)用側(cè)不需要處理指針為空也不用考慮異常。返回值 false 的三種情況——方括號(hào)沒(méi)閉合、host 為空、整體解析不出來(lái)——在 main 里統(tǒng)一打一行 usage 提示就夠了。3.2 DNS 解析與連接超時(shí)是必須的getaddrinfo 一次拿全地址族和端口比自己調(diào) gethostbyname 再拼 sockaddr_in 靠譜得多而且后者在現(xiàn)代代碼里已經(jīng)不該出現(xiàn)。連接超時(shí)我沒(méi)用SO_SNDTIMEO那玩意兒對(duì) connect 不生效正確做法是把 socket 設(shè)成非阻塞、connect 之后用 select 等可寫(xiě)。#include sys/socket.h #include netdb.h #include unistd.h #include fcntl.h #include sys/select.h #include cerrno #include cstring int connect_with_timeout(const std::string host, const std::string port, int timeout_ms, std::string err) { struct addrinfo hints; std::memset(hints, 0, sizeof(hints)); hints.ai_family AF_UNSPEC; // IPv4 / IPv6 都接受 hints.ai_socktype SOCK_STREAM; // TCP struct addrinfo* res nullptr; int rc getaddrinfo(host.c_str(), port.c_str(), hints, res); if (rc ! 0) { err gai_strerror(rc); return -1; } int fd -1; for (auto p res; p ! nullptr; p p-ai_next) { fd socket(p-ai_family, p-ai_socktype, p-ai_protocol); if (fd 0) continue; int flags fcntl(fd, F_GETFL, 0); fcntl(fd, F_SETFL, flags | O_NONBLOCK); rc connect(fd, p-ai_addr, p-ai_addrlen); if (rc 0) { fcntl(fd, F_SETFL, flags); break; } // 本機(jī)地址立即成功 if (errno ! EINPROGRESS) { close(fd); fd -1; continue; } fd_set wfds; FD_ZERO(wfds); FD_SET(fd, wfds); struct timeval tv; tv.tv_sec timeout_ms / 1000; tv.tv_usec (timeout_ms % 1000) * 1000; rc select(fd 1, nullptr, wfds, nullptr, tv); if (rc 0) { int soerr 0; socklen_t len sizeof(soerr); getsockopt(fd, SOL_SOCKET, SO_ERROR, soerr, len); if (soerr 0) { fcntl(fd, F_SETFL, flags); break; } // 恢復(fù)阻塞 } close(fd); fd -1; } freeaddrinfo(res); // addrinfo 必須配對(duì)釋放 if (fd 0) err connect failed or timeout; return fd; }hints.ai_family寫(xiě)AF_UNSPEC讓系統(tǒng)自己決定走 IPv4 還是 IPv6用AF_INET硬編碼在純 IPv6 環(huán)境里直接連不上。循環(huán)遍歷ai_next是重點(diǎn)一個(gè)域名可能解析出多個(gè)地址第一個(gè)不通要試第二個(gè)直接取res頭節(jié)點(diǎn)是新手常見(jiàn)寫(xiě)法。select返回大于 0 只說(shuō)明“有事件”不代表連接成功必須用getsockopt(SO_ERROR)再確認(rèn)一次這一步漏了會(huì)出現(xiàn)“連接明明失敗但程序往下走”的詭異現(xiàn)象。連接成功后把O_NONBLOCK恢復(fù)掉后面 recv 用阻塞模式邏輯簡(jiǎn)單。timeout_ms我一般設(shè) 5000內(nèi)網(wǎng)環(huán)境可以降到 1000。3.3 拼請(qǐng)求報(bào)文CRLF、Host 頭、Connection請(qǐng)求報(bào)文就是一段字符串但每個(gè)換行必須是\r\n兩個(gè)字節(jié)少一個(gè)\r服務(wù)端就可能把整個(gè)請(qǐng)求當(dāng)非法處理。std::string build_request(const Url u) { std::string req; req GET u.path HTTP/1.1\r\n; req Host: u.host \r\n; req User-Agent: tiny-http/1.0\r\n; req Accept: */*\r\n; req Accept-Encoding: identity\r\n; // 明確不要壓縮省掉解壓邏輯 req Connection: close\r\n; // 服務(wù)端回完即關(guān)讀起來(lái)不用判長(zhǎng)度 req \r\n; // 空行報(bào)文結(jié)束標(biāo)志 return req; }Host在 HTTP/1.1 里是必填項(xiàng)虛擬主機(jī)靠它區(qū)分后端站點(diǎn)漏了大概率回 400。Accept-Encoding: identity是我故意的不聲明 gzip服務(wù)端就不會(huì)壓客戶(hù)端也就不需要鏈接解壓庫(kù)整個(gè)依賴(lài)又少一層。Connection: close讓服務(wù)端發(fā)完就關(guān)連接recv 讀到 0 就代表結(jié)束不用自己算 Content-Length是這類(lèi)小工具最省心的寫(xiě)法。發(fā)送時(shí)有個(gè)細(xì)節(jié)send的返回值可能小于請(qǐng)求長(zhǎng)度尤其是請(qǐng)求頭很大或者網(wǎng)絡(luò)擁塞時(shí)。小請(qǐng)求一般一次發(fā)完但我習(xí)慣寫(xiě)成循環(huán)避免偶發(fā)的半包問(wèn)題size_t sent 0; while (sent req.size()) { ssize_t n send(fd, req.data() sent, req.size() - sent, 0); if (n 0) { /* 記錄錯(cuò)誤并退出 */ break; } sent (size_t)n; }4. 響應(yīng)讀取與解析狀態(tài)行、頭部、正文三段式4.1 收數(shù)據(jù)recv 循環(huán)與內(nèi)存上限一次 recv 拿到的可能只是響應(yīng)頭的一部分也可能是整個(gè)響應(yīng)加后半截正文絕不能假設(shè)一次收完。寫(xiě)法就是循環(huán)拼接到對(duì)端關(guān)閉為止同時(shí)加一個(gè)上限防止碰上超大文件把內(nèi)存吃光。std::string recv_all(int fd, size_t max_bytes 8 * 1024 * 1024) { std::string buf; char tmp[8192]; while (true) { ssize_t n recv(fd, tmp, sizeof(tmp), 0); if (n 0) { buf.append(tmp, (size_t)n); if (buf.size() max_bytes) break; // 保護(hù)性截?cái)?} else if (n 0) { break; // 對(duì)端關(guān)閉正常結(jié)束 } else { if (errno EINTR) continue; // 被信號(hào)打斷重試 break; // 超時(shí)或真錯(cuò)誤 } } return buf; }緩沖區(qū) 8192 是個(gè)經(jīng)驗(yàn)值太小系統(tǒng)調(diào)用次數(shù)多太大棧上占用高。EINTR那行必須留著某些環(huán)境下信號(hào)會(huì)打斷 recv不重試就會(huì)拿到半截?cái)?shù)據(jù)還以為收完了。max_bytes默認(rèn) 8MB超過(guò)就停返回的內(nèi)容雖然不完整但程序不會(huì)崩——下載大文件應(yīng)該走邊收邊寫(xiě)的路子不該用這個(gè)函數(shù)。另外如果 socket 上設(shè)了SO_RCVTIMEO超時(shí)后 recv 返回 -1 且 errno 為EAGAIN或EWOULDBLOCK這里會(huì)走到 break調(diào)用方看到的是“短響應(yīng)”需要自己根據(jù)狀態(tài)判斷是超時(shí)還是正常結(jié)束。4.2 解析狀態(tài)行與響應(yīng)頭響應(yīng)分成三段狀態(tài)行、若干頭部行、空行之后的正文。用\r\n\r\n定位頭部結(jié)束位置是最穩(wěn)的做法比逐行掃描簡(jiǎn)單。#include map #include sstream #include algorithm struct Response { int status 0; std::string reason; std::mapstd::string, std::string headers; // 鍵統(tǒng)一轉(zhuǎn)小寫(xiě) std::string body; }; static std::string to_lower(std::string s) { std::transform(s.begin(), s.end(), s.begin(), [](unsigned char c) { return (char)std::tolower(c); }); return s; } static std::string trim(const std::string s) { size_t b s.find_first_not_of( \t); if (b std::string::npos) return ; size_t e s.find_last_not_of( \t\r); return s.substr(b, e - b 1); } bool parse_response(const std::string raw, Response resp) { auto head_end raw.find(\r\n\r\n); if (head_end std::string::npos) return false; // 響應(yīng)頭都不完整 std::istringstream ss(raw.substr(0, head_end)); std::string line; std::getline(ss, line); // 狀態(tài)行 if (!line.empty() line.back() \r) line.pop_back(); auto sp1 line.find( ); // HTTP/1.1 200 OK if (sp1 std::string::npos) return false; auto sp2 line.find( , sp1 1); resp.status std::atoi(line.substr(sp1 1, sp2 - sp1 - 1).c_str()); if (sp2 ! std::string::npos) resp.reason line.substr(sp2 1); while (std::getline(ss, line)) { // 逐個(gè)頭字段 if (!line.empty() line.back() \r) line.pop_back(); if (line.empty()) continue; auto colon line.find(:); if (colon std::string::npos) continue; resp.headers[to_lower(trim(line.substr(0, colon)))] trim(line.substr(colon 1)); } resp.body raw.substr(head_end 4); // 剩下的就是正文 return true; }頭部鍵統(tǒng)一轉(zhuǎn)小寫(xiě)是關(guān)鍵設(shè)計(jì)。RFC 里頭部名不區(qū)分大小寫(xiě)實(shí)際服務(wù)端有的發(fā)Content-Type有的發(fā)content-type不歸一的話(huà)查詢(xún)會(huì)落空。trim里順帶把\r也去掉因?yàn)槟承?getline 實(shí)現(xiàn)會(huì)把\r留在行尾。resp.body這里直接取剩余全部字節(jié)前提是用了Connection: close。如果以后改成 keep-alive這里就得改成按Content-Length精確切否則會(huì)把下一個(gè)響應(yīng)也吃進(jìn)來(lái)。4.3 正文邊界Content-Length 與 chunked大部分響應(yīng)帶Content-Length但有一類(lèi)響應(yīng)會(huì)帶Transfer-Encoding: chunked正文被切成一塊塊每塊前面是十六進(jìn)制的長(zhǎng)度。不處理的話(huà)輸出里會(huì)混進(jìn)1a2b\r\n這種字符這是最容易被誤判成“亂碼”的坑。// in: 原始正文含 chunk 頭out: 解碼后的凈正文 bool dechunk(const std::string in, std::string out) { size_t pos 0; out.clear(); while (pos in.size()) { auto eol in.find(\r\n, pos); if (eol std::string::npos) return false; std::string size_line in.substr(pos, eol - pos); auto semi size_line.find(;); // 可能有 chunk 擴(kuò)展參數(shù) if (semi ! std::string::npos) size_line size_line.substr(0, semi); size_t chunk_size std::strtoul(size_line.c_str(), nullptr, 16); pos eol 2; if (chunk_size 0) return true; // 結(jié)束塊 if (pos chunk_size in.size()) return false; // 數(shù)據(jù)還沒(méi)收全 out.append(in, pos, chunk_size); pos chunk_size 2; // 跳過(guò)數(shù)據(jù)尾巴的 \r\n } return false; }strtoul第三個(gè)參數(shù)寫(xiě) 16 表示按十六進(jìn)制解析長(zhǎng)度字段本來(lái)就是 hex。返回 false 有兩種含義數(shù)據(jù)沒(méi)收全或者格式不對(duì)調(diào)用側(cè)可以選擇再 recv 一輪或者直接報(bào)錯(cuò)。結(jié)束塊之后理論上還可能跟 trailer 頭這個(gè)版本直接忽略夠用。調(diào)用順序很講究先解析頭部再判斷Transfer-Encoding是否含chunked含才走解碼否則原樣輸出。判斷時(shí)要把值轉(zhuǎn)小寫(xiě)再find因?yàn)橛械姆?wù)端寫(xiě)Chunked。5. 排查清單把程序跑通前大概率會(huì)遇到的五個(gè)坑5.1 程序卡住不返回連錯(cuò)誤都沒(méi)有現(xiàn)象命令行敲下去光標(biāo)一直閃幾分鐘都不動(dòng)CtrlC 才能退出日志里一個(gè)字都沒(méi)有。原因兩條路都可能阻塞——getaddrinfo 不受任何超時(shí)控制DNS 服務(wù)器不通就會(huì)卡在這兒connect 在默認(rèn)阻塞模式下目標(biāo)不可達(dá)時(shí)會(huì)等系統(tǒng)級(jí)超時(shí)動(dòng)輒一兩個(gè)分鐘。這兩個(gè)位置都不打日志看上去就是“死機(jī)”。解決連接超時(shí)按 3.2 節(jié)用非阻塞加 select 實(shí)現(xiàn)日志在解析前后各打一行就能立刻看出卡在哪一步。getaddrinfo 本身沒(méi)法設(shè)超時(shí)工程上的做法是把它扔到獨(dú)立線(xiàn)程里主線(xiàn)程等結(jié)果超過(guò)閾值就放棄——雖然線(xiàn)程收不回來(lái)但至少主流程能繼續(xù)。5.2 服務(wù)端返回 400但請(qǐng)求看起來(lái)沒(méi)問(wèn)題現(xiàn)象自己拼的請(qǐng)求發(fā)出去回來(lái)的狀態(tài)碼是 400把同樣的 URL 丟瀏覽器里完全正常。原因九成是Host頭缺失或者請(qǐng)求行里的路徑?jīng)]寫(xiě)全。HTTP/1.1 強(qiáng)制要求 Host虛擬主機(jī)環(huán)境下沒(méi)有 Host 服務(wù)端不知道該給哪個(gè)站點(diǎn)。另一種情況是路徑寫(xiě)成了空字符串請(qǐng)求行變成GET HTTP/1.1兩個(gè)空格連著解析直接失敗。解決把實(shí)際發(fā)出的報(bào)文用十六進(jìn)制方式打出來(lái)看重點(diǎn)檢查每一行結(jié)尾是不是\r\n兩個(gè)字節(jié)、空行有沒(méi)有、Host 值是不是純域名不帶http://前綴和端口以外的多余字符。5.3 正文比預(yù)期少一截或者末尾混進(jìn)奇怪字符現(xiàn)象抓下來(lái)的 HTML 尾部缺了幾個(gè)標(biāo)簽或者正文里出現(xiàn)3f\r\n這類(lèi)十六進(jìn)制字符串。原因前者是只調(diào)用了一次 recv拿到的只是第一段數(shù)據(jù)后者是響應(yīng)用了 chunked 編碼但沒(méi)解碼長(zhǎng)度行被當(dāng)成正文輸出了。解決recv 必須循環(huán)到返回 0同時(shí)看響應(yīng)頭里有沒(méi)有Transfer-Encoding: chunked。加日志把每次 recv 的字節(jié)數(shù)和累計(jì)長(zhǎng)度打出來(lái)一眼就能看出是提前退出還是解碼漏了。5.4 編譯過(guò)了鏈接階段一堆未定義符號(hào)現(xiàn)象Linux 下編譯運(yùn)行正常拿到 Windows 上編譯通過(guò)鏈接報(bào)找不到socket、connect、getaddrinfo之類(lèi)。原因Windows 的 socket API 在 ws2_32 庫(kù)和 winsock2.h 里和 POSIX 不是一套而且進(jìn)程啟動(dòng)時(shí)必須調(diào)WSAStartup否則所有 socket 調(diào)用直接失敗。解決頭文件包含改成條件編譯Windows 走winsock2.h并鏈ws2_32同時(shí)在 main 開(kāi)頭調(diào)WSAStartup(MAKEWORD(2, 2), wsa)退出前調(diào)WSACleanup()。注意包含順序winsock2.h必須在windows.h前面否則會(huì)帶進(jìn)老的 winsock1 定義。5.5 中文頁(yè)面抓下來(lái)是亂碼現(xiàn)象英文頁(yè)面一切正常中文頁(yè)面輸出成一片問(wèn)號(hào)或者奇怪的符號(hào)組合。原因這個(gè)實(shí)現(xiàn)把字節(jié)原樣輸出不做任何轉(zhuǎn)碼。如果響應(yīng)頭里Content-Type帶的是charsetgbk而終端按 UTF-8 顯示就必然亂碼。解決先看Content-Type里的 charset再?zèng)Q定怎么處理。最省事的做法是在請(qǐng)求頭里加Accept-Language并優(yōu)先挑 UTF-8 的站點(diǎn)真要做轉(zhuǎn)換就得引入編碼轉(zhuǎn)換庫(kù)那已經(jīng)超出這個(gè)項(xiàng)目的最小定位了我一般建議在調(diào)用側(cè)解決。6. 二次開(kāi)發(fā)把它收斂成一個(gè)能進(jìn)項(xiàng)目的類(lèi)單文件 main 跑通之后下一步一定是包裝。我的做法是把 fd 的生命周期收進(jìn) RAII 類(lèi)里外面只看到一個(gè)get(url)接口錯(cuò)誤用返回值加錯(cuò)誤串往外拋不折騰異常。class HttpClient { public: explicit HttpClient(int timeout_ms 5000) : timeout_ms_(timeout_ms) {} bool get(const std::string url, Response resp, std::string err) { Url u; if (!parse_url(url, u)) { err bad url; return false; } if (u.scheme ! http) { err scheme not supported; return false; } int fd connect_with_timeout(u.host, u.port, timeout_ms_, err); if (fd 0) return false; bool ok false; std::string req build_request(u); if (send_all(fd, req)) { // 內(nèi)部循環(huán)處理短寫(xiě) std::string raw recv_all(fd); ok parse_response(raw, resp); if (ok is_chunked(resp)) ok dechunk(resp.body, resp.body); // 成功后原地替換 } else { err send failed; } close(fd); // 析構(gòu)前統(tǒng)一關(guān)閉 return ok; } private: int timeout_ms_; };封裝時(shí)有三個(gè)點(diǎn)別省。第一close(fd)一定要在所有分支上都覆蓋到我見(jiàn)過(guò)太多在錯(cuò)誤分支直接 return 導(dǎo)致句柄泄漏的代碼跑久了文件描述符耗盡報(bào)的錯(cuò)還跟網(wǎng)絡(luò)毫無(wú)關(guān)系。第二send_all內(nèi)部循環(huán)處理短寫(xiě)把這段從業(yè)務(wù)邏輯里挪出去。第三dechunk支持原地替換時(shí)要小心實(shí)現(xiàn)簡(jiǎn)單起見(jiàn)可以先解到臨時(shí)串再 swap。改完之后驗(yàn)證方法很樸素拿幾個(gè)已知行為的地址跑一遍——一個(gè)返回固定長(zhǎng)度正文的、一個(gè)返回 chunked 的、一個(gè)會(huì) 302 跳轉(zhuǎn)的、一個(gè)不存在的域名。四個(gè)用例覆蓋了正常路徑和三類(lèi)異常每次改動(dòng)后全跑一遍比盯著代碼看有效得多。擴(kuò)展路線(xiàn)按需要挑別一次全加需求改動(dòng)位置大致工作量支持 POSTrequest.cpp 加請(qǐng)求行和 Content-Length半小時(shí)自動(dòng)跟隨 302外層循環(huán)最多跟 5 跳一小時(shí)支持 HTTPS連接層換成 TLS 握手端口改 443半天起大文件下載recv 改成邊收邊寫(xiě)文件一小時(shí)連接復(fù)用加連接池解析改按 Content-Length 切一天最后說(shuō)個(gè)血淚經(jīng)驗(yàn)。這套代碼我前后改過(guò)三四版每次都是圖快跳過(guò)“把請(qǐng)求報(bào)文原樣打出來(lái)看一眼”這一步結(jié)果在 Host 頭、路徑斜杠、CRLF 這些地方反復(fù)翻車(chē)。從那以后我每次動(dòng)到請(qǐng)求拼裝相關(guān)代碼都強(qiáng)制把完整報(bào)文用十六進(jìn)制打一遍再走后續(xù)流程這個(gè)習(xí)慣救回來(lái)的時(shí)間遠(yuǎn)超它花掉的幾秒。希望幫到你。本文還有配套的精品資源點(diǎn)擊獲取