Linux网络编程—HTTP静态文件服务器

HTTP 静态文件服务器

一、整体设计目标

这个项目实现了一个 简易 HTTP 静态文件服务器

  • 用浏览器作为客户端,访问 http://IP:端口
  • 服务端接收 HTTP 请求 → 解析请求行 → 找到对应文件 → 构造 HTTP 响应 → 返回给浏览器
  • 每个客户端连接由独立线程处理(多线程并发)

模块划分

复制代码
┌─────────────────────────────────────────────────┐
│              HttpServer.cpp (入口)                │
│  main() → HttpServer::Run()                      │
└─────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────┐
│              HttpServer.hpp                      │
│  - HttpRequest 类:解析 HTTP 请求                 │
│    · Deserialize()  反序列化请求行                │
│    · Parse()         解析方法/URL/版本/文件路径   │
│  - HttpServer  类:HTTP 服务器                    │
│    · Run()           监听+accept+创建线程        │
│    · HanlderHttp()   处理单个 HTTP 请求          │
│    · ThreadFunc()    线程入口函数                │
│    · ReadHtmlContent() 读取文件内容               │
│    · SuffixToDesc()    MIME类型映射              │
└─────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────┐
│              Socket.hpp                          │
│  Sock 类:封装 TCP Socket 操作                    │
│   Socket() / Bind() / Listen() / Accept()       │
│   Connect() / Close() / Fd()                    │
└─────────────────────────────────────────────────┘
                    │
                    ▼
┌─────────────────────────────────────────────────┐
│              系统调用层                           │
│  socket / bind / listen / accept / connect      │
│  recv / send / setsockopt / inet_*              │
└─────────────────────────────────────────────────┘

二、Socket.hpp 详细解析 ------ Socket 封装

2.1 为什么要封装 Socket?

在 TCP 模块中,TcpServer 类直接调用 socket()bind()listen() 等系统调用。

但这样有个问题:Socket 操作是通用的 ,不只是服务端用,客户端也用;不只是 HTTP 用,其他 TCP 应用也用。

所以把 Socket 操作抽出来单独封装成一个类,哪里需要就包含哪里,代码复用性更高。

2.2 文件头部

cpp 复制代码
#pragma once
#include <iostream>
#include <unistd.h>      // close()
#include <sys/types.h>
#include <sys/socket.h>  // socket(), bind(), listen(), accept(), setsockopt(), connect()
#include <sys/stat.h>
#include <arpa/inet.h>   // htonl, htons, ntohs, inet_ntop, inet_pton
#include <netinet/in.h>  // sockaddr_in, INADDR_ANY
#include "Log.hpp"
#include <cerrno>        // errno
#include <cstring>       // strerror(), memset()

2.3 全局常量与枚举

cpp 复制代码
enum
{
    SOCKETERR = 2,   // socket() 失败
    BINDERR,         // bind()   失败 → 3
    LISTENERR,       // listen() 失败 → 4
};

const int BACKLOG = 10;  // 监听队列长度

2.4 Sock 类 - 成员变量

cpp 复制代码
private:
    int _socketfd;  // 唯一的成员变量:Socket 文件描述符

设计要点

  • 极简设计:类只封装一个 _socketfd
  • 所有操作都围绕这个 fd 展开
  • 这样的类叫做"资源包装类"(RAII 思想)

2.5 构造函数与析构函数

cpp 复制代码
Sock()    {}   // 空实现
~Sock()   {}   // 空实现

为什么构造/析构都是空的?

  • 构造函数不创建 Socket:用户可以先声明对象,后面再调 Socket() 方法创建
  • 析构函数不关闭 Socket:Close() 是显式方法,用户自己调用
  • 这样设计更灵活:对象的生命周期和 Socket 的生命周期可以解耦

思考 :如果析构函数里调 close(_socketfd),会怎么样?

  • 如果对象被拷贝,会导致同一个 fd 被 close 两次(错误)
  • 所以当前设计让用户手动调 Close(),虽然不那么 RAII,但更安全

2.6 Socket() ------ 创建 TCP Socket

cpp 复制代码
void Socket()
{
    _socketfd = socket(AF_INET, SOCK_STREAM, 0);
    if(_socketfd < 0)
    {
        logger(FATAL, "Socket failed, %s: %d", strerror(errno), errno);
        exit(SOCKETERR);
    }
    int opt = 1;
    setsockopt(_socketfd, SOL_SOCKET, SO_REUSEADDR, &opt, sizeof(opt));
}

