[Linux]HTTP 应用层协议全解(中篇)

大家国庆假期快乐,文章较长,创作不易,咱们"下篇"预计在国庆末发布。

上篇的结尾,我们服务器手上已经躺着一个填好的 HttpRequest 了。 它知道对方要什么方法、要哪个路径、带的什么版本、有哪些报头、有没有正文。

但它还什么都没做。

中篇要回答的就是接下来这两件事:东西在哪,以及怎么答。


承接上篇

如果你是从上篇读过来的,可以直接往下走。如果你是单独打开这一篇的,这里用最短的篇幅补一下上文。

上篇讲的是"请求"这一侧:

复制代码
   你在地址栏敲下字符串
        │
        ├─ URL 的七段结构(第二章)
        ├─ DNS 把域名翻译成 IP(第三章)
        ├─ TCP 三次握手搭上话(第四章)
        ├─ 报文的四段结构:请求行 / 报头 / 空行 / 正文(第五章)
        ├─ 服务器怎么从字节流里切出一条完整的报文(第六章)
        └─ 怎么把它装进 HttpRequest 结构体(第七章)

中篇接着往下走。第一步,是上篇留下的那个 _uri 字段:

复制代码
class HttpRequest {
    std::string _method;                       // "GET"
    std::string _uri;                          // ★ "/index.html" ------ 中篇从这里开始
    std::string _version;                      // "HTTP/1.1"
    std::unordered_map<std::string, std::string> _header;
    std::string _blank_line;
    std::string _text;
};

_uri 只是一个字符串。而服务器需要的是一个能 open() 的磁盘路径。这两者之间的转换,就是中篇的开篇。


中篇导读:一次完整的问答

上篇回答的是"对方说了什么",中篇回答的是"我该怎么答"。

而"怎么答"这件事,比它听起来复杂得多。它至少要解决五组问题:

复制代码
┌──────────────────────────────────────────────────────────────────┐
│  第一组:东西在哪?(第八章)                                     
│                                                                  
│    · /image/桃花.jpg  →  open() 的参数                             
│    · 中间要补 wwwroot、要处理 "/"、要防目录穿越                     
│    · 然后 open/read 这一路钻进内核:VFS → dentry → inode           
│                                                                  
│    ★ 上篇讲了"请求怎么来",这一章讲"请求怎么落到磁盘上"           
├──────────────────────────────────────────────────────────────────┤
│  第二组:答的格式是什么?(第九 ~ 十章)                          
│                                                                  
│    · 状态行 / 响应报头 / 空行 / 正文 ------ 和请求几乎对称              
│    · 但 Build() 有两个真正的难点:                                 
│      · target_file 怎么算出来                                    
│      · 文件内容怎么读进 _text(而且不能读漏、不能读错)           
├──────────────────────────────────────────────────────────────────┤
│  第三组:答得对不对?(第十一章)                                 
│                                                                  
│    · 200 / 301 / 302 / 404 / 500 ......                                
│    · 1xx~5xx 五大类,看一眼第一位就够                              
│    ★ 301 与 302 的一字之差,能让用户再也登不上账号               
├──────────────────────────────────────────────────────────────────┤
│  第四组:答的是什么?(第十二章)                                 
│                                                                 
│    · 字节不会自解释 ------ 同一串字节,可以是 HTML,也可以是 PNG        
│    · 靠 Content-Type 说清楚                                        
│    ★ 顺便理解:为什么打开一个网页会发几十次请求                   
├──────────────────────────────────────────────────────────────────┤
│  第五组:参数怎么带、怎么保密?(第十三 ~ 十四章)                
│                                                                  
│    · GET 放 URI 上,POST 放正文里 ------ 差别只在位置                   
│    · 但这个"位置"带来了一连串现实后果                              
│    · 以及最根本的一个问题:明文传输                                 
│    ★ 第十四章从网卡的混杂模式讲起,让你亲眼看到密码被读走          
└──────────────────────────────────────────────────────────────────┘

走完这五组问题,一个能跑的 HTTP 服务器就完整了:它能收到请求、找到文件、拼出响应、发回去。

中篇的最后,我们还会回答一个上篇一直悬着的问题:为什么必须加密。


目录


第八章 从 URI 到磁盘:wwwroot 与 VFS

8.1 问题的起点:URI 只是一个字符串

现在服务器手里有一个字符串:

复制代码
_uri = "/index.html";

它似乎只是一个以 / 开头的字符串。

要把它变成一个真正的文件,中间隔着好几个问题:

复制代码
问题一:这个 "/" 是什么的根?
        是 Linux 文件系统的根目录 "/" 吗?
        如果是,那 /etc/passwd 岂不是人人都能下载?!

问题二:如果 URI 只有 "/"(没有文件名),该返回什么?

问题三:把这个路径交给 open() 之后,内核是怎么找到文件的?

这三个问题,就是这一章的全部内容。

8.2 Web 根目录:为什么必须有 wwwroot

先看问题一,假设服务器直接把 _uri 拿去 open():

复制代码
// ❌ 危险示范:绝对不能这么写
int fd = open(_uri.c_str(), O_RDONLY);

这句话意味着,URI 的 / 就等于 Linux 根目录的 /。

那么攻击者只要请求:

复制代码
GET /etc/passwd        HTTP/1.1      ← 拿到所有用户列表
GET /etc/shadow        HTTP/1.1      ← 拿到密码哈希(如果有权限)
GET /proc/self/environ HTTP/1.1      ← 拿到进程的环境变量(可能含密钥)

整个文件系统就全部暴露了。

原文对这个问题的描述非常直接:

👈 如果这里不处理 _uri,即不做加 g_wwwroot 处理的话,会直接锚定到 Linux 家目录的 "/" 下,脱离了客户端希望的 Web 根目录下的资源!所以加上 g_wwwroot 是必须这么做的!

注意"脱离"这个词用得极准。 客户端心里的 / 和 Linux 心里的 /,根本就不是同一个 /:

复制代码
   客户端以为的:
   /index.html   →   "服务器上那个放网页的目录里的 index.html"

   Linux 以为的:
   /index.html   →   "系统根目录下的 index.html"(这个文件根本不存在)

   ★ 两个 "/" 含义完全不同 ------ 这就是问题的根源

解决办法:给这个路径加一个前缀,把它"钉"在指定的目录里。

复制代码
// 定义一个"Web 根目录"常量
const std::string g_wwwroot = "wwwroot";

原文说明了这个目录的用途:

/a/b/c/d.html 是 Web 根目录 Web 根目录(wwwroot)的首页 index.html 前端工程师写的资源会直接放到 www.root 的位置

然后,在解析完 URI 之后做一次拼接:

复制代码
// 把客户端的 URI,转换成服务器本地的真实路径
    _uri = g_wwwroot + _uri;
//  "/index.html"  ──►  "wwwroot/index.html"
//                           └──┬──┘ └────┬────┘
//                          Web 根目录   客户端要的文件

这一次字符串拼接,就是整个安全模型的关键一步。

复制代码
拼接前:  /index.html                 ← 指向 Linux 根目录 ❌
拼接后:  wwwroot/index.html          ← 指向 Web 根目录 ✅

再看一次那个攻击,现在会发生什么:

复制代码
GET /etc/passwd  →  _uri = "/etc/passwd"
                 →  拼接后 = "wwwroot/etc/passwd"
                 →  Web 根目录下不存在这个文件
                 →  返回 404 ✅

攻击被挡住了------不是靠过滤,而是靠"根本不给它走到别处的路径"。

一个安全设计的通用原则 :比起 "列出所有危险的输入然后禁止"(黑名单),更好的做法是 "只允许它走到允许的地方"(白名单 / 限定根目录)。

黑名单永远会漏,因为你想象不到所有的攻击方式;而限定根目录是结构性 的------攻击者无论输什么,都出不了这个目录。
不过要提醒一句:仅靠字符串拼接还不够。如果 URI 里含有:

../、wwwroot/../../etc/passwd 仍然可能穿越出去。

真正的服务器还需要做路径规范化 (把 ../ 展开后再判断是否仍在根目录内)或者用 openat + O_NOFOLLOW 之类的系统调用加固。这和安全无关的 .. 一样,是同一个"路径穿越"问题家族。

8.3 首页:为什么 / 要变成 /index.html

现在看问题二。

回到第五章 5.5 节那个真实报文:

复制代码
GET / HTTP/1.1
    ↑
  URI 就是一个 "/"

/ 指的是 Web 根目录本身,它是一个目录,不是文件------文件内容没法返回一个目录啊。

所以服务器必须自己决定:当用户请求一个目录时,返回什么文件?

行业惯例是:返回这个目录下的 index.html。

原文的代码就是这么写的:

复制代码
const std::string g_first_page = "index.html";   // 默认首页的名字

// 处理 URI
if (_uri == "/") {
    _uri += g_first_page;
    //  "/"  ──►  "/index.html"
}
_uri = g_wwwroot + _uri;
//  "/index.html"  ──►  "wwwroot/index.html"

两行代码,完成了两次意义不同的转换:

复制代码
第一步:if (_uri == "/")  _uri += g_first_page;
        语义:把"请求目录"补全成"请求该目录下的默认文件"
        /  →  /index.html

第二步:_uri = g_wwwroot + _uri;
        语义:把"客户端视角的路径"翻译成"服务器视角的路径"
        /index.html  →  wwwroot/index.html

注意这两步的顺序不能颠倒 :

如果先加 wwwroot,_uri 就变成 wwwroot/,此时再判断 _uri == "/" 就永远不成立了。所以必须先补全文件名,再加根目录前缀。

index.html 这个名字不是随便起的,它是一套广泛遵守的约定:

复制代码
Apache / Nginx / IIS ... 都默认找 index.html
(也有 index.htm、index.php、default.html 等变体,
  通常在服务器配置里可以修改这个列表)

这就是为什么你访问 www.example.com 而不写文件名,也能看到首页。

8.4 再往下钻一层:这个 read 在内核里发生了什么

好,现在服务器手里有了一个真实的路径:

复制代码
wwwroot/index.html

下一步就是把这个文件的内容读出来:

复制代码
int fd = open("wwwroot/index.html", O_RDONLY);
read(fd, buf, size);
close(fd);

对应用层的我们来说,这一步到这就结束了。但既然这是一篇写给 Linux 学习者的文章,我们不妨再往下钻一层------看看 open 和 read 这两个调用,在内核里究竟走了一条什么路。

原文对 HTTP 的本质下过这样一个定义:

HTTP 超文本请求的本质:就是通过网络服务器把 Linux 机器上,特定目录下的文件,以 http 应答的方式返回给 client(浏览器或者 app)。 这个文件,可以是图片、音频,所以称 http 为超文本协议。

"Linux 机器上特定目录下的文件"------这半句话,就把 HTTP 和文件系统接了起来。

那么问题来了:

open("wwwroot/index.html", O_RDONLY) 这一行,内核是怎么把它变成磁盘上的一块数据的?

8.4.1 第一层:VFS------为什么 open 能打开所有东西

先问一个前置问题:Linux 支持几十种文件系统(ext4、xfs、btrfs、proc、sysfs、tmpfs......),它们组织数据的方式完全不同。

但 open / read / close 只有一套接口。

这意味着内核里必须有一层"翻译官",把统一的接口翻译成各个文件系统的具体操作。这一层就是 VFS(Virtual File System,虚拟文件系统)。

复制代码
      应用层        open("wwwroot/index.html")
                              │
                              ▼
┌──────────────────────────────────────────────────┐
│  VFS 抽象层                                       │
│  ↑ 统一接口:不管你是什么文件系统,都长这样         │
│  ────────────────────────────────────────────    │
│  向下分发:根据挂载点,找到是哪个文件系统           │
└──────┬───────────────────────┬───────────────────┘
       │                       │
       ▼                       ▼
   ext4 文件系统            proc 文件系统
   (磁盘上的真实文件)      (内核内存里的虚拟文件)

VFS 的核心思想,还是我们反复见到的那四个字:先描述,再组织。

内核对"打开的文件"这件事,设计了结构体去描述它。

8.4.2 struct file:一次"打开"的化身

先问需求:open() 返回一个 fd,用户拿这个 fd 去 read。内核需要记住什么?

复制代码
① 我现在读到文件的哪个位置了?(读写偏移量)
   ↑ 这个必须每个"打开"单独记!
     因为同一个文件被打开两次,两个位置是各自独立的

② 我是用什么方式打开的?(只读?只写?追加?)
   ↑ 决定了这次 read/write 是否被允许、行为如何

③ 我对应的是哪个文件本身?
   ↑ 需要指向 inode

需求清楚,字段就出来了:

