52、HTTP客户端实现:使用socket实现HTTP GET请求、响应解析
说实话,HTTP协议看起来复杂,但底层就是TCP socket通信。你想想看,浏览器访问网页时,本质上就是建立TCP连接,然后发送一段特定格式的文本,再接收服务器返回的文本。今天我们就手写一个HTTP客户端,用最原始的socket来实现GET请求,并解析返回的响应。
我在项目中遇到过不少这样的情况:用现成的libcurl库固然方便,但一旦遇到嵌入式环境或者需要定制化协议时,你就得自己动手了。所以,理解底层原理非常有必要。
HTTP GET请求的本质
一个标准的HTTP GET请求,说白了就是一段符合格式的字符串。它长这样:
GET /index.html HTTP/1.1\r\n
Host: www.example.com\r\n
User-Agent: MyCClient/1.0\r\n
Connection: close\r\n
\r\n
注意看,这里有三个关键点:
- 请求行:包含方法(GET)、路径(/index.html)、协议版本(HTTP/1.1)
- 请求头:每行一个键值对,用冒号分隔
- 空行:\r\n\r\n 表示请求头结束,这是必须的
我个人习惯把请求头的末尾空行叫做「信号弹」——告诉服务器:我发完了,你可以开始处理了。
响应解析的难点
服务器返回的响应格式类似:
HTTP/1.1 200 OK\r\n
Content-Type: text/html\r\n
Content-Length: 1234\r\n
\r\n
<html>...</html>
解析时最头疼的是什么呢?分块接收。网络数据是流式的,你一次recv()可能只收到一部分,也可能一次收到好几段。我曾经在调试一个HTTP客户端时,就因为假设一次recv()能收完所有数据,结果在弱网环境下频繁出bug。
完整代码实现
下面是我写的一个精简版HTTP客户端。它支持GET请求,能解析响应状态码、头部和body。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <netdb.h>
#define BUFFER_SIZE 4096
// 解析HTTP响应状态行
int parse_status_line(const char *buf, int *status_code) {
// 格式: HTTP/1.1 200 OK\r\n
if (sscanf(buf, "HTTP/%*d.%*d %d", status_code) != 1) {
return -1;
}
return 0;
}
// 查找头部字段的值
char *get_header_value(const char *headers, const char *field) {
char *found = strstr(headers, field);
if (!found) return NULL;
char *colon = strchr(found, ':');
if (!colon) return NULL;
char *value = colon + 1;
// 跳过空格
while (*value == ' ') value++;
// 找到行尾
char *end = strstr(value, "\r\n");
if (!end) return NULL;
int len = end - value;
char *result = (char *)malloc(len + 1);
strncpy(result, value, len);
result[len] = '\0';
return result;
}
// 发送HTTP GET请求
int http_get(const char *host, const char *path, int port) {
int sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
perror("socket");
return -1;
}
struct hostent *server = gethostbyname(host);
if (!server) {
fprintf(stderr, "无法解析主机名: %s\n", host);
close(sock);
return -1;
}
struct sockaddr_in addr;
memset(&addr, 0, sizeof(addr));
addr.sin_family = AF_INET;
addr.sin_port = htons(port);
memcpy(&addr.sin_addr.s_addr, server->h_addr, server->h_length);
if (connect(sock, (struct sockaddr *)&addr, sizeof(addr)) < 0) {
perror("connect");
close(sock);
return -1;
}
// 构造GET请求
char request[1024];
snprintf(request, sizeof(request),
"GET %s HTTP/1.1\r\n"
"Host: %s\r\n"
"User-Agent: MyCClient/1.0\r\n"
"Connection: close\r\n"
"\r\n",
path, host);
if (send(sock, request, strlen(request), 0) < 0) {
perror("send");
close(sock);
return -1;
}
// 接收响应
char buffer[BUFFER_SIZE];
char response[8192];
int total = 0;
int n;
while ((n = recv(sock, buffer, BUFFER_SIZE - 1, 0)) > 0) {
buffer[n] = '\0';
if (total + n < (int)sizeof(response) - 1) {
memcpy(response + total, buffer, n);
total += n;
} else {
break;
}
}
response[total] = '\0';
close(sock);
// 解析响应
char *header_end = strstr(response, "\r\n\r\n");
if (!header_end) {
fprintf(stderr, "无效的HTTP响应\n");
return -1;
}
int header_len = header_end - response;
char *headers = (char *)malloc(header_len + 1);
strncpy(headers, response, header_len);
headers[header_len] = '\0';
// 解析状态码
int status_code;
if (parse_status_line(response, &status_code) == 0) {
printf("状态码: %d\n", status_code);
}
// 获取Content-Length
char *cl = get_header_value(headers, "Content-Length");
if (cl) {
printf("Content-Length: %s\n", cl);
free(cl);
}
// 输出body
char *body = header_end + 4;
printf("\n=== Body ===\n%s\n", body);
free(headers);
return status_code;
}
int main() {
// 示例:请求httpbin.org的根路径
http_get("httpbin.org", "/get", 80);
return 0;
}
代码要点解析
这段代码里,有几个地方我想特别强调一下:
- gethostbyname():这是DNS解析的老牌函数。虽然现在更推荐getaddrinfo(),但在简单场景下它够用了。我早期做嵌入式开发时,很多平台只支持这个函数。
- Connection: close:我故意用了这个头部。它告诉服务器:发完响应就关闭连接。这样我们就不需要处理keep-alive的长连接逻辑,简化了代码。
- 响应解析的边界处理:代码里用strstr查找"\r\n\r\n"来定位头部结束。但要注意,如果响应体里也包含这个序列怎么办?嗯,这里其实不会,因为头部结束后紧接着就是body,我们只查找第一次出现的位置。
核心流程图
下面我用SVG画了一张流程图,展示HTTP GET请求的完整生命周期:
常见问题与避坑
写HTTP客户端时,有几个坑我踩过,分享给你:
坑1:忘记处理chunked编码
有些服务器返回的响应没有Content-Length,而是用Transfer-Encoding: chunked。这时候body是分块传输的,每块前面有长度标识。我早期写的客户端没处理这个,结果解析出来的body总是残缺的。
坑2:忽略HTTP重定向
当你请求一个URL,服务器可能返回301或302状态码,并在Location头部告诉你要跳转到另一个地址。我建议至少实现一次重定向跟随,否则很多网站都访问不了。
坑3:缓冲区溢出
上面代码里我用的是固定大小的response数组。如果响应超过8KB,就会截断。实际项目中,应该动态扩容或者边接收边处理。
扩展思考
这个基础版本实现了HTTP/1.1的GET请求。如果你想继续深入,可以考虑:
- 支持HTTPS:在connect之后,用OpenSSL库做TLS握手
- 支持POST请求:把请求行改成POST,加上Content-Type和Content-Length头部,然后在空行后发送body
- 支持Cookie:解析Set-Cookie头部,并在后续请求中带上Cookie头部
- 连接池:复用TCP连接,避免频繁三次握手
说实话,写一个完整的HTTP客户端库并不简单。但掌握了今天这个基础版本,你就已经理解了HTTP协议的核心通信机制。剩下的,无非是在这个骨架上不断添砖加瓦。