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请求,然后异步读取响应。整个过程不阻塞主线程。
整体架构
先看一张流程图,理解整个客户端的运行脉络:
这张图展示了从初始化到接收响应的完整链路。每个步骤都是异步的,靠回调函数串联起来。
代码实现
直接上代码。我习惯把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