首页 / 资讯中心 / 文章详情

手写HTTP服务器:MFC与Winsock实现局域网文件共享

手写HTTP服务器:MFC与Winsock实现局域网文件共享 ★ FEATURED ARTICLE
简介一套基于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 而不是 CAsyncSocketMFC 自带 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 表示 IPv4SOCK_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 KB64 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; charsetutf-8, 0); closesocket(client); return -1; }逻辑说明istringstream 用空格作为分隔符天然切出三块。method 是 GET 或 POSTpath 是类似/images/logo.pngversion 是 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 .htmtext/html; charsetutf-8.csstext/css; charsetutf-8.jsapplication/javascript.pngimage/png.jpg .jpegimage/jpeg.gifimage/gif.svgimage/svgxml.icoimage/x-icon.txttext/plain; charsetutf-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; charsetutf-8; if (ext css) return text/css; charsetutf-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/svgxml; if (ext ico) return image/x-icon; return application/octet-stream; }逻辑说明扩展名可能大写所以统一转小写。text 类型加 charsetutf-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 htmlbodyh1404 Not Found/h1/body/html; SendResponseHeader(client, 404, text/html; charsetutf-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; charsetutf-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 headmeta 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 pa 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_castCString*(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; charsetutf-8, 0); closesocket(client); return 0; } // HEAD 与 GET 都可处理静态文件 if (method ! GET method ! HEAD) { SendResponseHeader(client, 405, text/plain; charsetutf-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; charsetutf-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用命令找到占用 PIDnetstat -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 发送代码都会强制走一遍这个流程再也没在这个位置翻过车。希望帮到你。本文还有配套的精品资源点击获取
阅读完成 · 觉得有帮助?
咨询建站