Linux网络编程:应用层协议究竟是什么?从网络计算器到TCP Socket抽象基类完整实现

🔥 星恒随风: 个人主页 ❄️ 个人专栏: 《指针合集》 | 《C语言基础》 | 《数据结构》 | 《机器学习导论》 | 《前端基础》 | 《python基础》 | 《C++从入门到入土》 | 《Linux的学习之旅》 ✨ 数据即知识,压缩即智能
文章目录
- [Linux网络编程:应用层协议究竟是什么?从网络计算器到TCP Socket抽象基类完整实现](#Linux网络编程:应用层协议究竟是什么?从网络计算器到TCP Socket抽象基类完整实现)
- 一、什么是应用层?
-
- [1.1 从TCP/IP体系结构开始](#1.1 从TCP/IP体系结构开始)
- [1.2 我们编写的网络服务主要工作在哪一层?](#1.2 我们编写的网络服务主要工作在哪一层?)
- [1.3 应用层协议一定是HTTP吗?](#1.3 应用层协议一定是HTTP吗?)
- 二、再谈协议:协议到底是什么?
-
- [2.1 协议就是通信双方遵守的约定](#2.1 协议就是通信双方遵守的约定)
- [2.2 为什么说协议需要结构化的数据?](#2.2 为什么说协议需要结构化的数据?)
- [2.3 Socket接口负责解释这些字段吗?](#2.3 Socket接口负责解释这些字段吗?)
- 三、需求分析:设计一个网络版计算器
-
- [3.1 程序需要完成哪些功能?](#3.1 程序需要完成哪些功能?)
- [3.2 支持哪些运算?](#3.2 支持哪些运算?)
- [3.3 为什么响应不能只包含一个result?](#3.3 为什么响应不能只包含一个result?)
- 四、方案一:直接制定字符串格式
-
- [4.1 制定规则](#4.1 制定规则)
- [4.2 这种方式是不是协议?](#4.2 这种方式是不是协议?)
- [4.3 这种方案存在哪些问题?](#4.3 这种方案存在哪些问题?)
- 五、方案二:定义请求和响应对象
-
- [5.1 请求对象](#5.1 请求对象)
- [5.2 响应对象](#5.2 响应对象)
- [5.3 怎样把Request对象发送给服务器?](#5.3 怎样把Request对象发送给服务器?)
- 六、什么是序列化与反序列化?
-
- [6.1 序列化](#6.1 序列化)
- [6.2 反序列化](#6.2 反序列化)
- [6.3 为什么不直接发送结构体内存?](#6.3 为什么不直接发送结构体内存?)
- [6.4 为什么选择JSON?](#6.4 为什么选择JSON?)
- 七、重新理解TCP为什么支持全双工
-
- [7.1 TCP连接为什么能够同时发送和接收?](#7.1 TCP连接为什么能够同时发送和接收?)
- [7.2 为什么同一个sockfd可以读写?](#7.2 为什么同一个sockfd可以读写?)
- [7.3 send成功是不是意味着对方调用recv成功了?](#7.3 send成功是不是意味着对方调用recv成功了?)
- [7.4 为什么TCP会产生应用层消息边界问题?](#7.4 为什么TCP会产生应用层消息边界问题?)
- 八、正式开始设计网络计算器工程
-
- [8.1 整体要实现哪些模块?](#8.1 整体要实现哪些模块?)
- [8.2 模块之间的关系](#8.2 模块之间的关系)
- 九、为什么先实现Socket.hpp?
-
- [9.1 Socket抽象基类负责什么?](#9.1 Socket抽象基类负责什么?)
- [9.2 为什么要使用纯虚函数?](#9.2 为什么要使用纯虚函数?)
- 十、Socket.hpp还需要封装什么?
-
- [10.1 服务端创建流程](#10.1 服务端创建流程)
- [10.2 客户端创建流程](#10.2 客户端创建流程)
- [10.3 已经接受的连接怎么处理?](#10.3 已经接受的连接怎么处理?)
- [10.4 这里体现了什么设计思想?](#10.4 这里体现了什么设计思想?)
- 十一、先设计Socket.hpp的完整接口
- 十二、Socket.hpp完整实现
- 十三、完整分析Socket.hpp中的关键实现
-
- [13.1 为什么Socket析构函数是virtual?](#13.1 为什么Socket析构函数是virtual?)
- [13.2 为什么TcpSocket析构时调用CloseSocket?](#13.2 为什么TcpSocket析构时调用CloseSocket?)
- [13.3 为什么AcceptConnection返回Socket*?](#13.3 为什么AcceptConnection返回Socket*?)
- [13.4 为什么Recv要使用append?](#13.4 为什么Recv要使用append?)
- [13.5 为什么接收缓冲区使用vector?](#13.5 为什么接收缓冲区使用vector?)
- [13.6 为什么Recv无法直接保证返回一条完整请求?](#13.6 为什么Recv无法直接保证返回一条完整请求?)
- [13.7 为什么Send不能只调用一次send?](#13.7 为什么Send不能只调用一次send?)
- [13.8 为什么使用MSG_NOSIGNAL?](#13.8 为什么使用MSG_NOSIGNAL?)
- [13.9 为什么有些函数叫OrDie?](#13.9 为什么有些函数叫OrDie?)
- 十四、三个Build方法分别完成什么?
-
- [14.1 BuildListenSocketMethod](#14.1 BuildListenSocketMethod)
- [14.2 BuildConnectSocketMethod](#14.2 BuildConnectSocketMethod)
- [14.3 BuildNormalSocketMethod](#14.3 BuildNormalSocketMethod)
- 十五、单独验证Socket.hpp
-
- [15.1 测试文件的功能](#15.1 测试文件的功能)
- [15.2 接口调用](#15.2 接口调用)
- [15.3 完整实现](#15.3 完整实现)
- 十六、Socket封装与应用层协议之间是什么关系?
-
- [16.1 Socket负责什么?](#16.1 Socket负责什么?)
- [16.2 Protocol应该负责什么?](#16.2 Protocol应该负责什么?)
- [16.3 Calculate负责什么?](#16.3 Calculate负责什么?)
- [16.4 因此工程应该怎样分层?](#16.4 因此工程应该怎样分层?)
- 十七、下一步需要解决的问题:TCP如何识别完整报文?
- 十八、几个值得思考的问题
-
- [18.1 定制协议是否一定需要JSON?](#18.1 定制协议是否一定需要JSON?)
- [18.2 一个程序是否只能使用一种应用层协议?](#18.2 一个程序是否只能使用一种应用层协议?)
- [18.3 结构化数据与报文边界是同一个问题吗?](#18.3 结构化数据与报文边界是同一个问题吗?)
- [18.4 为什么要先封装Socket,再实现Protocol?](#18.4 为什么要先封装Socket,再实现Protocol?)
- 十九、本篇总结
前言:学会Socket编程以后,为什么还需要设计协议?
在前面的TCP网络编程中,我们已经学习了:
cpp
socket()
bind()
listen()
accept()
connect()
recv()
send()
并且能够实现简单的Echo Server。
例如客户端发送:
text
hello
服务器返回:
text
server echo# hello
这个程序已经可以完成基本的网络通信。
但是,如果希望服务器不只是原样返回客户端的数据,而是能够完成实际的计算任务呢?
例如:
text
客户端:100 + 200
服务器:300
这时我们就需要考虑:
- 客户端应该怎样描述计算请求?
- 服务器应该怎样理解两个操作数和运算符?
- 服务器应该返回哪些信息?
- 如何把C++对象转换成适合网络传输的数据?
- TCP接收的数据为什么不一定是一条完整请求?
这些问题已经不只是TCP传输层要解决的问题了。
它们属于应用程序自身的通信约定。
也就是:
应用层协议。
本篇先将应用层协议的设计动机和Socket封装讲清楚。下一篇再正式实现计算请求、响应、Jsoncpp序列化、报文封装与解析,以及完整的网络计算器。
一、什么是应用层?
1.1 从TCP/IP体系结构开始
我们已经学习过TCP/IP五层模型:
text
应用层
↓
传输层
↓
网络层
↓
数据链路层
↓
物理层
其中TCP属于传输层。
我们通过:
cpp
socket(AF_INET, SOCK_STREAM, 0);
创建TCP Socket。
再通过:
cpp
send()
recv()
完成数据收发。
但是,TCP并不知道:
text
我们传输的是聊天消息
还是计算请求
还是文件内容
还是某个业务对象
TCP主要负责按照协议提供可靠、有序的字节流传输服务。
至于字节流表示什么业务含义,由应用程序自身决定。
1.2 我们编写的网络服务主要工作在哪一层?
例如:
text
网络聊天室
在线计算器
网络字典
HTTP服务器
这些程序虽然依赖TCP或UDP传输数据,但真正处理用户需求的逻辑主要位于应用层。
因此,我们可以先建立一个认识:
应用层负责定义网络数据的业务含义与交互规则。
例如:
text
客户端发送什么格式?
服务器如何解释?
服务器回复什么格式?
客户端如何判断处理结果?
这些规则就构成了应用层协议的重要内容。
1.3 应用层协议一定是HTTP吗?
当然不是。
HTTP是一种已经标准化的应用层协议。
但我们也完全可以为自己的程序制定一个简单协议。
例如:
text
客户端发送:10 + 20
服务器返回:30
只要双方约定好消息格式和处理规则,就可以进行通信。
不过,需要强调:
自定义协议能够在两个程序之间工作,并不意味着它天然具有通用性、兼容性或完整的错误处理机制。
这些都取决于协议具体如何设计。
二、再谈协议:协议到底是什么?
2.1 协议就是通信双方遵守的约定
前面学习网络协议时,我们经常听到:
text
TCP协议
IP协议
UDP协议
但协议并不是一个只存在于计算机网络中的抽象名词。
可以从更简单的角度理解:
协议就是通信双方共同遵守的一组约定。
例如,两个人提前规定:
text
发送一个整数表示请求
返回一个整数表示计算结果
那么双方就可以按照这个约定交换数据。
但如果没有事先约定:
text
100 200 +
究竟应该表示:
text
100 + 200
还是:
text
100、200和+是三个没有关系的字段
接收方就无法正确理解。
2.2 为什么说协议需要结构化的数据?
假设客户端发送:
text
100+200
服务器需要知道:
text
100:第一个操作数
+:运算符
200:第二个操作数
这条消息并不是毫无结构的字符串。
它实际上描述了三个字段:
cpp
int x;
char op;
int y;
因此可以说:
通信协议需要让发送方和接收方对数据结构与含义达成一致。
2.3 Socket接口负责解释这些字段吗?
不负责。
例如:
cpp
send(sockfd, buffer, len, 0);
只是把指定缓冲区中的字节交给对应Socket的发送操作。
它不会主动判断:
text
哪个字段是x
哪个字段是y
哪个字符表示除法
服务器即使成功调用:
cpp
recv()
获得全部数据,也仍然需要按照应用层协议解析内容。

三、需求分析:设计一个网络版计算器
现在假设我们要开发:
TCP网络计算器。
3.1 程序需要完成哪些功能?
客户端输入:
text
10 + 20
将两个操作数和运算符发送给服务器。
服务器收到请求后:
text
解析请求
↓
识别操作数
↓
识别运算符
↓
执行计算
↓
生成响应
↓
发送回客户端
客户端最终显示:
text
result = 30
3.2 支持哪些运算?
可以先支持:
text
+
-
*
/
%
分别对应:
text
加法
减法
乘法
除法
取模
3.3 为什么响应不能只包含一个result?
例如:
text
10 / 0
这个请求无法完成普通整数除法。
如果服务器只发送:
text
0
客户端无法区分:
text
计算结果确实等于0
与:
text
发生了除零错误
因此,响应除了计算结果,还需要保存:
text
计算状态
可以设计:
cpp
int result;
int code;
其中:
text
result:计算结果
code:计算状态
至于不同状态码代表什么,由协议双方共同约定。
四、方案一:直接制定字符串格式
最简单的做法是:
客户端直接发送:
text
100+200
服务器按照固定格式解析。
4.1 制定规则
例如:
- 请求包含两个整数操作数。
- 两个整数之间存在一个运算符。
- 运算符只能来自预先规定的集合。
- 运算符与数字之间没有空格。
- 接收方按照相同规则解释数据。
于是:
text
1+2
表示:
text
x = 1
op = '+'
y = 2
服务器解析以后:
cpp
result = x + y;
返回:
text
3
4.2 这种方式是不是协议?
是。
因为客户端和服务器已经约定了:
text
请求字段如何组织
每个字段表示什么
服务器如何解释
因此,这已经是一种简单的自定义应用层协议。
4.3 这种方案存在哪些问题?
例如:
text
-10+-20
需要处理负数。
text
100/0
需要处理计算异常。
text
1 + 2
存在空格。
如果协议又要增加:
text
请求ID
计算状态
其他业务字段
手工拼接和解析字符串的复杂度就可能不断增加。
所以可以考虑:
先把协议描述为结构化对象,再统一进行序列化。
五、方案二:定义请求和响应对象
这种方式更适合我们接下来实现的计算器。
5.1 请求对象
定义:
cpp
class Request
{
private:
int _data_x;
int _data_y;
char _oper;
};
其中:
cpp
_data_x
表示第一个操作数。
cpp
_data_y
表示第二个操作数。
cpp
_oper
表示运算符。
例如:
cpp
Request req(100, 200, '+');
这个对象在业务逻辑中就代表:
text
100 + 200
5.2 响应对象
定义:
cpp
class Response
{
private:
int _result;
int _code;
};
例如:
cpp
Response resp(300, 0);
可以约定:
text
result = 300
code = 0 表示成功
5.3 怎样把Request对象发送给服务器?
这里出现一个关键问题。
在客户端内存中:
cpp
Request req(100, 200, '+');
是一个C++对象。
但Socket发送接口处理的是字节数据。
因此,我们需要一种转换过程:
text
Request对象
↓
转换为可传输的数据表示
↓
send()
服务器收到以后,还要反过来执行:
text
recv()
↓
收到的数据表示
↓
转换为Request对象
这就是:
序列化与反序列化。

六、什么是序列化与反序列化?
6.1 序列化
序列化(Serialization)指:
将结构化数据转换成适合保存或者传输的表示形式。
例如:
cpp
Request req(100, 200, '+');
转换为:
json
{
"datax": 100,
"datay": 200,
"oper": 43
}
其中:
text
43
是字符:
cpp
'+'
的ASCII编码值。
为什么使用数字43保存运算符?
因为我们可以明确规定:
text
oper字段使用整数保存运算符字符编码
这样客户端和服务器读取字段时更容易保持一致。
6.2 反序列化
反序列化(Deserialization)则是相反过程。
例如收到:
json
{
"datax": 100,
"datay": 200,
"oper": 43
}
解析后得到:
cpp
_data_x = 100;
_data_y = 200;
_oper = '+';
于是服务器就恢复了原来的计算请求。
6.3 为什么不直接发送结构体内存?
例如:
cpp
send(
sockfd,
&req,
sizeof(req),
0
);
看起来似乎可以直接发送对象。
但直接发送C++对象内存会面临:
text
结构体或对象内存布局
对齐与填充字节
整数的字节序
不同平台的表示差异
对象中的指针等非持久化信息
问题。
特别是如果对象中包含:
cpp
std::string
这样的成员,简单发送整个对象的内存,不能正确传递其所管理的字符串数据。
所以网络程序一般需要明确的序列化规则。
6.4 为什么选择JSON?
JSON是一种常见的结构化数据表示格式。
例如:
json
{
"datax": 100,
"datay": 200,
"oper": 43
}
相对于手工拼接字符串,它能够清楚地表示:
text
字段名称
字段类型
字段值
对于当前网络计算器,我们准备使用:
text
Jsoncpp
完成JSON序列化和反序列化。
这样就可以将精力主要放在:
如何制定通信协议,以及怎样处理TCP字节流。
七、重新理解TCP为什么支持全双工
7.1 TCP连接为什么能够同时发送和接收?
一个TCP连接的每个端点,在内核中都具有与发送和接收有关的缓冲与状态管理机制。
可以简化为:
text
主机A 主机B
应用程序A 应用程序B
| |
v v
发送缓冲区 接收缓冲区
| ^
+------------ TCP数据流 ------------+
| |
v ^
接收缓冲区 发送缓冲区
^ |
+------------ TCP数据流 -------------+
因此:
text
主机A → 主机B
的数据发送,与:
text
主机B → 主机A
的数据发送可以同时进行。
这就是TCP支持全双工通信的基础。
7.2 为什么同一个sockfd可以读写?
因为同一个已经连接的TCP Socket,可以用于访问该连接的发送和接收功能。
例如:
cpp
send(sockfd, ...);
用于发送。
cpp
recv(sockfd, ...);
用于接收。
因此并不需要人为把一个正常的TCP通信Socket拆成:
text
专门发送的Socket
专门接收的Socket
7.3 send成功是不是意味着对方调用recv成功了?
不是。
通常情况下,send() 成功表示调用的数据已经被内核接受处理,或者部分数据已经被接受。
不能据此断言:
text
对方已经收到完整消息
对方已经调用recv
对方已经完成业务处理
实际发送时机、传输过程和相关错误处理,由TCP协议栈管理。
7.4 为什么TCP会产生应用层消息边界问题?
因为TCP面向的是连续字节流。
例如客户端连续发送:
text
第一条请求:100+200
第二条请求:300-50
服务器的某次:
cpp
recv()
可能读到:
text
第一条请求的一部分
也可能读到:
text
两条请求拼接形成的一段连续字节
因此:
TCP保证可靠、有序的字节流传输,但不会自动为应用程序划分一条条完整的业务请求。
这就是下一篇需要设计报文封装和拆包接口的原因。

八、正式开始设计网络计算器工程
8.1 整体要实现哪些模块?
根据刚才的分析,需要完成:
text
网络通信模块
应用层协议模块
计算业务模块
服务器模块
客户端模块
可以规划为:
text
network_calculator/
├── Socket.hpp
├── Protocol.hpp
├── Calculate.hpp
├── TcpServer.hpp
├── TcpServerMain.cc
├── TcpClientMain.cc
└── Makefile
另外还可以预留:
text
Daemon.hpp
用于以后根据需要组织服务器后台运行功能。
但当前协议和序列化阶段并不需要先实现守护进程。
8.2 模块之间的关系
text
TcpClientMain.cc
|
+------ Socket.hpp
|
+------ Protocol.hpp
TcpServerMain.cc
|
v
TcpServer.hpp
|
+------ Socket.hpp
|
+------ Protocol.hpp
|
+------ Calculate.hpp
Socket.hpp
负责:
text
创建TCP Socket
绑定地址
设置监听
接受连接
连接服务器
发送与接收字节
Protocol.hpp
负责:
text
Request请求对象
Response响应对象
序列化与反序列化
报文封装与拆包
Factory对象创建
Calculate.hpp
负责:
text
根据Request执行计算
生成Response
TcpServer.hpp
负责:
text
接受客户端连接
组织请求处理流程
TcpClientMain.cc
负责:
text
读取用户输入
构造请求
接收和显示结果
因此,可以看出:
Socket负责字节传输,Protocol负责数据格式,Calculate负责业务运算。
九、为什么先实现Socket.hpp?
前面已经使用过TCP Socket API。
如果每个服务器项目都重复写:
cpp
socket()
bind()
listen()
accept()
connect()
recv()
send()
就会产生大量类似代码。
所以希望先封装:
cpp
Socket
接口类。
以后即使底层Socket操作需要修改,上层协议模块也不必到处修改系统调用。
这里采用两层设计:
text
Socket抽象基类
|
v
TcpSocket具体实现类
9.1 Socket抽象基类负责什么?
它规定一个Socket实现应该提供哪些功能。
例如:
cpp
CreateSocketOrDie()
创建Socket。
cpp
BindSocketOrDie()
绑定地址。
cpp
ListenSocketOrDie()
设置监听。
cpp
AcceptConnection()
接受连接。
cpp
ConnectServer()
连接服务器。
cpp
Recv()
接收数据。
cpp
Send()
发送数据。
9.2 为什么要使用纯虚函数?
因为抽象基类负责规定接口,而不直接限定每个具体网络实现的内部细节。
例如:
cpp
virtual void CreateSocketOrDie() = 0;
表示派生类必须实现对应功能。
当前只有:
cpp
TcpSocket
一个具体实现。
不过,这样的接口组织方式已经具备一定的扩展基础。
十、Socket.hpp还需要封装什么?
除了底层接口,还需要封装服务器和客户端的标准创建流程。
10.1 服务端创建流程
服务器通常执行:
text
socket
↓
bind
↓
listen
因此可以封装:
cpp
void BuildListenSocketMethod(
uint16_t port,
int backlog
);
内部按顺序调用:
cpp
CreateSocketOrDie();
BindSocketOrDie(port);
ListenSocketOrDie(backlog);
以后创建服务器监听Socket时,只需要:
cpp
sock.BuildListenSocketMethod(8888, 5);
10.2 客户端创建流程
客户端通常执行:
text
socket
↓
connect
所以设计:
cpp
bool BuildConnectSocketMethod(
std::string& serverip,
uint16_t serverport
);
内部:
cpp
CreateSocketOrDie();
return ConnectServer(serverip, serverport);
10.3 已经接受的连接怎么处理?
服务器通过:
cpp
accept()
获得的文件描述符已经对应一条通信连接。
因此不应该再次:
cpp
socket()
bind()
listen()
只需要让一个 TcpSocket 对象管理这条现有连接。
所以设计:
cpp
void BuildNormalSocketMethod(int sockfd);
内部调用:
cpp
SetSockFd(sockfd);
10.4 这里体现了什么设计思想?
可以把:
cpp
BuildListenSocketMethod()
理解为规定执行步骤的模板方法。
它统一安排:
text
创建
绑定
监听
的顺序。
而具体步骤:
cpp
CreateSocketOrDie()
BindSocketOrDie()
ListenSocketOrDie()
由派生类实现。
这体现了模板方法模式的基本思想:
由基类组织流程,由派生类实现具体步骤。
注意,这里说的是面向对象设计模式中的模板方法,不是C++语言的 template 关键字。

十一、先设计Socket.hpp的完整接口
在编写实现以前,先看整个类应该具有什么结构。
cpp
namespace Net_Work
{
class Socket
{
public:
virtual ~Socket() {}
virtual void CreateSocketOrDie() = 0;
virtual void BindSocketOrDie(
uint16_t port
) = 0;
virtual void ListenSocketOrDie(
int backlog
) = 0;
virtual Socket* AcceptConnection(
std::string* peerip,
uint16_t* peerport
) = 0;
virtual bool ConnectServer(
std::string& serverip,
uint16_t serverport
) = 0;
virtual int GetSockFd() = 0;
virtual void SetSockFd(int sockfd) = 0;
virtual void CloseSocket() = 0;
virtual bool Recv(
std::string* buffer,
int size
) = 0;
virtual void Send(
std::string& send_str
) = 0;
public:
void BuildListenSocketMethod(
uint16_t port,
int backlog
);
bool BuildConnectSocketMethod(
std::string& serverip,
uint16_t serverport
);
void BuildNormalSocketMethod(
int sockfd
);
};
class TcpSocket : public Socket
{
// 具体实现
};
}
这里保留了原有接口命名:
text
CreateSocketOrDie
BindSocketOrDie
ListenSocketOrDie
AcceptConnection
ConnectServer
GetSockFd
SetSockFd
CloseSocket
Recv
Send
接下来给出完整代码。
为了能够直接用于后续网络计算器,代码补上几个必要的基础细节:
text
禁止误拷贝文件描述符
关闭后重置描述符
接收时按照实际字节数拼接
发送时处理部分写入
在构造后正确管理Socket生命周期
这些处理不改变接口的核心职责。
十二、Socket.hpp完整实现
cpp
#pragma once
#include <cerrno>
#include <cstdint>
#include <cstdlib>
#include <cstring>
#include <iostream>
#include <stdexcept>
#include <string>
#include <vector>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
namespace Net_Work
{
const int defaultsockfd = -1;
const int backlog = 5;
enum
{
SocketError = 1,
BindError,
ListenError
};
// Socket抽象接口类
class Socket
{
public:
Socket() = default;
virtual ~Socket() {}
Socket(const Socket&) = delete;
Socket& operator=(const Socket&) = delete;
// 由派生类实现的底层操作
virtual void CreateSocketOrDie() = 0;
virtual void BindSocketOrDie(
uint16_t port
) = 0;
virtual void ListenSocketOrDie(
int back
) = 0;
virtual Socket* AcceptConnection(
std::string* peerip,
uint16_t* peerport
) = 0;
virtual bool ConnectServer(
std::string& serverip,
uint16_t serverport
) = 0;
virtual int GetSockFd() = 0;
virtual void SetSockFd(int sockfd) = 0;
virtual void CloseSocket() = 0;
virtual bool Recv(
std::string* buffer,
int size
) = 0;
virtual void Send(
std::string& send_str
) = 0;
public:
// 服务器:socket -> bind -> listen
void BuildListenSocketMethod(
uint16_t port,
int back
)
{
CreateSocketOrDie();
BindSocketOrDie(port);
ListenSocketOrDie(back);
}
// 客户端:socket -> connect
bool BuildConnectSocketMethod(
std::string& serverip,
uint16_t serverport
)
{
CreateSocketOrDie();
return ConnectServer(
serverip,
serverport
);
}
// 已经存在的通信Socket
void BuildNormalSocketMethod(int sockfd)
{
SetSockFd(sockfd);
}
};
// TCP Socket的具体实现
class TcpSocket : public Socket
{
public:
explicit TcpSocket(
int sockfd = defaultsockfd
)
: _sockfd(sockfd)
{
}
~TcpSocket() override
{
CloseSocket();
}
// 创建TCP Socket
void CreateSocketOrDie() override
{
if (_sockfd >= 0)
{
throw std::logic_error(
"socket already exists"
);
}
_sockfd = ::socket(
AF_INET,
SOCK_STREAM,
0
);
if (_sockfd < 0)
{
perror("socket");
std::exit(SocketError);
}
}
// 绑定本地端口
void BindSocketOrDie(
uint16_t port
) override
{
struct sockaddr_in local{};
local.sin_family = AF_INET;
local.sin_addr.s_addr = INADDR_ANY;
local.sin_port = htons(port);
int ret = ::bind(
_sockfd,
reinterpret_cast<sockaddr*>(&local),
sizeof(local)
);
if (ret < 0)
{
perror("bind");
std::exit(BindError);
}
}
// 设置监听状态
void ListenSocketOrDie(
int back
) override
{
int ret = ::listen(
_sockfd,
back
);
if (ret < 0)
{
perror("listen");
std::exit(ListenError);
}
}
// 接受客户端连接
Socket* AcceptConnection(
std::string* peerip,
uint16_t* peerport
) override
{
struct sockaddr_in peer{};
socklen_t len = sizeof(peer);
int newfd = ::accept(
_sockfd,
reinterpret_cast<sockaddr*>(&peer),
&len
);
if (newfd < 0)
{
return nullptr;
}
if (peerport != nullptr)
{
*peerport = ntohs(peer.sin_port);
}
if (peerip != nullptr)
{
char ipbuf[INET_ADDRSTRLEN] = {};
const char* result = ::inet_ntop(
AF_INET,
&peer.sin_addr,
ipbuf,
sizeof(ipbuf)
);
*peerip = result ? ipbuf : "Unknown";
}
// 新对象接管accept返回的文件描述符
return new TcpSocket(newfd);
}
// 主动连接服务器
bool ConnectServer(
std::string& serverip,
uint16_t serverport
) override
{
struct sockaddr_in server{};
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
if (::inet_pton(
AF_INET,
serverip.c_str(),
&server.sin_addr
) != 1)
{
return false;
}
int ret = ::connect(
_sockfd,
reinterpret_cast<sockaddr*>(&server),
sizeof(server)
);
return ret == 0;
}
int GetSockFd() override
{
return _sockfd;
}
// 接管已有Socket文件描述符
void SetSockFd(int sockfd) override
{
if (_sockfd != sockfd)
{
CloseSocket();
_sockfd = sockfd;
}
}
// 关闭Socket并重置状态
void CloseSocket() override
{
if (_sockfd >= 0)
{
::close(_sockfd);
_sockfd = defaultsockfd;
}
}
// 接收字节并拼接到现有字符串末尾
bool Recv(
std::string* buffer,
int size
) override
{
if (buffer == nullptr || size <= 0)
{
return false;
}
std::vector<char> inbuffer(
static_cast<std::size_t>(size)
);
while (true)
{
ssize_t n = ::recv(
_sockfd,
inbuffer.data(),
inbuffer.size(),
0
);
if (n > 0)
{
buffer->append(
inbuffer.data(),
static_cast<std::size_t>(n)
);
return true;
}
if (n == 0)
{
return false;
}
if (errno == EINTR)
{
continue;
}
perror("recv");
return false;
}
}
// 尽可能发送完整字符串
void Send(
std::string& send_str
) override
{
std::size_t total = 0;
while (total < send_str.size())
{
ssize_t n = ::send(
_sockfd,
send_str.data() + total,
send_str.size() - total,
MSG_NOSIGNAL
);
if (n > 0)
{
total += static_cast<std::size_t>(n);
}
else if (n < 0 && errno == EINTR)
{
continue;
}
else
{
throw std::runtime_error(
"send failed"
);
}
}
}
private:
int _sockfd;
};
} // namespace Net_Work
十三、完整分析Socket.hpp中的关键实现
13.1 为什么Socket析构函数是virtual?
cpp
virtual ~Socket() {}
因为我们可能通过基类指针:
cpp
Socket* ptr;
管理派生类对象:
cpp
TcpSocket
如果以后通过基类指针删除派生类对象,需要确保正确执行派生类析构函数。
因此基类应具有虚析构函数。
13.2 为什么TcpSocket析构时调用CloseSocket?
cpp
~TcpSocket() override
{
CloseSocket();
}
因为 TcpSocket 对象负责管理 _sockfd。
当对象生命周期结束时,应释放自己持有的文件描述符。
而:
cpp
CloseSocket()
还会将:
cpp
_sockfd
重置为:
cpp
-1
因此即使此前已经主动关闭过Socket,也不会再根据旧的描述符编号重复关闭。
13.3 为什么AcceptConnection返回Socket*?
cpp
Socket* AcceptConnection(...);
这里体现了通过抽象基类使用具体Socket对象的设计。
例如:
cpp
Socket* conn = new TcpSocket(newfd);
从上层看,只需要知道:
cpp
Socket
的公共接口,而不一定要直接依赖具体的 TcpSocket 类型。
不过返回的是原始指针。
因此调用者必须负责对象生命周期。
后面的服务器代码会使用:
cpp
std::shared_ptr<Socket>
或者:
cpp
std::unique_ptr<Socket>
接管返回指针。
13.4 为什么Recv要使用append?
这是整个Socket封装中特别重要的一点。
原来的思路是:
cpp
*buffer += inbuffer;
其设计目的并不是覆盖字符串,而是:
把本次接收到的数据添加到之前的接收数据后面。
例如第一次:
text
100
第二次:
text
+200
最终得到:
text
100+200
但直接使用C字符串拼接,需要保证字符缓冲区中存在正确的字符串结束符,并且无法自然处理其中包含零字节的数据。
因此更稳妥的写法是:
cpp
buffer->append(
inbuffer.data(),
static_cast<std::size_t>(n)
);
这样使用的是:
cpp
recv()
实际返回的字节数量。
13.5 为什么接收缓冲区使用vector?
这里使用:
cpp
std::vector<char> inbuffer(size);
而不是:
cpp
char inbuffer[size];
因为当 size 是运行时变量时,后者属于可变长度数组形式,并不是标准C++11支持的数组声明方式。
使用:
cpp
std::vector<char>
可以避免依赖编译器的非标准扩展。
13.6 为什么Recv无法直接保证返回一条完整请求?
因为:
cpp
Recv()
只调用TCP接收接口获取当前可用的一段字节流。
例如当前完整请求是:
text
100+200
某次调用可能只得到:
text
100+
所以:
cpp
Recv()
只负责将读到的字节追加到:
cpp
std::string
中。
至于:
text
当前缓冲区中是否包含完整请求
应该由后面的协议解析模块判断。
因此:
Socket负责接收字节,Protocol负责识别报文。
13.7 为什么Send不能只调用一次send?
因为一次:
cpp
send()
不保证把指定的全部数据都发送出去。
例如:
text
需要发送1000字节
某次调用可能只接受:
text
300字节
因此还需要继续发送剩余部分。
所以通过:
cpp
total
记录已经发送的字节数量。
13.8 为什么使用MSG_NOSIGNAL?
在Linux环境中,对已经失效的连接发送数据可能触发 SIGPIPE。
当前使用:
cpp
MSG_NOSIGNAL
避免因为这一信号的默认行为导致进程直接终止,并由程序处理发送错误。
13.9 为什么有些函数叫OrDie?
例如:
cpp
CreateSocketOrDie()
表示:
text
创建失败时直接结束程序
这是当前简化工程采用的错误处理策略。
但需要注意:
这种设计适合基础教学服务器,不表示真实网络库都应在底层直接 exit()。
在完整工程中,也可以使用返回错误码、抛出异常等方式,把错误处理权交给上层。
十四、三个Build方法分别完成什么?
14.1 BuildListenSocketMethod
cpp
void BuildListenSocketMethod(
uint16_t port,
int back
)
{
CreateSocketOrDie();
BindSocketOrDie(port);
ListenSocketOrDie(back);
}
用于服务器监听Socket。
执行流程:
text
创建Socket
↓
绑定端口
↓
进入监听状态
调用示例:
cpp
Net_Work::TcpSocket server;
server.BuildListenSocketMethod(
8888,
Net_Work::backlog
);
14.2 BuildConnectSocketMethod
cpp
bool BuildConnectSocketMethod(
std::string& serverip,
uint16_t serverport
)
{
CreateSocketOrDie();
return ConnectServer(
serverip,
serverport
);
}
用于客户端。
例如:
cpp
Net_Work::TcpSocket client;
std::string ip = "127.0.0.1";
bool ok = client.BuildConnectSocketMethod(
ip,
8888
);
14.3 BuildNormalSocketMethod
cpp
void BuildNormalSocketMethod(
int sockfd
)
{
SetSockFd(sockfd);
}
用于管理已经存在的Socket文件描述符。
例如:
cpp
int connfd = accept(...);
成功后:
cpp
Net_Work::TcpSocket conn;
conn.BuildNormalSocketMethod(connfd);
当前设计中,调用成功后应把这个描述符的管理责任交给 conn 对象,避免其他对象又将同一个原始文件描述符当作独占资源管理。
十五、单独验证Socket.hpp
正式编写网络计算器之前,可以先验证Socket封装能够正确创建TCP Socket。
15.1 测试文件的功能
创建:
text
SocketTest.cc
负责:
text
创建TcpSocket对象
调用CreateSocketOrDie
取得文件描述符
主动关闭Socket
15.2 接口调用
cpp
CreateSocketOrDie()
GetSockFd()
CloseSocket()
15.3 完整实现
SocketTest.cc:
cpp
#include <iostream>
#include "Socket.hpp"
int main()
{
Net_Work::TcpSocket sock;
sock.CreateSocketOrDie();
std::cout
<< "socket fd: "
<< sock.GetSockFd()
<< std::endl;
sock.CloseSocket();
std::cout
<< "after close: "
<< sock.GetSockFd()
<< std::endl;
return 0;
}
编译:
bash
g++ SocketTest.cc \
-std=c++11 \
-Wall -Wextra \
-o socket_test
运行:
bash
./socket_test
可能输出:
text
socket fd: 3
after close: -1
其中:
text
3
只是示例文件描述符。
实际数值由操作系统决定。
十六、Socket封装与应用层协议之间是什么关系?
到这里,需要把前面学习的内容联系起来。
16.1 Socket负责什么?
例如:
cpp
sock.Recv(&buffer, 1024);
负责从TCP连接取得字节。
cpp
sock.Send(data);
负责将字节发送出去。
16.2 Protocol应该负责什么?
例如:
text
请求包含哪些字段?
响应包含哪些字段?
怎样序列化?
怎样反序列化?
怎样识别一条完整报文?
这些问题不应该由底层Socket负责。
16.3 Calculate负责什么?
例如:
text
100 + 200 = 300
10 / 0 = 错误
这是网络计算器的业务逻辑。
它不应该直接操作:
cpp
socket()
bind()
accept()
16.4 因此工程应该怎样分层?
text
TcpClientMain / TcpServer
|
v
Protocol
|
v
Request/Response
|
v
Calculate
网络字节收发
|
v
Socket
|
v
Linux Socket API
从这个结构可以看出:
业务数据如何组织,与数据如何通过TCP传输,是两个不同的问题。

十七、下一步需要解决的问题:TCP如何识别完整报文?
假设我们已经把计算请求序列化为JSON:
json
{"datax":100,"datay":200,"oper":43}
但TCP提供的是字节流。
所以服务器可能先接收到:
text
{"datax":
随后接收到:
text
100,"datay":200,
最后才接收到:
text
"oper":43}
如果第一次调用:
cpp
recv()
以后,就立即:
cpp
Deserialize()
那么JSON字符串可能还不完整。
于是反序列化就可能失败。
但这种失败并不一定说明客户端发送了错误的请求。
也可能只是:
当前TCP接收缓冲区中的应用层数据还没有接收完整。
因此,下一篇需要解决:
text
怎样在连续TCP字节流中识别一条完整报文?
我们将采用一个简单的格式:
text
有效载荷长度
+
换行分隔符
+
有效载荷
+
换行分隔符
例如:
text
5\nhello\n
这也是接下来 Encode() 与 Decode() 的设计依据。
十八、几个值得思考的问题
18.1 定制协议是否一定需要JSON?
不一定。
只要发送方与接收方对格式达成一致,就可以使用不同的序列化方式。
JSON只是其中一种选择。
18.2 一个程序是否只能使用一种应用层协议?
不是。
不同功能、不同消息类型,都可以拥有自己的协议或协议字段。
甚至同一个系统中的客户端与服务器,也可能约定多种业务消息。
18.3 结构化数据与报文边界是同一个问题吗?
不是。
结构化数据解决:
text
收到的数据应该怎样解释?
报文边界解决:
text
应该从TCP字节流的哪个位置开始、哪个位置结束,提取完整的一条消息?
这是两个不同层次的问题。
18.4 为什么要先封装Socket,再实现Protocol?
因为网络计算器需要通过TCP交换请求和响应。
而Socket封装可以先统一:
text
创建连接
接受连接
发送字节
接收字节
这些基础功能。
这样协议模块就可以专注于数据结构与消息格式。
十九、本篇总结
本文首先从应用层的职责出发,重新理解了:
协议就是通信双方对数据结构、字段含义与交互方式达成的约定。
随后以网络计算器为例,比较了两种协议设计方案。
方案一:
text
直接使用字符串约定
例如:
text
1+2
方案二:
text
定义Request和Response结构
通过序列化转换为可传输数据
通过反序列化恢复业务对象
接下来重新理解TCP全双工以及发送缓冲区、接收缓冲区的作用。
最后从工程设计出发,实现:
text
Socket抽象基类
TcpSocket具体实现
以及:
cpp
BuildListenSocketMethod()
BuildConnectSocketMethod()
BuildNormalSocketMethod()
三个封装流程。
但当前只解决了:
text
怎样建立TCP通信
怎样收发字节
还没有真正解决:
text
怎样设计计算请求和响应
怎样使用Jsoncpp进行序列化
怎样识别完整报文
这些问题将在下一篇继续完成。