第24章 HTTP协议解析:请求与响应格式、基于Socket的HTTP客户端实现
说到网络编程,HTTP协议绝对是绕不开的话题。我刚开始做嵌入式网络开发那会儿,总觉得HTTP是上层应用的事,跟我写C代码没关系。直到有一次要为一个物联网设备写固件升级模块,才发现——嗯,不懂HTTP还真不行。
说白了,HTTP就是客户端和服务器之间约定好的一种对话方式。你问一句,它答一句。问有问的格式,答有答的规矩。今天我们就把它拆开来看。
24.1 HTTP请求长什么样?
一个标准的HTTP请求,由三部分组成:请求行、请求头、请求体。我习惯把它想象成一张快递单——收件地址、附加说明、包裹内容。
比如:
GET /index.html HTTP/1.1
请求头是键值对,一行一个。常见的像:
Host:目标主机名(HTTP/1.1 强制要求)User-Agent:客户端标识Accept:客户端能接收的内容类型Connection:是否保持连接
请求体不是必须的。GET请求就没有,POST请求才有。你想想看,你只是去服务器拿个页面,带什么包裹呢?
24.2 HTTP响应又是什么结构?
响应和请求是对称的。它也有三部分:状态行、响应头、响应体。
比如:
HTTP/1.1 200 OK
状态码是重点。我整理了一个速查表:
| 状态码 | 含义 | 我的经验 |
|---|---|---|
| 200 | OK,成功 | 最常见,看到它就放心了 |
| 301/302 | 重定向 | 我曾经被这个坑过,没处理重定向导致一直拿不到数据 |
| 400 | 请求格式错误 | 多半是你拼错了请求行 |
| 404 | 资源不存在 | 路径写错了,检查一下 |
| 500 | 服务器内部错误 | 不是你的问题,是服务器炸了 |
响应头里有个字段特别重要——Content-Length。它告诉你响应体有多长。为什么重要?因为TCP是流式传输,你不知道什么时候读完。有了这个长度,你就知道该读多少字节。
24.3 基于Socket的HTTP客户端实现
理论说完了,咱们动手写代码。用Socket实现HTTP客户端,说白了就是三步:
- 建立TCP连接(socket + connect)
- 发送HTTP请求(send/write)
- 接收HTTP响应(recv/read)
听起来简单吧?但细节里全是坑。我一个个说。
24.3.1 建立连接
先解析域名,拿到IP地址。这里用getaddrinfo,比老式的gethostbyname更安全。我个人习惯用这个:
struct addrinfo hints, *res;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
int ret = getaddrinfo("example.com", "80", &hints, &res);
if (ret != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(ret));
return -1;
}
int sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
connect(sockfd, res->ai_addr, res->ai_addrlen);
freeaddrinfo(res);
freeaddrinfo 释放内存。我见过有人忘了这个,程序跑几天内存就爆了。
24.3.2 构造并发送请求
请求的格式必须严格遵循HTTP规范。行尾是\r\n,不是\n。我曾经在这个细节上栽过跟头——在Linux上写\n没问题,但换到某个嵌入式系统上,服务器死活不认。
char request[1024];
snprintf(request, sizeof(request),
"GET / HTTP/1.1\r\n"
"Host: example.com\r\n"
"Connection: close\r\n"
"\r\n");
send(sockfd, request, strlen(request), 0);
注意最后那个空行——\r\n\r\n,它表示请求头结束。没有它,服务器会一直等你的请求头。
24.3.3 接收响应
接收响应是最容易出问题的环节。为什么?因为TCP是流,你不知道一次recv能收到多少数据。可能一次收完,也可能分好几次。
我的做法是循环接收,直到连接关闭:
char buf[4096];
int bytes;
while ((bytes = recv(sockfd, buf, sizeof(buf) - 1, 0)) > 0) {
buf[bytes] = '\0';
printf("%s", buf);
}
这里有个细节:Connection: close。我在请求头里加了这一行,告诉服务器:你发完数据就断开连接吧。这样我就能通过recv返回0来判断数据结束。
Connection: keep-alive,连接不会自动关闭。这时候你必须解析Content-Length或Transfer-Encoding: chunked来精确控制读取长度。否则程序会一直阻塞在recv上。
24.4 完整示例:一个简单的HTTP客户端
把上面的代码拼起来,就是一个能用的HTTP客户端了。我写了一个精简版,只保留核心逻辑:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/socket.h>
#include <netdb.h>
#include <unistd.h>
int main() {
const char *host = "example.com";
const char *port = "80";
// 1. 解析域名
struct addrinfo hints, *res;
memset(&hints, 0, sizeof(hints));
hints.ai_family = AF_INET;
hints.ai_socktype = SOCK_STREAM;
int ret = getaddrinfo(host, port, &hints, &res);
if (ret != 0) {
fprintf(stderr, "getaddrinfo: %s\n", gai_strerror(ret));
return 1;
}
// 2. 创建socket并连接
int sockfd = socket(res->ai_family, res->ai_socktype, res->ai_protocol);
if (sockfd < 0) {
perror("socket");
freeaddrinfo(res);
return 1;
}
if (connect(sockfd, res->ai_addr, res->ai_addrlen) < 0) {
perror("connect");
close(sockfd);
freeaddrinfo(res);
return 1;
}
freeaddrinfo(res);
// 3. 发送HTTP请求
char request[] =
"GET / HTTP/1.1\r\n"
"Host: example.com\r\n"
"Connection: close\r\n"
"\r\n";
send(sockfd, request, strlen(request), 0);
// 4. 接收并打印响应
char buf[4096];
int bytes;
while ((bytes = recv(sockfd, buf, sizeof(buf) - 1, 0)) > 0) {
buf[bytes] = '\0';
printf("%s", buf);
}
close(sockfd);
return 0;
}
这个程序虽然简单,但已经能完成最基本的HTTP GET请求了。你编译运行一下,就能看到example.com返回的HTML页面。
24.5 知识体系总览
为了让你对整个流程有个直观的认识,我画了一张图:
24.6 避坑指南
做HTTP客户端,有几个坑我踩过,你注意一下:
- 换行符必须是
\r\n——我曾在Windows上开发,换到Linux上部署,结果服务器返回400。查了半天,原来是代码里混用了\n。 - 不要假设一次recv能收完所有数据——TCP是流,不是消息。循环接收是标准做法。
- 处理HTTP重定向——很多服务器会返回302让你跳转到HTTPS。如果你的客户端不处理重定向,就永远拿不到真正的页面。
- 注意超时设置——用
setsockopt设置SO_RCVTIMEO,防止网络异常时程序卡死。
telnet手动发一次请求,确认服务器行为。比如:telnet example.com 80,然后手动输入GET / HTTP/1.1和Host: example.com。这样能快速验证你的请求格式是否正确。
好了,HTTP协议解析和基于Socket的客户端实现就讲到这里。代码虽然简单,但它是很多高级网络应用的基础。你把这个吃透了,后面写HTTPS客户端、WebSocket客户端都会轻松很多。