复制代码
// include/linux/fs.h ------ 节选
struct file {
    loff_t              f_pos;      // ① 当前读写偏移量("我读到哪了")
    fmode_t             f_mode;     // ② 打开模式(O_RDONLY / O_WRONLY / O_RDWR ...)
    const struct file_operations *f_op;  // ☆ 函数表:这个文件的 read/write 该调谁
    struct dentry       *f_path.dentry;  // ③ 指向 dentry("文件名")
    struct inode        *f_inode;        // ④ 指向 inode("文件本身")
    unsigned int        f_flags;    // ⑤ 打开时的标志位(O_APPEND / O_NONBLOCK ...)
    atomic_long_t       f_count;    // ⑥ 引用计数(有几个 fd 指向我)
    // ...
};

几个字段值得多看一眼。

f_pos------为什么"打开"需要独立描述?

因为文件本身是共享的,但"读到哪了"是私有的。

复制代码
进程 A 打开 log.txt,读到第 100 字节
进程 B 打开 log.txt,读到第 0 字节
        ↑ 同一个文件,两个独立的 f_pos,互不干扰

所以"打开"这件事必须有一个自己的结构体。这就是 struct file 存在的理由。

f_op------函数表,C 语言实现多态的经典手法。

这和我们讲 TCP 时见到的 proto_ops 是同一个套路:

复制代码
// 一个普通磁盘文件的 read/write 会走到这里
const struct file_operations ext4_file_operations = {
    .read_iter = ext4_file_read_iter,   // read() 最终调它
    .write_iter = ext4_file_write_iter, // write() 最终调它
    // ...
};

你调用的 read() 是同一个符号,但最终执行的函数取决于这个文件是 ext4 的、还是 proc 的。 这就是 VFS 的味道。

一个细节:struct file 和"文件"不是一一对应的。

复制代码
同一个文件被 open 两次  →  内核里有两个 struct file(两个 f_pos)
                       →  但它们指向【同一个 inode】

这一点非常关键,它是理解 fd、重定向、fork 共享文件偏移量的基础。

8.4.3 struct dentry:路径解析的缓存

下一个问题:open("wwwroot/index.html") 里的这串路径,内核怎么处理?

最朴素的办法是:从根目录开始,一层一层读磁盘,查找 wwwroot 目录、再查 index.html。但这样每次都要磁盘 IO,太慢了。

解法:把路径解析的结果缓存到内存里。

这个缓存就是 dcache ,它的节点就是 dentry(Directory Entry,目录项)。

需求分析:要缓存"一个路径节点",需要记住什么?

复制代码
① 我叫什么名字?
② 我对应哪个文件(inode)?
③ 我的父目录是谁?(向上找)
④ 我的子节点有哪些?(向下找)
⑤ 我还在被使用吗?(引用计数)

// include/linux/dcache.h ------ 节选
struct dentry {
    struct inode    *d_inode;      // ① 指向对应的 inode("名字 → 文件"的桥梁)
    struct dentry   *d_parent;     // ② 指向父目录的 dentry(向上找爹)
    struct list_head d_child;      // ③ 挂在父目录的子节点链表上(找兄弟姐妹)
    struct list_head d_subdirs;    // ④ 自己子节点的链表头(向下找孩子)
    unsigned char    d_name[];     // ⑤ 文件名本身("index.html")
    unsigned int     d_count;      // ⑥ 引用计数(归零才能被回收)
    // ...
};

把这几个字段连起来看,你会发现一件事:

整个文件系统的目录结构,被组织成了一棵内存里的多叉树。

复制代码
                    "/" (根 dentry)
                     │
        ┌────────────┼────────────┐
        ▼            ▼            ▼
     "home"       "etc"       "wwwroot"     ← 每个都是一个 dentry
                                   │
                                   ▼
                              "index.html"   ← 叶子节点,也是一个 dentry

这就是"第一次慢、第二次快"的原理:

复制代码
第一次访问 wwwroot/index.html:
   逐级读磁盘 → 建 dentry → 全是磁盘 IO → 慢

第二次访问同一个路径:
   全部命中 dcache → 纯内存查找 → 快

对 HTTP 服务器来说,这意味着一个非常实际的现象:

服务器刚启动时,第一批请求会明显慢一些 (要建 dcache); 跑了一会儿之后,热门页面(比如首页)会一直很快(dcache 命中)。

8.4.4 struct inode:文件本身

最后,dentry 上的 d_inode 指向的那个东西------文件本身。

需求分析:描述一个文件,需要记住什么?

复制代码
① 这个文件有多大?
② 它的内容存在哪些数据块上?
③ 它是谁创建的、权限是什么、什么时候改的?
④ 它是普通文件、目录还是设备文件?

// include/linux/fs.h ------ 节选
struct inode {
    umode_t             i_mode;     // ① 文件类型 + 权限(rwx、是不是目录)
    kuid_t              i_uid;      // ② 属主
    kgid_t              i_gid;      // ③ 属组
    loff_t              i_size;     // ④ 文件大小(字节)★
    struct timespec64   i_atime;    // ⑤ 访问时间
    struct timespec64   i_mtime;    // ⑥ 修改时间
    struct timespec64   i_ctime;    // ⑦ 状态改变时间
    const struct inode_operations *i_op;   // ⑧ 函数表
    const struct file_operations  *i_fop;  // ⑨ 默认的 file 操作
    struct super_block  *i_sb;      // ⑩ 属于哪个文件系统
    // ... 以及各文件系统自己的私有数据
};

注意这里的 i_size------它就是 HTTP 响应里 Content-Length 的数据来源!

复制代码
服务器要把 wwwroot/index.html 的内容发回去,必须先告诉客户端"有多长"

    内核里:  inode->i_size  = 1024
                  │
                  ▼
    响应里:  Content-Length: 1024

这是一条从磁盘 inode 一路连到 HTTP 报头的完整链路。

再补一个反直觉的点(我们在文件系统那一章强调过):

文件名不属于 inode!

文件名存在目录的数据块 里(wwwroot 这个目录的数据块里,有一项是 "index.html" → inode 编号)。

为什么要这样设计?

因为一个 inode 可以被多个名字指向------这正是硬链接能存在的原因。

复制代码
   "a.txt" ──┐
             ├──► 同一个 inode(同一份内容)
   "b.txt" ──┘

这个"名字与内容分离"的设计,就是 dentry 存在的意义:dentry 负责"名字"和"路径结构",inode 负责"文件本身"。

有关文件系统部分更详细的说明可以跳转链接🧐Linux 文件系统:磁盘结构、软硬链接、库文件与 ELF 格式-CSDN博客

8.4.5 把整条链路串起来

现在,把 open("wwwroot/index.html") 这行代码在内核里的完整旅程画出来:

复制代码
   用户态:open("wwwroot/index.html", O_RDONLY)
        │
        ▼
┌──────────────────────────────────────────────────────────────┐
│  ①  VFS 层:路径解析                                          
│      在 dcache 里找 "wwwroot" 的 dentry                       
│        → 命中?直接用                                          
│        → 没命中?读目录数据块,建一个 dentry,挂进 dcache      
│      再找 "index.html" 的 dentry                              
├──────────────────────────────────────────────────────────────┤
│  ②  拿到 dentry → 顺着 d_inode 拿到 inode                     
│      inode 里记着:文件多大(i_size)、内容在哪些数据块上       
├──────────────────────────────────────────────────────────────┤
│  ③  分配一个 struct file                                       
│      f_pos      = 0         (从文件开头开始读)               
│      f_mode     = O_RDONLY  (只读)                           
│      f_op       = 该文件系统的函数表                           
│      f_inode    = 刚才那个 inode                               
├──────────────────────────────────────────────────────────────┤
│  ④  分配一个 fd(数组下标),把 fd 和 struct file 的对应关系    
│      记进当前进程的 files_struct 里                            
│      → open() 把 fd 返回给用户态                               
└──────────────────────────────────────────────────────────────┘
        │
        ▼
   用户态:read(fd, buf, size)
        │
        ▼
┌──────────────────────────────────────────────────────────────┐
│  ⑤  fd → struct file → f_op->read_iter() → ext4 的实现        
│  ⑥  ext4 根据 inode 里的块指针,算出这些数据在磁盘的哪些扇区   
│  ⑦  向块设备层发起 IO 请求(可能命中页缓存,那就省一次磁盘 IO)
│  ⑧  数据从磁盘(或页缓存)拷回用户态缓冲区 buf                  
└──────────────────────────────────────────────────────────────┘

从这里再回头看 HTTP,你会发现一个很有意思的层次关系:

复制代码
     HTTP  请求  ──►  一个 URI 字符串
                       │
                       ▼
     HttpRequest 里的 _uri  ──►  加上 wwwroot 前缀
                       │
                       ▼
     open()  ──►  路径解析(dentry / dcache)  ──►  inode
                       │
                       ▼
     read()  ──►  数据块  ──►  磁盘扇区(或页缓存)
                       │
                       ▼
     读到的字节  ──►  放进 HttpResponse 的 _text
                       │
                       ▼
     HTTP  响应  ──►  发回给浏览器

一条 HTTP 请求,起点是浏览器地址栏里的几个字符,终点是磁盘上的一块数据。这就是这条完整链路。

8.5 承上启下

好,现在服务器手里有文件内容了:

复制代码
std::string body = "......index.html 的全部内容......";

剩下的问题只有一个:怎么把它送回给客户端?

而且不只是"送回内容"这么简单。我们还欠客户端一些信息:

复制代码
① 这次请求成功了吗?(还是失败了?)
② 内容多长?
③ 内容是什么类型的?(HTML?图片?)
④ 客户端该怎么理解它?

这些信息,就是下一章"HTTP 响应报文"要回答的内容。


第九章 HTTP 响应:服务器怎么答

9.1 一条铁律:不论成败,必须有应答

在讲格式之前,先记住一条铁律。原文是这么说的:

无论成功还是失败、如果网络没有问题,服务器没有挂掉,都必须有应答。

注意这句话里的两个前提条件,它们把"必须有应答"的边界划得很清楚:

复制代码
条件一:网络没有问题
   → 如果网络断了,应答发不出去,那当然不算违反这条规则

条件二:服务器没有挂掉
   → 如果服务器进程直接崩了,连接被内核关闭,
     客户端会收到一个 RST 或 FIN,这也"算一种应答"

在满足这两个条件的前提下,"没有应答"是严重错误。

因为客户端会一直等:

复制代码
   客户端                                 服务器
      │                              
      │  请求 ──────────────────────►
      │                               
      │  等待......                        (处理中......)
      │  等待......                       
      │  等到超时                     
      │  等到用户以为网站挂了          

一个 HTTP 服务器最不应该做的事,就是"收到了请求但什么都不回"。

"必须有应答"这句话还有一个更深层的含义:

即使请求是错的、即使文件不存在、即使服务器内部出错了,也要回一个明确的响应,告诉客户端"出了什么问题"。

这就是状态码存在的意义------它是"失败也要好好说话"的机制。

9.2 响应报文的四段结构

现在看响应报文的格式。它和请求报文的格式几乎是镜像的:

复制代码
┌──────────────────────────────────────────────────────────┐
│  状态行                                                  
│  ┌──────────┬──────┬────────┬──────┬───────────┬───────┐ 
│  │ HTTP版本   空格    状态码     空格   状态码描述    \r\n 
│  └──────────┴──────┴────────┴──────┴───────────┴───────┘ 
├──────────────────────────────────────────────────────────┤
│  响应报头                                                
│  ┌──────────────────────────────┬──────────────────────┐ 
│  │  Key: [空格] Value              \r\n                 
│  ├──────────────────────────────┼──────────────────────┤
│  │  Key: [空格] Value              \r\n                
│  ├──────────────────────────────┼──────────────────────┤ 
│  │  ...(可以有任意多行)            \r\n                 
│  └──────────────────────────────┴──────────────────────┘ 
├──────────────────────────────────────────────────────────┤
│  空行                                                    
│  ┌──────────────────────────────────────────────────────┐
│  │  \r\n            ★ 同样是不可省略的分隔符             
│  └──────────────────────────────────────────────────────┘
├──────────────────────────────────────────────────────────┤
│  响应正文                                                
│  ┌──────────────────────────────────────────────────────┐
│  │  DATA  (HTML / CSS / 图片二进制 / ...)              
│  └──────────────────────────────────────────────────────┘
└──────────────────────────────────────────────────────────┘

和请求报文对照着看,对应关系一目了然:

复制代码
      请求报文                    响应报文
   ──────────────────      ──────────────────
    请求行                   状态行
    (方法 URI 版本)         (版本 状态码 描述)
        ↕                        ↕
    请求报头                 响应报头
        ↕                        ↕
    空行                     空行
        ↕                        ↕
    请求正文                 响应正文

第一段换了个名字(请求行 → 状态行),内容全变了,但整体骨架完全一致。

这正是同一个协议必然的设计:客户端和服务器的解析代码,结构上可以高度复用。

原文对响应正文有一句话,讲清楚了"正文里装的是什么":

