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客户端:代码实现

好了,理论说完了,咱们直接上代码。这个客户端会做三件事:

  1. 建立TCP连接
  2. 发送HTTP GET请求
  3. 接收并解析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客户端解析的核心流程串起来了:

HTTP客户端解析流程 1. 构建HTTP请求 2. 发送请求 3. 接收响应 4. 解析响应(头部 + 正文) 解析状态行 提取状态码、状态描述 解析头部字段 提取Content-Type、Content-Length等 提取正文 根据Content-Length或chunked读取 输出解析结果

说白了,整个流程就是:拼字符串 → 发出去 → 收回来 → 拆字符串。但每一步都有细节要注意。比如拼请求时,路径要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等。


公众号:蓝海资料掘金营,微信deep3321