简介:一套基于VC/MFC的简单HTTP服务器源码工程,面向希望掌握Windows平台网络编程的C++开发者,目标是帮助读者理解HTTP协议解析、套接字通信以及图片与内页访问的实现方式。压缩包共26个文件,以h头文件、cpp源文件为主,辅以rc资源脚本、vcproj工程配置以及已编译好的exe程序,整体仅115KB,体量精简、便于逐行研读。已有2349人浏览学习,具较好的参考价值。内容覆盖Winsock初始化与监听、accept连接处理、GET请求解析、目标文件查找、响应状态码与Content-Type设置,并专门针对JPEG、PNG图片及HTML页面的数据返回做了处理,工程内对话框界面、监听模块、请求处理模块分工明确。通过阅读源码并结合可执行程序运行验证,可以快速掌握在VC/MFC环境下搭建简易HTTP服务的核心流程,为后续扩展动态内容、并发连接或更完整Web服务打下基础。
1. 手写 HTTP 服务器这件事,为什么值得自己做一遍
一个 200 行的 VC/MFC 程序,就能让局域网里的其他电脑通过浏览器访问你机器上的图片和 HTML 页面。这不是什么高性能服务器,但用来应付课程设计、临时共享文件,或者把老旧设备的管理界面嵌进 MFC 窗口,已经绰绰有余。我在帮别人做这类东西时发现,问题的关键不是会不会写 socket,而是请求解析、Content-Type 设置、二进制文件读取这些细节没有捋顺。我会从 Winsock 初始化一路讲到 accept 循环、HTTP 请求行解析、MIME 映射、动态内页生成,最后把常见的翻车点逐条列出来。这篇笔记适合想在内嵌 MFC 程序里提供一个 Web 入口、又不想引入庞大第三方库的开发者。
2. 搭建服务器骨架:从 Winsock 启动到 accept 循环
2.1 为什么直接使用 Winsock API 而不是 CAsyncSocket
MFC 自带 CAsyncSocket 和 CSocket,它们封装了 WSAAsyncSelect,能让 socket 事件投递到窗口消息。听起来方便,但实际工程里有几个别扭的地方:第一,CAsyncSocket 的事件响应在主线程消息循环里完成,并发请求稍微多一点,消息队列就会被 socket 事件淹没,界面拖拽都卡;第二,CSocket 的阻塞模式底层有临时线程参与,调试时经常看到意外断开。所以我更愿意直接用 winsock2.h 的原始 API,自己开工作线程,把网络逻辑和 UI 完全分开。
在这个工程里,建议把以下代码放在预编译头 stdafx.h 的顶部。注意头文件顺序,否则会和 windows.h 的定义冲突。
#include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib")逻辑说明:winsock2.h 是 Winsock 2 的主头文件,ws2tcpip.h 提供了 inet_pton、getaddrinfo 等较新的地址转换函数;#pragma comment(lib, "ws2_32.lib")让链接器直接带上 Socket 库,省去在工程属性里配置链接依赖的步骤。
提示:winsock2.h 必须放在 windows.h 之前,否则会报一大堆重定义错误。如果工程里某个头文件先引用了 windows.h,就把这组 include 放在预编译头最上方,保证它最先被包含。
2.2 初始化与监听套接字:端口、地址复用和绑定
任何使用 Winsock 的程序第一步都是 WSAStartup。在 MFC 的 CWinApp::InitInstance 里调用一次即可,每个线程不需要重复调用。
bool StartWinsock() { WSADATA wsa; if (WSAStartup(MAKEWORD(2, 2), &wsa) != 0) return false; if (LOBYTE(wsa.wVersion) != 2 || HIBYTE(wsa.wVersion) != 2) return false; return true; }参数说明:MAKEWORD(2,2) 表示请求 Winsock 2.2 版本;WSADATA 会返回系统实际支持的版本。WSAStartup 返回 0 只代表启动成功,不代表版本匹配,所以还要检查 LOBYTE 和 HIBYTE,避免在只安装了旧版本 Winsock 的系统上继续运行。
创建监听 socket 时,要尽早设置 SO_REUSEADDR。这个选项的作用是让服务器关闭后可以立即重启,避免出现“端口被占用”的假象。尤其在调试中频繁中断进程,这个设置能省掉很多不必要的 TIMEWAIT 等待。
SOCKET CreateListenSocket(int port) { SOCKET sock = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (sock == INVALID_SOCKET) return INVALID_SOCKET; BOOL reuse = TRUE; setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuse, sizeof(reuse)); SOCKADDR_IN addr = { 0 }; addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons((u_short)port); if (bind(sock, (SOCKADDR*)&addr, sizeof(addr)) == SOCKET_ERROR) { closesocket(sock); return INVALID_SOCKET; } if (listen(sock, 10) == SOCKET_ERROR) { closesocket(sock); return INVALID_SOCKET; } return sock; }逻辑说明:socket 函数创建 TCP 套接字,AF_INET 表示 IPv4,SOCK_STREAM 表示面向流,IPPROTO_TCP 指定 TCP 协议。bind 把本地地址和端口绑定到 socket 上,INADDR_ANY 表示监听本机所有网卡。listen 的第二个参数是未处理连接队列长度,Windows 下实际上不会严格限制,设 10 或者 5 都行。
端口选择上有两个常识:80 端口需要管理员权限,而且容易和 IIS 或其他服务冲突;8080、9090 这类高位端口更适合开发期调试。如果目标是局域网访问,要保证防火墙放行,否则别的机器连不上。
2.3 accept 循环与每连接一线程的取舍
监听 socket 就绪后,进入 accept 循环。accept 会阻塞当前线程,直到有客户端连接进来。首页和图片会产生大量并发请求,服务器主线程不能一直睡在 accept 里,所以我把 accept 放在独立线程中,每个连接再开一个工作线程。
UINT WINAPI AcceptThread(LPVOID param) { SOCKET listenSock = (SOCKET)param; while (!g_bStop) { SOCKADDR_IN clientAddr; int addrLen = sizeof(clientAddr); SOCKET client = accept(listenSock, (SOCKADDR*)&clientAddr, &addrLen); if (client == INVALID_SOCKET) { if (WSAGetLastError() == WSAEINTR) break; continue; } unsigned threadId = 0; HANDLE hThread = (HANDLE)_beginthreadex( nullptr, 0, ClientThread, (LPVOID)client, 0, &threadId); if (hThread != nullptr) CloseHandle(hThread); } return 0; }逻辑说明:accept 返回的 socket 已经和特定客户端绑定,原本的 listenSock 继续接受新连接。每来一个客户端就开一条线程,代码直白,缺点是线程创建频繁。对一个设备内嵌式服务器来说,页面展开后就会连着请求 CSS、JS、图片,每秒几十次连接,这种模型完全够用。
如果后续并发量上来,可以改成线程池:线程池里的工作线程从队列里取 socket 处理。改动不大,但要额外维护一个临界区和条件变量,课程设计如果不是压力测试,每连接一线程更容易解释清楚。
_close 少了句柄会泄漏,所以上面代码在拿到线程句柄后立刻 CloseHandle。线程本身运行在自己的句柄空间里,线程退出时系统自动回收栈和内核对象。
2.4 启动流程与验证监听是否生效
在 InitInstance 里依次调用 StartWinsock、CreateListenSocket、开启 AcceptThread。需要两个全局变量:g_bStop 和 g_listenSock。程序退出时,置 g_bStop 为 TRUE,然后 closesocket,让 accept 从阻塞中返回。
BOOL CMyApp::InitInstance() { if (!StartWinsock()) { AfxMessageBox(_T("Winsock 初始化失败")); return FALSE; } g_listenSock = CreateListenSocket(8080); if (g_listenSock == INVALID_SOCKET) { AfxMessageBox(_T("创建监听套接字失败")); return FALSE; } _beginthreadex(nullptr, 0, AcceptThread, (LPVOID)g_listenSock, 0, nullptr); return TRUE; }服务起来后,用下面命令验证监听状态:
netstat -ano | findstr :8080正常会看到TCP 0.0.0.0:8080处于 LISTENING。如果找不到监听项,先看 WSAGetLastError 的错误码:10048 是端口被占用,10047 是协议族不支持。防火墙有时候很玄学,即使本机访问正常,局域网里的机器也可能连不上,我一般先在本机验证,再检查入站规则是否放行。
3. 解析 HTTP 请求:请求行、Header 和 Content-Type 分发
3.1 先弄清楚浏览器发过来的是什么
HTTP 请求头是一段纯文本。最简单的 GET 请求长这样:
GET /index.html HTTP/1.1 Host: 192.168.1.10:8080 Connection: close第一行是请求行,包含方法、路径、协议版本。接下来每行是一个请求头,以 \r\n 分隔,空行表示头部结束。浏览器请求图片时,除了路径不同,其他结构完全一样,比如GET /images/logo.png HTTP/1.1。
我的服务器只需要关注三点:方法、URL 路径、是否要求关闭连接。搞懂这个结构后,解析就只是字符串处理。改错一点,可能整个页面都打不开,这也是这个资源里最容易被忽略的地方。
3.2 循环 recv 直到拿到完整头部
socket 接收数据像自来水管,不能假设一次 recv 就能收到完整请求。浏览器的请求被拆成多个 TCP 段,在局域网里也许还好,跨路由时半包很常见。写解析代码时,我习惯先把头部拼出来。
std::string requestBuf; char tmp[4096]; while (requestBuf.find("\r\n\r\n") == std::string::npos) { int n = recv(client, tmp, sizeof(tmp), 0); if (n <= 0) break; requestBuf.append(tmp, n); if (requestBuf.size() > 64 * 1024) { SendResponseHeader(client, 400, "text/plain", 0); closesocket(client); return -1; } }逻辑说明:find("\r\n\r\n")找头部结束空行。循环 recv 直到拿到完整头部或出错。64 KB 上限用来防止恶意客户端只发GET不结束,造成服务器内存膨胀。普通浏览器请求头一般在 1 到 2 KB,64 KB 已经是相当宽的安全边界了。
参数说明:tmp 缓冲区 4096 字节,每次 recv 后 append 到 std::string。这里不能用 C 字符串拼接,因为数据里可能包含 \0 空字符,用 append 可以按长度写入。
3.3 解析请求行并拆出方法、路径和版本
请求行是头部字符串的第一个 \r\n 之前的内容。可以用 find 加 substr 实现,不需要引入正则。
std::string RequestLine(const std::string& data) { size_t pos = data.find("\r\n"); if (pos == std::string::npos) return ""; return data.substr(0, pos); }再对请求行做字段拆分:
std::string method, path, version; std::istringstream iss(RequestLine(requestBuf)); iss >> method >> path >> version; if (path.empty() || path[0] != '/') { SendResponseHeader(client, 400, "text/html; charset=utf-8", 0); closesocket(client); return -1; }逻辑说明:istringstream 用空格作为分隔符,天然切出三块。method 是 GET 或 POST,path 是类似/images/logo.png,version 是 HTTP/1.1。如果拿到空 path 或首字符不是/,说明格式不合法,按 400 返回。
这里还有一个容易出现的问题:某些老式客户端会在路径中带?query=...,路径和查询参数之间用?隔开,应该在解析后把查询参数摘掉。我只保留问号之前的部分,否则文件系统里找不到带问号的文件名。可以加一行:
size_t qPos = path.find('?'); if (qPos != std::string::npos) path = path.substr(0, qPos);3.4 URL 解码
浏览器会把非 ASCII 字符和保留字符转义成 %XX。中文“首页”经过 UTF-8 编码后可能变成%E9%A6%96%E9%A1%B5。如果不解码,Windows 文件系统里永远找不到这个名字。解码函数要处理%和+两种形式。
int HexValue(char c) { if (c >= '0' && c <= '9') return c - '0'; if (c >= 'a' && c <= 'f') return c - 'a' + 10; if (c >= 'A' && c <= 'F') return c - 'A' + 10; return -1; } std::string UrlDecode(const std::string& input) { std::string result; for (size_t i = 0; i < input.size(); i++) { if (input[i] == '%' && i + 2 < input.size()) { int high = HexValue(input[i + 1]); int low = HexValue(input[i + 2]); if (high >= 0 && low >= 0) { result.push_back((char)((high << 4) | low)); i += 2; } else { result.push_back(input[i]); } } else if (input[i] == '+') { result.push_back(' '); } else { result.push_back(input[i]); } } return result; }逻辑说明:%E4%B8%AD这类编码其实是“中”字 UTF-8 字节的十六进制表示。两个十六进制字符合成一个字节,就还原成原始字符串。+在 URL 查询串里等于空格,在路径部分实际很少出现,但解码时顺手处理掉更稳妥。
边界检查必须做:如果%后面不足两个字符,不要乱拼,直接把%原样保留。否则解码函数会越界读取,在 MFC 调试环境下直接触发 assert。
3.5 Content-Type 映射表决定页面和图片能否正常渲染
同样的字节流,告诉浏览器是图片还是 HTML,结果完全不同。图片 Content-Type 写错会变成乱码,HTML 的 charset 写错会让页面里的中文全变方块。我维护一张映射表:
| 扩展名 | Content-Type |
|---|---|
| .html .htm | text/html; charset=utf-8 |
| .css | text/css; charset=utf-8 |
| .js | application/javascript |
| .png | image/png |
| .jpg .jpeg | image/jpeg |
| .gif | image/gif |
| .svg | image/svg+xml |
| .ico | image/x-icon |
| .txt | text/plain; charset=utf-8 |
实现函数:
std::string GetContentType(const std::string& path) { std::string ext; size_t dot = path.find_last_of('.'); if (dot != std::string::npos) ext = path.substr(dot + 1); std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); if (ext == "html" || ext == "htm") return "text/html; charset=utf-8"; if (ext == "css") return "text/css; charset=utf-8"; if (ext == "js") return "application/javascript"; if (ext == "png") return "image/png"; if (ext == "jpg" || ext == "jpeg") return "image/jpeg"; if (ext == "gif") return "image/gif"; if (ext == "svg") return "image/svg+xml"; if (ext == "ico") return "image/x-icon"; return "application/octet-stream"; }逻辑说明:扩展名可能大写,所以统一转小写。text 类型加 charset=utf-8,可以避免页面标题和正文出现乱码;图片类型不需要 charset。如果后缀名不认识,返回 application/octet-stream,浏览器会按下载处理,至少不会乱显示。
3.6 状态行、响应头和错误页
构造响应头时,要同时写状态行、Content-Type、Content-Length 和 Connection。四个字段缺哪个都会出怪问题。
void SendResponseHeader(SOCKET client, int code, const std::string& contentType, long contentLength) { std::string reason = (code == 200) ? "OK" : (code == 404) ? "Not Found" : (code == 403) ? "Forbidden" : "Internal Server Error"; std::string head = "HTTP/1.1 " + std::to_string(code) + " " + reason + "\r\n"; head += "Content-Type: " + contentType + "\r\n"; head += "Content-Length: " + std::to_string(contentLength) + "\r\n"; head += "Connection: close\r\n"; head += "\r\n"; send(client, head.c_str(), head.size(), 0); }逻辑说明:HTTP 状态码必须作为响应行的一部分。Content-Length 的值是响应体的字节数,不是头部长度,也不是整个 TCP 段的长度。Connection: close 告诉浏览器“发完就断开”,省去实现长连接解析。这里没有写 Date 和 Server 头,浏览器也不强制要求。
404 页面也要带 Content-Type 和 Content-Length,否则浏览器同样会等超时。下面这个简单错误页够用:
void Send404(SOCKET client) { std::string body = "<html><body><h1>404 Not Found</h1></body></html>"; SendResponseHeader(client, 404, "text/html; charset=utf-8", body.size()); send(client, body.c_str(), body.size(), 0); }注意:send 可能只发出部分数据,严格讲这两个 send 都需要循环。不过在局域网环境、页面很小时,一次 send 通常能发完;为了稳妥,还是应该把 send 包在循环里,后面第 5 章会专门讲这个坑。
4. 让浏览器能看到图片和网站内页:路径映射、目录安全和动态页面
4.1 把 URL 路径映射到本地文件
服务器需要有一个根目录,所有文件都从根目录下找,绝不允许浏览器请求随便指向磁盘任意位置。映射函数做三件事:拼路径、规范化、防目录穿越。
bool ResolveLocalPath(const std::string& root, const std::string& urlPath, std::string& localPath) { if (urlPath.empty() || urlPath[0] != '/') return false; std::string relative = urlPath.substr(1); std::string candidate = root + "\\" + relative; char full[_MAX_PATH] = { 0 }; if (_fullpath(full, candidate.c_str(), _MAX_PATH) == nullptr) return false; std::string fullStr = full; if (fullStr.compare(0, root.size(), root) != 0) return false; // 防止 root 是 C:\webroot,攻击者用 C:\webroot2 绕过 if (fullStr.size() > root.size() && fullStr[root.size()] != '\\') { return false; } localPath = fullStr; return true; }逻辑说明:Windows 的_fullpath会把..和.全部展开成绝对路径。比如 URL 里包含/../../windows/win.ini,展开后变成C:\Windows\win.ini,前缀检查会直接拒绝。后面那个额外判断很重要,它保证请求的前缀分割符是反斜杠而不是另一个同前缀目录。
参数说明:_MAX_PATH是 Windows 路径最大长度 260,存储在栈上即可。如果传给这个函数的urlPath已经被 UrlDecode 解码,还带着?query参数,记得先截断问号后内容。
4.2 默认页与目录请求
URL 的/表示根目录,按 Web 服务器惯例返回 index.html。如果 index.html 不存在,可以生成一个简单的目录列表,列出当前目录下的文件和子目录。
std::string GetIndexPath(const std::string& root) { return root + "\\index.html"; }目录列表不是必须,但调试时能看到请求到了哪个文件很省事。我一般用_findfirst遍历目录,生成<a href="/xxx">链接列表。这里有个细节:拼接 href 时要保留 URL 风格的正斜杠/,不能拼成\,否则部分浏览器会把\当成合法路径或者直接拒绝。
如果你只希望这是一个最简单的服务器,默认页硬编码返回一个字符串也行,像下面这样:
std::string html = BuildPage(); SendResponseHeader(client, 200, "text/html; charset=utf-8", html.size()); send(client, html.c_str(), html.size(), 0);4.3 读取本地文件并循环 send
读取静态文件的关键,是使用rb二进制模式、拿到文件长度、循环 send。图片和 HTML 文件都是二进制字节流,不能用文本模式读,否则 Windows 下的0x1A会被当作文件结束符,图片会被截断。
bool SendStaticFile(SOCKET client, const std::string& filePath) { FILE* fp = nullptr; errno_t err = fopen_s(&fp, filePath.c_str(), "rb"); if (err != 0) return false; fseek(fp, 0, SEEK_END); long fileSize = ftell(fp); fseek(fp, 0, SEEK_SET); SendResponseHeader(client, 200, GetContentType(filePath), fileSize); char buf[8192]; size_t bytesRead = 0; while ((bytesRead = fread(buf, 1, sizeof(buf), fp)) > 0) { int offset = 0; while (offset < (int)bytesRead) { int nSent = send(client, buf + offset, (int)bytesRead - offset, 0); if (nSent == SOCKET_ERROR || nSent == 0) { fclose(fp); return false; } offset += nSent; } } fclose(fp); return true; }逻辑说明:fread 返回真实读取的字节数,send 可能只发送一部分。内层循环把剩余部分继续发完,直到整个缓冲区发送结束。很多“大图传一半”的坑就是少了这个循环。
参数说明:缓冲区 8 KB,对一般网页足够。如果换 1 MB 大块发送,可以减少循环次数,但内存占用变大,每次 fread 也要先知道文件大小。读到文件尾部时返回字节数可能小于 8192,用返回值参与发送循环。
4.4 构造一个可访问的“网站内页”
所谓“可访问图片和网站内页”,核心是让浏览器先获取一个 HTML 页面,页面里再引用几张图片。浏览器看到<img>标签后会向服务器发起第二次请求,请求路径为/images/xxx。服务器内页生成逻辑可以动态加入时间、链接和图片。
std::string BuildHomePage() { time_t now = time(nullptr); char timeStr[64] = { 0 }; ctime_s(timeStr, sizeof(timeStr), &now); std::string html; html = "<html>\r\n"; html += "<head><meta charset=\"utf-8\"><title>内页演示</title></head>\r\n"; html += "<body>\r\n"; html += "<h1>这是一个由 VC/MFC 服务器生成的内页</h1>\r\n"; html += std::string("<p>生成时间:") + timeStr + "</p>\r\n"; html += "<img src=\"/images/demo.png\" alt=\"演示图片\" />\r\n"; html += "<p><a href=\"/page2.html\">进入下一个内页</a></p>\r\n"; html += "</body>\r\n"; html += "</html>\r\n"; return html; }逻辑说明:img 的 src 使用的是绝对路径/images/demo.png,浏览器会向同一台服务器发起第二次请求。必须保证服务器根目录下真的存在images\demo.png文件,否则第二次请求返回 404,页面就会出现破图。
如果页面或文件名里有中文,浏览器会自动把它转成百分号编码,服务器端再做 UrlDecode。所以我建议页面内所有链接路径先用英文,调试通过后再加中文。
4.5 把日志显示到 MFC 界面
既然用 MFC,不做界面有点浪费。把请求日志滚动输出到 CListBox 或 CListCtrl,调试直观很多。工作线程里不能直接操作 UI,安全做法是 PostMessage 到主窗口。
#define WM_SERVER_LOG (WM_USER + 200) void PostLog(const CString& text) { CString* item = new CString(text); ::PostMessage(g_hMainWnd, WM_SERVER_LOG, (WPARAM)item, 0); }主窗口消息处理函数:
LRESULT CMainDlg::OnServerLog(WPARAM wParam, LPARAM) { CString* msg = reinterpret_cast<CString*>(wParam); if (msg != nullptr) { m_logList.AddString(*msg); delete msg; } return 0; }提示:> 注意:PostMessage 是异步的,传给它的指针必须从堆上 new 出来,不能传局部变量地址,否则消息还没处理,栈对象就已经析构了。主线程处理完消息后负责 delete,能有效避免内存泄漏和悬垂指针。
4.6 一个完整的请求处理流程
把前面几个环节串联起来,ClientThread 的执行流程如下:
UINT WINAPI ClientThread(LPVOID arg) { SOCKET client = (SOCKET)arg; std::string requestBuf; if (!ReadRequest(client, requestBuf)) { closesocket(client); return 0; } std::string method, urlPath, version; if (!ParseRequestLine(requestBuf, method, urlPath, version)) { SendResponseHeader(client, 400, "text/plain; charset=utf-8", 0); closesocket(client); return 0; } // HEAD 与 GET 都可处理静态文件 if (method != "GET" && method != "HEAD") { SendResponseHeader(client, 405, "text/plain; charset=utf-8", 0); closesocket(client); return 0; } std::string decodedPath = UrlDecode(urlPath); if (decodedPath == "/") decodedPath = "/index.html"; std::string localPath; if (!ResolveLocalPath(g_rootDir, decodedPath, localPath)) { SendResponseHeader(client, 403, "text/plain; charset=utf-8", 0); closesocket(client); return 0; } if (!SendStaticFile(client, localPath)) { Send404(client); } closesocket(client); return 0; }逻辑说明:ReadRequest 负责拼装完整头部;ParseRequestLine 取请求行并拆字段;路径解析前先做 URL 解码;根路径/映射到 index.html。SendStaticFile 内部会打开文件并发送,如果文件不存在就返回 false,然后发送 404。
HEAD 请求在真实服务器上应该只返回头部不发正文,这里简化为和 GET 一样处理。如果头文件大小很大,HEAD 会白白占用带宽,但对这个规模的小服务器没什么影响。
5. 避坑记录:五个让浏览器和服务器互相折磨的典型问题
5.1 浏览器一直转圈,显示“等待服务器响应”
现象:浏览器打开http://127.0.0.1:8080/,地址栏一直在转,页面空白,开发者工具的 Network 面板里 Request 状态显示 pending。
原因:HTTP/1.1 默认是持久连接。如果响应头里没有 Content-Length,浏览器就不知道一个响应到底什么时候结束。它会在 keep-alive 状态里一直等着,直到服务器主动关闭连接或者超时。
解决:每个响应都要带上Content-Length和Connection: close。只要这两样写全,即使服务器发送完数据后不主动关闭 socket,浏览器也能立刻判断数据接收完整。
head += "Content-Length: " + std::to_string(fileSize) + "\r\n"; head += "Connection: close\r\n";调试时一定用 curl 看响应头:
curl -v http://127.0.0.1:8080/如果响应头里没有长度字段,或者长度比实际文件大,立刻就能发现问题。
5.2 图片变成乱码或触发下载框
现象:HTML 页面正常打开,但图片区域显示一串符号,或者浏览器弹出“下载 image.png”的提示。
原因:Content-Type 没按图片类型设置,或者文件读取时用了文本模式。文本模式下 Windows 会把 0x1A 当成文件结束符,图片半途而废;0x0D 0x0A 也有可能被转换,导致字节数和 Content-Length 不一致。
解决:fopen 时用"rb",不能写"r"。GetContentType 根据扩展名小写后匹配,避免路径是大写后缀时匹配失败。最直观的检查方法是把响应头打印出来,看图片请求的 Content-Type 是不是 image/png,如果不是就查扩展名映射函数。
5.3 recv 只收到半截请求头导致解析失败
现象:服务器日志里输出 “Unknown request”,仔细看请求行只有GET /ind,后面什么都没解析出来。
原因:TCP 是流协议,不是消息边界协议。socket 收到的数据可能被分成多个段,一次 recv 只返回其中一部分。如果代码只 recv 一次然后立刻解析,就会把半截数据当完整包处理。
解决:循环 recv,直到找到\r\n\r\n或超出上限。前面已经给出实现。注意每次 recv 后要 append,而不是覆盖缓冲区,否则后半段数据会被新数据冲掉。
5.4 端口被占用与 TIMEWAIT
现象:程序退出后马上重启,bind 失败,WSAGetLastError 返回 10048。netstat 看端口仍处于 TIME_WAIT。
原因:TCP 连接关闭后,端口会在 TIME_WAIT 状态里停留一段时间,等可能迟到的数据包消失。如果进程被调试器强杀,连接没有正常关闭,这个状态残留时间更长。
解决:监听 socket 创建后立刻设置 SO_REUSEADDR。这个选项允许多个 socket 绑定同一端口,但条件允许的情况下,能让 TIME_WAIT 中的端口被重新绑定。
BOOL reuse = TRUE; setsockopt(sock, SOL_SOCKET, SO_REUSEADDR, (const char*)&reuse, sizeof(reuse));如果还是 10048,用命令找到占用 PID:
netstat -ano | findstr :8080 taskkill /PID <pid> /F5.5 工作线程直接操作 MFC 控件导致崩溃
现象:图片一多,程序主界面卡死,或者弹 “Debug Assertion Failed”,调用堆栈指向 CListBox 或 CListCtrl 的某个方法。
原因:Windows 控件不是线程安全的。工作线程里直接调用 AddString,实际上是在没有消息循环的线程里操作窗口对象,MFC 内部缓存和 HWND 都会失去同步。
解决:使用 PostMessage 把日志文本传回主线程,由主线程在消息处理函数里操作控件。指针必须堆分配,主线程接收后 delete。整个通信模式就一句话:任何 UI 操作都通过消息投递,不允许工作线程直接碰窗口句柄。
6. 让服务器更接近真实:加一点 Keep-Alive 和请求日志
6.1 在一个连接里处理多个请求
当前的服务器每个请求都关闭连接,实现简单效率低。如果想提高内页加载速度,可以做 Keep-Alive。实现方式并不复杂:解析请求头里的 Connection 字段,如果值是 keep-alive,发送完响应后不 closesocket,继续下一次 recv。
判断函数可以这样写:
bool IsKeepAlive(const std::string& request) { size_t pos = request.find("Connection:"); if (pos == std::string::npos) return true; // HTTP/1.1 默认 keep-alive return request.find("keep-alive", pos + 11) != std::string::npos; }注意:如果响应头带了Connection: close,浏览器就不会复用连接。如果要支持长连接,响应头就不要写 close,而是要精确输出 Content-Length,让浏览器知道每次响应结束的位置。
6.2 写一个文件日志
在 ClientThread 里把请求行和状态码写进文本文件,排查问题比看 MFC 界面更直接。注意多线程写同一个文件不能频繁 fopen,否则可能错行。我一般把日志消息 PostMessage 给主线程,由主线程统一写文件。
void WriteAccessLog(const std::string& logLine) { FILE* fp = nullptr; fopen_s(&fp, "access.log", "a"); if (fp) { fprintf(fp, "%s\n", logLine.c_str()); fclose(fp); } }这个函数的代价是每条日志一次磁盘操作,对小型服务器完全可接受。如果日后再优化,可以考虑用内存缓冲区批量刷盘。
6.3 把这些代码收进一个类
建议把 socket 相关代码整理成 HttpServer 类,成员包括 rootDir、端口、日志回调、启动/停止方法。MFC 对话框只需要一个启动按钮,调用server.Start(8080, m_hWnd),其余逻辑和 UI 完全解耦。这样以后把服务器换成线程池或第三方库,界面层几乎不用动。
我第一次把这个原型交给某公司测试时,忘了循环 send,一张 2MB 的截图传了半天还中断。后来我把所有发送操作都包进一个循环发送的函数,从那以后每次写 socket 发送代码都会强制走一遍这个流程,再也没在这个位置翻过车。希望帮到你。
本文还有配套的精品资源,点击获取