1.预备知识
1.1.理解源IP地址和目的IP地址
因特网上的每台计算机都有一个唯一的IP地址,如果一台主机上的数据要传输到另一台主机,那么对端主机的IP地址就应该作为该数据传输时的目的IP地址。但仅仅知道目的IP地址是不够的,当对端主机收到该数据后,对端主机还需要对该主机做出响应,因此对端主机也需要发送数据给该主机,此时对端主机就必须知道该主机的IP地址。因此一个传输的数据当中应该涵盖其源IP地址和目的IP地址,目的IP地址表明该数据传输的目的地,源IP地址作为对端主机响应时的目的IP地址。
在数据进行传输之前,会先自顶向下贯穿网络协议栈完成数据的封装,其中在网络层封装的IP报头当中就涵盖了源IP地址和目的IP地址。
1.2.理解源MAC地址和目的MAC地址
大部分数据的传输都是跨局域网的,数据在传输过程中会经过若干个路由器,最终才能到达对端主机。
源MAC地址和目的MAC地址是包含在链路层的报头当中的,而MAC地址实际只在当前局域网内有效,因此当数据跨网络到达另一个局域网时,其源MAC地址和目的MAC地址就需要发生变化,因此当数据达到路由器时,路由器会将该数据当中链路层的报头去掉,然后再重新封装一个报头,此时该数据的源MAC地址和目的MAC地址就发生了变化。
因此数据在传输的过程中是有两套地址:
一套是源IP地址和目的IP地址,这两个地址在数据传输过程中基本是不会发生变化的(存在一些特殊情况,比如在数据传输过程中使用NET技术,其源IP地址会发生变化,但至少目的IP地址是不会变化的)。
另一套就是源MAC地址和目的MAC地址,这两个地址是一直在发生变化的,因为在数据传输的过程中路由器不断在进行解包和重新封装。
1.3.理解源端口号和目的端口号
网络通信的本质:
首先我们需要明确的是,两台主机之间通信的目的不仅仅是为了将数据发送给对端主机,而是为了访问对端主机上的某个服务。比如我们在用百度搜索引擎进行搜索时,不仅仅是想将我们的请求发送给对端服务器,而是想访问对端服务器上部署的百度相关的搜索服务。
现在通过IP地址和MAC地址已经能够将数据发送到对端主机了,但实际我们是想将数据发送给对端主机上的某个服务进程。此外,数据的发送者也不是主机,而是主机上的某个进程,比如当我们用浏览器访问数据时,实际就是浏览器进程向对端服务进程发起的请求。
也就是说,网络通信本质上就是两个进程之间在进行通信,只不过这里是跨网络的进程间通信。比如逛淘宝和刷抖音的动作,实际就是手机上的淘宝进程和抖音进程在和对端服务器主机上的淘宝服务进程和抖音服务进程之间在进行通信。
注:
1.进程间要通信,就一定要保证要通信的进程能够看到同一份共享资源。在网络中,客户端进程和服务端进程要通信,两个进程也要能够看到同一份共享资源,这里的同一份共享资源就是网络。
2.进程间通信的方式除了管道、消息队列、信号量、共享内存等方式外,还有套接字,只不过前者是不跨网络的,而后者是跨网络的。
端口号:
实际在两台主机上,可能会同时存在多个正在进行跨网络通信的进程,因此当数据到达对端主机后,必须要通过某种方法找到该主机上对应的服务进程,然后将数据交给该进程处理。而当该进程处理完数据后还要对发送端进行响应,因此对端主机也需要知道,是发送端上的哪一个进程向它发送的数据请求。
端口号(port)的作用实际就是标识一台主机上的一个进程。
IP地址确保主机的唯一性,端口号确保主机上进程的唯一性,那么IP地址+端口号就能够标识互联网中唯一的一个进程。注:我们通常将 IP : PROT 称为socket,用来标定互联网中唯一的一个进程。源IP:源端口 是一个socket,目的IP:目的端口也是一个socket,源IP:源端口和目的IP:目的端口称为一个socket对。
端口号相关细节:
端口号是传输层协议的内容。
端口号是一个2字节16位的整数。
端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理。
IP地址 + 端口号能够标识网络上的某一台主机的某一个进程。一个端口号只能被一个进程占用。
当数据在传输层进行封装时,就会添加上对应源端口号和目的端口号的信息。这时通过源IP地址+源端口号就能够在网络上唯一标识发送数据的进程,通过目的IP地址+目的端口号就能够在网络上唯一标识接收数据的进程,此时就实现了跨网络的进程间通信。
注:
1.因为端口号是隶属于某台主机的,所以端口号可以在两台不同的主机当中重复,但是在同一台主机上进行网络通信的进程的端口号不能重复。
2.一个进程可以绑定多个端口号,但是一个端口号不能被多个进程同时绑定。一个进程可以绑定多个端口号,因为一个服务端进程可能既要和客户端的进程A通信,又要和客户端进程A的子线程通信,那么此时客户端A就要绑定两个端口号,所以一个进程绑定多个端口号是没有问题的,只要能够根据端口号找到对应进程即可。一个端口号不能被多个进程同时绑定,因为如果一个端口号被多个进程同时绑定了,那么端口号到进程间就不再具有唯一性的映射了。
3.传输层协议(TCP和UDP)的数据段中有两个端口号,分别叫做源端口号和目的端口号,就是在描述“数据是谁发的,要发给谁”。
理解socket这个名字:
socket在英文上有“插座”的意思,插座上有不同规格的插孔,我们将插头插入到对应的插孔当中就能够实现电流的传输。在进行网络通信时,客户端就相当于插头,服务端就相当于一个插座,但服务端上可能会有多个不同的服务进程(多个插孔),因此当我们在访问服务时需要指明服务进程的端口号(对应规格的插孔),才能享受对应服务进程的服务。
1.4.理解端口号(PROT)和进程ID(PID)
问题1:端口号(port)的作用唯一标识一台主机上的某个进程,进程ID(PID)的作用也是唯一标识一台主机上的某个进程,那在进行网络通信时为什么不直接用PID来代替port呢?
答:进程ID(PID)是用来标识系统内所有进程的唯一性的,它是属于系统级的概念;而端口号(port)是用来标识需要对外进行网络数据请求的进程的唯一性的,它是属于网络的概念。
一台机器上可能会有大量的进程,但并不是所有的进程都要进行网络通信,可能有很大一部分的进程是不需要进行网络通信的本地进程,此时PID虽然也可以标识这些网络进程的唯一性,但在该场景下就不太合适了。
比如每个人都有自己的身份证号,身份证号已经可以标识我们的唯一性了,但是当我们到了学校还是会有学号,到了公司还是会有工号。这是为什么呢?为什么不直接用身份证号来代替学号和工号呢?
因为身份证号是国家用于行政管理时用的编号,而学号是学校用于管理学生时用的编号,工号是公司用于管理员工时用的编号。但并不是全中国人都在某所学校或某家公司,因此在学校或公司当中,没必要用身份证号来标识每个人的唯一性。此时就出现了学号和工号,在学号和工号当中还可以包含一些便于管理的信息,比如入学(入职)年份、性别等信息。
也就是说,在不同的场景下可能需要不同的编号来标识某种事物的唯一性,因为这些编号更适合用于该场景。
问题2:底层如何通过port找到对应进程的?
答:实际底层采用哈希的方式建立了端口号和进程PID或PCB之间的映射关系,当底层拿到端口号时就可以直接执行对应的哈希算法,然后就能够找到该端口号对应的进程。
1.5.认识TCP协议和UDP协议
网络协议栈是贯穿整个体系结构的,在应用层、操作系统层(传输层和网络层)和驱动层(数据链路层)各有一部分。当我们使用系统调用接口实现网络数据通信时,不得不面对的协议层就是传输层,而传输层最典型的两种协议就是TCP协议和UDP协议。
TCP协议:
TCP协议叫做传输控制协议(Transmission Control Protocol),TCP协议是一种面向连接的、可靠的、基于字节流的传输层通信协议。TCP协议是面向连接的,如果两台主机之间想要进行数据传输,那么必须要先建立连接,当连接建立成功后才能进行数据传输。其次,TCP协议是保证可靠的协议,数据在传输过程中如果出现了丢包、乱序等情况,TCP协议都有对应的解决方法。
UDP协议:
UDP协议叫做用户数据报协议(User Datagram Protocol),UDP协议是一种无需建立连接的、不可靠的、面向数据报的传输层通信协议。使用UDP协议进行通信时无需建立连接,如果两台主机之间想要进行数据传输,那么直接将数据发送给对端主机就行了,但这也就意味着UDP协议是不可靠的,数据在传输过程中如果出现了丢包、乱序等情况,UDP协议本身是不知道的。
问题:TCP协议是一种可靠的传输协议,使用TCP协议能够在一定程度上保证数据传输时的可靠性,而UDP协议是一种不可靠的传输协议,既然UDP协议是不可靠的,那为什么还要有UDP协议的存在?
答:首先,可靠是需要我们做更多的工作的,TCP协议虽然是一种可靠的传输协议,但这一定意味着TCP协议在底层需要做更多的工作,因此TCP协议底层的实现是比较复杂的,我们不能只看到TCP协议面向连接可靠这一个特点,我们也要能看到TCP协议对应的缺点。
同样的,UDP协议虽然是一种不可靠的传输协议,但这一定意味着UDP协议在底层不需要做过多的工作,因此UDP协议底层的实现一定比TCP协议要简单,UDP协议虽然不可靠,但是它能够快速的将数据发送给对方,虽然在数据在传输的过程中可能会出错。
编写网络通信代码时具体采用TCP协议还是UDP协议,完全取决于上层的应用场景。如果应用场景严格要求数据在传输过程中的可靠性,此时我们就必须采用TCP协议,如果应用场景允许数据在传输出现少量丢包,那么我们肯定优先选择UDP协议,因为UDP协议足够简单。
注: 一些优秀的网站在设计网络通信算法时,会同时采用TCP协议和UDP协议,当网络流畅时就使用UDP协议进行数据传输,而当网速不好时就使用TCP协议进行数据传输,此时就可以动态的调整后台数据通信的算法。
1.6.网络字节序
网络字节序概述:我们已经知道,内存中的多字节数据相对于内存地址有大端和小端之分,磁盘文件中的多字节数据相对于文件中的偏移地址也有大端小端之分,网络数据流同样有大端小端之分。那么如何定义网络数据流的地址呢?发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出,接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存,因此,网络数据流的地址应这样规定:先发出的数据是低地址,后发出的数据是高地址。TCP/IP协议规定,网络数据流应采用大端字节序,即低地址高字节。不管这台主机是大端机还是小端机, 都会按照这个TCP/IP规定的网络字节序来发送/接收数据。如果当前发送主机是小端,就需要先将数据转成大端,如果当前发送主机是大端,就直接发送即可。网络字节序详解:
大端模式: 数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
小端模式: 数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。
如果编写的程序只在本地机器上运行,那么是不需要考虑大小端问题的,因为同一台机器上的数据采用的存储方式都是一样的,要么采用的都是大端存储模式,要么采用的都是小端存储模式。但如果涉及网络通信,那就必须考虑大小端的问题,否则对端主机识别出来的数据可能与发送端想要发送的数据是不一致的。
例如,现在两台主机之间在进行网络通信,其中发送端是小端机,而接收端是大端机。发送端将发送缓冲区中的数据按内存地址从低到高的顺序发出后,接收端从网络中获取数据依次保存在接收缓冲区时,也是按内存地址从低到高的顺序保存的。
由于我们不能保证通信双方存储数据的方式是一样的,因此网络当中传输的数据必须考虑大小端问题。因此TCP/IP协议规定,网络数据流采用大端字节序,即低地址高字节。无论是大端机还是小端机,都必须按照TCP/IP协议规定的网络字节序来发送和接收数据。
如果发送端是小端,需要先将数据转成大端,然后再发送到网络当中。
如果发送端是大端,则可以直接进行发送。
如果接收端是小端,需要先将接收到数据转成大端后再进行数据识别。
如果接收端是大端,则可以直接进行数据识别。
注:所有的大小端的转化工作是由操作系统来完成的,因为该操作属于通信细节,不过也有部分的信息需要我们自行进行处理,比如端口号和IP地址。
问题:为什么网络字节序采用的是大端而不是小端?
答:网络字节序采用的是大端,而主机字节序一般采用的是小端,那为什么网络字节序不采用小端呢?如果网络字节序采用小端的话,发送端和接收端在发生和接收数据时就不用进行大小端的转换了。
TCP在Unix时代就有了,以前Unix机器都是大端机,因此网络字节序也就采用的是大端,但之后人们发现用小端能简化硬件设计,所以现在主流的都是小端机,但协议已经不好改了。
网络字节序与主机字节序之间的转换:
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,系统提供了四个函数,可以通过调用以下库函数实现网络字节序和主机字节序之间的转换。
uint32_t htonl(uint32_t hostlong); uint16_t htons(uint16_t hostshort); uint32_t ntohl(uint32_t netlong); uint16_t ntohs(uint16_t netshort);
函数名当中的h表示host,n表示network,l表示32位长整数,s表示16位短整数。例如htonl表示将32位长整数从主机字节序转换为网络字节序。
如果主机是小端字节序,则这些函数将参数做相应的大小端转换然后返回。如果主机是大端字节序,则这些函数不做任何转换,将参数原封不动地返回。注:使用上面的函数需要包含<arpa/inet.h>头文件。
2.套接字socket编程接口
2.1.socket常见API
创建套接字:(TCP/UDP,客户端+服务器)
int socket(int domain, int type, int protocol);
绑定端口号:(TCP/UDP,服务器)
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
监听套接字:(TCP,服务器)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
接收请求:(TCP,服务器)
int accept(int sockfd, struct sockaddr *addr, socklen_t *addrlen);
建立连接:(TCP,客户端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
2.2.sockaddr结构
套接字不仅支持跨网络的进程间通信,还支持本地的进程间通信(域间套接字)。在进行跨网络通信时我们需要传递的端口号和IP地址,而本地通信则不需要,因此套接字提供了sockaddr_in结构体和sockaddr_un结构体,其中sockaddr_in结构体是用于跨网络通信的,而sockaddr_un结构体是用于本地通信的。
为了让套接字的网络通信和本地通信能够使用同一套函数接口,于是就出现了sockeaddr结构体,该结构体与sockaddr_in和sockaddr_un的结构都不相同,但这三个结构体头部的16个比特位都是一样的,这个字段叫做协议家族。
此时当我们在传递在传参时,就不用传入sockeaddr_in或sockeaddr_un这样的结构体,而统一传入sockeaddr这样的结构体。在设置参数时就可以通过设置协议家族这个字段,来表明我们是要进行网络通信还是本地通信,在这些API内部就可以提取sockeaddr结构头部的16位进行识别,进而得出我们是要进行网络通信还是本地通信,然后执行对应的操作。此时我们就通过通用sockaddr结构,将套接字网络通信和本地通信的参数类型进行了统一。
IPv4和IPv6的地址格式定义在netinet/in.h中,IPv4地址用sockaddr_in结构体表示,包括16位地址类型,16位端口号和32位IP地址。
IPv4、IPv6地址类型分别定义为常数AF_INET、AF_INET6。这样,只要取得某种sockaddr结构体的首地址,不需要知道具体是哪种类型的sockaddr结构体,就可以根据地址类型字段确定结构体中的内容。
socket API可以都用struct sockaddr* 类型表示,在使用的时候需要强制转化成sockaddr_in;这样的好处是程序的通用性,可以接收IPv4、IPv6,以及UNIX Domain Socket各种类型的sockaddr结构体指针做为参数。
注:实际我们在进行本地的进程间通信和网络通信时,定义的还是sockaddr_un和sockaddr_in这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为sockaddr罢了。
问题1:本地进程间通信的方式已经有管道、消息队列、共享内存、信号量等方式了,现在在套接字这里又出现了可以用于本地进程间通信的域间套接字,为什么会有这么多通信方式,并且这些通信方式好像并不相关?
答:实际是因为早期有很多不同的实验室都在研究通信的方式,由于是不同的实验室,因此就出现了很多不同的通信方式,比如常见的有System V标准的通信方式和POSIX标准的通信方式。
注:System V标准的通信方式只能进行本地通信,并且其不是以文件的方式来进行通信的,而现在的网络服务器都是以文件的方式进行传输(一切皆文件),因此System V标准的通信方式如今用的很少。
问题2:我们可以将这些函数的struct sockaddr*参数类型改为void*,此时在函数内部也可以直接指定提取头部的16个比特位进行识别,最终也能够判断是需要进行网络通信还是本地通信,那为什么还要设计出sockaddr这样的结构呢?
答:实际在设计这一套网络接口的时候C语言还不支持void*,于是就设计出了sockaddr这样的解决方案。并且在C语言支持了void*之后也没有将它改回来,因为这些接口是系统接口,系统接口是所有上层软件接口的基石,系统接口是不能轻易更改的,否则引发的后果是不可想的,这也就是为什么现在依旧保留sockaddr结构的原因。
3. 简单的UDP网络程序
3.1.创建套接字与套接字绑定
3.1.1.创建套接字socket函数
我们把服务器封装成一个类,当我们定义出一个服务器对象后需要马上初始化服务器,而初始化服务器需要做的第一件事就是创建套接字。
socket函数:
socket函数用来创建套接字。
int socket(int domain, int type, int protocol);
参数:
domain:创建套接字的域或者叫做协议家族,也就是创建套接字的类型。该参数就相当于struct sockaddr结构的前16个位。如果是本地通信就设置为AF_UNIX,如果是网络通信就设置为AF_INET(IPv4)或AF_INET6(IPv6)。
type:创建套接字时所需的服务类型。其中最常见的服务类型是SOCK_STREAM和SOCK_DGRAM,如果是基于UDP的网络通信,我们采用的就是SOCK_DGRAM,叫做用户数据报服务,如果是基于TCP的网络通信,我们采用的就是SOCK_STREAM,叫做流式套接字,提供的是流式服务。
protocol:创建套接字的协议类别。你可以指明为TCP或UDP,但该字段一般直接设置为0就可以了,设置为0表示的就是默认,此时会根据传入的前两个参数自动推导出你最终需要使用的是哪种协议。返回值:
套接字创建成功返回一个文件描述符,创建失败返回-1,同时错误码会被设置。
注:
使用socket函数需要包含<sys/types.h>和<sys/socket.h>头文件。
问题1:socket函数属于什么类型的接口?
答:网络协议栈是分层的,按照TCP/IP四层模型来说,自顶向下依次是应用层、传输层、网络层和数据链路层。而我们现在所写的代码都叫做用户级代码,也就是说我们是在应用层编写代码,因此我们调用的实际是下三层的接口,而传输层和网络层都是在操作系统内完成的,也就意味着我们在应用层调用的接口都叫做系统调用接口。问题2:socket函数是被谁调用的?
答:socket这个函数是被程序调用的,但并不是被程序在编码上直接调用的,而是程序编码形成的可执行程序运行起来变成进程,当这个进程被CPU调度执行到socket函数时,然后才会执行创建套接字的代码,也就是说socket函数是被进程所调用的。
问题3:socket函数底层做了什么?
答:socket函数是被进程所调用的,而每一个进程在系统层面上都有一个进程地址空间PCB(task_struct)、文件描述符表(files_struct)以及对应打开的各种文件。而文件描述符表里面包含了一个数组fd_array,其中数组中的0、1、2下标依次对应的就是标准输入、标准输出以及标准错误。当我们调用socket函数创建套接字时,实际相当于我们打开了一个“网络文件”,打开后在内核层面上就形成了一个对应的struct file结构体,同时该结构体被连入到了该进程对应的文件双链表,并将该结构体的首地址填入到了fd_array数组当中下标为3的位置,此时fd_array数组中下标为3的指针就指向了这个打开的“网络文件”,最后3号文件描述符作为socket函数的返回值返回给了用户。
其中每一个struct file结构体中包含的就是对应打开文件各种信息,比如文件的属性信息、操作方法以及文件缓冲区等。其中文件对应的属性在内核当中是由struct inode结构体来维护的,而文件对应的操作方法实际就是一堆的函数指针(比如read和write)在内核当中就是由struct file_operations结构体来维护的。而文件缓冲区对于打开的普通文件来说对应的一般是磁盘,但对于现在打开的“网络文件”来说,这里的文件缓冲区对应的就是网卡。
对于一般的普通文件来说,当用户通过文件描述符将数据写到文件缓冲区,然后再把数据刷到磁盘上就完成了数据的写入操作。而对于现在socket函数打开的“网络文件”来说,当用户将数据写到文件缓冲区后,操作系统会定期将数据刷到网卡里面,而网卡则是负责数据发送的,因此数据最终就发送到了网络当中。
3.1.2.绑定套接字bind函数
bind函数:
bind函数是对套接字进行IP地址+port端口号的绑定。
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数:
sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
addr:网络相关的属性信息,包括协议家族、IP地址、端口号等。
addrlen:传入的addr结构体的长度。
返回值:
绑定成功返回0,绑定失败返回-1,同时错误码会被设置。
注:
1.使用bind函数需要包含<sys/types.h>和<sys/socket.h>头文件。
2.使用struct sockaddr类型需要包含<netinet/in.h>和<arpa/inet.h>头文件
struct sockaddr_in结构体:
在绑定时需要将网络相关的属性信息填充到一个结构体当中,然后将该结构体作为bind函数的第二个参数进行传入,这实际就是struct sockaddr_in结构体。struct sockaddr_in结构体中的成员变量如下:
sin_family:表示公共的协议家族字段。
sin_port:表示端口号,是一个16位的整数。
sin_addr:sin_addr结构体的s_addr成员表示IP地址,是一个32位的整数。注:
1.struct sockaddr_in结构体的 sin_port 端口号在填充的时候,需要使用htons函数将端口号转为大端格式,因为端口号将来是要通过网络发送给对方主机的。
2.struct sockaddr_in结构体的 sin_addr.s_addr IP地址在填充的时候,需要注意几点:
需要使用inet_addr函数将字符串风格的点分十进制IP地址转换成整型的四字节IP地址,inet_addr函数声明如下图所示,参数cp为字符串风格的点分十进制IP地址,返回整型的四字节IP地址。
这里inet_addr函数除了会将字符串风格的点分十进制IP地址转换成整型的四字节IP地址,还会自动将整型的四字节IP地址进行主机转网络,即返回的整型的四字节IP地址是大端的。
使用 inet_addr函数需要包含<sys/socket.h><netinet/in.h><arpa/inet.h>头文件。
服务器一般不关心会bind绑定哪一个IP地址,而是可以绑定任意的IP地址,那么我们直接给sin_addr.s_addr赋值INADDER_ANY即可,如下代码所示,该做法强烈推荐。如果给服务器绑定特定的IP地址,那么该服务器只能收到目的IP为该IP的数据,一般在做测试时使用。
INADDER_ANY是一个宏,其数值是0,数值0转不转大端是无所谓的,因此INADDER_ANY也可以不用调用htonl函数做主机转网络操作。
local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
云服务器有一些特殊情况:云服务器禁止bind绑定任何确定IP,因此只能使用INADDER_ANY初始化sin_addr.s_addr IP地址。
3.struct sockaddr_in属于系统级的概念,不同的平台接口设计可能会有点差别。
struct sockaddr_in结构体初始化函数:
给struct sockaddr_in结构体填充内容之前最好先进行初始化,给struct sockaddr_in结构体初始化可以使用bzero函数或memset函数,下图一二是bzero函数和memset函数的函数声明,这里不再细讲。
注:使用bzero函数和memset函数需要包含<string.h>头文件。
3.2.读取信息函数recvfrom函数和发送信息函数sendto函数
recvfrom函数:
recvfrom函数是UDP特有的,功能是通过套接字读取消息。
参数:
sockfd:要读取的套接字。
buf:将读取到的数据放到buf中。
len:buf的大小。
flags:设置为0表示阻塞式读取。
src_addr:输出型参数,获取发送该消息主机的sockeaddr结构体指针(指向内容中包含发送主机网络相关的属性信息)
addrlen:输入输出型参数,输入时代表src_addr指针指向的sockeaddr变量定义时的大小,输出时代表src_addr指针得到了发送该消息主机的sockeaddr结构体指针后,对方主机的sockeaddr结构体的大小。
返回值:
如果正确接收返回接收到的字节数,失败返回-1。
注:使用recvfrom函数需要包含<sys/types.h><sys/socket.h>头文件。
sendto函数:
sendto函数是UDP特有的,功能是通过套接字发送消息。
sockfd:要写入的套接字。
buf:将buf中的数据发送。
len:buf的大小。
flags:设置为0表示阻塞式发送。
src_addr:输入型参数,要接收该消息主机的sockeaddr结构体指针(指向内容中包含发送主机网络相关的属性信息)
addrlen:输入型参数,src_addr指针指向的sockeaddr变量的大小。
返回值:
如果正确发送返回发送的字节数,失败返回-1。
注:使用sendto函数需要包含<sys/types.h><sys/socket.h>头文件。
3.3.简单的UDP网络程序
服务端和客户端都是Linux的本地通信:
创建udpServer.cc文件,写入下图一所示的代码,创建udpClient.cc文件,写入下图二所示的代码,创建Log.hpp文件,写入下图三所示的代码,创建Makefile文件,写入下图四所示的代码,使用make命令生成udpClient和udpServer可执行程序,创建两个选项卡,一个选项卡使用./udpServer命令运行udpServer可执行程序,一个选项卡使用./udpClient命令运行udpClient可执行程序,再创建一个选项卡,使用netstat -lnup命令作为监控脚本,如下图五所示。
netstat -lnup命令可以看到我们服务器程序udpServer的网络状态,Proto是服务类型这里为udp的,Recv-Q是收到的消息数这里为0,Send-Q是发送的消息数这里为0,Local是本地绑定的IP地址这里是0.0.0.0,即任意地址绑定,Address是绑定的端口号这里是8080,Foreign为允许访问的远端主机IP,这里为任何远端主机,Address为允许访问的远端主机的端口,这里为任何远端主机的端口。
这里在客户端进程输入消息并回车后,服务端进程就会打印出对应的消息。从服务端打印的内容可以看到,客户端进程所在主机IP为127.0.0.1,操作系统给客户端进程绑定的随机端口号为 。
udpServer.cc文件:
#include <iostream> #include <string> #include <cstring> #include <unistd.h> #include <stdlib.h> #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> #include "Log.hpp" static void Usage(const std::string porc) { std::cout << "Usage:\n\t" << porc << " port [ip]" << std::endl; } /// @brief 我们想写一个简单的udpSever /// 云服务器有一些特殊情况: /// 1. 禁止你bind云服务器上的任何确定IP, 只能使用INADDR_ANY,如果你是虚拟机,随意 class UdpServer { public: UdpServer(int port, std::string ip = "") : port_((uint16_t)port), ip_(ip), sockfd_(-1) { } ~UdpServer() { } public: void init() { // 1. 创建socket套接字 sockfd_ = socket(AF_INET, SOCK_DGRAM, 0); // 就是打开了一个文件 if (sockfd_ < 0) { logMessage(FATAL, "socket:%s:%d", strerror(errno), sockfd_); exit(1); } logMessage(DEBUG, "socket create success: %d", sockfd_); // 2. 绑定网络信息,指明ip+port // 2.1 先填充基本信息到 struct sockaddr_in struct sockaddr_in local; // local在哪里开辟的空间? 用户栈 -> 临时变量 -> 写入内核中 bzero(&local, sizeof(local)); // memset // 填充协议家族,域 local.sin_family = AF_INET; // 填充服务器对应的端口号信息,一定是会发给对方的,port_一定会到网络中 local.sin_port = htons(port_); // 服务器都必须具有IP地址,"xx.yy.zz.aaa",字符串风格点分十进制 -> 4字节IP -> uint32_t ip // INADDR_ANY(0): 程序员不关心会bind到哪一个ip, 任意地址bind,强烈推荐的做法,所有服务器一般的做法 // inet_addr: 指定填充确定的IP,特殊用途,或者测试时使用,除了做转化,还会自动给我们进行 h—>n local.sin_addr.s_addr = ip_.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str()); // 2.2 bind 网络信息 if (bind(sockfd_, (const struct sockaddr *)&local, sizeof(local)) == -1) { logMessage(FATAL, "bind: %s:%d", strerror(errno), sockfd_); exit(2); } logMessage(DEBUG, "socket bind success: %d", sockfd_); // done } void start() { // 服务器设计的时候,服务器都是死循环 char inbuffer[1024]; //将来读取到的数据,都放在这里 char outbuffer[1024]; //将来发送的数据,都放在这里 while (true) { struct sockaddr_in peer; //输出型参数 socklen_t len = sizeof(peer); //输入输出型参数 // UDP无连接的 // 对方给你发了消息,你想不想给对方回消息?要的!后面的两个参数是输出型参数 ssize_t s = recvfrom(sockfd_, inbuffer, sizeof(inbuffer) - 1, 0, (struct sockaddr *)&peer, &len); if (s > 0) { inbuffer[s] = 0; //当做字符串 } else if (s == -1) { logMessage(WARINING, "recvfrom: %s:%d", strerror(errno), sockfd_); continue; } // 读取成功的,除了读取到对方的数据,你还要读取到对方的网络地址[ip:port] std::string peerIp = inet_ntoa(peer.sin_addr); //拿到了对方的IP uint32_t peerPort = ntohs(peer.sin_port); // 拿到了对方的port // 打印出来客户端给服务器发送过来的消息 logMessage(NOTICE, "[%s:%d]# %s", peerIp.c_str(), peerPort, inbuffer); } } private: // 服务器必须得有端口号信息 uint16_t port_; // 服务器必须得有ip地址 std::string ip_; // 服务器的socket fd信息 int sockfd_; }; // ./udpServer port [ip] int main(int argc, char *argv[]) { if (argc != 2 && argc != 3) //反面:argc == 2 || argc == 3 { Usage(argv[0]); exit(3); } uint16_t port = atoi(argv[1]); std::string ip; if (argc == 3) { ip = argv[2]; } UdpServer svr(port, ip); svr.init(); svr.start(); return 0; }
udpClient.cc文件:
#include <iostream> #include <string> #include <cstdlib> #include <cassert> #include <unistd.h> #include <strings.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/types.h> #include <sys/socket.h> static void Usage(std::string name) { std::cout << "Usage:\n\t" << name << " server_ip server_port" << std::endl; } // ./udpClient server_ip server_port // 如果一个客户端要连接server必须知道server对应的ip和port int main(int argc, char *argv[]) { if (argc != 3) { Usage(argv[0]); exit(1); } // 1. 根据命令行,设置要访问的服务器IP std::string server_ip = argv[1]; uint16_t server_port = atoi(argv[2]); // 2. 创建客户端 // 2.1 创建socket int sockfd = socket(AF_INET, SOCK_DGRAM, 0); assert(sockfd > 0); // 2.2 client 需不需要bind??? 需要bind,但是不需要用户自己bind,而是os自动给你bind // 所谓的"不需要",指的是: 不需要用户自己bind端口信息!因为OS会自动给你绑定,你也最好这么做! // 如果我非要自己bind呢?可以!严重不推荐! // 所有的客户端软件 <-> 服务器 通信的时候,必须得有 client[ip:port] <-> server[ip:port] // 为什么呢??client很多,不能给客户端bind指定的port,port可能被别的client使用了,你的client就无法启动了 // 那么server凭什么要bind呢??server提供的服务,必须被所有人知道!server不能随便改变! // 2.2 填写服务器对应的信息 struct sockaddr_in server; bzero(&server, sizeof server); server.sin_family = AF_INET; server.sin_port = htons(server_port); server.sin_addr.s_addr = inet_addr(server_ip.c_str()); // 3. 通讯过程 std::string buffer; while (true) { std::cout << "Please Enter# "; std::getline(std::cin, buffer); // 发送消息给server sendto(sockfd, buffer.c_str(), buffer.size(), 0, (const struct sockaddr *)&server, sizeof(server)); // 首次调用sendto函数的时候, //我们的client会自动bind自己的ip和port } return 0; }
Log.hpp文件:
#pragma once #include <cstdio> #include <ctime> #include <cstdarg> #include <cassert> #include <cstring> #include <cerrno> #include <stdlib.h> #define DEBUG 0 #define NOTICE 1 #define WARINING 2 #define FATAL 3 const char *log_level[]={"DEBUG", "NOTICE", "WARINING", "FATAL"}; // logMessage(DEBUG, "%d", 10); void logMessage(int level, const char *format, ...) { assert(level >= DEBUG); assert(level <= FATAL); char *name = getenv("USER"); char logInfo[1024]; va_list ap; // ap -> char* va_start(ap, format); vsnprintf(logInfo, sizeof(logInfo)-1, format, ap); va_end(ap); // ap = NULL FILE *out = (level == FATAL) ? stderr:stdout; fprintf(out, "%s | %u | %s | %s\n", \ log_level[level], \ (unsigned int)time(nullptr),\ name == nullptr ? "unknow":name,\ logInfo); }
Makefile文件:
.PHONY:all all:udpClient udpServer udpClient: udpClient.cc g++ -o $@ $^ -std=c++11 udpServer:udpServer.cc g++ -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f udpClient udpServer
注:
1.在Log.hpp文件中,日志函数logMessage函数是一个可变参数函数,在可变参数的函数中可以使用va_start函数、va_arg函数,函数声明如下图所示,va_list类型其实就是一个char*类型的指针,用来指向可变参数列表。使用这些函数需要包含<stdarg.h>头文件。
va_start函数是使用last(离可变参数最近的参数)对ap指针做初始化。
va_end函数是用来将ap指针置NULL。
va_arg函数用来提取传入可变参数函数中type类型的参数值。
可变参数的函数中还可以使用vsnprintf函数,其声明如下图所示。使用这些函数需要包含<stdarg.h>头文件。
vsnprintf用来将ap指向的可变参数列表采用format格式化后写入到str字符串中,size为str字符串的大小,format表示用什么方式("%d"、"%s"等)格式化显示。
2.服务端创建套接字:当我们在进行初始化服务器创建套接字时,就是调用socket函数创建套接字,创建套接字时我们需要填入的协议家族就是AF_INET,因为我们要进行的是网络通信,而我们需要的服务类型就是SOCK_DGRAM,因为我们现在编写的UDP服务器是面向数据报的,而第三个参数之间设置为0即可。
当析构服务器时,我们可以将sockfd对应的文件进行关闭,但实际上不进行该操作也行,因为一般服务器运行后是就不会停下来的。3.服务端绑定网络信息(IP+PORT):现在套接字已经创建成功了,但作为一款服务器来讲,如果只是把套接字创建好了,那我们也只是在系统层面上打开了一个文件,操作系统将来并不知道是要将数据写入到磁盘还是刷到网卡,此时该文件还没有与网络关联起来。
由于现在编写的是不面向连接的UDP服务器,所以初始化服务器要做的第二件事就是先填充基本信息到sockaddr_in结构体中,然后使用bind函数进行绑定。
4.在服务器的start函数中,使用recvfrom函数将客户端发送的消息读取到inbuffer数组中,同时将客户端(发送端)的sockeaddr结构体指针(包含发送主机网络相关的属性信息:IP地址+端口号PORT)保存在peer结构体中。peer结构体中peer.sin_addr和peer.sin_port成员变量因为是从网络中获取的,所以需要使用ntohs函数大端转小端,此外peer.sin_addr成员变量存储的是整型的四字节IP地址,需要使用inet_ntoa函数转换成字符串风格的点分十进制IP地址。
inet_ntoa函数声明如下图所示,参数in为sockaddr_in结构体的sin_addr成员变量(整形的四字节IP地址),返回字符串风格的点分十进制IP地址。
这里inet_ntoa函数除了会将整形的四字节IP地址转换成字符串风格的点分十进制IP地址,还会自动将字符串风格的点分十进制IP地址进行网络转主机,即返回的字符串风格的点分十进制IP地址是小端的。
使用 inet_ntoa函数需要包含<sys/socket.h><netinet/in.h><arpa/inet.h>头文件。
5.客户端要连接服务端,首先必须知道服务器对应的IP地址和端口号PORT,因此客户端首先要根据命令行,设置要访问的服务器的IP和PORT,然后进行套接字socket的创建并填写服务器对应信息,创建完套接字socket并填写完服务器对应信息后就可以进行通信了。
需要注意的是:客户端进程不需要使用bind函数将自己的IP地址和端口号PORT与套接字进行绑定,因为操作系统会自动绑定(在客户端首次调用sendto函数的时候)。如果非要自己进行bind绑定,也可以,但是严重不推荐。
问题:为什么客户端进程不要进行绑定呢?
答:因为客户端的进程很多,不能由用户手动的给客户端的某个进程绑定某个port,如果用户强行给客户端进程A绑定portA端口,而后面又给客户端进程B绑定portA端口(有可能是用户误操作绑定的,也有可能是用户没有绑定服务器随机绑定的,用户强行绑定的端口号不在服务器已绑定端口号列表中),那么此时客户端进程A就无法启动了。
客户端进程的端口号重要,但是客户端进程的端口号是多少一点都不重要,因为没有主机会连客户端进程,客户端进程的端口号只要能保证在客户端主机的唯一性即可。
所以,所有的客户端进程都不要手动的绑定套接字,将这个工作交给操作系统,操作系统随机生成一个未使用的端口号自动进行绑定即可。
问题:为什么服务端进程要进行绑定呢?
答:因为服务端进程是提供服务的,必须被所有人知道,所有的客户端认的是确定的服务器IP地址和端口号PORT,不能交给操作系统去绑定随机的申请端口号PORT,操作系统的IP地址和端口号PORT不能随便改变。公司内部在服务端,什么样的服务进程绑定什么样的端口是确定好的,一旦确定后不会轻易改变了。
6.服务器在端口绑定时,不要绑定和使用0-1023的端口号,0-1023的端口号是供服务器内部使用的。因为这里测试时,服务端进程和客户端进程都在同一台云服务器主机上,所以客户端在发送时,服务端IP可以写成127.0.0.1,127.0.0.1这种IP称为本地环回,代表当前主机。这样客户端进程发送的消息经过网络协议栈最底部后不往网络里发,由网络协议栈最底部向上交付,最终交给服务端进程。
补充:(服务端和客户端都是Linux的跨网络通信)
上面的测试我们是进行本地测试(同一台主机的两个进程之间通信,消息并没有真正的通过网络进行传递),如果有两台云服务器,那么就可以进行网络间通信测试了。
第一步:在云服务器A上完成上面的udpClient.cc、udpServer.cc、Log.hpp、Makefile文件。首先对上面的Makefile文件进行修改,在g++生成客户端可执行程序udpClient的指令中加上-static选项,如下图所示,采用静态编译。然后使用make命令生成udpClient可执行程序。
第二步:使用 sz udpClient 命令,然后选择要保存的地方点击确定,将客户端udpClient可执行程序从云服务器A保存到本地。
第三步:打开云服务器B,将保存在本地的udpClient可执行程序拖拽进xshell框中(或者使用 rz -e 命令然后选择要上传的udpClient可执行程序点击确定),将本地的udpClient可执行程序上传到云服务器B。此时使用ll命令就可以看到上传的udpClient可执行程序。
第四步:在云服务器B中,上传的udpClient可执行程序默认是没有可执行权限的,使用 chmod +x udpClient 命令加上可执行权限即可。
第五步:在服务器A中使用 ./udpServer 8080 命令运行udpServer可执行程序(这里我们指定使用8080端口),运行后服务器A阻塞式的等待接收消息。在服务器B中使用 ./udpClient 42.192.83.143 8080 命令运行udpClient 可执行程序(这里假设服务器A的IP地址为42.192.83.143),运行后输入发送消息的内容并回车,此时服务器A中便打印出接收到消息,这样便实现了云服务器之间的消息发送与接收。
服务端是Linux、客户端是windows的跨网络通信:
在Linux云服务器下创建udpServer.cc文件,写入下图一所示的代码,在windows主机下打开vs2019,创建udpClient.cc文件,写入下图二所示的代码,在Linux云服务器下创建Log.hpp文件,写入下图三所示的代码,创建Makefile文件,写入下图四所示的代码,使用make命令生成udpClient和udpServer可执行程序。在Linux云服务器下使用./udpServer命令运行udpServer可执行程序,再创建一个选项卡,使用netstat -lnup命令作为监控脚本,如下图五所示。
注:
1.windows主机下客户端udpClient.cc文件中需要使用#pragma comment(lib,"ws2_32.lib")代码包含链接库,并且需要包含<WinSock2.h>头文件。
windows下创建套接字前还需要初始化启动信息与Linux下有些区别(Linux下不需要初始化启动信息),windows下创建套接字前需要先使用 WSADATA data 代码创建变量用来初始化套接字,然后使用 WSAStartup(MAKEWORD(2,2),&data) 代码初始化启动信息(初始化套接字),然后就可以创建套接字了。
windows下没有bzero函数,我们使用memset函数初始化sockaddr_in类型的server变量(服务端网络相关的属性信息)
命令行参数在windows下就不使用了(argc和argv),我们直接定义全局的服务端IP地址变量server_ip和服务端端口号变量server_port。
最后在windows主机下,需要使用 closesocket(sockfd) 代码释放套接字,使用 WSACleanup() 代码清空启动信息。
这里使用inet_addr函数会有警告,如下图所示,我们使用 #define _WINSOCK_DEPRECATED_NO_WARNINGS 2 代码(或使用 #pragma warning(disable:4996) 代码)将警告屏蔽掉即可。