目录
预备知识
1. 理解源IP地址和目的IP地址
2. 理解源MAC地址和目的MAC地址
3. 认识端口号
4. 理解源端口号和目的端口号
5. 端口号(port) vs 进程pid
6. 认识TCP协议和认识UDP协议
7. 网络字节序
socket编程接口
1. socket 常见API
2. sockaddr结构
简单的UDP网络程序
1. 服务端创建udp socket
创建套接字函数——socket函数
服务端创建套接字
2. 服务端绑定
bind函数
增加IP地址和端口号
服务端绑定
如何快速将整数IP<->字符串IP
3. 运行服务器
recvfrom函数
4. 客户端创建套接字
5.启动客户端
sendto函数
6. 本地测试
7. INADDR_ANY
8.对服务端代码进行分层
9. 简易的回声服务器
10. 命令过滤服务器
11.实现Windows客户端和Linux的客户端进行网络通信
12. 实现多人聊天服务器
预备知识
1. 理解源IP地址和目的IP地址
在IP数据包头部中, 有两个IP地址, 分别叫做源IP地址, 和目的IP地址。
- 在因特网上,每台计算机都有一个唯一的IP地址,用于标识该计算机。当一台主机需要将数据传输到另一台主机时,接收数据的那一台主机的IP地址被用作目的IP地址。但仅仅知道目的IP地址是不够的。
- 为了确保数据能够正确地传输到目标主机并得到响应,源主机也需要知道目标主机的IP地址。因此,一个数据包中应该包含源IP地址和目的IP地址。目的IP地址表示数据传输的目的地,而源IP地址则是目标主机响应时的目的地址。
- 在数据传输之前,数据会自上而下地通过网络协议栈进行封装。在网络层,数据被封装在IP报头中,其中包含了源IP地址和目的IP地址。
除了IP地址外,还有MAC地址的概念。MAC地址是用于标识网络接口卡(NIC)的唯一地址。在网络层和数据链路层之间,数据还经过了封装过程,其中包含了源MAC地址和目的MAC地址。这些MAC地址是用来在局域网中进行通信的,以确保数据能够准确地传输到目标主机。
2. 理解源MAC地址和目的MAC地址
大部分数据的传输都是跨局域网的,数据在传输过程中会经过若干个路由器,最终才能到达对端主机。
- 在局域网中,为了确保数据能够准确地传输到目标主机,除了IP地址外,还需要用到MAC地址。源MAC地址和目的MAC地址位于链路层的报头中,它们只在当前的局域网内部有效。
- 当数据跨越不同的网络,从一个局域网传输到另一个局域网时,源MAC地址和目的MAC地址需要进行相应的变化。这是因为MAC地址的目的是在局域网内部进行通信,而不是跨网络通信。
- 当数据到达路由器时,路由器会去掉原有的链路层报头,并重新封装一个新的报头。在这个过程中,源MAC地址和目的MAC地址会发生改变,以适应新的网络环境。这样做的目的是确保数据能够正确地传输到目标主机,并在必要时进行路由选择和转发。
数据在传输过程中IP地址和MAC地址的区别
- IP地址主要用于跨网络的通信,确保数据能够准确地传输到目标主机。在数据传输过程中,源IP地址和目的IP地址通常不会发生变化,除非使用了某些特殊技术,如NAT(网络地址转换)
- 而MAC地址主要用于局域网内的通信。由于MAC地址只在局域网内有效,当数据跨越不同的网络时,源MAC地址和目的MAC地址会发生变化。这是因为路由器在接收到数据后,会去掉原有的链路层报头,并重新封装一个新的报头。
总结一下,IP地址主要负责跨网络的通信,而MAC地址则主要用于局域网内的通信。在数据传输过程中,IP地址相对稳定,而MAC地址则可能会发生变化。
3. 认识端口号
在进行网络通信的时候,是不是我们的两台机器在进行通信呢? 不是!其实是通过进程:
- 网络协议中的下三层,主要解决的是,数据安全可靠的送到远端机器
- 用户使用应用层软件,完成数据发送接收的。先把这个软件启动起来——>进程!(应用层软件通常以进程的形式在主机上运行。当用户启动一个应用层软件时,操作系统会创建一个与之相关的进程。这个进程负责处理该软件与网络之间的通信,发送和接收数据。)
网络通信的本质是进程间的通信
- 因为真正进行通信的主体是在主机中的进程。这些进程可能是各种应用,如网页浏览器、邮件客户端、文件传输程序等。当我们在网络上进行通信时,实际上是这些进程在交换数据。
- 例如,当我们在浏览器中访问一个网页时,浏览器进程会向服务器发送请求,并等待服务器的响应。服务器上的网页服务进程会处理这个请求,并返回响应给浏览器进程。这个过程实际上是两个进程间的通信,即浏览器进程和网页服务进程之间的通信。
- 同样的,其他类型的网络通信也涉及到了进程间的通信。例如,当我们使用电子邮件客户端发送或接收邮件时,实际上是邮件客户端进程和邮件服务器上的相关进程之间的通信。
- 因此,可以说网络通信的本质是进程间的通信,因为这种通信方式使得不同的应用程序能够通过网络进行数据交换和协作。
端口号
当两台主机之间进行跨网络的通信时,可能同时存在多个进程在交互。这意味着,当数据到达目标主机时,需要有一种方式来识别哪个服务进程应该接收和处理这些数据。同样地,当一个进程处理完数据后,它也需要向发送端的主机发送响应,并且需要明确指出是哪个发送端的进程发送了请求。这样,即使有多个进程同时进行通信,也能确保数据和响应能够正确地匹配和传输。
端口号(port)是传输层协议的内容,它的作用实际就是标识一台主机上的一个进程。
- 端口号是一个2字节16位的整数;
- 端口号用来标识一个进程,告诉操作系统,当前的这个数据要交给哪一个进程来处理;
- IP地址 + 端口号能够标识网络上的某一台主机的某一个进程;
- 一个端口号只能被一个进程占用。
4. 理解源端口号和目的端口号
传输层协议(TCP和UDP)的数据段中有两个端口号, 分别叫做源端口号和目的端口号. 就是在描述 "数据是谁发的, 要发给谁";
由于IP地址能够唯一标识公网内的一台主机,而端口号能够唯一标识一台主机上的一个进程,因此用IP地址+端口号就能够唯一标识网络上的某一台主机的某一个进程。
当数据在传输层进行封装时,就会添加上对应源端口号和目的端口号的信息。这时通过源IP地址+源端口号就能够在网络上唯一标识发送数据的进程,通过目的IP地址+目的端口号就能够在网络上唯一标识接收数据的进程,此时就实现了跨网络的进程间通信。这种基于ip+端口的通信方式,我们叫做socket。
socket在英文上有“插座”的意思,插座上有不同规格的插孔,我们将插头插入到对应的插孔当中就能够实现电流的传输。
在网络通信中,客户端和服务端的角色与插头和插座的关系非常相似。客户端就像是插头,需要插入到服务端这个“插座”中,以实现数据的传输。但是,服务端上的“插座”实际上是多个不同的服务进程,它们各自独立地提供不同的服务。
这就好比插座上有多个插孔,每个插孔都有特定的规格(端口号),对应不同的电流或数据传输需求。当我们要使用某个特定的服务时,我们需要找到对应的“插孔”(端口号),以便能够正确地与该服务进程进行通信。
因此,在访问服务时,我们需要明确指出要使用哪个服务进程的端口号,这样才能确保数据能够正确地发送到目标服务,并得到期望的响应。这种机制确保了网络通信的准确性和可靠性,使得不同进程之间能够有效地进行数据交换和协作。
注意:
- 因为端口号是隶属于某台主机的,所以端口号可以在两台不同的主机当中重复,但是在同一台主机上进行网络通信的进程的端口号不能重复。
- 一个进程可以绑定多个端口号; 但是一个端口号不能被多个进程绑定;
5. 端口号(port) vs 进程pid
端口号(port)的作用是唯一标识一台主机上的某个进程,进程ID(PID)的作用也是唯一标识一台主机上的某个进程。pid已经能够标识一台主机上进程的唯一性了,为什么还要搞一个端口号?
- 系统和网络功能解耦。进程ID(PID)是系统用于标识每个进程的唯一性标识符,属于系统级别;而端口号(port)则是网络中用于标识需要进行外部通信的进程的唯一性标识符。
- 不是所有的进程都要网络通信,但是所有的进程都要有pid。在机器上,可能同时运行着多个进程,但并非所有进程都需要进行网络通信。对于那些不需要进行网络通信的本地进程,仅使用PID来标识其唯一性可能并不合适。(以身份证为例,它用于国家行政管理,每个人都有唯一的身份证号。但在学校和公司中,除了身份证号外,还有学号和工号。这是因为身份证号主要用于国家行政管理,而不是学校或公司的内部管理。)
- 因此,在不同的场景下,可能需要不同的编号系统来标识事物的唯一性。这些编号系统是为了满足特定场景下的管理需求而设计的,可以包含有助于管理的额外信息。
底层如何找到与特定端口号对应的进程?
实际上使用了一种数据结构来建立这种映射关系。这种数据结构通常是一个哈希表,当底层拿到端口号时就可以直接执行对应的哈希算法,然后就能够找到该端口号对应的进程。
那我们的客户端,如何知道服务器的端口号是多少?
每一个服务的端口号必须是众所周知,精心设计,被客户端知晓的。
6. 认识TCP协议和认识UDP协议
网络协议栈是一个分层结构,贯穿了整个系统,包括应用层、操作系统层和驱动层。当我们使用系统调用接口实现网络数据通信时,不得不面对的协议层就是传输层,传输层中有两种最典型的协议:TCP协议和UDP协议。
TCP协议
TCP协议,全称为传输控制协议(Transmission Control Protocol),是一种位于传输层的通信协议。在计算机网络中,TCP协议负责控制数据包的顺序和流量控制,以确保数据能够可靠地传输到目标主机。
TCP协议具有以下几个关键特性:
- 有连接:在进行数据传输之前,需要先建立TCP连接。通过三次握手(3-way handshake)过程,两台主机之间会交换一系列的数据包以确认彼此的可用性以及分配必要的传输参数。只有在连接建立成功后,数据传输才会开始。
- 可靠传输:TCP协议采用了许多机制来确保数据的可靠传输。其中包括数据包的编号、确认机制、重传机制、流量控制和拥塞控制等。如果在数据传输过程中出现丢包、乱序等情况,TCP协议会采取相应的措施进行恢复,例如重传丢失的数据包或者调整网络流量等。
- 面向字节流:TCP协议将数据看作一个无边界的字节流,发送方和接收方之间的数据传输就像是一个连续的字节流。发送方会将数据分割成合适大小的数据包进行发送,接收方会将接收到的数据包重新组合成原始的字节流。
TCP协议通过可靠的连接和有效的控制机制,确保了数据的可靠传输和顺序正确,是互联网协议(IP)网络中非常重要的协议之一。
UDP协议
UDP协议,全称为用户数据报协议(User Datagram Protocol),是一种位于传输层的通信协议。
- UDP协议与TCP协议不同,它不需要建立连接即可进行数据传输。这意味着,当两台主机之间需要通信时,可以直接发送数据报文给对方,无需事先建立连接。
- 然而,由于UDP协议的这种直接传输方式,它不具备TCP协议那样的可靠性。在数据传输过程中,如果出现丢包、乱序等情况,UDP协议本身并不知道,也不会采取措施进行恢复。因此,UDP协议被认为是不可靠的传输协议。
既然TCP协议比UDP协议可靠,那为什么还要有UDP协议的存在?
- TCP协议作为可靠的传输协议,能够确保数据在传输过程中的可靠性和顺序正确。然而,这种可靠性是以复杂的底层实现为代价的。相比之下,UDP协议虽然不可靠,但其实现相对简单,能够快速地将数据发送给对方。
- 在选择使用TCP协议还是UDP协议时,需要考虑到具体的应用场景。如果数据传输的可靠性至关重要,如金融交易、邮件服务等,必须使用TCP协议来确保数据的完整性和顺序。但对于一些对实时性要求较高、对数据完整性要求不严格的场景,如在线游戏、实时音视频等,UDP协议则成为更好的选择,因为它能够提供更快速的数据传输。
- 一些优秀的网站会根据网络状况动态调整所使用的协议。在网络状况良好时,使用UDP协议进行数据传输以提高效率和实时性;而在网络状况不佳时,切换到TCP协议以保证数据的可靠传输。这种动态调整的方式能够更好地平衡数据传输的可靠性和实时性。
- 总之,TCP协议和UDP协议各有其适用场景,选择哪种协议取决于具体需求。在实际应用中,我们需要综合考虑可靠性、实时性、实现复杂度等多个因素,以做出最适合的决策。
7. 网络字节序
网络中的大小端问题
计算机在存储数据时是有大小端的概念的:
- 大端模式:数据的高字节内容保存在内存的低地址处,数据的低字节内容保存在内存的高地址处。
- 小端模式:数据的高字节内容保存在内存的高地址处,数据的低字节内容保存在内存的低地址处。
当程序仅在本地机器上运行时,我们不需要考虑大小端问题,因为同一台机器上的数据存储格式是一致的。这意味着,要么采用大端存储模式,要么采用小端存储模式,不会出现格式不一致的情况。
然而,在进行网络通信时,我们必须关注大小端问题。由于不同的机器可能采用不同的存储模式,如果不对此进行处理,对端主机接收到的数据将与发送端发送的数据不一致。因此,在网络通信中,我们需要确保数据的正确解释和处理,以避免因大小端问题导致的数据不一致。
我们来看下面这个例子:
假设有两台主机进行网络通信,其中一台是发送端,另一台是接收端。发送端采用小端存储模式,而接收端采用大端存储模式。当发送端将数据按内存地址从低到高的顺序发送给接收端时,接收端将这些数据依次保存在接收缓冲区中,也是按照内存地址从低到高的顺序保存的。
发送主机通常将发送缓冲区中的数据按内存地址从低到高的顺序发出;
接收主机把从网络上接到的字节依次保存在接收缓冲区中,也是按内存地址从低到高的顺序保存;
由于发送端和接收端采用了不同的大小端存储模式,对于内存地址从低到高为44332211的序列,发送端按照小端模式解析为0x11223344,而接收端按照大端模式解析为0x44332211。
这种大小端的差异导致了数据在传输过程中的解析错误。原本发送端想要发送的数据在接收端被错误地识别,这表明大小端问题在网络通信中是一个重要考虑因素。
由于无法保证通信双方采用相同的存储数据方式,因此网络传输的数据必须考虑大小端问题。为了解决这个问题,TCP/IP协议规定网络数据流采用大端字节序,即低地址对应高字节。无论主机是大端存储还是小端存储,都必须遵循TCP/IP协议规定的网络字节序来发送和接收数据。
- 如果发送端是小端,需要先将数据转成大端,然后再发送到网络当中。
- 如果发送端是大端,则可以直接进行发送。
- 如果接收端是小端,需要先将接收到数据转成小端后再进行数据识别。
- 如果接收端是大端,则可以直接进行数据识别。
现在我们再来看上面这个例子,由于发送端是小端机,因此在发送数据前需要先将数据转成大端,然后再发送到网络当中,而由于接收端是大端机,因此接收端接收到数据后可以直接进行数据识别,此时接收端识别出来的数据就与发送端原本想要发送的数据相同了。
需要注意的是,所有的大小端的转化工作是由操作系统来完成的,因为该操作属于通信细节,不过也有部分的信息需要我们自行进行处理,比如端口号和IP地址。
为什么TCP/IP协议规定的网络字节序采用的是大端?而不是小端?
网络字节序采用的是大端,而主机字节序一般采用的是小端,那为什么网络字节序不采用小端呢?如果网络字节序采用小端的话,发送端和接收端在发生和接收数据时就不用进行大小端的转换了。
网络字节序采用大端序的原因有多种说法。
- 一种观点认为,在Unix时代,由于当时的机器都是大端机,因此网络字节序也采用了大端序,后来虽然小端机逐渐普及,但由于协议已经确定,修改起来较为困难。
- 另一种观点认为,大端序更符合人类的读写习惯,例如在文本和字符串的读写中,通常是从高位开始,这与大端序的读写逻辑相符合。此外,对于数值型数据,大端序能够更好地统一各种端序,避免传输数据时出现混乱。
网络字节序与主机字节序之间的转换
为使网络程序具有可移植性,使同样的C代码在大端和小端计算机上编译后都能正常运行,可以调用以下库函数做网络字节序和主机字节序的转换。
- 这些函数名很好记,h表示host,n表示network,l表示32位长整数,s表示16位短整数。
- 例如htonl表示将32位的长整数从主机字节序转换为网络字节序,例如将IP地址转换后准备发送。
- 如果主机是小端字节序,这些函数将参数做相应的大小端转换然后返回;
- 如果主机是大端字节序,这些函数不做转换,将参数原封不动地返回。
socket编程接口
1. socket 常见API
- 创建 socket 文件描述符 (TCP/UDP, 客户端 + 服务器)
int socket(int domain, int type, int protocol);
- 绑定端口号 (TCP/UDP, 服务器)
int bind(int socket, const struct sockaddr *address, socklen_t address_len);
- 开始监听socket (TCP, 服务器)
int listen(int socket, int backlog);
- 接收请求 (TCP, 服务器)
int accept(int socket, struct sockaddr* address, socklen_t* address_len);
- 建立连接 (TCP, 客户端)
int connect(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
2. sockaddr结构
套接字编程的种类:
- 域间套接字编程(同一个机器内)
- 原始套接字编程(网络工具)
- 网络套接字编程(用户间的网络通信)
套接字不仅支持跨网络的通信,还支持在同一网络(本地)上的进程间通信。在进行跨网络的通信时,我们需要知道目标进程所在的IP地址和所使用的端口号,因此套接字提供了sockaddr_in结构体来存储这些信息。而对于在同一网络上的进程间通信,我们不需要知道目标进程的IP地址,只需要知道其本地的地址,因此套接字提供了sockaddr_un结构体。
为了确保无论是网络通信还是本地通信都能使用相同的函数接口,套接字引入了一个新的结构体sockaddr。这个结构体的格式与sockaddr_in和sockaddr_un都不相同,但它们头部的16个比特位是相同的,这16个比特位被称为协议家族。
这样一来我们不再需要区分是使用sockaddr_in还是sockaddr_un结构体。现在,我们只需传递sockaddr结构体。在设置参数时,只需关注协议家族这一字段,它决定了通信类型:网络通信还是本地通信。这些API内部会解析sockaddr结构体头部的16位,以判断通信类型,并据此执行相应的操作。这样,我们通过统一的sockaddr结构体,简化了套接字网络通信和本地通信的参数设置,提高了代码的通用性和可维护性。
注意: 实际我们在进行网络通信时,定义的还是sockaddr_in这样的结构体,只不过在传参时需要将该结构体的地址类型进行强转为sockaddr*。
socket API是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4、IPv6,以及UNIX Domain Socket。然而,各种网络协议的地址格式并不相同。
- 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 结构
sockaddr_in 结构
虽然socket api的接口是sockaddr,但是我们真正在基于IPv4编程时,使用的数据结构sockaddr_in; 这个结构里主要有三部分信息: 地址类型,端口号,IP地址。
in_addr结构
in_addr用来表示一个IPv4的IP地址。其实就是一个32位的整数;
为什么没有用void*代替struct sockaddr*类型?
在设计这一套网络接口的时候C语言还不支持void*,于是就设计出了sockaddr这样的解决方案。即使后来C语言引入了void类型,出于兼容性和稳定性的考虑,因为这些接口是系统调用接口,系统接口不好轻易修改。因为系统接口是整个软件生态系统的基础,轻易更改可能会引发一系列不可预测的问题。因此,尽管现在可以使用void*来达到相同的目的,但sockaddr结构仍然被保留下来。
简单的UDP网络程序
1. 服务端创建udp 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即可,表示使用默认协议。系统会根据前两个参数自动推导出应使用哪种协议。
返回值说明:
- 如果套接字创建成功,函数会返回一个文件描述符;如果创建失败,则返回-1,同时设置相应的错误码。
socket函数属于什么类型的接口?
在网络协议栈中,协议层次结构是非常重要的,它们按照TCP/IP四层模型从上到下依次是应用层、传输层、网络层和数据链路层。而我们通常编写的代码,也就是用户级代码,是在应用层进行编写的。这意味着我们实际上是在调用下三层(传输层、网络层和数据链路层)的接口。这些下三层的功能是由操作系统内部完成的,这意味着我们在应用层所调用的接口实际上是系统调用接口,它们负责与操作系统进行交互,进而实现更底层的功能。因此,我们的代码主要是通过调用系统调用接口来与网络协议栈进行交互,从而实现各种网络通信功能。
socket函数是被谁调用的?
我们通过调用socket函数创建一个套接字。然而,这个函数并不是直接由程序在编码过程中调用的,而是由程序编译后的可执行文件形成的进程来调用的。当进程被操作系统调度到执行socket函数时,才会真正执行创建套接字的代码。因此,可以说socket函数是由进程调用的,而不是直接由程序编码调用的。
socket函数的底层原理是什么?
在操作系统中,每个进程都有自己的执行环境,包括进程地址空间(PCB,例如task_struct数据结构),文件描述符表(files_struct)以及与打开的文件相关的各种信息。文件描述符表包含了fd_array数组,它用于跟踪进程已打开的文件。在fd_array数组中,下标0、1、2分别对应于标准输入、标准输出和标准错误。简而言之,这些文件描述符是进程与操作系统进行通信的通道,通过它们,进程可以读取输入、写入输出和报告错误。
当我们使用socket函数创建一个套接字时,这个操作在底层实际上相当于打开了一个“网络文件”。在内核层面,这个操作会创建一个对应的struct file结构体,这个结构体随后会被加入到与当前进程相关的文件描述符表中。具体来说,该结构体的地址会被存放在fd_array数组的下标3的位置,这样,下标为3的文件描述符就指向了这个新打开的“网络文件”。最后,这个3号文件描述符作为socket函数的返回值,返回给调用它的用户进程。
每个struct file结构体包含了关于打开文件的详细信息,例如文件的属性、操作方式和缓冲区。文件的属性在内核中由struct inode结构体维护,而与文件相关的操作方法则是一系列的函数指针,例如read*和write*。在内核中,这些函数指针由struct file_operations结构体管理。至于文件缓冲区,对于普通的磁盘文件,它通常对应于磁盘。但对于当前这个“网络文件”,其文件缓冲区则对应于网卡。
对于一般的文件,用户通过文件描述符将数据写入到文件缓冲区,然后操作系统将这些数据刷新到磁盘上,完成数据的写入操作。而对于通过socket函数创建的网络套接字,当用户将数据写入到文件缓冲区后,操作系统会定期将这些数据发送到网卡。网卡负责数据的实际发送,因此数据最终被发送到网络中。简单来说,对于普通文件,数据先写入磁盘;而对于网络套接字,数据先写入网卡,然后由网卡发送到网络。
服务端创建套接字
在创建服务器套接字时,我们需要调用socket函数来初始化它。为了进行网络通信,我们需要指定协议家族为AF_INET。因为我们使用UDP协议,所以服务类型应设置为SOCK_DGRAM。至于第三个参数,通常设置为0即可。
UdpServer.hpp
#pragma once
#include <iostream>
#include <string>
#include <strings.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
using namespace std;
class UdpServer
{
public:
UdpServer()
{}
void InitServer()
{
//1.创建udp socket
sockfd_ = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd_ < 0)
{
cout << "Fatal, socket create error, sockfd: " << sockfd_ << endl;
exit(SOCKET_ERR);
}
cout << "Info, socket create success, sockfd: " << sockfd_ << endl;
}
~UdpServer()
{
if(sockfd_ > 0)
close(sockfd_);
}
};
当析构服务器时,我们可以将sockfd对应的文件进行关闭,但实际上不进行该操作也行,因为一般服务器运行后是就不会停下来的。
我们简单测试套接字是否创建成功:
#include "UdpServer.hpp"
int main()
{
UdpServer* svr = new UdpServer();
svr->InitServer();
return 0;
}
运行结果:
运行程序后可以看到套接字是创建成功的,对应获取到的文件描述符就是3。
2. 服务端绑定
虽然我们已经成功创建了套接字,但这仅仅是在系统层面上的文件打开操作。作为服务器,我们还需要确保这个套接字与网络建立关联,这样操作系统才能知道是向磁盘写入数据还是将数据发送到网卡。换句话说,虽然套接字已经创建,但只有当它与网络建立连接后,才能实现数据的传输和通信。
由于现在编写的是不面向连接的UDP服务器,所以初始化服务器要做的第二件事就是绑定。
bind函数
绑定的函数叫做bind,该函数的函数原型如下:
int bind(int sockfd, const struct sockaddr *addr, socklen_t addrlen);
参数说明:
- sockfd:绑定的文件的文件描述符。也就是我们创建套接字时获取到的文件描述符。
- addr:网络相关的属性信息,包括协议家族、IP地址、端口号等。
- addrlen:传入的addr结构体的长度。
返回值说明:
- 绑定成功返回0,绑定失败返回-1,同时错误码会被设置。
struct sockaddr_in结构体
在绑定时需要将网络相关的属性信息填充到一个结构体当中,然后将该结构体作为bind函数的第二个参数进行传入,这实际就是struct sockaddr_in结构体。
我们来看struct sockaddr_in结构的定义,需要注意的是,struct sockaddr_in属于系统级的概念,不同的平台接口设计可能会有点差别。
可以看到,struct sockaddr_in当中的成员如下:
- sin_family:表示协议家族。
- sin_port:表示端口号,是一个16位的整数。
- sin_addr:表示IP地址,是一个32位的整数。
剩下的字段一般不做处理。
其中sin_addr的类型是struct in_addr,实际该结构体当中就只有一个成员,该成员就是一个32位的整数,IP地址实际就是存储在这个整数当中的。
如何理解绑定?
在进行绑定的时候需要将IP地址和端口号告诉对应的网络文件,此时就可以改变网络文件当中文件操作函数的指向,将对应的操作函数改为对应网卡的操作方法,此时读数据和写数据对应的操作对象就是网卡了,所以绑定实际上就是将文件和网络关联起来。
增加IP地址和端口号
由于绑定时需要用到IP地址和端口号,因此我们需要在服务器类当中引入IP地址和端口号,在创建服务器对象时需要传入对应的IP地址和端口号,此时我们就可以根据传入的IP地址和端口号对对应的成员进行初始化。我们设置默认端口号为8080,默认ip地址为0.0.0.0(这里后面解释)。
#pragma once
#include <iostream>
#include <string>
#include <strings.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>//包含了许多与IPv4网络协议相关的定义和函数声明(struct sockaddr_in等)
#include <unistd.h>
using namespace std;
uint16_t defaultport = 8080;
string defaultip_ = "0.0.0.0";//?
class UdpServer
{
public:
UdpServer(const string& ip = defaultip_ , const uint16_t& port = defaultport)
:ip_(ip),port_(port),isrunning_(false)
{}
~UdpServer()
{
if(sockfd_ > 0)
close(sockfd_);
}
private:
int sockfd_;//网络文件描述符
string ip_;//
uint16_t port_;//表面服务器进程的端口号
bool isrunning_;
};
服务端绑定
在套接字创建完成后,我们需要进行绑定操作。在绑定之前,我们需要定义一个struct sockaddr_in结构体,并将相关的网络属性信息填充到该结构体中。由于该结构体中有一些可选字段,建议在填充之前先清空结构体的内容,然后填写协议家族、端口号和IP地址等信息。
值得注意的是,在数据发送到网络之前,我们需要将端口号转换为网络字节序。由于端口号是16位的,我们可以使用前面提到的htons函数来完成转换。另外,由于网络中传输的是整数形式的IP地址,我们需要使用inet_addr函数将字符串形式的IP地址转换为整数形式。
当网络属性信息填写完毕后,由于bind函数接受的是通用参数类型,我们需要将struct sockaddr_in强制转换为struct sockaddr类型,然后再将其作为参数传入。这样,我们就完成了套接字的绑定操作,为后续的网络通信做好了准备。
#pragma once
#include <iostream>
#include <string>
#include <strings.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>//包含了许多与IPv4网络协议相关的定义和函数声明(struct sockaddr_in等)
#include <arpa/inet.h>//包含了一些网络编程相关的函数和宏(inet_ntoa()函数等)
#include <unistd.h>
using namespace std;
enum{
SOCKET_ERR = 1,
BIND_ERR
};
uint16_t defaultport = 8080;
string defaultip_ = "0.0.0.0";
class UdpServer
{
public:
UdpServer(const string& ip = defaultip_ , const uint16_t& port = defaultport)
:ip_(ip),port_(port),isrunning_(false)
{}
void InitServer()
{
//1.创建udp socket
sockfd_ = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd_ < 0)
{
cout << "Fatal, socket create error, sockfd: " << sockfd_ << endl;
exit(SOCKET_ERR);
}
cout << "Info, socket create success, sockfd: " << sockfd_ << endl;
// 2. bind socket
struct sockaddr_in local;
bzero(&local,sizeof(local));//将内存区域的内容设置为0
local.sin_family = AF_INET;
local.sin_port = htons(port_);//需要保证我的端口号是网络字节序列,因为该端口号是要给对方发送的
local.sin_addr.s_addr = inet_addr(ip_.c_str());//1. string -> uint32_t,调用inet_addr函数将字符串IP转换成整数IP 2. uint32_t必须是网络序列的
if(bind(sockfd_, (const struct sockaddr*)&local, sizeof(local)) < 0)
{
cout << "Fatal,bind error, errno:" << errno << "err string:" << strerror(errno) << endl;
exit(BIND_ERR);
}
cout << "Info, bind success, errno:" << errno << "err string:" << strerror(errno) << endl;
}
~UdpServer()
{
if(sockfd_ > 0)
close(sockfd_);
}
private:
int sockfd_;//网络文件描述符
string ip_;//
uint16_t port_;//表面服务器进程的端口号
bool isrunning_;
};
我们测试一下能否绑定成功:
#include "UdpServer.hpp"
int main()
{
UdpServer* svr = new UdpServer();
svr->InitServer();
return 0;
}
运行结果:
我们可以看到完成了套接字的绑定操作,为后续的网络通信做好了准备。
如何快速将整数IP<->字符串IP
IP地址的表现形式有两种:
- 字符串IP:类似于192.168.50.100这种字符串形式的IP地址,叫做基于字符串的点分十进制IP地址。
- 整数IP:IP地址在进行网络传输时所用的形式,用一个32位的整数来表示IP地址。
为什么要有整数IP?
- 在网络传输中,数据是非常宝贵的。如果我们选择以点分十进制格式传输IP地址,每个IP地址需要十几个字节。但实际上,我们并不需要这么多的字节来传输IP地址。
- IP地址实际上由四个部分组成,每个部分的值范围是0到255。这个范围内的数字只需要8个比特位就能表示,因此我们实际上只需要32个比特位就能表示一个IP地址。这32位的整数中的每一个字节对应着IP地址的一个部分。我们把这种表示IP地址的方法称为整数IP,此时表示一个IP地址只需要4个字节。简而言之,通过使用整数IP表示法,我们可以更高效地传输IP地址,只占用必要的字节数,从而节省网络资源。
字符串IP和整数IP相互转换的方式
转换IP地址的方法有多种。我们可以定义一个位段A,其中包含四个成员,每个成员的大小为8个比特位。这四个成员依次表示IP地址的四个区域,总共32个比特位。
接下来,我们可以定义一个联合体IP。这个联合体有两个成员:一个是32位的整数,代表整数IP;另一个是位段A类型的成员,代表字符串IP。通过这种方式,我们可以方便地在整数IP和字符串IP之间进行转换。
由于联合体的特性,其成员共享同一块内存空间,因此我们可以采用以下方式来设置和读取IP地址:
- 当我们希望以整数IP的形式设置IP时,只需将其值赋给联合体的第一个成员即可。
- 若要以字符串IP的形式设置IP,首先需要将字符串以‘.’为分隔符,拆分为对应的四个部分,然后将每个部分转换为对应的二进制序列。接着,依次将这些二进制序列设置到联合体中第二个成员的p1、p2、p3和p4字段中。
- 当我们需要读取整数IP时,直接读取联合体的第一个成员即可。
- 而当我们需要读取字符串IP时,依次获取联合体中第二个成员的p1、p2、p3和p4字段,然后将每个部分转换为字符串后进行拼接。
操作系统内部实际上就是使用位段和枚举来完成字符串IP和整数IP之间的相互转换。
inet_addr函数
实际在进行字符串IP和整数IP的转换时,我们无需自行编写转换逻辑,因为系统已经为我们提供了相应的转换函数。我们只需直接调用这些函数即可完成IP地址的转换。
将字符串IP转换成整数IP的函数叫做inet_addr,该函数的函数原型如下:
in_addr_t inet_addr(const char *cp);
该函数使用起来非常简单,我们只需传入待转换的字符串IP,该函数返回的就是转换后的整数IP。
3. 运行服务器
UDP服务器的初始化过程只需要创建套接字和进行绑定操作。一旦服务器完成初始化,就可以启动并开始提供服务。
服务器是一个持续提供服务的实体,不断地响应各种请求。它之所以被称为服务器,是因为它通常会长时间运行而不会自行关闭。因此,服务器的核心通常是一个不会自然结束的循环代码。
由于UDP服务器是无连接的,一旦启动后,它可以立即读取来自客户端的数据,而无需先建立连接。
recvfrom函数
UDP服务器读取数据的函数叫做recvfrom,该函数的函数原型如下:
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:这是一个输出参数,用于获取对端网络的相关属性信息,包括协议家族、IP地址和端口号等。
- addrlen:这是一个输入输出参数。调用时传入期望读取的src_addr结构体的长度,返回时表示实际读取到的src_addr结构体的长度。
返回值说明:
- 读取成功返回实际读取到的字节数,读取失败返回 -1,同时错误码会被设置。
注意:
- 由于UDP是无连接的协议,所以在接收数据时,除了数据本身,我们还需要获取到对端网络的相关属性信息,如IP地址和端口号等。
- 在调用recvfrom读取数据时,必须将addrlen设置为你要读取的结构体对应的大小。
- 由于recvfrom函数提供的参数也是struct sockaddr*类型的,因此我们在传入结构体地址时需要将struct sockaddr_in*类型进行强转。
在服务端,通过recvfrom函数读取客户端数据后,我们可以将接收到的数据视为字符串处理。为了将数据正确地输出,我们需要将读取到的数据的最后一个位置设置为'\0',以确保字符串的正确结束。
同时,我们还可以将获取到的客户端的IP地址和端口号一同输出。需要注意的是,获取到的客户端端口号是网络序列,因此我们需要使用ntohs函数将其转换为主机序列后再进行打印输出。另外,客户端的IP地址是整数形式,我们需要使用inet_ntoa函数将其转换为字符串形式后再进行打印输出。
通过这些处理,我们可以正确地输出接收到的数据以及客户端的网络相关信息。
#pragma once
#include <iostream>
#include <string>
#include <strings.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>//包含了许多与IPv4网络协议相关的定义和函数声明(struct sockaddr_in等)
#include <arpa/inet.h>//包含了一些网络编程相关的函数和宏(inet_ntoa()函数等)
#include <unistd.h>
#include <functional>
using namespace std;
const int size = 1024;
class UdpServer
{
public:
UdpServer(const string& ip = defaultip_ , const uint16_t& port = defaultport)
:ip_(ip),port_(port),isrunning_(false)
{}
void Run()
{
isrunning_ = true;
char inbuffer[size];
while (isrunning_)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
size_t n = recvfrom(sockfd_,inbuffer,sizeof(inbuffer),0,(struct sockaddr*)&client,&len);
if(n < 0)
{
cerr << "recvfrom error" << endl;
continue;
}
int port = ntohs(client.sin_port);
string ip = inet_ntoa(client.sin_addr);
inbuffer[n] = 0;//将读取到的数据的最后一个位置设置为'\0',以确保字符串的正确结束。
string info = inbuffer;
string echo_string = "Server get a message: ";
echo_string += inbuffer;
cout << echo_string << endl;
}
}
~UdpServer()
{
if(sockfd_ > 0)
close(sockfd_);
}
private:
int sockfd_;//网络文件描述符
string ip_;//
uint16_t port_;//表面服务器进程的端口号
bool isrunning_;
};
注意: 如果调用recvfrom函数读取数据失败,我们可以打印一条提示信息,但是不要让服务器退出,服务器不能因为读取某一个客户端的数据失败就退出。
引入命令行参数
由于构造服务器时需要传入IP地址和端口号,我们这里可以引入命令行参数。此时当我们运行服务器时在后面跟上对应的IP地址和端口号即可。
云服务器非常方便,我们只需要关心端口号,不需要手动输入IP地址。现在我们先手动设置IP地址为127.0.0.1,这个地址就是我们电脑自己的地址,也叫做本地环回地址。这样设置后,我们就可以先在本地测试一下服务器是否能正常工作,然后再放到真正的网络环境中去测试。这样就能确保一切都能正常进行。
void Usage(string proc)
{
cout << "\n\t Usage: " << proc << " proc[1024+]\n" << endl;//
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(0);
}
uint16_t port = stoi(argv[1]);
// UdpServer* svr = new UdpServer();
unique_ptr<UdpServer> svr(new UdpServer(port,127.0.0.1));
svr->InitServer();
svr->Run(Handler);
return 0;
}
注意:
- [0,1023]是系统内定的端口号,一般都要有固定的应用层协议使用,http:80 https:443 建议使用1024以上的端口号,但也不是1024以上都不是系统内定的(如mysqt:3306..)
- 需要注意的是,agrv数组里面存储的是字符串,而端口号是一个整数,因此需要使用atoi函数将字符串转换成整数。然后我们就可以用这个IP地址和端口号来构造服务器了,服务器构造完成并初始化后就可以调用Start函数启动服务器了。
此时带上端口号运行程序就可以看到套接字创建成功、绑定成功,现在服务器就在等待客户端向它发送数据。
虽然现在客户端代码还没有编写,但是我们可以通过netstat命令来查看当前网络的状态,这里我们可以选择携带nlup选项。
netstat常用选项说明:
- -n:直接使用IP地址,而不通过域名服务器。
- -l:显示监控中的服务器的Socket。
- -t:显示TCP传输协议的连线状况。
- -u:显示UDP传输协议的连线状况。
- -p:显示正在使用Socket的程序识别码和程序名称。
此时你就能查看到对应网络相关的信息,在这些信息中程序名称为./udp_server的那一行显示的就是我们运行的UDP服务器的网络信息。
其中netstat命令显示的信息中,Proto表示协议的类型,Recv-Q表示网络接收队列,Send-Q表示网络发送队列,Local Address表示本地地址,Foreign Address表示外部地址,State表示当前的状态,PID表示该进程的进程ID,Program name表示该进程的程序名称。
其中Foreign Address写成0.0.0.0:*表示任意IP地址、任意的端口号的程序都可以访问当前进程。
4. 客户端创建套接字
在创建客户端对象后,我们需要对其进行初始化,这个过程包括创建套接字。客户端通过这个套接字发送或接收数据。在客户端的初始化过程中,我们选择AF_INET协议家族和SOCK_DGRAM服务类型来创建套接字。当客户端对象不再需要时,我们可以选择关闭与之关联的套接字。与服务端稍有不同,客户端在初始化时只需要创建套接字,而不需要显示进行绑定操作。
int main()
{
//1.创建客户端socket
int sockfd = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd < 0)
{
cout << "socker error" << endl;
return 1;
}
close(sockfd);
return 0;
}
一旦客户端完成初始化,就可以开始运行。
客户端的绑定问题
- 在计算机网络通信中,服务端和客户端都需要各自拥有IP地址和端口号,以便于相互识别和连接。其中,服务端需要进行端口号的绑定,而客户端则不需要显示绑定,由操作系统随机选择。
- 服务器的目的是为其他人提供服务,因此必须让其他设备能够找到并连接到它。服务器的IP地址和端口号是其提供的服务的标识,其中IP地址通常与域名相对应,而端口号则没有明确的标识。为了保证服务端能够被外部访问,需要将其端口号绑定到一个众所周知的端口号上,并且选定后不能轻易更改。这样可以确保客户端能够通过已知的端口号找到并连接到服务端。
- 对于客户端来说,虽然也需要端口号进行通信,但通常不需要显示进行绑定。客户端在访问服务端时,只要保证端口号是唯一的即可,不需要和特定客户端进程强相关。客户端的端口号可以动态设置,并且操作系统会自动为当前客户端分配一个唯一的端口号。这样可以确保客户端能够正常启动和通信,而不会因为端口号冲突而导致问题。当我们调用类似于sendto这样的接口时,操作系统会自动给当前客户端获取一个唯一的端口号。
总结来说,服务端需要进行端口号的绑定,以确保其服务的唯一性和可访问性;而客户端则不需要进行绑定,其端口号可以动态设置并由操作系统自动分配。这样可以确保网络通信的稳定性和可靠性。也就是说,客户端每次启动时使用的端口号可能是变化的,此时只要我们的端口号没有被耗尽,客户端就永远可以启动。
5.启动客户端
由于客户端和服务端的功能是相互补充的,客户端需要向服务端发送数据,而服务端则读取这些数据。
sendto函数
客户端发送数据的函数是sendto函数
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:对端网络相关信息,包括协议族、IP地址和端口号等。
- addrlen:dest_addr结构体的长度。
返回值说明:
- 如果写入成功,返回实际写入的字节数;如果失败,返回-1,并设置相应的错误码。
注意事项:
- 由于UDP是无连接的协议,除了要发送的数据外,还需要指定对端网络的相关信息,如IP地址和端口号等。
- 由于sendto函数的参数类型为struct sockaddr*,因此在传入结构体地址时需要将struct sockaddr_in*类型进行强制转换。
int main()
{
//1.创建客户端socket
int sockfd = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd < 0)
{
cout << "socker error" << endl;
return 1;
}
string message;
char buffer[1024];
while (true)
{
cout << "Please Enter@:";
getline(cin,message);
//1.数据 2.给谁发
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, len);
}
close(sockfd);
return 0;
}
引入命令行参数
鉴于构造客户端时需要传入对应服务端的IP地址和端口号,我们这里也可以引入命令行参数。当我们运行客户端时直接在后面跟上对应服务端的IP地址和端口号即可。
void Usage(string proc)
{
cout << "\n\rUsage: " << proc << " serverip serverport\n" << endl;
}
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(0);
}
string serverip = argv[1];
uint16_t serverport = stoi(argv[2]);
struct sockaddr_in server;
bzero(&server,sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());//1. string -> uint32_t,调用inet_addr函数将字符串IP转换成整数IP 2. uint32_t必须是网络序列的
socklen_t len = sizeof(server);
close(sockfd);
return 0;
}
需要注意的是,命令行参数argv是以字符串形式存储的,而端口号实际上是一个整数。因此,我们需要使用atoi函数将字符串转换为整数,以便正确使用。一旦我们有了IP地址和端口号,就可以开始构建并初始化客户端。
6. 本地测试
现在服务端和客户端的代码都已经编写完毕,我们可以先进行本地测试,此时服务器没有绑定外网,绑定的是本地环回。现在我们运行服务器时指明端口号为8080,再运行客户端,此时客户端要访问的服务器的IP地址就是本地环回127.0.0.1,服务端的端口号就是8080。
此时我们再用netstat命令查看网络信息,可以看到服务端的端口是8081,客户端的端口是35410。这里客户端能被netstat命令查看到,说明客户端也已经动态绑定成功了,这就是我们所谓的网络通信。
7. INADDR_ANY
现在我们已经通过了本地测试,接下来就需要进行网络测试了,那是不是直接让服务端绑定我的公网IP,此时这个服务端就能够被外网访问了呢?下面我们来进行测试。
现在我将服务端设置的改为我的公网IP,此时当我们重新编译程序再次运行服务端的时候会发现服务端绑定失败。
注意:云服务器禁止绑定公网ip!由于云服务器的IP地址是由云厂商提供的,这个IP地址可能不是真正的公网IP。如果需要让外部网络访问云服务器,不能直接绑定这个IP地址。要实现外网访问,需要在云服务器上绑定一个特殊的IP地址,即INADDR_ANY。这个地址是一个宏值,对应的数值是0(bind(ip:0))。通过绑定INADDR_ANY,云服务器才能被外部网络访问。
绑定INADDR_ANY的作用
当服务器的带宽足够大时,一台机器的接收数据能力可能成为其IO效率的瓶颈。为了解决这个问题,一些服务器配置了多张网卡,这意味着它们拥有多个IP地址。然而,一个服务器上的特定端口号,如8080,只对应一个服务。在接收数据时,这些网卡实际上都接收到了数据。如果这些数据都试图访问端口号为8080的服务,那么问题就出现了。
如果服务端在绑定时只指定了一个特定的IP地址,那么它只能从与该IP地址对应的网卡接收数据。这意味着其他网卡上的数据将被忽略。然而,如果服务端绑定的是INADDR_ANY,那么无论数据是从哪个网卡发送的,只要它是发送给端口号为8080的服务,系统都会确保该数据被传递给该服务端。
服务端绑定INADDR_ANY是推荐的做法。实际上,大部分服务器在操作时都会采用这种方式。
然而,如果你希望绑定特定的IP地址以供外网访问,但又不能使用云服务器,那么你可以考虑使用虚拟机或自己安装的Linux操作系统。这些环境允许你绑定特定的IP地址,而云服务器通常不支持这种操作。
因此,如果想要让外网访问我们的服务,我们这里就需要将服务器类当中IP地址相关的代码去掉,而在填充网络相关信息设置struct sockaddr_in结构体时,将设置的IP地址改为INADDR_ANY就行了。由于INADDR_ANY的值本质就是0,不存在大小端的问题,因此在设置时可以不进行网络字节序的转换。我们也可以在初始化的时候设置默认地址为0.0.0.0,然后不传入IP地址。
enum{
SOCKET_ERR = 1,
BIND_ERR
};
uint16_t defaultport = 8080;//
string defaultip_ = "0.0.0.0";//
const int size = 1024;
class UdpServer
{
public:
UdpServer(const uint16_t& port = defaultport, const string& ip = defaultip_)
:ip_(ip),port_(port),isrunning_(false)
{}
void InitServer()
{
//1.创建udp socket
sockfd_ = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd_ < 0)
{
cout << "Fatal, socket create error, sockfd: " << sockfd_ << endl;
exit(SOCKET_ERR);
}
cout << "Info, socket create success, sockfd: " << sockfd_ << endl;
// 2. bind socket
struct sockaddr_in local;
bzero(&local,sizeof(local));//将内存区域的内容设置为0
local.sin_family = AF_INET;
local.sin_port = htons(port_);//需要保证我的端口号是网络字节序列,因为该端口号是要给对方发送的
// local.sin_addr.s_addr = inet_addr(ip_.c_str());//1. string -> uint32_t,调用inet_addr函数将字符串IP转换成整数IP 2. uint32_t必须是网络序列的
local.sin_addr.s_addr = htonl(INADDR_ANY);//邦定INADDR_ANY
if(bind(sockfd_, (const struct sockaddr*)&local, sizeof(local)) < 0)
{
cout << "Fatal,bind error, errno: " << errno << " err string: " << strerror(errno) << endl;
exit(BIND_ERR);
}
cout << "Info, bind success, errno: " << errno << " err string: " << strerror(errno) << endl;
~UdpServer()
{
if(sockfd_ > 0)
close(sockfd_);
}
private:
int sockfd_;//网络文件描述符
string ip_;//
uint16_t port_;//表面服务器进程的端口号
bool isrunning_;
}
此时当我们再重新编译运行服务器时就不会绑定失败了,并且此时当我们再用netstat命令查看时会发现,该服务器的本地IP地址变成了0.0.0.0,这就意味着该UDP服务器可以在本地读取任何一张网卡里面的数据。
8.对服务端代码进行分层
为了对方便服务端的各种数据进行处理,我们可以使用function对代码进行分层。这样面对不同的应用场景,我们可以传入相对应不同的处理方法函数进行处理。
我们定义了一个表示接受一个常量字符串引用作为参数并返回一个字符串的函数对象,并把她重命名为func_t。
typedef function<string(const string&)> func_t;//定义了一个名为func_t的类型,它表示一个接受一个常量字符串引用作为参数并返回一个字符串的函数对象。
enum{
SOCKET_ERR = 1,
BIND_ERR
};
uint16_t defaultport = 8080;//
string defaultip_ = "0.0.0.0";//
const int size = 1024;
class UdpServer
{
public:
UdpServer(const uint16_t& port = defaultport, const string& ip = defaultip_)
:ip_(ip),port_(port),isrunning_(false)
{}
void Run(func_t func)
{
isrunning_ = true;
char inbuffer[size];
while (isrunning_)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
size_t n = recvfrom(sockfd_,inbuffer,sizeof(inbuffer),0,(struct sockaddr*)&client,&len);
if(n < 0)
{
cerr << "recvfrom error" << endl;
continue;
}
int port = ntohs(client.sin_port);
string ip = inet_ntoa(client.sin_addr);
inbuffer[n] = 0;//将读取到的数据的最后一个位置设置为'\0',以确保字符串的正确结束。
string info = inbuffer;
string echo_string = func(info);
}
}
~UdpServer()
{
if(sockfd_ > 0)
close(sockfd_);
}
private:
int sockfd_;//网络文件描述符
string ip_;//
uint16_t port_;//表面服务器进程的端口号
bool isrunning_;
};
这样我们要实现不同的功能只要传入不同的执行函数即可func_t。例如下面这个Handler函数,我们定义了一个表示接受一个常量字符串引用作为参数并返回一个字符串的函数对象,调用run时将他传入即可。
string Handler(const string &str)
{
string res = "Server get a message: ";
res += str;
cout << res << endl;
return res;
}
9. 简易的回声服务器
服务端代码
在进行网络测试时,当客户端向服务端发送数据,服务端会打印接收到的数据,因此服务端可以观察到数据传输的现象。然而,客户端无法直接判断服务端是否成功接收了其发送的数据。
为了解决这个问题,我们可以考虑将服务器改造成一个简单的回声服务器。当服务端收到客户端发送的数据后,除了在服务端进行打印外,服务端还可以调用sendto函数将接收到的数据重新发送给对应的客户端。
需要注意的是,服务端在调用sendto函数时需要传入客户端的网络属性信息。由于服务端之前已经通过recvfrom函数获取了客户端的网络属性信息,因此服务端知道如何正确地发送数据给对应的客户端。
通过这种方式,客户端可以间接地判断服务端是否成功接收了其发送的数据,因为服务端会将接收到的数据回传给客户端。这种回传机制使得客户端能够观察到服务端的接收情况,从而更好地进行网络测试。
#pragma once
#include <iostream>
#include <string>
#include <strings.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>//包含了许多与IPv4网络协议相关的定义和函数声明(struct sockaddr_in等)
#include <arpa/inet.h>//包含了一些网络编程相关的函数和宏(inet_ntoa()函数等)
#include <unistd.h>
#include <functional>
using namespace std;
typedef function<string(const string&)> func_t;//定义了一个名为func_t的类型,它表示一个接受一个常量字符串引用作为参数并返回一个字符串的函数对象。
enum{
SOCKET_ERR = 1,
BIND_ERR
};
uint16_t defaultport = 8080;//
string defaultip_ = "0.0.0.0";//
const int size = 1024;
class UdpServer
{
public:
UdpServer(const uint16_t& port = defaultport, const string& ip = defaultip_)
:ip_(ip),port_(port),isrunning_(false)
{}
void InitServer()
{
//1.创建udp socket
sockfd_ = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd_ < 0)
{
cout << "Fatal, socket create error, sockfd: " << sockfd_ << endl;
exit(SOCKET_ERR);
}
cout << "Info, socket create success, sockfd: " << sockfd_ << endl;
// 2. bind socket
struct sockaddr_in local;
bzero(&local,sizeof(local));//将内存区域的内容设置为0
local.sin_family = AF_INET;
local.sin_port = htons(port_);//需要保证我的端口号是网络字节序列,因为该端口号是要给对方发送的
local.sin_addr.s_addr = inet_addr(ip_.c_str());//1. string -> uint32_t,调用inet_addr函数将字符串IP转换成整数IP 2. uint32_t必须是网络序列的
// local.sin_addr.s_addr = htonl(INADDR_ANY);
if(bind(sockfd_, (const struct sockaddr*)&local, sizeof(local)) < 0)
{
cout << "Fatal,bind error, errno: " << errno << " err string: " << strerror(errno) << endl;
exit(BIND_ERR);
}
cout << "Info, bind success, errno: " << errno << " err string: " << strerror(errno) << endl;
}
void Run(func_t func)
// void Run()
{
isrunning_ = true;
char inbuffer[size];
while (isrunning_)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
size_t n = recvfrom(sockfd_,inbuffer,sizeof(inbuffer),0,(struct sockaddr*)&client,&len);
if(n < 0)
{
cerr << "recvfrom error" << endl;
continue;
}
int port = ntohs(client.sin_port);
string ip = inet_ntoa(client.sin_addr);
inbuffer[n] = 0;//将读取到的数据的最后一个位置设置为'\0',以确保字符串的正确结束。
string info = inbuffer;
string echo_string = func(info);
sendto(sockfd_,echo_string.c_str(),sizeof(echo_string),0,(struct sockaddr*)&client,len);
}
}
~UdpServer()
{
if(sockfd_ > 0)
close(sockfd_);
}
private:
int sockfd_;//网络文件描述符
string ip_;//
uint16_t port_;//表面服务器进程的端口号
bool isrunning_;
};
客户端代码编写
修改服务端代码后,客户端的代码也需要相应地进行调整。当客户端向服务端发送数据后,由于服务端会将接收到的数据回传给客户端,客户端需要调用recvfrom函数来接收服务端的响应数据。
在客户端调用recvfrom函数时,虽然客户端已经知道服务端的网络信息,但为了确保数据的正确接收和处理,建议不要将参数设置为空。而是应该用一个临时变量来读取服务端的网络信息。
客户端接收到服务端的响应数据后,只需将其原封不动地打印出来即可。这样,客户端发送给服务端的数据不仅会在服务端进行打印显示,同时还会被服务端回传给客户端,客户端也会接收到响应数据并将其打印出来。
通过这种方式,客户端可以验证服务端是否成功接收并处理了其发送的数据。这有助于进行有效的网络测试和调试,确保数据在网络传输过程中的完整性和准确性。
#include <iostream>
#include <string>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
void Usage(string proc)
{
cout << "\n\rUsage: " << proc << " serverip serverport\n" << endl;
}
// ./udpclient serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(0);
}
string serverip = argv[1];
uint16_t serverport = stoi(argv[2]);
struct sockaddr_in server;
bzero(&server,sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport);
server.sin_addr.s_addr = inet_addr(serverip.c_str());//1. string -> uint32_t,调用inet_addr函数将字符串IP转换成整数IP 2. uint32_t必须是网络序列的
socklen_t len = sizeof(server);
//1.创建客户端socket
int sockfd = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd < 0)
{
cout << "socker error" << endl;
return 1;
}
// client 要bind吗?要!只不过不需要用户显示的bind!一般有OS自由随机选择!
// 一个端口号只能被一个进程bind,对server是如此,对于client,也是如此!
// 其实client的port是多少,其实不重要,只要能保证主机上的唯一性就可以!
// 系统什么时候给我bind呢?首次发送数据的时候
string message;
char buffer[1024];
while (true)
{
cout << "Please Enter@:";
getline(cin,message);
//1.数据 2.给谁发
sendto(sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&server, len);
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t s = recvfrom(sockfd,buffer,sizeof(buffer),0,(struct sockaddr*)&temp, &len);
if(s > 0)
{
buffer[s] = 0;
cout << buffer << endl;
}
}
close(sockfd);
return 0;
}
测试回声服务器
在服务端和客户端就都能够看到对应的现象,这样就能够判断通信是否正常了。
10. 命令过滤服务器
前面我们将服务端的执行代码进行分层,并制作了简易的回声服务器。
现在我们换一个应用场景:客户端输入命令,服务端执行命令。然后对客户端执行的命令的一部分命令进行过滤,也就是禁掉一些命令不能使用。并将执行结果显示在客户端中。
我们先来介绍一下popen函数:
popen函数可以让我们不用去处理冗长的字符串,也不用分析客户端输入的命令。它在底层会帮我们建立管道并创建一个子进程,然后执行第一个参数command的命令。最后子进程的执行结果会通过管道交给父进程。父进程通过popen的返回值FILE* 指针就能读到执行结果。我们在父进程用fgets将子进程的执行结果读到buffer缓冲区。
string ExcuteCommand(const string &cmd)
{
cout << "get a request cmd: " << cmd << endl;
if(!SafeCheck(cmd)) return "Bad man";
FILE *fp = popen(cmd.c_str(), "r");
if(nullptr == fp)
{
perror("popen");
return "error";
}
string result;
char buffer[4096];
while(true)
{
char *ok = fgets(buffer, sizeof(buffer), fp);
if(ok == nullptr) break;
result += buffer;
}
pclose(fp);
return result;
}
SafeCheck函数是对一些指令的过滤,我们使用一个vector存放禁止输入的指令。当输入指令,我们对vector进行遍历,不是禁止的指令我们返回true,否则返回false。下面是SafeCheck函数:
bool SafeCheck(const string &cmd)
{
int safe = false;
vector<string> key_word = {
"rm",
"mv",
"cp",
"kill",
"sudo",
"unlink",
"uninstall",
"yum",
"top",
"while"
};
for(auto &word : key_word)
{
auto pos = cmd.find(word);
if(pos != string::npos) return false;//npos表示字符串中没有找到指定的字符或子字符串时的返回值,实际值为-1。
}
return true;
}
下面我们服务端代码不需要改动,只需要在传入函数时将ExcuteCommand函数传入即可:
void Usage(string proc)
{
cout << "\n\t Usage: " << proc << " proc[1024+]\n" << endl;
}
int main(int argc, char* argv[])
{
if(argc != 2)
{
Usage(argv[0]);
exit(0);
}
uint16_t port = stoi(argv[1]);
// UdpServer* svr = new UdpServer();
unique_ptr<UdpServer> svr(new UdpServer(port));
svr->InitServer();
// svr->Run();
svr->Run(ExcuteCommand);
return 0;
}
运行结果:
我们看到我们通过客户端输入命令创建了一个文本文件,并通过ls命令查看了当前文件夹下的文件。当我们想要rm 我们刚创建的文本文件。我们发现创建不了。这是因为rm命令被我们禁止掉了。
11.实现Windows客户端和Linux的客户端进行网络通信
我们前面说过,操作系统可以不一样,但是网络协议栈一定是一样的。因此网络接口也都是一样的,因此我们在Linux写的网络套接字这份代码在Windows基本上就可以使用,只不过Windows在应用层上对个别数据类型稍有变化。
接下来我在Window下使用vs2019进行编程,下面是Windows使用网络通信的代码结构:
#include <WinSock2.h>
#pragma comment(lib,"ws2_32.lib")//引入静态库
int main()
{
WSADATA wsd;
WSAStartup(MAKEWORD(2,2), &wsd);
WSACleanup();
return 0;
}
- #include <WinSock2.h>: 引入Windows Socket 2头文件,这是进行网络编程所需的基本头文件。
- #pragma comment(lib,"ws2_32.lib"): 这是一个预处理指令,它告诉链接器链接到ws2_32.lib库。这个库包含了Windows Socket 2 API的实现。
- WSADATA wsd;: 定义一个WSADATA结构体变量wsd。这个结构体用于存储Winsock版本和其他相关信息。
- WSAStartup(MAKEWORD(2,2), &wsd);: 调用WSAStartup函数来初始化Winsock库。这里使用的是Winsock 2.2版本。该函数返回后,wsd结构体会被填充相关的版本信息。
- WSACleanup();: 调用WSACleanup函数来清理和关闭任何由Winsock使用的资源。
- }: 结束主函数的代码块。
下面我们就将Linux下的客户端代码移到这份代码结构当中即可:
#define _CRT_SECURE_NO_WARNINGS 1
#include <iostream>
#include <cstdio>
#include <stdlib.h>
#include <string>
#include <WinSock2.h>
#include <Windows.h>
using namespace std;
#pragma warning(disable:4996)//禁掉inet_addr告警
#pragma comment(lib,"ws2_32.lib")//引入静态库
uint16_t serverport = 8888;
string serverip = "60.204.169.245";
int main()
{
cout << "hello client" << endl;
WSADATA wsd;
WSAStartup(MAKEWORD(2,2), &wsd);
struct sockaddr_in server;
memset(&server, 0,sizeof(server));
server.sin_family = AF_INET;
server.sin_port = htons(serverport); //?
server.sin_addr.s_addr = inet_addr(serverip.c_str());
SOCKET sockfd = socket(AF_INET, SOCK_DGRAM, 0);
if (sockfd == SOCKET_ERROR)
{
cout << "socker error" << endl;
return 1;
}
string message;
char buffer[1024];
while (true)
{
cout << "Please Enter@ ";
getline(cin, message);
// cout << message << endl;
// 1. 数据 2. 给谁发
sendto(sockfd, message.c_str(), (int)message.size(), 0, (struct sockaddr*)&server, sizeof(server));
struct sockaddr_in temp;
int len = sizeof(temp);
int s = recvfrom(sockfd, buffer, 1023, 0, (struct sockaddr*)&temp, &len);
if (s > 0)
{
buffer[s] = 0;
cout << buffer << endl;
}
}
closesocket(sockfd);
WSACleanup();
return 0;
}
注意:#pragma warning(disable:4996)是禁用inet_addr运行时库的安全警告。
运行示例:
可以看到,我们实现了Window和Linux的网络通信。
注意:如果出现跨网络通信不了的问题,可能是服务器的端口号没有打开,我们只要在对应的云服务器的更改安全组添加对应的端口号。如何打开端口号后还是不能进行通信,需要检查一下服务器的防火墙是否开放了该端口。
12. 实现多人聊天服务器
服务端代码:
我们用一个unordered_map容器来存放当前在网络的用户,我们用ip地址作为key,客户端的信息作为value。
接着,我们封装一个CheckUser函数,通过遍历unordered_map来检查当前的用户是否在当前的网络,不在的话就插入到unordered_map。
最后,我们封装一个Broadcast函数,当一个用户发信息,服务器转发到每个在网的用户,这样就实现了群聊的功能。
#pragma once
#include <iostream>
#include <string>
#include <strings.h>
#include <cstring>
#include <sys/types.h>
#include <sys/socket.h>
#include <netinet/in.h>//包含了许多与IPv4网络协议相关的定义和函数声明(struct sockaddr_in等)
#include <arpa/inet.h>//包含了一些网络编程相关的函数和宏(inet_ntoa()函数等)
#include <unistd.h>
#include <functional>
#include <unordered_map>
using namespace std;
enum{
SOCKET_ERR = 1,
BIND_ERR
};
uint16_t defaultport = 8080;//
string defaultip_ = "0.0.0.0";//
const int size = 1024;
class UdpServer
{
public:
UdpServer(const uint16_t& port = defaultport, const string& ip = defaultip_)
:sockfd_(0),ip_(ip),port_(port),isrunning_(false)
{}
void InitServer()
{
//1.创建udp socket
// 2. Udp 的socket是全双工的,允许被同时读写的
sockfd_ = socket(AF_INET,SOCK_DGRAM,0);
if(sockfd_ < 0)
{
cout << "Fatal, socket create error, sockfd: " << sockfd_ << endl;
exit(SOCKET_ERR);
}
cout << "Info, socket create success, sockfd: " << sockfd_ << endl;
// 2. bind socket
struct sockaddr_in local;
bzero(&local,sizeof(local));//将内存区域的内容设置为0
local.sin_family = AF_INET;
local.sin_port = htons(port_);//需要保证我的端口号是网络字节序列,因为该端口号是要给对方发送的
local.sin_addr.s_addr = inet_addr(ip_.c_str());//1. string -> uint32_t,调用inet_addr函数将字符串IP转换成整数IP 2. uint32_t必须是网络序列的
// local.sin_addr.s_addr = htonl(INADDR_ANY);
if(bind(sockfd_, (const struct sockaddr*)&local, sizeof(local)) < 0)
{
cout << "Fatal,bind error, errno: " << errno << " err string: " << strerror(errno) << endl;
exit(BIND_ERR);
}
cout << "Info, bind success, errno: " << errno << " err string: " << strerror(errno) << endl;
}
void CheckUser(const struct sockaddr_in &client, const string clientip, uint16_t clientport)
{
auto iter = online_user_.find(clientip);
if(iter == online_user_.end())
{
online_user_.insert({clientip, client});
cout << "[" << clientip << ":" << clientport << "] add to online user." << endl;
}
}
void Broadcast(const string &info,const string clientip, uint16_t clientport)
{
for(const auto &user : online_user_)
{
string message = "[";
message += clientip;
message += ":";
message += to_string(clientport);
message += "]# ";
message += info;
socklen_t len = sizeof(user.second);
sendto(sockfd_,message.c_str(),message.size(),0,(struct sockaddr*)(&user.second),len);
}
}
// void Run(func_t func) // 对代码进行分层
void Run()
{
isrunning_ = true;
char inbuffer[size];
while (isrunning_)
{
struct sockaddr_in client;
socklen_t len = sizeof(client);
size_t n = recvfrom(sockfd_,inbuffer,sizeof(inbuffer),0,(struct sockaddr*)&client,&len);
if(n < 0)
{
cerr << "recvfrom error" << endl;
continue;
}
uint16_t clientport = ntohs(client.sin_port);
string clientip = inet_ntoa(client.sin_addr);
CheckUser(client,clientip,clientport);
string info = inbuffer;
Broadcast(info,clientip, clientport);
}
}
~UdpServer()
{
if(sockfd_ > 0)
close(sockfd_);
}
private:
int sockfd_;//网络文件描述符
string ip_;//任意地址bind 0
uint16_t port_;//表面服务器进程的端口号
bool isrunning_;
unordered_map<string, struct sockaddr_in> online_user_;//存放当前在网络的用户
};
注意:我们要将服务器绑定的ip地址为INADDR_ANY,这样云服务器才能被外部网络访问。
客户端代码:
由于 Udp 的socket是全双工的,允许被同时读写的。如果我们用之前的方法,直接在主函数while循环等待客户端输入才执行后面代码,这样如果客户端不输入就阻塞住了。接收不到服务端发来的信息。
因此我们创建两个线程,一个进行发送,一个进行接收,这样就实现了全双工。
#include <iostream>
#include <pthread.h>
#include <string>
#include <cstring>
#include <strings.h>
#include <sys/types.h>
#include <sys/socket.h>
#include <unistd.h>
#include <netinet/in.h>
#include <arpa/inet.h>
using namespace std;
void Usage(string proc)
{
cout << "\n\rUsage: " << proc << " serverip serverport\n" << endl;
}
struct ThreadData
{
struct sockaddr_in server;
int sockfd;
string serverip;
};
void* recv_message(void* args)
{
ThreadData *td = static_cast<ThreadData *>(args);
char buffer[1024];
while (true)
{
memset(buffer,0,sizeof(buffer));
struct sockaddr_in temp;
socklen_t len = sizeof(temp);
ssize_t s = recvfrom(td->sockfd,buffer,sizeof(buffer),0,(struct sockaddr*)&temp, &len);
if(s > 0)
{
buffer[s] = 0;
cerr << buffer << endl;
}
}
}
void* send_message(void* args)
{
ThreadData *td = static_cast<ThreadData *>(args);
string message;
socklen_t len = sizeof(td->server);
string welcome = td->serverip;
welcome += " comming...";
sendto(td->sockfd, message.c_str(), message.size(), 0, (struct sockaddr *)&(td->server), len);
while (true)
{
cout << "Please Enter@:";
getline(cin,message);
//1.数据 2.给谁发
sendto(td->sockfd, message.c_str(), message.size(), 0, (struct sockaddr*)&td->server,len);
}
}
// ./udpclient serverip serverport
int main(int argc, char *argv[])
{
if (argc != 3)
{
Usage(argv[0]);
exit(0);
}
string serverip = argv[1];
uint16_t serverport = stoi(argv[2]);
struct ThreadData td;
bzero(&td.server,sizeof(td.server));
td.server.sin_family = AF_INET;
td.server.sin_port = htons(serverport);
td.server.sin_addr.s_addr = inet_addr(serverip.c_str());
socklen_t len = sizeof(td.server);
//1.创建客户端socket
td.sockfd = socket(AF_INET,SOCK_DGRAM,0);
if(td.sockfd < 0)
{
cout << "socker error" << endl;
return 1;
}
td.serverip = serverip;
//为了实现全双工,引入两个线程,一个负责收数据,一个复杂发数据
pthread_t recvr, sender;
pthread_create(&recvr,nullptr,recv_message,&td);
pthread_create(&sender,nullptr,send_message,&td);
pthread_join(recvr,nullptr);
pthread_join(sender,nullptr);
close(td.sockfd);
return 0;
}
注意: 为了方便看客户端接收到的消息和发出去的消息,我们对发送的消息和收到的消息放在两个显示窗口,我们将收到的消息用标准错误打印,并重定向到其它窗口进行显示。
我们可以用ls /dev/pts/ 来查看当前有哪些窗口,使用echo指令来测试当前窗口是几号窗口。
下面我们来进行测试:
可以看到我们实现了“群聊”的功能,由于作者服务器数量有限就进行简单测试。大家可以找同学服务器一同测试。