正文:客户端申请的资源,html、css、图片、视频、音频等,以正文的形式返回来。

注意"客户端申请的资源"这个说法 ------正文的内容不是服务器随便给的,它必须是客户端在 URI 里点名要的那个东西。

这体现了 HTTP 的本质:

HTTP 就是"你要什么,我给你什么"------一个取文件的协议。

9.3 状态行:版本 + 状态码 + 状态码描述

状态行里也有三段,用空格分开:

复制代码
HTTP/1.1 200 OK
   ↑      ↑   ↑
   │      │   └── 状态码描述(给人看的)
   │      └────── 状态码(给机器看的)
   └───────────── HTTP 版本(服务器自己的版本)
9.3.1 HTTP 版本:服务端的版本必须 ≥ 客户端

这个字段和请求行里的版本有一个关键区别,原文特意点出来了:

HTTP版本:服务端是什么版本的!服务端的版本必须≥客户端

对照第五章 5.3.3 里请求的版本:

复制代码
请求行里的版本   →  是【客户端】的版本(客户端自报家门)
状态行里的版本   →  是【服务端】的版本(服务器自报家门)

约束条件         →  服务端版本 ≥ 客户端版本

为什么必须是"≥",而不是"相等"?

因为如果服务器的版本低于客户端,它就不具备客户端所期待的能力。

举一个具体的例子:

复制代码
客户端说:"我是 HTTP/1.1,我要求长连接(keep-alive)"
服务器说:"我是 HTTP/1.0"
           ↑ 1.0 默认是短连接,可能根本不支持长连接
           → 客户端的期待落空了

所以服务器必须"至少和客户端一样新",才能满足客户端的期待。

而反过来是允许的:

复制代码
客户端说:"我是 HTTP/1.0"
服务器说:"我是 HTTP/1.1"
           ↑ 服务器更新,它可以降级用 1.0 的方式和客户端说话
           → 完全没问题 ✅

这就是第五章讲的"向后兼容"在版本字段上的体现。

9.3.2 状态码与状态码描述:一个给机器,一个给人

原文对这两个字段的区分,说得非常清楚:

状态码:给机器看的数字 状态码概述:OK、Not Found ------ 给人看的

复制代码
HTTP/1.1 200 OK
         ↑   ↑
         │   └─ 状态码描述:给人看的(人类可读的文字)
         └───── 状态码:给机器看的(程序判断用的数字)

为什么要有两份?

因为它们服务两个完全不同的读者:

复制代码
    读者一:程序(浏览器、爬虫、前端 JS)
      它判断的是数字:  if (status == 200) { 正常显示 }
                        if (status == 404) { 显示"页面不存在" }

    ★ 程序不认文字,只认数字。因为文字可以被翻译、被修改、
      甚至可以写错,而数字是稳定的。

    读者二:人(开发者、运维)
      他看的是文字:"OK"、"Not Found"
      这些描述在调试时一眼就能看懂,不用去查表

      你在浏览器 F12 里看到的、在 curl 输出里看到的,
      都是这两者一起呈现的

一个实战经验:程序判断永远只看状态码,不要解析描述文字。

因为描述文字是可以被服务器自定义的:

复制代码
HTTP/1.1 404 Not Found
HTTP/1.1 404 找不到这个页面          ← 某些服务器会改成中文

这两行的状态码完全一样,但描述不同。只会认数字的程序,两种都能处理。

9.3.3 状态码什么时候用

原文有一个很实在的提醒:

假设你请求的文件内容不存在呢?→ 状态码

也就是说,状态码不是装饰,它是错误处理的落点。

在 HttpResponse::Build() 里(下一章要讲),第一个要做的判断就是:

复制代码
① 文件找得到吗?
    找得到  →  200 OK,正文 = 文件内容
    找不到  →  404 Not Found,正文 = 一个错误页面(或空)

② 用户有权限吗?
    有  →  继续
    没有  →  403 Forbidden

③ 服务器内部出错了吗?
    出错  →  500 Internal Server Error

每一个分支,都对应一个状态码。这就是为什么状态码必须存在------它是服务器"把话说清楚"的方式。

9.4 承上启下

到这里,响应报文的格式也讲完了。

现在我们把镜头切到服务端的代码实现上------如果把"造一个响应"这件事写成代码,它需要哪些字段?以及最难的两个地方在哪里?

这就是下一章的主题:HttpResponse 与它的 Build() 函数。


第十章 HttpResponse:Build() 的两大难点

10.1 先有需求,再有字段

还是那个方法:先说需求,再说字段。

需求分析:服务器要发回一条响应,需要记住哪些信息?

把第九章的响应报文结构拿过来看:

复制代码
HTTP/1.1 200 OK                 ← 状态行:三段信息
Content-Type: text/html         ← 报头:不定数量
Content-Length: 1024
                                ← 空行
<html>...</html>                ← 正文

和请求的结构一一对应,所以字段也一一对应:

复制代码
class HttpResponse
{
public:
    // ★ 核心接口:根据 Request 来完善自己
    void Build(HttpRequest &req);

    // 难点一:target_file 的获取(从 URI 来)
    // 难点二:如何分类并查看我们的目标文件的内容,放在 _text 中(ReadFile())

private:
    std::string _version;       // ① HTTP 版本:HTTP/1.1
    int         _code;          // ② 状态码:200 / 404 ...
    std::string _code_desc;     // ③ 状态码描述:OK / Not Found ...
    std::unordered_map<std::string, std::string> _header;  // ④ 响应报头
    std::string _blank_line;    // ⑤ 空行
    std::string _text;          // ⑥ 响应正文(文件内容都在这)
};

把它和 HttpRequest 并排放在一起,一眼就能看出这个协议的设计有多对称:

复制代码
      HttpRequest                   HttpResponse
   ─────────────────────      ─────────────────────
    _method  (方法)            _code     (状态码)
    _uri     (URI)             _code_desc(描述)
    _version (版本)            _version  (版本)
   ─────────────────────      ─────────────────────
    _header  (请求报头)        _header   (响应报头)
    _blank_line(空行)          _blank_line(空行)
    _text    (请求正文)        _text     (响应正文)

第一行的三个字段,都是"行"里的三段信息;下面三个字段,都是"报头/空行/正文"。这就是"同一个协议的请求和响应"在数据结构上的必然对称。

注意 _code 用的是 int 而不是 std::string。

这和 _method 用 string 的选择正好相反,理由是:

复制代码
_method  →  string   :因为方法名是【开放集合】,且代码里主要靠"原样传递"
_code    →  int      :因为状态码是【封闭的数字】,且代码里主要靠"数值判断"

    if (_code == 200) { ... }
    if (_code >= 400) { ... }     ← int 才能这么写

又一次"根据使用模式选类型"。

10.2 难点一:target_file 的获取

原文在这个类上标注了两个难点:

// 难点1 target_file 的获取(URI) 得到目标文件 // 难点2 如何分类并查看我们的目标文件的内容放在 _text 中。(ReadFile())

难点一,我们在第八章已经解决过了。 这里把完整的思路复述一遍:

复制代码
void HttpResponse::Build(HttpRequest &req)
{
    // 第一步:拿到客户端要的资源(URI)
    std::string target_file = req.GetUri();

    // 第二步:把 URI 翻译成服务器本地路径
    if (target_file == "/") {
        target_file += g_first_page;      // "/" → "/index.html"
    }
    target_file = g_wwwroot + target_file;  // "/index.html" → "wwwroot/index.html"

    // 此时 target_file 就是"目标文件在服务器上的真实路径"
}

为什么说它是"难点"?

因为它不是一次简单的字符串拼接,而是完成了两次语义转换:

复制代码
   客户端视角                   服务器视角
   ──────────                  ──────────
   "/"  ──────────────────────►  "wwwroot/index.html"
        ① 补全默认首页
        ② 加上 Web 根目录前缀

   ★ 这两步,把"一个逻辑上的请求"变成了"一个物理上的文件"

而这正是 8.4 节那条完整链路的起点:

复制代码
   URI 字符串
      │  ← 难点一:翻译成真实路径
      ▼
   "wwwroot/index.html"
      │  ← 难点二:读进内存
      ▼
   _text

10.3 难点二:怎么把文件内容读进 _text

拿到路径之后,要做的事看起来很直白:

复制代码
把 wwwroot/index.html 这个文件的内容,读到 _text 里去

但真写起来,会发现一堆问题:

复制代码
问题一:文件有多大?
        我得先知道大小,才能分配足够的空间

问题二:怎么读?
        一次 read 能读完吗?还是得循环读?

问题三:读不到怎么办?
        文件不存在?没权限?是个目录?

问题四:读进来之后呢?
        浏览器拿到的是一堆字节,它怎么知道这是 HTML 还是图片?

问题一、二、三属于"怎么正确读文件",用的是我们在文件 IO 那一章学过的标准手法:

复制代码
// ReadFile 的典型实现思路(伪代码)
std::string ReadFile(const std::string &path)
{
    // ① 打开文件
    int fd = open(path.c_str(), O_RDONLY);
    if (fd < 0) {
        return "";              // ← 文件不存在/没权限,返回空
    }                           //   ★ 这个空返回,就是后面 404 的触发点!

    // ② 先拿到文件大小(用于 stat 之后分配缓冲区)
    struct stat st;
    fstat(fd, &st);
    size_t size = st.st_size;   // ← 这个值,最终会成为 Content-Length!

    // ③ 按大小读(注意:read 可能读不满,要循环)
    std::string content;
    content.resize(size);
    size_t total = 0;
    while (total < size) {
        ssize_t n = read(fd, &content[total], size - total);
        if (n <= 0) break;       // 出错或读到尾
        total += n;
    }

    // ④ 一定要关!(fd 是有限资源,不关会泄漏)
    close(fd);
    return content;
}

这里有三个细节值得单独点出来。

细节一:st.st_size 就是 Content-Length 的来源。

复制代码
   内核的 inode->i_size
        │
        ▼
   stat 结构体的 st_size
        │
        ▼
   响应报头里的 Content-Length

一条从磁盘到报头的完整链路,这里终于闭上了环。

细节二:read 可能读不满。

这是 TCP 那一章留下的纪律。read 的返回值是"实际读到了多少字节 ",它可能小于你请求的字节数。所以必须用 while 循环读到够为止。

细节三:文件读不到时返回空字符串------这就是 404 的触发点。

复制代码
   ReadFile 返回 ""
        │
        ▼
   _text 是空的
        │
        ▼
   ★ 服务器判断:"文件没读到,说明不存在"
        │
        ▼
   _code = 404;  _code_desc = "Not Found";

所以状态码不是在"事情发生后补一个数字",而是从代码的执行路径里自然长出来的。

问题四的答案,在下一章:Content-Type。

原文在这里有一个细节提醒:

// 应答部分不必带 Content Length,浏览器会自己做这件事

这句话的意思是:在我们自己实现的简易 HTTP 服务器里,即使响应报头里不写 Content-Length,浏览器往往也能自己处理 ------因为短连接模式下,服务器发完就 close,浏览器读到 EOF 就知道"内容结束了"。

但这不是规范推荐的做法。 在长连接模式下(Connection: keep-alive),必须 带 Content-Length 或 Transfer-Encoding: chunked,否则浏览器根本不知道"这一条响应到哪里结束",会一直等下去。

所以:写代码时老老实实带上 Content-Length,是最稳妥的选择。

用一句话概括 Build() 的全过程:

复制代码
   Build(req):
     ①  从 req 里取出 _uri
     ②  _uri 翻译成真实路径 target_file      ← 难点一
     ③  ReadFile(target_file) 读进 _text     ← 难点二
     ④  根据读取结果决定 _code / _code_desc
     ⑤  根据 _text 的内容决定 Content-Type
     ⑥  填充 _header
     ⑦  拼成一个完整的响应字符串,发回去

10.4 承上启下

到这里,Build() 的框架已经清楚了。但第 ④ 步"根据结果决定状态码"这件事,我们还欠一个完整的交代。

状态码到底有多少个?各自什么含义?什么时候该用哪个?

下一章,我们把状态码这张表完整地过一遍。


第十一章 状态码:给机器看的数字,给人看的描述

11.1 五大类:看一眼第一位就够

HTTP 状态码一共有几十个,但你永远不需要死记硬背------因为它们可以被"第一位数字"一刀切成五类。