接口设计

  • 无参数(参数太少,直接用硬编码值)
  • 无返回值(失败直接 exit)
  • 创建后直接设置 SO_REUSEADDR

两步操作

  1. socket() 创建 TCP Socket
  2. setsockopt() 设置地址重用

与 TCP 模块的对比

  • TCP 模块在 TcpServer::Init() 中完成了 socket + setsockopt
  • 这里把 socket + setsockopt 打包成一个 Socket() 方法
  • 因为这两个操作总是一起出现的,属于"创建 Socket"的完整流程

2.7 Bind(uint16_t port) ------ 绑定端口

cpp 复制代码
void Bind(uint16_t port)
{
    struct sockaddr_in local;
    memset(&local, 0, sizeof(local));
    local.sin_family = AF_INET;
    local.sin_addr.s_addr = htonl(INADDR_ANY);   // 注意:用 INADDR_ANY
    local.sin_port = htons(port);

    if(bind(_socketfd, (struct sockaddr *)&local, sizeof(local)) < 0)
    {
        logger(FATAL, "Bind failed, %s: %d", strerror(errno), errno);
        exit(BINDERR);
    }
}

接口设计

  • 参数:port(端口号)
  • IP 硬编码为 INADDR_ANY(所有网卡)
  • 因为作为服务端 Socket,绑定所有网卡是最常见的场景

htonl(INADDR_ANY) 是什么?

含义
INADDR_ANY 0.0.0.0 绑定本机所有网卡的 IP
  • INADDR_ANY 的值就是 0(主机序)
  • htonl(INADDR_ANY) 转成网络字节序的 0(还是 0)
  • 虽然值没变,但写法规范,清晰表达了"转网络序"的意图

与 TCP 模块的对比

  • TCP 模块:inet_aton(_ip.c_str(), &local.sin_addr) ------ IP 由用户传入
  • 这里:htonl(INADDR_ANY) ------ 直接绑定所有网卡
  • 封装取舍:服务端场景下,绑定所有网卡是 99% 的情况,所以硬编码更简洁

2.8 Listen() ------ 开始监听

cpp 复制代码
void Listen()
{
    if(listen(_socketfd, BACKLOG) < 0)
    {
        logger(FATAL, "Listen failed, %s: %d", strerror(errno), errno);
        exit(LISTENERR);
    }
}

接口设计

  • 无参数(backlog 用全局常量)
  • 调用 listen(_socketfd, BACKLOG)

与 TCP 模块对比

  • TCP 模块:listen(_listensockfd, DEFAULT_BACKLOG)
  • 这里:listen(_socketfd, BACKLOG)
  • 逻辑完全一致,只是常量名不同

2.9 Accept(clientip, clientport) ------ 接受连接

cpp 复制代码
int Accept(std::string *clientip, uint16_t *clientport)
{
    struct sockaddr_in peer;
    socklen_t peerlen = sizeof(peer);
    int newfd = accept(_socketfd, (struct sockaddr *)&peer, &peerlen);
    if(newfd < 0)
    {
        logger(WARNING, "Accept failed, %s: %d", strerror(errno), errno);
        return -1;
    }
    char ip[64];
    inet_ntop(AF_INET, &peer.sin_addr, ip, sizeof(ip));
    *clientip = ip;
    *clientport = ntohs(peer.sin_port);
    return newfd;
}

接口设计

参数 类型 方向 作用
clientip std::string* 输出 把客户端 IP 写回调用者
clientport uint16_t* 输出 把客户端端口写回调用者
返回值 int 输出 新的连接 Socket fd(失败返回 -1)

为什么用指针参数而不是返回结构体?

复制代码
方案 A:返回结构体(包含 newfd + ip + port)
  优点:一个返回值搞定
  缺点:需要定义结构体,调用方要解析

方案 B:返回 newfd + 两个输出参数(当前设计)
  优点:接口简单,常见的 C 风格"多个输出值"写法
  缺点:调用方必须传两个指针

与 TCP 模块的对比

TCP 模块(TcpServer)是这样写的:

cpp 复制代码
int sockfd = accept(_listensockfd, (struct sockaddr *)&client, &client_len);
uint16_t client_port = ntohs(client.sin_port);
char client_ip[16];
inet_ntop(AF_INET, &(client.sin_addr), client_ip, sizeof(client_ip));

HTTP 模块(Sock)把 accept + 地址解析 封装在一起:

cpp 复制代码
int sockfd = _listensock.Accept(&clientip, &clientport);

