简介:面向具备C++基础、希望理解Windows网络编程与HTTP协议实践的开发者,这份VC/MFC工程演示如何用Winsock搭建可访问图片和站点内页的轻量服务器。内容围绕套接字初始化、监听、连接接收与请求解析展开,处理GET请求下的文件查找、常见状态码及Content-Type设置,图片按JPEG、PNG类型返回,内页结合目录结构与相对链接加载HTML和配套资源。压缩包共26个文件,约115KB,以10个头文件和8个cpp源文件为主体,承载服务器、请求套接字与对话框逻辑;另含rc资源、vcproj工程、ico图标、txt说明和可执行文件,便于编译对照学习。已有2352人学习下载。读者可获得一套可运行的服务端骨架、响应组织方式,以及Winsock初始化、套接字关闭与资源释放的初步排错思路,适合网络课程设计或自学者进阶服务端开发参考。
1. 双击 exe 就能用的 VC HTTP 服务器,解决的是"不想装环境"这件事
某公司现场找到我,说车间局域网里想用手机浏览器看设备截图和几张报表页,装 IIS 太重,临时起个脚本服务又得给那台老工控机配运行环境。最后翻出来的是一个 VC HTTP 服务器的小工程:MFC 对话框程序起一个 Winsock 监听线程,绑定 8080 端口,浏览器访问就能拿到index.html和目录下的图片。它的价值不在性能,而在于"一个 exe 拷过去双击就跑",不用注册表、不用服务安装、不用额外运行库。适合两类人:手上有老 MFC 上位机项目、想顺手挂个网页界面的开发者;以及想搞明白 HTTP 协议到底长什么样、想从 socket 层手写一遍响应报文的学习者。代码量不大,但每一行都对应协议里真实存在的东西。
2. 工程结构与技术选型:MFC 对话框程序里怎么塞下一个监听循环
2.1 一份能落地的文件清单与各自职责
这类工程的目录结构通常很朴素,但职责边界必须划清楚,否则后面加功能会乱成一锅粥。我习惯的分法是:界面归界面,协议归协议,socket 归 socket,三者之间只通过函数调用和消息传递打交道。对话框类只管按钮和日志框,CHttpServer管生命周期和监听,请求解析单独抽一个文件,MIME 表和路径处理放进工具头文件。
HttpServer/ ├── HttpServerDlg.h/.cpp // 主对话框:启动/停止按钮、日志 ListBox、端口与根目录输入 ├── HttpServer.h/.cpp // 监听器:WSAStartup、socket/bind/listen/accept、线程调度 ├── HttpRequest.h/.cpp // 请求解析:请求行、头部、URL 解码、路径安全校验 ├── MimeType.h // 扩展名到 Content-Type 的静态查表 ├── Resource.h // 对话框控件 ID └── wwwroot/ // Web 根目录,跟着 exe 走 ├── index.html ├── style.css └── images/logo.png这里有个容易被忽略的点:wwwroot到底放在哪。如果按 exe 所在目录拼相对路径,调试时是Debug\wwwroot,发布时是Release\wwwroot,很容易出现"我明明放了文件却 404"。我一般会在启动时用GetModuleFileName拿到 exe 全路径,再取目录拼wwwroot,并且把最终解析出来的绝对路径打到日志里,出问题第一眼就能看到程序到底在找哪。
2.2 原生 Winsock、CAsyncSocket、CSocket 怎么选
MFC 提供了两层封装:CAsyncSocket是 Winsock 的薄封装,回调式,仍然要自己处理阻塞和非阻塞的边界;CSocket更厚,内部靠消息泵做伪阻塞,在 UI 线程外用起来有不少限制,尤其在工作线程里创建CSocket会有隐患。做服务器这类"一个 accept 循环 + 若干连接"的场景,我建议直接用原生 Winsock API,理由很实在:行为可预期,出错时WSAGetLastError()拿到的是原始错误码,不会在封装层被吞掉。
| 方案 | 上手成本 | 线程安全 | 排错难度 | 适用场景 |
|---|---|---|---|---|
| 原生 Winsock | 中 | 自己控制 | 低,错误码直接 | 服务端监听、多连接 |
| CAsyncSocket | 低 | 需自行加锁 | 中,回调里出错难追 | 客户端、少量连接 |
| CSocket | 最低 | 依赖消息泵 | 高,阻塞行为隐晦 | 简单客户端、教学演示 |
选原生还有一个现实好处:网上关于recv返回 0、WSAEWOULDBLOCK的处理经验基本都是按原生 API 写的,遇到玄学问题时能对上号。用封装层遇到同样的现象,你得先猜封装做了什么,再回头查文档,血泪经验。
2.3 监听端口的初始化、绑定与退出清理
监听器的启动顺序必须严格:WSAStartup→socket→setsockopt→bind→listen→ 起线程。少一步或者顺序颠倒,错误码会很难懂。下面这段是核心骨架,我加了几处实际项目中必须有的防御。
// HttpServer.cpp BOOL CHttpServer::Start(UINT nPort, const CString& strRoot, CWnd* pLogWnd) { m_strRoot = strRoot; m_pLogWnd = pLogWnd; m_bRunning = TRUE; WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) // 必须先初始化,否则后面全部失败 return FALSE; m_hListen = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (m_hListen == INVALID_SOCKET) { WSACleanup(); return FALSE; } // 关键:进程重启时端口常处于 TIME_WAIT,不复用会 bind 失败 int nReuse = 1; setsockopt(m_hListen, SOL_SOCKET, SO_REUSEADDR, (const char*)&nReuse, sizeof(nReuse)); sockaddr_in addr; ZeroMemory(&addr, sizeof(addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡,手机才能访问 addr.sin_port = htons((u_short)nPort); // 主机序转网络序,忘了就是乱端口 if (bind(m_hListen, (sockaddr*)&addr, sizeof(addr)) == SOCKET_ERROR) { Log(_T("bind 失败,错误码: %d"), WSAGetLastError()); closesocket(m_hListen); WSACleanup(); return FALSE; } if (listen(m_hListen, SOMAXCONN) == SOCKET_ERROR) // SOMAXCONN 交给系统定队列深度 { Log(_T("listen 失败,错误码: %d"), WSAGetLastError()); closesocket(m_hListen); WSACleanup(); return FALSE; } AfxBeginThread(AcceptThread, this); // accept 必须离开 UI 线程 return TRUE; }参数上有两处值得掰开说。SO_REUSEADDR不是可有可无的优化,它决定你调试期间反复启停时会不会被"端口已被占用"拦住;如果还需要多个进程抢同一个端口,才考虑SO_REUSEPORT,但 Windows 上的行为和 Linux 不完全一致,不建议在这类小工程里碰。INADDR_ANY意味着监听所有网卡,如果你只想本机访问,改成inet_addr("127.0.0.1"),这样局域网里其他人扫不到端口,安全边界清楚很多。
停止逻辑要跟启动对称,并且必须先把m_bRunning置 FALSE 再closesocket,否则 accept 线程可能卡在一个永远不会返回的调用上。
void CHttpServer::Stop() { m_bRunning = FALSE; if (m_hListen != INVALID_SOCKET) { closesocket(m_hListen); // 唤醒阻塞中的 accept m_hListen = INVALID_SOCKET; } Sleep(100); // 给工作线程一点退出时间,实际项目可换成事件等待 WSACleanup(); }Sleep(100)是个偷懒写法,正规做法是用CreateEvent加WaitForSingleObject等线程真正退出,再WSACleanup。为什么不能省这一步:WSACleanup会让本进程所有 socket 失效,如果工作线程还在跑,它会拿到一堆莫名其妙的错误码,日志里全是噪音,排错时你会怀疑人生。
3. 请求解析链路:从 recv 缓冲区里抠出方法、路径和头部
3.1 半包与粘包:为什么不能一次 recv 就当收到完整请求
初学者最常见的写法是recv(s, buf, 4096, 0)然后直接把buf当完整请求去解析,在浏览器地址栏敲一次回车可能没问题,一旦页面里带 CSS 和图片,浏览器会复用连接连续发多个请求,或者请求头较长被拆成两次到达,立刻翻车。TCP 是字节流,不保证边界,这个坑必须靠"读到头部结束标记"来填。
CStringA CHttpServer::RecvRequest(SOCKET sClient) { CStringA strBuf; char szChunk[4096]; while (m_bRunning) { int nRet = recv(sClient, szChunk, sizeof(szChunk), 0); if (nRet == 0) break; // 对端正常关闭 if (nRet < 0) return CStringA(); // 出错,交给上层返回 400 strBuf += CStringA(szChunk, nRet); // 按长度追加,不能当 C 字符串用 if (strBuf.Find("\r\n\r\n") >= 0) break; // 头部结束标记出现,可以停了 if (strBuf.GetLength() > 16384) break; // 防御:畸形请求直接掐断 } return strBuf; }这段代码有三个细节值得注意。CStringA(szChunk, nRet)这个双参数构造是必须的,如果写成strBuf += szChunk,recv回来的数据里一旦有\0(POST 的二进制体、或者某些客户端填充),拼接就会提前截断,而且这种 bug 只在特定请求下出现,属于典型玄学问题。结束判断用\r\n\r\n而不是\n\n,因为 HTTP 规范要求 CRLF。长度上限 16KB 是经验值,普通 GET 请求头不会超过 8KB,超过的大概率是恶意构造或客户端异常,直接放弃比继续读更安全。
3.2 请求行拆解与 URL 百分号解码
拿到完整头部后,第一行是GET /images/logo.png HTTP/1.1这种格式,按空格切三段就够了。要注意的是方法可能大写也可能小写(虽然规范要求大写,但浏览器之外的客户端不一定守规矩),路径可能带查询串?v=1,这两点不处理都会导致 404。
BOOL ParseRequestLine(const CStringA& strRaw, CStringA& strMethod, CStringA& strPath, CStringA& strVersion) { int nLineEnd = strRaw.Find("\r\n"); CStringA strLine = (nLineEnd > 0) ? strRaw.Left(nLineEnd) : strRaw; if (strLine.IsEmpty()) return FALSE; int nSp1 = strLine.Find(' '); if (nSp1 <= 0) return FALSE; int nSp2 = strLine.Find(' ', nSp1 + 1); strMethod = strLine.Left(nSp1); strMethod.MakeUpper(); // 统一大写,后面比较省事 strPath = (nSp2 > nSp1) ? strLine.Mid(nSp1 + 1, nSp2 - nSp1 - 1) : strLine.Mid(nSp1 + 1); strVersion = (nSp2 > 0) ? strLine.Mid(nSp2 + 1) : CStringA("HTTP/1.0"); int nQuery = strPath.Find('?'); if (nQuery >= 0) strPath = strPath.Left(nQuery); // 静态服务不关心查询串 return !strPath.IsEmpty(); }URL 解码要单独写,因为文件名里出现空格或中文时,浏览器会发%20和%E4%B8%AD这种形式。注意浏览器发的百分号编码通常是 UTF-8 字节流,而 MFC 工程如果是 Unicode 编译,CString内部是 UTF-16,中间必须转一道,直接MultiByteToWideChar(CP_ACP, ...)会把中文路径变成乱码,然后就是"文件明明在,死活 404"。
CString UrlDecodeToTString(const CStringA& strIn) { CStringA strTmp; for (int i = 0; i < strIn.GetLength(); ++i) { char c = strIn[i]; if (c == '%' && i + 2 < strIn.GetLength()) { char hex[3] = { strIn[i + 1], strIn[i + 2], 0 }; strTmp += (char)strtol(hex, NULL, 16); // 还原成一个原始字节 i += 2; } else if (c == '+') strTmp += ' '; // 表单编码里的加号等于空格 else strTmp += c; } int nLen = MultiByteToWideChar(CP_UTF8, 0, strTmp, strTmp.GetLength(), NULL, 0); CString strOut; MultiByteToWideChar(CP_UTF8, 0, strTmp, strTmp.GetLength(), strOut.GetBuffer(nLen), nLen); strOut.ReleaseBuffer(nLen); return strOut; }这里必须用CP_UTF8而不是CP_ACP,因为现代浏览器默认按 UTF-8 编码 URL。如果你是为了兼容某个老客户端,可以加个判断:先试 UTF-8 解码,转换失败再退回CP_ACP,但别默认就用CP_ACP,那是十年前 IE 时代的习惯。
3.3 路径映射与目录穿越拦截
把 URL 路径映射成本地文件路径,是整个服务里唯一有安全风险的地方。写错了,攻击者请求/../../windows/win.ini就能读走系统文件。防御思路不是简单检查有没有..(因为%2e%2e、..%5c各种变形都能绕过),而是先规范化成绝对路径,再判断它是否还在根目录之下。
CString CHttpServer::ResolvePath(const CString& strUrlPath) { CString strRel = strUrlPath; if (strRel.IsEmpty() || strRel == _T("/")) strRel = _T("/index.html"); strRel.Replace(_T('/'), _T('\\')); // 统一成 Windows 分隔符 CString strCandidate = m_strRoot + strRel; TCHAR szFull[MAX_PATH * 2] = { 0 }; if (GetFullPathName(strCandidate, MAX_PATH * 2, szFull, NULL) == 0) return CString(); // 路径非法 CString strFull(szFull); CString strRootFull = m_strRoot; strRootFull.MakeLower(); CString strCheck = strFull; strCheck.MakeLower(); // 规范化后仍以根目录开头,才算合法 if (strCheck.Left(strRootFull.GetLength()) != strRootFull) return CString(); return strFull; }GetFullPathName是关键一步,它会把..、.、重复分隔符统统折叠掉,得到唯一的绝对路径,之后再做前缀比较才靠得住。比较时统一转小写,是因为 Windows 文件系统大小写不敏感,C:\WWW和c:\www必须视为同一个根。返回值留空,上层直接回 403,不要把真实路径写进错误页面,那等于给探测者送情报。
还有一个细节:根目录字符串必须以反斜杠结尾再拼接,否则C:\wwwroot加上\..\secret.txt规范化后变成C:\secret.txt,前缀比较能对上C:\wwwroot吗?对不上,会被拦住,但C:\wwwroot2这种兄弟目录就会误判。所以m_strRoot在初始化时就补上尾部反斜杠,比在比较函数里临时处理更稳。
4. 静态资源响应:图片、HTML 与 MIME 类型怎么发才对
4.1 MIME 查表:浏览器是靠 Content-Type 决定怎么渲染的
HTTP 响应头里最容易被新手忽略的是Content-Type。少了它,浏览器会自己猜,现代浏览器基本都会猜错:.css被当纯文本显示,.js直接拒绝执行(因为 MIME 类型检查),.png可能显示成一堆乱码。这不是浏览器的锅,是服务端没告诉它这是什么。
// MimeType.h struct MIME_ENTRY { LPCTSTR szExt; LPCTSTR szType; }; static const MIME_ENTRY g_mimeTable[] = { { _T(".html"), _T("text/html; charset=utf-8") }, { _T(".htm"), _T("text/html; charset=utf-8") }, { _T(".css"), _T("text/css; charset=utf-8") }, { _T(".js"), _T("application/javascript; charset=utf-8") }, { _T(".json"), _T("application/json; charset=utf-8") }, { _T(".png"), _T("image/png") }, { _T(".jpg"), _T("image/jpeg") }, { _T(".jpeg"), _T("image/jpeg") }, { _T(".gif"), _T("image/gif") }, { _T(".ico"), _T("image/x-icon") }, { _T(".svg"), _T("image/svg+xml") }, { _T(".txt"), _T("text/plain; charset=utf-8") }, }; CString GetMimeType(const CString& strPath) { int nDot = strPath.ReverseFind(_T('.')); if (nDot < 0) return _T("application/octet-stream"); CString strExt = strPath.Mid(nDot); strExt.MakeLower(); // 扩展名大小写不敏感 for (int i = 0; i < _countof(g_mimeTable); ++i) { if (strExt == g_mimeTable[i].szExt) return g_mimeTable[i].szType; } return _T("application/octet-stream"); // 未知类型一律按二进制流 }表里的字符集声明不能省。HTML 里如果没写<meta charset>,浏览器就靠响应头的charset=utf-8判断编码,缺了这一项,中文页面大概率变乱码。图片类型不需要 charset,加了反而多余。兜底用application/octet-stream是标准做法,浏览器会当成下载或者直接展示二进制,至少不会错误地当 HTML 渲染(这一点在安全上很重要,避免用户上传的文件被当页面执行)。
4.2 二进制读文件与定长循环发送
响应体是图片时必须按二进制读,这是最容易翻车的地方。CFile默认就是二进制模式,别用CStdioFile,它是文本模式,遇到0x1A(Ctrl+Z)会当成 EOF 提前结束,读 PNG 时表现为"图片只显示上半截"或者干脆裂开,而且不报任何错,纯纯的黑匣子。
BOOL CHttpServer::SendFile(SOCKET sClient, const CString& strFullPath, const CString& strMime) { CFile file; CFileException e; if (!file.Open(strFullPath, CFile::modeRead | CFile::shareDenyWrite, &e)) return FALSE; ULONGLONG ullLen = file.GetLength(); CStringA strHeader; strHeader.Format( "HTTP/1.1 200 OK\r\n" "Content-Type: %s\r\n" "Content-Length: %I64u\r\n" "Connection: close\r\n" "Cache-Control: no-cache\r\n" "\r\n", (LPCSTR)CStringA(strMime), ullLen); if (!SendAll(sClient, (const BYTE*)strHeader.GetString(), strHeader.GetLength())) { file.Close(); return FALSE; } BYTE buf[8192]; UINT nRead = 0; while ((nRead = file.Read(buf, sizeof(buf))) > 0) { if (!SendAll(sClient, buf, nRead)) break; } file.Close(); return TRUE; }Content-Length用%I64u是因为GetLength()返回 64 位长度,超过 4GB 的文件(视频常见)用 32 位会溢出成负数,浏览器会一直转圈等数据。Connection: close表示响应完就断开,省得处理 keep-alive 的连接复用逻辑,对这类小服务完全够用,代价是每个资源都要新建一次连接,页面资源多时会略慢,但换来的是代码简单可靠。
SendAll是必须封装的,因为send的返回值表示"这次发出去多少",不保证全发完。大数据量时内核发送缓冲区满了就会只发一部分,剩下的必须继续发,否则客户端收到的是半截文件。
BOOL CHttpServer::SendAll(SOCKET sClient, const BYTE* pData, int nLen) { int nSent = 0; while (nSent < nLen) { int nRet = send(sClient, (const char*)pData + nSent, nLen - nSent, 0); if (nRet <= 0) return FALSE; nSent += nRet; } return TRUE; }指针偏移用pData + nSent,长度用nLen - nSent,这两个参数写反是经典错误,症状是图片能显示但末尾一段是花屏,或者服务端 log 显示发送成功但客户端收不全。写这段代码时我一般会在循环里临时加一行日志打印nSent,确认它真的走到过nSent < nLen的分支,否则不知道循环发送逻辑到底有没有被触发过。
4.3 404、403、405 与 favicon 的分支处理
处理函数的主干要把各种情况分清楚,别一股脑全返回 404。方法不是 GET 就回 405,路径校验失败回 403,文件不存在回 404,读文件失败回 500。状态码对了,前端调试才有方向。
void CHttpServer::HandleClient(SOCKET sClient) { CStringA strRaw = RecvRequest(sClient); if (strRaw.IsEmpty()) return; CStringA strMethod, strPathA, strVersion; if (!ParseRequestLine(strRaw, strMethod, strPathA, strVersion)) { SendError(sClient, 400, "Bad Request"); return; } if (strMethod != "GET" && strMethod != "HEAD") { SendError(sClient, 405, "Method Not Allowed"); return; } CString strPath = UrlDecodeToTString(strPathA); CString strFull = ResolvePath(strPath); if (strFull.IsEmpty()) { SendError(sClient, 403, "Forbidden"); return; } if (GetFileAttributes(strFull) == INVALID_FILE_ATTRIBUTES) { SendError(sClient, 404, "Not Found"); return; } CString strMime = GetMimeType(strFull); if (!SendFile(sClient, strFull, strMime)) SendError(sClient, 500, "Internal Server Error"); }HEAD方法顺手支持一下不费事,它和 GET 的差别只是不返回正文,但要在响应头里给出正确的Content-Length。有些监控工具会用 HEAD 探活,不支持的话会看到 405,容易误判服务挂了。浏览器还会自动请求/favicon.ico,你的目录里没有就返回 404,控制台会有一条红色报错,看着烦但无害;想消掉就在wwwroot下放一个 ico,或者专门拦一下这个路径返回 204。
5. 避坑与排查:浏览器白屏、图片裂开、端口占用这些坑
5.1 五条踩坑记录:现象、原因、解决
现象一:浏览器一直转圈,最后超时,服务端日志显示连接已建立。原因通常是响应头拼错了,比如\r\n写成了\n,或者忘了最后的空行。HTTP 头部靠空行结束,少了这个空行,浏览器会一直等头部后续内容,直到超时。排查方法是把拼出来的响应头原样打到日志里,逐字节看有没有\r\n\r\n。我一般会在strHeader拼完后OutputDebugStringA一次,肉眼扫一遍比猜快得多。
现象二:HTML 正常,图片全是裂的,右键另存为得到的文件比原文件小。八成是读文件用了文本模式,或者用了strlen之类按\0截断的函数取长度。图片二进制里\0遍地都是,一旦按字符串处理,长度立刻算短。解决办法是全程二进制:CFile::modeRead(默认就是 binary)、长度用GetLength()、发送用SendAll传字节数。别用CStdioFile,别用fopen(..., "r")。
现象三:程序重启后 bind 失败,错误码 10048。10048是WSAEADDRINUSE,端口被占。两种情况:上一次进程还没完全退出,socket 处于TIME_WAIT;或者别的程序占了这个端口。前者加SO_REUSEADDR就能缓解,后者得换端口。诊断用netstat -ano | findstr :8080看是哪个 PID 占着,再对任务管理器。血泪经验是:别图省事把端口写死在代码里,做成配置文件或者对话框输入框,现场调试时能省半小时。
现象四:界面点"启动服务"后整个窗口卡死,鼠标变成沙漏。accept是阻塞调用,放 UI 线程里执行,消息循环就停了,窗口自然不响应。正确做法是把监听循环整个丢进AfxBeginThread开的工作线程,UI 线程只负责按钮和显示。工作线程里更新界面不能直接SetWindowText,会偶发崩溃,必须走PostMessage把消息投回 UI 线程。
// 工作线程 -> UI 线程的安全通道 if (m_pLogWnd && ::IsWindow(m_pLogWnd->GetSafeHwnd())) { CString* pMsg = new CString(strLog); m_pLogWnd->PostMessage(WM_APP + 100, 0, (LPARAM)pMsg); // 堆上传递,接收方负责 delete }WM_APP + 100是自定义消息的常规起点,WM_APP之后的值系统不会占用,安全。接收方在消息处理函数里delete (CString*)lParam,千万别直接传栈上CString的地址,消息是异步的,函数返回后栈变量就没了,收到的是野指针。
现象五:中文文件名访问 404,日志里路径显示成问号。编码链路断了。浏览器发 UTF-8 百分号编码,服务端用CP_ACP解,两边对不上。统一改成CP_UTF8,并且工程如果是多字节字符集,还得再转一次到CString。这类问题的排查技巧是把解码前后的字符串都打日志,一眼看出在哪一步变味。
5.2 排查顺序与日志埋点
出问题时不要瞎猜,按固定顺序走能省很多时间。第一步看服务端有没有收到连接,日志里应该有accept记录;第二步看请求行解析结果,方法和路径对不对;第三步看ResolvePath返回的绝对路径,是不是你以为的那个文件;第四步看响应头的Content-Type和Content-Length;第五步才怀疑发送逻辑。这五步走完,九成问题都能定位。
日志建议至少打三行:请求行原文、解析后的本地绝对路径、响应状态码和字节数。不要只打"收到请求",那种日志等于没打。调试阶段可以把日志级别调高一点,每次recv的字节数和每次send的返回值都记下来,虽然吵,但出问题时是唯一的后悔药。
提示:浏览器开发者工具的 Network 面板能直接看到响应头和状态码,比自己拼日志快。但如果是连接层面的问题(比如根本没收到请求),就得靠服务端日志和抓包工具配合判断。
6. 二次开发:从静态小站扩到能接动态接口的服务骨架
这个工程的骨架已经够用了,但真要用到项目里,通常得往上加三样东西。第一是动态接口,比如设备状态查询。做法是在HandleClient里对路径做前缀判断,命中/api/就不走文件流程,直接调业务函数拼 JSON 返回,Content-Type用application/json,长度按 UTF-8 字节数算而不是字符数。
if (strPath.Left(5) == _T("/api/")) { CStringA strJson = "{\"temp\":25.3,\"status\":\"ok\"}"; // 实际项目里从设备对象取 CStringA strResp; strResp.Format( "HTTP/1.1 200 OK\r\n" "Content-Type: application/json; charset=utf-8\r\n" "Content-Length: %d\r\n" "Connection: close\r\n\r\n%s", strJson.GetLength(), (LPCSTR)strJson); SendAll(sClient, (const BYTE*)strResp.GetString(), strResp.GetLength()); return; }第二是连接处理改成线程池。现在是一个accept循环里串行处理,遇到慢客户端(比如手机信号差,发一半卡住)会把后面所有请求堵死。简单改法是每accept一个连接就AfxBeginThread一个处理线程,处理完自己退出,但线程创建销毁有开销,请求量大了要换成固定大小的线程池加任务队列。
第三是配置外置。端口、根目录、是否允许局域网访问、是否开日志,这几项写死在代码里,每次现场调整都要重新编译,太难受。用GetPrivateProfileString读一个同目录的 ini 就够了,改完重启 exe 生效,不用动开发环境。
改造后的验证方法很朴素:先用浏览器手动访问首页、图片、一个不存在的路径、一个../路径,确认状态码和内容都对;再写个循环脚本连续请求两百次,看有没有句柄泄漏(任务管理器里看句柄数是否持续上涨);最后拿手机连同一个 wifi 访问一次,确认INADDR_ANY和防火墙都放行了。这三步做完,基本能确认改动没把原来的东西弄坏。
从那以后我每次改这类网络代码,都强制先把日志级别开到最详细,跑一遍完整的资源加载,确认每个send的累计字节数和Content-Length对得上,再关日志。听起来麻烦,但比上线后被"图片裂开"追着问要轻松得多。希望帮到你。
本文还有配套的精品资源,点击获取