复制代码
┌──────┬───────────────┬──────────────────────────────────────┐
│ 类别      名称                        含义                     
├──────┼───────────────┼──────────────────────────────────────┤
│ 1XX  │ Informational    信息性状态码 ------ 接收的请求正在处理     
│      │ 信息性状态码                                         
├──────┼───────────────┼──────────────────────────────────────┤
│ 2XX  │ Success          成功状态码 ------ 请求正常处理完毕         
│      │ 成功状态码                                           
├──────┼───────────────┼──────────────────────────────────────┤
│ 3XX  │ Redirection      重定向状态码 ------ 需要进行附加操作        
│      │ 重定向状态码      以完成请求                            
├──────┼───────────────┼──────────────────────────────────────┤
│ 4XX  │ Client Error     客户端错误状态码 ------ 服务器无法          
│      │ 客户端错误        处理请求                              
├──────┼───────────────┼──────────────────────────────────────┤
│ 5XX  │ Server Error      服务器错误状态码 ------ 服务器处理          
│      │ 服务器错误         请求出错                              
└──────┴───────────────┴──────────────────────────────────────┘

这张表有一个非常好用的记忆法:看第一位数字,就足以定位到责任方。

复制代码
   1xx / 2xx / 3xx   →  一切正常,或者只是需要再走一步
   4xx               →  ★ 是【你的】问题(客户端)
   5xx               →  ★ 是【我的】问题(服务器)

这个"4 是你、5 是我"的划分,是排查问题时最重要的直觉。

复制代码
用户:"网站打不开!"
你打开页面看到 404  →  4 开头 → 是客户端(用户)的错,链接写错了
你打开页面看到 500  →  5 开头 → 是服务端的错,去看服务器日志

原文还把"最常见的几个"单独拎了出来:

最常见的状态码,比如 200(OK)、404(Not Found)、403(Forbidden)、302(Redirect 重定向)、504(Bad Gateway)

这五个是日常调试里出场率最高的。

11.2 常考常查的十几个状态码

现在看完整的表。这张表不需要背,需要的时候回来查就行------但每一行的"应用样例"值得读一遍,因为它们把状态码和真实场景绑在了一起。

复制代码
┌──────┬────────────────────────┬──────────────────────────────────────┐
│状态码           含义                      应用样例                   
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 100  │ Continue               │ 上传大文件时,服务器告诉客户端可以继续  
│      │                        │ 上传                                 
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 200  │ OK                     │ 访问网站首页,服务器返回网页内容        
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 201  │ Created                │ 发布新文章,服务器返回文章创建成功的信息 
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 204  │ No Content             │ 删除文章后,服务器返回"无内容"表示      
│      │                        │ 操作成功                              
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 301  │ Moved Permanently      │ 网站换域名后,自动跳转到新域名;        
│      │                        │ 搜索引擎更新网站链接时使用              
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 302  │ Found 或 See Other        用户登录成功后,重定向到用户首页        
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 304  │ Not Modified           │ 浏览器缓存机制,对未修改的资源返回       
│      │                        │ 304 状态码                            
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 400  │ Bad Request            │ 填写表单时,格式不正确导致提交失败       
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 401  │ Unauthorized           │ 访问需要登录的页面时,未登录或认证失败   
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 403  │ Forbidden              │ 尝试访问你没有权限查看的页面            
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 404  │ Not Found              │ 访问不存在的网页链接                   
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 500  │ Internal Server Error  │ 服务器崩溃或数据库错误导致页面无法加载   
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 502  │ Bad Gateway            │ 使用代理服务器时,代理服务器无法从上游   
│      │                        │ 服务器获取有效响应                     
├──────┼────────────────────────┼──────────────────────────────────────┤
│ 503  │ Service Unavailable    │ 服务器维护或过载,暂时无法处理请求       
└──────┴────────────────────────┴──────────────────────────────────────┘

这张表里有几个点值得停下来想想。

其一:204 No Content 说明了一个重要的观念。

成功 ≠ 有内容返回。

删除一篇文章,操作成功了,但确实没有什么东西需要返回给客户端 。这时候用 204 表达"成功了,但正文是空的"。

其二:401 和 403 是两回事,很多人会混。

复制代码
401 Unauthorized  →  "你没登录 / 你登录失败了" → 请先去登录
403 Forbidden     →  "你登录了,但你没权限"   → 登录也没用

★ 一个简单的判断法:
   401 → 缺的是"身份"
   403 → 缺的是"权限"

其三:502 和 503 也是两回事。

复制代码
502 Bad Gateway       →  "我(代理)后面那个服务器出问题了" → 不是我的锅
503 Service Unavailable → "我自己出问题了(维护/过载)"      → 是我的锅

★ 用一个比喻:
   502 → 你是前台,你给后厨打电话,后厨没接
   503 → 你自己病了,今天不营业

其四:304 揭示了一个我们还没讲的重要机制------缓存。

浏览器缓存机制,对未修改的资源返回 304 状态码

这个机制是这样的:

复制代码
   浏览器                                             服务器
      │                                        
      │  "我之前存过 index.html,时间是 X"       
      │ ──────────────────────────────────────►
      │                                          检查:这之后改过吗?
      │                                               没改过 ↓
      │  "304 Not Modified,你不用重新下载"      
      │ ◄──────────────────────────────────────
      │                                        
      │  直接用自己的本地缓存 ✅                 

服务器连正文都不用发,只回一个 304------这就是为什么第二次访问同一个网站会明显更快。

11.3 重定向:301 与 302 的分水岭

原文对 3xx 这一类的讲解最详细,因为它涉及一个必须分清的区别:

3xx 状态码: Moved Permanently(永久重定向)------ 更改客户端的认识 Found 或 See Other(临时重定向)------ 不会更改客户端的认识

"更改客户端的认识"这句话是理解重定向的关键。

先看重定向本身的机制。原文的描述是:

在访问时,服务端返回 302 告诉新的 Location 地址供其跳转 客户端返回 301,给 Location 通知客户端更改本地标签

机制的核心是 Location 报头------它和状态码配合使用:

复制代码
HTTP/1.1 302 Found
Location: https://www.new-site.com/index.html
          ↑
      服务器在说:"你要的东西不在我这了,去这个地址拿"

浏览器收到这个响应后,会"自动"再发一次请求到 Location 指定的地址。 用户在地址栏里看到的,就是页面"自己跳过去"了。

那么 301 和 302 的区别在哪?就在"永久"和"临时"这两个词上。

复制代码
┌──────────┬────────────────────┬──────────────────────────────────┐
│          │  301 永久重定向            302 临时重定向                   
├──────────┼────────────────────┼──────────────────────────────────┤
│ 语义       这个资源【永远】         这个资源【暂时】换了个位置        
│            换到新地址了                                          
├──────────┼────────────────────┼──────────────────────────────────┤
│ 客户端      ★ 应该记住新地址,     ★ 不应该改,还是用老地址          
│ 的行为      以后直接用新的          下次访问还是发到老地址            
├──────────┼────────────────────┼──────────────────────────────────┤
│ 典型场景    网站换域名了            用户登录成功后跳转到首页          
├──────────┼────────────────────┼──────────────────────────────────┤
│ 对搜索      搜索引擎会把新地址      搜索引擎不会改,                
│ 引擎的影响  当成正式地址,          原来的链接权重保持不变           
│          │ 老的链接权重转移过去                                     
└──────────┴────────────────────┴──────────────────────────────────┘

把这个区别讲透,最好用一个真实的场景。

场景一:301 用在哪?网站换域名。

复制代码
老域名:www.old-site.com
新域名:www.new-site.com

用户访问 www.old-site.com  →  服务器回 301 + Location: www.new-site.com
                          →  浏览器把结果【记下来】
                          →  ★ 下次用户再输 www.old-site.com,
                             浏览器直接就去 www.new-site.com 了,连请求都不发

搜索引擎那边也一样 → 权重(SEO) 转移到新域名,老域名平稳退役

这就是原文说的"搜索引擎更新网站链接时使用"。

场景二:302 用在哪?登录成功后的跳转。

复制代码
用户在 /login 提交账号密码
        →  验证通过
        →  服务器回 302 + Location: /home
        →  浏览器跳到 /home

★ 重点来了:这里【必须】用 302,不能用 301!

   因为"/login 成功后去 /home"这件事是【这次】的行为,
   不是"/login 这个地址永远变成了 /home"。

   如果用 301,浏览器会把 "/login → /home" 这个映射【永久缓存】下来。
   于是下次用户想【重新登录】时,浏览器直接把他送到 /home,
   根本不去 /login ------ 用户就再也登不了新账号了!

这是一个真实世界里的经典 bug,也是 301/302 必须分清的最实际的理由。

一句话总结:

301 是"我搬家了,以后都别来老地址了" 302 是"我今天不在家,你去隔壁找我,明天还来老地方"

补充一点:Location 这个报头不只用于 3xx。

原文对它的描述是:

Location:搭配 3xx 状态码使用,告诉客户端接下来要去哪里访问

"搭配 3xx 使用"是它的主要用途 ,这也是它的定义所在------Location 就是"下一个地址"。

11.3.1 动手实践:用 302 做一个"错误页跳转"

理解了 302,我们就能自己实现一个很常见的需求:

需求:用户访问了一个不存在的页面,不要只丢一个干巴巴的 404 Not Found,而是把他【跳转】到一个漂亮的错误页面。

代码长这样:

复制代码
void SendNotFound(HttpResponse &resp) {
    // ★ 第一步:告诉浏览器"去 /404.html 看看"
    resp._code = 302;                          // 302,不是 301!
    resp._code_desc = "Found";
    resp._header["Location"] = "/404.html";    // ★ 去哪里

    // ★ 第二步:302 响应通常也带一个简短的正文
    resp._text = "<h1>页面不存在,正在跳转...</h1>";
}

浏览器收到之后的动作:

复制代码
   ① 浏览器看到 302 + Location: /404.html
   ② ★ 立刻【自动】发起第二次请求:GET /404.html
   ③ 服务器返回 404.html 的内容
   ④ 地址栏【变成】 http://host:8080/404.html

这里有一个必须想清楚的取舍------为什么用 302 而不是在 404 响应里直接返回那段 HTML?

复制代码
┌────────────────────────────────────────────────────────────────┐
│  方案 A:直接返回 404 + 错误页正文                                
│                                                                 
│     GET /not-exist  →  404 Not Found                            
│                        正文 = <h1>页面不存在</h1>                 
│                                                                 
│    ★ 优点:一次请求搞定,最快                                  
│    ★ 缺点:错误页面的 HTML 必须【硬编码在 C++ 里】                
│            想改文案就要重新编译服务器                            
│            而且每个 404 都重复传一遍 HTML                        
├────────────────────────────────────────────────────────────────┤
│  方案 B:302 跳转到 /404.html                                    
│                                                                 
│     GET /not-exist  →  302 Found, Location: /404.html          
│     GET /404.html   →  200 OK, 正文 = 404.html 的内容           
│                                                                 
│    ★ 优点:错误页面变成一个【普通的静态文件】,                  
│            放在 wwwroot 里,改文案只要改文件,不用重编译         
│    ★ 缺点:多一次请求往返                                        
└────────────────────────────────────────────────────────────────┘

方案 B 的取舍,其实就是我们前面反复见到的那个思路:把一个"硬编码在代码里的东西"变成一个"可以由数据驱动的资源"。

这和第十八章的服务注册表是同一个思想------ 都是"让变化的部分待在代码外面"。

⚠️ 但是这里有一个非常容易被忽略的问题:用 302 做错误页,浏览器最终停在 /404.html,地址栏里显示的不是用户原本访问的那个地址。

而且严格来说,搜索引擎会认为用户的错误页"跳转成功"了,不再报告 404 状态。

正确的做法是:返回 404 状态码,同时在正文里给出错误页面 (方案 A)------或者用服务端内部转发 (服务器自己把 /404.html 的内容读出来、以 404 状态码返回),而不是让浏览器再发一次请求(方案 B)。

我们这里用 302 演示,是因为它把"重定向是怎么工作的"讲得最清楚;但在真实项目里,错误页应该用 404 + 错误页正文。

11.4 为什么状态码不能自定义

原文在这里有一句话,说清了一个重要的工程纪律:

状态码和描述非自定义,是约定俗成的,直接 switch case 就好。

"约定俗成"是关键词。 状态码非某个程序员拍脑袋定的,它是全世界共同遵守的一份清单。

为什么必须统一? 因为在 HTTP 的世界里,客户端和服务端是两个完全陌生的程序:

复制代码
   你的服务器  ←→  别人的浏览器
   你的服务器  ←→  别人的爬虫
   你的服务器  ←→  别人的 API 调用方

如果状态码可以自定义,这套协作立刻就崩了:

复制代码
服务器心想: "我要表示'文件不存在',就用 999 吧"
          → 浏览器收到 999:"这什么玩意?" → 当作未知错误处理
          → 爬虫收到 999:"404 的判断条件不成立" → 当作成功,把错误页存进了索引

所以状态码的价值,恰恰在于它是"事先约定好的共同语言"。

这句话对写代码的直接指导是:

复制代码
// ✅ 正确做法:用标准状态码 + 标准描述
_code = 404;
_code_desc = "Not Found";

// ❌ 千万别这么做
_code = 404;
_code_desc = "你要的东西我这儿没有";   // 描述可以自定义吗?

