跳到主要内容 Linux 网络编程:使用 C++ 实现 JSON 与 HTTP Web 服务器 | 极客日志
C++
Linux 网络编程:使用 C++ 实现 JSON 与 HTTP Web 服务器 JSON 序列化原理及 nlohmann/json 库的使用,详解 HTTP 协议结构(请求/响应报文、状态码、方法),并基于 C++ Socket 编程与线程池实现了一个支持静态资源与计算功能的 Web 服务器。内容涵盖网络通信、协议解析、文件 IO 及业务逻辑,适合 Linux 网络编程学习者。
宁静 发布于 2026/4/6 更新于 2026/4/17 9 浏览本文前置知识
序列化与反序列化
引入
在之前的博客中,我详细介绍了序列化 与反序列化 的概念。对于使用 TCP 协议进行通信的双方,由于 TCP 是面向字节流的,在发送数据之前,我们通常需要定义一种结构化的数据来描述传输内容,并以此作为数据的容器。在 C++ 中,这种结构化数据通常表现为对象或结构体。然而,我们不能直接将结构体内存中对应的字节原样发送到另一端,因为直接传递内存字节会引发字节序 和结构体内存对齐 的问题。不同平台、不同编译器所遵循的内存对齐规则可能不同,这可能导致接收方在解析结构体字段时出现错误。
因此,我们需要借助序列化 。序列化 是指将结构化的数据按照预定的规则转换为连续的字节流。其主要目的是屏蔽平台差异,使得位于不同平台的进程能够以统一的方式解析该字节流。序列化通常分为两种形式:文本序列化 与二进制序列化 。
文本序列化将结构化的数据转换为一个完整的字符串。字符串本身是以字符为单位的连续序列,每个字符通常占用一个字节,因此字符串本质上也是一个连续的字节流。由于字符串以字符为单位解析,不存在字节序问题。通信双方只需约定字符串的格式与编码方式,即可正确解析该字符序列,最终将连续的字节流还原为结构化的数据。
二进制序列化则直接发送数据在内存中的原始二进制序列,无需额外转换。这两种方式各有优劣:文本序列化直观、可读性高、便于调试;而二进制序列化发送的是二进制数据,人类难以直接阅读。文本序列化会将数据转换为字符形式,可能导致传输体积增大——例如整数 100000 在文本序列化中会被转换为 "100000" 占 6 个字节,而作为 int 类型的二进制序列化仅需 4 个字节。因此,二进制序列化在传输体积上通常更小。此外,文本序列化还需要对字符串进行解析以恢复原始数据,而二进制序列化的解析开销通常更低,因为它直接对应数据的原始二进制表示。
特性 文本序列化 (JSON/XML) 二进制序列化 (Protobuf/Thrift) 可读性 极高(肉眼可读) 低(十六进制乱码) 传输体积 较大(数字变字符,带大量引号) 极小(紧凑编码) 解析速度 较慢(需字符串扫描、词法解析) 极快(直接偏移寻址或位运算) 跨语言 完美(天然支持) 优秀(需编译 IDL 文件)
在上一篇博客中,我们手动实现了文本序列化,即将结构体各字段按一定格式拼接为完整字符串。我之所以手动实现,是为了帮助大家理解序列化的基本原理,并为本文内容做铺垫。
然而在实际开发中,我们通常不需要从头实现序列化,可以使用成熟的第三方库来完成这项工作。这些库的实现通常更完善、更高效。本文将介绍的第一个主题——JSON ,就是一种广泛应用的文本序列化格式。
JSON
首先,介绍一下什么是 JSON 。JSON (JavaScript Object Notation)是一种轻量级、基于文本、人类可读的数据交换格式。JSON 源于 JavaScript,借鉴了其对象和数组的表示方法。但由于 JSON 本身是文本格式,且所表示的基本数据类型(如整型、布尔值等)在绝大多数编程语言中都得到支持,因此JSON 并不局限于 JavaScript,而是能够被多种编程语言解析与生成。正因如此,JSON 不仅具备跨平台 能力,还能实现跨语言 的数据交换。
了解 JSON 的基本定义后,我们进一步探讨其本质。如上所述,JSON 实质上是一种文本序列化的方式。在此之前,我们曾手动实现过文本序列化,其核心原理是将结构体的各个字段按照特定格式拼接为一个完整的字符串。因此,JSON 的本质其实就是符合 JSON 规范(风格)的字符串。
理论上,只要我们清楚 JSON 格式的规范,就可以利用字符串操作函数手动拼接出符合 JSON 风格的字符串,而无需借助第三方库。字符串拼接本身并不复杂,因此自然引出一个疑问:相比手动实现,第三方库的优势究竟在哪里?如果仅实现序列化(即转换为 JSON 字符串),那么使用第三方库似乎并未显著减轻负担,因为序列化这一步本身并不困难。要回答这个问题,我们首先需要明确 JSON 风格字符串的具体形式,进而理解第三方库所承担的工作。这一点我们稍后再展开。
微信扫一扫,关注极客日志 微信公众号「极客日志」,在微信中扫描左侧二维码关注。展示文案:极客日志 zeeklog
相关免费在线工具 Base64 字符串编码/解码 将字符串编码和解码为其 Base64 格式表示形式即可。 在线工具,Base64 字符串编码/解码在线工具,online
Base64 文件转换器 将字符串、文件或图像转换为其 Base64 表示形式。 在线工具,Base64 文件转换器在线工具,online
Markdown转HTML 将 Markdown(GFM)转为 HTML 片段,浏览器内 marked 解析;与 HTML转Markdown 互为补充。 在线工具,Markdown转HTML在线工具,online
HTML转Markdown 将 HTML 片段转为 GitHub Flavored Markdown,支持标题、列表、链接、代码块与表格等;浏览器内处理,可链接预填。 在线工具,HTML转Markdown在线工具,online
JSON 压缩 通过删除不必要的空白来缩小和压缩JSON。 在线工具,JSON 压缩在线工具,online
JSON美化和格式化 将JSON字符串修饰为友好的可读格式。 在线工具,JSON美化和格式化在线工具,online
JSON 支持若干基本数据类型 ,例如整型、浮点型和布尔型,也支持字符串 、对象 等复杂类型:
JSON 类型 C++ 对应类型 描述 Number int, double, floatJSON 不区分整数和浮点数,统一视为数字。 Boolean bool只有 true 和 false 两个字面值。 String std::string必须使用 双引号 包围,支持转义字符(如 \n, \t)。 Null nullptr / NULL表示空值或不存在,常用于可选字段。
需注意,基本数据类型(如整型、浮点型)可直接书写,而字符串类型必须用双引号括起来。
如果我们需要将一个对象或结构体的数据传递给另一端,在未接触 JSON 时,通常需要手动将其各字段拼接成字符串,再发送该字符串的字节流。而 JSON 可以直接表示对象,其方法是用一对大括号包裹内容,括号内是一个或多个键值对 。每个键值对 中,键与值之间用冒号分隔,不同键值对之间用逗号分隔。
这里的键值对对应于结构体或对象的成员变量:键表示成员名称,值表示该成员的取值。这种表示方式不仅书写方便,也能直观体现对象结构及其属性值:
{ "age" : 20 , "sex" : "girl" , "height" : 160 }
需要注意的是,值可以是任意基本类型,但键必须是字符串类型。
对于数组,JSON 使用中括号表示,括号内为数组元素,各元素之间以逗号分隔。数组元素可以是基本类型,也可以是对象等复杂类型:
[100,"bob" ,{"id" :1234,"name" :"mike" } ]
了解 JSON 字符串的格式后,我们可以回应前文提出的问题:既然已知对象用花括号表示、键值对用逗号分隔,数组用中括号表示、元素间用逗号分隔,我们确实可以通过字符串拼接函数,将结构化数据转换为符合 JSON 格式的字符串。
然而,如果需要修改对象中某一字段的值,通常有两种做法:一是修改原始结构体的值,然后重新拼接整个字符串;二是直接修改已拼接好的字符串(在不改动原始数据的情况下)。第二种方式需要定位字符串中的特定字段,进行覆盖并调整后续字符位置,过程较为繁琐。此外,在序列化过程中,某些字符(如双引号、反斜杠等)需要进行转义处理,这也增加了手动实现的复杂度。
更重要的是,除了序列化,我们还需考虑反序列化——即将 JSON 字符串解析并还原为原始数据。根据上述 JSON 格式,反序列化需识别键值对、分离键与值,并将值转换回对应数据类型。若遇到嵌套对象(即对象中某个属性的值仍为对象),则需递归处理,实现难度显著增加:
{ "id" : 1234 , "name" : "bob" , "person" : { "id" : 125 , "name" : "kie" } }
如果序列化与反序列化均自行实现,那么在序列化时若遗漏逗号或括号,将给反序列化带来极大困难,甚至导致解析失败。
因此,引入第三方库显得十分必要。其优势不仅在于提供高效的序列化与反序列化功能,更在于它提供了一系列功能丰富的接口,用于直接操作 JSON 对象内部维护的结构化原始数据 。这些库通常通过一个与编程语言相对应的数据结构(在 C++ 中通常是一个类对象 )来映射和承载 解析后的 JSON 数据,同时提供成员函数来方便地进行相关操作。
在 C++ 中,常用的 JSON 库包括json.hpp (即 nlohmann/json)。它提供的 JSON 对象可被视为一个容器,用于存储要发送或解析的 JSON 数据,并封装了丰富的操作方法,大大简化了 JSON 的处理流程。
这里需要注意,nlohmann/json 是一个第三方库,这意味着 C++ 标准库并不包含该库,因此我们需要自行引入。本文采用的方式是:获取官方json.hpp 源代码文件,将其全部内容复制到 Linux 系统下的相应目录中并保存。当然,引入该库还有其它多种方法,在此不再赘述。
需要明确的是,该第三方库维护了一个类, 该类可实例化为一个 JSON 对象。它不仅作为数据容器,还能以更灵活的方式支持我们对内部结构化数据的管理与维护。通过该对象提供的函数,我们可以直接操作数据项,而不需要去关心底层字符串的具体拼写格式。
首先关注其使用方法,即如何操作这个json 对象。json 类的定义位于json.hpp 中的nlohmann 命名空间内,因此我们需要指定该命名空间,随后创建一个json 对象。
json 对象最常见的数据类型是对象(object)和数组(array)。初始化一个 json 对象主要有两种方式。第一种是通过构造函数完成,由于json.hpp 支持 C++11,我们可以使用列表初始化的语法。
如前所述,JSON 对象的内容由一系列键值对组成。在列表初始化中,可以直接向构造函数传递一系列std::pair 对象,每个 pair 对应一个键值对。
nlohmann::json j ={{"name" ,"WZ" },{"age" ,20 },{"gender" ,"girl" }};
除了通过构造函数进行初始化,另一种更推荐的方式是直接使用赋值运算符。可以这样理解:若 json 对象存储的是 JSON 对象,其内部实际上维护了一个字典(即哈希表)。更详细的实现原理将在后文说明。
我们知道哈希表内部存储键值对,并重载了
"[]" 运算符。若哈希表中不存在指定的键,则会插入对应的键值对,从而完成初始化。这种方式不仅方便,也更符合 C++ 标准库容器的使用习惯,其效果与上述列表初始化相同,因此本人更推荐此种写法:
nlohmann::json j; j["name" ]="wz" ; j["age" ]=20 ; j["gender" ]="girl" ;
了解如何创建json 对象后,下一步是进行序列化。json 类提供了dump() 成员函数用于序列化,其返回类型为std::string 。因为 JSON 本质上是一个具有特定格式的字符串,而dump() 的返回值正是该格式的字符串表示。该函数可接收一个整数参数,若不传递参数,则默认生成紧凑格式(compact)的字符串。紧凑格式是指所有键值对均在同一行内输出,键值对之间仅以逗号分隔,不包含换行与额外空格,因此可读性相对较低。以下代码演示其效果:
#include "json.hpp"
#include <iostream>
int main () {
nlohmann::json j ={{"name" ,"WZ" },{"age" ,20 },{"gender" ,"girl" }};
std::string name = j["name" ];
std::string s = j.dump ();
return 0 ;
}
若向dump() 传递一个整型参数,则输出的字符串会进行格式化:每个键值对单独占一行,并且该参数值表示每一级缩进的空格数。例如,若参数值为 2,则每对键值前会有 2 个空格。如果 JSON 对象中嵌套了其他对象或数组,内层元素会根据嵌套深度进一步增加缩进。具体而言,设嵌套深度为 n(n ≥ 0),每级缩进空格数为 m,则某键值对前的空格总数为 (n + 1) * m。此规则不必强记,了解即可。
{
"name" : "WangZhe" ,
"stats" : {
"level" : 99 ,
"equipment" : [
"Sword" ,
"Shield"
]
}
}
在原先的代码基础上,我们可令序列化后的 JSON 字符串使用 2 格缩进,观察其效果:
#include "json.hpp"
#include <iostream>
int main () {
nlohmann::json j ={{"name" ,"WZ" },{"age" ,20 },{"gender" ,"girl" }};
std::string name = j["name" ];
std::string s = j.dump ();
return 0 ;
}
通常不建议在序列化时添加缩进,因为缩进虽然提高了可读性,但也会引入额外的换行符和空格,从而增加字符串的体积。若 JSON 数据包含大量键值对或嵌套层次较深,这种体积增长会在网络传输等场景中带来额外开销。因此,一般情况下建议调用dump() 时不传入参数。
接下来介绍如何初始化表示数组的json 对象。初始化数组同样有两种方法:第一种仍然是通过构造函数的列表初始化,但此时传递的是值(而非键值对),构造函数会据此完成初始化:
#include "json.hpp"
#include <iostream>
int main () {
nlohmann::json j ={{"name" ,"WZ" },{"age" ,20 },{"gender" ,"girl" }};
std::string s=j.dump (2 );
nlohmann::json j1 ={1 ,2 ,3 ,4 ,5 };
std::string s1=j1. dump ();
std::cout<<s<<std::endl;
std::cout<<s1<<std::endl;
return 0 ;
}
第二种方式是使用 json 类提供的 push_back() 函数,该函数专用于向表示数组的 json 对象末尾添加元素。可将其简单理解为在内部维护的数组尾部插入元素,具体原理将在后文详细阐述。初始化完成后,同样可调用 dump() 进行序列化。
json 对象的功能不止于此。假设json 对象内部存储的是一个对象或者数组,我们可以像操作标准库中的哈希表或者 vector 一样,通过[] 运算符访问或修改其字段值:
#include "json.hpp"
#include <iostream>
int main () {
nlohmann::json j ={{"name" ,"WZ" },{"age" ,20 },{"gender" ,"girl" }};
std::string name = j["name" ];
std::cout << name << std::endl;
j["name" ]="kiki" ;
std::cout << j["name" ]<< std::endl;
std::string s = j.dump (2 );
std::cout << s << std::endl;
nlohmann::json j2 ={1 ,2 ,3 ,4 ,5 };
std::cout << j2[1 ]<< std::endl;
j2[1 ]=100 ;
std::cout << j2[1 ]<< std::endl;
std::string s2 = j2. dump ();
std::cout << s2 << std::endl;
return 0 ;
}
最后介绍反序列化,即将 JSON 格式的字符串还原为json 对象。json 类提供了静态成员函数parse() ,它接收一个 JSON 格式的字符串,在内部解析后存储到 json 对象中。此过程即反序列化——将连续的字节流还原为结构化的 json 对象,其内部保存的数据即为原始内容。
为了熟悉parse() 的用法,我们编写如下代码进行验证。首先需要准备一个符合 JSON 语法的字符串。根据前面的介绍,JSON 对象由大括号包裹,数组由中括号包裹,键名与字符串值必须使用双引号。这些特殊字符在 C++ 字符串中需要使用转义字符表示,因此手动构造 JSON 字符串较为繁琐,例如:
std::string s ="{\"name\":\"WZ\",\"skills\":[\"C++\",\"Linux\"]}" ;
为此,C++11 引入了原始字符串字面量(raw string literal)语法:R"()" 。其基本格式为:
R"delimiter( raw_characters )delimiter"
其中R 指明该字符串为原始字符串,括号内为字符串内容,delimiter 为可选的分隔标识符。在原始字符串中,绝大多数字符(包括引号和换行)无需转义。仅当字符串内容本身包含)" 时,才需要在括号前添加一个自定义分隔符以避免歧义,例如:
// 在引号和括号间添加自定义标识符 "art" std::string s =R"art({" msg": " Look at this ) symbol"})art" ;// 编译器在遇到匹配的 )art" 时才会认为字符串结束
#include "json.hpp"
#include <iostream>
int main () {
std::string s =R"({"name":"WZ","age":18,"is_student":true,"gender":"female"})" ;
nlohmann::json j = nlohmann::json::parse (s);
std::cout << j["name" ]<< std::endl;
std::cout << j["age" ]<< std::endl;
std::cout << j["is_student" ]<< std::endl;
std::cout << j["gender" ]<< std::endl;
return 0 ;
}
原理 接下来介绍 JSON 的实现原理。基于上文的背景,json.hpp 库内部维护一个json 类,该类包含两个核心成员变量:类型变量 与 值变量。其中,类型变量用于记录当前json 对象所维护的原始数据类型,例如是一个对象、一个数组,还是一种基本数据类型。除了类型变量,类中还维护一个值变量,该值变量被实现为一个联合体。我们知道,联合体的各个成员变量共享同一块内存,且都从联合体的起始地址开始布局,这意味着在任意时刻,联合体中只有一个成员是有效的。将值变量设计为联合体的原因在于,一个json 对象同一时刻只能表示一种数据类型,不可能同时维护数组与对象;只能在数组内嵌套对象,或在对象内嵌套数组。
#include <iostream>
#include <string>
#include <vector>
#include <map>
enum class value_t {
null, number_integer, string, array, object
};
class json {
public :
union internal_value {
int64_t number_integer;
std::string* string;
std::vector<json>* array;
std::map<std::string, json>* object;
internal_value ():number_integer (0 ){}
};
value_t m_type = value_t ::null;
internal_value m_value;
};
在该联合体中,基本数据类型(如整数)直接存储其值,而复杂数据类型(如字符串、数组等)则存储指针,以此减少json对象本身的内存占用。json 类的关键组成部分之一是其构造函数。该类提供了多个版本的构造函数,每个版本对应一种特定的数据类型。每个构造函数的主要职责是:将类型变量设置为对应的数据类型,并同时初始化值变量。其中,对象与数组对应的构造函数较为特殊。
基于上文,对象和数组支持通过列表初始化进行构造。对于对象,列表初始化使用一系列pair (二元组)来完成;对于数组,则直接列举各个元素的值。列表初始化的底层机制与std::initializer_list 相关,它是一个标准库提供的模板类。
json 类定义了两个接收std::initializer_list 的构造函数:一个接收std::initializer_list<std::pair<std::string,Json>> ,用于对象初始化;另一个接收std::initializer_list<json> ,用于数组初始化。读者可能会有疑问:为什么数组构造函数的参数是以及 pair 对象的值的类型都是json 类型?这是因为数组的元素以及二元组的值的类型可以不同,例如:
[1 ,"hello" ,true ]{{"name" ,"wz" },{"age" ,20 }}
而json 对象本身是'泛型'的,其内部的值变量为联合体,可以容纳任意支持的数据类型。因此,std::initializer_list 模板在这里实例化为json 类型,这是一个需要注意的设计点。
若列表初始化传入的是pair 列表,则会调用接收std::initializer_list<std::pair<std::string,json>> 的构造函数。该函数的执行过程如下:首先,编译器会在栈上构建一个临时的只读数组,其元素类型为std::pair<std::string,json> ;接着,std::initializer_list 内部会保存两个指针,分别指向该临时数组的起始与结束位置。在构造函数内部,首先将类型变量设为object ,并在堆上动态分配一个std::map 作为值变量;之后,遍历临时数组,将每个pair 插入该std::map 中。
若传入的不是pair 列表,则会调用接收std::initializer_list<json> 的构造函数。此时,类型变量被设置为array ,并在堆上分配一个std::vector<json> 作为值变量,随后将临时数组中的每个json 元素依次插入该向量。
class json {
json (std::initializer_list<std::pair<std::string, json>> init){
m_type = value_t ::object;
m_value.object = new std::map <std::string, json>();
for (auto it = init.begin (); it != init.end ();++it){
m_value.object->insert (*it);
}
}
MyJson (std::initializer_list<json> init){
m_type = value_t ::array;
m_value.array = new std::vector <json>();
m_value.array->reserve (init.size ());
for (auto it = init.begin (); it != init.end ();++it){
m_value.array->push_back (*it);
}
}
json (int value){ m_type = value_t ::number_integer; m_value.number_integer = value; }
json (const char * value){ m_type = value_t ::string; m_value.string = new std::string (value);
~json (){
if (m_type == value_t ::string){ delete m_value.string; }
else if (m_type == value_t ::array){ delete m_value.array; }
}
};
在了解json 类的基本构造机制后,接下来介绍几个关键的成员函数——operator[]运算符的重载。
operator[] 有两个重载版本:一个接收std::string 类型参数,另一个接收整型参数。接收std::string 的版本用于处理 JSON 对象(键值对结构)。此时,json 对象的值变量应为一个哈希表(或
std::map )。如果调用该运算符时,当前json对象为空(即类型为null ),则该运算符会先将其类型转为object ,并初始化值变量为空的哈希表,然后插入对应的键值对;若非空,则直接进行键值对的插入或访问。
接收整型参数的版本用于处理 JSON 数组。类似地,若当前json对象为空,运算符会将其类型转为array
,并初始化值变量为一个空的向量。此外,该版本还包含自动扩容逻辑:若访问的下标超出当前数组长度,向量会自动扩容至该下标加一的大小,新增位置将以默认构造的json 对象(即null 类型)填充。
json& operator [](const std::string& key){
if (m_type == value_t ::null){
m_type = value_t ::object;
m_value.object = new std::map <std::string, json>();
}
if (m_type != value_t ::object){ throw std::domain_error ("JSON 类型不是 object,无法使用字符串 key 访问" ); }
return (*m_value.object)[key];
}
json& operator [](size_t index){
if (m_type == value_t ::null){
m_type = value_t ::array;
m_value.array = new std::vector <MyJson>();
}
if (m_type != value_t ::array){ throw std::domain_error ("JSON 类型不是 array,无法使用索引访问" ); }
if (index >= m_value.array->size ()){ m_value.array->resize (index + 1 ); }
return (*m_value.array)[index];
}
在了解了operator[] 的基本访问逻辑后,需要注意其返回类型是json 对象的引用。在代码中,我们常会书写如下语句:
此语句的底层执行逻辑如下:首先调用operator[] 重载函数,函数内部检查j 对象不为空且类型为object (而非array 或其他类型),随后找到键"age" 对应的值。该值本身是一个json 对象,函数返回其引用。然而,为了将json 对象转换为int 这样的基本类型,编译器会尝试进行隐式类型转换。具体地,当赋值运算符的左右操作数类型不匹配时,编译器会检查json 类是否定义了相应的类型转换运算符,若已定义,则自动调用。因此,上述代码实际上依次调用了两个重载函数:operator[] 和operator int() 。
class MyJson {
public :
operator int () const {
if (m_type != value_t ::number_integer){
throw std::runtime_error ("类型不匹配,无法转换为 int" );
}
return static_cast <int >(m_value.number_integer);
}
operator std::string () const {
if (m_type != value_t ::string){ throw std::runtime_error ("类型不匹配,无法转换为 string" ); }
return *(m_value.string);
}
operator bool () const {
}
};
此语句的执行过程是:首先调用operator[] 并返回一个json 对象的引用。这个被引用的json对象作为赋值运算符的左操作数(属于自定义类型),随后会调用其赋值运算符operator= 。该类定义了多个重载版本的operator= ,其中一个接收int 类型参数。该赋值运算符会首先检查当前json 对象的类型是否匹配:如果匹配(例如原本就是整数类型),则直接修改其内部存储的值;如果不匹配,则需要先释放当前对象可能持有的资源(如堆内存),再将类型标签更新为目标类型,并初始化对应的值变量。
MyJson& operator =(int value){
if (m_type == value_t ::number_integer){
m_value.number_integer = value;
}else {
this ->destroy ();
m_type = value_t ::number_integer;
m_value.number_integer = value;
}
return *this ;
}
通过结合operator[] 、类型转换运算符以及赋值运算符的重载,json 类实现了灵活且直观的读写接口,同时在内部保证了类型安全与资源管理的正确性。
json.hpp 的底层设计在一定程度上模糊了数组与对象的界限。其operator[] 不仅是一个访问器,更扮演了构造助手的角色。它利用std::vector<json> 的默认构造特性,以null 对象作为'内存粘合剂',实现了边访问边构造的灵活性,同时借助my_type 成员确保了 C++ 层面的类型安全。
补充 至此,我们已从使用与原理两个层面解析了 JSON。在原理层面,我并未详细解释dump 与parse 的具体实现原理,因为本文的重点在于'使用轮子而非造轮子'。适当了解轮子的构造,有助于我们更从容、得心应手地运用 JSON,但对其底层的理解也应适度——将 JSON 类的实现完全剖析清楚,反而可能收益有限。dump 与 parse 的具体实现较为复杂,感兴趣的读者可自行深入研究。
对于结构化数据(即 JSON 对象),我们可以调用dump 函数将其序列化为符合 JSON 规范的字符串。该字符串是以字符为单位的字符序列,每个字符通常对应一个字节。dump 函数的作用正是将数据结构转化为连续的字符序列。然而,转换为字符序列后,还需经过一步额外处理才能通过网络发送:即通过特定编码将字符序列转换为字节序列。因此,这里补充说明一下编码的相关知识。
我们知道,计算机底层只能存储二进制序列。但现实生活中大部分信息需以字符串形式表示,因此需要将字符串中的各个字符映射为唯一的二进制值,即进行编码。早期最常见的编码是 ASCII 码,它使用一个字节为英文字母及特殊符号分配唯一的二进制值,其范围是 0~127。
随着计算机的发展,需要表示的字符不再仅限于英文,还包括中文、其他语言字符乃至表情符号等。一个字节已不足以表示如此多的字符,于是 UTF-8 编码应运而生。UTF-8 是当前最主流、最常用的编码方式,它能表示包括中文在内的多国语言,并且完全兼容 ASCII 码。
为了对全球各类字符进行编码,Unicode 标准为每个字符定义了一个唯一的'码点'(Code Point),相当于字符的身份证。UTF-8 则是一种将码点转换为 1 至 4 个字节的二进制序列的规则,使得计算机能够存储和处理这些字符。具体字符映射到几个字节,取决于其码点的大小:
码点范围 (十六进制) 字节数 字节模板 (二进制) 0000 0000 - 0000 007F1 0xxxxxxx (完全兼容 ASCII)0000 0080 - 0000 07FF2 110xxxxx 10xxxxxx0000 0800 - 0000 FFFF3 1110xxxx 10xxxxxx 10xxxxxx (大部分汉字在这)0001 0000 - 0010 FFFF4 11110xxx 10xxxxxx 10xxxxxx 10xxxxxx
此时读者可能会产生疑问:当一个字符映射为多个字节时,会存在字节序(Endianness)问题,但为何在将文本序列化为字符串时,大多数编译器和平台使用 UTF-8 编码却不会遇到字节序问题?
原因在于 UTF-8 是一种面向字节的编码。尽管一个字符可能对应多个字节,但 UTF-8 始终以字节为单位进行解析,而非将多个字节作为一个整体来处理。其解析规则如下:
若某字节的最高位为0 ,则该字节直接对应一个字符(即 ASCII 字符)。
若字符对应多个字节,则第一个字节称为'前导字节'。对于占用 n 个字节的字符(1 ≤ n ≤ 4),其前导字节的高 n 位为1 ,第 n+1 位为0 ,后续的每个辅助字节均以10 开头。这种设计使得解析器能够明确识别字符的起始与边界,从而正确地将多个字节组合解析为一个字符。
字节序问题本质源于多字节整型在内存中的存储顺序,而 UTF-8 在本质上是一种字节流协议。由于 UTF-8 的前导字节已包含字符长度与边界信息,且解析过程是逐字节顺序进行的,因此它天然避免了因 CPU 大小端架构差异所引发的问题。
相对地,像 UTF-16 这类定长编码(每个字符固定对应 2 或 4 字节),由于需将多个字节作为一个整体解析,就会受到平台字节序的影响。
JSON 标准明确规定使用 UTF-8 作为其编码方式,这进一步确保了其在不同系统和环境间的兼容性与一致性。
HTTP
引入 在上文详细讲解了 JSON 之后,接下来我们将过渡到 HTTP 的内容。在具体介绍 HTTP 协议之前,我仍然通过一个例子来引入。
大家平时应该都有使用浏览器上网的习惯。我们常常在浏览器中输入一个网址,用来打开或获取网页,甚至是观看视频等。但你是否想过,在输入网址的背后,其实涉及客户端与服务端之间的通信原理。浏览器作为客户端,会与服务端进行通信,而最终呈现给我们的各种网页、视频等内容,都可以统称为'资源'。这些资源都是从服务端获取的,而 HTTP 正是一个应用层的通信协议。
要理解 HTTP 的原理,我们首先要从整个通信过程的起点说起,也就是在浏览器输入网址的那一刻。
原理 根据上文可知,我们获取网页、音视频等资源的过程,其背后实际基于客户端 - 服务器模型(Client-Server Model),即客户端与服务器之间的通信。现在,让我们从这一通信过程的起点开始讲解——也就是在浏览器中输入网址的那一刻。首先需要明确的是,由于该过程本质上是客户端与服务器之间的通信,因此通信双方必须依赖 IP 地址 与 端口号,才能确保数据准确发送到目标主机上的对应进程。IP 地址通常表示为点分十进制形式的字符串,端口号则为整数值。然而,我们在输入网址时,并不会手动输入 IP 地址和端口号,却在按下回车键之后,对应的网页、音视频等资源几乎立即呈现在浏览器中。这究竟是如何实现的呢?
这就需要我们先了解'网址'这一概念。网址是一种通俗的说法,其专业术语是 URL(Uniform Resource Locator,统一资源定位符)。一个完整的 URL 通常由以下几个部分组成:协议、域名、端口、路径、查询参数和片段。
https://www.example.com:443/music/list?id =1024&type =pop#comment
组成部分 示例内容 专业术语 协议 https://Scheme 域名 www.example.comDomain/Host 端口 :443Port 路径 /music/listPath 参数 ?id=1024&type=popQuery String 锚点 #commentFragment
URL 被称为'统一资源定位符',是因为我们在浏览器中所见的各种内容——无论是网页、图片还是视频——本质上都是资源,通常存放在服务器上。客户端(浏览器)向服务器发起请求以获取这些资源。为了准确定位资源,不仅需要知道服务器的 IP 地址和端口,还需明确资源在服务器上的具体位置,这正是路径所起的作用。因此,URL 实际上提供了一种统一的方法来定位网络上的资源。关于路径的具体细节,我们将在后文进一步展开说明。
以上简要说明了路径的作用,端口我们也熟悉其含义,而协议则对应通信双方所使用的应用层协议。那么,域名又是什么?接下来的内容将围绕域名展开讲解。
域名实际上,在浏览器中可以直接输入 IP 地址来替代域名进行访问。既然如此,为什么大多数情况下我们仍使用域名呢?原因在于,IP 地址是一串点分十进制数字,对普通用户而言并不友好。如果每次访问网站都需要记忆和输入 IP 地址,将会非常不便。相比之下,域名更为直观,例如www.baidu.com 能让人联想到百度网站,而不暴露其背后的 IP 地址信息。我们可以将域名比作'人名',而 IP 地址则相当于'身份证号'——显然,使用域名访问更加直观和方便。
然而,问题随之而来:从形式上看,域名与 IP 地址并无直接关联,但获取资源的本质是进程间通信,必须依赖 IP 地址。因此,域名必须通过某种方式转换为对应的 IP 地址。这一转换过程即是接下来要介绍的域名解析服务。
在深入讲解域名解析之前,我们需要先了解域名的基本格式。一个完整的域名由'.'分隔为若干部分,从右向左依次为:顶级域名、二级域名、三级域名等。通常,在完整域名的末尾还有一个表示根域的'.'。
www.example .com . 根域名(Root Domain):. 顶级域名(Top -Level Domain, TLD):.com 二级域名(Second-Level Domain, SLD):example 三级域名(Third-Level Domain):www
在了解了域名的组成之后,接下来将分别说明这些域名的含义与作用。首先从根域名开始,根域名通常表示为顶级域名右侧的一个点('.'),它是所有域名的起点,这是一种约定俗成的设计。其次是顶级域名。
顶级域名主要分为两类:一类是国家及地区域名,另一类是通用域名。通过顶级域名,可以反映网站的背景或用途。国家及地区域名常见的有:
.cn (中国)
.us (美国)
.jp (日本)
.uk (英国)
如果使用国家或地区域名,通常意味着该网站受相应国家或地区的法律法规约束。相比而言,通用域名更为常见,并能更直接地体现网站的用途或所属行业,因为它们通常与机构、组织或特定行业相关联。例如:
.com 代表商业用途;
.net 代表网络服务机构;
.org 代表非营利性组织;
.edu 原指美国高等教育机构,该后缀主要由美国使用,中国对应使用二级域名.edu.cn ;
.gov 代表美国政府机构。
此外,如今的通用域名已大大扩展,出现了诸如.museum (博物馆)、.shop (电商平台)等新后缀。
域名后缀 代表含义 适用对象 .com Commercial 最初限企业,现已演变成全球通用的商业标识。 .org Organization 各种非营利性机构、开源项目。 .net Network 最初为网络基础设施(ISP)设计。 .edu Education 主要是美国高等教育,中国则对应二级域名 .edu.cn。 .gov Government 仅限政府机构使用,具有极高权威性。
通过顶级域名,用户可以初步判断网站的性质与用途。紧接着顶级域名的是二级域名。上文提到,域名的作用是对网站进行身份标识,使用户能够了解网站的背景与用途。而二级域名可进一步增强网站的身份识别性与记忆点。
二级域名通常与品牌、企业或个人身份等内容紧密关联,例如www.google.com 、www.baidu.com 。若需进一步添加个性化信息,还可以继续设置三级、四级域名等。
在了解域名的各个组成部分后,还需要补充一点关于域名申请的说明。首先要明确的是,申请域名并不是一次性获得完整域名,而是需要先确定顶级域名。申请顶级域名一般分为两种情况:
第一种是创建全新的顶级域名。这种情况需向 ICANN(互联网名称与数字地址分配机构)这一最高管理机构提交申请,审核该域名是否符合条件,并缴纳注册费。若申请成功,该顶级域名将交由申请者或其委托的机构管理,包括其下所有子域名的注册事务。不过这种情况较为少见。
更常见的是第二种情况,即申请已注册的顶级域名(如.com )。每个顶级域名通常有专门的注册局进行管理,负责该顶级域名下子域名的注册,并与注册商(如腾讯云、阿里云等)对接。注册商直接面向用户,帮助其申请二级域名。注册商会查询注册局的数据库,若该二级域名未被占用,即可完成注册并缴纳相应费用。需注意的是,二级域名并非永久有效,一旦到期未续费,该域名将被释放。
可以这样理解:顶级域名如同一个集合,二级域名则是在该集合中开辟出属于你自己的子集。获得二级域名后,就像开发商获得一块地皮,可在其下进一步设置子域名。子域名的管理不再通过注册商,但需要向注册商提供一个权威域名服务器地址,以便注册商知晓该二级域名对应的 IP 地址。这部分内容与后文将介绍的 DNS 解析相关,具体原理将在后续详细说明。
在了解了域名的构成、含义以及申请方式之后,接下来便进入域名解析环节,即域名如何转换为 IP 地址。这一过程与 DNS 服务器密切相关。DNS 服务器专门负责域名解析,它会接收 DNS 查询请求,并返回该域名对应的 IP 地址。
在进行 DNS 查询之前,系统会首先在本机缓存中查找是否有该域名映射的 IP 地址。如果之前曾在浏览器中访问过该域名对应的网站,浏览器可能会保留相应的缓存。若浏览器缓存未命中,则会查询操作系统缓存;若操作系统缓存也未命中,则会进一步查询磁盘上的 hosts 文件。hosts 文件中存储的内容是一组组'域名-IP'映射条目,因此域名解析会优先查找本机的这三级缓存。
需要补充的是,hosts 文件的优先级高于本地 DNS 服务器查询。我们有时利用这一机制屏蔽浏览器中弹出的广告,具体方法是将广告对应的域名映射到本机 IP 地址(如 127.0.0.1),并将该条目添加到 hosts 文件中。这样一来,在解析该域名时,会直接指向本机,从而阻止广告内容的加载。
如果本地缓存均未命中,则需要查询本地 DNS 服务器。本地 DNS 服务器通常由用户手动配置或由 DHCP 服务自动分配。此时,主机会向本地 DNS 服务器发送一个 DNS 查询报文,请求解析该域名对应的 IP 地址。若本地 DNS 服务器中存有该记录,则直接返回 IP 地址。
若本地 DNS 服务器没有缓存该记录,则会启动迭代查询流程。首先,它会向根域名服务器发送请求,询问应如何解析该域名。根域名服务器存储了所有顶级域名服务器的地址,它会根据域名中的顶级域名(如.com、.org 等),返回对应顶级域名服务器的 IP 地址,指引本地 DNS 服务器向下一级查询。全球共有 13 组根域名服务器 IP 地址,但每组 IP 背后对应着分布在全球各地的多台服务器,查询时会通过任播技术路由到距离最近的服务器节点。
接着,本地 DNS 服务器会向获得的顶级域名服务器发起查询。顶级域名服务器则管理其下属的权威域名服务器信息,并根据域名中的二级域名部分,返回对应的权威域名服务器地址。本地 DNS 服务器随后向该权威域名服务器查询,最终获得域名对应的真实 IP 地址,并将其返回给请求主机。至此,完成域名解析的全过程。
主机获得 IP 地址后,浏览器、操作系统及 hosts 文件都可能建立相应缓存,以加速后续解析。但这些缓存条目均具有时效性,到期后会被自动清除。本地 DNS 服务器在返回 IP 地址时,也会将这一映射关系缓存到本地,以提高相同域名的解析效率,该缓存同样设有有效期,常用于存储访问频率较高的域名记录。如果缓存失效或未命中,本地 DNS 服务器将重新执行从根域名服务器开始的迭代查询流程。
在了解了域名解析服务后,我们对网络通信流程的认知会更加清晰。根据上文的说明,通信的起点是在浏览器地址栏中输入URL 。URL 本质上是一个字符串,浏览器作为客户端,在获取用户输入的URL 字符串后,会按照其构成进行解析,将其拆分为协议、域名、路径和查询参数等部分。
由于浏览器需要与服务器进行通信,因此必须首先获得服务器的IP 地址,这一步即为域名解析。浏览器会先查询本地缓存,若未命中,则向本地DNS 服务器发起查询,最终获取到对应的IP 地址。获得IP 地址后,浏览器便可开始与服务器建立通信。
HTTP 作为应用层协议,基于 TCP 传输协议实现。因此,浏览器作为客户端,首先需要与服务器建立连接,即完成TCP 三次握手。三次握手成功后,客户端与服务器才正式进入通信阶段。接着,浏览器会构建一个请求报文,发送给服务器,报文中包含对特定资源(如网页、图片等)的请求。服务器作为资源的持有者,在接收到请求报文后,会处理该请求,定位对应资源,并构建响应报文。响应报文中携带客户端请求的资源,随后将其返回给客户端。以上便是完整的通信流程。
理解这一过程,有助于我们更清晰地认识 Web 服务器的工作原理。掌握该流程,意味着已经理解了大部分基本原理,后续只需对这一过程中涉及的具体细节进行补充说明。首先便是HTTP 协议。
HTTP 协议HTTP 协议作为应用层协议,定义了通信双方交互的规则。应用层协议所规定的内容,其本质上是对请求与响应报文的格式进行约束。通信双方必须对请求和响应报文的格式有明确的共识,才能正确解析对方发送的内容。
HTTP 是一种文本协议,这一特性体现在其请求与响应报文的格式上。文本协议的主要特征是所传输的数据由字符串构成,这意味着 HTTP 的请求与响应报文并非纯二进制流,而是字符序列,因此对人类是可读的。
首先来认识 HTTP 请求报文的格式。请求报文可由三部分或四部分构成,包括请求行、请求头、空行和可选的请求正文。之所以说'三部分或四部分',是因为请求正文是可选的,它是否存在取决于具体请求方法,这一点将在后文说明。
HTTP 请求报文的开头是请求行,由请求方法、URL 和协议版本三部分组成,各部分之间以空格分隔。请求行之后是请求头,它由多行键值对组成,每行以回车换行符\r\n 结束。请求行与请求头之间也通过一个回车换行符分隔。
请求头之后是一个空行,即单独的\r\n 。由于请求头每行已以\r\n 结尾,因此请求头末尾会连续出现两个换行符,即\r\n\r\n 标志着请求头的结束。空行之后的部分即为请求正文。
"POST /api/login HTTP/1.1\r \n Host: www.example.com\r \n User-Agent: Mozilla/5.0\r \n Content-Type: application/x-www-form-urlencoded\r \n Content-Length: 27\r \n Connection: keep-alive\r \n \r \n username=admin&password=123456"
接下来详细说明请求报文各组成部分的含义与作用。首先是请求行,它包含请求方法、URL 和协议版本三个部分,各部分以空格分隔。请求行的作用是告知服务器执行何种操作——这是通过第一个字段'请求方法'来体现的。此外,它还指明了操作的目标资源(URL)以及所使用的协议版本。
请求方法 定义了客户端希望服务器执行的具体操作。HTTP 协议定义了多种请求方法,常见的如下表所示:
请求方法 语义 (Action) 数据位置 是否有 Body 幂等性 *安全性 **典型应用场景 GET 获取 资源URL 查询参数 否 是 是 浏览网页、搜索图片、查询余额 POST 新增 或处理资源请求体 (Body) 是 否 否 注册账号、发表评论、上传文件 PUT 更新 (全量覆盖)请求体 (Body) 是 是 否 修改用户完整档案、上传同名覆盖文件 PATCH 更新 (局部修改)请求体 (Body) 是 否 否 只修改用户的头像或改个密码 DELETE 删除 资源URL 路径 否 是 否 注销账户、删除一条朋友圈 HEAD 获取头部 信息N/A 否 是 是 检查链接有效性、获取文件大小 OPTIONS 查询 支持的方法N/A 否 是 是 跨域 (CORS) 前询问服务器允许哪些操作 TRACE 回显 服务器收到的请求N/A 否 是 是 用于诊断或测试网络路径中的代理
注:幂等性 指同一操作执行一次或多次对服务器端的资源状态不会产生额外影响,即效果相同。例如 GET 请求仅从服务器获取数据,发起一次与发起多次对该资源本身没有区别(因为它是只读的)。而 POST 不具备幂等性,是因为其通常用于提交数据,例如在数据库中新增一条记录,执行一次与重复执行多次所产生的效果不同(会导致多条记录被创建)。安全性 则指该操作本身不应引起资源状态的改变。
根据上表可以看出,HTTP 协议定义了多种请求方法,每种方法对应特定的语义操作。在实际开发中,并不需要掌握所有方法,因为绝大多数 HTTP 通信场景只涉及GET 与 POST 两种方法。其中一个重要原因是,POST 方法在功能上具有一定的涵盖性,能够替代其他某些操作(这一点将在后文说明)。因此,掌握GET 和 POST 即可满足大部分基础开发需求。
GET 请求首先是GET 方法,其作用是从服务器获取资源。之前提到,这类资源可以是网页、图片、视频等,它们通常以文件形式存在于服务器上。例如,网页本质上是一个HTML 文档,图片和音视频则是二进制文件。服务器在磁盘中存储这些文件,而GET 请求的目标就是根据客户端提供的路径,定位并返回对应资源。
以网页为例,用户在浏览器中看到的内容是由浏览器渲染得到的。浏览器不仅作为网络通信的客户端,还负责解析和呈现网页内容。网页的骨架是HTML 文档,其基本结构包含以下三部分:
<!DOCTYPE html > <html lang ="zh-CN" > <head > <meta charset ="UTF-8" > <title > 我的第一个网页</title > </head > <body > <h1 > 欢迎来到我的网站</h1 > <p > 这是一个关于 HTML 结构的探讨。</p > </body > </html >
文档类型声明:首行的<!DONCTYPE html> 用于告知浏览器HTML 版本,确保其以符合标准的方式解析文档。
头部:<head> 部分定义网页的元信息,如字符编码、标题等。
主体:<body> 部分包含网页的实际内容,即用户在页面上看到的所有信息。
浏览器通过解析HTML 文档,将其转换为可视化的网页界面。作为后端开发者,我们通常不需要深入掌握HTML 细节——这属于前端开发的范畴。但在后续编写完整 Web 服务器代码时,可能会涉及简单网页的构建,因此在此对其结构作简要说明。
服务器上持有的资源,如网页(文本文件形式的 HTML 文档)或图片、音视频等(二进制文件),通常存储在服务器的文件系统中。GET 请求的核心目的,正是从该文件系统中获取这些资源对应的文件。为了实现这一目标,客户端必须在请求中提供一个路径,以便服务器能够准确定位资源。随后,服务器将找到的资源载入响应报文,并返回给客户端。因此,请求行中的 URL 部分,其作用就是指明这个资源路径。
请求行中的 URL 是浏览器中所输入 URL 去除协议、域名和片段(fragment)后剩余的部分,仅包含路径与查询参数。在此,我们终于可以明确解释'路径'的含义:路径的第一层含义是代表服务器文件系统中的某一目录路径。但需要说明的是,路径虽然以/ 开头,并不表示它一定是文件系统的绝对路径(即从根目录开始)。在实际的服务器配置中,路径通常是相对于服务器设定的根目录(如网站根目录)来解释的。
刚才我特别强调'第一层含义',是因为路径所指示的对象并不一定是静态文件,也可能是一个可执行程序。这种情况下,请求的目的并非获取静态资源,而是获取动态资源。此时,服务器需要调用该可执行程序,获取其输出结果。典型做法是:服务器通过fork() 创建子进程,再通过exec() 系列接口将子进程替换为目标可执行程序,接着建立管道获取程序输出,最后将输出填入响应报文并返回客户端。此外,路径甚至可以不对应实际文件,而表示服务器内部的一个可调用函数。服务器识别该 URL 后,会调用相应的内部函数,并将其返回值放入响应报文中。这一点将在后文结合具体场景进一步说明,此处请先形成初步印象。
而这里之所以说 POST 能够涵盖其他操作,是因为 POST 的 URL 可以作为虚拟路径 ,映射到服务器内部预定义的可调用函数 。
在这种设计下,原本属于 DELETE 或 PUT 等请求方法的操作逻辑 ,现在直接被写在了这些函数的内部;而请求报文携带的正文内容,则作为参数 传递给函数进行处理。这也就解释了上文埋下的伏笔:为什么绝大多数场景只涉及 GET 和 POST,因为我们完全可以通过