依据 OSI 分层写服务器:回调解耦、两层循环解析与 InAddr 封装
我的github:(https://github.com/xcx55/ubuntu-linux-project)
感谢各位大佬参观我的github!!
源笔记:学习网络 最重要的是什么(26-9-4)、依据OSI前三层描述一个优秀的服务器代码(26-9-5)、为什么使用回调、一般应用层协议处理方式+分层、自定义协议3(26-9-6)
上一篇把 len\\r\\n value \\r\\n 的报文定制和读取顺序推导完了,这一篇是手搓应用层协议的收官:把"学网络到底在学什么"这个问题正面回答掉——上三层是人的代码,下四层是操作系统的协议栈,而优秀服务器的全部秘密,就是把上三层按 OSI 分层写干净,再用回调把"解析"和"处理"解耦。
一、OSI 模型的网络真相:上三层运用,下四层内核
先把结论钉死:
- 下四层:操作系统网络协议栈,轮不到我们写;
- 上三层:全是运用层的活——
- send/recv 搞定服务器收发;
- 处理协议、地址传输(比如 JSON 传输以及封装 packet);
- JSON 数据来源解包、数据处理、再加包。
只不过 send/recv 是高度封装的——封装到里面藏着继承、多态、智能指针、类型擦除这些东西。
上三层的思路,大佬总结过一个模板,一个请求从进到出总共五步:
得到数据! 返回数据
1 客户端发来的封装报文(内核协议 → 应用层协议)
2 服务端进行对应解包(应用层协议解包)
3 服务端用解包得到的数据进行运算
2 计算的数据进行加包
1 发送回客户端(send 去对端)
注意一个关键点:解包拆包这一层,可能是 JSON 协议、可能是未知协议,也可能是多个应用层协议的组合共同构成第二层——但它们和 TCP/UDP 是解耦的。传输层只管搬字节,应用层协议怎么定,传输层一个字都不用改。
而 TCP 的特点决定了:必须进行多次 while,保证直到本次传输的数据被解析完整。这句话就是后面所有代码结构的种子。
二、依据 OSI 分层的服务器代码:三层各干各的
按照上三层把代码切开,一个优秀的服务器长这样:
- 最底下一级:客户端传来的数据封装报文(或者传回去的封装报文)——就是 recv 出来的原始字节流;
- 第二层:回调函数,负责对报文的解析、得到对应的处理函数;
- 第三层:一直循环读取数据,直到读取到一个完整的报文,交给回调函数。
拿手搓协议举例走一遍:一层里客户端将 JSON 转为 write 字符串,再进行字符串拼接 \\r\\n;再把 JSON 的 write 字符串做 size 转成 string,一起传出去。
服务器翻译是实时的:得到一堆报文(因为 TCP 的特性),但"第一次发送报文一定是以 size 字符串开始"——这是上一期推导过的读取顺序铁律——所以可以连续解析,解析出来一个完整报文就进行第一层运算;运算完成后,再 JSON 化、writer 一下,最后 pack 应用层装包、发送。
要是残缺的呢?先保留。 也就是 while 循环:有了就一步一步解析,没有就攒着。用笔记里的原话总结第三层的工作方式:
接收不是完整接收!第三层一直循环读取数据,直到读取到一个完整的,交给回调函数;out 里面储存封装报文,回调处理封装的协议,再以 out string 返回计算好的协议字符串,再 send。
客户端收到之后,再进行第二层的处理报文,一样 while 处理——只是客户端没有使用回调,是直接使用的。服务器要并发、要分层,客户端一个线程直来直去就够。
三、一般应用层协议的处理方式:两层循环咬合
把上面的结构抽象成通用的"两层咬合"模型:
第一层:while 循环读取
每读一次,都把读到的内容交给第二层看看有没有完整字节流
识别到某一次完整得到了报文(解析没有返回 "")
→ 就开始使用回调函数里面的方法
(应用层解析失败,代表第一层字节流不完整,继续读!)
第二层:解析 + 回调
服务器第二层可以开一个线程、或者自己干
向客户端发送返回字节流
第二层自己再 while 循环,得到完整报文再处理
没有 → return ""
→ 让第一层再 while 循环读取,每一次读取都要看看有没有完整字节流
两层各一个 while,互相咬合:第一层管"攒字节",第二层管"切报文+派活"。return "" 就是两层之间的握手信号——“这次还不够,继续读”。
四、为什么使用回调:解耦是第一生产力
第二层为什么非要塞一个回调进去?笔记里给的答案很直白:
- 解耦!这是最重要的:只需要改变构造的第一层函数即可——今天用 JSON 协议,明天换 HTTP,第三层循环读取的代码一个字不用动;
- 使用类型擦除、万能构造,用起来容易理解,而且方便(笔记原话还有三个字:更装逼)。
lambda 插曲:回调为什么能运作
写回调的时候被 lambda 的继承问题"炸"出来一段底层原理,值得单独记:
- lambda 类实例化的时候,生成的类定义就在表达式的上面;
- 类模板不是类定义!类模板在使用、实例化的时候,会在当前实例化代码上面再次进行"类模板 → 类定义"的实例化——这就是 lambda 类为什么可以运作的原因;
- 编译结束后,并不会有一个类定义出现在函数里面,而是在内存的语法树上——新型编译器检测到 [] () { },会在语法树生成类似的 struct 结构,内存会主动生成,类似代码桩、.got 表那样解析函数。“虽然我们文本看不见,但编译器链接器就是看得到”;
- 到静态表生成好了,就可以填指针了;
- 所以调用 lambda 的 operator(),本质还是 lambda 这个类的类型绑定——执行一个函数,无论什么函数,当生成一个 lambda 变量时,编译器全局就知道有这个 lambda 类。
五、自定义协议 3:InAddr 封装,给两套场景量身定做
协议层搞定了,地址层也顺手包了一层。InetAddr 这个类干的事:将传入的 port 或者 ip 转为网络序列;或者传入 sockaddr_in,将其转换为网络序列——后面进行 connect 或者 sendto 都很方便。
// 从 sockaddr_in 解出来(服务端 recvfrom 之后用)
InetAddress(const struct sockaddr_in &address)
: _address(address), _len(sizeof(address))
{
char ipstr[32];
inet_ntop(AF_INET, &(_address.sin_addr), ipstr, sizeof(ipstr));
_ip = ipstr;
_port = ntohs(_address.sin_port);
}
// 从 port + ip 构造(服务器 bind / 客户端 connect 之前用)
InetAddress(uint16_t port, const std::string &ip = "0.0.0.0")
: _ip(ip), _port(port)
{
bzero(&_address, sizeof(_address));
_address.sin_family = AF_INET;
_address.sin_port = htons(_port); // h->n
// _address.sin_addr.s_addr = inet_addr(_ip.c_str());
inet_pton(AF_INET, ip.c_str(), &(_address.sin_addr));
_len = sizeof(_address);
}
两个构造函数,对应两套东西量身定做:一个 listen 的服务器(自己构造 ip:port 去 bind),一个自己的 sockfd(从对端 sockaddr_in 里解出对方的 ip 和 port)。就算场景换成客户端,其 parse 也要变化——但变化都被关在了这一个类里面,外面调 connect/sendto 的人完全不感知。
六、意外收获:文件也是字节流,配置就是反序列化
学了序列化和反序列化之后回头看:
- 之前:读取难、写入难;
- 现在:读取容易、写入也容易;
- 我们可以把数据从网络里面读取,将封装的字符串进行写入文件——文件也是字节流!
所以:配置文件,就是 JSON 串;我们进行的反序列化,就是一种配置!
网络报文、磁盘文件,在"字节流"这个视角下是同一种东西,序列化/反序列化一套工具通吃两边——这就是分层和抽象的复利。
总结
- OSI 上三层是人的代码(收发、协议处理、运算),下四层是内核协议栈;send/recv 是高度封装;
- 优秀服务器五步模板:收报文 → 解包 → 运算 → 加包 → 发回;TCP/UDP 与应用层协议解耦;
- 三层代码结构:底层原始字节流、二层回调解析、三层循环读取;残缺就保留,return "" 握手;
- 回调的价值 = 解耦,换协议只改第一层构造;lambda 能运作靠编译器在语法树上生成的类结构;
- InetAddr 把网络序列转换关进一个类,服务器/客户端两套场景量身定做;
- 文件也是字节流 → 配置文件就是 JSON,反序列化就是配置。
下一篇开始,不再手搓协议了——HTTP,一个大佬们早就定义好的应用层协议,正等着检验我们手搓时练出的眼光。
网硕互联帮助中心



评论前必须登录!
注册