严格说:_code 绝对不能自定义,_code_desc 虽然允许自定义,但强烈建议用标准描述。

因为:

复制代码
_code       →  ★ 程序判断全靠它,必须全世界统一
_code_desc  →  主要是给人看的,自定义的影响较小
               但用标准描述,能让开发者一眼认出、不用查表

原文说"直接 switch case 就好",就是这个意思:

复制代码
// 状态码到描述的映射,就是一个简单的查表
switch (_code) {
    case 200: _code_desc = "OK";                    break;
    case 301: _code_desc = "Moved Permanently";     break;
    case 302: _code_desc = "Found";                 break;
    case 400: _code_desc = "Bad Request";           break;
    case 403: _code_desc = "Forbidden";             break;
    case 404: _code_desc = "Not Found";             break;
    case 500: _code_desc = "Internal Server Error"; break;
    // ...
}

这段代码不长,但它体现了一个原则:约定俗成的东西,就别自作主张。

11.5 承上启下

状态码讲完了,响应也就能完整地构造出来了。现在,一条响应发出去,长这样:

复制代码
HTTP/1.1 200 OK
Content-Type: text/html
Content-Length: 1024

<html>...</html>

但这里还剩最后一个问题没回答:

浏览器收到这堆字节,它怎么知道这是一段 HTML,还是一张图片?如果是图片,是 PNG 还是 JPEG?

这就是 Content-Type 要解决的问题,也是下一章的主题。


第十二章 正文、Content-Type 与静态资源

12.1 什么是"静态资源"

先给一个概念下定义。原文是这么说的:

图片、视频、音频、css、js ------► 静态资源(你只需要获取即可) 音频、视频这里也视为静态资源,因为它们只是文件!

"你只需要获取即可"------这七个字点出了静态资源的本质。

复制代码
静态资源的特征:
   ① 内容是【预先准备好】的,早就躺在磁盘上
   ② 服务器【不需要计算】,找出来发回去就行
   ③ 对任何人都返回【相同】的内容

典型的静态资源:
   .html  网页本体
   .css   样式表
   .js    JavaScript 脚本
   .png / .jpg / .gif / .webp    图片
   .mp4 / .avi                   视频
   .mp3 / .wav                   音频
   .json / .txt / .xml           数据与文本
   .woff / .ttf                  字体

原文特意加了一句话解释"为什么音视频也算静态资源",这其实是在纠正一种常见误解:

音频、视频这里也视为静态资源,因为它们只是文件!

很多人觉得"视频这么复杂,肯定不算静态资源吧"。不,判断标准不是"复不复杂",而是"要不要现场计算" ------一个 .mp4 文件,不也就是磁盘上的一串字节吗?服务器把它读出来发走就行了。

这个"静态"的划分标准非常重要,因为它决定了服务器的整个处理路径:

复制代码
静态资源  →  找到文件 → 读出来 → 发回去           (简单,无状态)
动态内容  →  执行代码 → 查数据库 → 算出来 → 发回去  (复杂,有状态)

前者就是这一章的内容,后者在 12.5 节。

12.2 浏览器怎么知道收到的是一张图还是一段 HTML

现在问一个非常基础的问题:

一段字节,<html><body>hello</body></html>,浏览器怎么知道要把它当网页渲染?

另一段字节,\x89PNG\r\n\x1a\n...,浏览器怎么知道要把它当图片显示?

答案是:靠 Content-Type 报头。

原文对这个问题的回答很直接:

浏览器怎么知道收到的 response 正文部分具体是什么格式的文件? Content-Type:数据类型(text、html 等)(Content-Type 对照表)

复制代码
HTTP/1.1 200 OK
Content-Type: text/html          ← ★ 这一行,告诉浏览器"当成网页处理"

HTTP/1.1 200 OK
Content-Type: image/png          ← ★ 这一行,告诉浏览器"当成 PNG 图片显示"

为什么必须要有这个字段?

因为字节本身不带自解释性:

复制代码
   "12345"  是什么?  数字?字符串?还是文本文件的内容?
   ★ 字节流本身无法回答这个问题,必须由【外部信息】来标注

   这个"外部信息",就是 Content-Type

常见类型对照表:

复制代码
┌────────────────────────────────────────┬──────────────────────────┐
│            Content-Type                           说明             
├────────────────────────────────────────┼──────────────────────────┤
│  text/html; charset=utf-8              │  HTML 网页(最常见的)    
│  text/css                              │  CSS 样式表              
│  text/plain                            │  纯文本                  
│  application/javascript                │  JavaScript              
│  application/json                      │  JSON 数据               
│  application/xml                       │  XML                     
│  image/png                             │  PNG 图片                
│  image/jpeg                            │  JPEG 图片               
│  image/gif                             │  GIF 图片                
│  image/svg+xml                         │  SVG 矢量图              
│  video/mp4                             │  MP4 视频                
│  audio/mpeg                            │  MP3 音频                
│  application/octet-stream              │  ★ 未知的二进制流(兜底) 
└────────────────────────────────────────┴──────────────────────────┘

注意 text/html; charset=utf-8 里的分号。

它是参数 ,用分号和主类型隔开。charset=utf-8 告诉浏览器"这段 HTML 用 UTF-8 编码"------否则中文会变成乱码。

这是实践中最容易踩的坑之一:Content-Type 写对了、但漏了 charset,中文网页就是乱码。

12.2.1 服务器怎么知道该填哪个?(靠后缀名)

现在换到服务器这一侧:服务器手里有一个文件 index.html,它怎么知道该填 text/html?

原文的答案是:

你怎么知道对应的返回资源是什么格式的(通过资源名称的后缀区分)

"通过后缀区分"------这是最简单也最通用的办法:

复制代码
// 从文件后缀推断 Content-Type(思路示意,实际实现用 map 查表更清晰)
std::string GetContentType(const std::string &path)
{
    if (EndsWith(path, ".html") || EndsWith(path, ".htm"))
        return "text/html";              // 网页
    if (EndsWith(path, ".css"))
        return "text/css";               // 样式表
    if (EndsWith(path, ".js"))
        return "application/javascript"; // 脚本
    if (EndsWith(path, ".png"))
        return "image/png";              // PNG 图片
    if (EndsWith(path, ".jpg") || EndsWith(path, ".jpeg"))
        return "image/jpeg";             // JPEG 图片
    if (EndsWith(path, ".mp4"))
        return "video/mp4";              // 视频
    if (EndsWith(path, ".mp3"))
        return "audio/mpeg";             // 音频
    // ...
    return "application/octet-stream";   // ★ 兜底:不认识的,当二进制流
}

两个细节值得注意。

细节一:后缀名 → Content-Type 的映射,本质上是一张查找表。

真实的服务器(比如 Nginx)里有一份配置文件(mime.types),里面就是如下的对应关系:

复制代码
types {
    text/html    html htm shtml;
    text/css     css;
    image/png    png;
    image/jpeg   jpeg jpg;
    ...
}

"通过后缀区分"就是这张表的全部逻辑。

细节二:一定要有兜底值。

复制代码
不认识的类型  →  application/octet-stream
                 ↑ 意思是"我也不知道这是什么,你当二进制流处理吧"

如果没有兜底,遇到 .xyz 这种没见过的后缀,程序可能会返回空字符串或者崩溃,导致浏览器收到一个没有 Content-Type 的响应------它就只能靠猜了。

这也解释了原文那句关于"网页显示成花花绿绿的图案"的话:

假设我们的前端网页是储存在一个 html 后缀的文件中,那么请求端得到的文件在网页端显示时,就会被浏览器处理成花花绿绿的图案

"花花绿绿的图案"就是浏览器把 HTML 渲染出来的效果。 而让浏览器知道"要渲染"而不是"要显示源码"的,正是 Content-Type: text/html。

反过来的坑 :如果你把 Content-Type 填成了 text/plain,浏览器就不会渲染,而是把 HTML 源码原样显示出来。

这是很多人第一次写 HTTP 服务器时踩过的坑------网页变成了一堆 <html><body>... 的代码。

12.3 一个细节:响应为什么要带 Content-Length

在第十章我们提过一句原文的提醒,这里展开说清楚。

原文说:

// 应答部分不必带 Content Length,浏览器会自己做这件事

为什么"不必带"也能工作?

因为短连接模式下,连接关闭本身就是一种结束信号:

复制代码
   服务器:发正文 → 发完 → close(fd)
   浏览器:一直读 → read 返回 0 → "哦,服务器关了,内容到此为止"

   ★ 在这种模式下,连接的关闭 = 内容的结束

但这句话有两个重要的前提,必须补上:

复制代码
前提一:必须是短连接
   如果是长连接(Connection: keep-alive),连接【不会关闭】,
   浏览器就一直等,永远等不到那个 EOF ------ 页面卡死!

前提二:这种"靠关闭判断结束"的方式,无法区分【成功】和【失败】
   内容发到一半,服务器崩溃了,连接被关闭
   → 浏览器以为"内容收完了",实际上只收到一半
   → 用户看到的是一个残缺的、坏掉的页面

所以正确的做法是:

复制代码
★ 老老实实带上 Content-Length。

   好处一:浏览器能精确知道"还差多少字节没收到"
   好处二:能识别"内容被截断了"
   好处三:长连接也能正常工作(这是现代 Web 的默认模式)
   好处四:可以做【下载进度条】------ 因为没有总长度就算不出百分比!

这就是为什么每个下载工具都要先看响应头------它要从 Content-Length 里拿到文件总大小。

12.4 页面的本质是一棵多叉树

现在说一个有意思的视角,原文的这句话值得单独拿出来:

页面的本质是一颗多叉树,每一次页面的切换就是一次 http 请求 <a> 标签是跳转

"页面的本质是一棵多叉树"------这句话是什么意思?

看一段最简单的 HTML:

复制代码
<html>
  <body>
    <div>
      <h1>标题</h1>
      <p>一段文字</p>
      <a href="/page2.html">点我跳转</a>
      <img src="/logo.png">
    </div>
  </body>
</html>

把它画成树结构:

复制代码
            html
             │
           body
             │
           div
        ┌────┼────┬──────────┐
        ▼    ▼    ▼          ▼
       h1    p    a          img
                    │          │
                    ▼          ▼
              href=/page2.html  src=/logo.png
                    │          │
                    ▼          ▼
                一次 HTTP    一次 HTTP
                  请求         请求

这棵树揭示了三件事。

第一:HTML 本身就是一个树形结构。

这是 HTML 的语法本质------标签可以互相嵌套,形成一个层次结构。浏览器里的 DOM 树、CSS 选择器的匹配,全都是在这棵树上做的。

第二:树上的每一个"叶子资源",都对应一次 HTTP 请求。

复制代码
   <a href="/page2.html">   →  点击时发一次 HTTP 请求
   <img src="/logo.png">    →  页面加载时发一次 HTTP 请求
   <link rel="stylesheet">  →  页面加载时发一次 HTTP 请求
   <script src="app.js">    →  页面加载时发一次 HTTP 请求

这就是为什么打开一个网页,你在 F12 的 Network 面板里会看到几十个请求,而不是一个。

每一个请求只带回一个资源。一个页面是几十次 HTTP 请求拼出来的。

原文给了一个具体的算法,叫"一个网页 = 1 + N 次请求":

复制代码
   假设一个页面里嵌了 10 张图片:

   ① HTML 页面本身          →  ★ 1 次请求
   ② 10 张图片,每张一次     →  ★ N = 10 次请求
   ────────────────────────────────────────
   合计:                   →  1 + N = 11 次请求

   ★ 打开这一个页面,浏览器在网络上往返了 11 次。

把这个"1 + N"再往前推一步,就能解释一件很多人没想过的事:

复制代码
┌────────────────────────────────────────────────────────────────┐
│  为什么"图片多的页面加载慢"?                                    
│                                                                 
│   不只是因为图片大(传输字节多),                               
│   更是因为图片多(★ 请求次数多)。                                
│                                                                 
│   每一次请求都有【固定开销】:                                    
│     · 排进浏览器同域并发队列里等(通常同域最多 6 个并发)         
│     · 发一次 HTTP 请求报文                                       
│     · 服务器处理、查磁盘、拼响应                                  
│     · 传回来                                                      
│                                                                 
│   ★ 哪怕每张图只有 1KB,11 次请求的"框架开销"                        
│     也可能比传图片本身还贵。                                     
└────────────────────────────────────────────────────────────────┘

这也就解释了前端优化里那些看起来很怪的做法:

