
简介这份 VC/MFC HTTP 服务器示例代码面向具备 C 基础、希望理解 Windows 网络编程与 Web 服务端通信原理的开发者重点解决在 MFC 工程中搭建可响应浏览器请求的简易 HTTP 服务问题。压缩包共 26 个文件约 115KB包含 10 个 h 头文件、8 个 cpp 源文件及 rc 资源、vcproj 工程、exe 可执行文件和 ico 图标等便于直接查看工程结构与编译运行。已有 2352 人学习或下载。代码围绕 Winsock 初始化、套接字 bind/listen/accept、HTTP GET 请求解析和响应构造展开并演示按文件类型设置 Content-Type 与 Content-Length 响应头以正确返回图片和站点内页同时涉及目录路径映射及 404、500 等状态处理。读者可据此梳理从 TCP 连接到静态资源响应的完整流程理解 MFC 对话框程序与网络服务结合的实现方式观察服务器如何读取请求行、定位文件并封装响应并作为后续扩展动态内容、表单处理或数据库交互的基础参考。1. 从「首页能开、图片全裂」说起很多人第一次动手写 VC HTTP 服务器动机都很朴素MFC 程序里已经攒了一堆生成的 HTML、CSS 和截图想让同事直接用浏览器点开看而不是每次都打个压缩包发过去。这套源码干的就是这件事——在 VC/MFC 工程里用 Winsock 起一个监听端口收到 GET 请求后把磁盘上的.html、.css、.jpg按正确的 Content-Type 原样发回浏览器网站内页和图片都能正常点开。它承载不了高并发但很适合做内网预览服务、给桌面程序配一个本地帮助中心或者拿来把 HTTP 报文分帧这件事真正搞明白。下面按我实际调试成功的顺序拆重点全压在「页面能开、图全裂」这类细节上。2. 选型与骨架阻塞 Winsock 还是 CAsyncSocket2.1 一个静态文件服务器为什么我不用 CAsyncSocketMFC 里做网络有三条路裸 Winsock、CAsyncSocket、CSocket。新手最容易被CAsyncSocket的名字吸引觉得异步就等于高效实际踩进去才发现它有个隐藏前提——异步通知靠的是WSAAsyncSelect而 MFC 会在第一次创建 socket 的那个线程里建一个隐藏窗口来收消息。这就意味着你在 UI 线程创建它OnReceive里读文件、拼报文全部阻塞消息循环窗口直接假死你在工作线程创建它那个线程必须自己跑一个GetMessage/PeekMessage循环否则OnReceive永远不触发表现为「连上了但一点反应都没有」。CSocket更绕它在CAsyncSocket上又包了一层阻塞语义内部还会偷偷 PumpMessages在对话框程序里用不好就是重入。我一般的做法是静态文件服务器用阻塞 socket 每连接一个工作线程。原因很实在——这类服务并发量通常是个位数到几十瓶颈在磁盘和网卡不在线程模型阻塞写法的心智负担低出问题看一眼调用栈就知道卡在哪不用去猜消息泵。方案上手成本调试难度适合场景裸 Winsock 阻塞 线程低低内网预览、本地帮助中心CAsyncSocket中要懂消息泵中连接数多、单连接数据少CSocket中高内部 Pump老工程兼容骨架就三个类一个管监听一个管连接主体逻辑放服务器类里// HttpServer.h —— 只保留主干成员函数实现见后面几节 #pragma once #include winsock2.h #include ws2tcpip.h #include string #include thread #include atomic #pragma comment(lib, ws2_32.lib) class CHttpServer { public: CHttpServer() : m_hListen(INVALID_SOCKET), m_bRunning(false), m_nPort(0) {} ~CHttpServer() { Stop(); } bool Start(unsigned short nPort, const std::string strRoot); void Stop(); private: void AcceptLoop(); // 监听线程主体 void HandleClient(SOCKET sClient); // 单连接处理独立线程跑 SOCKET m_hListen; std::atomicbool m_bRunning; unsigned short m_nPort; std::string m_strRoot; // 站点根目录绝对路径不带尾斜杠 std::thread m_thAccept; };m_strRoot存绝对路径且统一去掉尾部斜杠是为了后面拼路径时不会出现C:\site\/index.html这种半反半正的怪物——Windows 能吃但你自己写前缀比较的时候会被坑。2.2 启动监听SO_REUSEADDR、INADDR_ANY 与 10048Start()里有三个参数最容易设错绑定地址、端口复用、端口号。bool CHttpServer::Start(unsigned short nPort, const std::string strRoot) { WSADATA wsa {}; if (WSAStartup(MAKEWORD(2, 2), wsa) ! 0) return false; // 10093 之类基本是没初始化 winsock m_nPort nPort; m_strRoot strRoot; while (!m_strRoot.empty() (m_strRoot.back() \\ || m_strRoot.back() /)) m_strRoot.pop_back(); m_hListen socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hListen INVALID_SOCKET) { WSACleanup(); return false; } // 关键进程重启后旧连接还在 TIME_WAIT不加这句 bind 直接 10048 BOOL bReuse TRUE; setsockopt(m_hListen, SOL_SOCKET, SO_REUSEADDR, (const char*)bReuse, sizeof(bReuse)); sockaddr_in addr {}; addr.sin_family AF_INET; addr.sin_addr.s_addr htonl(INADDR_ANY); // 想只给本机用就换 inet_addr(127.0.0.1) addr.sin_port htons(m_nPort); if (bind(m_hListen, (sockaddr*)addr, sizeof(addr)) SOCKET_ERROR) { closesocket(m_hListen); WSACleanup(); return false; // WSAGetLastError()10048 端口被占 } if (listen(m_hListen, SOMAXCONN) SOCKET_ERROR) { closesocket(m_hListen); WSACleanup(); return false; } m_bRunning true; m_thAccept std::thread(CHttpServer::AcceptLoop, this); return true; }INADDR_ANY和127.0.0.1的差别不只是「专业」与否绑成回环地址同网段的同事一定连不上而你自己本机测一切正常这种「我这儿好好的」最容易消耗一上午。SO_REUSEADDR在 Windows 上语义和 Linux 不完全一样它允许绑定一个仍处于TIME_WAIT的端口但不允许两个活着的监听抢同一个端口——所以它解决的是重启时的 10048不是「两个人同时开」。如果你希望后启动的实例直接失败而不是悄悄抢占就把SO_REUSEADDR换成SO_EXCLUSIVEADDRUSE。listen的第二个参数用SOMAXCONN就够了别手写 5 或 10那只是未完成握手的队列长度写小了在浏览器并发拉图片时会丢连接。2.3 连接处理超时、accept 失败与 detach 的代价监听线程只做一件事——把accept出来的 socket 甩给工作线程自己立刻回去等下一个。void CHttpServer::AcceptLoop() { while (m_bRunning) { sockaddr_in addrClient {}; int nLen sizeof(addrClient); SOCKET sClient accept(m_hListen, (sockaddr*)addrClient, nLen); if (sClient INVALID_SOCKET) { if (!m_bRunning) break; // Stop() 里关掉了监听口属于正常退出路径 continue; // 其它错误不要 break否则一次抖动服务就没了 } // 收/发各 5 秒超时浏览器预连接不发数据、或被半开连接赖住时能自己脱身 DWORD dwTimeout 5000; setsockopt(sClient, SOL_SOCKET, SO_RCVTIMEO, (const char*)dwTimeout, sizeof(dwTimeout)); setsockopt(sClient, SOL_SOCKET, SO_SNDTIMEO, (const char*)dwTimeout, sizeof(dwTimeout)); std::thread(CHttpServer::HandleClient, this, sClient).detach(); } }SO_RCVTIMEO的单位是毫秒设 5000 表示 5 秒收不到任何字节就让recv返回 -1WSAETIMEDOUT。不设这个浏览器在页面加载完后保持的空连接会把工作线程一直挂着开几十个标签页就能把线程数堆到一个很难看的数字。detach()是这里的简化写法代价写清楚工作线程持有this如果用户在连接还没处理完时关掉程序析构函数跑完了线程还在读m_strRoot就是一次随机崩溃。玩具项目可以接受要发版就老老实实维护一个线程列表Stop()里join。这个坑在第 5 章会单独展开。3. 请求解析从一行 GET 里提取方法、路径和边界3.1 recv 不等于一个完整请求这是 HTTP 服务器新手翻车率最高的一条。TCP 是字节流recv返回什么完全看对面怎么切包可能一次就收到GET /index.html HTTP/1.1\r\nHost: ...\r\n\r\n全量也可能第一次只收到GET /ind第二次才是剩下的浏览器还可能把多个请求连着发keep-alive 场景一次recv里躺着两个\r\n\r\n。所以判断「请求收完了」的唯一依据是报文里出现了头部结束标记\r\n\r\n不是recv返回了。void CHttpServer::HandleClient(SOCKET sClient) { std::string strBuf; char szTmp[4096]; size_t nHeaderEnd std::string::npos; // 上限 64KB正常请求头几百字节超过这个量级基本可以判定是恶意或异常 while (strBuf.size() 64 * 1024) { int nRet recv(sClient, szTmp, sizeof(szTmp), 0); if (nRet 0) break; // 0对端关闭-1超时/出错 strBuf.append(szTmp, nRet); nHeaderEnd strBuf.find(\r\n\r\n); if (nHeaderEnd ! std::string::npos) break; // 头部收全了剩下的 body 不关心 } if (nHeaderEnd std::string::npos) { SendStatus(sClient, 400); // 连头都没收全回 400 直接关 closesocket(sClient); return; } std::string strHeader strBuf.substr(0, nHeaderEnd); // 请求行 METHOD SP Request-URI SP HTTP-Version CRLF std::string strMethod, strUrl, strVersion; size_t nSp1 strHeader.find( ); size_t nSp2 (nSp1 std::string::npos) ? nSp1 : strHeader.find( , nSp1 1); if (nSp1 ! std::string::npos nSp2 ! std::string::npos) { strMethod strHeader.substr(0, nSp1); strUrl strHeader.substr(nSp1 1, nSp2 - nSp1 - 1); strVersion strHeader.substr(nSp2 1, strHeader.find(\r\n, nSp2) - nSp2 - 1); } else { SendStatus(sClient, 400); closesocket(sClient); return; } if (strMethod ! GET strMethod ! HEAD) { SendStatus(sClient, 405); // 只读服务POST/PUT 一律拒掉 closesocket(sClient); return; } // ... 路径映射与文件发送见 3.2 / 4.3 }这里有三个参数值得留意。64 * 1024是头部上限超了就丢防止有人用超大 Cookie 把内存撑爆4096是单次recv的缓冲区和 MTU 无关取 4K 到 8K 都行太小会导致循环次数变多nRet 0必须统一处理因为超时和对端关闭在业务上都是「这个连接到此为止」。请求行的解析我用两个find( )而不是sscanf原因很实际sscanf(%s %s %s)遇到 URL 里有空格虽然合规 URL 会编码成%20但不合规的到处都是会静默截断还不报错。手工切分至少你知道切在哪。3.2 URL 解码与路径归一化把 /img/a.jpg 落到磁盘且不出根目录拿到strUrl之后要做三件事去掉查询串、百分号解码、把相对路径安全地拼到站点根目录下。// 百分号解码%20 - 空格%E4%B8%AD - 三个字节的 UTF-8 中文 static std::string UrlDecode(const std::string strIn) { std::string strOut; strOut.reserve(strIn.size()); for (size_t i 0; i strIn.size(); i) { if (strIn[i] % i 2 strIn.size()) { char hex[3] { strIn[i 1], strIn[i 2], 0 }; strOut.push_back((char)strtol(hex, NULL, 16)); i 2; } else if (strIn[i] ) { strOut.push_back( ); // 查询串里的 代表空格 } else { strOut.push_back(strIn[i]); } } return strOut; } bool CHttpServer::MapUrlToFile(const std::string strUrl, std::string strFullPath, int nStatus) { std::string strPath UrlDecode(strUrl); // 1) 丢掉 ?query 和 #fragment size_t nCut strPath.find_first_of(?#); if (nCut ! std::string::npos) strPath strPath.substr(0, nCut); // 2) 根路径落默认页 if (strPath.empty() || strPath /) strPath /index.html; // 3) 统一分隔符 合并重复斜杠 std::string strNorm; for (size_t i 0; i strPath.size(); i) { char c strPath[i]; if (c \\) c /; if (c / !strNorm.empty() strNorm.back() /) continue; strNorm.push_back(c); } // 4) 显式挡掉上跳别指望后面的字符串比较兜底 if (strNorm.find(..) ! std::string::npos) { nStatus 403; return false; } if (!strNorm.empty() strNorm[0] /) strNorm.erase(0, 1); // 5) 交给系统做一次规范化再用前缀比较锁死根目录 char szFull[MAX_PATH] { 0 }; std::string strJoin m_strRoot \\ strNorm; if (GetFullPathNameA(strJoin.c_str(), MAX_PATH, szFull, NULL) 0) { nStatus 404; return false; } strFullPath szFull; if (_strnicmp(strFullPath.c_str(), m_strRoot.c_str(), m_strRoot.size()) ! 0) { nStatus 403; return false; } // 归一化后跑到根目录外面去了 DWORD dwAttr GetFileAttributesA(strFullPath.c_str()); if (dwAttr INVALID_FILE_ATTRIBUTES) { nStatus 404; return false; } if (dwAttr FILE_ATTRIBUTE_DIRECTORY) { nStatus 404; return false; } // 不做目录列表 nStatus 200; return true; }第 4 步和第 5 步看着重复其实是两层。..检查挡的是明文上跳GetFullPathNameA 前缀比较挡的是%2e%2e%2f这类编码变种、以及C:\site与C:\site2这种前缀相同但目录不同的边界情况。少了_strnicmp这一层/..%2f..%2fWindows/win.ini就能读到站外文件这是静态服务器最经典的一个洞。注意m_strRoot必须是没有尾斜杠的绝对路径例如C:\site否则前缀比较会把C:\site2\...误判成站内文件。3.3 默认页、尾斜杠与状态码的判定顺序顺序错了会出现很奇怪的现象比如请求http://127.0.0.1:8080首页打不开但http://127.0.0.1:8080/index.html正常。我固定用下面这个次序方法是否 GET / HEAD否则 405URL 解码、去查询串空路径或/映射到/index.html路径归一化、越界检查不通过 403文件是否存在、是否目录不存在 404读文件、按扩展名定 MIME、回 200。状态码这块只用四个就够不要贪多状态码触发条件响应体200文件存在且可读文件内容400请求行解不出、头部没收全一行纯文本403路径归一化后越出站点根目录一行纯文本404文件不存在或请求的是目录一行纯文本405非 GET / HEAD一行纯文本错误页别拼 HTML就回text/plain的短句加Connection: close。原因很实在错误响应本身不走磁盘越简单越不容易在错误处理里再出错而且浏览器对短文本的渲染没有额外请求不会因为错误页里引用了不存在的 css 而雪崩出一串 404。4. 响应构造MIME 表、Content-Length 与二进制发送4.1 MIME 表少一个 image/jpeg 就是一片裂图浏览器不靠扩展名猜类型它看Content-Type。回错了会怎样页面还能开图片全是裂的F12 里那一行写着「资源被解释为文档但传输类型为 image/jpeg」或者反过来。我踩过的具体表现是.jpg全部走默认的application/octet-streamChrome 直接当下载处理img标签加载失败。static const char* GetMimeType(const std::string strFullPath) { // 只列静态站点最常见的几类查不到就老实给二进制流别乱猜 text/plain static const struct { const char* ext; const char* mime; } kMap[] { { .html, text/html; charsetutf-8 }, { .htm, text/html; charsetutf-8 }, { .css, text/css; charsetutf-8 }, { .js, application/javascript; charsetutf-8 }, { .json, application/json; charsetutf-8 }, { .txt, text/plain; charsetutf-8 }, { .png, image/png }, { .jpg, image/jpeg }, { .jpeg, image/jpeg }, { .gif, image/gif }, { .webp, image/webp }, { .svg, image/svgxml}, { .ico, image/x-icon }, { .woff2,font/woff2 }, { .mp4, video/mp4 }, }; size_t nDot strFullPath.find_last_of(.); if (nDot std::string::npos) return application/octet-stream; std::string strExt strFullPath.substr(nDot); for (auto c : strExt) c (char)tolower((unsigned char)c); for (const auto item : kMap) if (strExt item.ext) return item.mime; return application/octet-stream; }两个细节。扩展名必须tolower否则IMG.JPG就查不到——Windows 文件系统不区分大小写但你的字符串比较区分。charsetutf-8要跟你磁盘上 HTML 文件的实际编码一致如果页面是 GBK 存的这里写 utf-8 就是满屏乱码改回charsetgbk或者干脆把页面另存成 UTF-8后者更省事。.woff2和.svg这两行别省。现在随便一个前端模板都会带图标字体和 SVG logo漏了就是浏览器控制台里两条红色字体加载失败页面看起来「就是有点不对」很难往 MIME 上想。4.2 响应头拼装与 Content-Length 的字节口径响应头的格式比请求头严格状态行、每个头一行、空行结束、然后才是 body。顺序无所谓但\r\n\r\n一个都不能少。bool CHttpServer::SendStatus(SOCKET s, int nCode) { const char* pszReason OK; switch (nCode) { case 400: pszReason Bad Request; break; case 403: pszReason Forbidden; break; case 404: pszReason Not Found; break; case 405: pszReason Method Not Allowed; break; default: break; } char szBody[128] { 0 }; int nBody _snprintf_s(szBody, sizeof(szBody), _TRUNCATE, %d %s\r\n, nCode, pszReason); char szHdr[512] { 0 }; int nHdr _snprintf_s(szHdr, sizeof(szHdr), _TRUNCATE, HTTP/1.1 %d %s\r\n Content-Type: text/plain; charsetutf-8\r\n Content-Length: %d\r\n Connection: close\r\n \r\n, nCode, pszReason, nBody); return SendAll(s, szHdr, nHdr) SendAll(s, szBody, nBody); }Content-Length是整条链路上最容易错的一个数字因为它的口径是响应体的字节数不是字符数也不是文件在磁盘上占的簇大小。用CString::GetLength()或者strlen去算一个 UTF-8 中文页面得到的字符数远小于字节数浏览器会老老实实等剩下的字节一直等到超时——这就是「页面能显示一半然后转圈」的直接原因。正确做法是拿文件系统给的字节数LARGE_INTEGER liSize {}; GetFileSizeEx(hFile, liSize); // 真实字节数和编码、换行符都无关顺手提一句如果后期想支持 gzipContent-Length必须换成压缩后的长度而不是原文件长度。这两者搞混是所有「本地正常、服务器上截断」类问题的根源。4.3 二进制发送SendAll 循环与文本模式的坑发送分两段先发头再发文件内容。两个坑都在第二段。bool CHttpServer::SendAll(SOCKET s, const char* pData, int nLen) { int nSent 0; while (nSent nLen) { int nRet send(s, pData nSent, nLen - nSent, 0); if (nRet 0) return false; // send 不保证一次发完必须循环 nSent nRet; } return true; } bool CHttpServer::SendFile(SOCKET sClient, const std::string strFullPath, const std::string strMime, bool bHeadOnly) { HANDLE hFile CreateFileA(strFullPath.c_str(), GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile INVALID_HANDLE_VALUE) return false; LARGE_INTEGER liSize {}; GetFileSizeEx(hFile, liSize); char szHdr[512] { 0 }; int nHdr _snprintf_s(szHdr, sizeof(szHdr), _TRUNCATE, HTTP/1.1 200 OK\r\n Content-Type: %s\r\n Content-Length: %lld\r\n Accept-Ranges: bytes\r\n Cache-Control: no-cache\r\n Connection: close\r\n \r\n, strMime.c_str(), (long long)liSize.QuadPart); bool bOk SendAll(sClient, szHdr, nHdr); // HEAD 请求只要头用来给 curl -I 和预检用 if (bOk !bHeadOnly) { // 二进制必须按字节搬不能经过 CString / printf 之类的文本通道 char szBuf[8192]; DWORD dwRead 0; while (bOk ReadFile(hFile, szBuf, sizeof(szBuf), dwRead, NULL) dwRead 0) bOk SendAll(sClient, szBuf, (int)dwRead); } CloseHandle(hFile); return bOk; }send的返回值必须循环处理。局域网里一次发 8K 图片通常一次就成但只要客户端读得慢一点比如同时开了十个标签页内核发送缓冲区满了send就会只发一部分并返回已发字节数。这段不写循环表现出来就是「图片下半截是灰的」——非常典型的半张图。用CreateFileAReadFile而不是fopen(..., rb)fread主要是因为 Windows 下宽字符路径处理更顺而且CreateFileA能直接拿到LARGE_INTEGER大小不用fseek/ftell那种 32 位会溢出的老写法。如果你非要走 C 运行时rb里的b千万别丢——文本模式会把\r\n转成\n、把0x1A当 EOF图片和字体文件里这些字节遍地都是一转就废。发完之后关机也要讲顺序shutdown(sClient, SD_SEND); // 先把 FIN 发出去告诉浏览器「我这边发完了」 closesocket(sClient); // 再释放句柄直接closesocket在大多数时候也能工作但在Connection: close语义下先shutdown能让浏览器立刻知道响应结束而不是等 TCP 超时。4.4 HEAD 与 304两个小改动换来的兼容性HEAD只回头不回体代价是三行代码上面的bHeadOnly收益是curl -I能直接拿来当冒烟测试用很多监控探针也走 HEAD。不做的话你会收到一堆「方法不允许」的告警虽然不影响功能但排查时噪音很大。304 Not Modified我没在这套代码里做原因是静态预览场景下文件随时会被重新生成加了If-Modified-Since判断反而容易出现「改了页面刷不出来」。取而代之的是Cache-Control: no-cache——让它每次都真的来问一次。等页面稳定了再换成Last-Modified 304那是后话。5. 常见问题排查五个让我返工的现场5.1 首页能开图片全是裂的现象index.html正常渲染img全部显示裂图F12 网络面板里图片请求状态 200但响应大小比磁盘上的文件小一截控制台提示资源类型不匹配。原因图片走了文本通道。要么是fopen(path, r)少了b0x1A被当 EOF 提前截断要么是把二进制塞进CString再send中间经过了一次字符集转换。再加上没给Content-Type浏览器拿到了几十 KB 的「文档」自然解析不出图。解决发送路径上禁止任何字符串类型统一CreateFileAReadFile到char[]SendAllContent-Type按扩展名查表Content-Length用GetFileSizeEx的真实值不要用任何文本长度函数。5.2 页面显示完了浏览器还在转圈现象内容全都出来了标签页上的小圈一直转十几秒后才停或者干脆报ERR_CONTENT_LENGTH_MISMATCH页面被截断。原因声明的Content-Length和实际发出的字节数不一致。最常见的是头部按 UTF-8 中文页面的字符数算实际发出去是字节另一种是send没写循环最后的尾巴没发完就closesocket了。解决Content-Length一律取文件系统字节数SendAll必须循环到nSent nLen发完先shutdown(SD_SEND)再关让对端明确知道流结束。验证方法是curl -s -o out.bin -w %{size_download}打出来的数字和资源管理器里看到的字节数对一下差一个字节都算错。5.3 带中文的目录或文件名一律 404现象/img/产品图.jpg打不开服务端日志里路径看着是%E4%BA%A7%E5%93%81%E5%9B%BE.jpg改成纯英文名立刻正常。原因浏览器发过来的 URL 是 UTF-8 的百分号编码没做UrlDecode就直接拼路径当然找不到文件。另一个变种是解码了但把 UTF-8 字节直接当 ANSI 路径传给CreateFileA系统按当前代码页解释又变成乱码。解决先UrlDecode得到 UTF-8 字节序列再用MultiByteToWideChar(CP_UTF8, ...)转成std::wstring最后调CreateFileW。别指望在中间转一次 ANSI 能碰巧对上那取决于系统区域设置换台机器就翻车。5.4 点「停止」偶发崩溃调用栈停在一个陌生的地址现象关闭主窗口时大概十次有一次崩调试器断在std::string的某个赋值上调用栈顶是个工作线程。原因AcceptLoop里用了std::thread(...).detach()工作线程裸持this。窗口一关CHttpServer析构m_strRoot被释放还在处理请求的线程继续读它就是野指针。解决三种改法按工程量从低到高——把根目录、端口这些只读配置收进std::shared_ptrConfig线程按值捕获或者维护一个连接线程列表Stop()里置位m_bRunning、closesocket(m_hListen)、然后逐个join最省心的是直接上个固定大小的线程池连接丢队列里。我一般选第二种几十行代码逻辑一眼能看懂。5.5 本机能开同事访问不到或者重启就 bind 失败现象自己浏览器访问127.0.0.1:8080一切正常同网段另一台机器超时另一种是关掉程序立刻再开bind返回 -1WSAGetLastError()是 10048。原因前者多半绑了回环地址或者 Windows 防火墙第一次弹窗时点了「取消」规则里是阻止状态后者是上一个进程的监听 socket 还在TIME_WAIT没设SO_REUSEADDR。解决sin_addr.s_addr htonl(INADDR_ANY)setsockopt加SO_REUSEADDR第一次运行时把防火墙弹窗里的「专用网络」勾上。想确认是不是防火墙用netstat -ano | findstr :8080看端口状态是不是LISTENING是的话问题就在网络侧不在代码侧。6. 进阶用 curl 把回归做成一页清单功能写完只是开始真正省时间的是把「能不能用」变成一串可以重复执行的命令。我习惯在每个改动之后跑一遍下面这张表-I只看头-s -o把体落盘比字节数--path-as-is是唯一能让 curl 不帮你把..归一化的开关——不加这个越权测试永远测不出来。用例命令期望结果默认页curl -I http://127.0.0.1:8080/200text/html站内页curl -I http://127.0.0.1:8080/about.html200Content-Length与磁盘一致图片curl -s -o t.jpg -w %{content_type} %{size_download} http://127.0.0.1:8080/img/a.jpgimage/jpeg字节数相等不存在curl -I http://127.0.0.1:8080/nope.html404越权上跳curl -I --path-as-is http://127.0.0.1:8080/../win.ini403编码越权curl -I --path-as-is http://127.0.0.1:8080/%2e%2e/win.ini403方法限制curl -I -X POST http://127.0.0.1:8080/405跑完这张表还没完端口和并发得单独确认。下面这段是加访问日志和连接数上限的最小改法日志直接打OutputDebugStringA用 DebugView 看不额外引依赖// 全局连接计数超过上限直接回 503防止有人开一百个标签页把线程堆爆 static std::atomicint g_nActive(0); const int kMaxConn 32; void CHttpServer::HandleClient(SOCKET sClient) { if (g_nActive kMaxConn) { --g_nActive; SendStatus(sClient, 503); closesocket(sClient); return; } // ... 3.1 的收包与 3.2 的路径映射 char szLog[512] { 0 }; _snprintf_s(szLog, sizeof(szLog), _TRUNCATE, [%lu] %s - %d (%s)\n, GetCurrentThreadId(), strUrl.c_str(), nStatus, strFullPath.c_str()); OutputDebugStringA(szLog); // 只走调试通道不落盘、不拖慢请求 --g_nActive; closesocket(sClient); }kMaxConn取 32 是个经验值一个页面带二三十张图浏览器会并发 6 到 8 个连接留出四倍余量既够用又不会让线程数失控。回调里--g_nActive一定要在closesocket之前否则日志里会出现「连接数偶尔是负数」的假象我为此多翻了两小时代码。最后一个习惯每次改完MapUrlToFile我都会重跑一遍越权那两个用例哪怕这次只动了一行 MIME 表。路径比较这种东西改动看起来再无关也可能因为一次重构把_strnicmp的长度参数从m_strRoot.size()改成strlen(...)而悄悄失效——这种退化的血泪经验吃过一次就够记一辈子了。希望帮到你。本文还有配套的精品资源点击获取