24、HTTP协议解析:实现简单的HTTP客户端,解析请求与响应。
说实话,HTTP协议这东西,咱们天天都在用。你打开浏览器看网页,刷手机App,背后都是HTTP在干活。但很多人写了好几年C语言,真要自己动手解析一个HTTP请求或响应,反而懵了。
我记得刚入行那会儿,有个项目需要从嵌入式设备上抓取天气数据。设备资源有限,没法用现成的libcurl库。怎么办?只能自己手写一个HTTP客户端。那时候我才真正搞明白,HTTP协议说白了就是一堆文本,按特定格式排列好,然后通过Socket发出去。
今天咱们就来实现一个简单的HTTP客户端。不依赖任何第三方库,纯C语言+Socket,手撕HTTP协议。
24.1 HTTP协议的本质
HTTP协议,全称超文本传输协议。它基于请求-响应模型。客户端发一个请求,服务器回一个响应。就这么简单。
但简单归简单,格式是有严格要求的。我见过不少新手,自己拼HTTP请求字符串时,忘了加回车换行符,结果服务器一直不响应。嗯,这里要注意:HTTP协议里,每一行结尾必须是\r\n(回车+换行),不是单纯的\n。
核心要点:HTTP请求和响应都是纯文本格式,按行组织,每行以\r\n结束。头部和正文之间有一个空行(即单独的\r\n)。
24.2 HTTP请求的格式
一个标准的HTTP请求长这样:
GET /index.html HTTP/1.1\r\n
Host: www.example.com\r\n
User-Agent: Mozilla/5.0\r\n
Accept: */*\r\n
\r\n
我来拆解一下:
- 请求行:第一行。包含方法(GET)、路径(/index.html)、协议版本(HTTP/1.1)。
- 请求头:从第二行开始,到空行结束。每行一个键值对,用冒号分隔。
- 空行:一个单独的
\r\n,表示头部结束。 - 请求体:空行之后的内容。GET请求通常没有请求体,POST请求才有。
你想想看,为什么要有空行?因为服务器需要知道哪里是头部结束、哪里是正文开始。这个空行就是分界线。
24.3 HTTP响应的格式
响应和请求的结构类似,但第一行不同:
HTTP/1.1 200 OK\r\n
Content-Type: text/html\r\n
Content-Length: 1234\r\n
\r\n
<html>...</html>
响应第一行叫状态行,包含协议版本、状态码、状态描述。200表示成功,404表示没找到,500表示服务器内部错误。这些状态码我在项目中不知道见过多少次了。
响应头里有个关键字段:Content-Length。它告诉客户端正文有多长。没有这个字段,客户端就不知道什么时候该停止读取。我曾经踩过这个坑——服务器返回了分块传输编码(Transfer-Encoding: chunked),我没处理,结果读到的数据不完整。
24.4 手写HTTP客户端:代码实现
好了,理论说完了,咱们直接上代码。这个客户端会做三件事:
- 建立TCP连接
- 发送HTTP GET请求
- 接收并解析HTTP响应
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <arpa/inet.h>
#include <sys/socket.h>
#define BUFFER_SIZE 4096
// 构建HTTP GET请求
void build_http_request(const char *host, const char *path,
char *request, size_t size) {
snprintf(request, size,
"GET %s HTTP/1.1\r\n"
"Host: %s\r\n"
"User-Agent: SimpleCClient/1.0\r\n"
"Accept: */*\r\n"
"Connection: close\r\n"
"\r\n",
path, host);
}
// 解析HTTP响应状态行
int parse_status_line(const char *line, int *status_code) {
// 格式: HTTP/1.1 200 OK
char version[16];
char status_text[64];
if (sscanf(line, "%15s %d %63[^\r\n]", version, status_code, status_text) != 3) {
return -1;
}
return 0;
}
// 解析响应头,查找指定字段
const char* get_header_value(const char *headers, const char *field) {
const char *found = strstr(headers, field);
if (!found) return NULL;
// 跳过字段名和冒号空格
found += strlen(field);
while (*found == ' ' || *found == ':') found++;
// 找到行尾
const char *end = strstr(found, "\r\n");
if (!end) return NULL;
// 返回动态分配的字符串
static char value[256];
size_t len = end - found;
if (len >= sizeof(value)) len = sizeof(value) - 1;
strncpy(value, found, len);
value[len] = '\0';
return value;
}
int main() {
const char *host = "www.example.com";
const char *path = "/";
int sock;
struct sockaddr_in server_addr;
char request[1024];
char response[BUFFER_SIZE];
// 1. 创建Socket
sock = socket(AF_INET, SOCK_STREAM, 0);
if (sock < 0) {
perror("socket");
return 1;
}
// 2. 连接服务器
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(80);
if (inet_pton(AF_INET, "93.184.216.34", &server_addr.sin_addr) <= 0) {
perror("inet_pton");
close(sock);
return 1;
}
if (connect(sock, (struct sockaddr*)&server_addr, sizeof(server_addr)) < 0) {
perror("connect");
close(sock);
return 1;
}
// 3. 构建并发送HTTP请求
build_http_request(host, path, request, sizeof(request));
printf("发送请求:\n%s\n", request);
if (send(sock, request, strlen(request), 0) < 0) {
perror("send");
close(sock);
return 1;
}
// 4. 接收响应
memset(response, 0, sizeof(response));
int total_received = 0;
int n;
while ((n = recv(sock, response + total_received,
sizeof(response) - total_received - 1, 0)) > 0) {
total_received += n;
}
if (n < 0) {
perror("recv");
close(sock);
return 1;
}
response[total_received] = '\0';
printf("接收响应 (%d bytes):\n%s\n", total_received, response);
// 5. 解析响应
// 找到第一个空行,分割头部和正文
char *header_end = strstr(response, "\r\n\r\n");
if (!header_end) {
printf("错误: 未找到头部结束标记\n");
close(sock);
return 1;
}
// 提取状态行
char *status_line_end = strstr(response, "\r\n");
if (status_line_end) {
size_t status_len = status_line_end - response;
char status_line[256];
strncpy(status_line, response, status_len);
status_line[status_len] = '\0';
int status_code;
if (parse_status_line(status_line, &status_code) == 0) {
printf("状态码: %d\n", status_code);
}
}
// 提取头部
size_t header_len = header_end - response;
char headers[2048];
strncpy(headers, response, header_len);
headers[header_len] = '\0';
// 提取正文
char *body = header_end + 4;
size_t body_len = total_received - (body - response);
printf("响应头:\n%s\n", headers);
printf("响应体长度: %zu bytes\n", body_len);
// 查找Content-Type
const char *content_type = get_header_value(headers, "Content-Type");
if (content_type) {
printf("Content-Type: %s\n", content_type);
}
close(sock);
return 0;
}
个人经验:实际项目中,我一般会用一个循环来接收数据,因为一次recv可能收不完所有数据。特别是响应体很大的时候,需要多次recv直到服务器关闭连接或收到指定长度的数据。
24.5 解析响应的关键步骤
上面代码里,解析响应主要分三步:
- 找空行:用
strstr(response, "\r\n\r\n")找到头部和正文的分界点。 - 解析状态行:用
sscanf提取状态码和状态描述。 - 解析头部:遍历每一行,用冒号分割键值对。
这里有个坑:sscanf解析字符串时,如果字段里有空格,要注意格式控制。比如状态描述"OK"没问题,但如果是"Not Found",中间有空格,就要用%[^\r\n]来匹配到行尾。
24.6 处理常见问题
我在项目中遇到过几个典型问题,列出来给你参考:
| 问题 | 原因 | 解决方案 |
|---|---|---|
| 服务器不响应 | 请求格式错误,缺少\r\n | 检查每一行结尾,确保是\r\n |
| 收到数据不完整 | 未处理多次recv | 用循环接收,直到recv返回0或-1 |
| 解析头部失败 | 未正确处理大小写 | 比较字段名时忽略大小写 |
| Content-Length缺失 | 服务器使用chunked编码 | 检测Transfer-Encoding字段,按chunked规则解析 |
避坑指南:我曾经在解析响应头时,直接用strstr查找"Content-Length"。结果服务器返回的是"content-length"(全小写),没匹配上。后来我学乖了,比较头部字段名时统一转成小写再比较。
24.7 知识体系总览
下面这张图,把HTTP客户端解析的核心流程串起来了:
说白了,整个流程就是:拼字符串 → 发出去 → 收回来 → 拆字符串。但每一步都有细节要注意。比如拼请求时,路径要URL编码;收响应时,要考虑连接是否关闭;拆字符串时,要处理各种边界情况。
24.8 扩展:支持POST请求
如果你想支持POST请求,只需要改一下请求行和加上请求体:
void build_http_post(const char *host, const char *path,
const char *body, char *request, size_t size) {
snprintf(request, size,
"POST %s HTTP/1.1\r\n"
"Host: %s\r\n"
"Content-Type: application/x-www-form-urlencoded\r\n"
"Content-Length: %zu\r\n"
"Connection: close\r\n"
"\r\n"
"%s",
path, host, strlen(body), body);
}
注意:POST请求必须设置Content-Length字段,告诉服务器正文有多长。否则服务器不知道什么时候该停止读取请求体。
好了,关于HTTP协议解析和简单HTTP客户端的实现,就讲到这里。代码虽然简单,但涵盖了HTTP协议的核心机制。你可以在自己的项目里直接拿来用,或者在此基础上扩展,比如支持HTTPS、支持重定向、支持Cookie等。