封装的好处

  • 调用方代码更简洁
  • 不需要自己处理 sockaddr_ininet_ntop 等细节
  • 把"获取连接"这个完整操作打包了

失败处理的差异

  • TCP 模块:accept 失败 → continue(继续循环)
  • Socket 封装:accept 失败 → 返回 -1(由上层决定怎么办)
  • 封装层不做业务决策,只返回状态,这是良好的分层设计

2.10 Connect(ip, port) ------ 客户端连接

cpp 复制代码
int Connect(const std::string &ip, uint16_t port)
{
    struct sockaddr_in peer;
    memset(&peer, 0, sizeof(peer));
    peer.sin_family = AF_INET;
    inet_pton(AF_INET, ip.c_str(), &peer.sin_addr);
    peer.sin_port = htons(port);

    int n = connect(_socketfd, (struct sockaddr *)&peer, sizeof(peer));
    if(n < 0)
    {
        std::cerr<<"Connect failed, "<<strerror(errno)<<std::endl;
        return false;
    }
    return true;
}

接口设计

参数 类型 作用
ip const std::string& 服务端 IP
port uint16_t 服务端端口
返回值 int(实际是 bool) 成功 true,失败 false

这个方法是给客户端用的

  • Socket() + Bind() + Listen() + Accept() 是服务端调用链
  • Socket() + Connect() 是客户端调用链
  • 一个 Sock 类同时支持服务端模式和客户端模式

为什么 HTTP 模块用不到这个方法?

  • HTTP 模块是纯服务端,没有客户端代码
  • 但 Socket 是通用封装,所以把客户端的 Connect() 也放进来了
  • 体现了封装的通用性

2.11 Close() ------ 关闭 Socket

cpp 复制代码
void Close()
{
    close(_socketfd);
}

直接调用系统 close(),封装非常薄。

2.12 Fd() ------ 获取文件描述符

cpp 复制代码
int Fd()
{
    return _socketfd;
}

为什么需要这个方法?

  • _socketfd 是 private 的,外部不能直接访问
  • 但某些场景下外部需要原始 fd(比如注册到 epoll)
  • 提供一个只读的 getter 方法

三、HTTP 服务器

3.1 文件头部 - 全局常量

cpp 复制代码
const std::string wwwroot = "./wwwroot";  // Web 根目录(所有文件从这里找)
const std::string sep = "\r\n";            // HTTP 行分隔符(CRLF)
const std::string homepage = "index.html"; // 首页文件名

static const int DEFAULT_PORT = 8080;      // 默认端口

三个核心路径常量

常量 作用
wwwroot "./wwwroot" 所有静态文件的根目录
homepage "index.html" 访问 / 时返回的默认页面
sep "\r\n" HTTP 协议的行分隔符(CR + LF)

为什么 HTTP 用 \r\n 而不是 \n

  • HTTP 协议规定每行以 \r\n 结尾
  • 这是历史遗留(早期网络终端的习惯)
  • 空行就是连续两个 \r\n,即 \r\n\r\n

3.2 前向声明与 ThreadData

cpp 复制代码
class HttpServer;  // 前向声明

struct ThreadData
{
public:
    ThreadData(int fd, HttpServer *http_server)
        : sockfd(fd)
        , http_server(http_server)
    {}
    ~ThreadData()
    {}
public:
    int sockfd;
    HttpServer *http_server;
};

与 TCP 模块 ThreadData 的对比

字段 TCP ThreadData HTTP ThreadData 原因
sockfd 都需要
client_ip / client_port HTTP 版本在线程函数里不需要 IP/Port
TcpServer* / HttpServer* 回调成员函数

HTTP 版本更简洁,因为业务处理(HandlerHttp)不需要知道客户端 IP。

3.3 HttpRequest 类 - HTTP 请求解析

这是 HTTP 模块独有的核心类,负责把浏览器发来的原始 HTTP 请求字符串解析成结构化数据。

类成员变量
cpp 复制代码
public:
    std::vector<std::string> req_header;  // 请求头所有行(逐行存储)
    std::string text;                     // 请求体(GET 请求为空)

    std::string method;                   // 请求方法:GET / POST ...
    std::string url;                      // 请求路径:/index.html
    std::string http_version;             // HTTP 版本:HTTP/1.1
    std::string file_path;                // 服务器上的文件路径:./wwwroot/index.html

    std::string suffix;                   // 文件后缀:html / css / js ...

各字段的关系