复制代码
   · 精灵图(CSS Sprite)  → 把 10 张小图拼成 1 张大图
                             ★ 把 N 从 10 压到 1
   · 雪碧图 / 图标字体     → 同理
   · 文件合并(把多个 JS 合成一个)
                             ★ 把 N 次请求压到 1 次
   · 图片懒加载            → 没滚到的图先不请求
                             ★ 把 N 变小(只加载看得见的)
   · CDN 多域名           → 绕过"同域 6 并发"的限制
                             ★ 不是减少 N,而是让 N 个请求能并行
   · HTTP/2 多路复用       → 一条连接上并发跑完所有请求
                             ★ 从根上解决"请求次数多"的开销问题

顺带说一下,除了 <img>,还有一批标签的 src/href 同样会触发隐式请求:

复制代码
   <img  src="...">            → 图片
   <video src="...">           → 视频(还有 poster 封面图)
   <audio src="...">           → 音频
   <link rel="stylesheet" ...>  → CSS
   <script src="...">           → JS
   <link rel="icon" ...>        → 标签页图标(要是不写,浏览器还是去要 /favicon.ico)

注意最后一行------这就是 6.2.1 节那个"服务器收到两次请求"现象的背后机制: favicon.ico 根本不是 HTML 里的标签触发的,而是浏览器 在你什么都没写的时候自己去要的。标签触发的请求是"显式"的,浏览器补的那个是"隐式"的。

第三:"每一次页面的切换就是一次 http 请求"。

复制代码
   用户点击 <a>  →  浏览器发一次 HTTP 请求
                 →  服务器返回一个新的 HTML
                 →  浏览器丢掉旧的树,建一棵新的树
                 →  页面"切换"了

这就是传统网站(多页应用 MPA)的工作模式。

而现代的单页应用(SPA,比如用 React/Vue 写的)恰恰是要打破这一点:

复制代码
   第一次请求:拿到一个大的 HTML + 一堆 JS
   之后的操作:不再请求新的 HTML
               而是用 JS 修改【已有的那棵树】(DOM 操作)
               → 页面"看起来切换了",但其实没有发生页面级跳转
               → 只发一些小的数据请求(AJAX / Fetch)

理解"页面的本质是一棵树",是理解这一切的基础。

12.5 动态站点:从"取文件"到"做计算"

前面讲的都是静态资源:服务器只需要"找文件、读出来、发回去"。

但真实的网站远不止于此。原文给出了对照的概念:

动态站点 → 站点支持和用户进行交互 → 登录、注册、支付、查找

对照着看这两类站点的区别:

复制代码
┌──────────────┬──────────────────────┬────────────────────────────┐
│              │      静态站点                  动态站点             
├──────────────┼──────────────────────┼────────────────────────────┤
│ 内容来源          磁盘上的现成文件        程序【现场计算】出来的        
├──────────────┼──────────────────────┼────────────────────────────┤
│ 服务器做什么     找文件 → 读 → 发        执行代码 → 查数据库 →        
│                                        拼出内容 → 发               
├──────────────┼──────────────────────┼────────────────────────────┤
│ 同样的 URL      永远返回                 可能返回不同内容             
│ 返回           【相同】的内容           (取决于用户、时间、参数)    
├──────────────┼──────────────────────┼────────────────────────────┤
│ 典型代表        一个介绍页、一张图片      登录、注册、支付、搜索       
├──────────────┼──────────────────────┼────────────────────────────┤
│ 对服务器的      很小                     很大(要跑代码、连数据库)     
│ 要求                                                           
└──────────────┴──────────────────────┴────────────────────────────┘

看一个具体的例子就清楚了。

场景一(静态): 用户请求 /logo.png

复制代码
   服务器:在 wwwroot 下找 logo.png
        →  找到了
        →  读出来
        →  原样发回去
   ★ 无论谁来请求、请求多少次,返回的都是同一张图

场景二(动态): 用户请求 /user/profile

复制代码
   服务器:这不是一个文件!
        →  交给后端程序处理
        →  程序去问:"这是哪个用户?"(看 Cookie / Session)
        →  去数据库查这个用户的信息
        →  拼成一段 HTML
        →  发回去
   ★ 张三来请求,返回张三的资料;李四来请求,返回李四的资料
     同一个 URL,返回的内容【每次都不一样】

"登录、注册、支付、查找"------这四个场景全都属于这一类。

为什么?因为它们都依赖"用户是谁"这个信息:

复制代码
   登录  →  要验证这个用户名密码对不对
   注册  →  要往数据库里插一条新记录
   支付  →  要扣这个用户的钱、给那个商家加钱
   查找  →  要根据关键词去数据库里搜

这些都不是"磁盘上有一个现成的文件",而是"跑一遍程序算出来的"。

理解这个区别,是理解后面框架(CGI、FastCGI、Servlet、各种 Web 框架)的起点。

而对于我们这篇文章来说,重点只在于:

静态资源走"取文件"这条路,动态内容走"执行程序"这条路。

接下来的第八章到第十三章,我们实现的都是前一条路。

而"后一条路"------服务器怎么知道 /user/profile 该交给哪段代码去执行------我们在第十八章专门回答。 到那时候你会发现,它并不需要把整个服务器重写一遍,只要往一张表里加一条记录就够了。

12.6 承上启下

到这里,一条完整的 HTTP 响应就构造完毕了:

复制代码
   HTTP/1.1 200 OK
   Content-Type: text/html; charset=utf-8      ← 告诉浏览器怎么理解
   Content-Length: 1024                        ← 告诉浏览器有多长
                                               ← 空行
   <html>...</html>                            ← 正文

客户端已经把资源拿到手了。剩下的渲染工作,是浏览器的事------那属于前端领域,不在本文范围内。

现在,我们回头看一条之前一直没细讲的主线:

GET 和 POST 到底有什么不同?

用户在表单里填的数据,是怎么变成 HTTP 请求的一部分的?

这就是下一章的主题。


第十三章 GET 与 POST:参数是怎么送到服务器的

13.1 表单:浏览器替你拼请求

前面我们一直在讲"服务器怎么处理请求"。

现在换一个视角,回到客户端这一侧,问一个问题:

用户在网页上填的那些信息,是怎么变成 HTTP 请求的?

看一段最朴素的 HTML:

复制代码
<!DOCTYPE html>
<html>
<body>
  <form>
    First name:<br>
    <input type="text" name="firstname">
    <br>
    Last name:<br>
    <input type="text" name="lastname">
  </form>
</body>
</html>

这段 HTML 在浏览器里渲染出来,就是两个输入框:

复制代码
   First name:
   ┌──────────────────────┐
   │ ssss                 │
   └──────────────────────┘
   Last name:
   ┌──────────────────────┐
   │ aaaaaa               │
   └──────────────────────┘

原文对这张图有一句批注,很有意思:

请注意表单本身是不可见的。 同时请注意文本字段的默认宽度是 20 个字符。

"表单本身是不可见的"------这句话说的是:

复制代码
<form>
   ↑ 这个标签【不会】在页面画出任何东西

  它只是一个【逻辑容器】,用来圈定"哪些输入框属于同一组"
  真正画出来的,是它内部的 <input> 标签

这是一个很多人第一次写网页时会困惑的点:为什么我写了 <form> 却什么都没看到?因为 <form> 本身就是不可见的。

那么,用户填写这些框、然后点提交,会发生什么?

原文的描述是:

前端网页中,会有表单,用户填写表单,浏览器自动提取表单里面的内容 构建带参的 http request

关键词是"自动"。

复制代码
   用户做的事:
      ① 在两个框里打字
      ② 点"提交"按钮

   浏览器做的事(全是自动的!):
      ① 从表单里【提取】所有 <input> 的 name 和用户填的 value
      ② 按某种规则【拼装】成一个 HTTP 请求
      ③ 发出去

也就是说:用户只需要打字,浏览器负责把这些字变成符合 HTTP 规范的报文。

这段话里有一个关键线索:"那些 <input> 的 name 属性":

复制代码
<input type="text" name="firstname">
                    ↑
              这个 name,就是参数的名字!

用户填的内容:  ssss
那么拼出来的参数就是:  firstname=ssss

name + 用户输入的值 = 一个键值对。这就是"传参"的原始形态。

而"拼装"这一步,具体拼到哪里去?

答案是:取决于表单用什么方法提交。这就是 GET 和 POST 的分水岭。

13.2 GET:参数挂在 URI 上

原文的第一个结论:

结论1:GET 方法可以常用来获取静态资源,同时也可以用来向目标服务器传递参数。 通过 URI 方式进行传递参数!

"通过 URI 方式"------也就是挂在 URL 的查询字符串那一部分:

复制代码
GET /submit?firstname=ssss&lastname=aaaaaa HTTP/1.1
           └─────────────────────────────────┘
                      参数挂在这里!
                   (URL 的查询字符串部分)

拆开看这个链接:

复制代码
   /submit          ← 请求的路径(URI 里不含参数的部分)
   ?                ← 分隔符:参数部分从这里开始
   firstname=ssss   ← 第一个键值对
   &                ← 键值对之间的分隔符
   lastname=aaaaaa  ← 第二个键值对

所以 GET 传参的完整形态是:

复制代码
   URI?key1=value1&key2=value2&key3=value3

服务器拿到这个请求后,要自己从 _uri 里把参数切出来:

复制代码
// 服务器端解析 GET 参数的思路
// _uri = "/submit?firstname=ssss&lastname=aaaaaa"

size_t pos = _uri.find('?');
if (pos != std::string::npos) {
    std::string path  = _uri.substr(0, pos);      // "/submit"
    std::string query = _uri.substr(pos + 1);     // "firstname=ssss&lastname=aaaaaa"

    // 再按 & 切分,按 = 取值......
    // ★ 别忘了:取到的值可能还是 URL 编码的,要 Decode!
}
//GET 请求把参数序列化到 URL 的查询字符串里;服务器解析 HTTP 请求报文时,从请求目标中把路径和查询串拆出来,再解析出参数。

注意最后那句提醒------回到第三章讲的 URL 编码。

如果用户在输入框里填的是 "张 三",那它会被编码成 %E5%BC%A0%20%E4%B8%89 挂到 URI 上。服务器取出来之后必须 Decode,才能拿到真正的内容。

这就是"浏览器自动提取"里被省略掉的细节:它不仅提取,还做了编码。

13.3 POST:参数装进正文里

原文的第二个结论:

结论2:POST 可以用来向目标服务器传参,传参的方式是通过正文部分传参的!

对照 GET,区别只在"参数放哪":

复制代码
【GET】参数放这里 ↓
   GET /submit?firstname=ssss&lastname=aaaaaa HTTP/1.1
   Host: 115.190.145.241:8080
                              ← 没有正文(空行之后就是空的)

【POST】参数放这里 ↓
   POST /submit HTTP/1.1
   Host: 115.190.145.241:8080
   Content-Length: 30
   Content-Type: application/x-www-form-urlencoded
                              ← 空行
   firstname=ssss&lastname=aaaaaa    ← ★ 参数在正文里!

原文有一句话把两者的区别总结得非常精炼:

那么如果是 GET 或 POST 的方法,他们两个只有一点不一样,就是传参位置上不一样。 GET 的方法通过 URI 传参,POST 的方法通过正文传参。

"只有一点不一样,就是传参位置不一样"------这句话要好好理解,因为它打破了一个常见的误解。

一个常见的误解是:

"GET 和 POST 是两个完全不同性质的东西,一个是取数据,一个是提交数据。"

更准确的看法是:

复制代码
★ 从"服务器如何处理"的角度看,两者确实不同:
     GET  → 一般是"我要取东西"
     POST → 一般是"我要交东西"

★ 但从"报文结构"的角度看,两者【结构完全一样】:
     GET   = 请求行 + 报头 + 空行 + (没有正文)
     POST  = 请求行 + 报头 + 空行 +  (正文里放着参数)

  它们的差别,只是"有没有正文、正文里放什么"

这也解释了为什么 Content-Length 在 POST 里是必须的:

复制代码
   GET  通常没有正文  →  不需要 Content-Length
   POST 一定有正文    →  ★ 必须有 Content-Length
                          否则服务器不知道正文有多长(第六章讲过)

再注意 POST 里那个 Content-Type: application/x-www-form-urlencoded。

它说的是"正文里的数据格式是 URL 编码的表单数据"。注意这个词里的 urlencoded------参数在 POST 的正文里,用的还是 URL 编码的格式。

所以第三章讲的 URL 编码,在 GET 和 POST 里都会用到,只是位置不同。

13.4 两条结论

现在把这一章的内容收拢成两条结论(原文就是这么组织的):

复制代码
┌─────────────────────────────────────────────────────────────────┐
│  结论一:GET                                                      
│    ① 常用来获取静态资源(图片、CSS、JS、HTML......)                   
│    ② 也可以向目标服务器传递参数                                    
│    ③ ★ 传参方式:【通过 URI】                                     
│       /submit?key1=value1&key2=value2                            
├─────────────────────────────────────────────────────────────────┤
│  结论二:POST                                                     
│    ① 用来向目标服务器传参                                          
│    ② ★ 传参方式:【通过正文部分】                                 
│       参数放在空行之后的 DATA 区域里                               
│       并且必须带 Content-Length 报头                              
└─────────────────────────────────────────────────────────────────┘

