第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,它告诉服务器请求体有多长。服务器读到这个字节数,就知道请求体结束了。
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-urlencoded和Content-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);
CURLOPT_TIMEOUT和CURLOPT_CONNECTTIMEOUT,把总超时控制在10秒以内。
20.6 知识体系总览
下面这张图,把本章的核心逻辑串起来了。从底层socket到高层libcurl,每一层解决什么问题,一目了然。
20.7 总结
这一章我们走了三条路:
- socket裸写:让你理解HTTP协议在TCP层面的真实模样。虽然代码啰嗦,但这是基本功。
- 封装API:把重复劳动抽象成函数,提高复用性。你想想看,如果每个请求都写一遍socket,那项目得乱成什么样。
- libcurl:工业级方案,开箱即用。生产环境我强烈推荐用它,别自己造轮子——除非你想学习底层原理。
最后说一句:不管用哪种方式,错误处理一定要做全。网络编程最怕的就是「看起来没问题,跑起来就崩」。超时、断连、DNS解析失败……这些场景都要覆盖到。嗯,代码写稳了,晚上才能睡踏实。
strace跟踪一下系统调用,看看实际发了什么数据。有时候你以为自己发了GET / HTTP/1.1,结果因为字符串拼接错误,发出去的是GET / HTTP/1.1后面跟了一堆乱码。strace一抓一个准。