复制代码
原始请求字符串
    │
    ▼ Deserialize() 拆分
    ├── req_header[N行]    ← 每行一个字符串
    └── text               ← 请求体
           │
           ▼ Parse() 解析
           ├── method         ← "GET"
           ├── url            ← "/index.html"
           ├── http_version   ← "HTTP/1.1"
           ├── file_path      ← "./wwwroot/index.html"
           └── suffix         ← "html"
Deserialize() ------ 反序列化请求
cpp 复制代码
void Deserialize(std::string req)
{
    while(1)
    {
        std::size_t pos = req.find(sep);   // 找第一个 \r\n
        if(pos == std::string::npos) break;  // 找不到,结束

        std::string temp = req.substr(0, pos);  // 截取出一行
        if(temp.empty()) break;                  // 空行 = 请求头结束
        req_header.push_back(temp);              // 存入请求头数组
        req.erase(0, pos + sep.size());           // 从原字符串中删掉这一行
    }
    text = req;  // 剩下的就是请求体
}

算法流程

复制代码
输入: "GET /index.html HTTP/1.1\r\nHost: 127.0.0.1:8080\r\n\r\n"

第1轮循环:
  pos = 找到第一个 \r\n 的位置
  temp = "GET /index.html HTTP/1.1" → 存入 req_header[0]
  req 变成 "Host: 127.0.0.1:8080\r\n\r\n"

第2轮循环:
  pos = 找到第一个 \r\n 的位置
  temp = "Host: 127.0.0.1:8080" → 存入 req_header[1]
  req 变成 "\r\n"

第3轮循环:
  pos = 找到第一个 \r\n 的位置
  temp = "" → 空行 → break 循环

退出循环:
  text = req = "" → 请求体为空(GET 请求没有请求体)

为什么空行要 break?

  • HTTP 协议规定:请求头和请求体之间用一个空行分隔
  • 遇到空行说明请求头结束了
  • 剩下的内容都是请求体
Parse() ------ 解析请求行
cpp 复制代码
void Parse()
{
    // 第1步:解析第一行(请求行)
    std::stringstream ss(req_header[0]);
    ss >> method >> url >> http_version;
    // "GET /index.html HTTP/1.1" → method="GET", url="/index.html", http_version="HTTP/1.1"

    // 第2步:构造文件路径
    file_path = wwwroot;  // 默认根目录 ./wwwroot

    if(url == "/" || url == "/" + homepage)
    {
        file_path += "/" + homepage;  // 访问 / 或 /index.html → 返回首页
    }
    else
    {
        file_path += url;  // 其他路径 → 直接拼上
    }

    // 第3步:提取文件后缀
    suffix = url.substr(url.find_last_of(".") + 1);
}

第1步:用 stringstream 拆分请求行

复制代码
请求行格式:"方法 路径 版本\r\n"
示例:    "GET /index.html HTTP/1.1"

stringstream 自动按空格拆分:
  ss >> method         →  "GET"
  ss >> url            →  "/index.html"
  ss >> http_version   →  "HTTP/1.1"

第2步:构造文件路径

浏览器请求的 URL 服务器上的实际路径
/ ./wwwroot/index.html
/index.html ./wwwroot/index.html
/a/b/hello.html ./wwwroot/a/b/hello.html
/style.css ./wwwroot/style.css

url == "/" + homepage 的判断

  • 如果用户访问 /index.html,也指向同一个文件
  • 避免重复定义

第3步:提取后缀

复制代码
url = "/index.html"
        ↑ find_last_of(".") 找到 "." 的位置
suffix = url.substr(...) → "html"

find_last_of(".") 找最后一个点的位置,然后截取后面的部分作为后缀。

这样 a.b.c.html 也能正确提取出 html

Debug() ------ 打印调试信息
cpp 复制代码
void Debug()
{
    for(auto &item : req_header)
    {
        std::cout << item << "\n\n";
        std::cout << "--------------------" << std::endl;
    }
    std::cout <<"suffix: " << suffix << std::endl;
    std::cout <<"method: " << method << std::endl;
    std::cout <<"url: " << url << std::endl;
    std::cout <<"http_version: " << http_version << std::endl;
    std::cout <<"file_path: " << file_path << std::endl;
    std::cout << text << std::endl;
}

开发调试用的函数,把所有解析结果打印出来。

生产环境下可以注释掉或改成日志输出。

3.4 HttpServer 类 - 服务器主体

成员变量
cpp 复制代码
private:
    Sock _listensock;                       // 监听 Socket(封装类对象)
    uint16_t _port;                         // 监听端口
    std::unordered_map<std::string, std::string> _content_type_map;  // MIME 类型映射