最后补两条实践层面的提醒。原文在这两点上说得很克制,我们把它们展开说清楚。

提醒一:关于"私密性"------原文有一个精确的措辞

复制代码
   GET  的参数暴露在 URL 里  →  会被记录在:
                                  · 浏览器地址栏(旁边的人能看见)
                                  · 浏览器历史记录
                                  · 服务器访问日志(access.log)
                                  · 中间代理的日志
                              →  ★ 所以【绝对不能】用它传密码、验证码

   POST 的参数在正文里       →  浏览器只显示请求的服务,不显示正文数据
                              →  ★ 所以 POST 传参【更私密】

注意上面用的词是"更私密",不是"更安全"。

为什么这个措辞必须精确?因为"私密"和"安全"防的是两种完全不同的人:

复制代码
┌────────────────────────────────────────────────────────────────┐
│  "私密" 防的是【旁边的人】                                        
│                                                                 
│    · 站在你身后看屏幕的同事                                       
│    · 翻你浏览器历史的人                                           
│    · 事后翻服务器日志的人                                         
│                                                                 
│    POST 能做到 ✅ ------ 因为密码根本没进地址栏、没进历史、没进日志      
├────────────────────────────────────────────────────────────────┤
│  "安全" 要防的是【路上的设备】                                    
│                                                                 
│    · 同一个局域网里开了混杂模式的网卡(14.1.1 节)                 
│    · 途中的每一台路由器、运营商设备、代理                          
│                                                                 
│    POST ❌ 完全做不到!                                          
│    POST 的正文和 GET 的 URL 一样是【明文】,
│    抓包工具一抓就是一整个用户名密码。                              
└────────────────────────────────────────────────────────────────┘

一句话总结:POST 只是"不摆在台面上",不是"锁起来了"。真正的安全要靠 HTTPS(下一章)。

提醒二:URL 有长度限制

复制代码
   GET 把参数塞在 URL 里  →  URL 本身是【有长度上限】的

   · HTTP 协议规范本身【没有】规定 URL 长度的上限
   · 但【浏览器】和【服务器】各自设了限制:
       - 浏览器地址栏一般能处理到几千到几万字符(各浏览器不同)
       - 很多服务器的 URL 处理缓冲是 8KB 左右(如常见的 8190 字节)
       - 常见的经验安全线:2KB 以内最稳
   · 超过之后的表现往往是:截断、返回 414(URI Too Long)或 400

   ★ 结论:传大数据(上传文件、长文本、表单批量提交)必须用 POST
      因为正文的长度由 Content-Length 说了算,没有 URL 那种限制

把这两条提醒合起来看,GET 和 POST 的取舍就变成了一张很清晰的表:

对比项 GET POST
参数位置 通常在 URL 查询串:?k=v&k=v;也可有 body,但不推荐 通常在请求体 body;也可在 URL 查询串
地址栏回显 查询串会显示在地址栏 不显示
浏览器历史 完整 URL 会进历史 一般不进历史,除非前端自己记录
服务器/代理日志 URL 默认容易进访问日志、Referer body 默认不一定记录,但可配置记录;URL 部分仍会进日志
长度限制 HTTP 协议无硬限制;浏览器/服务器/代理有实现限制,约 2KB 是旧经验安全线 HTTP 协议无硬限制;实际受服务器配置、超时、内存等限制
收藏/分享 链接自带参数,可直接收藏/分享 链接不带 body,不能直接收藏/分享
HTTP 明文抓包 请求行、头、body 都可见 请求行、头、body 都可见
HTTPS 下 内容加密;但 URL 仍可能进日志/历史 内容加密;URL 部分仍可能进日志
安全语义 安全、幂等,适合查询 不安全、不幂等,适合创建/修改
典型用途 搜索、筛选、分页 登录、提交表单、上传、创建资源

13.5 重新回答一次:URI 到底是什么

现在,我们已经见过了 URI 的三种用法,可以回过头把那句定义补充完整了。

原文在最后提出了一个很好的问题:

你如何看待一个 http request 中的 URI 的?

它给了两个答案。

答案一(静态资源视角):

Linux web 根目录下存在的一个文件路径(静态资源访问),视频、音频、html......

复制代码
   /index.html  →  wwwroot/index.html  →  磁盘上的一个真实文件
   /logo.png    →  wwwroot/logo.png    →  磁盘上的一个真实文件

   ★ 这种 URI,可以直接翻译成一条文件系统路径
     服务器的工作就是"把文件读出来发走"

答案二(动态服务视角):

我们可以把他当做一个字符串,用来请求 http server 中所对应的服务,"字符串"也往往以路径形式体现,只不过这个路径不能在 Web 根目录下存在!

这段话非常关键,它说出了动态站点和静态站点的本质区别:

复制代码
   /user/profile   →  wwwroot/user/profile
                         ↑ 磁盘上【根本没有这个文件】!

   ★ 那服务器怎么处理?
     它不能去读文件(文件不存在)
     它必须【执行一段代码】来生成响应

     这个 URI 不是"文件名",而是"我要调用哪个服务"的标识

所以 URI 有一个漂亮的双重身份:

复制代码
┌────────────────────────────────────────────────────────────────┐
│  URI 的两种身份                                                 
├────────────────────────────────────────────────────────────────┤
│  身份一:文件路径                                               
│     /index.html  →  一个真实存在的文件                           
│     处理方式:读文件 → 发回去                                     
│     这就是【静态资源访问】                                        
├────────────────────────────────────────────────────────────────┤
│  身份二:服务标识                                                
│     /user/login  →  磁盘上不存在的路径                           
│     处理方式:路由到对应的处理函数 → 执行 → 生成响应               
│     这就是【动态服务请求】                                        
└────────────────────────────────────────────────────────────────┘

服务器怎么区分这两种情况?

一个朴素但有效的判断法:

复制代码
   拿到 URI → 加上 wwwroot 前缀 → 去文件系统里找
       │
       ├── 找到了  →  按静态资源处理(读文件,发回去)
       │
       └── 没找到  →  交给后面的业务逻辑
                       → 由它决定是执行某个服务,还是返回 404

"404 Not Found"这个状态码,正是从这个判断里自然产生的 ------路径找不到对应的东西。

回到我们写的那个简单 HTTP 服务器:它实现的是身份一。第十章的 Build() 里那个 ReadFile() 读不到文件就返回 404,处理的正是"这个 URI 不对应任何一个真实文件"的情况。

13.6 承上启下

到这里,GET 和 POST 的区别、参数是怎么从表单变成 HTTP 请求的一部分,都讲清楚了。

但还有一个贯穿全文的坑没有填------从第二章就埋下的那个:

http: 和 https: 有什么区别?

原文说"https 是加密的、http 是不加密的"------那具体加密的是什么?

这就是下一章的主题。


第十四章 HTTPS:那个"加密版本"

14.1 明文走网线,等于明信片走邮局

在第二章我们见过这个对比,当时只是一笔带过:

【https】是加密的协议。【http】是不加密的。

现在把这个区别讲透。

先想清楚"不加密"意味着什么。

HTTP 的报文是纯文本的(第五章讲过:它就是一个大字符串)。它从你的电脑出发,要经过:

复制代码
   你的电脑
      │
      ▼
   家里的路由器
      │
      ▼
   运营商的机房
      │
      ▼
   若干个中间路由器
      │
      ▼
   目标机房的交换机
      │
      ▼
   服务器

这条路上的任何一环,都可以把经过的数据完整地看一遍。

原文对这个风险的描述是:

HTTPS:加密的是正文部分,相应的请求、应答报文是没有被加密的!

这句话有一个非常值得展开的细节。我们在下一节专门讲。

先用一个比喻说清楚"不加密"到底有多严重:

复制代码
★ HTTP  好比【明信片】:
   写在上面,一路上经手的每个人都能读
   (路由器、运营商、公共 WiFi 的提供者......)

★ HTTPS 好比【密封信件】:
   内容装在信封里,中间的人只能看到"寄给谁、寄给谁"
   但看不到信里写了什么

这个比喻里有一个关键的细节:即使装了信封,信封上的收件地址还是要露在外面。 这个细节在 14.2.3 节会变得很重要。

14.1.1 它是怎么被看见的?------网卡的两种模式

"一路上经手的人都能读",这句话听起来有点抽象。我们把它落到实处:

原文给了一个非常具体、而且就在你身边的场景:

局域网抓包原理:

① 同一局域网内(如宿舍),你发给路由器的报文,同网段的其他主机也能收到;

② 正常情况下,主机在数据链路层收到报文后做 MAC 目的地址判定:不是发给自己的就丢弃(普通模式);

③ 恶意用户可以用工具绕开"丢弃非本机报文"的底层设定,把局域网里所有报文都抓上来 → 抓到你的 HTTP 请求 → 账号密码泄露。

这个机制的关键,在于网卡的两种工作模式:

复制代码
┌──────────────────────────────────────────────────────────────────┐
│  模式一:普通模式(默认)                                          
│                                                                  
│    网卡收到一个报文                                              
│        ↓                                                        
│    在【数据链路层】检查 MAC 目的地址:                             
│        ├── 是发给我的  →  向上交付给协议栈  ✅                     
│        └── 不是给我的  →  ★ 直接丢弃                            
└──────────────────────────────────────────────────────────────────┘

┌──────────────────────────────────────────────────────────────────┐
│  模式二:混杂模式(promiscuous)                                   
│                                                                  
│    ★ 一个标志位,由网卡驱动提供                                    
│                                                                  
│    把网卡设成"收到任何报文都向上交付"                              
│        ↓                                                        
│    ★ 于是能抓到局域网里【所有】报文,不只是发给自己的                
└──────────────────────────────────────────────────────────────────┘

为什么局域网里"别人的报文"会跑到你的网卡上?

这是以太网的共享介质特性决定的(我们在网络基础那一章讲过):

复制代码
   在同一个局域网(同一个广播域)里,
   报文是以【广播】的方式在物理介质上传播的。

   ★ 所以从物理上说,同一网段的每张网卡【都能收到】这些信号

   ★ 是"丢弃"这个动作(靠 MAC 地址判断)决定了
     "哪些报文该被上层看到"

   ★ 而混杂模式,就是把这个"丢弃"动作关掉

抓包工具的原理,就是把网卡设成混杂模式:

抓包工具原理:Wireshark、Linux 的 tcpdump,本质上就是把网卡工作模式设成混杂模式,把本机收到的所有报文抓上来再分析。

复制代码
# 这就是 tcpdump 的本质:把网卡设成混杂模式,然后用过滤器筛选
sudo tcpdump -i eth0 -A port 80
#              ↑ 指定网卡   ↑ -A 以 ASCII 显示内容
#                            → ★ HTTP 明文请求会直接显示出来!

注意 -A 这个参数的含义------"以 ASCII 显示内容",因为 HTTP 是纯文本的,所以抓到的内容是直接可读的。

原文对这个场景的总结是:

核心结论:GET 和 POST 传参都不安全,因为 HTTP 请求及传递的数据本身都是明文------请求向下封装(TCP/IP、数据链路层等)时全是字符信息,没有任何保护;中途要经过无数路由器、运营商、代理。

抓包工具:fiddler 现场抓到 POST 方法的正文,用户名密码就是明文的。

注意这里有一个非常容易搞错的地方,原文特意澄清过:

区别 1(回显/私密性):

GET 通过 URI 传参,账号密码等信息会回显在浏览器地址栏、暴露出来;

POST 正文传参,浏览器只显示请求的服务,不显示正文数据 → POST 传参更私密。

这个区分极其重要:

复制代码
   ★ "更私密" ≠ "更安全"

   POST 只是让你的密码【不在地址栏里显示】------
   你自己不会不小心被别人从屏幕上看到,
   也不会被记进浏览器历史。

   ★ 但在【网线上】,POST 的正文和 GET 的 URL 一样是明文,
     抓包工具一样看得一清二楚。

   一句话:POST 防的是【旁边的人】,防不了【路上的设备】。

所以真正的解决方案只有一个:加密。这就是 HTTPS 存在的全部理由。

14.2 到底加密了什么

现在看具体的技术事实。

HTTPS 的全称是 HTTP over SSL(后来 SSL 演进为 TLS,但"SSL"这个名字被沿用下来了)。

它不是一个新协议,而是"在 HTTP 下面垫了一层加密"。

原文对这个分层结构的描述是:

SSL:为保通信过程安全,应用层里还有一层 SSL = Secure Socket Layer(安全套接层),专门实现加密和解密逻辑;SSL 之上再做 HTTP。

HTTP + SSL = HTTPS,HTTPS 里的 S 就是 SSL;SSL 是一层"软件层",客户端、服务端都有。

