引言
北京时间:2023/8/19/23:01,耍了好几天,主要归咎于《我欲封天》这本小说,听了几个晚上之后逐渐入门,在闲暇时间又看了一下,小高潮直接来临,最终在三个昼夜下追完了,哈哈哈!没办法呀,哎!末200章有些些烂尾,结局合乎情理,总的来说优秀,毕竟耳根的名号摆在哪里。过度了两天,辅导员发来了开学通知,时间不允许我们摆烂啦!不然我肯定要把《一念永恒》给追完,哈哈哈!都说《求魔》才是耳根的巅峰,但是我看评论好像《一念永恒》才是经典,具体没看过,有待商榷,不过主要是这种类型的网文有一个缺点,就是容易上头,所以我打算找一个节奏慢的,像《剑来》这种应该就挺适合用听,具体有待尝试。前天晚上睡觉的时候感觉今天的引言有非常多的内容可以写,现在莫名想不起来要说什么了,那就算了,正式进入该篇博客的主题,socket编程实战,使用socket接口简单实现udp客户端/服务器。
正式学习套接字编程
在上篇博客中,我们重点对有关IP地址和端口号的知识进行了讲解,并且对于数据传输过程以及套接字有了更深的了解,并且对于什么网络字节序和套接字编程相关的接口有了认识,最终因为时间原因只是简单的看了一下对应的socket接口,并没有重点理解这些接口的使用,以及这些接口的功能,所以该篇博客就将重点讲解有关套接字接口的使用以及作用,当然也就是有关socket编程的具体实现,以socket编程实现UDP版本的网通通信为例。
认识sockaddr结构体
在学习socket编程,也就是socket常见的API(接口)之前,我们有必要先进行一个基础概念的梳理,当然此时的这个概念和上篇博客理解IP地址、端口号以及网络字节序不同,此时的这个基础概念是更加宏观的一个概念,本质也可以理解成为什么需要认识sockaddr结构体,sockaddr结构体是什么的问题。
上篇博客中我们反复强调网络通信的本质就是进程间通信的概念,而如果谈到进程间通信,那么我们此时就不得不回忆起进程间通信的两大标准:System V和POSIX,这两大进程间通信标准我们并不陌生,在之前学习多线程之信号量时,重点理解过,并且在上篇博客中,我们讲过socket不仅可以实现本地进程通信(本地套接字),也可以实现网络通信(网络套接字),所以此时两大通信标准的区别就诞生了。什么意思呢?也就是System V进程间通信标准只支持本地进程间通信,不支持网络通信,而因为socket(套接字)中所有的API都是POSIX标准提供和设计的,所以POSIX天然的就是一个既支持本地进程间通信,也支持网络间进程通信的标准,这也就是为什么目前大部分操作系统的内核都是使用POSIX标准的原因。明白了这点之后,此时我可以明白sockaddr本质应该就是POSIX标准下进行的结构体设计,具体用来做什么下述理解,此时我们对socket(套接字)再来一个明确的概念理解,明白socket本质就是一种网络通信技术,它会提供一套API供给我们使用,而使用这套API接口进行编程我们就称为socket网络编程,而常说的socket网络通信本质就是使用socket提供的API进行的进程间通信编码。
明白了上述知识之后,此时对于sockaddr的背景知识我们就了解了,所以此时基于该背景知识,我们就可以很好的进入该话题的理解,也就是因为POSIX标准既支持本地通信,也支持网络通信的特点,所以当时设计POSXI标准的时候,那些顶级的工程师为了实现本地进程通信和网络通信的套接字API(接口)兼容时,他们就提出了sockaddr结构体的方法,因为如果想要实现本地和网络之间的进程通信兼容同一套接口,那么必然就需要通过对接口中参数的改变来实现,所以最后为了实现接口中参数类型不同,而又能传参成功,此时就有了sockaddr_in和sockaddr_un被sockaddr强制类型转换的概念,所以此时我们就明白sockaddr本质是一个共用类型的结构体,而sockaddr_in(网络)和sockaddr_un(本地)才是不同场景下被使用的真正结构体类型,当然具体在POSIX标准下进程通信场景不止这两种,如还有IPV6(sockaddr_in6)等…,如下图所示:
如上图所示,就是sockaddr、sockaddr_in和sockaddr_un三种不同结构体的地址格式,不同的地址格式也就表示不同结构体对应存储的数据大不相同,从而最终决定什么结构体用于什么场景下的通信,当然这也就是为什么POSIX标准可以实现同一接口在不同场景下使用的重要原因,以sockaddr为公共结构体,然后根据不同场景下的数据去构造不同场景下的不同结构体。并且因为今天我们学习的是网络套接字,所以我们重点学习的就是sockaddr_in结构体,如下图所示,就是sockaddr_in结构体中的变量:
此时从图中,我们可以非常清晰的看出sockaddr_in结构体中有哪些数据,并且从上篇博客讲的有关IP地址和端口号的知识,此时我们可以很好的理解为什么sockaddr_in表示的是网络套接字通信(IP地址+端口号),但是,此时我们在该结构体中还发现了一个奇怪的数据:__SOCKADDR_COMMON (sin_);
这个玩意是个什么东西呢?从图中我们能看出,它对应的应该是一个16位地址类型AF_INET,那么它们之间存在什么关系呢?具体表示的又是什么意思呢?从上述有关sockaddr_in和sockaddr_un表示两种不同场景下的通信方式,然后在传参的时候需要使用sockaddr类型进行强制类型转换,我们就可以理解:强制类型转换的目的是为了让该目标结构体类型可以被对应的接口识别,那么此时就明白,目标接口本质识别到的一直都是sockaddr类型,那么具体如何去区分传过去的是sockaddr_in,还是sockaddr_un呢?所以为了解决这一问题,此时就设计出了AF_INET和AF_UNIX这两个字段去区分sockaddr_in/sockaddr_un,当然本质就是区分网络通信和本地通信,所以上述所说的__SOCKADDR_COMMON (sin_);
本质就是用来识别对应sockaddr结构体中第一个字段是AF_INET、AF_UNIX还是其它,具体识别方法如下:
如上图所示,此时我们就发现不同的sockaddr结构体,它们的__SOCKADDR_COMMON
宏定义对应的参数是不一样的,并且此时结合宏定义的具体实现,我们可以发现它使用了##来实现(注:##在C语言中起到一个将两边字符合成一个字符的功能),所以此时对不同结构体宏定义参数的不同以及##的分析,我们就能明白当时在设计sockaddr结构体时,为什么需要这么设计,本质就是为了让不同的结构体类型最终在初始化的时候,有不同的变量可以接收不同的值,也就是最终在sockaddr_in结构体中可以用sin_family变量来接收AF_INET字段,在sockaddr_un结构体中用sun_family变量来接收AF_UNIX字段,在sockaddr_in6中用sin6_family变量接收AF_INET6字段,最终实现使用同一个宏来区分不同的字段,当然本质还是##在起作用,所以此时使用##的好处就在于可以提高代码的复用性、灵活性、可维护性,让我们呢能够根据不同的情况来使用不同的变量,当然这种特性也就是多态的特性,这也就是为什么sockaddr_in、sockaddr_un和sockaddr之间存在多态的根本原因。
把对应结构体中的所有变量搞懂之后,此时我们对sockaddr_in的理解可以说是已臻化境,本质就是在我们初始化sockaddr_in结构体时,需要传入一个标识符来标识该结构体是sockaddr_in,标识我们想要进行的是网络通信,而为了区分各种通信场景,并且实现多态的特性,此时我们就使用了##来区分不同的变量,最后实现不同场景的结构体中标识符变量不同,最终让对应接口在识别是那种结构体,也就是那种标识符时,可以根据不同的变量来识别,如下图代码所示:
我们以套接字API中最为经典的bind接口为例,先简单来看一看对应接口内部是如何去处理不同场景下的结构体类型,如何识别该结构体内部的第一个字段,具体如上图所示,当然具体bind接口如何使用,下述我们详细介绍。
所以具体有关sockaddr结构体相关的知识我们就讲到这里,下述我们正式进入套接字API的理解,当然先理解sockaddr结构体,再学套接字接口肯定是有原因的,因为套接字接口依赖的就是sockaddr_in结构体,总而言之:在POSIX标准中对sockaddr结构体进行如此设计,此时就可以在不同的协议族(UDP/TCP)、地址类型(系统)和地址长度(IPV4/IPV6)之间进行通信,而不需要改变接口。
学习socket编程有关接口
-
int socket(int domain, int type, int protocol);
功能:创建套接字(本质就是打开一份网络文件),第一个参数domain:表示该套接字是进行网络通信(AF_INET)还是本地通信(AF_UNIX),第二个参数type:表示套接字种类,该套接字是TCP类型还是UDP类型,若传SOCK_STREAM则表示TCP类型(可靠传输、连接、面向字节流),若传SOCK_DGRAM则表示UDP类型(不可靠传输、无连接、面向数据报),第三个参数protocol:默认是0,表示默认让系统根据前两个参数选择使用TCP协议还是UDP协议,返回值int:表示如果创建套接字成功,则返回系统默认为套接字打开文件的文件描述符,失败返回-1,并设置错误码。 -
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
功能:将sockaddr_in结构体从本地绑定到内核套接字中,第一个参数sockfd:表示创建套接字时返回的文件描述符(存在该参数也就是表示需要将sockaddr_in结构体中的数据写入到该文件描述符对应的文件中),第二个参数addr:是一个sockaddr结构体指针类型,也就是需要把我们初始化完成的sockaddr_in结构体传过去,第三个参数addrlen:本质就是sockaddr_in的长度(大小),返回值int:绑定成功返回0,失败返回-1。 -
ssize_t recvfrom(int sockfd, void *buf, size_t len, int flags,struct sockaddr *src_addr, socklen_t *addrlen);
功能:接收其它主机中套接字发送的数据(网络通信),并记录发送方主机的IP地址和端口号(sockaddr_in) ,第一个参数sockfd:同理表示此时套接字对应返回文件的文件描述符(存在该参数可以明白最后接收的数据肯定就是该文件描述符对应文件中获取的),第二个参数buffer:自己定义的一个存储数据的缓冲区(本质就是用来存储接收到的数据),第三个参数len:定义缓冲区的大小(用来标明你最多可以接收多少数据),第四个参数flags:标示接收数据的方式(阻塞式/非阻塞式),默认设置为0,第五个参数src_addr:同理是一个结构体指针类型,只不过此时的这个指针类型与bind接口中的指针类型不同,它代表的是一个输出型参数,此时传的不再是初始化完成的sockaddr_in结构体,而是一个未初始化的sockaddr_in结构体,本质就是为了作为输出型参数,接收数据发送方的sockaddr_in结构体中的信息(IP地址+端口号),第六个参数addrlen:类型同理是一个指针类型,作为输出型参数,在该接口调用完毕之后,它就会将实际发送方sockaddr_in的大小更新(输出型参数的好处)。返回值ssize_t:最终调用完毕之后,该接口就会返回实际接收数据的字节数。 -
ssize_t sendto(int sockfd, const void *buf, size_t len, int flags,const struct sockaddr *dest_addr, socklen_t addrlen);
功能:发送数据到其它主机的套接字中,第一个参数sockfd:同理,套接字对应文件的文件描述符(存在该参数表明最后数据肯定是被发送到了该文件描述符对应的文件中),第二个参数buffer:存储待发送的数据,第三个参数len:待发送数据的大小,第四个参数flags:同理默认为0,第五个参数dest_addr:明白此时发数据一定需要知道目标套接字的sockaddr_in数据,也就是需要知道向谁发数据,所以此时该参数表示的就是目标套接字的sockaddr_in信息(IP地址+端口号),第六个参数addrlen:同理目标套接字sockaddr_in结构体的大小。返回值ssize_t:同理,表示实际发送数据的字节数。 -
uint16_t ntohs(uint16_t netshort);
该接口同理上篇博客所说有关网络字节序相关知识,功能:将数据从网络序列转化为主机序列,一般在从网络中接收数据时使用,参数:16位的网络序列数据,返回值:返回从网络序列转化成主机序列之后的16位主机序列。 -
uint16_t htons(uint16_t hostshort);
同理,功能:将数据从主机序列转化为网络序列,用在需要被网络转述的数据(IP地址和端口号等),参数:16位的主机序列,返回值:返回转化后的16位网络序列。 -
in_addr_t inet_addr(const char *cp);
功能:将IP地址从点分十进制转换为32位无符号整数,并且自带主机序列转网络序列的功能,本质也就是可以把我们日常见到的IP地址转换为允许在网络中传输的数据,参数:需要被转换的字符串类型IP地址,返回值:成功返回转换之后的32位无符号整形,失败则返回INADDR_NONE。 -
char *inet_ntoa(struct in_addr in);
功能:同理,将IP地址从32位无符号整数转化为点分十进制的形式,并自带将网络序列转化为主机序列,参数:一个32位无符号整形的IP地址,返回值:转换之后的点分十进制形式的IP地址。
正式开始UDP客户端/服务器代码编写
搞定了上述常见套接字接口之后,此时实现一份网络通信形式的代码对于我们来说可以说是不费吹灰之力,本质和我们之前学习进程间通信,使用管道或者共享内存实现一份本地进程间通信的代码一样,只需要把步骤和需要初始化的数据搞定,最后直接使用socket为我们提供的数据发送和数据接收接口,很轻松就能将整份代码搞定。所以下面我们就分为三个场景来实现我们的服务端和客户端,由浅入深将我们的客户端和服务端一步一步完善。
首先是客户端的代码实现
此时因为我们想要实现的是三种不同场景下的服务端代码,而服务端代码的改变并不改变客户端,所以此时明白服务端代码有三份,而客户端的代码大致都是相似的,主要起的功能就是向服务端发送数据,接收服务端传回来的数据,所以如下图所示,就是使用socket编程实现的UDP客户端:
明白,此时对于客户端而言,还有一个问题需要我们解决,本质也就是理解为什么服务端关心的是自己的IP地址和端口号,而客户端关心的也是服务器的IP地址和端口号, 对比服务端和客户端的代码我们发现,在进行sockaddr_in初始化时初始化的都是服务器的IP地址和端口号,那么第一个问题就来了,为什么客户端不需要设置自己的IP地址和端口号呢?原因是因为:在我们的电脑主机上存在非常多的客户端,如果让客户端像服务端一样自己设置端口号,那么就有可能会导致两个客户端设置的端口号相同,那么此时就会导致客户端启动冲突,因为socket网络通信的本质就是 [client_ip、client_port和server_ip、server_port] 两个进程之间的通信,所以对于客户端而言,它的端口号和IP地址都是操作系统统一分配的,在发送数据时操作系统同理会将对应的端口号和IP地址绑定到该主机的套接字,然后发送给服务端套接字,从而让服务端最终也能通过recvfrom接口顺利拿到客户端的sockaddr_in数据,这也就是为什么无论是服务端还是客户端它们关心的都是服务端的IP地址和端口号了,换一个角度来看,本质也就是取决于服务端是先收后发,而客户端是先发后收的原理,再本质一点来看也就是套接字的使用规则和网络通信的基本原理决定的。
其次是服务端代码的编写
搞定了有关客户端的知识,此时我们正式进入三个不同场景服务端代码的编写,本就就是一个逐渐深入,代码逐渐丰富,逐渐变化的一个过程,如下场景一、场景二、场景三所示。
场景一:客户端发消息给服务端,服务端将消息进行回显
此时该场景是三种场景下最简单的一种,也是socket编程最为简单的一种运用,总体socket的使用规则已包含在其中,当然对于现在的我们(搞懂sockaddr_in和API),这种场景还是非常基础和实用的,下述两种场景本质万变不离其宗,更多的是对代码能力和C++语法的熟练使用而已,现在就让我们一起来看看如何实现socket最基础的使用吧!如下代码所示:
场景二:客户端发消息给服务端,服务端对消息进行处理,返回处理后的数据
该场景相比于场景一,有关使用套接字实现网络通信以及数据接收和转发本质都没有任何区别,因为这一套就是套接字编程的使用规则,最大的区别就是我们在服务器中增加了一个实现数据处理的函数指针,然后我们对其进行了数据处理函数的初始化,让其具有了处理数据的功能,并且此时我们使用到了一个popen接口,所以此时我们的服务器就具备了处理指令的能力,具体如图示,下面就是代码实现:
处理指令之后的结果,如图所示:
所以如上图所示,此时我们就可以实现将本地客户端中输入相关的指令,然后通过网络的形式传到某个专门处理或者说可以处理该指令的服务端上,然后该服务端处理该指令,最后将指令处理完成之后的数据返回给我们,所以根据这一原理,这不就是我们使用云服务器构建xshell机器的过程吗?让xshell将我们本地输入的指令发送到远端服务器上,然后通过服务器发送到xshell的服务端上,然后xshell根据我们在云服务器上的数据和输入的指令,进行处理,最后返回处理结果,这样我们就获取到了对应指令产生的功能,这不就是最经典的网络通信过程吗?哈哈哈,具体有待深入学习,这里先简单理解一下就行。
场景三:客户端发消息给服务端,服务端将收到的消息全部返回给客户端
这个功能不能说是上述场景二的进阶,只能说是一个相辅相成的存在,因为一个服务端的代码设计和逻辑肯定是非常复杂的,需要实现的功能和完成的效果非常多,但是因为该代码设计多线程相关知识,所以我们将其称为场景三,本质就是实现一个群聊效果,让服务端中一个线程负责接收数据,一个线程负责发送数据,然后每次都会将所有数据发送给所有在线用户,所以因为该部分代码改动较大,客户端也需要通过多线程来控制,所以客户端代码也会展示,如下代码所示:
首先是服务端代码实现
其次是客户端代码实现
上述就是有关实现网络群聊效果的UDP服务器和客户端,当然代码只是基础实现,但是麻雀虽小五脏俱全,我们想要实现的预期效果是有的,本质逻辑就是让服务器保存所有发送数据主机的IP地址和端口号,然后将服务器接收到的任意消息广播给所有在线用户,以这一逻辑实现方法有很多,上述只是其中一种,所有有关UDP实现网络通信服务器和客户端的知识我们就理解到这里。