第20章 HTTP客户端:从socket裸写到libcurl封装

说实话,HTTP协议看起来简单,但真要自己动手实现一个完整的客户端,坑还是挺多的。我记得刚入行那会儿,老大丢给我一个任务——用socket写个HTTP下载器。我当时心想,不就是发个GET请求嘛,结果被各种响应头解析、chunked编码、连接复用折腾得够呛。

这一章,咱们就从头捋一遍。先拿socket裸写一个HTTP客户端,理解底层原理;再封装成易用的API;最后看看libcurl这个工业级库怎么用。嗯,这样你以后遇到网络编程,心里就有底了。

20.1 先搞懂HTTP请求的本质

HTTP说白了就是文本协议。你打开一个TCP连接,往里面写一段特定格式的文本,服务器就会回你一段文本。就这么简单。

一个标准的GET请求长这样:

GET /index.html HTTP/1.1
Host: www.example.com
User-Agent: MyClient/1.0
Connection: close

注意最后有个空行,那是请求头的结束标志。服务器看到空行,就知道请求头发完了,开始处理。

POST请求呢,就是多了一个请求体:

POST /api/login HTTP/1.1
Host: www.example.com
Content-Type: application/x-www-form-urlencoded
Content-Length: 27

username=admin&password=123

关键点在于Content-Length,它告诉服务器请求体有多长。服务器读到这个字节数,就知道请求体结束了。

我的小经验: 早期我犯过一个低级错误——忘了在请求头末尾加空行。结果服务器一直等在那里,连接就挂死了。排查了半天才发现是少了个\r\n。这种细节,写代码时一定要盯紧。

20.2 用socket实现HTTP GET

好,理论说完了,咱们直接上代码。下面这个函数,用socket发送一个GET请求,并把响应体打印出来。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/socket.h>
#include <netdb.h>

int http_get(const char *host, const char *path) {
    // 1. 解析域名
    struct addrinfo hints, *res;
    memset(&hints, 0, sizeof(hints));
    hints.ai_family = AF_INET;
    hints.ai_socktype = SOCK_STREAM;

    int ret = getaddrinfo(host, "80", &hints, &res);
    if (ret != 0) {
        fprintf(stderr, "getaddrinfo failed: %s\n", gai_strerror(ret));
        return -1;
    }

    // 2. 创建socket
    int sock = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
    if (sock < 0) {
        perror("socket");
        freeaddrinfo(res);
        return -1;
    }

    // 3. 连接服务器
    if (connect(sock, res->ai_addr, res->ai_addrlen) < 0) {
        perror("connect");
        close(sock);
        freeaddrinfo(res);
        return -1;
    }
    freeaddrinfo(res);

    // 4. 构造并发送HTTP请求
    char request[1024];
    snprintf(request, sizeof(request),
             "GET %s HTTP/1.1\r\n"
             "Host: %s\r\n"
             "Connection: close\r\n"
             "\r\n",
             path, host);

    if (send(sock, request, strlen(request), 0) < 0) {
        perror("send");
        close(sock);
        return -1;
    }

    // 5. 接收响应
    char buf[4096];
    int n;
    while ((n = recv(sock, buf, sizeof(buf) - 1, 0)) > 0) {
        buf[n] = '\0';
        printf("%s", buf);
    }

    close(sock);
    return 0;
}

这段代码看着不长,但每一步都有讲究。我重点说几个地方:

  • getaddrinfo:这个函数把域名解析成IP地址。它比老旧的gethostbyname更安全,支持IPv6。我个人习惯只用它。
  • Connection: close:告诉服务器,响应发完就断开连接。这样我们不用处理keep-alive的复杂逻辑,适合初学者。
  • recv循环:HTTP响应可能分多个包到达,必须循环读取直到recv返回0(对端关闭)或出错。
注意: 上面的代码把响应头和响应体一起打印了。实际项目中,你需要先解析响应头,找到空行,再单独处理响应体。别偷懒,后面我们会讲怎么解析。

20.3 解析HTTP响应头

服务器返回的响应长这样:

HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1234
Server: nginx/1.18.0

<html>...</html>

第一行是状态行,包含协议版本、状态码和状态描述。后面是键值对形式的响应头,直到空行结束。空行之后就是响应体。

解析的思路很简单:逐行读取,先解析状态行,再解析头部字段,遇到空行就停止。下面是一个解析函数:

typedef struct {
    int status_code;
    char status_text[64];
    char content_type[128];
    int content_length;
} http_response;

int parse_response(const char *raw, http_response *resp) {
    const char *p = raw;

    // 解析状态行: "HTTP/1.1 200 OK\r\n"
    sscanf(p, "HTTP/1.1 %d %63s", &resp->status_code, resp->status_text);
    p = strstr(p, "\r\n");
    if (!p) return -1;
    p += 2; // 跳过\r\n

    // 解析头部字段
    resp->content_length = -1;
    resp->content_type[0] = '\0';

    while (*p) {
        // 遇到空行,头部结束
        if (p[0] == '\r' && p[1] == '\n') {
            p += 2;
            break;
        }

        char key[64], value[256];
        if (sscanf(p, "%63[^:]: %255[^\r\n]", key, value) == 2) {
            if (strcasecmp(key, "Content-Type") == 0) {
                strncpy(resp->content_type, value, sizeof(resp->content_type) - 1);
            } else if (strcasecmp(key, "Content-Length") == 0) {
                resp->content_length = atoi(value);
            }
        }

        p = strstr(p, "\r\n");
        if (!p) return -1;
        p += 2;
    }

    // 此时p指向响应体起始位置
    return p - raw; // 返回头部长度
}