复制代码
┌─────────────────────────────────────────────┐
│   应用层    HTTP                             │  ← 还是那个 HTTP,一点没变
├─────────────────────────────────────────────┤
│   ★ SSL/TLS 加密层                           │  ← HTTPS 新加的就是这一层
├─────────────────────────────────────────────┤
│   传输层    TCP                              │
├─────────────────────────────────────────────┤
│   网络层    IP                               │
└─────────────────────────────────────────────┘
        ↑
   ★ 注意:SSL 在 HTTP【之下】,是 HTTP 的"下层"
     所以 HTTP 调用 SSL 的接口,就像它调用 read/write 一样
14.2.1 整个 HTTP 报文都被加密了

这是本节最容易搞错的一点,必须说准确。

原文对加密范围的描述是:

HTTPS:先 HTTP 构建请求 → 到 SSL 层把整个 HTTP 报文全部加密(一般包括报头属性和正文,因为参数可能在 URI 里也可能在正文里,所以必须整包加密)→ 通过网络转发给对端 → 对端先到 SSL 层解密 → 解密后上层拿到的仍是 HTTP 请求。

为什么必须"整包加密"?原文给了理由,而这个理由很关键:

因为参数可能在 URI 里,也可能在正文里

回想第十三章讲的两种传参方式:

复制代码
   GET  →  参数在【请求行】的 URI 里
   POST →  参数在【正文】里

   ★ 如果只加密正文,那么 GET 请求的全部参数就是明文暴露的
     → 这显然不行

   ★ 所以必须把【整个 HTTP 报文】都加密
     包括:请求行 / 状态行、所有报头、空行、正文

于是得到两个重要的结论。原文的表述是:

结论一:只有通信双方在各自这两层能看到同一个 HTTP 明文;网络中抓包(请求、应答)看到的都是加密数据。

结论二(端口区分):HTTP 服务端默认绑定 80 端口;HTTPS 是另一种网络服务,默认绑定 443 端口。课堂用 8080 是因为绑定 80 需要超级用户权限。

结论一可以画成图:

"发的是一个 HTTP request,对方收到的也是一个 HTTP request"------这句话点出了 SSL 层的透明性:

复制代码
   ★ 对 HTTP 层来说,它完全不知道 SSL 的存在

     HTTP 只是"往下写一串字节",
     至于这些字节在下面被加密成了什么样子,
     HTTP 一概不管。

     ★ 这和第四章讲的"HTTP 不知道 TCP 的存在"
       是同一个道理------【分层的透明性】
14.2.2 在代码里,SSL 层长什么样

原文还描述了这个设计在代码里怎么落地:

软件架构实现:设计一个 SSL 层(加密解密底层),HTTP 属于上层、SSL 属于下层;HTTP 调用 SSL 的接口。

在 HTTP server 中:读数据时读到完整请求后,先对读到的 inbuffer 做解密,再交给上层处理;发送之前对 outbuffer(应答)做加密。

加密解密对上层 HTTP 的 HandleRequest 全程透明,从而形成上下层软件关系。

画出来就是一个非常清晰的两层结构:

复制代码
// 收数据的路径(从网络 → 到 HTTP)
char inbuffer[SIZE];
int n = read(sockfd, inbuffer, sizeof(inbuffer));

// ★ 解密这一步,是 SSL 层干的
std::string plain = ssl.Decrypt(inbuffer, n);

// ★ HTTP 层拿到的,已经是明文了
HandleRequest(plain);       // 它完全不知道刚才那串是密文


// 发数据的路径(从 HTTP → 到网络)
std::string response = BuildResponse();

// ★ 加密这一步,也是 SSL 层干的
std::string cipher = ssl.Encrypt(response);

write(sockfd, cipher.c_str(), cipher.size());

注意这两个注释里反复出现的词:

复制代码
   ★ "HTTP 层完全不知道刚才那串是密文"

   这就是分层设计的价值:
   HTTP 的代码一行都不用改,就能切换到 HTTPS
   ------ 只要在下面垫一个 SSL 层

原文还提到了一种更灵活的组织方式:

也可直接在 TCP server 上设置两个回调(一个加密方法、一个解密方法),注册后与 HTTP server 关联。

复制代码
   ★ 这就是我们第四章讲的"回调机制"的又一次应用:
     把"加密""解密"两个函数作为回调注册进去,
     TCP server 收到数据时调解密回调,
     发数据前调加密回调。
14.2.3 一个必须澄清的边界

"整包加密"这句话容易让人以为 HTTPS 是"全都看不见"的。这不准确。准确的说法是:

复制代码
★ HTTPS 保护的是【你说了什么】(HTTP 报文的内容)
★ HTTPS 不保护【你在跟谁说话】(域名、IP 这些"信封上的地址")

因为有些信息必须在加密建立【之前】就暴露出来,否则根本没法开始通信:

复制代码
     ① 你连的是哪个 IP、哪个端口        (TCP 层就看得见)
     ② 你连的是哪个域名(SNI)           ← ★ 这个在握手里是【明文】
     ③ 证书信息(服务器是谁、有效期)
     ④ 用了什么加密算法
     ⑤ 数据包的大小、时序

所以:

复制代码
   ✅ HTTPS 能防止:别人看到你的密码、Cookie、账号、聊天内容
   ❌ HTTPS 不能防止:别人知道你访问了哪个网站

这就是为什么"访问了哪些网站"这件事,即使全站 HTTPS,仍然可能被网络管理员、运营商观察到。 想要连"访问了哪个域名"都藏起来,需要更进一步的机制(比如 TLS 1.3 的加密 SNI / ECH)。

一句话好记:HTTPS 相当于给信装了个信封------信里的内容安全了,但信封上的收件地址还是露在外面的。

再看一个实际的差别,就知道 HTTPS 有多必要:

复制代码
【HTTP 下,登录一个网站】

   POST /login HTTP/1.1
   Host: www.example.com
   Content-Length: 30
                              ← 空行
   username=maisui&password=123456
                              ↑
                    ★ 这段明文,路上所有人都能看见
                      包括你在星巴克连的那个免费 WiFi

【HTTPS 下,同样的登录】

   一段完全看不懂的密文......
   ★ 中间的任何人拿到的都只是一堆乱码

"一个不经意的免费 WiFi,就能让整间咖啡馆的人的密码全部泄露"------这就是为什么今天所有正规网站都必须上 HTTPS。

另外,HTTPS 带来的不只是保密性,还有两个容易被忽略的好处:

复制代码
① 完整性校验 ------ 防止内容被中途篡改
   运营商/劫持者往网页里插广告,是早年非常常见的事
   有了 HTTPS,插入的任何东西都会导致校验失败

② 身份验证 ------ 证明"你连的确实是这家网站"
   通过证书机制,防止 DNS 劫持 / 中间人冒充
   ★ 这就是为什么你在浏览器里能看见那个"锁"图标

回顾一下第二章那个 URL 的第一段:

复制代码
   https://www.example.com/index.html
   └─┬──┘
     └── 【https:加密】 vs 【http:不加密】

这五个字符的差别,背后是一整层加密协议。

14.3 承上启下

到这里,HTTP 协议的全部核心内容就讲完了。

从第二章到第十四章,我们沿着"一次回车"的顺序,走完了全部十三步:

复制代码
   定位 → DNS → 编码 → 连接 → 请求 → 完整性 → 解析 → 结构体
        → 文件路径 → VFS/inode → 响应 → 状态码 → 正文 → 参数 → 加密

现在到了最后一件事:把这些零散的知识点,重新接回那条完整的故事线。

这就是下一章:全景回放。


------ 中篇到此为止 ------

回头看一遍,中篇走过的路是这样的:

复制代码
   HttpRequest._uri = "/image/桃花.jpg"
        │
        ├─ 第八章  字符串 → 磁盘路径
        │          "/" → "/index.html" ,再补 wwwroot 前缀
        │          ★ 顺序不能反,前缀不能少(这是安全底线)
        │          然后 open/read 钻进内核:
        │            VFS 屏蔽文件系统差异(函数表多态)
        │            dentry 组成目录树 + dentry cache 加速
        │            inode 里存着 i_size、i_op、i_fop
        │          ★ i_size 就是 Content-Length 的来源
        │
        ├─ 第九~十章  响应报文怎么拼
        │          状态行 + 响应报头 + 空行 + 正文(和请求对称)
        │          Build() 两大难点:target_file、ReadFile
        │          ★ 并在第十八章埋下伏笔:如果这个 URI 根本不是文件呢?
        │
        ├─ 第十一章  状态码
        │          1xx~5xx 五大类
        │          ★ 301 永久(会改客户端的认知)vs 302 临时(不会)
        │
        ├─ 第十二章  Content-Type
        │          字节不会自解释,后缀名 → MIME 表
        │          ★ 页面的本质是一棵多叉树,每个叶子一次请求
        │
        ├─ 第十三章  GET 与 POST
        │          参数位置:URI 上 vs 正文里
        │          ★ "更私密" ≠ "更安全"
        │
        └─ 第十四章  HTTPS
                   网卡普通模式 vs 混杂模式 → 抓包原理
                   ★ 明文传输下,GET 和 POST 一样都能被抓
                   TLS 加密的是【整个 HTTP 报文】,不只是正文

到这里,一个 HTTP 服务器"能跑"了。

复制代码
   ┌──────────────────────────────────────────────────────────┐
   │  ✅ 能做:收到请求 → 找到文件 → 拼出响应 → 发回去          
   │  ✅ 能做:答对状态码、答对 Content-Type、支持 GET/POST     
   │  ✅ 能做:走 HTTPS 把整包加密                              
   └──────────────────────────────────────────────────────────┘

但"能跑"离"能用"还有一段距离。把服务器真的放到用户面前,会立刻冒出三个问题------

复制代码
   问题一:服务器记不住人
        用户登录成功了,下一次请求服务器就不认识了。
        ★ 因为 HTTP 是【无状态】的。

   问题二:连接留不住
        HTTP 本身不维护连接,连接是 TCP 的事。
        ★ 因为 HTTP 是【无连接】的。

   问题三:服务器只认文件,不认代码
        /index.html 好办,磁盘上有这个文件。
        可 /login 呢?磁盘上没有这个文件,
        "登录"是一段代码,不是一个文件。
        ★ 因为前面所有章节,实现的一直是"静态资源"这条路。

这三个问题,就是下篇要回答的。


下篇预告:从一次问答到一整个系统

复制代码
   第十五章  全景回放
            把上篇和中篇的所有环节,重新接回一条线
            用一张时序图,看一次访问从头到尾的完整过程

   第十六章  无状态与无连接
            HTTP 的两个"天生缺陷"到底是什么
            以及为什么它们既是缺陷、又是 HTTP 能成功的原因

   第十七章  Cookie 与 Session
            怎么让一个天生"记不住事"的协议,记住你
            两条路:数据放客户端,还是放服务器
            顺带讲清 Referer 这个报头的三大用途

   第十八章  服务注册与路由
            ★ 中篇一直绕过去的那个问题:
              /login 该怎么处理?
            答案是往一张表里插一条记录
            你会发现:file_operations、sys_call_table、
            和这张路由表,是同一个设计模式的三个实例

   第十九章  速查表
            21 节,可以随手查阅

兄弟姐妹们,😉我们下篇见。

相关推荐
ai_xiaogui42 分钟前
PanelAI 1.1.1重磅更新:秒级安装脚本优化 + 无公网IP算力节点组网,私有化AI管理平台全面升级
人工智能·网络协议·tcp/ip·api聚合管理·开发者ai一键部署·ai底层架构解析·ai应用快速变现
玖石书1 小时前
linux 安装uv(python虚拟环境管理)
linux·python·uv
91刘仁德1 小时前
HTTPS 加密原理与 CA 数字证书:从对称加密到完整通信流程
网络·笔记·网络协议·http·https
奈落241 小时前
【RAG 深度修炼】专栏 · 第 9 期(收官):安全专题与生产 Checklist 总集——从能跑的 Demo 到睡得着觉的生产系统
大数据·网络·人工智能·安全·ai编程
皓月盈江1 小时前
Hexo博客Butterfly5.7.0主题网站信息:本站访客数和本站总浏览量的数据一直加载不显示的解决方法
linux·hexo·butterfly·busuanzi·vercount·本站访客数·本站总浏览量
H.莓飛1 小时前
【数据结构】堆_OJ题
linux·数据结构·算法
sogw-三叶草️1 小时前
HITCON CTF 2014 — stkof · Unsafe Unlink
网络·安全·web安全·pwn·堆·二进制安全·堆漏洞
硅基手札1 小时前
【linux内核专栏 09】内核模块与 kbuild
linux·架构
流浪0012 小时前
Linux系统篇43——线程(八) 线程的数据不一致问题,从 ticket-- 的非原子性说起
linux·操作系统·线程·线程安全·锁