实战:HTTP客户端(socket编程+HTTP协议)
说实话,HTTP 协议这东西,你天天在用。浏览器打开网页、手机刷朋友圈、甚至你点个外卖,背后都是 HTTP 在干活。但很多人写代码时,只是调个库就完事了——比如 libcurl、QNetworkAccessManager 之类的。
我个人习惯是,至少亲手写一次底层的 HTTP 客户端。为什么?因为只有你自己拼过 HTTP 请求报文、解析过响应头,你才能真正理解「协议」这两个字的分量。今天我们就用 C++ 的 socket 编程,手撸一个最简单的 HTTP 客户端。
核心目标:用原生 socket 发送 HTTP GET 请求,接收服务器返回的 HTML 页面,并解析 HTTP 状态码和响应头。
1. 先搞清楚 HTTP 协议长什么样
HTTP 协议说白了就是「一问一答」。客户端发一个请求,服务器回一个响应。格式非常固定,全是纯文本。
请求报文格式:
GET /index.html HTTP/1.1
Host: www.example.com
Connection: close
注意最后有一个空行,这是必须的。空行表示请求头结束,后面如果有请求体(比如 POST 请求),就接着写。
响应报文格式:
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1234
<html>...</html>
第一行是状态行,包含协议版本、状态码和状态描述。后面是响应头,空行之后是响应体。
我的一点经验:刚开始写的时候,最容易忘的就是那个空行。我曾经调试了半小时,发现服务器一直没响应,最后发现是少打了一个 \r\n。嗯,细节决定成败。
2. 用 socket 建立 TCP 连接
HTTP 底层走的是 TCP。所以第一步,你得创建一个 socket,然后连接到服务器的 80 端口(HTTPS 是 443,但今天我们只做 HTTP)。
代码其实不复杂,但有几个坑要注意:
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <netdb.h>
#include <unistd.h>
#include <string>
#include <iostream>
int create_socket(const std::string& host, int port) {
// 1. 获取主机 IP
struct hostent* server = gethostbyname(host.c_str());
if (server == nullptr) {
std::cerr << "无法解析主机名: " << host << std::endl;
return -1;
}
// 2. 创建 socket
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
std::cerr << "创建 socket 失败" << std::endl;
return -1;
}
// 3. 填充服务器地址结构
struct sockaddr_in server_addr;
memset(&server_addr, 0, sizeof(server_addr));
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(port);
memcpy(&server_addr.sin_addr.s_addr, server->h_addr, server->h_length);
// 4. 连接服务器
if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
std::cerr << "连接失败" << std::endl;
close(sock);
return -1;
}
return sock;
}
注意:gethostbyname 不是线程安全的。如果你在多线程环境下使用,建议用 getaddrinfo 替代。不过我们今天的例子是单线程,够用了。
3. 拼装 HTTP 请求并发送
连接建立之后,就该发送请求了。这里我直接拼字符串:
std::string build_http_get(const std::string& host, const std::string& path) {
std::string request;
request += "GET " + path + " HTTP/1.1\r\n";
request += "Host: " + host + "\r\n";
request += "Connection: close\r\n";
request += "\r\n"; // 这个空行不能少!
return request;
}
bool send_request(int sock, const std::string& request) {
ssize_t sent = send(sock, request.c_str(), request.size(), 0);
if (sent < 0) {
std::cerr << "发送请求失败" << std::endl;
return false;
}
return true;
}
你可能会问:为什么用 Connection: close?因为这样服务器发完响应后就会主动关闭连接,我们就不用自己判断什么时候该收完了。简单粗暴,适合初学者。
4. 接收并解析响应
接收数据是个循环过程。你不能指望一次 recv 就把所有数据都收完。网络数据是流式的,你得一直读到连接关闭。
std::string receive_response(int sock) {
std::string response;
char buffer[4096];
ssize_t bytes_received;
while ((bytes_received = recv(sock, buffer, sizeof(buffer) - 1, 0)) > 0) {
buffer[bytes_received] = '\0';
response += buffer;
}
if (bytes_received < 0) {
std::cerr << "接收数据出错" << std::endl;
}
return response;
}
收到完整响应后,我们来解析一下状态码和响应头:
void parse_response(const std::string& response) {
// 找第一个空行,区分头部和体部
size_t header_end = response.find("\r\n\r\n");
if (header_end == std::string::npos) {
std::cerr << "无效的 HTTP 响应" << std::endl;
return;
}
std::string header = response.substr(0, header_end);
std::string body = response.substr(header_end + 4);
// 解析状态行
size_t first_line_end = header.find("\r\n");
std::string status_line = header.substr(0, first_line_end);
std::cout << "状态行: " << status_line << std::endl;
// 提取状态码
size_t code_start = status_line.find(' ') + 1;
size_t code_end = status_line.find(' ', code_start);
std::string status_code = status_line.substr(code_start, code_end - code_start);
std::cout << "状态码: " << status_code << std::endl;
// 打印响应头
std::cout << "响应头:" << std::endl;
std::string headers = header.substr(first_line_end + 2);
std::cout << headers << std::endl;
// 打印响应体前200个字符
std::cout << "响应体 (前200字符):" << std::endl;
std::cout << body.substr(0, 200) << std::endl;
}
一个小技巧:实际项目中,你可能会遇到 chunked 编码的响应(Transfer-Encoding: chunked)。这种情况下,响应体不是靠 Content-Length 来分界的,而是靠特殊的块格式。不过今天我们只处理最简单的场景,够用就好。
5. 整合起来:完整的 main 函数
int main() {
std::string host = "www.example.com";
std::string path = "/";
int port = 80;
int sock = create_socket(host, port);
if (sock < 0) {
return 1;
}
std::string request = build_http_get(host, path);
if (!send_request(sock, request)) {
close(sock);
return 1;
}
std::string response = receive_response(sock);
close(sock);
parse_response(response);
return 0;
}
编译运行:
g++ -o http_client http_client.cpp
./http_client
如果一切顺利,你会看到类似这样的输出:
状态行: HTTP/1.1 200 OK
状态码: 200
响应头:
Content-Encoding: gzip
...
响应体 (前200字符):
<!doctype html>...
6. 核心流程一览
我把整个流程画了一张图,方便你理解:
7. 避坑指南
写这个程序的时候,有几个地方特别容易翻车:
- 换行符必须是 \r\n——HTTP 协议规定行尾是 CRLF,不是单纯的 \n。我曾经用 \n 发请求,服务器直接返回 400 Bad Request。
- recv 可能一次收不完——不要假设一次 recv 就能拿到完整响应。必须循环接收,直到 recv 返回 0(对端关闭)或出错。
- 主机名解析可能失败——如果网络不通或者 DNS 有问题,gethostbyname 会返回 nullptr。记得做错误处理。
- 端口号要用网络字节序——htons 这个函数不能省,否则端口会乱掉。
重要提醒:这个例子只是教学用途。生产环境中请使用成熟的 HTTP 库(如 libcurl、cpprestsdk),它们处理了超时、重定向、SSL/TLS、连接池等大量细节。自己手写 socket 做 HTTP 客户端,只适合学习协议原理。
8. 扩展思考
如果你觉得这个例子太简单,可以试着加几个功能:
- 支持 POST 请求,发送表单数据
- 解析 Content-Length,精确控制接收数据量
- 支持 HTTPS(需要集成 OpenSSL)
- 处理重定向(301/302 状态码)
- 支持持久连接(Connection: keep-alive)
我个人建议,先把 GET 请求吃透,再慢慢加功能。一口吃不成胖子,网络编程尤其如此。
好了,今天的实战就到这里。代码虽然简单,但麻雀虽小五脏俱全。你把它跑通了,HTTP 协议对你来说就不再是黑盒了。