一、HTTP 报文格式("快递包裹" 的说明书)
HTTP 报文是浏览器和服务器的 "沟通载体",就像你寄快递时的 "包裹 + 快递单"------ 既要有 "收件信息"(沟通规则),也要有 "包裹内容"(实际数据)。整体分为请求报文 (客户端→服务器)和响应报文(服务器→客户端),结构统一是 "开始行 + 首部行 + 实体主体"。
1. 报文整体结构(通用规则)
【是什么】
所有 HTTP 报文都遵循 "三段式" 结构,缺一不可(部分字段可省略):
plaintext
开始行(核心信息)→ 首部行(附加说明)→ 空行(分隔符)→ 实体主体(实际数据)
- 开始行:一句话说清 "干什么"(请求 / 响应的核心目的);
- 首部行:补充说明(比如 "包裹重量""接收要求"),可有多行;
- 空行:必须有!用来区分首部和主体,少了会解析失败;
- 实体主体:实际要传输的数据(比如表单数据、HTML 页面)。
【为什么】
没有统一格式,服务器和浏览器就 "看不懂对方的话"------ 比如浏览器发个 "我要首页",服务器不知道是要 HTML 还是图片,也不知道浏览器支持什么格式,报文格式就是统一的 "沟通语言规范"。
【怎么用】
举个直观例子(请求报文 + 响应报文):
plaintext
// 请求报文(客户端→服务器)
GET /index.html HTTP/1.1 // 开始行(请求行)
Host: 192.168.1.100:8080 // 首部行(服务器地址)
Connection: Keep-Alive // 首部行(长连接)
Accept: text/html // 首部行(接收HTML格式)
// 空行(必须有)
username=admin&password=123 // 实体主体(POST请求的数据)
// 响应报文(服务器→客户端)
HTTP/1.1 200 OK // 开始行(状态行)
Content-Type: text/html // 首部行(返回HTML)
Content-Length: 1024 // 首部行(数据长度1024字节)
Date: Thu, 01 Jan 2025 08:00:00 GMT // 首部行(时间)
// 空行(必须有)
<html><body>首页内容</body></html> // 实体主体(HTML页面)
【坑在哪】
- 缺少空行:服务器会把实体主体当首部行解析,直接报错;
- 首部行格式错:必须是 "字段名:值"(冒号后有空格),少空格会解析失败;
- 实体主体和 Content-Length 不匹配:服务器会提前断开连接或接收不全数据。
2. 请求报文(客户端发的 "需求单")
【是什么】
客户端发给服务器的 "需求申请",核心是 "我要什么资源,用什么方式要"。结构:
- 开始行(请求行):
请求方法 + URL + HTTP版本(比如GET / HTTP/1.1); - 首部行:补充请求信息(如 Host、Accept、Cookie 等);
- 实体主体:POST 请求时存放数据(如表单、文件),GET 请求一般为空。
【为什么】
服务器需要知道三个核心问题:"做什么操作"(请求方法)、"要什么资源"(URL)、"用什么规则沟通"(HTTP 版本),首部行补充额外要求(比如 "我只接受 HTML""保持连接")。
【怎么用】
- 请求方法:常用 GET(查资源)、POST(提交数据),资料里还提了 HEAD、PUT 等;
- URL:要访问的资源路径(比如
/index.html); - 首部行常用字段:
- Host:服务器 IP + 端口(必填,比如
Host: 192.168.1.100:8080); - Connection: Keep-Alive:要求长连接(HTTP 1.1 默认);
- Content-Length:POST 请求时说明实体主体长度;
- Host:服务器 IP + 端口(必填,比如
- 实体主体:POST 请求的表单数据(比如
username=admin&password=123)。
【坑在哪】
- GET 请求带大量数据:GET 的参数在 URL 里,长度有限制(约 2KB),大数据要用 POST;
- 请求方法用错:比如用 GET 提交密码(明文显示在 URL),不安全;
- 缺少 Host 字段:HTTP 1.1 要求必须带,否则服务器返回 400 错误。
3. 响应报文(服务器发的 "回执单")
【是什么】
服务器回复客户端的 "处理结果",核心是 "请求处理得怎么样,给你什么数据"。结构:
- 开始行(状态行):
HTTP版本 + 状态码 + 短语(比如HTTP/1.1 200 OK); - 首部行:补充响应信息(如 Content-Type、Content-Length);
- 实体主体:要返回给客户端的数据(比如 HTML 页面、JSON 数据)。
【为什么】
客户端需要知道 "请求成功了吗"(状态码)、"返回的是什么类型数据"(Content-Type)、"数据有多大"(Content-Length),这样才能正确显示内容。
【怎么用】
- 状态码(核心中的核心):
- 2XX:成功(200 OK:请求成功);
- 3XX:重定向(304 Not Modified:缓存可用);
- 4XX:客户端错(404 Not Found:资源不存在);
- 5XX:服务器错(500 Internal Server Error:服务器崩溃);
- 首部行常用字段:
- Content-Type:返回数据类型(
text/html=HTML,application/json=JSON); - Content-Length:数据长度(帮助客户端判断是否接收完);
- Set-Cookie:服务器给客户端设置 Cookie(记录登录状态);
- Content-Type:返回数据类型(
- 实体主体:比如 HTML 页面、求和结果(
1+2=3)。
【坑在哪】
- 状态码用错:比如明明资源存在却返回 404,客户端会误以为没找到;
- Content-Type 写错:比如返回 JSON 却标
text/html,浏览器会显示乱码; - 实体主体为空却带 Content-Length:客户端会一直等待数据,超时断开。
二、HTTP 服务器("快递驿站" 的运作流程)
HTTP 服务器本质是 "监听端口→接收请求→解析请求→处理业务→返回响应" 的程序,资料里给了完整的 C++ 实现(多线程并发),我们按 "代码文件 + 核心流程" 拆解,就像拆解驿站的运作步骤。
1. 服务器核心原理
【是什么】
一个 "永远在线的快递驿站":
- 监听端口:驿站开在固定地址(IP + 端口),等待客户(客户端)上门;
- 接收连接:客户到店(客户端连接),驿站分配工作人员(线程)接待;
- 解析请求:工作人员问清客户需求(解析 HTTP 报文);
- 处理业务:按需求找包裹(读取 HTML 文件)或办业务(求和、登录);
- 返回响应:把包裹 + 回执(响应报文)交给客户。
【为什么】
没有服务器,客户端的请求就 "没人接"------ 比如你在浏览器输http://localhost:8080,没有服务器监听 8080 端口,浏览器会直接提示 "无法连接"。服务器是 Web 通信的 "核心枢纽"。
【怎么用】(按代码实例讲解)
资料里的服务器由 5 个文件组成:main.cpp(入口)、http.cpp(核心处理)、custom_handle.cpp(业务逻辑)、http.h(头文件)、custom_handle.h(头文件),还有CMakeLists.txt(编译配置)。
核心文件拆解(逐个击破)
(1)入口文件:main.cpp(驿站大门)
cpp
运行
#include <myhead.h>
#include "http.h"
// 线程处理函数(每个客户分配一个线程)
void *msg_request(void *arg) {
int sock = *(int *)arg;
delete (int *)arg;
handler_msg(sock); // 调用核心处理函数
close(sock);
return NULL;
}
int main(int argc, const char *argv[]) {
int port = 80; // 默认端口80(HTTP默认端口)
if (argc > 1) port = atoi(argv[1]); // 允许手动传端口(比如./thttpd.out 8080)
int lis_socket = init_server(port); // 初始化服务器(创建套接字、绑定、监听)
// 循环接收客户端连接(多线程并发)
while (1) {
struct sockaddr_in peer;
socklen_t len = sizeof(peer);
int sock = accept(lis_socket, (struct sockaddr *)&peer, &len); // 接收连接
if (sock == -1) { perror("accept error"); return -1; }
printf("新客户端连接(fd=%d)\n", sock);
// 创建线程处理该客户端,防止阻塞其他客户
pthread_t tid;
if (pthread_create(&tid, NULL, msg_request, new int(sock)) > 0) {
printf("pthread_create error\n");
return -1;
}
pthread_detach(tid); // 线程分离,自动回收资源
}
close(lis_socket);
return 0;
}
【是什么】
服务器的 "入口大门":初始化服务器、监听端口、多线程接收客户端连接,给每个客户端分配独立线程处理(避免一个客户卡住所有人)。
【为什么】
- 多线程:单线程只能接待一个客户,多线程能同时接待多个(比如宿舍多个人同时访问);
- 端口可配置:默认 80 端口(不用输端口),也能手动改(比如 8080,避免端口占用)。
【怎么用】
- 编译:按
CMakeLists.txt配置(add_executable(thttpd.out main.cpp http.cpp custom_handle.cpp)),链接 pthread 库; - 运行:
./thttpd.out(默认 80 端口)或./thttpd.out 8080(自定义端口); - 访问:浏览器输
http://服务器IP:端口(比如http://192.168.1.100:8080)。
【坑在哪】
- 端口占用:80 端口可能被其他程序占用,换 8080、9090 等端口;
- 线程创建失败:检查是否链接 pthread 库(
target_link_libraries(thttpd.out pthread)); - 内存泄漏:
new int(sock)后要在线程里delete,否则内存越用越多。
(2)核心处理:http.cpp(驿站工作人员)
核心函数handler_msg(解析请求 + 响应):
cpp
运行
int handler_msg(int sock) {
char del_buf[SIZE] = "";
recv(sock, del_buf, SIZE, MSG_PEEK); // 偷看请求数据(不读走)
printf("收到请求:\n%s\n", del_buf);
// 1. 解析请求行(方法、URL、版本)
char buf[SIZE] = "";
int count = get_line(sock, buf); // 读取请求行
char method[32] = "";
int k=0, i=0;
// 提取请求方法(GET/POST)
while (i<count && !isspace(buf[i])) {
method[k++] = buf[i++];
}
method[k] = '\0';
// 跳过空格,提取URL
while (isspace(buf[i]) && i<SIZE) i++;
char url[SIZE] = "";
char *querry_string = NULL; // 存储URL中的参数(比如?data=123)
int t=0;
while (i<SIZE && !isspace(buf[i])) {
if (buf[i] == '?') { // 有参数(GET请求)
querry_string = &url[t];
querry_string++;
url[t] = '\0';
} else {
url[t++] = buf[i];
}
i++;
}
url[t] = '\0';
printf("方法:%s,URL:%s,参数:%s\n", method, url, querry_string);
// 2. 确定资源路径(wwwroot下的文件)
char path[SIZE] = "";
sprintf(path, "../wwwroot%s", url);
if (path[strlen(path)-1] == '/') strcat(path, "index.html"); // 默认首页
// 3. 判断是否需要处理业务(POST或GET带参数)
int need_handle = 0;
if (strcasecmp(method, "POST") == 0 || (strcasecmp(method, "GET")==0 && querry_string!=NULL)) {
need_handle = 1;
}
// 4. 处理请求
struct stat st;
if (stat(path, &st) == -1) { // 资源不存在
echo_error(sock, 404); // 返回404页面
close(sock);
return -1;
}
if (need_handle == 1) {
handle_request(sock, method, path, querry_string); // 处理业务(求和、登录)
} else {
clear_header(sock); // 清空首部
echo_www(sock, path, st.st_size); // 返回静态页面(比如HTML)
}
close(sock);
return 0;
}
【是什么】
服务器的 "核心大脑":解析请求行(方法、URL)、确定资源路径、判断是否需要业务处理(求和、登录),要么返回静态页面,要么调用业务逻辑。
【为什么】
- 解析 URL:把客户端请求的
/index.html转换成服务器本地的../wwwroot/index.html; - 区分请求类型:静态请求(直接返回 HTML)和动态请求(求和、登录)分开处理;
- 错误处理:资源不存在时返回 404 页面,提升用户体验。
【怎么用】
- 静态页面:把 HTML 文件放在
../wwwroot目录下(比如index.html、404.html),访问http://IP/会默认返回index.html; - 动态请求:POST 请求提交求和 / 登录数据,服务器调用
custom_handle.cpp的业务函数。
【坑在哪】
- 路径错误:
sprintf(path, "../wwwroot%s", url)要确保wwwroot目录在正确位置(比如服务器程序在bin目录,wwwroot在上级目录); - 解析 URL 失败:URL 中有特殊字符(比如中文)要编码,否则解析错乱;
- 404 页面缺失:要在
wwwroot下创建404.html,否则echo_www会报错。
(3)业务逻辑:custom_handle.cpp(驿站专项窗口)
处理求和、登录等业务,核心函数parse_and_process:
cpp
运行
int parse_and_process(int sock, const char *querry_string, char * req_buf) {
// 处理求和请求(data1=1&data2=2)
if (strstr(req_buf, "data1=") && strstr(req_buf, "data2=")) {
return handle_add(sock, req_buf);
}
// 处理登录请求(username=admin&password=123)
else if (strstr(req_buf, "username=") && strstr(req_buf, "password=")) {
return handle_login(sock, req_buf);
} else {
return 0;
}
}
// 求和逻辑
static int handle_add(int sock, const char * req_buf) {
int num1, num2;
sscanf(req_buf, "data1=%d&data2=%d", &num1, &num2); // 解析参数
printf("求和:%d+%d=%d\n", num1, num2, num1+num2);
char reply_buf[HTML_SIZE] = "";
sprintf(reply_buf, "%d", num1+num2); // 结果转字符串
send(sock, reply_buf, strlen(reply_buf), 0); // 返回结果
return 0;
}
// 登录逻辑
int handle_login(int sock, char *req_buf) {
char *uname = strstr(req_buf, "username=");
uname += strlen("username=");
char *ptr = strstr(req_buf, "password=");
*(ptr-1) = '\0'; // 分割用户名和密码
char *passwd = ptr + strlen("password=");
printf("登录:账号=%s,密码=%s\n", uname, passwd);
// 账号密码相等则登录成功,跳转首页
if (strcmp(uname, passwd) == 0) {
char reply_buf[HTML_SIZE] = "";
sprintf(reply_buf, "<script>localStorage.setItem('usr_user_name', '%s'); window.location.href='/index.html';</script>", uname);
send(sock, reply_buf, strlen(reply_buf), 0);
}
return 0;
}
【是什么】
服务器的 "专项业务窗口":处理动态请求(求和、登录),按业务逻辑处理后返回结果(比如求和结果、登录跳转脚本)。
【为什么】
静态页面只能展示内容,动态业务(比如用户登录、数据计算)需要专门的逻辑处理,这部分代码和核心解析分离,便于维护。
【怎么用】
- 求和功能:用 POST 请求提交
data1=1&data2=2,服务器返回3; - 登录功能:提交
username=admin&password=admin(账号密码相等),服务器返回跳转脚本,跳转到index.html并存储用户名到 localStorage。
【坑在哪】
- 解析参数失败:
sscanf格式要和请求数据匹配(比如data1=%d&data2=%d对应data1=1&data2=2); - 登录跳转失败:确保
index.html存在,脚本语法正确(引号闭合); - 业务逻辑耦合:如果要加新业务(比如注册),直接在
parse_and_process中加分支即可,不用改核心解析代码。
2. 服务器整体流程(从连接到响应)
plaintext
客户端发起请求 → main.cpp的accept接收连接 → 创建线程 → 线程调用handler_msg(http.cpp)→ 解析请求行/URL → 判断静态/动态请求 → 静态请求返回HTML(echo_www)→ 动态请求调用custom_handle.cpp的业务函数 → 返回响应 → 关闭连接
三、快速复习查找表
| 知识点 | 核心内容 | 怎么用 | 坑在哪 |
|---|---|---|---|
| HTTP 报文整体结构 | 开始行 + 首部行 + 空行 + 实体主体 | 按格式构造请求 / 响应,空行不可少 | 缺少空行导致解析失败;首部行格式错误(无冒号 / 空格) |
| 请求报文 - 请求行 | 方法(GET/POST)+ URL + 版本(HTTP/1.1) | GET 用于查资源,POST 用于提交数据;URL 是资源路径 | GET 带大量数据;请求方法用错(如 GET 提交密码) |
| 请求报文 - 首部行 | Host(必填)、Connection(长连接)、Content-Length(POST 数据长度) | 按需求添加字段,比如要长连接加Connection: Keep-Alive |
缺少 Host 字段;Content-Length 与实体主体长度不匹配 |
| 响应报文 - 状态行 | 版本 + 状态码 + 短语(200 = 成功,404 = 资源不存在,500 = 服务器错) | 按处理结果返回对应状态码 | 状态码用错(如资源存在返回 404) |
| 响应报文 - 首部行 | Content-Type(数据类型)、Content-Length(数据长度)、Set-Cookie(Cookie) | 返回 HTML 标text/html,JSON 标application/json |
Content-Type 写错导致乱码;Content-Length 缺失导致客户端超时 |
| 服务器 - main.cpp | 初始化服务器、多线程接收连接 | 编译链接 pthread 库;运行时指定端口(./thttpd.out 8080) | 端口占用;线程创建失败;内存泄漏(new 后未 delete) |
| 服务器 - http.cpp | 解析请求行 / URL、判断静态 / 动态请求、响应 | 静态页面放 wwwroot 目录;URL 解析为本地路径 | 路径错误(wwwroot 位置不对);404 页面缺失;解析 URL 失败(特殊字符) |
| 服务器 - custom_handle.cpp | 业务逻辑(求和、登录) | POST 提交对应数据(data1=1&data2=2);登录账号密码相等即可成功 | 解析参数格式不匹配;登录跳转脚本语法错误;业务逻辑耦合 |
总结
HTTP 报文是 "沟通规则",按 "三段式" 格式构造就能让服务器和浏览器看懂;HTTP 服务器是 "规则执行者",核心是 "接收 - 解析 - 响应",通过多线程支持并发,分离核心解析和业务逻辑便于维护。跟着代码实例跑一遍,就能理解从客户端请求到服务器响应的完整链路,遇到问题对照复习表找坑,很快就能上手!