71、libuv实战:使用libuv实现HTTP客户端

HTTP客户端,说白了就是帮你去网上「拿东西」的工具。你想想看,平时我们用浏览器访问网页,底层就是HTTP协议在干活。今天咱们用libuv这个事件驱动库,手写一个轻量级的HTTP客户端。

我个人习惯把网络编程分成三层:底层socket操作、中间的事件循环、上层的协议解析。libuv帮我们搞定了前两层,我们只需要关注HTTP协议本身。嗯,这里要注意,libuv本身不提供HTTP解析,所以我们需要自己处理响应报文。

为什么选libuv?

我在项目中遇到过不少网络库,libuv给我的感觉是「小而美」。它不像Boost.Asio那么庞大,也不像原始epoll那么底层。它提供了统一的异步接口,跨平台做得很好。

具体到HTTP客户端,libuv的优势在于:

  • 非阻塞I/O:不会因为网络慢而卡住整个程序
  • 事件驱动:连接、读、写都是回调函数,逻辑清晰
  • 内存管理:uv_buf_t的设计很巧妙,减少拷贝

核心思路:用libuv的tcp句柄建立连接,发送HTTP请求,然后异步读取响应。整个过程不阻塞主线程。

整体架构

先看一张流程图,理解整个客户端的运行脉络:

libuv HTTP客户端 核心流程 1. 初始化libuv事件循环 2. 创建tcp句柄 uv_tcp_t 3. 解析域名 → 建立TCP连接 4. 发送HTTP请求报文 5. 异步读取响应数据

这张图展示了从初始化到接收响应的完整链路。每个步骤都是异步的,靠回调函数串联起来。

代码实现

直接上代码。我习惯把HTTP客户端封装成一个结构体,方便管理状态:

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <uv.h>

#define BUFFER_SIZE 4096

typedef struct {
    uv_tcp_t *socket;
    uv_connect_t *connect_req;
    uv_write_t *write_req;
    uv_buf_t buf;
    char response[BUFFER_SIZE * 4];
    size_t response_len;
    int connected;
} http_client_t;

这个结构体包含了socket句柄、连接请求、写请求和响应缓冲区。response_len用来记录已经收到的数据长度,防止覆盖。

小技巧:缓冲区大小设为4KB的倍数,是因为HTTP响应头通常不会超过4KB,而body部分可以分多次读取。

连接回调

连接建立后,我们立刻发送HTTP GET请求。这里有个细节:连接回调里要检查错误码。

void on_connect(uv_connect_t *req, int status) {
    if (status < 0) {
        fprintf(stderr, "连接失败: %s\n", uv_strerror(status));
        return;
    }

    http_client_t *client = (http_client_t *)req->data;
    client->connected = 1;

    // 构造HTTP GET请求
    const char *request = "GET / HTTP/1.1\r\n"
                          "Host: example.com\r\n"
                          "Connection: close\r\n"
                          "\r\n";

    uv_buf_t buf = uv_buf_init((char *)request, strlen(request));
    uv_write(client->write_req, (uv_stream_t *)client->socket, &buf, 1, on_write);
}

我曾经踩过一个坑:忘记设置Connection: close,结果服务器一直保持连接,程序卡在读取阶段。后来才明白,短连接场景下显式关闭连接更安全。

读取响应

libuv的读取回调会多次触发,直到连接关闭。我们需要把每次收到的数据拼接起来:

void on_read(uv_stream_t *stream, ssize_t nread, const uv_buf_t *buf) {
    http_client_t *client = (http_client_t *)stream->data;

    if (nread > 0) {
        // 拼接到响应缓冲区
        if (client->response_len + nread < sizeof(client->response)) {
            memcpy(client->response + client->response_len, buf->base, nread);
            client->response_len += nread;
        }
        client->response[client->response_len] = '\0';
    } else if (nread < 0) {
        // 读取结束或出错
        if (nread != UV_EOF) {
            fprintf(stderr, "读取错误: %s\n", uv_strerror(nread));
        }
        // 打印完整响应
        printf("收到响应 (%zu bytes):\n%s\n", client->response_len, client->response);
        uv_close((uv_handle_t *)client->socket, on_close);
    }

    if (buf->base) {
        free(buf->base);
    }
}

嗯,这里要注意:uv_buf_t的base指针需要手动释放。libuv不会帮你管理这块内存。我刚开始用的时候漏了free,内存泄漏查了半天。

主函数与事件循环

最后是启动流程。先初始化事件循环,然后解析域名并连接:

int main() {
    uv_loop_t *loop = uv_default_loop();

    http_client_t client;
    memset(&client, 0, sizeof(client));

    uv_tcp_t socket;
    uv_tcp_init(loop, &socket);

    uv_connect_t connect_req;
    uv_write_t write_req;

    client.socket = &socket;
    client.connect_req = &connect_req;
    client.write_req = &write_req;

    // 设置回调数据
    connect_req.data = &client;
    socket.data = &client;

    // 解析域名并连接
    struct sockaddr_in addr;
    uv_ip4_addr("93.184.216.34", 80, &addr);  // example.com的IP

    uv_tcp_connect(&connect_req, &socket, (const struct sockaddr *)&addr, on_connect);

    // 启动事件循环
    uv_run(loop, UV_RUN_DEFAULT);

    uv_loop_close(loop);
    return 0;
}

注意:这里我直接用了IP地址。实际项目中应该用uv_getaddrinfo做DNS解析。直接写死IP只适合测试。

运行效果

编译运行后,你会看到类似这样的输出:

收到响应 (1270 bytes):
HTTP/1.1 200 OK
Content-Type: text/html; charset=UTF-8
...

<!doctype html>
<html>
<head>
    <title>Example Domain</title>
...

这说明我们的HTTP客户端成功拿到了example.com的首页内容。整个过程没有阻塞,事件循环驱动着每一步。

避坑指南

我总结几个实战中容易踩的坑:

  • 内存泄漏:每次uv_read_start分配的内存,一定要在回调里释放。我建议用uv_buf_init配合栈上缓冲区,减少动态分配。
  • 连接超时:libuv默认没有连接超时。我曾经在生产环境遇到DNS挂死,整个进程卡住。后来加了uv_timer_t做超时检测。
  • 响应不完整:HTTP响应可能分多个chunk传输。不要假设一次read就能拿到全部数据。正确的做法是持续读取直到连接关闭或遇到Transfer-Encoding: chunked的结束标记。

进阶建议:如果你想支持HTTPS,可以结合libuv的uv_tls_t或者用mbedTLS。libuv本身不提供SSL,但它的接口设计得很干净,很容易集成第三方库。

说实话,用libuv写HTTP客户端比直接用socket省心太多了。你不用操心epoll、kqueue这些底层机制,也不用处理非阻塞I/O的各种边界情况。libuv把这些都封装好了,你只需要关注业务逻辑。

我个人觉得,libuv最大的价值在于它的「一致性」。不管在Linux、macOS还是Windows上,同样的代码都能跑。这对于做跨平台工具的人来说,简直是福音。


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