实战: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. 核心流程一览

我把整个流程画了一张图,方便你理解:

HTTP 客户端核心流程 1. 创建 Socket 2. 连接服务器 3. 发送 HTTP 请求 4. 接收 HTTP 响应 等待连接 接受连接 解析请求 返回响应 TCP 三次握手 连接建立 GET / HTTP/1.1 HTTP/1.1 200 OK 客户端 → 服务器:请求 | 服务器 → 客户端:响应

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 协议对你来说就不再是黑盒了。