【Linux】三十八.C++ 手写 HTTP 服务器:裸 socket + fork 多进程,从 HTTP 报文解析到浏览器打开网页

C++ 网络编程实战 :C++、HTTP 服务器、socket 编程、fork 多进程、HTTP 协议解析、Linux 网络编程


TCP 三次握手背得滚瓜烂熟,socket 那几个函数也会调,可一旦要自己攒一个 HTTP 服务器,很多人就卡住了:报文格式记不牢、不知道该切几层、写完也不知道对不对,只能靠"看起来能跑"自我安慰。

更麻烦的是,等你真去读别人的 HTTP 服务器代码,往往是一坨------解析、网络、业务全糊在一个文件里,看不出哪层该干什么。

这篇文章把这件事从头拆到底。全部代码就是十来个文件,每一段我都讲透。读完你会拿到三样东西:

  1. 一个真正能跑的服务器------浏览器打开有页面,curl 拿到正确状态码,首页、404、301、动态路由全都实测过;

  2. 一张清晰的分层地图 ------TcpServer 管进程、Http 管协议、业务函数管逻辑,哪个文件干什么活,一眼看清;

  3. 一份 12 条踩坑清单------全是真跑出来的,包括那些编译零警告、进程不崩、页面"看起来"还能开,因而能活好几年的隐蔽 bug。

文末有完整的运行现象和我踩过的坑,输出都是真跑出来的,不是编的。


一、先看全景:这个项目的分层

写网络程序最怕一上来就扎进代码里出不来。我先把整个项目的层次画出来,你在哪个文件、干的是什么活,心里得有张地图:

bash 复制代码
+----------------------------------------------------------+
|  Main.cc          入口:注册路由 /login -> Login 函数      |
+----------------------------------------------------------+
|  Http.hpp         应用层:解析HTTP请求 / 构造HTTP响应 /     |
|                   路由分发 / 读静态文件                     |
+----------------------------------------------------------+
|  TcpServer.hpp    网络服务层:accept + fork 并发 + 回调     |
+----------------------------------------------------------+
|  Socket.hpp       socket封装:模板方法模式                  |
|  InetAddr.hpp     地址封装:主机序<->网络序                 |
+----------------------------------------------------------+
|  Util.hpp         工具:读文件 / 按分隔符切行 / 文件大小    |
|  Log.hpp          日志:策略模式(控制台/文件)             |
|  Mutex.hpp        锁:RAII封装                              |
|  Common.hpp       公共枚举 / 宏                             |
+----------------------------------------------------------+

这个分层的核心思想就一句话:网络和业务解耦

TcpServer 根本不知道 HTTP 是什么,它只管"来一个连接,我就 fork 一个进程去跑你给我的回调"。Http 也不关心 socket 怎么创建的,它只管解析报文、填响应。哪天你想把 HTTP 换成别的协议,TcpServer.hpp 一行不用动------这就是我折腾这么多层换来的自由。


二、HTTP 报文长什么样

代码怎么写,取决于报文长什么样。HTTP 请求和响应的结构是理解整个 Http.hpp 的钥匙。

HTTP REQUEST 报文格式图(请求行 / 请求报头 / 空行 / 请求正文)

HTTP RESPONSE 报文格式图(状态行 / 响应报头 / 空行 / 响应正文)

两张图对照着看,结构完全对称,就四段:

