☰
VC/MFC手写HTTP服务器:网络编程核心要点与协议解析实践
2026/10/13 7:09:07 网站建设 项目流程

简介:基于Visual C++与MFC实现的HTTP服务器示例工程,面向Windows平台网络编程初学者,完整演示了如何利用MFC类库搭建一个能监听、解析并响应HTTP请求的桌面服务程序。压缩包共27个文件、442KB,除7个头文件和6个C++源文件外,还包含工程配置(dsp/dsw/opt/ncb)、界面资源(rc/rc2/ico/aps)、可执行文件(exe)及说明文档,涵盖Socket封装、服务线程、对话框交互等模块,目录结构清晰。目前已有261人学习下载。通过研读源码可以掌握BLOCKSOCK、SERVERTHREAD等关键类的工作机制,理解请求接收、参数解析、响应构造的完整流程;附带的Server.exe可直接运行验证,配合ServerDlg对话框代码便于逐步调试和二次开发。适合作为理解Windows下HTTP服务器原理与MFC网络编程的入门参考资料。

1. 一个用 VC 手写的 HTTP 服务器:MFC 工程里藏着网络编程最该懂的几件事

“HTTP服务器(vc)”这份资源,是一套用 VC/MFC 写的可运行 HTTP 服务器源码,不是给网页做的后端框架,而是把监听、接收、解析请求、写回响应这几步全部落到 socket 层的小型服务器。它的价值在于:把协议解析和 MFC 网络类的用法摊开在眼前,适合两类人——要交课程设计或补网络编程经验的人,以及想搞明白“浏览器敲一个地址后服务器到底做了什么”的人。MFC 的网络封装看着老,但它没有把底层细节全包进黑匣子,反而比现代框架更容易看清请求-响应的完整链路。下面按协议模型、工程搭建、解析实现、排错、扩展的顺序来拆。

2. 先把协议和模型理顺:为什么 MFC 下做 HTTP 服务优先选异步 socket

2.1 HTTP 请求的最小模型:从请求行到空行

HTTP 的请求不是二进制协议,是一段有固定结构的文本。最小请求长这样:

GET /index.html HTTP/1.1\r\n Host: localhost:8080\r\n Connection: close\r\n \r\n

第一行为请求行,依次是方法、空格、URI、空格、协议版本,最后以回车换行结束。之后一直到空行之间的行都是头部字段,统一是“字段名: 值”的格式。空行,也就是 \r\n\r\n,表示头部结束;再往后如果有数据,才是请求体。GET 请求一般不带请求体,POST 才会在空行之后跟一段数据,数据有多长由头部里的 Content-Length 决定。

解析时最常犯的错误是把“空行”当成“整行倒过来数”。它必须是两个连续的 \r\n,很多入门代码只判断 \n,碰到 Windows 客户端发来的请求就直接把空行前面一个 \r 留在头部里,导致字段解析整体偏移。我一般会先把整段头部用 find("\r\n\r\n") 切出来,再按行拆,行分隔符只认 \r\n。

头部字段对大小写不敏感,比如 Content-Length 写成 content-length 是等价的,但请求行的方法是敏感的,GET 不能写成 get。这是协议里最容易忽略的两条规则,后面解析代码要按这个做。

2.2 选型依据:CAsyncSocket 与阻塞循环的取舍

MFC 里做网络服务有三条路,开销和可控性都不同:

方案阻塞性事件接收适用场景
CAsyncSocket非阻塞消息驱动,OnAccept/OnReceive 回调服务端监听,单线程也能服务多个连接
CSocket阻塞式通过 CArchive 流式读写客户端或一对一通信,连接卡住会拖人
原生 Winsock + 线程由你控制自行 select / WSAWaitForMultipleEvents高并发,但要自己维护线程池

这个资源里选的是 CAsyncSocket,这很合理。服务端最怕的是一个慢客户端把 accept 或 recv 卡死,CAsyncSocket 把“有客户端来了”“有数据到了”变成窗口消息,收到消息再干活,没有一处需要阻塞等待。配合 MFC 的消息泵,整个服务器可以跑在界面线程上,而不需要额外开线程,逻辑也清楚。

