UNIX网络编程笔记:客户/服务器程序示例
服务器实例
有个著名的项目,TinyWebServer,本项目将其改到windows下,并使用RAII重构,原因是用C编写过程中对于内存泄漏的问题确实很头疼:
/*
* 基于C++17的Windows平台高性能HTTP服务器实现
* 特性:
* 1. 使用RAII管理资源(Winsock、Socket)
* 2. 采用现代C++特性(移动语义、智能指针等)
* 3. 使用IOCP(IO完成端口)模型优化文件传输
* 4. 多线程处理客户端请求
* 5. 支持基本的HTTP GET请求和文件服务
* 6. 包含日志系统和错误处理
*/
// 系统头文件
#include <WinSock2.h> // Windows Socket API
#include <WS2tcpip.h> // TCP/IP相关函数
#include <Windows.h> // Windows API
#include <Mswsock.h> // TransmitFile扩展函数
// 标准库头文件
#include <algorithm> // 算法函数
#include <chrono> // 时间相关
#include <cstring> // C字符串处理
#include <fstream> // 文件流操作
#include <iomanip> // 格式化输出
#include <iostream> // 输入输出流
#include <memory> // 智能指针
#include <mutex> // 互斥锁
#include <sstream> // 字符串流
#include <string> // 字符串类
#include <unordered_map> // 哈希表
#include <vector> // 动态数组
#include <filesystem> // 文件系统操作
// 链接库
#pragma comment(lib, "WS2_32.lib") // Winsock库
#pragma comment(lib, "Mswsock.lib") // TransmitFile函数库
namespace fs = std::filesystem; // 文件系统命名空间别名
// 日志系统 ======================================================================
std::mutex g_log_mutex; // 全局日志互斥锁,保证多线程日志安全
/*
* 日志记录器类
* 特性:
* - 自动记录时间戳和线程ID
* - 线程安全输出
* - 支持流式日志记录
*/
class Logger {
std::ostringstream oss_; // 日志内容缓冲区
std::chrono::system_clock::time_point start_; // 程序启动时间基准
// 生成带时间戳的日志前缀
std::string timestamp() {
auto now = std::chrono::system_clock::now();
auto ms = std::chrono::duration_cast<std::chrono::milliseconds>(now - start_);
auto t = std::chrono::system_clock::to_time_t(now);
std::tm tm_local;
localtime_s(&tm_local, &t); // 安全版本的时间转换
std::ostringstream oss;
// 格式化:YYYY-MM-DD HH:MM:SS.fff [T线程ID]
oss << std::put_time(&tm_local, "%Y-%m-%d %H:%M:%S")
<< "." << std::setfill('0') << std::setw(3) << ms.count() % 1000
<< " [T" << std::setw(5) << GetCurrentThreadId() << "] ";
return oss.str();
}
public:
Logger() : start_(std::chrono::system_clock::now()) {
oss_ << timestamp(); // 构造时初始化时间戳
}
~Logger() {
std::lock_guard<std::mutex> lock(g_log_mutex); // 加锁保证输出原子性
std::clog << oss_.str() << std::endl; // 输出到标准错误
}
// 流式输出运算符重载
template <typename T>
Logger& operator<<(T&& value) {
oss_ << value; // 将数据追加到缓冲区
return *this;
}
};
// 日志宏,方便使用
#define LOG Logger()
// RAII封装 =====================================================================
/*
* Winsock初始化RAII封装
* 特性:
* - 构造函数初始化Winsock
* - 析构函数自动清理
* - 禁止拷贝
*/
class WSAInitialize {
WSADATA wsa_data_; // Winsock初始化信息
public:
explicit WSAInitialize(WORD version = MAKEWORD(2, 2)) {
LOG << "Initializing Winsock...";
if (WSAStartup(version, &wsa_data_) != 0) {
LOG << "WSAStartup failed: " << WSAGetLastError();
throw std::runtime_error("WSAStartup failed");
}
LOG << "Winsock initialized. Version: "
<< (wsa_data_.wVersion & 0xFF) << "."
<< (wsa_data_.wVersion >> 8); // 打印主次版本号
}
~WSAInitialize() {
LOG << "Cleaning up Winsock...";
WSACleanup();
}
// 禁止拷贝
WSAInitialize(const WSAInitialize&) = delete;
WSAInitialize& operator=(const WSAInitialize&) = delete;
};
/*
* Socket RAII封装类
* 特性:
* - 自动关闭socket
* - 支持移动语义
* - 异常安全
*/
class AutoSocket {
SOCKET socket_ = INVALID_SOCKET; // 管理的socket句柄
// 抛出带有错误信息的异常
static void throw_wsa_error(const char* context) {
int err = WSAGetLastError();
LOG << "Socket error (" << context << "): " << err << " - " << get_wsa_error_str(err);
throw std::runtime_error(context);
}
// 获取错误码对应的描述信息
static std::string get_wsa_error_str(int code) {
LPSTR msg = nullptr;
FormatMessageA(
FORMAT_MESSAGE_ALLOCATE_BUFFER | FORMAT_MESSAGE_FROM_SYSTEM,
nullptr, code, 0,
reinterpret_cast<LPSTR>(&msg), 0, nullptr);
std::string result(msg ? msg : "Unknown error");
LocalFree(msg);
return result;
}
public:
AutoSocket() = default;
// 创建新socket
explicit AutoSocket(int domain, int type, int protocol) {
LOG << "Creating socket...";
socket_ = socket(domain, type, protocol);
if (socket_ == INVALID_SOCKET) {
throw_wsa_error("socket creation");
}
LOG << "Socket created (fd: " << socket_ << ")";
}
~AutoSocket() {
close(); // 确保资源释放
}
// 移动语义支持
AutoSocket(AutoSocket&& other) noexcept : socket_(other.socket_) {
other.socket_ = INVALID_SOCKET; // 转移所有权
LOG << "Socket moved (from " << other.socket_ << " to " << socket_ << ")";
}
AutoSocket& operator=(AutoSocket&& other) noexcept {
if (this != &other) {
close(); // 先关闭现有socket
socket_ = other.socket_;
other.socket_ = INVALID_SOCKET;
}
return *this;
}
// 显式关闭socket
void close() noexcept {
if (socket_ != INVALID_SOCKET) {
LOG << "Closing socket " << socket_;
closesocket(socket_);
socket_ = INVALID_SOCKET;
}
}
// 隐式转换为SOCKET类型
operator SOCKET() const noexcept { return socket_; }
// Socket配置方法 ----------------------------------------------------------
void set_reuse_addr() const {
LOG << "Setting SO_REUSEADDR on socket " << socket_;
int opt = 1;
if (setsockopt(socket_, SOL_SOCKET, SO_REUSEADDR,
reinterpret_cast<const char*>(&opt), sizeof(opt)) == SOCKET_ERROR) {
throw_wsa_error("setsockopt(SO_REUSEADDR)");
}
}
// 绑定地址
void bind(const sockaddr_in& addr) const {
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &addr.sin_addr, ip_str, sizeof(ip_str));
LOG << "Binding to " << ip_str << ":" << ntohs(addr.sin_port);
if (::bind(socket_, reinterpret_cast<const sockaddr*>(&addr), sizeof(addr)) == SOCKET_ERROR) {
throw_wsa_error("bind");
}
}
// 开始监听
void listen(int backlog = SOMAXCONN) const {
LOG << "Listening on socket " << socket_ << " with backlog " << backlog;
if (::listen(socket_, backlog) == SOCKET_ERROR) {
throw_wsa_error("listen");
}
}
// 接受连接
AutoSocket accept(sockaddr_in& client_addr) const {
int addr_len = sizeof(client_addr);
LOG << "Waiting for incoming connections on socket " << socket_;
SOCKET client_socket = ::accept(socket_, reinterpret_cast<sockaddr*>(&client_addr), &addr_len);
if (client_socket == INVALID_SOCKET) {
throw_wsa_error("accept");
}
// 记录客户端信息
char client_ip[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &client_addr.sin_addr, client_ip, sizeof(client_ip));
LOG << "Accepted connection from " << client_ip << ":" << ntohs(client_addr.sin_port)
<< " (client fd: " << client_socket << ")";
return AutoSocket(client_socket); // 返回包装后的socket
}
private:
// 私有构造函数,用于包装已存在的socket
explicit AutoSocket(SOCKET s) : socket_(s) {}
};
// HTTP相关功能 =================================================================
namespace http {
/*
* 根据文件扩展名获取MIME类型
* 参数:path - 文件路径
* 返回:对应的MIME类型字符串
*/
std::string get_mime_type(const fs::path& path) {
// MIME类型映射表
static const std::unordered_map<std::string, std::string> mime_types{
{".html", "text/html"}, {".htm", "text/html"}, {".txt", "text/plain"},
{".css", "text/css"}, {".js", "application/javascript"},
{".jpg", "image/jpeg"}, {".jpeg", "image/jpeg"}, {".png", "image/png"},
{".gif", "image/gif"}, {".json", "application/json"},
{".ico", "image/x-icon"}
};
std::string ext = path.extension().string();
std::transform(ext.begin(), ext.end(), ext.begin(), ::tolower); // 转为小写
auto it = mime_types.find(ext);
return it != mime_types.end() ? it->second : "application/octet-stream"; // 默认二进制流
}
/*
* 发送HTTP响应
* 参数:
* client - 客户端socket
* content - 响应内容
* content_type - MIME类型
* status_code - HTTP状态码
*/
void send_response(SOCKET client, const std::string& content,
const std::string& content_type = "text/html",
unsigned status_code = 200) {
std::ostringstream response;
// 构建响应头
response << "HTTP/1.1 " << status_code << " "
<< (status_code == 200 ? "OK" : "Not Found") << "\r\n"
<< "Content-Type: " << content_type << "\r\n"
<< "Content-Length: " << content.size() << "\r\n"
<< "Connection: close\r\n\r\n"
<< content;
const std::string resp_str = response.str();
LOG << "Sending response (" << resp_str.size() << " bytes)"
<< "\nHeaders:" << resp_str.substr(0, resp_str.find("\r\n\r\n"));
send(client, resp_str.data(), static_cast<int>(resp_str.size()), 0);
}
/*
* 发送文件(使用TransmitFile优化大文件传输)
* 参数:
* client - 客户端socket
* path - 文件路径
*/
void send_file(SOCKET client, const fs::path& path) {
LOG << "Sending file: " << path;
// 尝试打开文件
std::ifstream file(path, std::ios::binary | std::ios::ate);
if (!file) {
LOG << "File open failed: " << path;
send_response(client, "<h1>404 Not Found</h1>", "text/html", 404);
return;
}
// 准备响应头
const auto file_size = file.tellg();
file.seekg(0);
std::ostringstream headers;
headers << "HTTP/1.1 200 OK\r\n"
<< "Content-Type: " << get_mime_type(path) << "\r\n"
<< "Content-Length: " << file_size << "\r\n"
<< "Connection: close\r\n\r\n";
std::string header_str = headers.str();
LOG << "Sending file headers (" << header_str.size() << " bytes)";
// 先发送头
if (send(client, header_str.data(), static_cast<int>(header_str.size()), 0) == SOCKET_ERROR) {
LOG << "Header send error: " << WSAGetLastError();
return;
}
// 使用TransmitFile高效传输文件(Windows特有)
HANDLE file_handle = CreateFileW(path.c_str(), GENERIC_READ, FILE_SHARE_READ,
nullptr, OPEN_EXISTING, FILE_ATTRIBUTE_NORMAL, nullptr);
if (file_handle != INVALID_HANDLE_VALUE) {
LOG << "Using TransmitFile for large file transfer (" << file_size << " bytes)";
TransmitFile(client, file_handle, 0, 0, nullptr, nullptr, TF_DISCONNECT);
CloseHandle(file_handle);
return;
}
// 小文件分块传输
std::vector<char> buffer(65536); // 64KB缓冲区
while (file.read(buffer.data(), buffer.size())) {
const int bytes_read = static_cast<int>(file.gcount());
if (send(client, buffer.data(), bytes_read, 0) == SOCKET_ERROR) {
LOG << "File send error: " << WSAGetLastError();
break;
}
LOG << "Sent " << bytes_read << " bytes (total: " << file.tellg() << "/" << file_size << ")";
}
LOG << "File transfer completed";
}
}
// 客户端会话处理 ==============================================================
/*
* 客户端会话处理类
* 职责:
* - 读取HTTP请求
* - 解析请求
* - 验证路径安全性
* - 发送响应或文件
*/
class ClientSession {
AutoSocket client_; // 客户端socket
sockaddr_in client_addr_; // 客户端地址信息
// 验证请求路径是否在文档根目录内
static bool is_valid_path(const fs::path& requested, const fs::path& doc_root) {
const auto abs_requested = fs::absolute(requested);
const auto abs_root = fs::absolute(doc_root);
// 计算相对路径,防止目录遍历攻击
const auto rel_path = fs::relative(abs_requested, abs_root);
return !rel_path.empty() && *rel_path.begin() != ".."; // 禁止上级目录访问
}
// 读取HTTP请求(简化版,仅读取头)
std::string read_request() {
std::vector<char> buffer(4096); // 4KB缓冲区
std::string request;
while (true) {
int bytes_received = recv(client_, buffer.data(), buffer.size(), 0);
if (bytes_received > 0) {
request.append(buffer.data(), bytes_received);
// 检查是否收到完整头部
if (request.find("\r\n\r\n") != std::string::npos) {
LOG << "Received full request headers:\n"
<< request.substr(0, request.find("\r\n\r\n") + 4);
return request;
}
}
else {
throw std::runtime_error("Connection closed or error");
}
}
}
public:
ClientSession(AutoSocket&& socket, sockaddr_in addr)
: client_(std::move(socket)), client_addr_(addr) {
char ip_str[INET_ADDRSTRLEN];
inet_ntop(AF_INET, &addr.sin_addr, ip_str, sizeof(ip_str));
LOG << "New session: " << ip_str << ":" << ntohs(addr.sin_port);
}
// 处理客户端请求
void process() {
try {
// 读取并解析请求
const std::string request = read_request();
std::istringstream iss(request);
std::string method, url, version;
iss >> method >> url >> version;
LOG << "Request: " << method << " " << url;
// 仅处理GET请求
if (method != "GET") {
LOG << "Unsupported method: " << method;
http::send_response(client_, "<h1>501 Not Implemented</h1>", "text/html", 501);
return;
}
// 构造文件路径
fs::path doc_root = "htdocs"; // 文档根目录
fs::path request_path = url.substr(1); // 去除前导斜杠
// 路径安全检查
if (!is_valid_path(doc_root / request_path, doc_root)) {
LOG << "Invalid path: " << request_path;
http::send_response(client_, "<h1>403 Forbidden</h1>", "text/html", 403);
return;
}
// 处理默认文件
if (request_path.empty() || fs::is_directory(doc_root / request_path, doc_root)) {
request_path /= "index.html"; // 自动添加默认文件
}
const fs::path full_path = doc_root / request_path; // 构造完整文件路径
// 检查文件是否存在且是普通文件
if (!fs::exists(full_path) || !fs::is_regular_file(full_path)) {
LOG << "File not found or not a regular file: " << full_path;
http::send_response(client_, "<h1>404 Not Found</h1>", "text/html", 404);
return;
}
// 发送文件内容(内部处理大文件优化传输)
http::send_file(client_, full_path);
}
catch (const std::exception& e) {
// 捕获所有处理过程中的异常,记录日志并返回500错误
LOG << "Processing error: " << e.what();
http::send_response(client_, "<h1>500 Server Error</h1>", "text/html", 500);
}
}
};
// 服务器主程序 ================================================================
int main() {
try {
// RAII方式初始化Winsock(作用域结束时自动清理)
WSAInitialize wsa;
// 创建TCP监听socket(IPv4,流式套接字,TCP协议)
AutoSocket listener(AF_INET, SOCK_STREAM, IPPROTO_TCP);
listener.set_reuse_addr(); // 设置地址重用,避免TIME_WAIT状态影响重启
// 配置服务器地址结构
sockaddr_in server_addr{};
server_addr.sin_family = AF_INET; // IPv4地址族
server_addr.sin_port = htons(8880); // 监听端口8880
server_addr.sin_addr.s_addr = INADDR_ANY; // 监听所有网络接口
// 绑定socket到指定地址
listener.bind(server_addr);
// 获取实际绑定的端口(适用于端口随机分配的情况)
sockaddr_in actual_addr{};
int addr_len = sizeof(actual_addr);
getsockname(listener, reinterpret_cast<sockaddr*>(&actual_addr), &addr_len);
LOG << "Server listening on port " << ntohs(actual_addr.sin_port);
// 开始监听,设置最大等待连接数为SOMAXCONN(系统最大值)
listener.listen();
// 主循环:接受并处理客户端连接
while (true) {
try {
// 接受客户端连接
sockaddr_in client_addr{};
AutoSocket client = listener.accept(client_addr);
// 创建新线程处理客户端请求(使用移动语义转移socket所有权)
std::thread([client = std::move(client), client_addr]() mutable {
LOG << "Client handler thread started";
ClientSession session(std::move(client), client_addr);
session.process(); // 处理客户端请求
LOG << "Client handler thread exiting";
}).detach(); // 分离线程,自主运行
}
catch (const std::exception& e) {
// 处理单个连接异常,防止服务器崩溃
LOG << "Accept error: " << e.what();
continue; // 继续接受新连接
}
}
}
catch (const std::exception& e) {
// 捕获初始化阶段的致命错误
LOG << "Fatal server error: " << e.what();
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}
服务器流程
1.网络协议初始化:
- 1.配置端口;
- 2.网络协议初始化,首先创建一个能存储版本协议等信息的结构体,然后初始化这个结构体(有轮子,不用自己实现);
- 3.配置服务器地址、创建socket、设置端口复用;
- 4.绑定地址和套接字、动态分配端口(防止端口被占用)、设置监听队列;
- 5.初始化成功后进入主事件循环,设置存储客户端地址的结构体,然后开始接收连接;
- 6.2345步除了配置服务器地址外都属于对socket的配置,因此可以将用到的函数整合成一个类;
- 7.然后就是创建新线程处理请求,实际应该使用线程池。
总结起来就是主线程开启监听 socket 之后,进入无限循环调用 accept 处理客户端连接,accept 返回新的客户端 socket 后封装成任务交给线程池处理,线程池的线程共用一个队列,当有任务产生时,从任务队列中取出任务执行。
这里会扩展出几个问题:
1.如何实现在主线程投递N个任务时同时唤醒N个工作线程?
结合条件变量和任务队列的设计,任务队列与条件变量:工作线程在队列为空时阻塞在条件变量上。精准唤醒机制:主线程每投递一个任务,立即调用一次条件变量的signal,总共调用N次。每次signal唤醒一个工作线程,确保N个任务对应N次唤醒。
2.如果 accept 之后就将客户端 socket 封装成任务交给线程池处理,此时严格来说是没有任务需要执行的,因为客户端 socket 上不一定有数据需要收发,如果有数据需要收发,任务线程如何处理?如果在工作线程中将客户端 socket 挂载到某 IO 复用函数上去,那么为了保证效率,这些任务就常驻线程池了,这样几个连接之后,线程池的所有线程都被占用了,无法继续处理其他任务了?
核心矛盾:直接将客户端 socket 作为任务交给线程池,会导致工作线程被阻塞在无数据的 socket 上,浪费线程资源。关键需求:工作线程不应阻塞等待数据,而应有选择地处理已就绪的 socket。
高效解决方案:Reactor + 线程池分层
架构分层设计:
主线程(Accept) → IO 复用线程(Reactor) → 线程池(Worker Threads)
主线程:仅负责接受新连接,将客户端 socket 设置为非阻塞模式。
IO 复用线程(1个或多个):
使用 epoll/kqueue 监听所有客户端 socket 的 IO 就绪事件(如可读/可写)。
当 socket 有数据可读时,生成实际可执行的任务(如读取请求数据)。
线程池:仅处理 IO 就绪后的具体任务(如解析请求、业务计算、发送响应)。
“Reactor” 指的是 IO 复用线程,在系统架构中起到连接主线程和线程池的作用,负责处理输入 / 输出操作的复用。一般来说,Reactor 模式是一种事件驱动的设计模式,用于处理并发的服务请求。
3.既然是 Web 服务,那么解析 HTTP 数据包是一个核心功能, HTTP 协议的格式, 包头和包体如何分界,HTTP 是基于 TCP 协议的,TCP 是流式协议,包头可以通过 \r\n\r\n 确定边界,包体如何确定边界呢?
在 HTTP 协议中,包体(Body)边界的确定是网络编程的核心难点,尤其当基于流式传输的 TCP 协议时,需要根据协议规范设计精准的解析逻辑。以下是 HTTP 包体边界判断的完整技术方案:
一、HTTP 包体边界的三种判定方式
1. Content-Length
字段(固定长度)
• 规范依据:HTTP/1.1 RFC 7230。
• 工作流程:
1. 解析包头时,提取 Content-Length: N
字段值。
2. 从包头结束符 \r\n\r\n
后开始读取固定 N 字节作为包体。
• 代码示例:
cpp // 伪代码:解析 Content-Length size_t content_length = parse_header_field("Content-Length"); read_body(fd, content_length); // 精确读取 N 字节
• 注意事项:
◦ 必须验证 Content-Length
是否为合法非负整数。
◦ 若实际数据不足 N 字节,需等待后续数据到达(需设计缓冲区暂存)。
2. Transfer-Encoding: chunked
(分块传输)
• 规范依据:HTTP/1.1 RFC 7230。
• 工作流程:
1. 包头中存在 Transfer-Encoding: chunked
字段。
2. 按分块格式逐块解析:
▪ 每块以 16进制长度值 + \r\n
开头。
▪ 读取长度值对应字节后,以 \r\n
结束当前块。
▪ 最终以 0\r\n\r\n
标识包体结束。
• 代码示例:
cpp // 伪代码:解析 chunked 数据 while (true) { read_chunk_size(); // 读取块长度(例如 "1A" 表示 26 字节) if (chunk_size == 0) break; read_exact_bytes(chunk_size); // 读取块数据 read_crlf(); // 跳过 \r\n } read_trailer(); // 可选尾部头字段(通常为空)
• 注意事项:
◦ 必须处理块扩展(如 1A;key=value
)但通常可忽略。
◦ 需合并所有分块数据才能得到完整包体。
3. 连接关闭(Fallback 方案)
• 规范依据:HTTP/1.0 默认行为。
• 工作流程:
1. 若包头中无 Content-Length
且非 chunked
,则持续读取数据直到 TCP 连接关闭。
• 注意事项:
◦ 不适用于持久连接(HTTP/1.1 默认 Keep-Alive)。
◦ 仅用于兼容老旧客户端或特定场景(如服务器主动推送)。
二、协议解析状态机设计
1. 解析器状态转移
Start → 解析包头 → 检查包头字段 → 选择包体解析模式 → 解析包体 → 完成
2. 关键数据结构
• 缓冲区管理:
cpp struct http_parser { enum { PARSE_HEADER, PARSE_BODY } state; size_t content_length; // 用于固定长度模式 size_t bytes_read; // 已读取字节计数 bool chunked_mode; // 分块传输标记 size_t chunk_remaining; // 当前块剩余字节 };
3. 解析逻辑伪代码
while (true) {
switch (parser.state) {
case PARSE_HEADER:
if (找到 \r\n\r\n) {
parse_headers();
if (has_content_length) {
parser.content_length = content_length;
parser.state = PARSE_BODY;
} else if (is_chunked) {
parser.chunked_mode = true;
parser.state = PARSE_BODY;
} else {
// 无包体或 Fallback 模式
}
}
break;
case PARSE_BODY:
if (parser.chunked_mode) {
parse_chunked_data();
} else {
read_fixed_length();
}
break;
}
}
三、工程实践中的关键问题
1. 数据不完整处理
• 问题:TCP 数据可能分多次到达(如包头和包体分离到达)。
• 方案:
◦ 设计环形缓冲区(如 4KB~1MB)暂存未处理数据。
◦ 每次读取后更新解析状态,保留未消费数据。
2. 恶意攻击防御
• Content-Length 超大值:限制最大允许值(如 10MB)。
• 分块长度溢出:校验 16 进制值为合法整数。
• 缓冲区溢出:限制单次读取数据量。
3. 与 Reactor 模式结合
• 非阻塞读取:当数据不完整时,将 socket 重新挂载到 epoll,等待下次可读事件。
• 高效触发:仅在数据足够时唤醒线程池处理业务逻辑。
四、对比与选型
方法 | 适用场景 | 复杂度 | 资源占用 |
---|---|---|---|
Content-Length | 包体长度已知(如文件下载) | 低 | 内存可预测 |
Chunked Encoding | 流式生成数据(如 ChatGPT) | 高 | 需动态合并数据 |
连接关闭 | 兼容旧客户端 | 低 | 无法复用连接 |
五、扩展讨论:HTTP/2 的帧结构
• 帧格式:HTTP/2 将数据封装为带长度字段的二进制帧(Length + Type + Flags + Stream ID + Payload),彻底解决流式边界问题。
• 优势:单连接多路复用,头部压缩,更精确的流控制。
3 既然是 HTTP 协议,那么肯定可以处理 GET 和 POST 请求,那么 GET 请求和 POST 请求有什么区别,你在处理的时候,如何区分的,分别又是如何解包的
在 HTTP 协议中,GET 和 POST 是最常用的两种请求方法,它们的核心区别体现在 语义、数据传递方式、安全性、编码规范 等方面。以下是详细的技术解析和具体处理方案:
一、GET 与 POST 的核心区别
特性 | GET | POST |
---|---|---|
语义 | 获取资源(幂等操作) | 提交数据(非幂等操作) |
数据位置 | URL 查询字符串(Query String) | 请求体(Body) |
数据长度限制 | 受 URL 长度限制(通常 ≤ 2KB) | 无限制(服务器可配置最大限制) |
安全性 | 参数暴露在 URL 中(易被缓存/记录) | 参数在 Body 中(相对安全) |
缓存行为 | 可被浏览器/代理缓存 | 默认不缓存 |
编码类型 | 仅支持 application/x-www-form-urlencoded | 支持多种类型(如 multipart/form-data 、application/json ) |
幂等性 | 幂等(多次请求结果相同) | 非幂等(可能产生副作用) |
二、区分 GET 与 POST 的请求方法
1. 解析 HTTP 请求行
• 从请求的第一行提取方法类型:
http GET /path?name=value HTTP/1.1 POST /path HTTP/1.1
• 代码示例:
cpp // 伪代码:解析请求行 std::string method = parse_request_line(buffer).method; if (method == "GET") { /* 处理 GET */ } else if (method == "POST") { /* 处理 POST */ }
2. 区分逻辑
• GET:无需检查请求头 Content-Type
,直接解析 URL 查询参数。
• POST:必须检查请求头 Content-Type
和 Content-Length
,按需解析 Body。
三、GET 请求的解包处理
1. 提取 URL 查询参数
• 从 URL 中截取 ?
后的查询字符串:
http GET /api?name=Alice&age=20 HTTP/1.1
• 代码示例:
cpp size_t pos = url.find('?'); if (pos != std::string::npos) { std::string query = url.substr(pos + 1); auto params = parse_url_encoded(query); // 解析键值对 }
2. URL 编码解码
• 对 %20
等特殊字符进行解码:
cpp std::string decoded = url_decode("Alice%26Bob"); // "Alice&Bob"
3. 缓存优化
• 根据 Cache-Control
和 Expires
头决定是否缓存响应。
四、POST 请求的解包处理
1. 确定 Body 数据类型
• 从 Content-Type
头选择解析方式:
http Content-Type: application/x-www-form-urlencoded Content-Type: multipart/form-data; boundary=xxx Content-Type: application/json
2. 解析 Body 数据
• 场景 1:application/x-www-form-urlencoded
cpp // 伪代码:解析键值对(类似 GET 查询参数) std::string body = read_body(content_length); auto params = parse_url_encoded(body);
• 场景 2:multipart/form-data
(文件上传)
cpp // 伪代码:解析多部分数据 std::string boundary = extract_boundary(content_type); auto parts = parse_multipart_body(body, boundary); for (auto& part : parts) { if (part.is_file) save_file(part.filename, part.data); else process_field(part.name, part.value); }
• 场景 3:application/json
cpp // 伪代码:解析 JSON json data = json::parse(body); std::string username = data["username"];
3. 处理大数据与流式上传
• 分块读取 Body 数据,避免内存溢出:
cpp while (bytes_read < content_length) { int n = read(fd, buffer, buffer_size); buffer.append(buffer, n); bytes_read += n; }
五、安全与防御措施
1. GET 潜在风险
• URL 参数泄露:避免在 GET 中传递敏感信息(如密码)。
• XSS 攻击:对输出到 HTML 的参数进行转义。
2. POST 潜在风险
• CSRF 攻击:校验 Referer
或添加 CSRF Token。
• 文件上传漏洞:限制文件类型、大小,重命名存储路径。
3. 通用防御
• 输入验证:对所有参数进行白名单过滤。
• 缓冲区溢出:限制最大请求长度(Header + Body)。
六、工程实践建议
1. 框架选择
• 优先使用成熟的 HTTP 解析库(如 llhttp、http-parser)。
• 避免重复造轮子,尤其是处理 multipart/form-data
边界和编码。
2. 性能优化
• 零拷贝技术:直接引用接收缓冲区解析数据,减少内存复制。
• 流式解析:在数据到达时逐步解析,而非等待完整 Body。
3. 调试工具
• 使用 Wireshark 或 tcpdump 抓包验证协议解析正确性。
• 通过 Postman 或 curl 构造复杂请求测试边界条件。
4. 一般 Reactor 模式用得更多,那你知道 Proactor 和 Reactor 模式有什么区别吗
Proactor 与 Reactor 模式对比详解
一、核心概念对比
特性 | Reactor 模式 | Proactor 模式 |
---|---|---|
I/O 模型 | 同步非阻塞(Non-blocking Sync) | 异步(Asynchronous) |
事件驱动核心 | 监听 I/O 就绪事件(可读/可写) | 监听 I/O 完成事件(读写完成) |
主动权归属 | 应用层主动读写数据 | 操作系统/框架完成 I/O,应用层处理结果 |
编程复杂度 | 较低(需自行管理读写缓冲区) | 较高(需处理异步回调逻辑) |
操作系统依赖 | 依赖 epoll /kqueue 等 | 依赖 IOCP (Windows)、io_uring (Linux) |
二、工作流程对比
1. Reactor 模式流程
1. 主线程注册事件(可读/可写)到多路复用器(如 epoll)。
2. 多路复用器监听事件,当 socket 可读时通知应用层。
3. 工作线程从 socket **同步非阻塞读取数据**,处理业务逻辑。
4. 工作线程将响应数据**同步写入** socket。
2. Proactor 模式流程
1. 应用层发起异步 I/O 操作(如 `aio_read`)。
2. 操作系统负责将数据从内核缓冲区读取到用户缓冲区(无需应用层参与)。
3. I/O 完成后,操作系统通知应用层(如通过回调函数)。
4. 应用层直接处理已准备好的数据(如解析 HTTP 请求)。
三、架构角色对比
1. Reactor 模式组件
• Handles(句柄):Socket 文件描述符。
• Synchronous Event Demultiplexer:epoll
/kqueue
等待事件就绪。
• Event Handler:定义 on_read()
、on_write()
回调接口。
• Concrete Event Handler:实现业务逻辑(如 HTTP 解析)。
2. Proactor 模式组件
• Proactor:管理所有异步 I/O 操作,接收完成通知。
• Asynchronous Operation Processor:内核或框架异步执行 I/O。
• Completion Handler:定义 on_completion()
回调接口。
• Future/Promise:封装异步操作结果(如 C++ std::future
)。
四、性能与资源消耗对比
指标 | Reactor | Proactor |
---|---|---|
CPU 利用率 | 高(需频繁切换上下文处理读写) | 更高(I/O 与计算完全解耦) |
内存占用 | 需维护读写缓冲区 | 通常由系统管理缓冲区 |
吞吐量 | 受限于应用层读写效率 | 更高(零拷贝优化潜力大) |
适用场景 | 10K~100K 并发连接 | 100K+ 并发连接(如高频交易) |
五、代码示例对比
1. Reactor 伪代码(epoll 实现)
// 主线程事件循环
while (true) {
int n = epoll_wait(epfd, events, MAX_EVENTS, -1);
for (int i = 0; i < n; i++) {
if (events[i].events & EPOLLIN) {
// 同步非阻塞读取数据
char buf[1024];
int len = read(fd, buf, sizeof(buf));
if (len > 0) process_data(buf, len);
}
}
}
2. Proactor 伪代码(io_uring 实现)
// 提交异步读请求
struct io_uring_sqe *sqe = io_uring_get_sqe(ring);
io_uring_prep_read(sqe, fd, buf, size, 0);
io_uring_submit(ring);
// 处理完成事件
struct io_uring_cqe *cqe;
io_uring_wait_cqe(ring, &cqe);
if (cqe->res > 0) process_data(buf, cqe->res);
六、优缺点总结
Reactor 模式
• 优点:
• 跨平台支持好(Linux/BSD 均支持 epoll
/kqueue
)。
• 编程模型简单,适合快速开发。
• 缺点:
• 高频小数据包场景可能产生“惊群效应”。
• 需自行管理读写边界(如 HTTP 分帧)。
Proactor 模式
• 优点:
• 最大化利用系统异步 I/O 能力,吞吐量极高。
• 天然支持零拷贝(如 sendfile
直接传输文件)。
• 缺点:
• Linux 生态支持较弱(io_uring
较新,需内核 ≥5.1)。
• 回调地狱(Callback Hell)增加代码维护成本。
七、选型建议
场景 | 推荐模式 | 理由 |
---|---|---|
高并发 Web 服务(Nginx) | Reactor | 成熟稳定,适合 Linux 环境 |
高频交易系统(Windows 服务) | Proactor | 利用 IOCP 最大化 Windows 性能 |
文件传输/大数据处理 | Proactor | 异步 + 零拷贝显著提升吞吐量 |
嵌入式/IoT 设备 | Reactor | 资源消耗低,适配实时操作系统(RTOS) |
八、混合模式(Hybrid)实践
现代高性能框架(如 Boost.Asio、libuv)通常提供统一接口,底层自动选择最优模式:
• Linux:默认使用 epoll
(Reactor),io_uring
可用时切换至 Proactor。
• Windows:直接使用 IOCP
(Proactor)。
// Boost.Asio 统一接口示例
asio::async_read(socket, asio::buffer(buf), [](error_code ec, size_t len) {
if (!ec) process_data(buf, len); // 回调由框架自动适配 Reactor/Proactor
});
总结
Reactor 与 Proactor 本质是两种不同的 I/O 事件处理哲学:
• Reactor:“等你有数据了我来读” → 同步非阻塞,主动权在应用层。
• Proactor:“你读好数据了通知我” → 完全异步,主动权在系统层。
选择时需权衡 性能需求、平台特性、开发成本,在高并发场景下,Reactor 因通用性强仍是主流,而 Proactor 在特定环境(如 Windows 或 Linux io_uring
)中可发挥极致性能。
5.select、poll、epoll,我接着询问这三个 IO 复用函数的使用场景和优缺点,该同学答对后,我问了下 epoll 模型的水平和边缘触发模式的区别。
一、select、poll、epoll 对比
特性 | select | poll | epoll |
---|---|---|---|
跨平台 | 所有平台支持 | 多数系统(Linux/BSD) | Linux 特有 |
最大连接数 | 受 FD_SETSIZE 限制(默认 1024) | 无硬性限制(基于链表) | 仅受系统内存限制 |
时间复杂度 | O(n) 线性遍历 | O(n) 线性遍历 | O(1) 事件驱动,仅处理活跃 fd |
触发模式 | 水平触发(LT) | 水平触发(LT) | 支持 LT 和边缘触发(ET) |
内存开销 | 固定大小 fd_set | 动态数组(pollfd 结构体) | 红黑树存储 fd,高效管理 |
适用场景 | 低并发、兼容性要求高 | 中低并发、需更多连接 | 高并发(如 10K+ 连接) |
核心区别:
• select/poll:每次调用需传递全部 fd 集合,内核遍历所有 fd 检查状态。
• epoll:通过 epoll_ctl
注册 fd,内核维护就绪队列,仅返回活跃 fd。
二、epoll 的 LT(Level-Triggered)与 ET(Edge-Triggered)模式
1. 触发机制对比
模式 | 触发条件 | 数据未读完时的行为 |
---|---|---|
LT | 只要 fd 可读/可写,持续通知 | 下次调用 epoll_wait 会再次通知 |
ET | 仅当 fd 状态变化(如空→非空)时通知一次 | 必须一次处理完,否则丢失通知 |
2. 代码实现差异
LT 模式示例:
// 水平触发:只要缓冲区有数据就会触发
if (events[i].events & EPOLLIN) {
char buf[1024];
int len = read(fd, buf, sizeof(buf)); // 可能未读完所有数据
// 未读完时,下次 epoll_wait 会再次触发
}
ET 模式示例:
// 边缘触发:必须一次性读完所有数据
if (events[i].events & EPOLLIN) {
while (true) {
char buf[1024];
int len = read(fd, buf, sizeof(buf));
if (len == -1 && errno == EAGAIN) break; // 无数据可读
if (len <= 0) { /* 处理关闭或错误 */ break; }
process_data(buf, len);
}
}
3. 关键注意事项
• ET 模式必须搭配非阻塞 fd:
// 设置非阻塞模式(ET 必须)
int flags = fcntl(fd, F_GETFL, 0);
fcntl(fd, F_SETFL, flags | O_NONBLOCK);
• ET 模式优势:
• 减少事件触发次数,提高吞吐量(如处理突发流量)。
• 避免 LT 模式下“惊群效应”(多个线程/进程被唤醒)。
• ET 模式缺点:
• 编程复杂度高,需处理一次性读取全部数据。
• 可能因未及时处理数据导致业务延迟。
三、使用场景选择
模式 | 适用场景 | 典型案例 |
---|---|---|
LT | 简单业务逻辑、低并发、需兼容性 | 传统 Web 服务、代理服务器 |
ET | 高性能要求、高并发、需精细控制事件触发 | 高频交易系统、实时游戏服务器 |
LT 与 ET 性能对比(假设 10K 并发连接):
指标 | LT | ET |
---|---|---|
事件触发次数 | 高(每次数据到达均触发) | 低(仅状态变化时触发) |
CPU 占用率 | 较高 | 较低 |
吞吐量 | 中 | 高 |
四、生产环境最佳实践
-
ET 模式 + 非阻塞 IO:
• 避免因未读完数据导致事件丢失。
• 结合read
/write
循环直到EAGAIN
错误。 -
LT 模式下的优化:
• 在数据可读时立即处理,避免多次触发。
• 使用EPOLLONESHOT
标志避免重复触发(需重新注册 fd)。 -
混合使用 LT 和 ET:
• 对写操作使用 ET(减少触发次数),对读操作使用 LT(简化处理逻辑)。
五、常见问题解答
Q1:ET 模式是否一定比 LT 快?
• 不一定。ET 减少事件触发次数,但处理不当可能因多次系统调用(如循环 read
)抵消优势。需根据业务场景实测。
Q2:如何避免 ET 模式下的数据饥饿?
• 使用非阻塞 IO + 循环读取到 EAGAIN
,确保单次事件处理完所有数据。
Q3:epoll 为什么高效?
• 内核通过红黑树管理 fd,仅返回就绪事件,避免无意义的遍历。
总结
• LT 模式:编程简单,适合对吞吐量要求不高的场景。
• ET 模式:性能更优,但需结合非阻塞 IO 和精细的数据处理逻辑。
• 实际开发中,推荐 ET 模式 应对高并发挑战,并通过严谨的代码设计规避潜在问题。
6.假设我某个客户端 socket 绑定到 epollfd 上后使用边缘触发模式,现在该客户端发来了 100 个字节,是否会触发读事件;服务端收了 50 个字节,读事件会在下一轮中继续触发吗?假设接着客户端又发了 10 字节,此时服务端会触发读事件吗?
分步解释与答案:
-
客户端发送 100 字节时:
• 触发条件:客户端首次发送数据,socket 接收缓冲区从空变为非空(状态变化)。
• 结果:触发一次读事件,epoll_wait
返回该 fd 的EPOLLIN
事件。 -
服务端读取 50 字节后:
• 缓冲区状态:仍有 50 字节未读。
• 触发条件:ET 模式仅在状态变化时触发。由于缓冲区始终有数据(从 100 → 50,但未变空),无新状态变化。
• 结果:下一轮epoll_wait
不会触发读事件,剩余 50 字节将滞留在缓冲区,直到下次数据到达或应用主动读取。 -
客户端再次发送 10 字节时:
• 缓冲区状态:从 50 字节 → 60 字节(未发生空 → 非空的状态变化)。
• 触发条件:ET 模式只关注“边缘”(如空 → 非空),不关心数据量增加。无新状态变化。
• 结果:不会触发读事件,除非满足以下条件:
◦ 服务端将缓冲区读空(返回EAGAIN
)。
◦ 客户端再次发送数据,导致缓冲区从空 → 非空。
关键结论:
• ET 模式的核心逻辑:仅在 fd 状态发生边缘变化(如空→非空、非空→空)时触发事件。
• 必须一次性读取所有数据:应用需循环读取直到 read
返回 EAGAIN
,否则后续数据可能无法及时处理。
• 数据滞留风险:若未读完数据,即使新数据到达,只要缓冲区未变空,ET 模式不会重复通知。
处理 ET 模式的正确姿势:
// 边缘触发模式下的读操作(非阻塞 fd)
while (true) {
char buf[1024];
int len = read(fd, buf, sizeof(buf));
if (len == -1) {
if (errno == EAGAIN || errno == EWOULDBLOCK) {
break; // 数据已读完,退出循环
}
// 处理其他错误(如连接关闭)
close(fd);
break;
} else if (len == 0) {
// 对端关闭连接
close(fd);
break;
}
process_data(buf, len); // 处理数据
}
最终答案:
• 第一次发 100 字节:触发读事件 ✅
• 读取 50 字节后:下一轮不触发 ❌
• 再发 10 字节:不触发 ❌(除非缓冲区被读空后再次发数据)