成员 作用
_listensock 封装好的 Socket 对象,负责 accept
_port 监听端口号
_content_type_map 文件后缀 → Content-Type 的映射表

_content_type_map 的作用

  • HTTP 响应头需要告诉浏览器"我返回的是什么类型的文件"
  • 浏览器根据 Content-Type 决定怎么渲染(HTML 渲染网页,图片显示图片)
构造函数
cpp 复制代码
HttpServer(int port = DEFAULT_PORT)
    : _port(port)
{
    _content_type_map.insert({".html", "text/html"});
    _content_type_map.insert({".css", "text/css"});
    _content_type_map.insert({".js", "application/javascript"});
    _content_type_map.insert({".jpg", "image/jpeg"});
    _content_type_map.insert({".png", "image/png"});
}

接口设计

  • 默认端口 8080
  • 构造函数中初始化 MIME 类型映射表

MIME 类型速查

文件后缀 Content-Type 浏览器行为
.html text/html 渲染为网页
.css text/css 解析为样式表
.js application/javascript 执行 JavaScript
.jpg image/jpeg 显示为图片
.png image/png 显示为图片

为什么 MIME 类型很重要?

  • 如果 .html 文件返回 text/plain,浏览器会把源码当文本显示,不会渲染
  • 正确的 MIME 类型是浏览器正确处理文件的前提
Run() ------ 服务器主循环
cpp 复制代码
void Run()
{
    _listensock.Socket();     // 第1步:创建 Socket
    _listensock.Bind(_port);  // 第2步:绑定端口
    _listensock.Listen();     // 第3步:开始监听

    for(;;)                   // 第4步:无限循环 accept
    {
        std::string clientip;
        uint16_t clientport;
        int sockfd = _listensock.Accept(&clientip, &clientport);
        if(sockfd < 0) continue;

        logger(INFO, "Accept client %s:%d", clientip.c_str(), clientport);

        // 第5步:为每个客户端创建线程
        pthread_t tid;
        ThreadData *td = new ThreadData(sockfd, this);
        pthread_create(&tid, nullptr, ThreadFunc, td);
    }
}

与 TCP 模块 Run() 的对比

