HTTP 静态文件服务器
一、整体设计目标
这个项目实现了一个 简易 HTTP 静态文件服务器:
- 用浏览器作为客户端,访问
http://IP:端口 - 服务端接收 HTTP 请求 → 解析请求行 → 找到对应文件 → 构造 HTTP 响应 → 返回给浏览器
- 每个客户端连接由独立线程处理(多线程并发)
模块划分:
┌─────────────────────────────────────────────────┐
│ HttpServer.cpp (入口) │
│ main() → HttpServer::Run() │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ HttpServer.hpp │
│ - HttpRequest 类:解析 HTTP 请求 │
│ · Deserialize() 反序列化请求行 │
│ · Parse() 解析方法/URL/版本/文件路径 │
│ - HttpServer 类:HTTP 服务器 │
│ · Run() 监听+accept+创建线程 │
│ · HanlderHttp() 处理单个 HTTP 请求 │
│ · ThreadFunc() 线程入口函数 │
│ · ReadHtmlContent() 读取文件内容 │
│ · SuffixToDesc() MIME类型映射 │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ Socket.hpp │
│ Sock 类:封装 TCP Socket 操作 │
│ Socket() / Bind() / Listen() / Accept() │
│ Connect() / Close() / Fd() │
└─────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────┐
│ 系统调用层 │
│ socket / bind / listen / accept / connect │
│ recv / send / setsockopt / inet_* │
└─────────────────────────────────────────────────┘
二、Socket.hpp 详细解析 ------ Socket 封装
2.1 为什么要封装 Socket?
在 TCP 模块中,TcpServer 类直接调用 socket()、bind()、listen() 等系统调用。
但这样有个问题:Socket 操作是通用的 ,不只是服务端用,客户端也用;不只是 HTTP 用,其他 TCP 应用也用。
所以把 Socket 操作抽出来单独封装成一个类,哪里需要就包含哪里,代码复用性更高。
2.2 文件头部
cpp
#pragma once
#include <iostream>
#include <unistd.h> // close()
#include <sys/types.h>
#include <sys/socket.h> // socket(), bind(), listen(), accept(), setsockopt(), connect()
#include <sys/stat.h>
#include <arpa/inet.h> // htonl, htons, ntohs, inet_ntop, inet_pton
#include <netinet/in.h> // sockaddr_in, INADDR_ANY
#include "Log.hpp"
#include <cerrno> // errno
#include <cstring> // strerror(), memset()
2.3 全局常量与枚举
cpp
enum
{
SOCKETERR = 2, // socket() 失败
BINDERR, // bind() 失败 → 3
LISTENERR, // listen() 失败 → 4
};
const int BACKLOG = 10; // 监听队列长度
2.4 Sock 类 - 成员变量
cpp
private:
int _socketfd; // 唯一的成员变量:Socket 文件描述符
设计要点:
- 极简设计:类只封装一个
_socketfd - 所有操作都围绕这个 fd 展开
- 这样的类叫做"资源包装类"(RAII 思想)
2.5 构造函数与析构函数
cpp
Sock() {} // 空实现
~Sock() {} // 空实现
为什么构造/析构都是空的?
- 构造函数不创建 Socket:用户可以先声明对象,后面再调
Socket()方法创建 - 析构函数不关闭 Socket:
Close()是显式方法,用户自己调用 - 这样设计更灵活:对象的生命周期和 Socket 的生命周期可以解耦
思考 :如果析构函数里调
close(_socketfd),会怎么样?
- 如果对象被拷贝,会导致同一个 fd 被 close 两次(错误)
- 所以当前设计让用户手动调
Close(),虽然不那么 RAII,但更安全
2.6 Socket() ------ 创建 TCP Socket
cpp
void Socket()
{
_socketfd = socket(AF_INET, SOCK_STREAM, 0);
if(_socketfd < 0)
{
logger(FATAL, "Socket failed, %s: %d", strerror(errno), errno);
exit(SOCKETERR);
}
int opt = 1;
setsockopt(_socketfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
}
接口设计:
- 无参数(参数太少,直接用硬编码值)
- 无返回值(失败直接 exit)
- 创建后直接设置
SO_REUSEADDR
两步操作:
socket()创建 TCP Socketsetsockopt()设置地址重用
与 TCP 模块的对比:
- TCP 模块在
TcpServer::Init()中完成了 socket + setsockopt - 这里把 socket + setsockopt 打包成一个
Socket()方法 - 因为这两个操作总是一起出现的,属于"创建 Socket"的完整流程
2.7 Bind(uint16_t port) ------ 绑定端口
cpp
void Bind(uint16_t port)
{
struct sockaddr_in local;
memset(&local, 0, sizeof(local));
local.sin_family = AF_INET;
local.sin_addr.s_addr = htonl(INADDR_ANY); // 注意:用 INADDR_ANY
local.sin_port = htons(port);
if(bind(_socketfd, (struct sockaddr *)&local, sizeof(local)) < 0)
{
logger(FATAL, "Bind failed, %s: %d", strerror(errno), errno);
exit(BINDERR);
}
}
接口设计:
- 参数:
port(端口号) - IP 硬编码为
INADDR_ANY(所有网卡) - 因为作为服务端 Socket,绑定所有网卡是最常见的场景
htonl(INADDR_ANY) 是什么?
| 宏 | 值 | 含义 |
|---|---|---|
INADDR_ANY |
0.0.0.0 |
绑定本机所有网卡的 IP |
INADDR_ANY的值就是0(主机序)htonl(INADDR_ANY)转成网络字节序的 0(还是 0)- 虽然值没变,但写法规范,清晰表达了"转网络序"的意图
与 TCP 模块的对比:
- TCP 模块:
inet_aton(_ip.c_str(), &local.sin_addr)------ IP 由用户传入 - 这里:
htonl(INADDR_ANY)------ 直接绑定所有网卡 - 封装取舍:服务端场景下,绑定所有网卡是 99% 的情况,所以硬编码更简洁
2.8 Listen() ------ 开始监听
cpp
void Listen()
{
if(listen(_socketfd, BACKLOG) < 0)
{
logger(FATAL, "Listen failed, %s: %d", strerror(errno), errno);
exit(LISTENERR);
}
}
接口设计:
- 无参数(backlog 用全局常量)
- 调用
listen(_socketfd, BACKLOG)
与 TCP 模块对比:
- TCP 模块:
listen(_listensockfd, DEFAULT_BACKLOG) - 这里:
listen(_socketfd, BACKLOG) - 逻辑完全一致,只是常量名不同
2.9 Accept(clientip, clientport) ------ 接受连接
cpp
int Accept(std::string *clientip, uint16_t *clientport)
{
struct sockaddr_in peer;
socklen_t peerlen = sizeof(peer);
int newfd = accept(_socketfd, (struct sockaddr *)&peer, &peerlen);
if(newfd < 0)
{
logger(WARNING, "Accept failed, %s: %d", strerror(errno), errno);
return -1;
}
char ip[64];
inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip));
*clientip = ip;
*clientport = ntohs(peer.sin_port);
return newfd;
}
接口设计:
| 参数 | 类型 | 方向 | 作用 |
|---|---|---|---|
clientip |
std::string* |
输出 | 把客户端 IP 写回调用者 |
clientport |
uint16_t* |
输出 | 把客户端端口写回调用者 |
| 返回值 | int |
输出 | 新的连接 Socket fd(失败返回 -1) |
为什么用指针参数而不是返回结构体?
方案 A:返回结构体(包含 newfd + ip + port)
优点:一个返回值搞定
缺点:需要定义结构体,调用方要解析
方案 B:返回 newfd + 两个输出参数(当前设计)
优点:接口简单,常见的 C 风格"多个输出值"写法
缺点:调用方必须传两个指针
与 TCP 模块的对比:
TCP 模块(TcpServer)是这样写的:
cpp
int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &client_len);
uint16_t client_port = ntohs(client.sin_port);
char client_ip[16];
inet_ntop(AF_INET, &(client.sin_addr), client_ip, sizeof(client_ip));
HTTP 模块(Sock)把 accept + 地址解析 封装在一起:
cpp
int sockfd = _listensock.Accept(&clientip, &clientport);
封装的好处:
- 调用方代码更简洁
- 不需要自己处理
sockaddr_in、inet_ntop等细节 - 把"获取连接"这个完整操作打包了
失败处理的差异:
- TCP 模块:
accept失败 →continue(继续循环) - Socket 封装:
accept失败 → 返回 -1(由上层决定怎么办) - 封装层不做业务决策,只返回状态,这是良好的分层设计
2.10 Connect(ip, port) ------ 客户端连接
cpp
int Connect(const std::string &ip, uint16_t port)
{
struct sockaddr_in peer;
memset(&peer, 0, sizeof(peer));
peer.sin_family = AF_INET;
inet_pton(AF_INET, ip.c_str(), &peer.sin_addr);
peer.sin_port = htons(port);
int n = connect(_socketfd, (struct sockaddr *)&peer, sizeof(peer));
if(n < 0)
{
std::cerr<<"Connect failed, "<<strerror(errno)<<std::endl;
return false;
}
return true;
}
接口设计:
| 参数 | 类型 | 作用 |
|---|---|---|
ip |
const std::string& |
服务端 IP |
port |
uint16_t |
服务端端口 |
| 返回值 | int(实际是 bool) |
成功 true,失败 false |
这个方法是给客户端用的:
Socket()+Bind()+Listen()+Accept()是服务端调用链Socket()+Connect()是客户端调用链- 一个 Sock 类同时支持服务端模式和客户端模式
为什么 HTTP 模块用不到这个方法?
- HTTP 模块是纯服务端,没有客户端代码
- 但 Socket 是通用封装,所以把客户端的
Connect()也放进来了 - 体现了封装的通用性
2.11 Close() ------ 关闭 Socket
cpp
void Close()
{
close(_socketfd);
}
直接调用系统 close(),封装非常薄。
2.12 Fd() ------ 获取文件描述符
cpp
int Fd()
{
return _socketfd;
}
为什么需要这个方法?
_socketfd是 private 的,外部不能直接访问- 但某些场景下外部需要原始 fd(比如注册到 epoll)
- 提供一个只读的 getter 方法
三、HTTP 服务器
3.1 文件头部 - 全局常量
cpp
const std::string wwwroot = "./wwwroot"; // Web 根目录(所有文件从这里找)
const std::string sep = "\r\n"; // HTTP 行分隔符(CRLF)
const std::string homepage = "index.html"; // 首页文件名
static const int DEFAULT_PORT = 8080; // 默认端口
三个核心路径常量:
| 常量 | 值 | 作用 |
|---|---|---|
wwwroot |
"./wwwroot" |
所有静态文件的根目录 |
homepage |
"index.html" |
访问 / 时返回的默认页面 |
sep |
"\r\n" |
HTTP 协议的行分隔符(CR + LF) |
为什么 HTTP 用 \r\n 而不是 \n?
- HTTP 协议规定每行以
\r\n结尾 - 这是历史遗留(早期网络终端的习惯)
- 空行就是连续两个
\r\n,即\r\n\r\n
3.2 前向声明与 ThreadData
cpp
class HttpServer; // 前向声明
struct ThreadData
{
public:
ThreadData(int fd, HttpServer *http_server)
: sockfd(fd)
, http_server(http_server)
{}
~ThreadData()
{}
public:
int sockfd;
HttpServer *http_server;
};
与 TCP 模块 ThreadData 的对比:
| 字段 | TCP ThreadData | HTTP ThreadData | 原因 |
|---|---|---|---|
sockfd |
✔ | ✔ | 都需要 |
client_ip / client_port |
✔ | ✘ | HTTP 版本在线程函数里不需要 IP/Port |
TcpServer* / HttpServer* |
✔ | ✔ | 回调成员函数 |
HTTP 版本更简洁,因为业务处理(HandlerHttp)不需要知道客户端 IP。
3.3 HttpRequest 类 - HTTP 请求解析
这是 HTTP 模块独有的核心类,负责把浏览器发来的原始 HTTP 请求字符串解析成结构化数据。
类成员变量
cpp
public:
std::vector<std::string> req_header; // 请求头所有行(逐行存储)
std::string text; // 请求体(GET 请求为空)
std::string method; // 请求方法:GET / POST ...
std::string url; // 请求路径:/index.html
std::string http_version; // HTTP 版本:HTTP/1.1
std::string file_path; // 服务器上的文件路径:./wwwroot/index.html
std::string suffix; // 文件后缀:html / css / js ...
各字段的关系:
原始请求字符串
│
▼ Deserialize() 拆分
├── req_header[N行] ← 每行一个字符串
└── text ← 请求体
│
▼ Parse() 解析
├── method ← "GET"
├── url ← "/index.html"
├── http_version ← "HTTP/1.1"
├── file_path ← "./wwwroot/index.html"
└── suffix ← "html"
Deserialize() ------ 反序列化请求
cpp
void Deserialize(std::string req)
{
while(1)
{
std::size_t pos = req.find(sep); // 找第一个 \r\n
if(pos == std::string::npos) break; // 找不到,结束
std::string temp = req.substr(0, pos); // 截取出一行
if(temp.empty()) break; // 空行 = 请求头结束
req_header.push_back(temp); // 存入请求头数组
req.erase(0, pos + sep.size()); // 从原字符串中删掉这一行
}
text = req; // 剩下的就是请求体
}
算法流程:
输入: "GET /index.html HTTP/1.1\r\nHost: 127.0.0.1:8080\r\n\r\n"
第1轮循环:
pos = 找到第一个 \r\n 的位置
temp = "GET /index.html HTTP/1.1" → 存入 req_header[0]
req 变成 "Host: 127.0.0.1:8080\r\n\r\n"
第2轮循环:
pos = 找到第一个 \r\n 的位置
temp = "Host: 127.0.0.1:8080" → 存入 req_header[1]
req 变成 "\r\n"
第3轮循环:
pos = 找到第一个 \r\n 的位置
temp = "" → 空行 → break 循环
退出循环:
text = req = "" → 请求体为空(GET 请求没有请求体)
为什么空行要 break?
- HTTP 协议规定:请求头和请求体之间用一个空行分隔
- 遇到空行说明请求头结束了
- 剩下的内容都是请求体
Parse() ------ 解析请求行
cpp
void Parse()
{
// 第1步:解析第一行(请求行)
std::stringstream ss(req_header[0]);
ss >> method >> url >> http_version;
// "GET /index.html HTTP/1.1" → method="GET", url="/index.html", http_version="HTTP/1.1"
// 第2步:构造文件路径
file_path = wwwroot; // 默认根目录 ./wwwroot
if(url == "/" || url == "/" + homepage)
{
file_path += "/" + homepage; // 访问 / 或 /index.html → 返回首页
}
else
{
file_path += url; // 其他路径 → 直接拼上
}
// 第3步:提取文件后缀
suffix = url.substr(url.find_last_of(".") + 1);
}
第1步:用 stringstream 拆分请求行
请求行格式:"方法 路径 版本\r\n"
示例: "GET /index.html HTTP/1.1"
stringstream 自动按空格拆分:
ss >> method → "GET"
ss >> url → "/index.html"
ss >> http_version → "HTTP/1.1"
第2步:构造文件路径
| 浏览器请求的 URL | 服务器上的实际路径 |
|---|---|
/ |
./wwwroot/index.html |
/index.html |
./wwwroot/index.html |
/a/b/hello.html |
./wwwroot/a/b/hello.html |
/style.css |
./wwwroot/style.css |
url == "/" + homepage 的判断:
- 如果用户访问
/index.html,也指向同一个文件 - 避免重复定义
第3步:提取后缀
url = "/index.html"
↑ find_last_of(".") 找到 "." 的位置
suffix = url.substr(...) → "html"
用 find_last_of(".") 找最后一个点的位置,然后截取后面的部分作为后缀。
这样 a.b.c.html 也能正确提取出 html。
Debug() ------ 打印调试信息
cpp
void Debug()
{
for(auto &item : req_header)
{
std::cout << item << "\n\n";
std::cout << "--------------------" << std::endl;
}
std::cout <<"suffix: " << suffix << std::endl;
std::cout <<"method: " << method << std::endl;
std::cout <<"url: " << url << std::endl;
std::cout <<"http_version: " << http_version << std::endl;
std::cout <<"file_path: " << file_path << std::endl;
std::cout << text << std::endl;
}
开发调试用的函数,把所有解析结果打印出来。
生产环境下可以注释掉或改成日志输出。
3.4 HttpServer 类 - 服务器主体
成员变量
cpp
private:
Sock _listensock; // 监听 Socket(封装类对象)
uint16_t _port; // 监听端口
std::unordered_map<std::string, std::string> _content_type_map; // MIME 类型映射
| 成员 | 作用 |
|---|---|
_listensock |
封装好的 Socket 对象,负责 accept |
_port |
监听端口号 |
_content_type_map |
文件后缀 → Content-Type 的映射表 |
_content_type_map 的作用:
- HTTP 响应头需要告诉浏览器"我返回的是什么类型的文件"
- 浏览器根据 Content-Type 决定怎么渲染(HTML 渲染网页,图片显示图片)
构造函数
cpp
HttpServer(int port = DEFAULT_PORT)
: _port(port)
{
_content_type_map.insert({".html", "text/html"});
_content_type_map.insert({".css", "text/css"});
_content_type_map.insert({".js", "application/javascript"});
_content_type_map.insert({".jpg", "image/jpeg"});
_content_type_map.insert({".png", "image/png"});
}
接口设计:
- 默认端口 8080
- 构造函数中初始化 MIME 类型映射表
MIME 类型速查:
| 文件后缀 | Content-Type | 浏览器行为 |
|---|---|---|
.html |
text/html |
渲染为网页 |
.css |
text/css |
解析为样式表 |
.js |
application/javascript |
执行 JavaScript |
.jpg |
image/jpeg |
显示为图片 |
.png |
image/png |
显示为图片 |
为什么 MIME 类型很重要?
- 如果
.html文件返回text/plain,浏览器会把源码当文本显示,不会渲染 - 正确的 MIME 类型是浏览器正确处理文件的前提
Run() ------ 服务器主循环
cpp
void Run()
{
_listensock.Socket(); // 第1步:创建 Socket
_listensock.Bind(_port); // 第2步:绑定端口
_listensock.Listen(); // 第3步:开始监听
for(;;) // 第4步:无限循环 accept
{
std::string clientip;
uint16_t clientport;
int sockfd = _listensock.Accept(&clientip, &clientport);
if(sockfd < 0) continue;
logger(INFO, "Accept client %s:%d", clientip.c_str(), clientport);
// 第5步:为每个客户端创建线程
pthread_t tid;
ThreadData *td = new ThreadData(sockfd, this);
pthread_create(&tid, nullptr, ThreadFunc, td);
}
}
与 TCP 模块 Run() 的对比:
| 对比项 | TCP 模块 | HTTP 模块 |
|---|---|---|
| Socket 操作 | 直接调系统调用 | 通过 Sock 封装类调用 |
| 并发模型 | 线程池(v4) | 每连接一线程 |
| 守护进程 | 有(Daemon()) |
无 |
| SIGPIPE 处理 | 有(注释中) | 无 |
HTTP 模块为什么用"每连接一线程"而不是线程池?
- HTTP/1.0 默认是短连接(请求完就断开)
- 每个连接生命周期很短
- 线程池适合长连接场景,短连接场景下每连接一线程也能工作
- 当然生产环境还是线程池或 IO 多路复用更好
为什么 ThreadData 要用 new 在堆上?
cpp
ThreadData *td = new ThreadData(sockfd, this);
- 如果在栈上创建,
pthread_create返回后函数继续执行,栈对象可能被覆盖 - 堆上的对象生命周期由我们自己控制,在线程函数里
delete td - 这是多线程编程的常见模式
ThreadFunc() ------ 线程入口函数
cpp
static void *ThreadFunc(void *arg)
{
pthread_detach(pthread_self()); // 第1步:线程分离
ThreadData *td = static_cast<ThreadData *>(arg);
td->http_server->HanlderHttp(td->sockfd); // 第2步:调用成员函数处理请求
delete td; // 第3步:释放 ThreadData
return nullptr;
}
pthread_detach(pthread_self()) 的作用:
- 默认情况下,线程结束后需要
pthread_join回收资源 pthread_detach把线程设为"分离状态"- 分离的线程结束后,系统自动回收资源,不需要 join
- HTTP 服务器不需要关心每个线程何时结束,所以用 detach 更方便
为什么 ThreadFunc 是 static 的?
pthread_create的回调函数签名是void* (*)(void*)- 非静态成员函数有隐藏的
this参数,签名不匹配 - 静态成员函数没有
this,可以作为回调 - 通过
arg传this指针,在函数内部再调用成员函数
这是 C++ 多线程的经典模式:
静态函数(回调入口)
├── 把 void* arg 转成 类指针
└── 调用 类指针->成员函数()
ReadHtmlContent() ------ 读取文件内容
cpp
static std::string ReadHtmlContent(const std::string &htmlpath)
{
std::ifstream in(htmlpath, std::ios::binary); // 二进制方式打开
if(!in.is_open())
{
logger(FATAL, "Open file %s failed", htmlpath.c_str());
return "";
}
// 第1步:获取文件大小
in.seekg(0, std::ios_base::end); // 指针移到文件末尾
auto len = in.tellg(); // 读指针位置 = 文件大小
in.seekg(0, std::ios_base::beg); // 指针移回文件开头
// 第2步:分配空间
std::string content(len, 0);
content.resize(len);
// 第3步:一次性读完
in.read((char *)content.c_str(), content.size());
in.close();
return content;
}
为什么用 std::ios::binary 二进制模式?
- 文本模式下,Windows 会把
\r\n转成\n(Linux 下没有区别) - 二进制模式下,数据原样读写,不会有转换
- 图片、CSS、JS 等文件必须用二进制,否则会损坏
获取文件大小的方法:
文件内容: [H][e][l][l][o][ ][W][o][r][l][d]
↑ ↑
beg end
seekg(0, end) → 指针到末尾
tellg() → 返回当前位置 = 文件大小(字节数)
seekg(0, beg) → 指针回到开头,准备读
为什么不用 getline 逐行读?
- 注释代码里展示了逐行读的方式
- 逐行读适合文本文件,但图片等二进制文件没有"行"的概念
- 一次性读取更高效,也更通用(支持二进制文件)
in.read((char *)content.c_str(), content.size()) 的注意事项:
c_str()返回const char*,强转成char*才能写入- C++98 标准下
string的内存不一定连续,但实际上所有实现都是连续的 - C++11 之后标准明确规定 string 内存连续
SuffixToDesc() ------ 后缀转 MIME 类型
cpp
std::string SuffixToDesc(const std::string &suffix)
{
auto iter = _content_type_map.find(suffix);
if(iter != _content_type_map.end())
return _content_type_map[suffix];
return _content_type_map[".html"]; // 找不到默认返回 text/html
}
查表逻辑:
- 在
_content_type_map中查找后缀 - 找到 → 返回对应的 MIME 类型
- 找不到 → 默认返回
text/html
返回值为什么用 _content_type_map[".html"] 而不是硬编码?
- 避免魔法字符串
- 如果以后要改默认类型,只改一处(构造函数里的 insert)就行
HanlderHttp() ------ 处理单个 HTTP 请求
这是 HTTP 服务器最核心的函数,负责完整的请求-响应流程。
cpp
void HanlderHttp(int sockfd)
{
// ========== 第1步:接收请求 ==========
char buffer[10240];
ssize_t n = recv(sockfd, buffer, sizeof(buffer)-1, 0);
if(n < 0)
{
logger(FATAL, "Read failed, %s: %d", strerror(errno), errno);
exit(READERR);
}
if(n > 0)
{
buffer[n] = '\0';
logger(INFO, "Client request: %s", buffer);
// ========== 第2步:解析请求 ==========
HttpRequest req;
req.Deserialize(buffer); // 拆分成请求行 + 请求头 + 请求体
req.Parse(); // 解析方法/URL/文件路径/后缀
req.Debug(); // 打印调试信息
// ========== 第3步:读取文件 ==========
std::string text = ReadHtmlContent(req.file_path);
bool is_ok = true;
if(text.empty()) // 文件不存在
{
is_ok = false;
std::string err_html = wwwroot + "/404.html";
text = ReadHtmlContent(err_html); // 读 404 页面
}
// ========== 第4步:构造响应 ==========
// 响应行
std::string response_line = is_ok ? "HTTP/1.0 200 OK" : "HTTP/1.0 404 Not Found";
// 响应头
std::string response_headers = "Content-Length: ";
response_headers += std::to_string(text.size());
response_headers += "\r\n";
response_headers += "Content-Type: ";
response_headers += SuffixToDesc(req.suffix);
response_headers += "\r\n";
// 空行
std::string blank_line = "\r\n";
// 响应体
// text 就是响应体
// 拼起来
std::string response = response_line + response_headers + blank_line + text;
// ========== 第5步:发送响应 ==========
send(sockfd, response.c_str(), response.size(), 0);
}
close(sockfd); // 关闭连接(HTTP/1.0 短连接)
}
让我们逐段详细解析。
第1步:recv() 接收请求
cpp
char buffer[10240];
ssize_t n = recv(sockfd, buffer, sizeof(buffer)-1, 0);
recv() 与 read() 的区别:
| 函数 | 头文件 | flags 参数 | 适用范围 |
|---|---|---|---|
read() |
<unistd.h> |
无(默认 0) | 所有文件描述符 |
recv() |
<sys/socket.h> |
有(可设 MSG_PEEK 等) |
仅 Socket |
recv(fd, buf, len, 0)等价于read(fd, buf, len)- HTTP 模块用
recv,TCP 模块用read,功能完全一样
缓冲区大小 10240 字节:
- HTTP 请求(尤其是 GET 请求)通常不大,10KB 足够
- 如果请求太大(比如 POST 上传文件),会被截断
- 真正的 HTTP 服务器需要循环读取,解析 Content-Length 来确定读多少
第2步:解析请求
cpp
HttpRequest req;
req.Deserialize(buffer);
req.Parse();
req.Debug();
调用上面讲过的 HttpRequest 类,把原始字符串变成结构化数据。
第3步:读取文件 & 404 处理
cpp
std::string text = ReadHtmlContent(req.file_path);
bool is_ok = true;
if(text.empty())
{
is_ok = false;
std::string err_html = wwwroot + "/404.html";
text = ReadHtmlContent(err_html);
}
逻辑:
- 根据解析出的
file_path读文件 - 文件存在 →
text非空 →is_ok = true - 文件不存在 →
text为空 →is_ok = false→ 读 404 页面
为什么 ReadHtmlContent 返回空字符串表示失败?
- 函数里
if(!in.is_open()) return ""; - 用空字符串作为"失败"的标记
- 缺点:如果文件本身就是空的(0 字节),也会被当成失败
- 但一般不会有空文件作为网页的情况
第4步:构造 HTTP 响应
HTTP 响应的格式:
响应行\r\n
响应头1\r\n
响应头2\r\n
...
空行\r\n
响应体
对应到代码:
response_line → "HTTP/1.0 200 OK\r\n" (或 404)
response_headers → "Content-Length: 1234\r\n"
"Content-Type: text/html\r\n"
blank_line → "\r\n"
text → 文件内容(响应体)
响应行格式 :HTTP版本 状态码 状态描述
HTTP/1.0 200 OK→ 成功HTTP/1.0 404 Not Found→ 资源不存在
Content-Length 的作用:
- 告诉浏览器响应体有多少字节
- 浏览器根据这个长度确定什么时候接收完毕
- 没有 Content-Length 的话,浏览器不知道什么时候读完(尤其是短连接)
Content-Type 的作用:
- 告诉浏览器文件是什么类型
- 浏览器根据类型决定如何渲染(HTML 解析、图片显示、下载等)
空行的作用:
- 分隔响应头和响应体
- 浏览器读到空行就知道"后面的都是响应体了"
第5步:send() 发送响应 & 关闭连接
cpp
send(sockfd, response.c_str(), response.size(), 0);
...
close(sockfd);
send()把完整的 HTTP 响应一次性发出close(sockfd)关闭连接 → HTTP/1.0 默认是短连接- 浏览器收到响应后,也会关闭自己这一端
短连接 vs 长连接:
| 模式 | HTTP 版本 | 行为 |
|---|---|---|
| 短连接 | HTTP/1.0 默认 | 每个请求建立一个连接,请求完就关闭 |
| 长连接 | HTTP/1.1 默认 | 一个连接可以发多个请求,节省握手开销 |
当前实现是 HTTP/1.0 风格的短连接,简单但效率低。
四、HttpServer.cpp ------ 入口文件
cpp
#include "HttpServer.hpp"
#include <iostream>
#include <memory>
int main(int argc, char *argv[])
{
if(argc != 2)
{
exit(1);
}
uint16_t port = std::stoi(argv[1]);
std::unique_ptr<HttpServer> httpserver(new HttpServer(port));
httpserver->Run();
return 0;
}
与 UDP/TCP 入口的对比:
| 对比项 | UDP (test.cpp) | TCP (test.cpp) | HTTP (HttpServer.cpp) |
|---|---|---|---|
| 类名 | UdpServer | TcpServer | HttpServer |
| 初始化方式 | Init() + Run() | Init() + Run() | 直接 Run()(Run 内部调 Socket/Bind/Listen) |
| Usage 提示 | 有 | 有 | 无(直接 exit) |
| 守护进程 | 无 | 有 | 无 |
五、完整 HTTP 请求-响应流程图
浏览器 服务器
│ │
│─── GET /index.html HTTP/1.1 ────────►│ 1. recv() 接收请求
│ Host: 127.0.0.1:8080 │
│ ... │
│ \r\n │
│ │
│ │ 2. Deserialize() 拆分行
│ │ req_header[0] = "GET /index.html HTTP/1.1"
│ │
│ │ 3. Parse() 解析
│ │ method = "GET"
│ │ url = "/index.html"
│ │ file_path = "./wwwroot/index.html"
│ │ suffix = "html"
│ │
│ │ 4. ReadHtmlContent() 读文件
│ │ text = "<html>..."
│ │
│ │ 5. 构造响应
│ │ "HTTP/1.0 200 OK\r\n"
│ │ "Content-Length: 123\r\n"
│ │ "Content-Type: text/html\r\n"
│ │ "\r\n"
│ │ "<html>..."
│ │
│◄─────── HTTP 响应 ───────────────────│ 6. send() 发送响应
│ │
│◄────── close() ──────────────────────│ 7. close() 关闭连接(短连接)
│
浏览器渲染 HTML 页面
六、关键 API 对比表
6.1 recv/send vs read/write
| 操作 | 通用文件函数 | Socket 专用函数 | 等价性 |
|---|---|---|---|
| 读数据 | read(fd, buf, len) |
recv(fd, buf, len, 0) |
flags=0 时等价 |
| 写数据 | write(fd, buf, len) |
send(fd, buf, len, 0) |
flags=0 时等价 |
HTTP 模块用 recv/send,TCP 模块用 read/write,功能一致。
6.2 Socket 封装层 API
| 方法 | 对应系统调用 | 服务端/客户端 |
|---|---|---|
Socket() |
socket() + setsockopt() |
都用 |
Bind(port) |
bind() |
服务端 |
Listen() |
listen() |
服务端 |
Accept(ip, port) |
accept() + inet_ntop() |
服务端 |
Connect(ip, port) |
connect() |
客户端 |
Close() |
close() |
都用 |
Fd() |
(返回成员变量) | 都用 |
七、编译与运行
7.1 编译
bash
cd HTTP/
make # 编译 HttpServer
make clean # 清理
7.2 运行
bash
# 启动服务器
./HttpServer 8080
# 浏览器访问
# 打开浏览器,访问 http://127.0.0.1:8080
# 看到 hello world 页面
7.3 测试 URL
| URL | 预期结果 |
|---|---|
http://127.0.0.1:8080/ |
首页 index.html |
http://127.0.0.1:8080/index.html |
首页(同上) |
http://127.0.0.1:8080/a/b/hello.html |
子目录页面 |
http://127.0.0.1:8080/xxx.html |
404 页面 |
7.2 HTTP/1.0 vs HTTP/1.1
当前实现是 HTTP/1.0 风格,与 HTTP/1.1 的主要区别:
| 特性 | HTTP/1.0 | HTTP/1.1 |
|---|---|---|
| 连接模式 | 默认短连接 | 默认长连接(Keep-Alive) |
| Host 头 | 可选 | 必须 |
| 响应状态行 | HTTP/1.0 200 OK |
HTTP/1.1 200 OK |
| 分块传输 | 不支持 | 支持(Transfer-Encoding: chunked) |