一. 预备知识
1. IP地址
因特网是在网络级进行互联的,因此,因特网在网络层(IP层)完成地址的统一工作,即将不同物理网络的地址统一到具有全球惟一性的IP地址上,IP层所用到的地址叫作因特网地址,又叫IP地址。
因特网采用一种全局通用的地址格式,为每一台主机都分配一个IP地址,以此屏蔽物理网络地址的差异,即IP地址的意义就是标识公网内唯一一台主机。
在IP数据包中的信息带有源IP地址和目的IP地址,它们分别标识通信的源结点和目的结点,即信源和信宿。IP数据包经由路由转发的时候,源IP和目的IP不会改变,除非做了NAT转换才能改变。
2. 端口号(port)
网络通信的本质是进程间通信,有了IP就可以标识公网内唯一的一台主句,想要完成网络通信我们还需要一个东西来标识一台主机上的某个进程,这个标识就是端口号(port)。
端口号是传输层协议的内容,它包括如下几个特点:
端口号是一个2字节,16比特位的整数。
一台主机中,一个端口号只能被一个进程所占用。
理解 “端口号” 和 “进程ID”
我们之前在学习操作系统的时候,知道pid可以用来标识进程;此处我们的端口号也是唯一标识一个进程。那么这两者之间又存在怎样的关系呢?
二者的相同点都是唯一标识主机内的一个进程,区别在于pid强调在系统的范围呢标识进程;而端口号强调在网络的范围内去标识进程。
既然pid已经做到唯一标识一个进程,为何还要引入端口号呢?我们可以从生活的角度去理解这种情况:即然每个人都有了唯一标识自己的身份照号,为何学校还要给我们分配学号呢?直接用身份照号不行吗?
学校给学生引入学号后,除了唯一标识学生这个作用外还有其他两个优点:
方便记忆和书写。常见的学号例如:20204912,因为比较短,这样就方便记忆,而且考试的时候书写也容易。试想一下,如果以身份证代替学号,能不能正确背下来是一回事,太长了万一考试的时候记错或写错了一个数字就麻烦了。
可以自己设定数字的意义,方便组织和管理。例如学号:20204912,一眼就能看出你是2020级的学生;对应的身份证号不同位置的数字也有各自的意义,但于学校管理意义不大。
源端口号和目的端口号
对应到网络层协议的源IP和目的IP,传输层协议(TCP和UDP)的数据段中也有两个端口号, 分别叫做源端口号和目的端口号.,它们就是在描述 “数据是那个进程发送的, 要发给另外那个进程”。
3. socket网络通信
socket通信的本质就是跨网络的进程间通信,任何的网络客户端和网络服务如果要进行正常的数据通信,它们必须要有自己的端口号和匹配所属主机的IP地址。
4. 认识TCP/UDP协议
我们进行网络编程时通常是在应用层编码,应用层下面就是传输层。应用层往下传输数据时不必担心也没有必要知道数据的传输情况如何,这个具体地交给传输层来解决,所以我们有必要简单了解一下传输层的两个重要协议TCP和UDP。
TCP协议
TCP全称Transmission Control Protocol,即传输控制协议,它有如下特点:
属于传输层协议。
有连接。
可靠传输。
面向字节流。
UDP协议
UDP全称User Datagram Protocol,即用户数据报协议,它有如下特点:
属于传输层协议。
无连接。
不可靠传输。
面向数据报。
在我们的认知里一定要是安全的、稳定的才好,那传输层为什么还要引入一个不可靠传输方式的UDP协议呢?TCP协议虽然是可靠传输,但是“可靠”是要付出一些效率上的成本的,可能会导致传输速度比较慢,而且实现起来相对复杂;以这个角度去看UDP协议,虽然可能在传输过程中出现丢包的情况,但效率上是要更快的。通常两个协议可以搭配起来使用,网速快时用TCP协议,网速慢时用UDP协议,但如果是要传输重要数据的时候就应该只用TCP了。
5. 网络字节序
我们知道,内存中的数据权值排列相对于内存地址的大小有大端和小端之分:
大端存储:高权值数字存到内存的低地址位置上,低权值存到高地址上。
小端存储:高权值数字存到内存的高地址位置上,低权值存到高地址上。
数据在发送时,发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序以字节为单位发出;接收主机把接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序以字节为单位保存的。即先发出低地址的数据,后发出高地址的数据;接收到的数据也是按低地址到高地址的顺序存。
如果发送端和接收端主机的存储字节序不同,则会造成发送的数据和识别出来的数据不一致的问题,如下图所示:
网络在传输数据时同样有大端小端之分,TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高权值,不管这台主机是大端机还是小端机,,最后都要按照TCP/IP规定的网络字节序(大端)来发送/接收数据:
如果发送主机是小端机, 就需要先进行数据转换;否则忽略,直接发送即可。
如果接受主机是小端机,则拿到数据后需要进行转换;否则忽略,直接读取即可。
二. socket编程
1. 套接字说明
socket通常也称为“套接字”,程序可以通过“套接字”向网络发出请求或者响应网络请求。socket位于传输层之上、应用层之下。socket编程是通过一系列系统调用完成应用层协议。如FTP、Telent、HTTP等应用层协议都是通过socket编程来实现的。
从套接字所处的位置来讲,套接字上连应用进程,下接网络协议栈,是应用程序与网络协议栈进行交互的接口。
套接字是对网络中应用进程之间进行双向通信的抽象,他提供了应用层进程利用网络协议栈交换数据的机制。
套接字的本质
Linux和UNIX的I/O内涵是系统中的一切都是文件。当程序在执行任何形式的I/O时,程序都是在读或者在写一个文件描述符,从而实现操作文件,但是,这个文件可能是一个socket网络连接、目录、FIFO、管道、终端、外设、磁盘上的文件。一样的道理,socket也是使用标准Linux文件描述符和其他网络进程进行通信的。
2. socket函数简介
socket函数基本为系统调用函数,它是操作系统向网络通信进程提供的函数接口。
从实现的角度来讲,套接字系列函数是一个复杂的软件模块,它包含了一定的数据结构和许多选项,由操作系统内核来管理。
3. socket编程说明
Linux系统是通过套接字(socket)函数来进行网络编程的。socket技术提供了在TCP/IP模型各个层上的编程支持,该技术是先在内核中处理收到的各层协议数据,然后应用程序再以文件操作的方式接收内核返回的数据。
其中应用程序对文件的处理是通过一个文件描述符来进行的,socket文件描述符可以看成普通的文件描述符来进行操作,这就是Linux设备无关性的好处,可以通过对文件描述符的读写操作来实现网络间数据流的传输。
重新理解IP地址与端口
端口是指网络中面向连接服务和无连接服务的通信协议端口,它是一种抽象的软件结构,包括一些数据结构和I/O(基本输入/输出)缓冲区。
IP地址用来标识网络中不同主机的地址,而端口号则是标识一台主机上不同网络通信进程的地址,IP地址与端口号合起来标识的就是网络中唯一的进程。
如果把IP地址比作一栋旅馆,端口就是旅馆里的一个个房间。一个IP地址的端口可以有65536(即2^16)个之多。端口是通过端口号来标识的,端口号是个16byte位的整数,范围是0 ~ 65535( 2^16-1)。其中端口1~1024是系统保留端口。
一次socket通信连接会涉及源IP地址、源端口、目的IP地址和目的端口四个要素。源IP地址、源端口标识的是客户端进程,其中源端口是操作系统随机分配的;目的IP地址、目的端口标识的是服务端进程,其中目的端口是由服务器程序指定的。
IP地址、端口号、socket套接字三者在数据结构上的联系
4. socket地址说明及转换函数
4.1 三种常见的结构类型
在套接字编程中,有三种常见的结构类型,它们用来存放socket地址信息。这三种结构类型分别为struct in_addr、struct sockaddr、struct sockaddr_in,对这三种结构类型说明如下,使用它们需要包含头文件#include <netinet/in.h>。
struct in_addr专门用来存储IP地址,对于IPv4来说,IP地址为32位无符号整数,其定义可以在/usr/include/linux/in.h下找到,具体IP地址的值存储在成员变量s_addr中:
struct sockaddr结构用来保存保存套接字的完整地址信息,其定义如下:
struct sockaddr { unsigned short sa_family; /* 地址簇,AF_xxx */ char sa_data[14]; /* 14B的协议地址 */ };
struct sockaddr结构中sa_family成员说明的是地址簇类型,一般为“AF_INET”;而sa_data则包含主机的IP地址和端口等信息。
struct sockaddr结构类型使用在socket相关的系统调用函数中,但这个结构中的sa_data字段可以包含较多的信息,不便于实际编程和对其进行赋值,因此,又建立了struct sockaddr_in结构,该结构与struct sockaddr结构的大小相等,能更好地处理struct sockaddr结构中的数据。对struct sockaddr_in结构变量进行赋值完成后进行socket相关的系统调用函数时,再将struct sockaddr_in结构变量强制转化为struct sockaddr结构类型即可。
sttruct sockaddr_in结构定义如下:
在实际应用的编程中,对套接字地址结构的使用方法和流程如下:
定义一个struct sockaddr_in类型的结构变量,并将它初始化为0,代码如下:
struct sockaddr_in myad; memset(&myad, 0, sizeof(struct sockaddr_in));
给这个结构变量赋值,代码如下:
myad.sin_family = AF_INET; myad.sin_port = htons(8080); myad.sin_addr.s_addr = htonl(INADDR_ANY);
在进行函数调用时,都要将这个结构强制转换为struct sockaddr类型,代码如下:
bind(serverFd, (struct sockaddr*)&myad, sizeof(myad));
4.2 整型数据字节序转化函数
为保证“大端”和“小端”字节序机器之间能相互进行正常的网络通信,需在发送多字节的整数时,将主机字节序转换成网络字节序,或将网络字节序转换为主机字节序。字节序转换主要是针对整型数据进行的,字符型由于是单字节,所以不存在这个问题。整型整型字节序转换函数原型及其说明如下。
所需头文件 #include <arpa/inet.h>
函数说明 完成网络字节序与主机字节序的转换,注意已经完成转换了的整数就不要在重复转换了
函数原型 uint16_t htons(uint16_t hostshort) //短整型主机转换为网络字节序
uint32_t htonl(uint32_t hostlong) //长整型主机转换为网络字节序
uint16_t ntohs(uint16_t netshort) //短整型网络转换为主机字节序
uint32_t ntohl(uint32_t netlong) //长整型网络转换为主机字节序
函数传入值 hostshort、hostlong:为转换前的主机字节序数值
netshort、netlong为转换前的网络字节序数值
函数返回值 ① htons、htonl返回转换后的网络字节序数值
② ntohs、ntohl返回转换后的主机字节序数值
附加说明 h表示主机,n表示网络,s表示短整数,l表示长整数,to表示转换
4.3 IP地址转换函数
IP地址转换函数是指完成点分十进制数IP地址与二进制数IP地址之间的相互转换。IP地址转换主要由inet_aton、inet_addr和inet_ntoa这三个函数完成,但它们都只能处理IPv4地址,而不能处理IPv6地址。这三个函数的函数原型及其具体说明如下。
1、inet_addr
函数原型 in_addr_t inet_addr(const char* cp)
函数说明 将点分十进制数IP地址转换为二进制数IP地址并完成网络字节序的转换
所需头文件 #include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
函数传入值 cp:点分十进制数IP地址,如“10.10.10.1”
函数返回值 in_addr_t 一般为32位的unsigned int
成功:返回二进制数形式的IP地址
失败:返回一个常值INADDR_NONE(32位均为1)
2、inet_aton
函数原型 int inet_aton(const char* cp, struct in_addr* inp)
函数说明 将点分十进制数IP地址转换为二进制数地址并完成网络字节序的转换
所需头文件 #include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
函数传入值 cp:点分十进制数IP地址,如“10.10.10.1”
inp:转换后的二进制数地址信息保存在inp中
函数返回值 成功:非0
失败:0
3、inet_ntoa
函数原型 char* inet_ntoa(struct in_addr in)
函数说明 将二进制数地址转换为点分十进制数IP地址
所需头文件 #include <sys/socket.h>
#include <netinet/in.h>
#include <arpa/inet.h>
函数传入值 in:二进制数IP地址,注意类型是struct in_addr,该数据一般从套接字地址结构中拿到
函数返回值 成功:返回字符串指针,此指针指向了转换后的点分十进制数IP地址
失败:NULL
5. socket主要函数说明
5.1 基本套接字函数
PS:基本套接字函数的头文件都为:#include <sys/socket.h>、#include<sys/types.h>这两个。
(1)socket函数
创建套接字要用到socket这个函数,该函数的原型如下:
头文件:#include <sys/socket.h>、#include<sys/types.h>
函数说明:创建一个socket文件描述符。
函数返回值:
成功:socket文件描述符。
失败:-1,失败原因存于error中。
参数说明:
domain:即协议簇。
AF_INET:IPv4协议。
AF_INET6:IPv6协议。
AF_LOCAL:UNIX域协议。
AF_ROUTE:路由套接口。
AF_KEY:密钥套接口。
type:即服务类型。
SOCK_STREAM:双向可靠数据流,对应TCP。
SOCK_DGRAM:双向不可靠数据报,对应UDP。
SOCK_RAM:提供传输层以下的协议,可以访问内部网络的接口,例如, 接收和发送ICMP报文。
protocol:即协议类型。
type为SOCK_RAM时,需要设置此值说明协议类型。
其他类型统一设置为0即可,内部会根据前两个参数的值自动推导出协议结果。
补充:下表列出了当进行socket调用时,其中的协议簇(domain)与服务类型(type)可能产生的组合。
- AF_INET AF_INET6 AF_LOCAL AF_ROUTE AF_KEY
SOCK_STREAM TCP TCP Yes
SOCK_DGRAM UDP UDP Yes
SOCK_RAW IPv4 IPv6 Yes Yes
(2)bind函数
头文件:#include <sys/socket.h>、#include<sys/types.h>
函数说明:将一个套接字地址与socket文件描述符联系起来。利用bind绑定地址时,可以指定主机的IP地址和端口号。
此函数一般为客户端调用,我们在填充IP地址时可以使用通配地址INADDR_ANY(为宏定义,其值等于0),此时的含义是让服务器端计算机上的所有网卡的IP地址都可以作为服务器的IP地址,也即监听外部客户端程序发送到服务器端所有网卡的网络请求。
参数说明:
socket:socket文件描述符。
address:指向sockaddr结构,该结构包含IP地址和端口等信息
address_len:sockaddr结构的大小,可设置为sizeof(struct sockaddr)
函数返回值:
成功:0
失败:-1,失败原因存于error中
(3)listen函数
头文件:#include<sys/socket.h>、#incldue<sys/types.h>
函数说明:设置监听套接字
参数说明:
sockfd:需要设置为监听套接字的socket文件描述符。
backlog:连接队列长度,通常设置为5或10。
函数返回值:成功返回0,失败返回-1,失败原因存于error中。
附加说明
对于监听套接字文件描述符sockfd,内核要维护两个队列,分别为未完成连接队列和已完成连接队列,这两个队列之和不能超过backlog。
(4)accept函数
头文件:#include<sys/socket.h>、#include<types.h>
函数说明:接受socket连接,返回一个新的socket文件描述符,原socket文件描述符仍为listen函数所用,而新的socket文件描述符用来处理连接的读写操作。
参数说明:
sockfd:监听套接字。
addr:写入远程主机的套接字地址,是一个输出型参数。
addrlen:写入addr的大小,也是一个输出型参数。
函数返回值:
成功:返回实际读取的字节数。
失败:-1,错误码存放于error中。
附加说明
accept函数由TCP服务器调用,为阻塞函数,从已完成连接的队列中返回一个连接;如果该队列为空,则进程进入阻塞等待。
函数返回的套接字为已新的连接套接字,监听套接字仍为listen函数所用。
(5)connect函数
头文件:#include<sys/socket.h>、#include<sys/types.h>
函数说明:主动建立socket连接
函数传入值:
sockfd:socket文件描述符
addr:对端主机的套接字地址
addrlen:sockaddr结构的大小,可设置为sizeof(struct sockaddr)
函数返回值:成功返回0,失败返回-1,失败原因存于error中。
5.2 UDP读写函数
UDP套接字是无连续协议,必须使用sendto函数发送数据,使用recvfrom函数接收数据,且发送时需要指明目的地地址。sendto函数与send的功能基本相同,recvfrom与recv的功能基本相同,只是sendto和recvfrom函数参数中都带有对端的地址信息,这两个函数是专门为UDP协议提供的。
(1)sendto函数
头文件:#include <sys/socket.h>
函数说明:通过socket文件描述符发送数据到对端,用于UDP协议
参数说明:
socket:socket文件描述符
message:发送数据的首地址
length:发送数据的长度
flags:该参数可以设置为以下标志的组合
0:以阻塞方式发送数据。
MSG_OOB:发送带外数
MSG_DONTROUTE:告诉IP协议目的主机在本地网络,没有必要查找路由表
MSG_DONTWAIT:设置为非阻塞操作
MSG_NOSIGNAL:表示发送动作不愿被SIGPIPE信号中断
dest_addr:存放目的主机的IP地址和端口信息,即socket地址
dest_len:to的长度,可设置为sizeof(struct sockaddr)
函数返回值:
成功:实际发送的字节数
失败:-1,失败原因存于error中
(2)recvfrom函数
头文件:#include <sys/socket.h>
函数说明:通过socket文件描述符从对方接收数据,用于UDP协议
参数说明:
socket:文件描述符。
buffer:接收数据的首地址(输出型参数)。
length:需要接受数据的长度。
flags:该参数可以设置为以下标志的组合。
0:以阻塞方式接收数据
MSG_OOB:接收带外数据
MSG_PEEK:查看数据标志,返回的数据并不在系统中删除,如果再次调用该函数,会返回相同的数据内容。
MSG_DONTWAIT:设置为非阻塞操作
MSG_WAITALL:强迫接收到length大小的数据后才返回,除非有错误或有信号产生。
address:存放发送方的IP地址和端口(输出型参数)。
address_len:socket地址的长度,可设置为sizeof(struct sockaddr)。
函数返回值
成功:实际接收到的字节数。
失败:-1,失败原因存于error中。
三. UDP套接字编程
UDP协议是非连接非可靠的数据传输,常用在对数据质量要求不高的场合。UDP服务器通常是非连接的,因而,UDP服务器进程不需要像TCP服务器那样在监听套接字上接收新建的连接;UDP只需要在绑定的端口上等待客户机发送来的UDP数据报文,并对其进行处理和响应。一个TCP服务进程只有在完成了对某客户机的服务后,才能为其他的客户机提供服务。而UDP服务器只是接收数据报文,处理并返回结果。UDP支持广播和多播,如果要使用广播和多播,必须使用UDP套接字。UDP套接字没有连接的建立和终止过程,UDP只需要两个分组来交换一个请求和答应。UDP不适合海量数据的传输。
1. 普通UDP服务器编程模型
1.1 UDP服务器端流程
① 建立UDP套接字
② 绑定套接字到特定的地址
③ 等待并接受客户端信息
④ 处理客户端请求
⑤ 发送信息给客户端
⑥ 关闭套接字
1.2 UDP客户端流程
① 建立UDP套接字
② 发送信息给服务器
③ 接收来自服务器的信息
④ 关闭套接字
1.3 UDP服务器、客户端的编程模型图
2. 普通UDP服务器编程实现
2.1 UDP服务器服务端代码
基本框架
服务端只有两个成员变量,端口号和自己的socket文件描述符,服务端不需要指定自己的IP地址,原因下文会做说明。
class UdpServer { public: // 构造函数,创建一个服务端对象时需要显示传入一个端口号给服务端 UdpServer(const int port) :_port(port) ,_sockfd(-1) {} // 析构函数,当服务端对象销毁时关闭打开的socket文件描述符 ~UdpServer() { if(_sockfd >= 0) { close(_sockfd); } } private: int _port; // 服务端进程的端口号 int _sockfd;// 服务端进程的socket打开文件描述符 };
初始化服务端
创建socket文件描述符
将服务端自己的套接字地址和刚刚创建的socket文件描述符绑定起来
void InitServer() { // 1、创建socket文件描述符 if((_sockfd=socket(AF_INET, SOCK_DGRAM, 0)) == -1) { cerr<<"socket error"<<endl; return; } cout<<"socket sucess"<<endl; // 2、将服务端自己的套接字地址和刚刚创建的socket文件描述符绑定起来 struct sockaddr_in local; memset(&local, 0, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(_port); local.sin_addr.s_addr = htonl(INADDR_ANY); if(bind(_sockfd, (struct sockaddr*)&local, sizeof(local)) == -1) { cerr <<"bind error"<<endl; return; } cout<<"bind sucess"<<endl; }
注意事项
绑定操作我们是通过bind函数来完成的,该函数是把套接字地址和socket文件描述符给绑定联系起来。而套接字地址就包括端口号和IP地址,在这里我们可以把IP地址设为INADDR_ANY,表示该服务器可以收发本主机中所有网卡的数据。
最后还要注意通过htons、htonl等整型数据字节序转化函数把P地址和端口号这些整型数据的字节序转化成网络字节序,然后再放到套接字地址变量中。
启动服务端
void Loop() { #define SIZE 128 // buffer用于接收客户端传来的数据 char buffer[SIZE]; // peer用于接收客户端的套接字地址信息 struct sockaddr_in peer; // len用来接收客户端套接字地址结构的大小 socklen_t len = sizeof(peer); while(true) { // 1、通过recvfrom函数不断接收客户端传来的数据和信息 ssize_t size = recvfrom(_sockfd, buffer, sizeof(buffer)-1, 0, (struct sockaddr*)&peer, &len); // 2、解析客户端的数据和信息 if(size >= 0) { buffer[size] = '\0'; int port = ntohs(peer.sin_port); string ip = inet_ntoa(peer.sin_addr); cout<<'['<<ip<<' '<<port<<"]#"<<buffer<<endl; } else { cerr<<"recvfrom error"<<endl; } } }
注意事项
recvfrom函数不仅可以拿到客户端传来的数据,还可以拿到客户端的IP地址和端口号,服务端有了客户端的IP地址和端口号之后我们可以通过sendto函数再发送数据回去给客户端进程,实现客户端、服务端的双向网络通信。
2.2 UDP客户端客户端代码
基本框架
客户端一般是要把任务传给服务器端,让服务端去处理任务的,所以客户端进程需要知道服务端的IP地址和端口号。
class UdpClient { public: // 构造函数,需要显示传入目的服务端的IP地址和端口号,作为数据发送的目的地 UdpClient(const string& serverIp, const int serverPort) :_sockfd(-1) ,_serverIp(serverIp) ,_serverPort(serverPort) {} // 析构函数,关闭打开的socket文件描述符 ~UdpClient() { if(_sockfd >= 0) { close(_sockfd); } } private: int _sockfd; // 客户端进程的socket文件描述符 string _serverIp;// 服务端进程的IP地址 int _serverPort; // 服务端进程的端口号 };
初始化客户端
这里只需要创建客户端网络进程的socket文件描述符即可,而不需要将socket文件描述符和自己的套接字地址进行绑定。因为没人会关心你客户端的IP地址和端口号,而服务端作为服务的提供者,所有客户端进程都需要向服务端进程发送任务,即服务端的IP地址和端口号是要被众所周知的,所以服务端需要进行绑定操作而客户端不需要。
void InitUdpClient() { if((_sockfd=socket(AF_INET, SOCK_DGRAM, 0)) == -1) { cerr<<"socket error"<<endl; return; } }
启动客户端
void Start() { string msg; // 在struct sockaddr_in结构中填入服务端的套接字地址 struct sockaddr_in peer; memset(&peer, 0, sizeof(peer)); peer.sin_family = AF_INET; peer.sin_port = htons(_serverPort); peer.sin_addr.s_addr = inet_addr(_serverIp.c_str()); // 通过sendto函数发送数据到服务端 while(true) { cout<<"Please Enter# "; getline(cin, msg); sendto(_sockfd, msg.c_str(), msg.size(), 0, (struct sockaddr*)&peer, sizeof(peer)); } }
2.3 测试普通UDP服务器
把上面UDP的服务端和客户端的实现代码分别写到udp_server.h 和 udp_client.h两个头文件中。
udp_server.cpp
创建一个服务端对象
初始化服务端对象
启动服务端对象
#include "udp_server.h" // 运行可执行程序时,这里通过命令行参数传入服务端的端口号 int main(int argc, char** argv) { if(argc != 2) { cerr<<"Usage:"<<"./ServerName"<<" port"<<endl; return -1; } // 1、把第二个命令行参数转为整型,拿到端口号,用这个端口号去构造一个服务端对象 UdpServer* svr = new UdpServer(atoi(argv[1])); // 2、初始化服务端对象 svr->InitServer(); // 3、启动服务端对象 svr->Loop(); return 0; }
udp_client.cpp
创建一个客户端对象
初始化客户端对象
启动客户端对象
#include "udp_client.h" // 运行可执行程序时,通过命令行参数传入目的服务端的IP地址和端口号 int main(int argc, char** argv) { if(argc != 3) { cout<<"Usage:"<<"./ServerName"<<" ServerIp ServerPort"<<endl; return -1; } // 1、解析命令行参数传入的IP地址和端口号 string serverIp = argv[1]; int serverPort = atoi(argv[2]); // 2、构造一个客户端对象 UdpClient* clt = new UdpClient(serverIp, serverPort); // 3、初始化客户端 clt->InitUdpClient(); // 4、启动客户端 clt->Start(); return 0; }
结果测试
编译生成可执行程序
分别启动可执行程序,进行本地环回测试
发现客户端发送的数据能被服务器接收到
四. TCP套接字编程
1. TCP套接字编程模型图
TCP套接字编程经常使用在客户端/服务器编程模型(简称C/S模型)中,C/S模型根据复杂度,可分为简单的客户端/服务端模型和复杂的客户端/服务端模型。简单的客户端/服务端模型是一对一关系,即一个服务器端某一时间段内只对应处理一个客户端的请求,迭代服务器模型属于此模型。复杂的客户端/服务端模型是一对多关系,即一个服务器端某一时间段内对应处理多个客户端的请求,并发服务器模型属于此模型。迭代服务器模型和并发服务器模型是socket编程中最常使用的两种编程模型。
迭代服务器模型和并发服务器模型的服务端处理流程如下图所示:
下图是更加具体的TCP套接字编程模型图,此模型不仅适合迭代服务器,也适合并发服务器,两者实现的流程类似,只不过并发服务器接收客户请求(accept)后会用fork调用子进程,由子进程处理客户端的请求。
2. TCP编程流程说明
2.1 服务器编程流程
①:创建套接字
②:绑定套接字
③:设置套接字为监听模式,进入被动接收连接状态
④:接受请求,建立连接
⑤:读写数据
⑥:终止连接
2.2 客户端编程流程
①:创建套接字
②:与远程服务器建立连接
③:读写数据
④:终止连接
3. TCP网络数据读写说明
在网络程序中,向套接字文件描述符写数据时有以下两种可能:
write的返回值大于等于0,表示写了部分或者全部的数据,最后数值为实际写入的字节数(0表明什么都没写)。
write的返回值等于-1,此时写出现错误,需要根据错误类型来处理。如果错误号为EINTR,则为中断引起的,可以忽略进行继续写操作;如果是其他错误号,表示网络连接出现了问题(可能对方关闭了连接),则需报错退出。
与向套接字文件描述符写数据不同,读数据有三种可能:
read的返回值大于0,表示读了部分或者全部的数据。
read的返回值等于0,表示写端关闭了。
read的返回值等于-1,表明读出现了错误,需要根据错误类型来处理,如果错误号为EINTR,则为中断引起的,可以忽略进行继续读操作;如果是其他错误号,表示网络连接出现了问题,则需要报错退出。
4. 迭代服务器编程
下面代码实现的是典型的迭代服务器,服务端的功能是接收客户发送来的字符串数据并原封不动地发回去。
4.1 服务端代码
tcp_server.h:用来存放服务端类的实现。
#define BACK_LOG 10 #define BUFF_SIZE 1024 class TcpServer { public: TcpServer(const int port) :_port(port) ,_listenSock(-1) {} ~TcpServer() { if(_listenSock >= 0) { close(_listenSock); } } void InitServer() { // 1、创建套接字 _listenSock = socket(AF_INET, SOCK_STREAM, 0); // 2、绑定套接字 struct sockaddr_in local; memset(&local, 0, sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(_port); local.sin_addr.s_addr = INADDR_ANY; bind(_listenSock, (struct sockaddr*)&local, sizeof(local)); // 3、设置监听套接字 listen(_listenSock, BACK_LOG); } // 服务客户端 void Service(const int linkSock, const string& ip, const int port) { char buff[BUFF_SIZE]; while(1) { ssize_t size = read(linkSock, buff, sizeof(buff)-1); if(size > 0) { buff[size] = 0; cout<<'['<<ip<<':'<<port<<"]# "<<buff<<endl; write(linkSock, buff, size); } else if(size == 0) { cout<<"client close!"<<endl; break; } else { cerr<<"read error"<<endl; break; } } // 服务完成后要记得关闭该连接套接字 close(linkSock); } // 启动服务器 void LoopServer() { struct sockaddr_in peer; memset(&peer, 0, sizeof(peer)); socklen_t len = sizeof(peer); // 不断地监听获取客户端的连接请求 while(1) { int linkSock = accept(_listenSock, (struct sockaddr*)&peer, &len); // 若该套接字监听失败,继续监听下一个套接字即可 if(linkSock == -1) { cout<<"accept error, continue next link"<<endl; continue; } int port = ntohs(peer.sin_port); string ip = inet_ntoa(peer.sin_addr); cout<<"get a new link, sockfd is "<<linkSock<<endl; // 连接成功后,为客户端提供服务 Service(linkSock, ip, port); } } private: int _port; int _listenSock; };
tcp_server.cpp:创建一个服务端对象,并初始化和启动它。
#include "tcp_server.h" int main(int argc, char* argv[]) { if(argc != 2) { cout<<"Usage:./ServerProc Serverport"<<endl; exit(-1); } // 解析参数 int port = atoi(argv[1]); // 根据参数去创建一个服务端对象 TcpServer* svr = new TcpServer(port); // 初始化、启动务端 svr->InitServer(); svr->LoopServer(); // 最后delete服务端对象 delete svr; return 0; }
4.2 客户端代码
tcp_client.h:用来存放客户端类的实现
class TcpClient { public: TcpClient(const string& serverIp, const int serverPort) :_serverIp(serverIp) ,_serverPort(serverPort) ,_linkSock(-1) {} ~TcpClient() { if(_linkSock >= 0) { close(_linkSock); } } // 初始化客户端 void InitClient() { // 初始化阶段只需创建套接字即可 _linkSock = socket(AF_INET, SOCK_STREAM, 0); } // 连接成功后,发送信息给服务端,然后在接收服务端返回的信息 void Request() { string msg; char echoBuff[1024]; while(1) { cout<<"please enter# "; getline(cin, msg); write(_linkSock, msg.c_str(), msg.size()); ssize_t size = read(_linkSock, echoBuff, sizeof(echoBuff)-1); if(size > 0) { echoBuff[size] = 0; cout<<"server echo# "<<echoBuff<<endl;; } else if(size == 0) { cout<<"server close!"<<endl; break; } else { cerr<<"read error"<<endl; break; } } } // 启动客户端,用已经创建出来的套接字去连接服务端并请求其处理任务 void Start() { struct sockaddr_in peer; memset(&peer, 0, sizeof(peer)); peer.sin_family = AF_INET; peer.sin_port = htons(_serverPort); peer.sin_addr.s_addr = inet_addr(_serverIp.c_str()); if(connect(_linkSock, (struct sockaddr*)&peer, sizeof(peer)) == -1) { cerr<<"connect error"<<endl; } else { cout<<"connect success"<<endl; Request(); } } private: string _serverIp; int _serverPort; int _linkSock; };
tcp_client.cpp:创建一个客户端对象并初始化和启动它。
#include "tcp_client.h" int main(int argc, char* argv[]) { if(argc != 3) { cout<<"Usage:./clientProc serverIp serverPort"<<endl; exit(-1); } // 解析参数 string ip = argv[1]; int port = atoi(argv[2]); // 根据参数去创建一个客户端对象 TcpClient* clt = new TcpClient(ip, port); // 初始化、启动客户端 clt->InitClient(); clt->Start(); // 最后delete客户端对象 delete clt; return 0; }
4.3 测试迭代服务器
两个会话分别启动服务端(左边)和客户端(右边),客户端发送数据给服务端,结果服务端能接收到数据并回响给客户端,说明该迭代服务器实现成功。
迭代服务器存在明显的的缺点,即一个服务器端某一时间段内只对应处理一个客户端的请求,下图可以看到如果再另起一个客户端进程去连接服务器,因为上一个客户端的服务还没有完成所以新起的客户端进程并不能享受服务。
5. 并发服务器编程
5.1 并发服务器编程注意事项
进程是一个程序的一次运行过程,它是一个动态实体,是独立的任务,它拥有独立的地址空间、执行堆栈、文件描述符等。每个进程拥有独立的地址空间,在进程不存在父子关系的情况下,互不影响。
进程的终止存在两种可能:父进程先于子进程终止(由init进程领养),子进程先于主进程终止。对于后者,系统内核为子进程保留一定的状态信息(进程ID、终止状态、CPU时间等),并向其父进程发送SIGCHLD信号。当父进程调用wait或waitpid函数时,将获取这些信息,获取后内核将对僵尸进程进行清理。如果父进程设置了忽略SIGCHLD信号或对SIGCHLD信号提供了处理函数,即使不调用wait或waitpid函数,内核也会清理僵尸进程。
父进程调用wait函数处理子进程退出信息时,会存在下面所述的问题。在有多个子进程的情况下,wait函数只等待最先到达的子进程的终止信息。比如下图中父进程有三个子进程,由于SIGCHLD信号不排队,在SIGCHLD信号同时到来后,父进程的wait函数只执行一次,这样将留下两个“僵尸进程”,使用waitpid函数并设置WNOHANG选项可以解决这个问题。
综上所述,在多进程并发的情况下,防止子进程变成僵尸进程的常见方法有如下三种。
①:父进程调用signal(SIGCHLD,SIG_IGN)对子进程退出信号进行忽略,或者把SIG_IGN替换为其他处理函数,设置对SIGCHLD信号的处理。
②:父进程调用waitpid(-1, NULL, WNOHANG)对所有的子进程SIGCHLD信号进行处理。
③:服务端进程先创建一个子进程(儿子进程),然后这个子进程再创建一个子进程(孙子进程),让孙子进程去处理任务并终止儿子进程,这样孙子进程处理完任务后因为没有父进程了,所以这个孙子进程会被init进程领养并释放。
5.2 并发服务器文件描述符变化图
下图画出了并发服务器文件描述符的变化流程图。其中listenfd为服务端的socket监听文件描述符,connfd为accept函数返回的socket连接文件描述符。
服务器调用accept函数时,客户端与服务端文件描述符如图所示:
服务器调用accept函数后,客户端与服务端文件描述符如图所示:
服务端调用fork函数后,客户端与服务端文件描述符如下图所示:
服务端父进程关闭连接套接字,子进程关闭监听套接字,客户端与服务端文件描述符状况如下图所示:
PS:并发服务器fork后父进程一定要关闭子进程的连接套接字;而子进程要关闭父进程的监听套接字,以免误操作。
5.3 TCP并发服务器代码实现
并发服务器处理流程
① 客户端首先发起链接。
② 服务端进程accept打开一个新的连接套接字与客户端进行连接,accept在一个while(1)循环内等待客户端的连接。
③ 服务端fork一个子进程,同时父进程close关闭子进程连接套接字,循环等待下一进程。
④ 服务端子进程colse父进程监听套接字,并用连接套接字保持与客户端的连接,客户端发送数据到服务端进程,然后阻塞等待服务端返回。
⑤ 子进程接收数据,进行业务处理,然后发送数据给客户端。
⑥ 子进程关闭连接,然后退出。
并发服务器服务端代码
只需在迭代服务器的基础上修改服务端的启动部分代码即可,当服务端连接成功拿到新的连接套接字时,服务端进程fork创建子进程,让子进程去执行客户端发来的任务,注意服务端进程需要忽略对SIGCHLD信号的处理。
先启动一个客户端1和一个服务端,发现确实能够正常通信:
再启动一个客户端2,也能够正常和服务端通信:
补充1:另一多进程版本的服务端编写
服务端进程先创建一个子进程(儿子进程),然后这个子进程再创建一个子进程(孙子进程),让孙子进程去处理任务并终止儿子进程,这样孙子进程处理完任务后因为没有父进程了,所以这个孙子进程会被init进程领养并释放。
只需修改服务端连接成功之后的那部分的代码即可:
结果演示,一个服务端进程依然可以同时为多个客户端进程提供服务:
补充2:多线程版本服务端编写
创建进程的开销是要比创建线程大得多的,我们的主线程在连接成功后可以考虑去创建线程来处理任务,在编码时要注意以下几点:
子线程和主线程共享一个打开文件描述符表。
子线程执行函数如果要封装到服务端类中的话,需要给它的执行函数加上static关键字,以解除this指针对参数列表的影响。
类的静态成员函数只能访问类的静态成员变量和成员函数,非静态的不能够访问到。
涉及到的客户端类代码如下:
class TcpServer { public: //...其他成员函数省略 // 多线程版本 // 启动服务器 void LoopServer() { struct sockaddr_in peer; memset(&peer, 0, sizeof(peer)); socklen_t len = sizeof(peer); // 不断地监听获取客户端的连接请求 while(1) { int linkSock = accept(_listenSock, (struct sockaddr*)&peer, &len); // 若该套接字监听失败,继续监听下一个套接字即可 if(linkSock == -1) { cout<<"accept error, continue next link"<<endl; continue; } int port = ntohs(peer.sin_port); string ip = inet_ntoa(peer.sin_addr); cout<<"get a new link, sockfd is "<<linkSock<<endl; // 建立连接后,创建子线程去处理任务 Param* pm = new Param(linkSock, ip, port); pthread_t tid; pthread_create(&tid, nullptr, Routine, pm); } } // 子线程处理任务函数(注意要设为静态的,要不然会参数里会有this指针) static void* Routine(void* arg) { pthread_detach(pthread_self()); Param* pm = (Param*)arg; Service(pm->_sockfd, pm->_ip, pm->_port); delete pm; return nullptr; } // 服务客户端(也要设为静态的,因为Routine的逻辑中有使用到该函数) static void Service(const int linkSock, const string& ip, const int port) { char buff[BUFF_SIZE]; while(1) { ssize_t size = read(linkSock, buff, sizeof(buff)-1); if(size > 0) { buff[size] = 0; cout<<'['<<ip<<':'<<port<<"]# "<<buff<<endl; write(linkSock, buff, size); } else if(size == 0) { cout<<"client close!"<<endl; break; } else { cerr<<"read error"<<endl; break; } } // 服务完成后要记得关闭该连接套接字 close(linkSock); } private: int _port; int _listenSock; };
结果演示:不论主线程还是其创造出来的子线程,它们都共属同一个进程,每一个子线程要使用一个自己的连接套接字去处理任务,又因为它们共用同一张打开文件描述符表,所以各自分配到的套接字不同。