bash 复制代码
请求:
    GET /login?username=zhangsan&password=123456 HTTP/1.1\r\n   <- 请求行
    Host: 127.0.0.1:8080\r\n                                    <- 报头,一堆 Key: Value
    User-Agent: curl/7.81.0\r\n
    Accept: */*\r\n
    \r\n                                                        <- 空行,报头结束的标志
    (body,GET 一般没有)                                        <- 正文

响应:
    HTTP/1.0 200 OK\r\n                                         <- 状态行
    Content-Type: text/html\r\n                                 <- 报头
    Content-Length: 2872\r\n
    \r\n                                                        <- 空行
    <html>...</html>                                            <- 正文

三个必须刻进脑子里的点:

  1. 每一行以 \r\n 结尾 ,不是 \n。这是协议规定的,少个 \r 浏览器都不认。

  2. 空行(也就是单独一个 \r\n)是报头结束的标志。解析的时候就是靠它知道"后面是正文了"。

  3. 正文多长由 Content-Length 说了算 ,不是靠 \0 也不是靠连接断开------因为 TCP 是字节流,没有"报文边界"这个概念,边界全是应用层自己约定的(这就是粘包问题的本质,后面细说)。

我们服务器的活,说白了就是:收一大坨字节流,按上面的格式切开,然后按格式拼一坨回去


三、地基四件套:Common / Mutex / Log / InetAddr

这四个文件不含任何 HTTP 逻辑,纯粹是地基。我按依赖顺序讲。

3.1 Common.hpp------全项目的"公共词典"

cpp 复制代码
#pragma once

#include <iostream>
#include <functional>
#include <unistd.h>
#include <string>
#include <cstring>
#include <sys/socket.h>
#include <sys/types.h>
#include <arpa/inet.h>
#include <netinet/in.h>

enum ExitCode
{
    OK = 0,
    USAGE_ERR,
    SOCKET_ERR,
    BIND_ERR,
    LISTEN_ERR,
    CONNECT_ERR,
    FORK_ERR,
    OPEN_ERR
};

这个 enum ExitCode 看着不起眼,其实是个好习惯:进程退出时 exit(SOCKET_ERR),shell 里 echo $? 就能拿到 2,写脚本监控服务器挂没挂的时候特别好用。比满代码的 exit(1) 强多了------1 是什么错?谁知道。


cpp 复制代码
class NoCopy
{
public:
    NoCopy(){}
    ~NoCopy(){}
    NoCopy(const NoCopy &) = delete;
    const NoCopy &operator = (const NoCopy&) = delete;
};

NoCopy 用 C++11 的 = delete 把拷贝构造和赋值直接删掉。想禁止拷贝的类继承它就行。这版代码里 TcpServer 还没继承它,属于留了个工具在手边。


cpp 复制代码
#define CONV(addr) ((struct sockaddr*)&addr)

这个宏是网络编程的老朋友了。acceptbind 的参数类型是 struct sockaddr*(通用地址结构),但我们实际用的是 struct sockaddr_in(IPv4 专用)。内核接口当年没做泛型,就靠强转凑合------sockaddr_in 前 16 字节和 sockaddr 布局兼容,所以强转是安全的,这是所有 Unix 网络代码的惯用写法。封装成宏,至少代码里不用到处写恶心的强转。


3.2 Mutex.hpp------两把锁的 RAII 封装

cpp 复制代码
class Mutex
{
public:
    Mutex()
    {
        pthread_mutex_init(&_mutex, nullptr);
    }
    void Lock()
    {
        int n = pthread_mutex_lock(&_mutex);
        (void)n;
    }
    void Unlock()
    {
        int n = pthread_mutex_unlock(&_mutex);
        (void)n;
    }
    ~Mutex()
    {
        pthread_mutex_destroy(&_mutex);
    }
private:
    pthread_mutex_t _mutex;
};

构造时 init,析构时 destroy------这就是 RAII 的味道,锁的生命周期和对象绑定,不会出现"初始化了忘了销毁"的泄漏。

真正有讲究的是下面这个:

cpp 复制代码
class LockGuard
{
public:
    LockGuard(Mutex &mutex):_mutex(mutex)
    {
        _mutex.Lock();
    }
    ~LockGuard()
    {
        _mutex.Unlock();
    }
private:
    Mutex &_mutex;
};

用法是在栈上创建一个 LockGuard 局部对象,构造时加锁。

关键在析构 :函数不管从哪条路径 return、哪怕抛异常,栈对象的析构函数都会执行,锁一定被释放。如果你手写 lock() ... 中间三行代码 ... unlock(),中间任何一个 early return 都能让锁永远回不去------这种死锁我在真实项目里见过不止一次。所以规矩就是:能 RAII 的绝不手写 lock/unlock

这版代码是 fork 多进程模型,进程间不共享锁,Mutex 实际用在了 Log.hpp 的多场景预留上(日志模块是照着多线程场景写的)。


3.3 InetAddr.hpp------地址的"翻译官"

网络地址有两副面孔:内存里是 struct sockaddr_in(4 字节 IP + 2 字节端口,全是大端网络序),人眼看的是 "127.0.0.1" + 8080InetAddr 就是这两副面孔之间的翻译官,把 sockaddr_inipport 三个装在一个对象里,谁用找谁要。

构造函数有三胞胎:

cpp 复制代码
InetAddr(struct sockaddr_in &addr)          // 从对端来的:peer地址
InetAddr(const std::string &ip, uint16_t port)  // 客户端连服务器用:指定IP
InetAddr(uint16_t port)                     // 服务器绑定用:INADDR_ANY

服务器绑定时用的是第三个:

cpp 复制代码
InetAddr(uint16_t port) : _ip(), _port(port) // 顺序和成员声明顺序保持一致
{
    memset(&_addr, 0, sizeof(_addr));
    _addr.sin_family = AF_INET;
    _addr.sin_addr.s_addr = INADDR_ANY;
    _addr.sin_port = htons(_port);
}

INADDR_ANY 就是 0.0.0.0,意思是"本机所有网卡上的这个端口我都听"。你的机器可能有 127.0.0.1(回环)、192.168.x.x(局域网)、公网 IP 好几个地址,绑 ANY 一劳永逸。htons 把主机序转网络序------这个必须转,别问,问就是历史约定:网络字节序统一大端,x86 是小端。

一个 -Wreorder 的坑 :我上面写了句注释"顺序和成员声明顺序保持一致"。C++ 的规矩是:成员初始化列表不管你写成什么顺序,实际初始化顺序永远按成员声明顺序执行 。如果初始化列表顺序和声明顺序不一致,编译器不报错,但 -Wall 会甩你一脸 -Wreorder。我 _port 写在 _ip 前面,声明却在后面,正好踩中。这种 bug 不炸逻辑,但说明你对执行顺序的认知和编译器不一致,得改。

从对端地址转回人话看的是 SetAddr

cpp 复制代码
void SetAddr(struct sockaddr_in &addr)
{
    _addr = addr;
    _port = ntohs(_addr.sin_port); // 从网络中拿到的!网络序列
    char ipbuffer[64];
    inet_ntop(AF_INET, &_addr.sin_addr, ipbuffer, sizeof(ipbuffer));
    _ip = ipbuffer;
}

这里有个我改过的 bug:inet_ntop 的最后一个参数是缓冲区大小 ,原来写的是 sizeof(_addr)------那是地址结构的大小 16,不是 ipbuffer 的大小 64。IPv4 点分十进制最长就是 "255.255.255.255" 恰好 16 字节(15 字符 + \0),所以侥幸没出事。但"恰好够"和"写对了"是两回事,哪天有人把 buffer 改小一点就是栈溢出。

老代码里常见的 inet_ntoa 我不建议用------它返回静态缓冲区指针,多线程下两个线程同时调会互相覆盖,inet_ntop 把 buffer 交给调用者,天然线程安全。


3.4 Log.hpp------策略模式日志

日志模块值得单独一节,因为它是这个项目里"设计模式浓度"最高的地方,而且那个 C++ 技巧相当漂亮。

第一层:刷新策略(策略模式)

cpp 复制代码
class LogStrategy
{
public:
    ~LogStrategy() = default;
    virtual void SyncLog(const std::string &message) = 0;
};

纯虚基类,约定"日志怎么刷出去"。两个实现:

  • ConsoleLogStrategy:往 std::cout 刷,内部带锁;

  • FileLogStrategy:往文件刷,构造时用 C++17 的 std::filesystem::create_directories 自动建目录(目录存在就跳过),写的时候以 std::ios::app 追加模式打开------服务器重启日志不丢。

切换策略就一行:

cpp 复制代码
void EnableFileLogStrategy()   { _fflush_strategy = std::make_unique<FileLogStrategy>(); }
void EnableConsoleLogStrategy(){ _fflush_strategy = std::make_unique<ConsoleLogStrategy>(); }

unique_ptr 换个指向就完成了策略切换,多态+智能指针,不需要 new/delete。想改成"日志进 MySQL"?加个子类就行,其他代码全不动。


第二层:拼一条日志的技巧------临时对象 + 析构刷新

这是整个 Log.hpp 最精妙的地方,我第一次看的时候拍案叫绝:

cpp 复制代码
class LogMessage
{
public:
    LogMessage(LogLevel &level, std::string &src_name, int line_number, Logger &logger)
        : _curr_time(GetTimeStamp()), _level(level), _pid(getpid()),
          _src_name(src_name), _line_number(line_number), _logger(logger)
    {
        // 日志的左边部分,合并起来
        std::stringstream ss;
        ss << "[" << _curr_time << "] "
           << "[" << Level2Str(_level) << "] "
           << "[" << _pid << "] "
           << "[" << _src_name << "] "
           << "[" << _line_number << "] "
           << "- ";
        _loginfo = ss.str();
    }
    template <typename T>
    LogMessage &operator<<(const T &info)
    {
        std::stringstream ss;
        ss << info;
        _loginfo += ss.str();
        return *this;
    }
    ~LogMessage()
    {
        if (_logger._fflush_strategy)
        {
            _logger._fflush_strategy->SyncLog(_loginfo);
        }
    }

看构造函数左边拼什么:时间、等级、PID 、文件名、行号。__FILE____LINE__ 是编译器内置宏,所以每条日志天然带"案发现场":

bash 复制代码
[2026-09-19 15:50:37] [DEBUG] [2778380] [Http.hpp] [64] - _method: GET

出了问题,日志一翻就知道哪个文件哪一行、是哪个进程打的------fork 模型下 PID 尤其重要,一眼看出是父进程还是哪个子进程在说话。

再看使用侧:

bash 复制代码
LOG(LogLevel::DEBUG) << "_method: " << _method;

宏展开是 logger(LogLevel::DEBUG, __FILE__, __LINE__),调用的是:

cpp 复制代码
LogMessage operator()(LogLevel level, std::string name, int line)
{
    return LogMessage(level, name, line, *this);  // 故意返回临时对象
}

注意:返回的是临时对象,不是指针不是引用。 后面一连串 << 都在这个临时对象上追加内容。

关键来了------这整条语句结束时,临时对象的生命周期到头,析构函数自动触发,SyncLog 把攒好的完整日志一次性刷出去

这就是"临时对象析构即刷新"技巧:刷新动作不用用户操心,C++ 的对象生命周期规则替你兜底。这也解释了一个坑------你绝对不能把 LOG(...) 写成多行语句

cpp 复制代码
LOG(LogLevel::DEBUG) << "a";
<< "b";   // 编译错误!临时对象上一行就析构了

还有个实现细节:日志里换行我统一用 \r\ngsep),不用 std::endl------endl 除了换行还会 flush 流,在模板 operator<< 里还有类型推导的坑,我之前被它坑出过编译错误,从此我的日志模块一律自己管换行。

inline 的教训Level2StrGetTimeStamp 这两个自由函数和 Logger logger 这个全局对象,都定义在头文件里。头文件会被多个 .cpp 包含,普通函数和全局变量在每个翻译单元里都会生成一份定义,链接时直接报"multiple definition"。我之前的项目就在这上面崩过。C++17 的解法很干净:全加 inline(变量也有 inline 了),多个翻译单元共享一份。


四、Socket.hpp------把 socket 系统调用包成对象

Linux 的 socket API 是 C 风格的:socket() 返回个 fd,bind/listen/accept 都拿这个 fd 操作。直接在业务代码里裸调这些函数,代码会散得到处都是。我的做法是封装成类:

cpp 复制代码
class Socket
{
public:
    virtual ~Socket() {}
    virtual void SocketOrDie() = 0;
    virtual void BindOrDie(uint16_t port) = 0;
    virtual void ListenOrDie(int backlog) = 0;
    virtual std::shared_ptr<Socket> Accept(InetAddr *client) = 0;
    virtual void Close() = 0;
    virtual int Recv(std::string *out) = 0;
    virtual int Send(const std::string &message) = 0;
    virtual int Connect(const std::string &server_ip, uint16_t port) = 0;

public:
    void BuildTcpSocketMethod(uint16_t port, int backlog = gbacklog)
    {
        SocketOrDie();
        BindOrDie(port);
        ListenOrDie(backlog);
    }
    void BuildTcpClientSocketMethod()
    {
        SocketOrDie();
    }
};

这是模板方法模式 :基类把"创建 TCP 服务器 socket 的固定套路"(创建→绑定→监听)在 BuildTcpSocketMethod 里定死,具体每一步怎么干交给子类。现在只有 TcpSocket 一个子类,但哪天要加 UdpSocket,套路是现成的。

每个方法名带 OrDie 是我的命名强迫症:这些操作失败就没法玩了(bind 失败服务器起来干嘛),直接打 FATAL 日志然后 exit错误分级很重要:能救的救(accept 偶尔失败 return nullptr 继续),救不了的痛快死(bind 失败活着也是浪费电)。

TcpSocket 里最值得细讲的是 Recv

cpp 复制代码
int Recv(std::string *out) override
{
    // 流式读取,不关心读到的是什么
    char buffer[1024];
    ssize_t n = ::recv(_sockfd, buffer, sizeof(buffer) - 1, 0);
    if (n > 0)
    {
        buffer[n] = 0;
        *out += buffer; // 故意
    }
    return n;
}

四个细节,全是 TCP 的本质:

  1. buffer 只开 1024:recv 是"来多少舀多少",一次调用最多舀 1023 字节。大报文要上层循环调。

  2. sizeof(buffer) - 1 :留 1 字节放 \0

  3. buffer[n] = 0 :recv 不帮你加 \0,它只返回读了多少字节。不补 \0 后面按 C 字符串处理就读越界了。

  4. *out += buffer 是 += 不是 = :这是"追加"语义,上层多次调用 Recv 把字节流攒起来。注意 += 一个 char*在第一个 \0 处截断 ------HTTP 报文里不会出现 \0 所以没事,但传二进制协议时这就是 bug,得换成 out->append(buffer, n)

Accept 返回 std::shared_ptr<Socket> 而不是裸 fd:

cpp 复制代码
std::shared_ptr<Socket> Accept(InetAddr *client) override
{
    struct sockaddr_in peer;
    socklen_t len = sizeof(peer);
    int fd = ::accept(_sockfd, CONV(peer), &len);
    if (fd < 0)
    {
        LOG(LogLevel::WARNING) << "accept warning ...";
        return nullptr;
    }
    client->SetAddr(peer);
    return std::make_shared<TcpSocket>(fd);
}

这是个很舒服的封装设计:accept 出来的通信 fd 直接包成一个新的 TcpSocket 对象交出去,调用方拿到的就是个能收能发的完整对象。

但有个必须说破的细节 :这版 TcpSocket 的析构函数是空的,并不会自动 close fd!

真正关 fd 靠两件事:上层显式调 Close()(TcpServer 里孙子进程干完活那次),以及进程退出时内核兜底回收。

那为什么不顺手在析构里加个 close(_sockfd) 做成完全 RAII?可以,但必须配套把 _sockfd 置回 -1------不然显式 Close() 关一次、析构再关一次就是 double close :fd 号会被系统复用分配给别人,第二个 close 会误伤别人刚打开的文件,这种 bug 能把人逼疯。要么纯显式关(现状),要么彻底 RAII(析构 close + 置 -1),最怕半吊子。TcpSocket(int fd) 这个构造函数就是为 Accept 准备的。


五、TcpServer.hpp------fork 并发模型(本项目的进程戏份全在这)

这个文件是纯网络层,对 HTTP 一无所知。先看它怎么定义"业务":

cpp 复制代码
using ioservice_t = std::function<void(std::shared_ptr<Socket> &sock, InetAddr &client)>;

一个 std::function,参数是通信 socket 和对端地址。TcpServer 收到连接后调它,调的谁?Http 传进来的处理函数。这就是回调解耦TcpServer 只管接客,接完客把"话筒"递出去。

主循环四步走:

cpp 复制代码
void Start(ioservice_t callback)
{
    _isrunning = true;
    while (_isrunning)
    {
        InetAddr client;
        auto sock = _listensockptr->Accept(&client); // 1. 拿到通信sockfd和对端地址
        if (sock == nullptr)
        {
            continue;   // accept偶发失败,不断主,继续接下一个
        }
        LOG(LogLevel::DEBUG) << "accept success ..." << client.StringAddr();
        ...fork 分发...
    }
}

accept 拿到的是新的 fd(通信 socket),和 listen socket 是两码事:listensock 负责"拉客",sock 负责"跟这位客人单独聊"。分不清这俩的,可以想象成餐厅门口领位的和进包间服务的不是同一个人。

然后是重头戏,fork 三分支。我把每个分支"谁活着、谁关什么"标清楚:

cpp 复制代码
pid_t id = fork();
if (id < 0)
{
    LOG(LogLevel::FATAL) << "fork error ...";
    exit(FORK_ERR);
}
else if (id == 0)
{
    // ---- 子进程 ----
    _listensockptr->Close();     // (1) 子进程不拉客,关掉listensock
    if (fork() > 0)              // (2) 二次fork
        exit(OK);                //     子进程自己退出,把活留给孙子
    callback(sock, client);      // (3) 孙子进程干活
    sock->Close();
    exit(OK);
}
else
{
    // ---- 父进程 ----
    sock->Close();               // (4) 父进程不聊天,关掉通信sock
    pid_t rid = ::waitpid(id, nullptr, 0);  // (5) 等儿子退出
    (void)rid;
}

关键点,一个都不能含糊:

(1) 子进程为什么关 listensock? fork 出来的子进程继承父进程所有 fd,包括 listensock。子进程只需要伺候这一个连接,listensock 在它手里纯属多余------不关的话,万一子进程也去 accept,一个连接可能被它抢走,而且每个子进程都攥着 listensock 的引用,fd 计数乱糟糟。

(2) 为什么要 fork 两次? 这是这个模型最精妙的设计。

如果只 fork 一次:父进程要么 waitpid 等子进程处理完业务(业务慢就阻塞了 accept,服务器"卡住"),要么设 SIG_IGN/信号处理回收(代码又多一份)。

二次 fork 的思路是:儿子 fork 完立刻退出,真正干活的孙子变成孤儿,被 1 号进程(init/systemd)领养,孙子退出后由 1 号进程自动收割。父进程 waitpid 等的是儿子------儿子 fork 完就退,waitpid 瞬间返回,主循环立刻回去 accept 下一个客人。处理耗时的业务被完全甩给了孤儿进程。

一句话:waitpid 只等"交接班",不等"干完活"

(3) 孙子进程干完活的收尾callback 跑完 sock->Close()exit(OK)。其实孤儿进程退出时内核同样会回收 fd,这两行更像"好习惯的仪式感"------万一以后业务函数里还有别的代码路径,显式关总是不吃亏的。

(4) 父进程为什么关 sock? 同理反一下:父进程只管拉客。通信 sock 不关,每来一个连接父进程就多攥一个 fd,连接一多直接把进程的 fd 上限耗光(ulimit -n,默认 1024)。fork 模型的铁律:不用的 fd 立刻关,父子各关各的。

(5) waitpid 阻塞在主循环里,不慌吗? 慌的时候才需要信号方案,这里不慌------因为儿子活得极短(fork 完就退),waitpid 实际上不阻塞。

还有一个容易漏的点:fork 失败分支里那个 sock 没 close 就 exit 了------要不要紧?不要紧,进程退出时内核会回收该进程所有的 fd,这是内核的兜底,不是你不用管,是内核替你管了。


六、Util.hpp------三个纯工具函数

6.1 ReadFileContent:读文件的两个坑

cpp 复制代码
static bool ReadFileContent(const std::string &filename, std::string *out)
{
    int filesize = FileSize(filename);
    if(filesize > 0)
    {
        std::ifstream in(filename, std::ios::binary);
        if(!in.is_open())
            return false;
        out->resize(filesize);
        in.read(out->data(), filesize);
        in.close();
    }
    else
    {
        return false;
    }
    return true;
}

流程:先问文件多大 → 一次性 resize 好内存 → 一口气读进来。

注释里我留了 version1 的对照(逐行 getline),它有个致命问题:按行读会丢换行符,而且是文本模式。HTTP 要回的可能是图片、视频,任何"按行"的假设都是错的,二进制必须整块读。

坑1:std::ios::binary 必须加 。这版代码原来没加------注释写着"以二进制方式进行读取",打开却默认文本模式。Linux 上侥幸没事(Linux 不区分文本/二进制模式),Windows 上读 PNG 会把 \r\n 做转换,图片直接损坏。代码意图和实现必须一致,侥幸≠正确。

坑2:别对 c_str() 强转写入 。原代码是 in.read((char*)(out->c_str()), filesize)------c_str() 返回 const char*,强转掉 const 往里写是未定义行为。C++17 里 string::data() 返回的就是非 const 指针,光明正大地用。


6.2 ReadOneLine:整个 HTTP 解析的切行核心

cpp 复制代码
static bool ReadOneLine(std::string &bigstr, std::string *out, const std::string &sep/*\r\n*/)
{
    auto pos = bigstr.find(sep);
    if(pos == std::string::npos)
        return false;

    *out = bigstr.substr(0, pos);
    bigstr.erase(0, pos + sep.size());
    return true;
}