这个函数返回头部长度,调用者可以用它来定位响应体的起始位置。注意我用的是strcasecmp做大小写不敏感比较——HTTP头部字段名是不区分大小写的。我曾经见过一个服务器返回content-length全小写,如果你的代码只认Content-Length,那就翻车了。

20.4 封装成易用的API

裸socket写多了,你会发现重复代码太多。每次都要创建socket、连接、发送、接收、解析。不如封装一下:

typedef struct {
    char *data;      // 响应体数据
    size_t size;     // 数据大小
    int status_code; // HTTP状态码
} http_result;

http_result *http_request(const char *method, const char *url, 
                          const char *body, const char *headers) {
    // 解析URL,提取host和path
    // 创建socket,连接
    // 构造请求,发送
    // 接收响应,解析头部
    // 提取响应体,返回
}

这个封装隐藏了所有细节,调用者只需要传一个URL就能拿到结果。实际项目中,你还可以加入超时控制、重定向跟随、cookie管理等功能。嗯,这些就是libcurl帮你做的事。

20.5 libcurl:工业级HTTP客户端

自己封装虽然能学到东西,但生产环境还是用libcurl靠谱。它支持HTTPS、代理、认证、多线程……你想得到的它都有。

先看一个最简单的例子:

#include <curl/curl.h>

size_t write_callback(void *ptr, size_t size, size_t nmemb, void *stream) {
    size_t total = size * nmemb;
    // 把数据追加到内存缓冲区
    // 这里可以写入文件、打印到屏幕等
    return total;
}

int main() {
    CURL *curl = curl_easy_init();
    if (!curl) {
        fprintf(stderr, "curl_easy_init failed\n");
        return -1;
    }

    curl_easy_setopt(curl, CURLOPT_URL, "http://www.example.com");
    curl_easy_setopt(curl, CURLOPT_WRITEFUNCTION, write_callback);

    CURLcode res = curl_easy_perform(curl);
    if (res != CURLE_OK) {
        fprintf(stderr, "curl_easy_perform failed: %s\n", 
                curl_easy_strerror(res));
    }

    curl_easy_cleanup(curl);
    return 0;
}

libcurl的核心概念是easy handle。你创建一个handle,设置各种选项,然后执行。选项非常多,我列几个常用的:

选项 作用 示例值
CURLOPT_URL 设置请求URL "http://api.example.com/data"
CURLOPT_POSTFIELDS 设置POST请求体 "key=value"
CURLOPT_TIMEOUT 超时时间(秒) 30L
CURLOPT_FOLLOWLOCATION 跟随重定向 1L
CURLOPT_SSL_VERIFYPEER 验证HTTPS证书 0L(测试用,生产环境别关)

对于POST请求,设置CURLOPT_POSTFIELDS即可:

curl_easy_setopt(curl, CURLOPT_URL, "http://api.example.com/login");
curl_easy_setopt(curl, CURLOPT_POSTFIELDS, "username=admin&password=123");
curl_easy_setopt(curl, CURLOPT_POSTFIELDSIZE, (long)strlen("username=admin&password=123"));

libcurl会自动设置Content-Type: application/x-www-form-urlencodedContent-Length。如果你想自定义头部,可以用curl_slist

struct curl_slist *headers = NULL;
headers = curl_slist_append(headers, "Authorization: Bearer mytoken");
headers = curl_slist_append(headers, "X-Custom-Header: value");
curl_easy_setopt(curl, CURLOPT_HTTPHEADER, headers);

// 执行请求...

curl_slist_free_all(headers);
避坑指南: 我曾经在生产环境遇到一个诡异的问题——libcurl请求偶尔会卡死。排查了半天,发现是DNS解析超时。默认情况下,libcurl的DNS超时时间很长(60秒)。解决方案是设置CURLOPT_TIMEOUTCURLOPT_CONNECTTIMEOUT,把总超时控制在10秒以内。

20.6 知识体系总览

下面这张图,把本章的核心逻辑串起来了。从底层socket到高层libcurl,每一层解决什么问题,一目了然。

HTTP客户端实现层次结构 第1层:Socket裸写 socket() → connect() → send() → recv() → close() 手动构造HTTP请求文本,逐字节解析响应 第2层:封装成API http_get() / http_post() / parse_response() 隐藏socket细节,提供结构化接口 第3层:libcurl工业级库 curl_easy_init() → curl_easy_setopt() → curl_easy_perform() 支持HTTPS、代理、认证、重定向、多线程 从底层到高层,每一层解决不同的问题

20.7 总结

这一章我们走了三条路:

  • socket裸写:让你理解HTTP协议在TCP层面的真实模样。虽然代码啰嗦,但这是基本功。
  • 封装API:把重复劳动抽象成函数,提高复用性。你想想看,如果每个请求都写一遍socket,那项目得乱成什么样。
  • libcurl:工业级方案,开箱即用。生产环境我强烈推荐用它,别自己造轮子——除非你想学习底层原理。

最后说一句:不管用哪种方式,错误处理一定要做全。网络编程最怕的就是「看起来没问题,跑起来就崩」。超时、断连、DNS解析失败……这些场景都要覆盖到。嗯,代码写稳了,晚上才能睡踏实。

我的习惯: 每次写完网络相关的代码,我都会用strace跟踪一下系统调用,看看实际发了什么数据。有时候你以为自己发了GET / HTTP/1.1,结果因为字符串拼接错误,发出去的是GET / HTTP/1.1后面跟了一堆乱码。strace一抓一个准。

公众号:蓝海资料掘金营,微信deep3321