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

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会产生应用层消息边界问题?)
  • 八、正式开始设计网络计算器工程
  • 九、为什么先实现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 制定规则

例如:

  1. 请求包含两个整数操作数。
  2. 两个整数之间存在一个运算符。
  3. 运算符只能来自预先规定的集合。
  4. 运算符与数字之间没有空格。
  5. 接收方按照相同规则解释数据。

于是:

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进行序列化

怎样识别完整报文

这些问题将在下一篇继续完成。


相关推荐
万联WANFLOW1 小时前
异地组网速度忽快忽慢到底怎么回事?直连与中继的判断方法和处理步骤
运维·网络·网络协议·p2p
jimmyleeee1 小时前
Agent安全之六:(ASI06)记忆与上下文投毒——当 AI 把“昨天的错误”当成“今天的真理”
网络·人工智能·安全
辰辰观影2 小时前
字幕导入教程:外挂字幕一键加载方法
网络·经验分享
无糖可乐没有灵魂2 小时前
Golang ❀ Netdiag 一个Go开源的网络排障信息收集工具
网络
智学AI3 小时前
AI博主写作素材管理方法分享:资料归集、笔记关联与文档归档
人工智能·笔记
叼烟扛炮3 小时前
[LangChain] 学习笔记(02):LCEL 链式调用与动态模型配置实战
服务器·开发语言·网络·笔记·学习·langchain
weixin_448119943 小时前
Datawhale Jev Cookbook 202610 笔记5
笔记
智慧光迅AINOPOL3 小时前
POF光电复合缆多业务承载原理
网络·安全·智能路由器
一木 之林3 小时前
Dify学习笔记 04 · 检索底座:Embedding、Rerank与向量数据库(第46-54集)
笔记·学习·embedding·dify