【Linux-网络】初识计算机网络 Socket套接字 TCP/UDP协议(包含Socket编程实战)
🎬 个人主页:谁在夜里看海.
📖 个人专栏:《C++系列》《Linux系列》《算法系列》
⛰️ 道阻且长,行则将至
目录
📚一、初识计算机网络
📖 背景
📖 网络协议
🔖OSI七层模型
🔖TCP/IP五层(或四层)模型
📖 地址管理
🔖MAC地址
🔖IP地址
🔖分层寻址流程
📚二、socket套接字
📖IP与端口号
📖套接字
🔖任务划分
🔖概念
🔖工作原理
🔖分类
📚三、UDP协议
📖 特点
📖 工作流程
🔖服务器端
🔖客户端
📖 常见API
🔖1.创建套接字
🔖2.绑定 IP 地址和端口
🔖3.发送数据
🔖4.接受数据
🔖5.关闭套接字
📖 UDP网络程序
🔖程序结构概述
🔖详细步骤
🔖具体代码实现
🔖运行步骤
📚四、TCP协议
📖 特点
📖 工作流程
🔖三次握手
🔖四次挥手
📖常见API
🔖3.监听端口
🔖4. 接收连接请求
🔖5. 发送数据
🔖6. 接收数据
🔖7.关闭套接字
📖 TCP网络程序
🔖程序结构概述
🔖详细步骤
🔖具体代码实现
🔖运行步骤
🔖改进(支持并发)
📚一、初识计算机网络
现如今互联网产业繁荣发达,渗透到了社会生活的方方面面,从个人日常生活到各行各业的发展,互联网已经成为了不可或缺的一部分。而这一切都与计算机网络的发展密不可分,互联网的核心功能之一就是信息传播,而计算机网络是信息传输的载体,它提供了信息的高速流通以及全球互联互通的能力。
不过计算机网络并不是计算机与生俱来的,下面我们就来谈谈它的诞生背景以及发展历程吧。
📖 背景
最早的计算机(1940s-1950s)是单机系统,不同计算机之间独立运行,数据存储在打孔卡或磁带等物理介质上,彼此之间没有之间的联系。
因此,当需要不同计算机互相协作处理数据时,只能异步进行,并且要经过多次数据在物理介质上写入与读取:
这种方式在小范围内尚可,但效率显然低下,而且很难实现数据的远程共享,因此,网络互联与局域网(LAN)的概念逐步形成,研究机构和企业内部开始构建计算机之间的连接。
网络互联:多台计算机通过网络连接在一起,数据通过网络进行传输。
局域网:计算机数量更多,通过交换机与路由器连接在一起。
现如今,计算机网络已广泛覆盖全球,形成局域网(LAN),广域网(WAN),互联网(Internet)等多种形式。
❓思考一个问题:数据在网络中是如何进行传输的呢?
数据在网络中传输时,最终是以二进制(0和1)的形式进行传输的,而这些二进制数据在物理介质上传输时,会被转换成不同的信号形式,如电信号、光信号、无线电波等,传输方式取决于使用的网络介质。
因此我们在传输数据时,先要将数据转换成信号,接收方需要将信号转换成数据,但是这当中该如何转换呢?传输方怎么将数据转换成信号的,接受方就得以逆向方式将信号解析成数据(参考二战时期发电报),于是我们就需要定义一套 数据->信号->数据 的规则。
理论上来说,每个人都可以自定义一套规则,就像方言一样,但是不同方言地区的人们存在交流障碍,使用不同协议的设备之间也无法实现数据交流,于是人们约定了一个共同的标准,大家共同遵守,这就是网络协议。
📖 网络协议
引用上述方言的例子,全国范围内要实现语言互通,于是约定了普通话协议,通过这一个协议,就可以实现口头语言层面的互通;但是网络通信要复杂的多,需要分成更多的层次,协议分层的设计理念借鉴了模块化的思想,将复杂的网络通信任务拆分成多个层次,每一层都专注于处理特定的功能,并与相邻层次协同工作。
如果没有分层,网络通信会变得非常复杂,每台设备都需要理解整个通信过程的细节,分层后,每一层只需要关注自己特定的功能,而不必关心其他层的复杂逻辑。
🔖OSI七层模型
OSI(Open System Interconnection,开放系统互连)七层网络模型称为开放式系统互联参考模型, 是一个逻辑上的定义和规范,它把网络从逻辑上分为了7层,每一层都有相关、相对应的物理设备,比如路由器,交换机。
它的最大优点是将服务、接口和协议这三个概念明确地区分开来,概念清楚,理论也比较完整,通过七个层次化的结构模型使不同的系统不同的网络之间实现可靠的通讯。
分层名称 | 功能 | 功能具体解释 | |
7 | 应用层 | 针对特定应用的协议 | 电子邮件:电子邮件协议 |
6 | 表示层 | 设备固有数据格式和网络标准数据格式的转换 | 接收不同形式的信号:文字、图像等 |
5 | 会话层 | 通信管理,负责建立和断开通信连接。管理传输层及以下的分层 | 何时建立连接、断开连接以及保持多久连接。 |
4 | 传输层 | 管理两个节点之间的数据传输,负责可靠传输 | 判断是否有数据丢失 |
3 | 网络层 | 地址管理与路由选择 | 经过哪个路由到达目的地址 |
2 | 数据链路层 | 互连设备之间传输和识别数据帧 | 数据帧与比特流之间的转换 |
1 | 物理层 | 以“0”,“1”表示信号,界定连接器和网线的规格 | 比特流与电子信号之间的切换 |
🔖TCP/IP五层(或四层)模型
TCP/IP是一组协议的代名词,它还包括许多协议,组成了TCP/IP协议簇。TCP/IP通讯协议采用了5层的层级结构,每一层都呼叫它的下一层所提供的网络来完成自己的需求
① 物理层:负责光/电信号的传递方式.。比如现在以太网通用的网线(双绞线)、wifi无线网使用电磁波等。物理层的能力决定了最大传输速率、传输距离、抗干扰性等。集线器(Hub)工作在物理层。
② 数据链路层:负责设备之间的数据帧的传送和识别.。例如网卡设备的驱动、帧同步(就是说从网线上检测到什么信号算作新帧的开始)、冲突检测(如果检测到冲突就自动重发)、数据差错校验等工作。交换机(Switch)工作在数据链路层。
③ 网络层:负责地址管理和路由选择。例如在IP协议中, 通过IP地址来标识一台主机, 并通过路由表的方式规划出两台主机之间的数据传输的线路(路由)。路由器(Router)工作在网路层。
④ 传输层:负责两台主机之间的数据传输。如传输控制协议 (TCP), 能够确保数据可靠的从源主机发送到目标主机。
⑤ 应用层:负责应用程序间沟通,如简单电子邮件传输(SMTP)、文件传输协议(FTP)、网络远程访问协议(Telnet)等。我们的网络编程主要就是针对应用层。
我们还可以通过网络通信中的设备来理解五层模型:
对于一台主机, 它的操作系统内核实现了从传输层到物理层的内容;
对于一台路由器, 它实现了从网络层到物理层;
对于一台交换机, 它实现了从数据链路层到物理层;
对于集线器, 它只实现了物理层;
📖 地址管理
❓为什么需要地址?
✅在网络中,不同设备有各自的地址,就像进程标识符唯一标识进程一般,地址唯一标识设备,根据源地址和目标地址,网络就可以确定数据的传输路径。这里我们要区分两个地址:IP地址与MAC地址。
🔖MAC地址
每个网络设备(如网卡、交换机、路由器等)在出厂时会由制造商根据全球唯一的规则分配MAC地址,且通常不可更改,换句话说,MAC地址唯一标识网络设备,由6个字节(48位)组成:
00:1A:2B:3C:4D:5E 或 00-1A-2B-3C-4D-5E
理论上来说,根据源MAC地址和目标MAC地址就可以确定传输路径,但是会有下面一系列问题:
① 在复杂的网络环境中,设备的位置并不是固定的,如果每次都要手动或自动更新网络中的设备位置,将带来极大的管理开销。
② 网络中可能有成千上万甚至数百万台设备,如果要每个设备都保存其位置,存储和查找的开销会非常大。
于是为了在网络中更好地定位设备与管理地址,引入了IP地址:
🔖IP地址
IP地址(Internet Protocol Address)是一种逻辑地址(虚拟地址),它可以在网络上唯一标识一台设备。IP地址不是物理意义上的地址,而是设备在接入网络后分配的“虚拟地址”,用于在网络中进行寻址和通信。
例如:你的手机的MAC地址不会改变,无论在哪里连接网络。是当你从家庭Wi-Fi切换到公共Wi-Fi时,手机的IP地址会发生变化,因为网络不同。
IP地址有两种类型:IPv4 vs IPv6
① IPv4(32位,常见)
格式:192.168.1.1
(点分十进制表示法),地址数量约43亿个,目前已接近耗尽。
② IPv6(128位,未来趋势)
格式:2001:db8::ff00:42:8329
(冒号分隔的十六进制表示),地址数量极其极其极其庞大,庞大到几乎可以给每一粒沙子分配一个地址。
🔖分层寻址流程
区分了上述两个地址之后,我们来介绍数据传输的两个阶段:
① 网络层(IP地址):
负责端到端的寻址和路径选择,确保数据能够从源设备到达目标设备,即“逻辑寻址”。数据包从源IP地址到目标IP地址,经过多个路由器跳跃,逐步向目的地靠近。
② 数据链路层(MAC地址):
负责局域网(LAN)内的寻址和传输,即在同一网段内将数据正确传递到目标设备。每当数据包到达一个新的局域网时,源设备通过ARP协议查询目标设备的MAC地址,并在数据链路层进行传输。
❓为什么在“宏观”上使用IP地址寻址,在局部使用MAC地址:
IP地址提供了一种分层的、逻辑化的地址体系,使设备能够在全球范围内被唯一标识和访问,而无需关心设备的物理位置;
交换机工作在数据链路层,它维护一个MAC地址表(CAM表),记录设备的MAC地址及其连接端口,使用硬件逻辑快速查找并转发数据。如果用IP地址寻址,交换机将不得不解析IP并进行复杂的处理,增加延迟和处理开销。
📚二、socket套接字
📖IP与端口号
我们需要清楚一个概念:网络通信到底是谁在通信,是主机设备吗?并不是,例如当你想给别人发信息的时候,是不是需要打开通信软件,在通信软件上编辑信息并发送,这个通信的过程其实是软件,也就是进程在执行,所以网络通信的本质就是进程间通信。
然而当我们的数据发送到目标主机时,目标主机怎么知道这个数据是归哪个进程的呢?就是通过端口号来标识进程,定位数据的发送位置:
端口号用来唯一标识一个进程, 告诉操作系统, 当前的这个数据要交给哪一个进程来处理,端口号和IP地址结合可以标识网络上某一台主机的某一个进程,所以在网络通信时,端口号和IP地址两者缺一不可。
❓问题来了,我们知道在操作系统中,进程标识符PID也可以唯一标识进程,那为什么在网络通信时不可以直接用PID,而是引入一个新概念——端口号呢?
✅PID是系统内部管理的,PID的分配、管理方式完全依赖于具体的操作系统,如果网络通信直接使用PID来标识进程,那么不同的操作系统(Windows、Linux、macOS)在进程管理上的不同实现将导致兼容性问题,使得应用无法跨平台运行。
引入端口号的目的是解耦:端口号是跨平台、跨操作系统的标准,无论在哪个系统上,端口号的功能和意义都是一致的。因此,应用程序可以在不同的主机、不同的操作系统上使用相同的端口号。
🔖另外:一个进程可以绑定多个端口号,但是一个端口号不能被多个进程绑定。
📖套接字
我们现在已知,网络通信的本质是不同主机上的进程进行通信。不同主机上的进程要实现跨网络的通信,需要经过OSI七层模型,在每一层呢都需要对数据包进行封装,例如:
① 传输层:添加端口号,用于标识通信的具体进程;
② 网络层:添加IP地址,用于标识源主机与目标主机。等等
如果每一层的封装操作都由用户手动执行,不仅繁琐复杂,而且许多操作是重复的。因此,为了简化流程,操作系统可以自动处理一部分常规任务,而用户只需执行必要的步骤。
🔖任务划分
在网络通信中,用户和操作系统的职责可以划分为:
① 用户需要完成的任务:
用户必须决定用哪个进程进行通信,而进程在网络中通过端口号唯一标识,因此封装端口号的工作需要用户手动执行;
② 操作系统可以自动完成的任务:
例如,IP地址是主机的固定属性,用户无需手动绑定,这项工作可以交由操作系统自动完成。而传输层以下(网络层、数据链路层、物理层)的封装与传输操作,也可以交由操作系统处理,从而简化用户的操作流程。
因此,我们只需要在用户层提供一个接口,用户完成端口号的封装操作后,通过调用接口,操作系统就可以自动完成后续的封装传输操作,而这个接口就是套接字 Socket:
🔖概念
Socket(套接字) 是计算机网络中进程间通信的接口,它提供了一种在网络中不同主机上的进程之间进行数据收发的机制。
在网络通信过程中,Socket充当了应用层与传输层之间的桥梁,帮助应用程序与底层网络协议进行交互,使开发者无需关心底层的复杂细节,只需通过Socket提供的接口进行数据传输。
🔖工作原理
Socket的工作原理如下:
① 创建Socket:应用程序调用操作系统提供的API创建一个套接字对象。
② 绑定地址与端口(服务器端):将Socket绑定到指定的IP地址和端口,使其可以接收来自其他设备的连接请求。
③ 连接(客户端)/监听(服务器):客户端发起连接请求,服务器监听并等待连接。
④ 数据传输:双方建立连接后,进行数据的发送与接收。
🔖分类
Socket分为以下两种类型:
① 流式Socket(TCP):采用TCP协议,提供面向连接、可靠的数据传输;
② 数据报Socket(UDP):采用UDP协议,提供无连接、不可靠但高效的数据传输。
两种类型都有各自的应用场景,TCP 适用于对数据完整性和顺序性要求较高的场景,而 UDP 适用于对实时性要求较高,而不需要保证可靠性的场景。下面我们分别来介绍这两种协议:
📚三、UDP协议
UDP(User Datagram Protocol,用户数据报协议) 是一种无连接、不可靠、面向报文的传输层协议。它提供了一种简单、高效的通信方式,适用于对实时性要求高,但不需要保证数据完整性的场景,如视频通话、在线游戏、实时广播等。
📖 特点
① 无连接:UDP在通信前不需要建立连接,直接发送数据,不需要"握手"过程。
② 不可靠:UDP不保证数据包的到达、顺序,数据可能会丢失、重复、乱序。
③ 面向报文:UDP以报文(Datagram)为单位进行发送,发送方的一个报文就是接收方接收到的完整报文,不会合并或拆分数据。
④ 速度快、开销小:UDP头部仅8字节,相较于TCP的20~60字节,协议开销小,传输效率高。
📖 工作流程
❓提问:进行网络通信的前提是,知晓对方主机的IP地址以及处理通信的进程端口号,但是这些信息我们应该怎么获取呢,由对方告知我们吗,那对方想告知我们是不是也要通过网络通信,那是不是也要知晓我们的IP与端口号信息......这样就陷入一个死循环。了解了Socket的编程模型,就可以回答上述问题了:
Socket的使用可以分为服务器端和客户端两种编程模型。服务器端常保持运行状态,并且端口号公开,这样客户端就可以随时向服务端发送数据,客户端发送的数据包还包含了源IP与端口号信息,因此服务器端接受数据后就可以向客户端发送数据了。
那么客户端与客户端之间的通信呢?需要通过服务端,因为客户端只知道服务端的地址信息,而服务端保存了所有与他建立通信信道的客户端地址信息,因此客户端之间的通信实际上是:客户端1->服务端->客户端2。
由此以来,我们就得到了UDP报文通信的工作流程:
🔖服务器端
① 创建 UDP 套接字(socket()
)
② 绑定 IP 地址和端口(bind()
)
③ 接收客户端数据(recvfrom()
)
④ 处理数据并发送响应(sendto()
)
⑤ 关闭套接字(close()
)
🔖客户端
① 创建 UDP 套接字(socket()
)
② 发送数据到服务器(sendto()
)
③ 接收服务器响应(recvfrom()
)
④ 关闭套接字(close()
)
下面我们来具体介绍UDP通信的常见API:
📖 常见API
🔖1.创建套接字
int socket(int domain, int type, int protocol);
① 参数1:domain,表示IP协议类型,AF_INET
(IPv4)、AF_INET6
(IPv6)
② 参数2:type,表示通信协议类型,SOCK_DGRAM
(UDP 套接字)
③ 参数3:protocal,通常为 0
(系统自动选择协议)
④ 返回值:成功返回 socket 描述符,失败返回 -1
❓
socket描述符是什么:
✅与文件描述符的使用方式相似,都是指向内核中相关数据结构的整形索引,套接字描述符指向一个socket数据结构,该结构包含网络通信所需的各种信息,如协议类型、端口号、IP地址等。
示例:
int sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd == -1) {
perror("socket 创建失败");
exit(1);
}
🔖2.绑定 IP 地址和端口
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
① 参数1:sockfd,套接字文件描述符
② 参数2:addr,服务器地址结构(sockaddr_in
)
③ 参数3:addlen,地址结构大小
④ 返回值:成功返回 0
,失败返回 -1
示例:
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
server_addr.sin_addr.s_addr = INADDR_ANY;
if (bind(sockfd, (struct sockaddr*)&server_addr, sizeof(server_addr)) == -1) {
perror("bind 失败");
exit(1);
}
🔖3.发送数据
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,
const struct sockaddr *dest_addr, socklen_t addrlen);
① sockfd:套接字描述符
② buf:需要发送的数据
③ len:数据长度
④ flags:一般设置为 0
⑤ dest_addr:目标服务器地址
⑥ addrlen:目标地址大小
返回值:成功返回发送的字节数,失败返回 -1
示例:
struct sockaddr_in server_addr;
server_addr.sin_family = AF_INET;
server_addr.sin_port = htons(8080);
inet_pton(AF_INET, "192.168.1.100", &server_addr.sin_addr);
char *message = "Hello, UDP Server!";
sendto(sockfd, message, strlen(message), 0,
(struct sockaddr*)&server_addr, sizeof(server_addr));
🔖4.接受数据
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,
struct sockaddr *src_addr, socklen_t *addrlen);
① sockfd:套接字描述符
② buf:用于存储接收到的数据
③ len:缓冲区大小
④ flags:一般设置为 0
⑤ src_addr:发送方地址
⑥ addrlen:地址长度(调用前需赋初值)
返回值:成功返回接受的字节数,失败返回 -1
示例:
char buffer[1024];
struct sockaddr_in client_addr;
socklen_t client_len = sizeof(client_addr);
ssize_t received = recvfrom(sockfd, buffer, sizeof(buffer), 0,
(struct sockaddr*)&client_addr, &client_len);
buffer[received] = '\0';
printf("收到客户端数据: %s\n", buffer);
🔖5.关闭套接字
int close(int sockfd);
① sockfd:需要关闭的 socket 描述符
② 返回值:成功返回 0
,失败返回 -1
示例:
close(sockfd);
📖 UDP网络程序
下面我们来实现一个简单的基于UDP协议的英译汉服务器:
🔖程序结构概述
该 UDP 英译汉服务器主要由以下几个部分组成:
① Socket 封装层(udp_socket.hpp):提供 UDP 套接字的基本操作封装,如 socket
、bind
、sendto
、recvfrom
、close
。
② 服务器封装 (udp_server.hpp):创建服务器,绑定地址,接收客户端请求并进行处理。
③ 客户端封装 (udp_client.hpp):负责发送用户输入的查询词,接收服务器返回的结果。
④ 服务器主程序 (dict_server.hpp):读取用户输入,进行英译汉查询,返回翻译结果。
⑤ 客户端主程序 (dict_client.hpp):允许用户输入单词,查询服务器,并输出翻译结果。
🔖详细步骤
服务器端:
① 实现 UdpSocket
套接字封装
class UdpSocket {
public:
bool Socket(); // 创建 UDP socket
bool Bind(const string &ip, uint16_t port); // 绑定 IP 和端口
bool RecvFrom(string *buf, string *ip, uint16_t *port); // 接收数据
bool SendTo(const string &buf, string &ip, uint16_t port); // 发送数据
bool Close(); // 关闭 socket
private:
int _fd; // 套接字文件描述符
};
② 实现 UdpServer
服务器端封装
class UdpServer {
public:
bool Start(const string &ip, uint16_t port, Handler handler);
private:
UdpSocket _sock;
};
其中 Start() 内部逻辑为:
1. 绑定服务器 IP 和端口。
2. 进入循环,等待客户端请求。
3. 解析客户端请求,调用
Translate()
函数处理。4. 发送翻译结果给客户端。
③ 服务器主程序
int main(int argc, char* argv[]) {
unordered_map<string, string> g_dict = {
{"hello", "你好"},
{"world", "世界"},
{"apple", "苹果"}
};
UdpServer server;
server.Start(argv[1], atoi(argv[2]), Translate);
return 0;
}
客户端:
① 实现 UdpClient
客户端封装
class UdpClient {
public:
bool SendTo(const string& buf);
bool RecvFrom(string *buf);
private:
UdpSocket _sock;
};
负责发送单词给服务器,并接收服务器返回的翻译结构。
② 客户端主程序
int main(int argc, char* argv[]) {
UdpClient client(argv[1], atoi(argv[2]));
for(;;){
cout << "请输入单词: ";
string word;
cin >> word;
client.SendTo(word);
string result;
client.RecvFrom(&result);
cout << "翻译: " << result << endl;
}
return 0;
}
🔖具体代码实现
封装socket套接字:
// udp_socket.hpp
#pragma once
#include <iostream>
using namespace std;
#include <string>
#include <cassert>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;
class UdpSocket
{
public:
UdpSocket():_fd(-1){
};
bool Socket(){
_fd = socket(AF_INET, SOCK_DGRAM, 0);
if(_fd < 0){
perror("socket");
return false;
}
return true;
}
bool Close(){
close(_fd);
return true;
}
bool Bind(const string &ip, uint16_t port){
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(ip.c_str());
addr.sin_port = htons(port);
int ret = bind(_fd, (sockaddr*)&addr, sizeof(addr));
if(ret < 0){
perror("bind");
return false;
}
return true;
}
bool RecvFrom(string *buf, string *ip = NULL, uint16_t *port = NULL){
char tmp[1024 * 10] = {0};
sockaddr_in peer;
socklen_t peerlen = sizeof(peer);
ssize_t read_size = recvfrom(_fd, tmp, sizeof(tmp)-1, 0,
(sockaddr*)&peer, &peerlen);
if(read_size < 0){
perror("recvform");
return false;
}
buf->assign(tmp, read_size);
if(ip != NULL)
*ip = inet_ntoa(peer.sin_addr);
if(port != NULL)
*port = ntohs(peer.sin_port);
return true;
}
bool SendTo(const string &buf, string &ip, uint16_t port){
sockaddr_in peer;
peer.sin_family = AF_INET;
peer.sin_addr.s_addr = inet_addr(ip.c_str());
peer.sin_port = htons(port);
ssize_t write_size = sendto(_fd, buf.data(), buf.size(), 0,
(sockaddr*)&peer, sizeof(peer));
if(write_size < 0){
perror("sendto");
return false;
}
return true;
}
private:
int _fd;
};
封装服务器:
// udp_server.hpp
#pragma once
#include "udp_socket.hpp"
#include <functional>
typedef function<void(const string&, string *resp)> Handler;
class UdpServer
{
public:
UdpServer(){
assert(_sock.Socket());
}
~UdpServer(){
_sock.Close();
}
bool Start(const string &ip, uint16_t port, Handler handler){
bool ret = _sock.Bind(ip, port);
if(!ret) return false;
for(;;){
string req;
string remote_ip;
uint16_t remote_port;
bool ret = _sock.RecvFrom(&req, &remote_ip, &remote_port);
if(!ret) continue;
string resp;
handler(req, &resp);
_sock.SendTo(resp, remote_ip, remote_port);
printf("[%s:%d] req: %s, resp: %s\n", remote_ip.c_str(), remote_port,
req.c_str(), resp.c_str());
}
_sock.Close();
return true;
}
private:
UdpSocket _sock;
};
封装客户端:
// udp_client.hpp
#pragma once
#include "udp_socket.hpp"
class UdpClient
{
public:
UdpClient(const string& ip, uint16_t port)
:_ip(ip)
,_port(port){
assert(_sock.Socket());
}
~UdpClient(){
_sock.Close();
}
bool RecvFrom(string *buf){
return _sock.RecvFrom(buf);
}
bool SendTo(const string& buf){
return _sock.SendTo(buf, _ip, _port);
}
private:
UdpSocket _sock;
string _ip;
uint16_t _port;
};
服务器程序:
// dict_server.cc
#include "udp_server.hpp"
#include <unordered_map>
unordered_map<string, string> g_dict;
void Translate(const string& req, string *resp){
auto it = g_dict.find(req);
if(it == g_dict.end()){
*resp = "未查到!";
return;
}
*resp = it->second;
}
int main(int argc, char* argv[])
{
if(argc != 3)
{
cout << "Usage ./dict_server [ip] [port]" << endl;
return 1;
}
g_dict.insert(make_pair("hello", "你好"));
g_dict.insert(make_pair("world", "世界"));
g_dict.insert(make_pair("C++", "最好的编程语言"));
g_dict.insert(make_pair("apple", "苹果"));
g_dict.insert(make_pair("banana", "香蕉"));
UdpServer server;
server.Start(argv[1], atoi(argv[2]), Translate);
return 0;
}
客户端程序:
// dict_client.cc
#include "udp_client.hpp"
int main(int argc, char* argv[])
{
if(argc != 3)
{
cout << "Usage ./dict_client [ip] [port]" << endl;
return 1;
}
UdpClient client(argv[1], atoi(argv[2]));
for(;;){
string word;
cout << "请输入您要查询的单词:";
cin >> word;
if(!cin){
cout << "Good Bye" <<endl;
break;
}
client.SendTo(word);
string result;
client.RecvFrom(&result);
cout << word << " 意思是" << result << endl;
}
return 0;
}
🔖运行步骤
1. 编译项目
# makefile
.PHONY: all clean
all: dict_client dict_server
dict_client: dict_client.cc
g++ -o $@ $^
dict_server: dict_server.cc
g++ -o $@ $^
clean:
rm -f dict_client dict_server
2. 启动服务器
./dict_server [本地IP地址] [端口号]
3.运行客户端
./dict_client [服务器IP地址] [端口号]
运行结果展示:
如此一来,一个基于UDP协议的建议英译汉服务器就创建好了。
下面我们来介绍一下TCP协议:
📚四、TCP协议
TCP协议(Transmission Control Protocol,传输控制协议)是一种面向连接、可靠的传输层协议,它提供了可靠的数据传输服务,确保数据按照顺序到达,且没有丢失。TCP广泛用于要求数据可靠性和顺序性的场景,如HTTP、FTP、SMTP等应用。
📖 特点
① 面向连接:TCP通信开始之前需要先建立连接,通过“三次握手”过程来确保双方准备就绪,保证数据的可靠传输。
② 可靠性:TCP通过序列号、确认应答(ACK)、重传等机制确保数据的完整性和顺序,数据可以在丢失时进行重传。
③ 流量控制与拥塞控制:TCP提供流量控制(防止接收方来不及处理过多的数据)和拥塞控制(防止网络发生拥塞,保证网络稳定性)。
④ 面向字节流:TCP是面向字节流的协议,数据在传输过程中没有边界,接收方接收到的数据是一个字节流,接收方需要根据实际的协议或应用层协议来分割数据。
⑤ 全双工通信:TCP连接是全双工的,数据可以在任意时刻双向流动。
📖 工作流程
我们在编写基于UDP协议的建议服务器时会发现:服务器与客户端之间每一次发送数据时(send())都需要显示传入目标IP与端口号信息;然而TCP通信提供的是可靠的连接通信信道,也就是在每一次通信时,不需要显示传参地址信息,那么具体是怎么实现的呢?
服务器首先创建一个监听套接字,用于监听客户端的连接请求,收到连接请求后,会创建一个新的套接字,存储了对方的地址信息,之后的网络通信就可以通过该套接字完成。
而通信连接的建立需要经过三次握手,连接的断开需要经过四次挥手:
🔖三次握手
① 第一次握手:客户端发送SYN包到服务器,表示请求建立连接。(确认客户端可以正常发送)
② 第二次握手:服务器接收到SYN包后,返回SYN-ACK包,表示同意建立连接。(确认服务端可以正常接收与正常发生)
③ 第三次握手:客户端接收到SYN-ACK包后,返回ACK包,表示连接建立成功。(确认客户端可以正常接收)
⚠️注:三次握手步骤缺一不可,因为建立连接要确保客户端与服务端都能正常接收、发送数据。
🔖四次挥手
① 第一次挥手:客户端发送FIN包表示关闭连接。(确认客户端已经准备好关闭连接,且没有数据需要发送)
② 第二次挥手:服务器接收到FIN包,返回ACK包,表示收到关闭请求。(确认服务器已经收到关闭请求,但仍然可能有数据需要发送)
③ 第三次挥手:服务器发送FIN包请求关闭连接。(确认服务器完成数据发送,并准备关闭连接)
④ 第四次挥手:客户端接收到FIN包,返回ACK包,连接终止。 (确认客户端收到关闭请求并且关闭连接)
⚠️注:四次挥手同样缺一不可,因为每一方需要有足够的时间来完成数据的发送与接收,确保双方都能完全关闭连接并释放资源。
📖常见API
其中创建套接字(socket())、绑定本地(bind())与UDP通信一样,就不再赘述。
🔖3.监听端口
int listen(int sockfd, int backlog);
① 参数1:sockfd
,套接字描述符
② 参数2:backlog
,连接请求队列的大小(即最多等待多少个连接)
③ 返回值:成功返回0,失败返回-1
示例:
if (listen(sockfd, 5) == -1) {
perror("listen 失败");
exit(1);
}
🔖4. 接收连接请求
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
① 参数1:sockfd
,监听套接字描述符
② 参数2:addr
,客户端地址结构(sockaddr_in
)
③ 参数3:addrlen
,地址结构大小
④ 返回值:成功返回客户端套接字描述符,失败返回-1
示例:
int client_sock = accept(sockfd, (struct sockaddr*)&client_addr, &client_len);
if (client_sock == -1) {
perror("accept 失败");
exit(1);
}
🔖5. 发送数据
ssize_t send(int sockfd, const void *buf, size_t len, int flags);
① sockfd:套接字描述符
② buf:数据缓冲区
③ len:数据长度
④ flags:一般设置为0
返回值:成功返回发送的字节数,失败返回-1
示例:
send(sockfd, "Hello, TCP!", 12, 0);
🔖6. 接收数据
ssize_t recv(int sockfd, void *buf, size_t len, int flags);
① sockfd:套接字描述符
② buf:接收数据的缓冲区
③ len:缓冲区大小
④ flags:一般设置为0
返回值:成功返回接收的字节数,失败返回-1
示例:
char buffer[1024];
ssize_t bytes_received = recv(sockfd, buffer, sizeof(buffer), 0);
🔖7.关闭套接字
int close(int sockfd);
① sockfd:需要关闭的套接字描述符
② 返回值:成功返回0,失败返回-1
示例:
close(sockfd);
📖 TCP网络程序
下面我们来实现基于TCP协议的英译汉服务器:
🔖程序结构概述
该TCP英译汉服务器主要由以下几个部分组成:
① Socket封装层 (tcp_socket.hpp
):提供TCP套接字的基本操作封装,如socket
、bind
、send
、recv
、close
等。
② 服务器封装 (tcp_server.hpp
):创建服务器,绑定地址,接收客户端请求并进行处理。
③ 客户端封装 (tcp_client.hpp
):负责发送用户输入的查询词,接收服务器返回的翻译结果。
④ 服务器主程序 (dict_server.cpp
):读取用户输入,进行英译汉查询,返回翻译结果。
⑤ 客户端主程序 (dict_client.cpp
):允许用户输入单词,查询服务器,并输出翻译结果。
🔖详细步骤
服务器端:
① 实现TcpSocket套接字封装
class TcpSocket {
public:
TcpSocket() :_fd(-1) {}
TcpSocket(int fd) :_fd(fd) {}
bool Socket();
bool Close();
bool Bind(const string &ip, uint16_t port);
bool Listen(int backlog) const;
bool Accept(TcpSocket *new_sock, string *ip = NULL, uint16_t *port = NULL);
bool Recv(string* buf);
bool Send(const string &buf);
bool Connect(const string &ip, uint16_t port);
int GetFd() const;
private:
int _fd;
};
② 实现TcpServer服务器端封装
class TcpServer {
public:
TcpServer(const string& ip, uint16_t port) : _ip(ip), _port(port) {}
bool Start(Handler handler);
private:
TcpSocket _sock;
string _ip;
uint16_t _port;
};
③ 服务器主程序
int main(int argc, char* argv[]) {
if (argc != 3) {
printf("Usage: ./dict_server [ip] [port]\n");
return 1;
}
unordered_map<string, string> g_dict = {
{"hello", "你好"},
{"world", "世界"},
{"apple", "苹果"}
};
TcpServer server(argv[1], atoi(argv[2]));
server.Start(Translate);
return 0;
}
客户端:
① 实现TcpClient客户端封装
class TcpClient {
public:
TcpClient(const string &ip, uint16_t port) : _ip(ip), _port(port) {
assert(_sock.Socket());
}
~TcpClient() { _sock.Close(); }
bool Connect();
bool Recv(string *buf);
bool Send(const string &buf);
private:
TcpSocket _sock;
string _ip;
uint16_t _port;
};
② 客户端主程序
int main(int argc, char* argv[]) {
if (argc != 3) {
printf("Usage: ./dict_client [ip] [port]\n");
return 1;
}
TcpClient client(argv[1], atoi(argv[2]));
bool ret = client.Connect();
if (!ret)
return 1;
for (;;) {
cout << "请输入您要查询的单词:";
string word;
cin >> word;
if (!cin)
break;
client.Send(word);
string result;
client.Recv(&result);
cout << word << " 的意思是:" << result << endl;
}
return 0;
}
🔖具体代码实现
封装socket套接字:
// tcp_socket.hpp
#pragma once
#include <iostream>
using namespace std;
#include <string>
#include <stdlib.h>
#include <cassert>
#include <unistd.h>
#include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
#include <fcntl.h>
typedef struct sockaddr sockaddr;
typedef struct sockaddr_in sockaddr_in;
#define CHECK_RET(exp) if(!exp) {\
return false;\
}
class TcpSocket
{
public:
TcpSocket() :_fd(-1) { }
TcpSocket(int fd) :_fd(fd) { }
bool Socket(){
_fd = socket(AF_INET, SOCK_STREAM, 0);
if(_fd < 0){
perror("socket");
return false;
}
printf("Open fd = %d\n", _fd);
return true;
}
bool Close(){
close(_fd);
printf("Close fd = %d\n", _fd);
return true;
}
bool Bind(const string &ip, uint16_t port){
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(ip.c_str());
addr.sin_port = htons(port);
int ret = bind(_fd, (sockaddr*)&addr, sizeof(addr));
if(ret < 0){
perror("bind");
return false;
}
return true;
}
bool Listen(int backlog) const{
int ret = listen(_fd ,backlog);
if(ret < 0){
perror("listen");
return false;
}
return true;
}
bool Accept(TcpSocket *new_sock, string *ip = NULL, uint16_t *port = NULL){
sockaddr_in peer;
socklen_t peer_len = sizeof(peer);
int new_fd = accept(_fd, (sockaddr*)&peer, &peer_len);
if(new_fd < 0){
perror("accept");
return false;
}
new_sock->_fd = new_fd;
if(ip != NULL)
*ip = inet_ntoa(peer.sin_addr);
if(port != NULL)
*port = ntohs(peer.sin_port);
return true;
}
bool Recv(string* buf){
buf->clear();
char tmp[1024*10] = {0};
ssize_t num = recv(_fd, tmp, sizeof(tmp), 0);
if(num < 0){
perror("recv");
return false;
}
if(num == 0)
return false;
buf->assign(tmp, num);
return true;
}
bool Send(const string &buf){
ssize_t num = send(_fd, buf.data(), buf.size(), 0);
if(num < 0){
perror("send");
return false;
}
return true;
}
bool Connect(const string &ip, uint16_t port){
sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_addr.s_addr = inet_addr(ip.c_str());
addr.sin_port = htons(port);
int ret = connect(_fd, (sockaddr*)&addr, sizeof(addr));
if(ret < 0){
perror("connect");
return false;
}
return true;
}
int GetFd() const{
return _fd;
}
private:
int _fd;
};
封装服务器:
// tcp_server.hpp
#pragma once
#include "tcp_socket.hpp"
#include <functional>
typedef function<void(const string &req, string *resp)> Handler;
class TcpServer
{
public:
TcpServer(const string& ip, uint16_t port)
:_ip(ip)
,_port(port){
}
bool Start(Handler handler){
CHECK_RET(_sock.Socket());
CHECK_RET(_sock.Bind(_ip, _port));
CHECK_RET(_sock.Listen(5));
for(;;){
TcpSocket new_sock;
string peer_ip;
uint16_t peer_port = 0;
bool ret = _sock.Accept(&new_sock, &peer_ip, &peer_port);
if(!ret)
continue;
printf("[Client %s:%d] Connect!\n", peer_ip.c_str(), peer_port);
// 在链路中循环收发数据
for(;;){
string req;
bool ret = new_sock.Recv(&req);
if(!ret){
printf("[Client %s:%d] Disconnect!\n", peer_ip.c_str(), _port);
new_sock.Close();
break;
}
string resp;
handler(req, &resp);
new_sock.Send(resp);
printf("[%s:%d] req: %s, resp: %s\n",_ip.c_str(), _port,
req.c_str(), resp.c_str());
}
}
return true;
}
private:
TcpSocket _sock;
string _ip;
uint16_t _port;
};
封装客户端:
// tcp_client.hpp
#pragma once
#include "tcp_socket.hpp"
class TcpClient
{
public:
TcpClient(const string &ip, uint16_t port)
:_ip(ip)
,_port(port){
assert(_sock.Socket());
}
~TcpClient(){
_sock.Close();
}
bool Connect(){
return _sock.Connect(_ip, _port);
}
bool Recv(string *buf){
return _sock.Recv(buf);
}
bool Send(const string &buf){
return _sock.Send(buf);
}
private:
TcpSocket _sock;
string _ip;
uint16_t _port;
};
服务器程序:
#include "tcp_thread_server.hpp"
#include <unordered_map>
unordered_map<string, string> g_dict;
void Translate(const string& req, string *resp)
{
auto it = g_dict.find(req);
if(it == g_dict.end()){
*resp = "未找到";
return;
}
*resp = it->second;
return;
}
int main(int argc, char* argv[])
{
if(argc != 3){
printf("Usage: ./dict_server [ip] [port]\n");
return 1;
}
g_dict.insert(make_pair("hello", "你好"));
g_dict.insert(make_pair("world", "世界"));
g_dict.insert(make_pair("C++", "世界上最好的语言!"));
g_dict.insert(make_pair("apple", "苹果"));
g_dict.insert(make_pair("banana", "香蕉"));
TcpServer server(argv[1], atoi(argv[2]));
server.Start(Translate);
return 0;
}
客户端程序:
#include "tcp_client.hpp"
int main(int argc, char* argv[])
{
if(argc != 3){
printf("Usage: ./dict_client [ip] [port]\n");
return 1;
}
TcpClient client(argv[1], atoi(argv[2]));
bool ret = client.Connect();
if(!ret)
return 1;
for(;;){
cout << "请输入您要查询的单词:";
string word;
cin >> word;
if(!cin)
break;
client.Send(word);
string result;
client.Recv(&result);
cout << word << " 的意思是:" << result << endl;
}
return 0;
}
🔖运行步骤
1. 编译项目
# makefile
.PHONY:all clean
all:dict_server dict_client
dict_server:dict_server.cc
g++ -o $@ $^
dict_client:dict_client.cc
g++ -o $@ $^
clean:
rm -f dict_server dict_client
2. 启动服务器
./dict_server [本地IP地址] [端口号]
3.运行客户端
./dict_client [服务器IP地址] [端口号]
运行结果展示:
🔖改进(支持并发)
一个服务器肯定是要支持并发访问,但是我们实现的英译汉服务器支持吗:
我们发现,当启动第二个客户端, 尝试连接服务器时,其不能正确的和服务器进行通信,分析原因,是因为我们accecpt了一个请求之后, 就在一直while循环尝试read, 没有继续调用到accecpt, 导致不能接 受新的请求。
所以我们要对服务器程序进行改进,使其支持并发访问的情况,我们可以使用多进程或多线程:
多进程:
在多进程模型中,我们让服务器每接受一个客户端连接请求后,创建一个新的子进程来处理该客户端的请求。父进程会继续监听新的连接请求,从而保证每个连接都可以被独立处理。
#pragma once
#include "tcp_socket.hpp"
#include <signal.h>
#include <functional>
typedef function<void(const string &req, string *resp)> Handler;
class TcpServer
{
public:
TcpServer(const string& ip, uint16_t port)
:_ip(ip)
,_port(port){
signal(SIGCHLD, SIG_IGN);
}
void ProcessServer(TcpSocket &sock, string &ip, uint16_t port, Handler handler){
int pid = fork();
if(pid > 0){
sock.Close();
return;
}
else if(pid == 0){
// 在链路中循环收发数据
for(;;){
string req;
bool ret = sock.Recv(&req);
if(!ret){
printf("[Client %s:%d] Disconnect!\n", ip.c_str(), port);
exit(0);
break;
}
string resp;
handler(req, &resp);
sock.Send(resp);
printf("[%s:%d] req: %s, resp: %s\n",ip.c_str(), port,
req.c_str(), resp.c_str());
}
}
else
perror("fork");
}
bool Start(Handler handler){
CHECK_RET(_sock.Socket());
CHECK_RET(_sock.Bind(_ip, _port));
CHECK_RET(_sock.Listen(5));
for(;;){
TcpSocket new_sock;
string peer_ip;
uint16_t peer_port = 0;
bool ret = _sock.Accept(&new_sock, &peer_ip, &peer_port);
if(!ret)
continue;
printf("[Client %s:%d] Connect!\n", peer_ip.c_str(), _port);
ProcessServer(new_sock, peer_ip, peer_port, handler);
}
return true;
}
private:
TcpSocket _sock;
string _ip;
uint16_t _port;
};
运行结果展示:
我们可以看到,此时两个客户端可以并发访问服务器。
多线程:
在多线程模型中,我们让服务器为每个客户端连接创建一个新的线程来处理请求,而父线程则继续监听新的连接请求。与多进程相比,多线程会消耗更少的系统资源(因为线程共享进程的内存空间)。
#pragma once
#include "tcp_socket.hpp"
#include <functional>
typedef function<void(const string &req, string *resp)> Handler;
class TcpServer
{
public:
TcpServer(const string& ip, uint16_t port)
:_ip(ip)
,_port(port){
}
typedef struct ThreadArg{
TcpSocket _sock;
string _ip;
uint16_t _port;
Handler _handler;
} ThreadArg;
bool Start(Handler handler){
CHECK_RET(_sock.Socket());
CHECK_RET(_sock.Bind(_ip, _port));
CHECK_RET(_sock.Listen(5));
for(;;){
ThreadArg *arg = new ThreadArg();
arg->_handler = handler;
bool ret = _sock.Accept(&arg->_sock, &arg->_ip, &arg->_port);
if(!ret)
continue;
printf("[Client %s:%d] Connect!\n", arg->_ip.c_str(), arg->_port);
pthread_t tid;
if(pthread_create(&tid, NULL, ThreadExec, arg) != 0)
perror("pthread_create");
pthread_detach(tid);
}
return true;
}
static void *ThreadExec(void *arg){
ThreadArg *p = reinterpret_cast<ThreadArg*>(arg);
// 在链路中循环收发数据
for(;;){
string req;
bool ret = p->_sock.Recv(&req);
if(!ret){
printf("[Client %s:%d] Disconnect!\n", p->_ip.c_str(), p->_port);
break;
}
string resp;
p->_handler(req, &resp);
p->_sock.Send(resp);
printf("[%s:%d] req: %s, resp: %s\n",p->_ip.c_str(), p->_port,
req.c_str(), resp.c_str());
}
// 释放内存,关闭描述符
p->_sock.Close();
delete p;
return NULL;
}
private:
TcpSocket _sock;
string _ip;
uint16_t _port;
};
运行结果展示:
此时两个客户端也可以并发访问服务器了
以上就是【初识计算机网络 & Socket套接字 & TCP/UDP协议】的全部内容,欢迎指正~
码文不易,还请多多关注支持,这是我持续创作的最大动力!