逻辑三行:找分隔符 → 切出前半段 → 把切走的(连同分隔符)从源串里删掉。

妙在 bigstr引用传入、原地缩减 :每调一次,"吃掉"一行,剩下的内容还在 bigstr 里。上层循环调它就能把报文逐行剥开。返回 false 意味着"找不到 \r\n 了"------对请求行解析来说就是报文残缺,上层直接放弃。


6.3 FileSize:seekg/tellg 组合拳

cpp 复制代码
static int FileSize(const std::string &filename)
{
    std::ifstream in(filename, std::ios::binary);
    if(!in.is_open())
        return -1;
    in.seekg(0, in.end);    // 文件指针甩到结尾
    int filesize = in.tellg(); // 问当前位置=第几字节,就是文件大小
    in.seekg(0, in.beg);    // 拨回开头
    in.close();
    return filesize;
}

原理就像问你"这本书多厚"------把书签翻到最后一页,看页码。seekg 移动读指针,tellg 报告当前位置,一去一回拿到字节数。打不开文件返回 -1,调用方据此走 404 逻辑。


七、Http.hpp------正戏:报文的解析与组装

这个文件是应用层核心,我拆成四块讲:请求解析、响应组装、路由分发、服务器组装。

7.1 HttpRequest:把请求行嚼碎