对比项 TCP 模块 HTTP 模块
Socket 操作 直接调系统调用 通过 Sock 封装类调用
并发模型 线程池(v4) 每连接一线程
守护进程 有(Daemon()
SIGPIPE 处理 有(注释中)

HTTP 模块为什么用"每连接一线程"而不是线程池?

  • HTTP/1.0 默认是短连接(请求完就断开)
  • 每个连接生命周期很短
  • 线程池适合长连接场景,短连接场景下每连接一线程也能工作
  • 当然生产环境还是线程池或 IO 多路复用更好

为什么 ThreadData 要用 new 在堆上?

cpp 复制代码
ThreadData *td = new ThreadData(sockfd, this);
  • 如果在栈上创建,pthread_create 返回后函数继续执行,栈对象可能被覆盖
  • 堆上的对象生命周期由我们自己控制,在线程函数里 delete td
  • 这是多线程编程的常见模式
ThreadFunc() ------ 线程入口函数
cpp 复制代码
static void *ThreadFunc(void *arg)
{
    pthread_detach(pthread_self());     // 第1步:线程分离
    ThreadData *td = static_cast<ThreadData *>(arg);
    td->http_server->HanlderHttp(td->sockfd);  // 第2步:调用成员函数处理请求
    delete td;                           // 第3步:释放 ThreadData
    return nullptr;
}

pthread_detach(pthread_self()) 的作用

  • 默认情况下,线程结束后需要 pthread_join 回收资源
  • pthread_detach 把线程设为"分离状态"
  • 分离的线程结束后,系统自动回收资源,不需要 join
  • HTTP 服务器不需要关心每个线程何时结束,所以用 detach 更方便

为什么 ThreadFunc 是 static 的?

  • pthread_create 的回调函数签名是 void* (*)(void*)
  • 非静态成员函数有隐藏的 this 参数,签名不匹配
  • 静态成员函数没有 this,可以作为回调
  • 通过 argthis 指针,在函数内部再调用成员函数

这是 C++ 多线程的经典模式

复制代码
静态函数(回调入口)
    ├── 把 void* arg 转成 类指针
    └── 调用 类指针->成员函数()
ReadHtmlContent() ------ 读取文件内容
cpp 复制代码
static std::string ReadHtmlContent(const std::string &htmlpath)
{
    std::ifstream in(htmlpath, std::ios::binary);  // 二进制方式打开
    if(!in.is_open())
    {
        logger(FATAL, "Open file %s failed", htmlpath.c_str());
        return "";
    }

    // 第1步:获取文件大小
    in.seekg(0, std::ios_base::end);   // 指针移到文件末尾
    auto len = in.tellg();             // 读指针位置 = 文件大小
    in.seekg(0, std::ios_base::beg);   // 指针移回文件开头

    // 第2步:分配空间
    std::string content(len, 0);
    content.resize(len);

    // 第3步:一次性读完
    in.read((char *)content.c_str(), content.size());
    in.close();

    return content;
}

为什么用 std::ios::binary 二进制模式?

  • 文本模式下,Windows 会把 \r\n 转成 \n(Linux 下没有区别)
  • 二进制模式下,数据原样读写,不会有转换
  • 图片、CSS、JS 等文件必须用二进制,否则会损坏

获取文件大小的方法

复制代码
文件内容: [H][e][l][l][o][ ][W][o][r][l][d]
          ↑                                   ↑
        beg                                 end

seekg(0, end)  →  指针到末尾
tellg()        →  返回当前位置 = 文件大小(字节数)
seekg(0, beg)  →  指针回到开头,准备读

为什么不用 getline 逐行读?

  • 注释代码里展示了逐行读的方式
  • 逐行读适合文本文件,但图片等二进制文件没有"行"的概念
  • 一次性读取更高效,也更通用(支持二进制文件)

in.read((char *)content.c_str(), content.size()) 的注意事项

  • c_str() 返回 const char*,强转成 char* 才能写入
  • C++98 标准下 string 的内存不一定连续,但实际上所有实现都是连续的
  • C++11 之后标准明确规定 string 内存连续
SuffixToDesc() ------ 后缀转 MIME 类型
cpp 复制代码
std::string SuffixToDesc(const std::string &suffix)
{
    auto iter = _content_type_map.find(suffix);
    if(iter != _content_type_map.end()) 
        return _content_type_map[suffix];
    return _content_type_map[".html"];  // 找不到默认返回 text/html
}

查表逻辑

  1. _content_type_map 中查找后缀
  2. 找到 → 返回对应的 MIME 类型
  3. 找不到 → 默认返回 text/html

返回值为什么用 _content_type_map[".html"] 而不是硬编码?

  • 避免魔法字符串
  • 如果以后要改默认类型,只改一处(构造函数里的 insert)就行
HanlderHttp() ------ 处理单个 HTTP 请求

这是 HTTP 服务器最核心的函数,负责完整的请求-响应流程。

cpp 复制代码
void HanlderHttp(int sockfd)
{
    // ========== 第1步:接收请求 ==========
    char buffer[10240];
    ssize_t n = recv(sockfd, buffer, sizeof(buffer)-1, 0);
    if(n < 0)
    {
        logger(FATAL, "Read failed, %s: %d", strerror(errno), errno);
        exit(READERR);
    }
    if(n > 0)
    {
        buffer[n] = '\0';
        logger(INFO, "Client request: %s", buffer);

        // ========== 第2步:解析请求 ==========
        HttpRequest req;
        req.Deserialize(buffer);  // 拆分成请求行 + 请求头 + 请求体
        req.Parse();              // 解析方法/URL/文件路径/后缀
        req.Debug();              // 打印调试信息

        // ========== 第3步:读取文件 ==========
        std::string text = ReadHtmlContent(req.file_path);
        bool is_ok = true;
        if(text.empty())  // 文件不存在
        {
            is_ok = false;
            std::string err_html = wwwroot + "/404.html";
            text = ReadHtmlContent(err_html);  // 读 404 页面
        }

        // ========== 第4步:构造响应 ==========
        // 响应行
        std::string response_line = is_ok ? "HTTP/1.0 200 OK" : "HTTP/1.0 404 Not Found";
        
        // 响应头
        std::string response_headers = "Content-Length: ";
        response_headers += std::to_string(text.size());
        response_headers += "\r\n";
        response_headers += "Content-Type: ";
        response_headers += SuffixToDesc(req.suffix);
        response_headers += "\r\n";

        // 空行
        std::string blank_line = "\r\n";

        // 响应体
        // text 就是响应体

        // 拼起来
        std::string response = response_line + response_headers + blank_line + text;

        // ========== 第5步:发送响应 ==========
        send(sockfd, response.c_str(), response.size(), 0);
    }
    close(sockfd);  // 关闭连接(HTTP/1.0 短连接)
}

让我们逐段详细解析。

第1步:recv() 接收请求
cpp 复制代码
char buffer[10240];
ssize_t n = recv(sockfd, buffer, sizeof(buffer)-1, 0);

recv()read() 的区别

函数 头文件 flags 参数 适用范围
read() <unistd.h> 无(默认 0) 所有文件描述符
recv() <sys/socket.h> 有(可设 MSG_PEEK 等) 仅 Socket
  • recv(fd, buf, len, 0) 等价于 read(fd, buf, len)
  • HTTP 模块用 recv,TCP 模块用 read,功能完全一样

缓冲区大小 10240 字节

  • HTTP 请求(尤其是 GET 请求)通常不大,10KB 足够
  • 如果请求太大(比如 POST 上传文件),会被截断
  • 真正的 HTTP 服务器需要循环读取,解析 Content-Length 来确定读多少
第2步:解析请求
cpp 复制代码
HttpRequest req;
req.Deserialize(buffer);
req.Parse();
req.Debug();

调用上面讲过的 HttpRequest 类,把原始字符串变成结构化数据。

第3步:读取文件 & 404 处理
cpp 复制代码
std::string text = ReadHtmlContent(req.file_path);
bool is_ok = true;
if(text.empty())
{
    is_ok = false;
    std::string err_html = wwwroot + "/404.html";
    text = ReadHtmlContent(err_html);
}

逻辑

  1. 根据解析出的 file_path 读文件
  2. 文件存在 → text 非空 → is_ok = true
  3. 文件不存在 → text 为空 → is_ok = false → 读 404 页面

为什么 ReadHtmlContent 返回空字符串表示失败?

  • 函数里 if(!in.is_open()) return "";
  • 用空字符串作为"失败"的标记
  • 缺点:如果文件本身就是空的(0 字节),也会被当成失败
  • 但一般不会有空文件作为网页的情况
第4步:构造 HTTP 响应

HTTP 响应的格式:

复制代码
响应行\r\n
响应头1\r\n
响应头2\r\n
...
空行\r\n
响应体

对应到代码:

复制代码
response_line    →  "HTTP/1.0 200 OK\r\n"  (或 404)
response_headers →  "Content-Length: 1234\r\n"
                    "Content-Type: text/html\r\n"
blank_line       →  "\r\n"
text             →  文件内容(响应体)

响应行格式HTTP版本 状态码 状态描述

  • HTTP/1.0 200 OK → 成功
  • HTTP/1.0 404 Not Found → 资源不存在

Content-Length 的作用

  • 告诉浏览器响应体有多少字节
  • 浏览器根据这个长度确定什么时候接收完毕
  • 没有 Content-Length 的话,浏览器不知道什么时候读完(尤其是短连接)

Content-Type 的作用

  • 告诉浏览器文件是什么类型
  • 浏览器根据类型决定如何渲染(HTML 解析、图片显示、下载等)

空行的作用

  • 分隔响应头和响应体
  • 浏览器读到空行就知道"后面的都是响应体了"
第5步:send() 发送响应 & 关闭连接
cpp 复制代码
send(sockfd, response.c_str(), response.size(), 0);
...
close(sockfd);
  • send() 把完整的 HTTP 响应一次性发出
  • close(sockfd) 关闭连接 → HTTP/1.0 默认是短连接
  • 浏览器收到响应后,也会关闭自己这一端

短连接 vs 长连接

模式 HTTP 版本 行为
短连接 HTTP/1.0 默认 每个请求建立一个连接,请求完就关闭
长连接 HTTP/1.1 默认 一个连接可以发多个请求,节省握手开销

当前实现是 HTTP/1.0 风格的短连接,简单但效率低。


四、HttpServer.cpp ------ 入口文件

cpp 复制代码
#include "HttpServer.hpp"
#include <iostream>
#include <memory>

int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        exit(1);
    }
    uint16_t port = std::stoi(argv[1]);
    std::unique_ptr<HttpServer> httpserver(new HttpServer(port));
    httpserver->Run();

    return 0;
}

