简介:一份基于Visual C++与MFC框架实现的HTTP服务器程序工程,适合正在学习Windows网络编程、MFC界面开发或HTTP协议原理的C++开发者参考。代码以可编译的工程形式提供,可直接在VC++环境中打开,包含服务器主对话框、请求处理线程、Socket封装、IP设置等模块,并附简要说明。压缩包共27个文件,以.h头文件和.cpp源文件为主,辅以.dsw/.dsp工程配置、.rc资源脚本及编译好的Server.exe可执行文件,整体仅442KB,结构紧凑。已有259人学习下载。通过阅读Server.cpp、ServerDlg.cpp、ServerThread.h、BlockSock.h等关键代码,可理解MFC中CInternetSession、CHttpServer的用法,掌握监听端口、解析HTTP请求、调用处理函数、生成并返回响应等完整流程,同时学习多线程并发、错误处理及基础安全加固思路,为后续开发完整Web服务或嵌入式网络应用打下基础。 做个HTTP服务器没那么玄乎,尤其在Windows上用VC(Visual C++)这套老伙计来搞,反而是理解网络编程底层逻辑的一条捷径。很多人一听“HTTP服务器”就觉得得上一堆框架或者找个现成库凑合,实际上用VC原生WinSock从零搭一个轻量级的,能把协议解析、并发模型、资源处理这些基础概念一次吃透。这篇东西就围绕“HTTP服务器(vc)”这个主题,把从思路到落地再到排坑的全过程拆开揉碎讲清楚,适合刚接触网络编程、或者想自己掌控协议细节的朋友参考。
1. 整体设计思路:为什么选VC和WinSock,而不是第三方库
1.1 核心需求解析
先明确这个服务器要干什么:接收浏览器的HTTP请求,解析请求行和请求头,从磁盘上捞文件出来,按协议拼好响应报文回传过去。听起来像一个静态文件服务器,但麻雀虽小五脏俱全,协议解析、并发连接、资源读取、异常处理这些环节一个都少不了。
在Windows平台上做这事儿,绕不开WinSock这套系统提供的Socket接口。选择VC配合WinSock,核心原因是所有逻辑都握在自己手里,不依赖IIS、Apache这些现成服务器软件。这样你能清清楚楚看到每一个字节是怎么从网线走到应用层的。对于想搞明白HTTP协议本质的人来说,这种“从零造轮子”的过程比直接拿框架调API收获大得多。
1.2 技术选型考量:同步阻塞、多线程还是异步
早期写HTTP服务器,最容易掉进去的坑就是:用一个无限循环accept连接,然后同步处理请求。这在只有一个客户端的时候没毛病,但一旦有第二个浏览器同时发请求,后面的就得排队等着,体验极差。
我采用的是“主线程监听 + 每连接一线程”的模式。主循环负责accept新连接,每来一个客户端就开一个工作线程去处理这次请求。这样实现简单,逻辑清晰,多核CPU也能利用起来。缺点是线程开销大,但作为学习项目和中小并发场景完全够用。
另外有一版我试过用WSAEventSelect做异步事件驱动,代码写起来绕很多,调试也费劲儿。不是说异步不好,而是这个项目阶段,同步阻塞加多线程比异步更容易理解和维护。等你把同步版本调通了,再往异步迁移会顺利得多。
1.3 功能范围界定
这个服务器实现三个核心功能:
- 监听端口,接收HTTP请求并解析请求行、请求头
- 根据URL路径,从指定根目录读取文件并返回,支持常见MIME类型
- 处理404、403这类错误状态码,响应格式遵循HTTP/1.1规范
为了控制篇幅,CGI、POST解析、Cookie会话这些暂时不做,但代码结构上留好了扩展口子。最后项目形态是一个WIN32控制台程序,启动后打印监听地址和端口,用浏览器访问http://127.0.0.1:8000就能看到文件列表。
2. 核心细节解析:HTTP协议解析与响应报文构造
2.1 HTTP请求报文结构拆解
一个标准的HTTP请求报文长这样:
GET /index.html HTTP/1.1\r\n Host: 127.0.0.1:8000\r\n User-Agent: Mozilla/5.0\r\n Accept: text/html\r\n \r\n每一行以\r\n结尾,请求行在最前面,然后是一堆请求头,最后空一行(也就是单独的\r\n)表示头部结束。如果后面还有内容,那就是请求体(POST请求才有)。
解析的时候最容易忽略的是:TCP是流式协议,数据不是按“完整报文”边界到达的。也就是说,你调用一次recv,可能只收到半行,也可能一次收了好几个请求。所以必须在接收缓冲区里做状态解析,而不是天真地认为“recv一次就是一个完整请求”。
我建议的解析方式是写一个循环,把recv到的数据追加到一个内存缓冲区,然后尝试从中解析出完整的请求行和请求头。如果判断头部结束标识(\r\n\r\n)还没出现,就继续等下一次recv。这个细节不处理好,就会出现莫名其妙的“丢请求”或者“卡死”现象。
2.2 响应报文设计要点
响应报文跟请求报文结构类似:
HTTP/1.1 200 OK\r\n Content-Type: text/html; charset=utf-8\r\n Content-Length: 1024\r\n Connection: close\r\n \r\n <文件内容>这里有几个关键点必须注意:
Content-Length是必须的,而且必须和实际发送的文件字节数完全一致。浏览器靠这个字段判断响应体有多长。短了内容显示不全,长了浏览器会一直等后续数据直到超时。
Content-Type决定了浏览器怎么处理返回的内容。HTML就写text/html,CSS写text/css,图片按实际格式写。这个映射表需要你自己维护一份,常见的:.txt→ text/plain、.jpg→ image/jpeg、.png→ image/png、.js→ application/javascript、.json→ application/json。
2.3 线程安全与资源管理
多线程模式下,全局变量和共享资源必须加锁保护。比如日志输出,多个线程同时往控制台写内容会串行乱掉。我习惯用一个全局互斥锁包住printf。
另外,每个连接处理完后,必须记得关闭socket句柄和线程句柄。Windows上句柄泄漏是相当隐蔽的,跑一晚上之后程序突然打不开文件了,八成是句柄耗尽了。Socket用完了要closesocket,文件用完了要CloseHandle,一个都不能漏。
3. 实操过程与核心环节实现
3.1 环境准备与工程初始化
项目用Visual Studio创建控制台应用程序就行。我这里用的是VS2019,老一点的VC6.0也能编译,只需要注意WinSock库的引入方式不同。
需要引入的头文件和库:
#include <winsock2.h> #include <ws2tcpip.h> #pragma comment(lib, "ws2_32.lib")注意:winsock2.h必须放在windows.h之前,否则会报一堆重定义错误。如果工程里已经间接包含了windows.h,可以通过定义
WIN32_LEAN_AND_MEAN来避免冲突。
初始化WinSock是第一步,所有Socket调用之前都必须先做:
WSADATA wsaData; if (WSAStartup(MAKEWORD(2, 2), &wsaData) != 0) { printf("WSAStartup failed\n"); return -1; }这个函数的作用是向系统申请使用Ws2_32.dll,并协商使用哪个版本的Winsock。MAKEWORD(2, 2)表示请求2.2版本,这是目前最通用的。调用完之后记得在程序退出时对应调用WSACleanup()释放资源。
3.2 创建监听Socket与绑定端口
Socket的创建、绑定、监听这三步是服务器启动的核心流程,直接上代码:
SOCKET listenSocket = socket(AF_INET, SOCK_STREAM, IPPROTO_TCP); if (listenSocket == INVALID_SOCKET) { printf("socket failed: %d\n", WSAGetLastError()); WSACleanup(); return -1; } // 允许端口重用,避免 TIME_WAIT 状态下重启失败 int opt = 1; setsockopt(listenSocket, SOL_SOCKET, SO_REUSEADDR, (const char*)&opt, sizeof(opt)); sockaddr_in service; service.sin_family = AF_INET; service.sin_addr.s_addr = htonl(INADDR_ANY); // 监听所有网卡 service.sin_port = htons(8000); // 端口号 if (bind(listenSocket, (SOCKADDR*)&service, sizeof(service)) == SOCKET_ERROR) { printf("bind failed: %d\n", WSAGetLastError()); closesocket(listenSocket); WSACleanup(); return -1; } if (listen(listenSocket, SOMAXCONN) == SOCKET_ERROR) { printf("listen failed: %d\n", WSAGetLastError()); closesocket(listenSocket); WSACleanup(); return -1; }这里的几个函数值得说清楚:
socket(AF_INET, SOCK_STREAM, IPPROTO_TCP):创建一个IPv4的TCP套接字。AF_INET代表IPv4地址族,SOCK_STREAM表示流式套接字(对应TCP),IPPROTO_TCP就是指定TCP协议。htonl和htons:把整数从主机字节序转成网络字节序。Intel CPU是小端序,网络协议规定用大端序,所以端口号和IP地址都要做转换。忘了这一步,监听的可能就不是你想的那个端口。INADDR_ANY:表示绑定本机所有网卡的IP地址,任何通过本机IP和localhost发来的连接都能接住。
端口我这里写死为8000。之前热搜词里有个http://你的服务器ip:8000的示例,正好对应,代码里如果你想改成环境变量或者命令行参数,灵活处理即可。
3.3 主循环与多线程处理
监听建立之后,主循环就做一件事:接收连接,转发给工作线程。
while (true) { SOCKET clientSocket = accept(listenSocket, NULL, NULL); if (clientSocket == INVALID_SOCKET) { printf("accept failed: %d\n", WSAGetLastError()); continue; } // 创建线程处理该连接 HANDLE hThread = CreateThread( NULL, 0, HandleClientThread, (LPVOID)clientSocket, 0, NULL ); CloseHandle(hThread); // 不等待线程结束,让线程自己跑 }accept是阻塞式的,在没有新连接的时候会一直停在那里。一旦有客户端连进来,它就返回一个新的socket句柄,专门用于和该客户端通信,监听socket继续等待后续连接。
工作线程的处理函数里,首先要做的就是接收请求数据并解析:
DWORD WINAPI HandleClientThread(LPVOID lpParam) { SOCKET clientSocket = (SOCKET)lpParam; char recvBuffer[8192] = {0}; int bytesRecv = recv(clientSocket, recvBuffer, sizeof(recvBuffer) - 1, 0); if (bytesRecv <= 0) { closesocket(clientSocket); return 0; } recvBuffer[bytesRecv] = '\0'; // 解析请求行 char method[16] = {0}; char url[1024] = {0}; char version[16] = {0}; sscanf(recvBuffer, "%15s %1023s %15s", method, url, version); // 处理请求 RequestHandler(clientSocket, url); closesocket(clientSocket); return 0; }实际项目中,请求可能不止一包就收完。上面这里为了表达核心思路做了简化。稳妥的做法是循环recv,直到缓冲区里出现\r\n\r\n为止。对于GET请求,头部收齐就可以处理了;对于POST请求,还要根据Content-Length继续读取请求体。
3.4 请求处理与文件读取
请求处理函数是服务器的“大脑”,核心逻辑如下:
void RequestHandler(SOCKET clientSocket, const char* url) { // URL解码 + 去掉查询参数 char filePath[1024] = {0}; ExtractFilePath(url, filePath); // 拼上根目录 char fullPath[2048] = {0}; sprintf_s(fullPath, "C:\\wwwroot%s", filePath); // 检查文件是否存在且可读 DWORD attr = GetFileAttributesA(fullPath); if (attr == INVALID_FILE_ATTRIBUTES) { SendErrorResponse(clientSocket, 404, "Not Found"); return; } if (attr & FILE_ATTRIBUTE_DIRECTORY) { // 目录处理,这里可以生成文件列表或者自动找 index.html SendDirectoryListing(clientSocket, fullPath); return; } // 读取文件内容并响应 SendFileResponse(clientSocket, fullPath); }ExtractFilePath这个函数特别值得注意:浏览器请求的URL里可能包含查询参数(?id=123)、锚点(#top),甚至URL编码后的中文字符(%E4%B8%AD%E6%96%87)。这些都需要过滤和解码。不处理查询参数的话,文件读取会带着一串垃圾字符导致失败。
URL解码的核心逻辑就是把%XX还原成对应字符:
char UrlDecodeChar(const char* hex) { int val = 0; sscanf(hex, "%2x", &val); return (char)val; }3.5 响应发送与连接关闭
发送文件内容的时候,我建议先读入内存再一次性发送,避免频繁调用send导致小包堆积:
void SendFileResponse(SOCKET clientSocket, const char* filePath) { HANDLE hFile = CreateFileA( filePath, GENERIC_READ, FILE_SHARE_READ, NULL, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, NULL ); if (hFile == INVALID_HANDLE_VALUE) { SendErrorResponse(clientSocket, 403, "Forbidden"); return; } LARGE_INTEGER fileSize; GetFileSizeEx(hFile, &fileSize); char* fileBuffer = (char*)malloc(fileSize.QuadPart); DWORD bytesRead = 0; ReadFile(hFile, fileBuffer, (DWORD)fileSize.QuadPart, &bytesRead, NULL); CloseHandle(hFile); // 拼接响应头 std::string responseHeader; responseHeader += "HTTP/1.1 200 OK\r\n"; responseHeader += "Content-Type: " + GetMimeType(filePath) + "\r\n"; responseHeader += "Content-Length: " + std::to_string(bytesRead) + "\r\n"; responseHeader += "Connection: close\r\n"; responseHeader += "\r\n"; send(clientSocket, responseHeader.c_str(), (int)responseHeader.size(), 0); send(clientSocket, fileBuffer, bytesRead, 0); free(fileBuffer); }这里我踩过一个坑:文件大小超过4GB的时候,ReadFile的第三个参数是DWORD类型,没法一次读完。但这对于本地小型HTTP服务器来说几乎是碰不到的情况,真遇到了就得做分块读取和分块发送了。
发送完响应之后,按照HTTP/1.1的规范,如果响应头里写了Connection: close,服务器就可以直接关闭连接了。
4. 常见问题与排查技巧实录
4.1 端口被占用
启动服务器时报bind failed: 10048,意思就是端口已被占用。常见原因是上次运行的程序没有彻底退出,或者有别的程序在监听这个端口。排查方法:
netstat -ano | findstr :8000查看哪个进程占用了8000端口,然后在任务管理器里结束对应进程。如果确认没有进程占用还报错,那就是TIME_WAIT状态残留,加上SO_REUSEADDR这个socket选项就可以解决,代码里已经加上了。
4.2 WSAStartup版本不兼容
老项目从VC6.0迁到现代VS的时候,容易遇到这个问题:WSAStartup使用MAKEWORD(1, 1),但其实你用的WinSock2函数它是支持的。解决办法就是在代码里用MAKEWORD(2, 2),这是目前所有Windows系统都支持的版本。
4.3 中文字符路径乱码
服务器根目录下放了个中文文件名的HTML,浏览器访问的时候返回404。这个问题根子出在编码不一致:HTTP请求URL里带的是UTF-8编码的路径,而Windows API处理文件默认用的是GBK编码。
解决办法是对URL解码后的字符串做一次编码转换,从UTF-8转成GBK(多字节Windows API),或者干脆用CreateFileW/ReadFileW这套宽字符API,让Windows自己处理。前者写起来简单,后者更符合现代规范。我的做法是提供一个Utf8ToGb2312函数,在打开文件前转换一次路径字符串。
4.4 socket错误10053和10054
浏览器访问过几次之后,服务器端打印recv failed: 10054(连接被对方重置)。这个通常不用太担心,因为浏览器用户直接点击停止按钮、或者不等待响应就刷新时,TCP连接会被强制断开。这是正常现象,处理方式是:recv失败或者返回0时,直接关闭当前连接句柄就行,不要把它当成致命错误。真正的异常是WSAECONNRESET连续出现且伴随内存暴涨,那才需要警惕是某个客户端在恶意打流量。
4.5 一次recv收到的数据不完整
新手最容易懵的场景:浏览器已经发出了请求,但recv返回的字节数少于请求报文长度,甚至只有几十个字节。原因前面说过了,TCP是流协议,数据到达的边界不保证与消息边界一致。可能你这次recv只收到了GET /几个字节,剩下的HTTP/1.1 Host:...还在路上。
解决办法就是把接收逻辑写成一个循环,把每次收到的数据追加到一个buffer中,直到检测到\r\n\r\n或者达到缓冲区上限才停止。解析时基于这个累积的buffer来做,而不是每次recv完就立刻解析。
4.6 多线程环境下控制台输出乱序
多个线程同时printf,控制台会变得乱七八糟。解决办法有两种:一是用一个全局锁把printf包起来,二是干脆改用OutputDebugString输出到调试器,避免控制台竞争。我自己的习惯是第一种,日志模块统一封装一个LogMessage函数,内部加锁,任何线程需要打印日志都走这个函数,后续想加时间戳、写日志文件,改动也集中。
5. 进一步扩展与优化方向
整个项目跑起来之后,你会发现“让浏览器能打开网页”这只是个起点。真正有价值的扩展方向有这么几个:
第一个是支持Keep-Alive。现在每个连接只服务一次请求就关闭,浏览器访问一个页面如果有10个资源就要建10个TCP连接,效率很低。支持Connection: keep-alive需要循环read请求并响应,直到收到关闭标记或超时。
第二个是加入CGI支持。URL后头带个可执行文件名,服务器把其他参数接收完整,拼成命令行参数去启动子进程,再把子进程标准输出作为响应体转发给浏览器。这个玩法跟早年做动态网站的思路很接近,能直观理解动态请求和静态请求的区别。
第三个是加上日志文件持久化,把每次访问的IP、时间戳、路径、状态码追加写到一个access.log里,方便事后分析。格式照着Apache的Combined Log Format写就行。
第四个是大一点的方向:封装成服务,用Windows Service的方式在后台运行,开机自启,配合简单的JSON配置文件让用户指定端口和根目录。这已经是一个可以商业交付的静态文件服务器雏形了。
最后的一点体会
写这个项目的过程中,最难的部分不是代码本身,而是养成“按协议办事”的习惯。HTTP协议看着就这么几行文本,但里面的细节决定了你得上多少当:Content-Length多一个字节少一个字节都会出问题,URL编码忘了解码就打不开中文文件,TCP粘包拆包不处理好就收不全请求。这些坑用现成框架的人基本遇不到,但亲手踩一遍之后,你再回去看IIS、Nginx的配置,理解完全不在一个层次了。有时候慢就是快,VC这把老骨头虽然界面用了二十多年没大变,但用它把网络编程的地基打扎实了,后面学什么都快。
本文还有配套的精品资源,点击获取