cpp 复制代码
class HttpRequest
{
public:
    HttpRequest() : _is_interact(false) {}
    ...
private:
    std::string _method;
    std::string _uri;
    std::string _version;

    std::unordered_map<std::string, std::string> _headers;
    std::string _blankline;
    std::string _text;

    std::string _args;      // ?后面的参数
    bool _is_interact;      // 是否是带参数的动态交互请求
};

_is_interact 是理解这个类设计的钥匙:请求分两类------静态请求 (要个网页/图片,回文件就行)和动态请求/login?username=xxx 这种要跑代码的)。区别就是 URI 里有没有 ?

反序列化的主干:

cpp 复制代码
bool Deserialize(std::string &reqstr)
{
    // 1. 提取请求行:从reqstr里按\r\n切下第一行
    std::string reqline;
    bool res = Util::ReadOneLine(reqstr, &reqline, glinespace);
    if (!res) // 连请求行都切不出来,说明报文是空的/残缺的
        return false;
    LOG(LogLevel::DEBUG) << reqline;

    // 2. 对请求行进行反序列化
    ParseReqLine(reqline);

ParseReqLinestringstream 一行搞定三个字段:

cpp 复制代码
void ParseReqLine(std::string &reqline)
{
    // GET / HTTP/1.1
    std::stringstream ss(reqline);
    ss >> _method >> _uri >> _version;
}

>> 按空白切,正好对应"方法 空格 URI 空格 版本"的格式------用格式本身的规律省掉手写切分,这是 C++ 处理定式文本的惯用招。

接着是 URI 的规整和参数剥离:

cpp 复制代码
if (_uri == "/")
    _uri = webroot + _uri + homepage; // ./wwwroot/index.html
else
    _uri = webroot + _uri; // ./wwwroot/a/b/c.html
...
const std::string temp = "?";
auto pos = _uri.find(temp);
if (pos == std::string::npos)
{
    return true;   // 没有?,纯静态请求
}
_args = _uri.substr(pos + temp.size()); // username=zhangsan&password=123456
_uri = _uri.substr(0, pos);             // ./wwwroot/login
_is_interact = true;

两个设计点:

  1. 浏览器请求 / 时补上默认首页 。用户访问 http://ip:8080/,URI 是 /,你总不能回一句"你要啥文件啊",约定俗成回 index.html

  2. URI 加上 webroot 前缀变成磁盘路径 。浏览器说 /index.html,那是相对 Web 根目录的逻辑路径,服务器翻译成 ./wwwroot/index.html 才知道去哪读。这个前缀同时挡住了直接读系统文件的企图------传 /etc/passwd 拼出来是 ./wwwroot/etc/passwd,读不到。

但还没完 :传 /../../etc/passwd 这种穿越路径,./wwwroot/../.. 就跳回了系统根目录,照样把系统文件读走(路径规范化必须在拼接前做,这版代码还没做,属于已知待办)------Web 安全永远是攻防博弈,挡一层不够。

? 的请求,? 后面就是参数(query string),格式 key=value&key=value,剥出来存 _args,留给业务函数处理。

已知简化 (注释里我也写了):这版只解析了请求行,header 和 body 没读 。对 GET 取静态页够用;要做 POST 拿 body,得继续按 \r\n 剥 header,找到 Content-Length 再按长度读 body。


7.2 HttpResponse:把响应拼出来

cpp 复制代码
HttpResponse() : _version("HTTP/1.0"), _code(200), _desc("OK"), _blankline(glinespace)
{
}

先看构造函数------这行是我改出来的血泪:_codeint不初始化就是随机值 。原代码全靠每个分支记得先调 SetCode,哪天新加个分支忘了调,Serialize 就把栈上的垃圾数字当状态码发给浏览器,那种 bug 能查一下午。构造时给默认值,防御性编程不丢人。

序列化------注意注释里那句"成熟的 http,应答做序列化,不要依赖任何第三方库":

cpp 复制代码
std::string Serialize()
{
    std::string status_line = _version + gspace + std::to_string(_code) + gspace + _desc + glinespace;
    std::string resp_header;
    for (auto &header : _headers)
    {
        std::string line = header.first + glinesep + header.second + glinespace;
        resp_header += line;
    }
    return status_line + resp_header + _blankline + _text;
}

纯字符串拼接:状态行 + 报头逐行 + 空行 + 正文。

对比一下:上篇计算器的请求解析用 jsoncpp,是因为业务字段是 JSON、嵌套结构手切容易错;HTTP 请求行是"空格分隔的定式文本",stringstream 就够;而响应序列化是我们生产报文,格式完全由自己控制,字符串拼接最轻量------这也是你以后看 Nginx 源码会看到的手法。

MakeResponse 是静态资源服务的灵魂:

bash 复制代码
bool MakeResponse()
{
    if (_targetfile == "./wwwroot/favicon.ico")
    {
        LOG(LogLevel::DEBUG) << "用户请求: " << _targetfile << "忽略它";
        return false;
    }
    if (_targetfile == "./wwwroot/redir_test")
    {
        SetCode(301);
        SetHeader("Location", "https://www.qq.com/");
        return true;
    }
    int filesize = 0;
    bool res = Util::ReadFileContent(_targetfile, &_text);
    if (!res)
    {
        _text = "";
        SetCode(404);
        _targetfile = webroot + page_404;
        filesize = Util::FileSize(_targetfile);
        if (filesize <= 0) // 连404.html都不存在时,不能把Content-Length写成-1
            filesize = 0;
        Util::ReadFileContent(_targetfile, &_text);
        SetHeader("Content-Type", Uri2Suffix(_targetfile));
        SetHeader("Content-Length", std::to_string(filesize));
    }
    else
    {
        SetCode(200);
        filesize = Util::FileSize(_targetfile);
        SetHeader("Content-Type", Uri2Suffix(_targetfile));
        SetHeader("Content-Length", std::to_string(filesize));
        SetHeader("Set-Cookie", "username=zhangsan;");
    }
    return true;
}

四个现象级的逻辑:

  1. favicon.ico 直接忽略 :浏览器每次访问都会自动请求一次 站点/favicon.ico(标签页小图标)。我们没这文件,不理它就行------虽然返回 false 意味着这个连接啥也不回,浏览器对 favicon 有自己的容错,不影响页面渲染(实测现象见第九节现象2)。

  2. 301 重定向SetCode(301) + SetHeader("Location", "https://www.qq.com/"),就这两个头。浏览器收到 301,看到 Location,二话不说跳转到新地址。重定向的本质就这么朴素。

  3. 404 的兜底逻辑 :文件读不到 → 换 404.html 的内容当正文 → 状态码 404。注意那个 filesize <= 0 的保护:404.html 自己也丢了的话,FileSize 返回 -1,Content-Length: -1 发出去浏览器直接懵------报头数值永远是对方解析的对象,不能有非法值

  4. Set-Cookie :成功响应顺手种了个 cookie。浏览器会存下来,以后每次请求自动带上 Cookie: username=zhangsan------HTTP 本身无状态,会话保持全靠这对配合。

Uri2Suffix 管 MIME 类型:

cpp 复制代码
if (suffix == ".html" || suffix == ".htm")
    return "text/html";
else if (suffix == ".jpg")
    return "image/jpeg";
else if (suffix == ".png")
    return "image/png";

这里有个我抓出来的经典 bug :原代码写的是 suffix == "png"------少个点,永远匹配不上,PNG 的 Content-Type 一直是空。substr(pos). 开始切,切出来的是 .png,字符串匹配差一个字符都不行。浏览器拿到空的 Content-Type 就只能靠猜(嗅探),猜错了就当文本下载。这类 bug 编译器不报、运行不崩,页面"看起来"还能开,最容易存活下来。

还有 SetHeader 的语义注释:

bash 复制代码
// 注意语义:同名key已存在时不覆盖,第二次设置会被丢弃
// 想覆盖的话,把这里改成 _headers[key] = value; 即可

"先查再插"实现的是不覆盖语义。这是设计选择(防止后面的代码误改关键头),但你必须知道它------不然明明调了 SetHeader 却"不生效",查半天查不出问题。


7.3 路由表 + 服务器组装

cpp 复制代码
using http_func_t = std::function<void(HttpRequest &req, HttpResponse &resp)>;

class Http
{
public:
    Http(uint16_t port) : tsvrp(std::make_unique<TcpServer>(port)) {}

    void RegisterService(const std::string name, http_func_t h)
    {
        std::string key = webroot + name; // ./wwwroot/login
        if (_route.find(key) == _route.end())
        {
            _route.insert(std::make_pair(key, h));
        }
    }

路由表就是 unordered_map<路径, 处理函数>。注册时把 /login 也拼上 webroot 前缀变成 ./wwwroot/login------和请求解析后的 URI 同一套格式,查找才能对上。

这就是"注册和查找必须用同一种坐标系",两边坐标系不一致,路由永远 404,而且是那种代码看着全对、就是跑不通的 404。

核心分发逻辑:

cpp 复制代码
void HandlerHttpRquest(std::shared_ptr<Socket> &sock, InetAddr &client)
{
    std::string httpreqstr;
    // 这里实际是"TCP粘包问题":HTTP报文之间靠空行(\r\n\r\n)分界,只recv一次,
    // 大网页/慢网络时可能只收到半个报文,也可能一次收到好几个报文
    // 完整做法:先读到\r\n\r\n拿到header,解析出Content-Length,再循环recv把body读够
    int n = sock->Recv(&httpreqstr);
    if (n > 0)
    {
        std::cout << "##########################" << std::endl;
        std::cout << httpreqstr;
        std::cout << "##########################" << std::endl;

        HttpRequest req;
        HttpResponse resp;
        if (!req.Deserialize(httpreqstr))
            return;
        if (req.isInteract())
        {
            if (_route.find(req.Uri()) == _route.end())
            {
                // 路由表里没有这个交互路径:不能什么都不回!
                // 不回数据的话,浏览器会一直干等到超时,表现就是页面卡死转圈
                resp.SetCode(404);
                std::string text = "<html><h1>404 Not Found</h1></html>";
                resp.SetHeader("Content-Type", "text/html");
                resp.SetHeader("Content-Length", std::to_string(text.size()));
                resp.SetText(text);
                sock->Send(resp.Serialize());
            }
            else
            {
                _route[req.Uri()](req, resp);
                std::string response_str = resp.Serialize();
                sock->Send(response_str);
            }
        }
        else
        {
            resp.SetTargetFile(req.Uri());
            if (resp.MakeResponse())
            {
                std::string response_str = resp.Serialize();
                sock->Send(response_str);
            }
        }
    }
}

这段里最值得圈出来的是交互路径未注册时的处理 。原代码那里是空的------找到路由走回调,找不到就什么都不干。TCP 连接还开着,对方在等响应,我们不回也不关,浏览器就一直转圈等到超时。我实测:curl exit 52(空响应)。任何分支都必须给对方一个交代,哪怕是个 404。

最后 Start 把自己和 TcpServer 缝合:

cpp 复制代码
void Start()
{
    tsvrp->Start([this](std::shared_ptr<Socket> &sock, InetAddr &client)
                 { this->HandlerHttpRquest(sock, client); });
}

lambda 捕获 this,把 Http 的成员方法包装成 TcpServer 要的 ioservice_t。至此:TcpServer 管连接和进程,Http 管协议,Main.cc 管业务------三层各干各的。


八、Main.cc------把所有积木拼起来

cpp 复制代码
int main(int argc, char *argv[])
{
    if(argc != 2)
    {
        std::cout << "Usage: " << argv[0] << " port" << std::endl;
        exit(USAGE_ERR);
    }
    // 坑1:stoi遇到非数字会抛std::invalid_argument,进程直接崩
    // 坑2:stoi返回int,超出端口范围直接塞进uint16_t会被截断(99999->34463)
    int port_num = 0;
    try
    {
        port_num = std::stoi(argv[1]);
    }
    catch (const std::exception &e)
    {
        std::cout << "port must be a number! " << e.what() << std::endl;
        exit(USAGE_ERR);
    }
    if (port_num < 0 || port_num > 65535)
    {
        std::cout << "port must be in [0, 65535]!" << std::endl;
        exit(USAGE_ERR);
    }
    uint16_t port = static_cast<uint16_t>(port_num);

    std::unique_ptr<Http> httpsvr = std::make_unique<Http>(port);
    httpsvr->RegisterService("/login", Login); // 注册路由:uri -> 处理函数

    httpsvr->Start();
    return 0;
}

命令行参数给端口------端口号不该写死在代码里,不然测试环境 8080、线上 80 就得改代码重编。

std::stoi 这块是我加固过的,两坑都实测过(见第九节现象6):传字母抛异常;传 99999 不抛异常但塞进 uint16_t 静默截断成 34463,服务器绑了个莫名其妙的端口,你查半天"为什么 99999 连不上"。端口本质是个 16 位无符号数,范围 0, 65535,边界必须自己守

业务回调 Login

cpp 复制代码
void Login(HttpRequest &req, HttpResponse &resp)
{
    LOG(LogLevel::DEBUG) << req.Args() << ", 我们成功进入到了处理数据的逻辑";
    std::string text = "hello: " + req.Args();

    resp.SetCode(200);
    resp.SetHeader("Content-Type","text/plain");
    resp.SetHeader("Content-Length", std::to_string(text.size()));
    resp.SetText(text);
}

真实的业务函数就该这么干净:从 req 拿参数(这里偷懒直接回显),往 resp 填状态码、报头、正文。业务代码完全不碰 socket、不碰 recv/send ------网络的事 TcpServer 管了,协议的事 Http 管了。这就是分层给你的自由。

注意 Content-Length 必须和 text.size() 一致,虚标长度浏览器就傻等不存在的字节。

Makefile 就四行,两处讲究:

cpp 复制代码
# Mutex.hpp用了pthread_mutex,链接要带-pthread
myhttp:Main.cc
	g++ -o $@ $^ -std=c++17 -pthread
.PHONY:clean
clean:
	rm -f myhttp

-std=c++17std::filesystem、inline 变量、string::data() 非 const 版,全要 17。-pthread:编译器/链接器需要知道你在用 POSIX 线程库------不加在某些平台上能过,某些平台上运行时炸,别赌。

还有个 Makefile 的暗坑:recipe 行(Tab 开头的命令)里的 # 会原样传给 shell,我第一版把注释写在 g++ 行尾,能跑但脏,后来挪到了独立行。


九、运行现象:真跑起来是什么样

编译、启动(-pthread 已就位):

bash 复制代码
$ make
g++ -o myhttp Main.cc -std=c++17 -pthread
$ ./myhttp 8080
[2026-09-19 15:50:36] [INFO] [2778366] [Socket.hpp] [72] - socket success
[2026-09-19 15:50:36] [INFO] [2778366] [Socket.hpp] [83] - bind success
[2026-09-19 15:50:36] [INFO] [2778366] [Socket.hpp] [93] - listen success

三条日志,对应 BuildTcpSocketMethod 的三步。[2778366] 是父进程 PID。服务器启动只干这三件事,之后所有日志都是每个连接的处理过程

现象1:curl 首页------一个完整请求的全过程

bash 复制代码
$ curl -s -o /dev/null -w "%{http_code} %{size_download}B %{content_type}\n" http://127.0.0.1:8080/
200 2872B text/html

服务端这边同时打出了完整的一套日志:

bash 复制代码
##########################
GET / HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: curl/7.81.0
Accept: */*

##########################
[DEBUG] [2778380] [Http.hpp] [54] - GET / HTTP/1.1        <- 干活的进程PID是2778380,不是父进程2778366
[DEBUG] [2778380] [Http.hpp] [65] - _uri: ./wwwroot/index.html
[DEBUG] [2778380] [Http.hpp] [66] - _version: HTTP/1.1
[DEBUG] [2778380] [Http.hpp] [223] - 读取文件: ./wwwroot/index.html

几个现象值得品:

  1. ##### 包着的原始报文就是浏览器/curl 发来的第一手数据,空行肉眼可见------和第二节讲的格式图一一对上。

  2. 处理请求的进程 PID 是 2778380,启动日志是 2778366------fork 模型的直接证据:父进程接客,孤儿进程干活,日志里的 PID 不会骗人。

  3. URI / 被自动补成 ./wwwroot/index.html------默认首页逻辑生效。

  4. 注意 header 打印顺序每次可能不一样(Set-Cookie 有时排在最前)------因为报头存在 unordered_map 里,哈希表无序 ,序列化时按遍历序输出。HTTP 报头的顺序协议不要求,浏览器不在乎,但强迫症看着难受(想固定顺序就换 std::map 或者 vector<pair>)。

完整响应抓出来看:

bash 复制代码
HTTP/1.0 200 OK
Set-Cookie: username=zhangsan;
Content-Length: 2872
Content-Type: text/html

我还做了个字节级校验:Content-Length: 2872,实际 body 也是 2872 字节,分毫不差。长度报头虚标是 HTTP 的大忌,浏览器会按 Content-Length 读正文,虚了就永远读不满。


现象2:浏览器会偷偷要 favicon.ico

用 curl 模拟浏览器补一刀:

bash 复制代码
$ curl -s -o /dev/null -w "%{http_code}\n" --max-time 2 http://127.0.0.1:8080/favicon.ico
000            <- 2秒内服务器一个字节都没回
$ echo $?
52             <- curl: Empty reply from server

服务端日志:

bash 复制代码
[DEBUG] [2804378] [Http.hpp] [54] - GET /favicon.ico HTTP/1.1
[DEBUG] [2804378] [Http.hpp] [65] - _uri: ./wwwroot/favicon.ico
[DEBUG] [2804378] [Http.hpp] [193] - 用户请求: ./wwwroot/favicon.ico忽略它

浏览器访问任何网站都会自动 带一次 /favicon.ico 请求(标签页图标),这是浏览器行为,跟页面代码无关。我们选择忽略------浏览器对这个请求有自己的容错,页面照常渲染。如果你在日志里看到莫名其妙的 favicon 请求,不是谁在攻击你,是浏览器的小习惯。


现象3:404------兜底页面

bash 复制代码
$ curl -s -o /dev/null -w "%{http_code} %{size_download}B\n" http://127.0.0.1:8080/no_such.html
404 1506B

请求的文件不存在,服务器读 wwwroot/404.html 当正文返回,1506 字节就是 404 页面的大小。服务端 WARNING 日志:client want get : ./wwwroot/no_such.html but not found


现象4:动态交互 /login

bash 复制代码
$ curl -s "http://127.0.0.1:8080/login?username=zhangsan&password=123456"
hello: username=zhangsan&password=123456

走了完全不同的路:is_interact=true → 查路由表命中 ./wwwroot/login → 回调 Login → 回显参数。静态请求读文件、动态请求跑代码,在这个 curl 里肉眼可见地分岔了。

参数里的 & 记得给 shell 加引号,不然 shell 把它当后台运行符,你的 curl 会莫名其妙只发一半。


现象5:301 重定向

bash 复制代码
$ curl -s -o /dev/null -w "%{http_code} -> %{redirect_url}\n" http://127.0.0.1:8080/redir_test
301 -> https://www.qq.com/

服务器只回了状态行 301 Moved Permanently + Location: https://www.qq.com/,跳转是浏览器/客户端完成的------服务器从不替你跳,它只指路。


现象6:参数传错的防守

bash 复制代码
$ ./myhttp abc
port must be a number! stoi
$ echo $?
1
$ ./myhttp 99999
port must be in [0, 65535]!
$ ./myhttp -1
port must be in [0, 65535]!
$ ./myhttp
Usage: ./myhttp port

修复前传 abc 是未捕获的 std::invalid_argument 直接 core;传 99999 静默截断成 34463 绑了个鬼端口。现在都拦在门口。


现象7:并发

多开几个终端同时 curl,服务端日志会出现不同的 PID 交错处理------每个连接一个孤儿进程,天然并发。

这也是 fork 模型的好处:进程间隔离,一个连接的处理崩了(比如 stoi 抛异常),死的是那个孤儿进程,父进程和其他连接毫发无损。代价是 fork 开销大,连接量级上去了要换多线程/epoll------那是后续课程的事。


十、踩坑实录:这次检查修掉的问题清单

这一节是我最想写的。这版代码编译零警告、一跑全是坑------下面每一个都是实际运行抓出来的,不是纸面推演:

# 位置 问题 现象 修法
1 webroot 代码写 ./wwwroot,磁盘目录叫 www.root,差一个点 所有请求 404,包括首页 目录改名 wwwroot。Linux 文件系统大小写敏感、名字精确匹配,差一个字符都不行
2 MakeResponse 成功分支报头写成 Conent-Type(拼写错) 浏览器收不到正确类型头 改回 Content-Type
3 Uri2Suffix suffix == "png" 少个点 PNG 永远匹配不上,Content-Type 为空 改成 ".png"
4 交互路由未命中 什么都不回 curl exit 52,浏览器卡死转圈 回 404
5 ReadFileContent 打开文件没加 std::ios::binary(char*)c_str() 强转写入 UB Windows 上读图必坏 加 binary;改用 data()
6 stoi 非数字崩 / 99999 截断 服务器绑错端口 try-catch + 范围检查
7 HttpResponse 构造 _code 未初始化 忘调 SetCode 就发随机状态码 构造时给默认值
8 inet_ntop 缓冲区大小传成 sizeof(_addr) IPv4 最长恰好 16 字节,纯撞大运 sizeof(ipbuffer)
9 初始化顺序 初始化列表顺序和成员声明顺序不一致 -Wall-Wreorder 按声明顺序写
10 Log.hpp 全局符号 头文件里定义函数和全局变量没加 inline 多个 .cpp 包含会 multiple definition inline 函数 + C++17 inline 变量
11 Makefile 没带 -pthread 部分平台运行时炸 补上
12 favicon/空PNG 0 字节的图片文件 ReadFileContent 返回 false → 404 这不是 bug:空文件本来就该 404,放真图即可

最容易活下来的 bug 是 #2 和 #3:编译器不报、进程不崩、页面"看起来"还能开。这种 bug 的存活时间可以以年计。

所以我的流程是:改完必须 -Wall -Wextra 编译 + curl 逐项实测 + 校验响应头和字节数,光"看代码没问题"不作数。


十一、收个尾:这个项目的口诀

整个服务器拆完,就干四件事:

建连接(socket/bind/listen),接客人(accept + fork), 收报文(recv + 切行解析),回报文(读文件 / 跑业务 + 拼响应)。

分层口诀:

TcpServer 管进程,Http 管协议,业务函数管逻辑,谁也别越界。

几个刻进 DNA 的点:

  1. TCP 是字节流 ,报文边界是应用层的事------HTTP 靠 \r\nContent-Length,你的协议得自己定(上次是 len\r\njson\r\n,一回事)

  2. fork 后不用的 fd 立刻关,父子各关各的

  3. 二次 fork:儿子交接班,孙子干活,waitpid 不等干完活

  4. 任何分支都要给对端一个交代,不回数据人家就挂着

  5. 报头数值(Content-Length)和实际字节数必须分毫不差

  6. 改完代码:-Wall -Wextra 零警告 + 实测每一项,再相信"我写对了"

相关推荐
FW-Linker1 小时前
偏远山区户外直播网络优化:多链路聚合如何保障稳定低时延传输
网络·5g·智能路由器
日拱一卒的小田1 小时前
ZYNQ学习笔记4-ZYNQ的SD卡控制器2
笔记·学习
外收内放1 小时前
Python与AI应用(项目开发实战:AI智能伴侣第一版)
python·学习·ai编程
へ蟲児.1 小时前
幼儿英语启蒙APP怎么选,专业性、适配性、实用性三大角度解读
学习
动词ing1 小时前
【学习笔记】动态规划+背包问题
笔记·学习
Hhy_11071 小时前
《C++深度解构04》类和对象(下)——类型转换、static、友元与编译器优化
c语言·c++·学习·类和对象·visual studio
傲世仙尊2 小时前
设计者的取舍-路径fork的诞生与全局文件队列
linux
传奇开心果编程2 小时前
【Flutter入门练中学】第1课:从零开始
学习·flutter·ui
逐流人2 小时前
Containerd容器管理实战:从架构原理到nerdctlcrictl工具链
linux·运维·云原生·容器·云计算·containerd