与 UDP/TCP 入口的对比

对比项 UDP (test.cpp) TCP (test.cpp) HTTP (HttpServer.cpp)
类名 UdpServer TcpServer HttpServer
初始化方式 Init() + Run() Init() + Run() 直接 Run()(Run 内部调 Socket/Bind/Listen)
Usage 提示 无(直接 exit)
守护进程

五、完整 HTTP 请求-响应流程图

复制代码
浏览器                                  服务器
  │                                       │
  │─── GET /index.html HTTP/1.1 ────────►│  1. recv() 接收请求
  │     Host: 127.0.0.1:8080             │
  │     ...                               │
  │     \r\n                             │
  │                                       │
  │                                       │  2. Deserialize() 拆分行
  │                                       │     req_header[0] = "GET /index.html HTTP/1.1"
  │                                       │
  │                                       │  3. Parse() 解析
  │                                       │     method = "GET"
  │                                       │     url = "/index.html"
  │                                       │     file_path = "./wwwroot/index.html"
  │                                       │     suffix = "html"
  │                                       │
  │                                       │  4. ReadHtmlContent() 读文件
  │                                       │     text = "<html>..."
  │                                       │
  │                                       │  5. 构造响应
  │                                       │     "HTTP/1.0 200 OK\r\n"
  │                                       │     "Content-Length: 123\r\n"
  │                                       │     "Content-Type: text/html\r\n"
  │                                       │     "\r\n"
  │                                       │     "<html>..."
  │                                       │
  │◄─────── HTTP 响应 ───────────────────│  6. send() 发送响应
  │                                       │
  │◄────── close() ──────────────────────│  7. close() 关闭连接(短连接)
  │
  浏览器渲染 HTML 页面