使用 CAsyncSocket 必须先定一堆回调。典型派生类是这样:

class CHttpListenSocket : public CAsyncSocket { public: CHttpListenSocket() : m_pServer(NULL) {} // FD_ACCEPT 消息触发,表示有连接排队等待接入 virtual void OnAccept(int nErrorCode) { if (nErrorCode == 0 && m_pServer != NULL) { m_pServer->OnClientAccept(); // 实际在对话框里 Accept } CAsyncSocket::OnAccept(nErrorCode); } void SetCallback(void* pServer) { m_pServer = pServer; } private: void* m_pServer; };

OnAccept 只负责通知“可以接了”,真正的 Accept 要在外面做,原因是 MFC 会为每个接受进来的客户端再准备一个 CAsyncSocket 实例,把创建逻辑放到业务类里更好管理。nErrorCode 不是可忽略的返回值,它等于 WSA 错误码,非零时直接操作 Accept 会拿到一条无效连接。

还有一条和线程有关的限制:CAsyncSocket 依赖窗口消息,必须在有消息循环的线程里创建和运行。把它放在普通工作线程里,事件会收不到,服务看起来“没反应”。

3. 把服务框架搭起来:工程配置、监听启动与第一个响应

3.1 工程配置与字符集陷阱:一个 VC 工程的正确起点

拿到 MFC 工程后,第一件事不是写代码,而是确认两处配置:有没有链接 ws2_32 库;字符集用的是 Unicode 还是多字节。许多老工程会把 Winsock 初始化放在程序入口里:

// 程序启动时初始化 Winsock WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { AfxMessageBox(_T("Winsock 初始化失败")); return FALSE; }

MAKEWORD(2,2) 表示请求 2.2 版本协议栈,这行必须出现在任何 socket 调用之前。除了 WSAStartup,还要确认链接库。比较省心的做法是在 stdafx.h 里补一行:

#pragma comment(lib, "ws2_32.lib")

编译器看到这行就自动带上库,不用去工程设置里翻平台工具集。

字符集是比链接库更容易翻车的点。MFC 的 CString 在 Unicode 工程下是 16 位字符,而 HTTP 协议和 socket 接口都按字节流处理。直接把 CString 用 LPCTSTR 塞进 send,在 Unicode 工程下每个字符变成两个字节,浏览器收到后必乱码。所以发送前要统一转成 UTF-8:

CStringA Utf8FromWide(const CString& str) { int nLen = WideCharToMultiByte(CP_UTF8, 0, str, str.GetLength(), NULL, 0, NULL, NULL); CStringA result; char* pBuf = result.GetBuffer(nLen); WideCharToMultiByte(CP_UTF8, 0, str, str.GetLength(), pBuf, nLen, NULL, NULL); result.ReleaseBuffer(nLen); return result; }

WideCharToMultiByte 的第五个参数是输出缓冲长度,第一次调用传 NULL 是为了让系统算出需要的字节数;倒数第二个参数在 UTF-8 下传 NULL 就够。这里的坑是:输出缓冲长度很容易写成 str.GetLength(),中文文本转 UTF-8 后一定会超,结果就是字符串被截断,页面显示缺字。

3.2 启动监听与主循环:消息驱动下的 accept 节奏

在对话框初始化函数里启动监听:

BOOL CHttpServerDlg::OnInitDialog() { CDialogEx::OnInitDialog(); // ... // 端口写在配置里,调试期用 8080,避开 80 的管理员权限门槛 if (!m_listenSocket.Create(8080, SOCK_STREAM, FD_ACCEPT)) { int nErr = GetLastError(); // 10048 端口占用 CString msg; msg.Format(_T("监听失败,错误码 %d"), nErr); AfxMessageBox(msg); return FALSE; } m_listenSocket.SetCallback(this); m_listenSocket.Listen(5); // 积压队列长度,5~10 足够 return TRUE; }

Create 的第二个参数 SOCK_STREAM 是 TCP 流式套接字,HTTP 要求可靠有序传输,不能用 SOCK_DGRAM。第三个参数 FD_ACCEPT 决定消息泵会回调 OnAccept;如果同时还想感知断开,把 FD_CLOSE 也加进来。Listen(5) 里的 5 不是并发数,是内核里还没被 Accept 的连接队列长度,超了之后新连接会被拒绝。

上面的写法里,监听 socket 是对话框的成员变量,这样生命周期跟随对话框。释放时有个顺序问题:先 Close 监听 socket,再释放客户端 socket,否则内核会把 TIME_WAIT 状态留很久。对话框销毁时执行:

void CHttpServerDlg::OnDestroy() { m_listenSocket.Close(); // 先关监听,拒绝新连接 CDialogEx::OnDestroy(); }

到这里工程能跑,浏览器访问 http://127.0.0.1:8080/ 会触发 OnAccept 回调,但页面还是打不开,因为还没有东西写回给客户端。这个环节暴露了 MFC 服务和命令行服务的差别:它没有一个 while(1) 主循环,一切推进靠消息,accept 的节奏完全由 FD_ACCEPT 控制。

4. 请求解析与响应输出:GET 和静态页面怎么落地

4.1 解析请求行与头部:一块缓冲怎么拆

当 OnAccept 触发并 Accept 后,客户端的 OnReceive 会被窗口消息驱动。数据到达是不定长的,因此不能用“读一次就解析”。我习惯在客户端套接字里放一个 std::string 作为接收缓冲,所有解析都基于这个缓冲:

// 把请求头和头部完整切出来,返回 false 说明数据没到齐 bool ParseRequest(const std::string& raw, std::string& method, std::string& uri, std::map<std::string, std::string>& headers) { size_t headerEnd = raw.find("\r\n\r\n"); if (headerEnd == std::string::npos) return false; // 半包,等下一段数据 // 切出请求行,按空格拆成 method、uri、version size_t firstLineEnd = raw.find("\r\n"); std::string firstLine = raw.substr(0, firstLineEnd); size_t p1 = firstLine.find(' '); size_t p2 = firstLine.rfind(' '); if (p1 == std::string::npos || p2 == p1) return false; // 请求行格式不对 method = firstLine.substr(0, p1); uri = firstLine.substr(p1 + 1, p2 - p1 - 1); // 中间几行是头部,冒号前后拆开,字段名统一转小写 size_t pos = firstLineEnd + 2; while (pos < headerEnd) { size_t lineEnd = raw.find("\r\n", pos); if (lineEnd == std::string::npos || lineEnd > headerEnd) break; std::string line = raw.substr(pos, lineEnd - pos); size_t colon = line.find(':'); if (colon != std::string::npos) { std::string name = line.substr(0, colon); std::string value = line.substr(colon + 1); // 值开头可能有空格,规范允许,但要剥掉 if (!value.empty() && value[0] == ' ') value.erase(0, 1); // 统一转小写,避免客户端混用大小写导致字段重复 for (size_t i = 0; i < name.size(); i++) name[i] = (char)tolower(name[i]); headers[name] = value; } pos = lineEnd + 2; } return true; }

这里的 find("\r\n\r\n") 是整个解析的锚点。缓冲区里可能同时有两个请求,也可能一个请求都没收完整,所以函数只做“从缓冲里把当前这个请求的头部切出来”,切完不负责删缓冲,删除要由调用方根据 Content-Length 整体计算。URI 拆出来之后,对静态文件服务来说,下一步就是把它映射到本地路径,注意处理 URL 编码里的 %20,以及把 “/” 映射成默认的 “/index.html”。

这个函数也有意不去动请求体:GET 没有请求体,POST 的请求体长度要另外看 Content-Length,把这两件事拆开,后续扩展时才不会把解析逻辑越改越乱。

4.2 组装响应与 MIME 映射:状态行、头部、正文的拼装顺序

响应和请求一样,也是“状态行 + 头部 + 空行 + 正文”。组装的顺序非常重要,很多人习惯先拼头部,其实先定 body 才对,因为 Content-Length 必须等于 body 的实际字节数:

void BuildResponse(const std::string& body, const std::string& mime, std::string& response) { char header[512]; int headerLen = sprintf(header, "HTTP/1.1 200 OK\r\n" "Content-Type: %s\r\n" "Content-Length: %d\r\n" "Connection: close\r\n" "\r\n", mime.c_str(), (int)body.size()); response.clear(); response.append(header, headerLen); response.append(body.data(), body.size()); }

Content-Length 填的是 body.size(),拼进头部之后再用 response.size() 一次 send 出去。连接是短连接,Connection: close 告诉浏览器发完数据就断;断口由客户端关闭还是服务端关闭不影响本次传输的完整性,但必须保证头部声明和实际发送严格一致。

MIME 类型不能全省略成 text/plain。没有对应表,浏览器会把 CSS 当普通文本渲染,把 PNG 当文本下载或直接打不开。常见的映射:

文件扩展名Content-Type
.html / .htmtext/html; charset=utf-8
.csstext/css
.jsapplication/javascript
.pngimage/png
.jpg / .jpegimage/jpeg
.txttext/plain; charset=utf-8

实现时用扩展名查表:

std::string GetMime(const std::string& path) { size_t dot = path.rfind('.'); std::string ext = dot == std::string::npos ? "" : path.substr(dot); if (ext == ".html" || ext == ".htm") return "text/html; charset=utf-8"; if (ext == ".css") return "text/css"; if (ext == ".js") return "application/javascript"; if (ext == ".png") return "image/png"; if (ext == ".jpg" || ext == ".jpeg") return "image/jpeg"; return "application/octet-stream"; }

文件读不到了要返回 404 页面,而不是把错误文本当 200 返回。404 的状态行是 “HTTP/1.1 404 Not Found”,body 可以是一个简短 HTML,Content-Length 依然按实际内容算。很多初学者处理 404 时只改了状态行,忘了 Content-Length 要按新 body 重算,结果就是浏览器收到了“404 错误”的报头,却配着不存在内容的长度,页面卡死。

到这一步,GET 静态页面已经通了。剩下的全是工程问题:编码、端口、缓冲区边界。

5. 排查常见问题:粘包、乱码、挂起与端口占用

5.1 浏览器一直转圈:Content-Length 与实际发送字节数不一致

现象:页面打开后一直转圈,最后报错连接被重置或响应不完整。

原因:Content-Length 是按 body.size() 写的,但实际发送前又经过了编码转换或强转,比如把 CString 的 GetBuffer 当 char* 发送,遇到中文或 Unicode 工程时实际字节数是声明的两倍;或者发送时用的是 int,body 超过 2GB 被截断,静态文件场景少见但会发生。

解决:发送前先确定 body 是 std::string 或 char 数组,Content-Length 用的就是它的 size(),发送时把同一段数据原样交给 send,中间不要再做任何类型转换。验证方式是用 curl 的 -v 参数看响应头里的 Content-Length,再对比实际收到的字节数,两者不一致就是这里出了问题。

5.2 中文乱码:MFC 的 Unicode 与 HTTP 的字节流之间差一层编码

现象:页面内容一片乱码,但调试时字符串变量内容看着是对的。

原因:常见的是 html 文件本身是 GBK,而响应头写的是 utf-8,浏览器按 utf-8 解码自然乱;另一种是把 CString 的内容直接当字节流发出去,Unicode 工程下被当作 UTF-16 传输。

解决:文件用二进制方式读,读进来的是原始字节,不改编码;动态拼的内容在发送前统一走 Utf8FromWide 转 UTF-8;响应头的 Content-Type 里显式写 charset=utf-8。只改其中一处都不彻底,三处要同时满足,字符集才稳定。注意不要在 socket 发送路径里调用 CString::GetBuffer 再强转,这条路径在非多字节工程下几乎必出乱码。

5.3 一次 recv 不等于一个请求:粘包与半包怎么治

现象:两个请求连在一起,第一个请求处理完,第二个请求头少了一半;或者一个请求分两次到达,第一次解析失败。

原因:TCP 是字节流,没有请求边界。CAsyncSocket 的 OnReceive 只保证“有数据可读”,不保证“一次正好是一个请求”。一次 Read 可能读到两个请求,也就是粘包;也可能只读到半个请求头,也就是半包。

解决:接收缓冲不断累加,每次读完都回到“找 \r\n\r\n、找 Content-Length、判断是否完整”的流程上。处理完一个请求就把它从缓冲头部 erase,剩下的继续留到下次 OnReceive:

void CHttpClientSocket::OnReceive(int nErrorCode) { if (nErrorCode != 0) return; char buf[4096]; int nRead = Receive(buf, 4096); while (nRead > 0) { m_buffer.append(buf, nRead); // 每收一段就尝试解析,解析完拆走一段 ProcessBuffer(); nRead = Receive(buf, 4096); } // Receive 返回错误且 WSAGetLastError 是 10035 时,表示缓冲读空 }

Receive 返回 SOCKET_ERROR 时用 WSAGetLastError 确认是不是 WSAEWOULDBLOCK(10035),是就表示数据读空了,这次回调结束;不要去 sleep 或死等,因为 CAsyncSocket 是消息驱动的,下一批数据到了会再触发 OnReceive。读空之后缓冲里的残片要原样留着,等下一批数据拼上。

5.4 启动失败:端口占用与监听失败别乱设复用地址

现象:Create 失败,GetLastError 返回 10048,或者程序关闭后马上重启又监听失败。

原因:上一个进程没释放监听端口;或者另一个服务占了同一个端口。Windows 上 SO_REUSEADDR 的语义和 Linux 不一样,在 Windows 上乱设它不但不能让端口立刻复用,还可能造成两个监听 socket 同时绑定同一个端口,收到连接时行为不可控。

解决:先查占用再换端口。命令行执行netstat -ano | findstr :8080,看到占用进程 PID 后用任务管理器确认。正常释放流程是客户端 socket 和监听 socket 都 Close,监听 socket 的 Close 放在对话框 OnDestroy 里。调试期测试端口直接改配置成 8081 或 8082,省得每次和系统服务抢端口。

6. 再进一步:POST 与 Keep-Alive 的两种核心改造

6.1 让 POST 进来:按 Content-Length 读完 body

GET 请求没有请求体,POST 在空行之后还有数据,长度由头部的 Content-Length 指定。在解析头部拿到 headers 之后,先把 body 起始位置算出来:

size_t bodyStart = headerEnd + 4; int bodyLen = 0; std::map<std::string, std::string>::iterator it = headers.find("content-length"); if (it != headers.end()) bodyLen = atoi(it->second.c_str()); if (m_buffer.size() < bodyStart + bodyLen) return; // body 还没收完整,等下次 OnReceive std::string body = m_buffer.substr(bodyStart, bodyLen); HandlePost(uri, body); m_buffer.erase(0, bodyStart + bodyLen);

这里必须和 5.3 的思路一致:先确认整个请求完整了再处理,而不是边读边解析。Content-Length 用 atoi 解析时注意去掉前导空格,前面解析头部时已经剥过,这里直接用就行。

6.2 连接复用:Keep-Alive 与请求边界的处理

前面的实现发的是 Connection: close,一条连接服务一个请求,简单但效率低。要支持 Keep-Alive,需要把“处理一个请求”改成“循环从缓冲区里切请求”:

while (true) { size_t headerEnd = m_buffer.find("\r\n\r\n"); if (headerEnd == std::string::npos) break; // 解析头部、取出 content-length,判断 body 是否到齐 // 未到齐就 break,等下一次 OnReceive std::string request = m_buffer.substr(0, headerEnd + 4 + bodyLen); HandleRequest(request); m_buffer.erase(0, request.size()); }

每次循环都要重新 find,不能提前缓存 headerEnd,因为缓冲区在处理完一段后头已经变了。然后响应头里把 Connection 字段改成 keep-alive;如果客户端请求里带 Connection: close,服务端就应该关闭连接。

验证协议处理有没有问题,最快的方式是手工发请求:telnet 127.0.0.1 8080,粘贴 GET / HTTP/1.1、Host: 127.0.0.1:8080,最后一行空行回车,看服务器返回。这时候你会非常直观地看到“空行丢没丢”“Content-Length 对不对”。这份带注释的 MFC 工程我整理在了下载位置,拿到后建议直接把 5.3 那条缓冲区逻辑多读两遍。从那以后我每写完一个 HTTP 处理分支,都强制走一遍这个手工请求流程,再盯一遍返回值,基本能堵住八成协议层问题。希望帮到你。

本文还有配套的精品资源,点击获取

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询