六、关键 API 对比表

6.1 recv/send vs read/write

操作 通用文件函数 Socket 专用函数 等价性
读数据 read(fd, buf, len) recv(fd, buf, len, 0) flags=0 时等价
写数据 write(fd, buf, len) send(fd, buf, len, 0) flags=0 时等价

HTTP 模块用 recv/send,TCP 模块用 read/write,功能一致。

6.2 Socket 封装层 API

方法 对应系统调用 服务端/客户端
Socket() socket() + setsockopt() 都用
Bind(port) bind() 服务端
Listen() listen() 服务端
Accept(ip, port) accept() + inet_ntop() 服务端
Connect(ip, port) connect() 客户端
Close() close() 都用
Fd() (返回成员变量) 都用

七、编译与运行

7.1 编译

bash 复制代码
cd HTTP/
make          # 编译 HttpServer
make clean    # 清理

7.2 运行

bash 复制代码
# 启动服务器
./HttpServer 8080

# 浏览器访问
# 打开浏览器,访问 http://127.0.0.1:8080
# 看到 hello world 页面

7.3 测试 URL

URL 预期结果
http://127.0.0.1:8080/ 首页 index.html
http://127.0.0.1:8080/index.html 首页(同上)
http://127.0.0.1:8080/a/b/hello.html 子目录页面
http://127.0.0.1:8080/xxx.html 404 页面

7.2 HTTP/1.0 vs HTTP/1.1

当前实现是 HTTP/1.0 风格,与 HTTP/1.1 的主要区别:

特性 HTTP/1.0 HTTP/1.1
连接模式 默认短连接 默认长连接(Keep-Alive)
Host 头 可选 必须
响应状态行 HTTP/1.0 200 OK HTTP/1.1 200 OK
分块传输 不支持 支持(Transfer-Encoding: chunked)
相关推荐
逝水无殇2 小时前
C# 正则表达式详解
开发语言·后端·正则表达式·c#
mounter6252 小时前
Linux 7.2 内核技术前瞻:核心 subsystems 合并与重构深度解析
linux·kernel
珠海西格电力2 小时前
西格电力零碳园区管理系统:核心功能全解析,赋能园区低碳智能化落地
大数据·运维·网络·人工智能·信息可视化·能源
朱永博2 小时前
使用Onnruntime实现Padim/Patchcore推理
开发语言·c#
前端H2 小时前
Biome & Rolldown:Rust 工具链接管前端基建
开发语言·前端·rust
ZPC82102 小时前
model bingxing
网络·人工智能·网络协议·机器人
天天进步20152 小时前
UI-TARS 源码解析 #4:UI-TARS 与传统 RPA 的区别:为什么它不是简单的坐标点击脚本?
开发语言
运维行者_3 小时前
广域网性能监控:分布式IT架构下的链路质量保障
开发语言·网络·分布式·后端·架构·数据库架构
梅雅达编程笔记3 小时前
零基础学 Python 第7章 | 字典 dict:键值对存储
开发语言·python·beautifulsoup·numpy·pandas
A_humble_scholar3 小时前
Linux(十七)深入多线程编程:同步原语与实战指南
linux·运维·c++