网络基础(1)网络编程套接字UDP

发布于:2024-05-01 ⋅ 阅读:(23) ⋅ 点赞:(0)

要完成网络编程首先要理解原IP和目的IP,这在上一节已经说明了。

也就是一台主机要进行通信必须要具有原IP和目的IP地址。

端口号

首先要知道进行网络通信的目的是要将信息从A主机送到B主机吗?

很显然不仅仅是。

例如唐僧要去到西天取真经,让唐僧去到西天取真经的请求不是唐僧发的,而是太宗发的。响应也不是唐僧去响应的,是如来佛祖响应的。所以取西经的本质就是太宗和如来佛祖之间进行的数据通信。

所以唐僧从A机器来去到B机器是目的吗?不是。

所以看似是两个主机在通信,其实根本就不是,而是两个主机中的两个进程在进行通信。

所以:

此时对于双方而言

让数据到达另外一台目的主机是由IP地址解决的这个问题。找到指定的进程就需要通过port(端口号)来完成了。

所以{ip,port}就能表示互联网中唯一一个进程。

所以网络通信的本质就是进程间通信

而我们将{IP,port}称之为套接字(socket"有插座,插孔的意思”)。

所以网络通信本质就是进程间通信。

此时两个主机上的进程独立性是能够保证的,那么两个进程所能看到的同一份资源是什么呢?

自然就是网络了。

如何理解port

port是用来表示该指定机器中进程的唯一性,但是进程的pid不就可以标识吗?为什么还需要port呢?

首先端口号是一个16位的数字,至于为什么是16位,之后会说明。

这里只需要知道端口号是为了标识主机上的唯一的一个网络进程,

网络进程哟呵port进行绑定,绑定之后未来在发送报文时,对方就会将目标端口和目标ip都带上。然后接收到这个信息的主机就能够通过这些信息确定是否是发送给我的(接收到信息的某一个主机)。

那么在进程中已经存在了一个pid了,为什么还要存在一个端口号呢?

从技术角度来说通过进程pid来进行标识是可以完成的,关键问题在于要不要这么做。

这里假设确实是使用了pid来进行网络上唯一进程的标识,现在一个信息已经通过网络到达了目的主机(操作系统),现在操作系统就需要将这个信息交给目标进程pid,但是如果这个进程pid发生了改变呢?此时势必就会影响网络这一套机制。还有一点在计算机中是不是所有的线程都需要进行网络通信呢?当然不是,可能是有一部分需要网络通信有一部分是不需要的。

所以在网络通信这里专门设置一个端口号来标识某一台主机中进程的唯一性,有两个原因。

第一个:为了让其它模块(进程管理)和网络进行解耦(着用进程管理就不会影响网络通信了)

第二个:port专门用来进行网络通信。

以上就是为什么要专门使用端口号来标识进程的原因

下一个问题:首先这里有一个前提是一个端口号和一个进程相关联。

那么在特殊的情况下,一个端口号可以和多个进程关联吗?

以及一个进程可以和多个端口号关联吗?

首先一个端口号是不可以和多个进程相关联的,如果可以的话未来一个端口号是标识哪一个进程呢?并且一个端口号和多个进程管关联也是破坏了前提条件的。

但是一个进程是可以和多个端口号相关联的

下一个问题先提出一个生活的例子,某一天你的手机卡收费出了问题,你拨打了10086,然后对于这个自动服务不满意,选择了转人工

。转人工的时候每一个客服人员都是自己的工号。

此时10086给你提供服务的是:

这里的工号就相当于一个具体的进程,而10086就相当于一个IP地址

这里使用网络通信就是,找到了10086这台主机,让某一个进程(工号)给你提供服务。这就是IP地址和port的关系。

因为一个主机中是不止存在一个进程的,所以一个IP对应的port是不止一个的。

这也是一台主机上可以部署多个服务的原因。

在操作系统内部使用端口号找进程,本质就是使用一个整型去找一个进程的task_struct,使用哈希的策略就能够快速的完成这一个工作。

未来进行网络通信的时候,在数据链路层存在两种协议,一种是TCP协议,一种是UDP协议

简单认识TCP和UDP协议

TCP协议和UDP协议具有多个特点,这里暂时只提出一个特点。

TCP是一种可靠通信,而UDP协议是一种不可靠通信。

那么什么叫做可靠通信,什么又叫做不可靠通信呢? 首先这里的可靠和不可靠通信都是一种中性词。

这里的可靠通信指定的是在发生传输问题的时候(例如丢包了)

TCP协议是会做出一些处理的,而UDP协议在发生传输问题的时候,是不会进行处理的(如果两个协议在传输信息的时候,都没有出现问题,那么没有什么不同)。由此也能知道TCP协议是要比UDP协议复杂的,因为网络通信的时候丢包了,那么TCP协议是怎么知道的,又要如何重传,如果重传的时候再出现问题怎么办,都是一些技术问题。

所以可靠通信就要做更多的工作(更为复杂)所以TCP适合一些复杂的场景使用。而UDP协议,因为没有这些处理所以比较简单,适合对数据可靠性要求不高的场景。

对于这两种协议只有不同,没有好坏。

如果遇到一些情况下,我们自己也不知道对于数据的可靠性要求高不高,推荐使用TCP协议。毕竟慢一点,比起将数据丢弃,慢一点也是可以接收的

网络字节序

讲解这个之前要知道一个点就是:我们的机器是分成大端存储和小段存储的。大端存储也就是大端机,小端储存也就是小端机。

然后按照字节为单位,数据是可以被分成高权值位和低权值位的

对于上面的这个数字,aa就是高权值位。

然后是一段内存

当然这些数据在计算机中最后都会变成二进制这里暂时不用管。

然后这些数据在内存中的储存方式不同也就形成了大端储存和小端储存。

至于为什么会存在大端储存和小端储存,也是历史的问题。

对于一个内存中的数据是采用大端(高权值位放到低地址处)还是小端(高权值放到高地址处),完全是由不同的厂商来定的,由此就造成了内存储存的大端和小端。

但是这里就有了问题,不要忘了一个有东西叫做网络。

假设现在一个小端机和一个未知的机器进行网络通信。

然后发送端主机和接收端主机也是存在特定的:

也就是说,发送端发送信息的顺序和接收端接收信息的顺序是一样的

现在将0xaa bb cc dd使用这个规则发送出去大端机发送,也就是:

如果是小端机:

也就是说大小端发送的信息相反的。

这就意味着如果一个小端机器将信息发送出去,而接收信息的主机不是一个小端机,那么就会出现信息16进制完全相反的问题。导致接收端信息接收错误。对于计算机使用大端还是小端的问题,现在的市场上解决不了,否则也就不会出现大小端机器都存在的情况了。

而网络是要完成大小端机器也能完成网络通信的,所以网络就要求了。

网络规定:所有到达网络的数据,必须是大端,潜台词就是所有从网络收到数据的机器,都会知道数据是大端的。

如果你是小端机器,在通过网络发送信息的时候需要将信息变成大端,而接收信息的机器如果是小端就需要将信息转化为小端。

那么为什么网络要规定使用大端储存呢?

答案就是没有理由,如果非要给理由的话,使用大端存储的可读性比较好。

最后在进行网络通信的时候因为网络规定的信息使用大端,所以对于信息一定是要进行各种转化的,对于这些转化我们可以自己实现,但是在系统中是为我们提供了一批接口的:

socket编程接口

常见的API

从这些接口中可以看到一个struct sockaddr的结构体,这是什么呢?

这里就要知道网络编程的时候,socket是有很多类别的

第一种:unix socket:域间socket->使用同一台机器上的文件路径做标识统一资源,和之间学习的命名管道特别像。主要负责,本主机内部进行通信。使用的接口也是上面的

第二种:网络socket:主要使用ip+port进行网络通信。使用的接口也是上面说的那些接口。

第三种:原始socket,那么什么是原始socket呢?一般我们在应用层要进行网络通信的时候要贯穿网络协议栈,所以一般的通信就会去使用tcp,udp协议。

但是os也是允许绕过tcp/udp直接使用下层协议的。也就是不以数据传输为目的,直接访问网络层/数据链路层的socket就是原始socket使用的接口也是类似于上面一样的接口,只不过结构体上存在差别。

这种socket通常用于编写一些网络工具

这里重点学习第二种网络socket,介绍这些只是为了说明:

就和之前的进程间通信,匿名管道是一套,命名管道是一套,system V版本的共享内存又是一套。

但是设计者不想这么干,设计者想用同一套接口。

但是每一种套接字又不一样,每种需要传递的参数也是不同的。

为了解决这个问题,设计者就提出了一个socketaddr的结构体。

这里的

其中更为具体的结构体如下:

未来如果使用的是网络socket使用的就是中间的这个结构体。

最右边的结构体用于域间套接。

为了将右边的两个编程接口统一为一套设计者就设计了sockaddr的结构体。

为了区分所以三个结构体使用了16位的地址类型做了区分。

如果前两个字节内容 = AF_INET(宏)就是中间的结构体,会将传过来的指针强转为这种类型的。如果是AF_UNIX(宏)就会强转为右边的结构体类类型指针。

这样就能完成使用同一套接口完成不同的通信要求,这种技术就是c语言风格的多态。

这也是为什么现在的很多语言都是面向对象的语言,就是因为这种多态的技术在当时是很顶级的技术。

但是在c语言中不是存在一个void*的指针吗?这里为什么不使用呢?

首先使用void*是可以完成的不使用void*,原因就是设计这个的时候void*还没有出现。

udp学习代码

服务端的编写

下面来学习使用一下这些接口

首先创建三个文件:

Main.cc用于执行主逻辑而,udpserver.hpp就是用来完成udp协议的文件。

先来写udpserver.hpp文件

写一个Udpserver的类,这个类肯定要有自己的初始化函数和析构,然后为了让这个网络服务,能够运行初始化函数(Init)肯定是要具有的,然后就是开始服务的函数(start)

以上就是类结构。

然后就是未来我们的服务器要怎么被调用呢?来写Main.cc函数

并且这个服务器我不希望被拷贝,这里我在写一个类。

然后我们让Udpserver继承这个类。

这样Udpserver就不可被拷贝了。

因为在实现的时候会先实现基类,基类没有实现拷贝函数,所以Uspserver这个子类自然也就没有拷贝了。

然后为了让这个代码具有日志的能力,我还是将之前写的日志代码加过来了。

既然是一个服务器首先要有的就是服务器的名字,然后就是服务器的端口号。

对于服务器的id,现在是存在一些问题的,后面会修改,现在如果要使用这个服务器就需要传入一个端口号。

然后我希望接下来用户在使用我的这个服务器的时候是这么去使用的:

./udp_server <IP地址> <端口号>

要完成这个工作就需要去完善Main.cc中的代码

当用户没有使用这种规范使用的时候就需要打印使用手册,同时返回特定的返回码

下面是我规定的返回码

然后就是主函数:

现在测试一下:

然后完善Main.cc代码将IP地址和端口号传递过去。

这样上层的调用就完成了,这里make_unique出现红线是因为make_unique是c++14才出现的语法,所以这里出现了红线,但是我已经更新了我的g++编译器所以是可以完成编译的。

下面就是要创建一个端用于网络的通信了。

使用接口:

对于这个socket函数首先是返回值。

对于这个函数创建成功之后会返回一个文件描述符。

这很正常因为在Linux下一切皆文件,网卡也是文件,创建套接字也就是将网卡这个文件打开,打开之后就要为这个问津创建一个struct file对象(包含了一大堆的函数指针(读写方法))。所以对网络的操作就是对文件的操作。当然udp有一些特殊性,但是大体来说未来要对网络的信息进行接收就相当于对这个文件中进行读取操作即可。

第一个参数 domain表示当前进行网络通信的协议家族或者叫做域。将来你想将这个网络通信设置成哪一种通信(域间socket,网络socket等等)。

这个函数一般都是用于网络通信的是,所以这个参数一般都是固定的。

第二个参数type表示你的套接字类型是什么。

类型如下

一般来说最常见的就两种:SOCK_STREAM(流式套接)一般面对的都是创建TCP套接字。

UDP的套接字类型叫做面向数据报(SOCK_DGRAM),这个面向数据报是什么东西呢?这里暂时不解释直接使用即可。

表示的是底层的原始套接。

回到socket函数上,第三个参数代表的是你使用的是TCP协议还是UDP协议,一般填成哪一种都是可以的。其实在网络通信这里,前两个参数一确定,最后一个参数缺省为0即可。

所以这里的socket函数的参数三个其实都是固定的写法。

下面就将这个函数添加到udpserver中,因为这个函数的返回值是要完成网络通信的重要返回值,所以需要新增成员变量,用于储存这个文件描述符

这里又增加了一个枚举Socket_Error。

下面运行一下:

到这里udp网络通信的第一步就完成了

创建socket完成了。

第二步就是绑定了,刚才第一步创建socket的本质就是创建了文件细节。

但是这个文件的IP是多少呢?port又是多少呢?这些都是未知的。为了别人在将来能够找到这个服务器,所以需要指定网络信息。

而第二步绑定需要的函数:

这个函数和c++中的bind函数没有任何的关系。

说清楚点就是:上面创建了网络的文件信息,而bind就是要将网络信息和这个文件进行关联,所以需要bind。

第一个参数:为刚刚我们创建的socket文件描述符,。

第二个参数,虽然上面写的是sockaddr,但是这里是UDP网络通信,所以这里使用的结构体是struct sockaddr_in。

第三个参数,代表传入的结构体的长度。

最后是返回值:

下面就来绑定一下。

然后struct sockaddr_in这个结构体默认是没有在<sys/types.h>和<sys/socket.h>头文件中的,需要增加头文件。在下面的两个头文件中:

然后我们来看一下这个结构体的定义:

其中的sin_port就是端口号,sin_addr就是IP地址,后面的一大堆就是填充字段。什么是填充字段呢?填充字段就是这个空间我不用但是我要将这个空间字段保存着。最后这个结构体还有一个16位的地址类型,如何填写呢?

看代码:

对于这个结构体最好要进行初始化,这里使用了bzero函数(相当于memset,使用memset也是可以的)

首先解释一下sin_port成员。因为这个sin_port的类型为:

为了防止大小端的文艺所以这里使用htons将_port转化为网络字节序列(大端),然后下面的inet_addr函数则是将string类型的ip地址转化为32位的无符号地址类型。

这个函数输入的是一个点分式的字符串IP,返回的是一个四字节IP,

这个函数会做两件事情:

最后还有一个问题在刚刚的结构体中我们并没有看到sin_family字段啊,那么这个字段在哪里呢?

虽然没有sin_family字段但是有一个__SOCKADDR_COMMON字段,我们继续往下:

其中sa_prefix就是sin_,而##的作用就是将两个字符连接起来:

也就是sin_family了。

这样结构体就填写完成了,但是结构体填写完成,并没有被设置到内核中。

这个结构体的定义是在栈中定义的,而栈是属于用户地址空间的,并没有被设置到内核中。所以需要bind函数。

而bind函数也就是将这个网络信息写到内核中,也就是将网络信息和文件信息进行关联。

现在回到bind函数中,bind函数中的第二个参数要求的是struct sockaddr的类型(基类),所以要进行强转。

到这里Init函数就完成了。

下面就是start函数了。

对于start函数首先要知道服务器永远是不会退出的。

所以服务器注定是一个死循环。既然服务器永远不会退出之后要做什么呢?UDP服务器接下来要做的事情就是收发消息,因为UDP是不面向连接的。

使用函数:

这个函数就是收消息,这个接口需要介绍一下:

在UDP中要收消息,第一个参数自然不需要多说就是刚刚创建的套接字。然后第二个参数和第三个参数是一起的,第二个参数就是用户级缓冲区,第三个参数代表你期望收到多少消息。然后返回值是实际收到的字节数。

flag代表收数据的模式,这里因为不使用flag(后面有更好的方案),所以设定为0,表示阻塞式收消息。

最关键的就是后两个参数了,后两个参数严格来说是一个输入/输出型的参数。

什么意思呢?

UDP服务器是用来接发信息的,现在有人发送了一个信息给这个服务器,那么UDP如果想知道这个信息是谁发的呢?

而这个结构体很明显就是基类结构体,而在UDP中这里的结构体就是sockaddr_in,里面储存的最主要的就是IP和port,所以这个结构体能够输出client端(发送消息端)的IP和port(用于将消息发送回去)。

然后就来使用一下这个函数。

以下是socklen_t这个类型的底层

可以看到我还使用了一个sendto函数这个函数就是用于发送信息的。

这个函数的参数和recvform几乎是一样的除了最后的长度不是一个指针以外。

到这里一个最简单的UDP服务器收发信息逻辑就完成了。

下面来运行一下这个代码:

这里的ip和port是乱写的。

可以看到这个程序确实运行起来了,但是我怎么知道呢?

这里使用指令

netstat -anup

可以看到这个服务确实启动了,也就代表这个进程启动了

下面我们就需要来写Udpclient.hpp了

客户端的编写

既然客户端也是要进行网络通信的所以,也需要创建网络套接字的。

这里截图的时候n没有修改,后面这里的n被我修改为了sock,因为这个sock就是套接字文件的文件描述符,使用sock更容易理解。

服务端最后不需要将close,因为服务端是一直在运行中的,所以不需要close网络文件。

那么以后的服务器我们要怎么使用呢?

我们希望在命令行中./udp_server server_ip server_port以这样的类型使用这个客户端。因为客户端是要往服务端发送消息,所以需要知道的是服务端的ip和服务端的port。

而客户端如何知道这个服务端的ip和port呢?这是由服务提供者提供给客户端的。

而根据上面服务端的经验下面就需要进行绑定了。

但是我们思考一下,现在是客户端要和服务端进行通信,所以客户端需要知道服务端的ip和port用于和服务端的通信,同时客户端也有自己的clientip和clientport,这个ip和port也需要让服务端知道,方便服务端之后回访信息给客户端。由此就有了客户端这里需不需要bind呢? 答案是一定需要bind,但是不需要我们显示的去固定bind,而是要让客户端bind随机的端口,为什么呢?因为客户端是很多的,假设仙子一个客户端bind了端口8888,但是这个端口在这个主机中已经被其它的进程使用了,此时就会造成该客户端启动失败。

最后的结论:

所以对于一个客户端而言只需要创建好套接字即可。

下面我们就可以使用客户端去发送消息给服务端了。

首先来搞定输入功能。

这样基本的输入完成了,下面就是要将这个信息发送到服务端了。

既然要发送给服务器,那么服务器是谁呢?所以客户端需要获取服务端的ip和port。

以上就完成了使用手册的编写和获取服务端的ip和端口。

然后虽然服务端不需要bind端口,但是struct sockaddr_in结构体也是需要填充的。

现在已经知道了要发送信息的对象和要发送的信息是什么,那么要怎么发送呢?

使用函数sendto。

而os什么时候会为client绑定端口呢?就是在第一次sendto的时候会为这个服务端绑定端口。

然后我写的服务端会将写入的信息回返过来,所以客服端也需要接收信息。而使用recvform时可以看到接收信息也是从sock这个文件中读取的,说明udp协议是支持双工通信的,如果在之后加入了多线程,就能够实现在发送信息的同时接收信息。

然后来实验一下

可以看到虽然没有服务端但是这里已经能够编过了。

可以看到这里我使用的ip地址是127.0.0.1,这个地址待会说明,而端口号这个代码中可以随意写,但是还是建议使用1024以上的端口。

在向127.0.0.1发送信息时,建议使用1024以上的端口号,这是因为1024以下的端口号通常被系统保留,用于一些特殊的系统服务或应用程序。如果使用1024以下的端口号,可能会与系统服务或应用程序产生冲突,导致通信失败或其他问题。

然后我们来介绍一下这个127.0.0.1ip是什么。

通过netstat - uan指令可以看到这个127.0.0.1ip地址,这个地址其实是一个本地环回。

其中netstat的选项作用

-u代表查询udp协议 -a代表所有 -n代表ip以点分十进制来显示。如果想要查询这个协议和进程相关的信息还可以加上 -p。

现在服务端和客户端都已经完成了,我们尝试一下运行通信

在使用上面的指令去查询的时候可以看到:

client端虽然我们没有bind人恶化的端口但是os自动bind了一个33729的端口,这个端口号的绑定时随机的。

而如果我现在将客户端退出再重新登录,并且不发消息的话,是看不到客户端的信息的,因为此时的客户端还没有进行任何的bind。

只有在我成功发送了一次信息之后,才会显示出来。

但是到这里为止还只是进行的本地通信,从这我们就能知道如果你想进行进程间通信也可以使用网络通信然后填写ip地址为127.0.0.1即可。如果只是纯本地。

回到代码中,现在已经完成了服务端和客户端的通信,但是现在服务端接收到了信息之后还想知道客户端是谁要怎么处理呢?

这些信息都在

peer这个结构体中储存着取出来即可,但是不要忘了通过网络获取到的信息都是大端网络字节序列的,并且网络信息中ip使用的是4字节的整数代表的,都需要进行转化。

inet_ntoa这个函数做了两件事情,将网络字节序列转化为了主机字节序列,然后将其转化为了点分10进制类型的字符串。

ntohs函数将16位的整数值从网络字节序(big-endian)转换为主机字节序。

这样服务端就能够知道客户端的ip地址和端口号了。

然后修改一下打印函数:

这样服务端就能知道是哪一个客户端发送的信息,并且知道端口号是多少了。

运行一下:

可以看到客户端的端口号确实是34376。

经过上面的代码我们可以知道,在网络通信这里网络ip和网络port经常要使用,而将网络ip和端口号的转化也是需要经常要使用的,所以这里可以进行一下对网络ip和port的封装。

这样就完成了一个简单的网络ip和port的封装。

然后将这个封装使用到刚刚的服务端上:

现在我写的这个服务端,在绑定了127.0.0.1的IP地址之后就只能进行本地通信。

那么现在如果我在启动服务器的时候端口号依旧使用8888,但是IP不使用127.0.0.1而是使用一个云服务器的IP不就可以了吗?

我这里尝试一下:

可以看到报出了这个错误,这个错误说不能将我写的这个IP地址给我。

这里的结论就是:云服务器公网IP其实是提供商给我虚拟出来的公网IP,无法直接bind,如果你是真的Linux环境(虚拟机),可以直接bind你的IP。

但是这里强烈不推荐给服务器bind固定IP,原因是如果服务器bind了一个固定ip那么之后服务器,就只能收到来自该机器上的报文。

但是有的时候云服务器可能会有多个IP地址。

如果现在绑定了ipA那么来自同一台机器的ipB的报文就无法接收了。

那么不绑定固定IP是否就是没有IP呢?答案是不是,更加推荐本地任意IP绑定的方式:

如何做到呢?

首先如果要进行任意IP地址的绑定那么在server端中就不再使用别人传递进来的IP地址了。

这个INADDR_ANY其实就是地址任意。

其实这就是一个宏,宏值为0

此时就能实现IP的动态绑定。

之后发送给服务端的数据,只要是机器上的IP地址,无论你这个机器有几个IP地址,只要端口固定,都能完成报文的接收,既然都这么写了,ip地址自然也就不需要了在服务端的代码中。

下面就是将udp_server类中将对应的服务端的IP字段删除。

接下来服务端启动的时候直接./udp_server <端口号> 即可。修改手册等一系列东西。

然后是运行测试:

可以看到此时的服务器绑定的就不是一个固定的ip地址了。

这个IP地址中为0代表的就是任意的IP地址绑定。

Foreign Address为 0.0.0.0:* 代表客户端可以从任意的IP地址任意的端口往这个服务器发送信息。

再次启动客户端

这样服务器就能够工作了。

但是云服务器的大部分的端口号被腾讯云,阿里云拦截的,所以我们要进行数据的发送,需要开放指定的多个端口。

一般是在云服务器的网站后端,防火墙,安全组中开放。

开放后这个代码就能够运行了。因为这里我没有两台服务器就不截图了。

代码应用

下面接口的使用已经基本会了,下面来谈一谈应用:

现在Linux机器已经能够做到网络通信了,但是现实的很多情况其实是Linux充当服务器,而Windows充当客户端的。

所以下面我们需要写一个Windows版本的客户端。

那么Windows端的socket和Linux端的socket差别会不会很大呢?

答案是不会很大,下面就是一份代码

对于Windows下的udp通信代码首先要做的就是加上下面的头文件:

然后需要包含一个库的名称就和Linux下需要使用pthread库需要加-lpthread一样。

然后下面是代码中第一个和Linux代码的不同点:

其中WSDATA是Windows定义的一个Windows下的socket的数据类型。

然后下面的WSAStartup就是要对winsock做一下初始化,而MAKEWORD就是形成一个2.2。调用这两行就相当于对这个库完成了初始化。

然后是差别2当我们将发送/接收消息等代码写完了。需要将套接字关闭:

closesocket就是Windows下的接口。

最主要的区别就是

这里就是固定的写法,需要初始化一下ws2库

中间部分和Linux一模一样。

代码:

#define _CRT_SECURE_NO_WARNINGS 1
#define _WINSOCK_DEPRECATED_NO_WARNINGS 2

#include <iostream>  
#include <cstring> 
#include<string>
#include <winsock2.h>  
#pragma comment(lib, "ws2_32.lib") // 链接Winsock库  
int main() {

        WSADATA wsaData;
        int result = WSAStartup(MAKEWORD(2, 2), &wsaData); // 初始化Winsock  
        //和Linux下的差别1
        
        
        if (result != 0) {
                std::cerr << "WSAStartup failed with error: " << result << std::endl;
                return 1;
        }
        SOCKET udpSocket = socket(AF_INET, SOCK_DGRAM, 0); // 创建UDP套接字  Windows自己封装了一个socket类型
        if (udpSocket == INVALID_SOCKET) {
                std::cerr << "socket failed with error: " << WSAGetLastError() << std::endl;
                WSACleanup(); // 清理Winsock  
                return 1;
        }
        std::cout << "Enter server IP address: ";
        char serverIP[1024];
        std::cin >> serverIP;
        std::cout << "Enter server port: ";
        unsigned short serverPort;
        std::cin >> serverPort;
        sockaddr_in serverAddr;
        memset(&serverAddr, 0, sizeof(serverAddr));
        serverAddr.sin_family = AF_INET;
        serverAddr.sin_addr.s_addr = inet_addr(serverIP); // 使用用户输入的IP地址  
        serverAddr.sin_port = htons(serverPort); // 使用用户输入的端口号
        char message[1024];
        while (true) {
                std::cout << "please enter message#"; 
                std::getline(std::cin, message);
                sendto(udpSocket, message, strlen(message), 0, (sockaddr*)&serverAddr, sizeof(serverAddr)); // 发送消息到服务器  
                char buffer[1024];
                int recvLen = recvfrom(udpSocket, buffer, sizeof(buffer), 0, nullptr, nullptr); // 从服务器接收消息  
                if (recvLen > 0) {
                        buffer[recvLen] = '\0'; // 确保字符串以null结尾  
                        std::cout << "Received from server: " << buffer << std::endl;
                }
                else {
                        int error = WSAGetLastError();
                        if (error != WSAEWOULDBLOCK) {
                                std::cerr << "recvfrom failed with error: " << error << std::endl;
                        }
                }
        }
        closesocket(udpSocket); // 关闭套接字  
        WSACleanup(); // 清理Winsock  
        return 0;
}

然后在中间有一个类型SOCKET其实就是一个无符号的整型。

最后这个代码如果没有加这一行是存在一个告警的

#define _WINSOCK_DEPRECATED_NO_WARNINGS 2

这个告警是说使用inet_addr接口不安全,推荐我们使用inet_pton(),scanf一样

最后来运行一下:

此时Windows客户端也能将信息发送到Linux主机上了。这也验证了我们之前说的不同的os在实现网络通信的时候使用的是同一份标准。

至于为什么中文是乱码,因为Windows上的命令行窗口种中文版编码方式和Linux下中文的编码方式不同导致的。

后面还会测试手机。

总结一下:手机或者Windows一般都是客户端。而购买的云服务器一般都是作为服务端的。

到目前为止,我们还停留在接口使用的阶段,至少现在我们要写出一些基本的应用出来

下面我们在给udp_server增加一些功能。

首先完善以下InetAddr(对IP和端口进行封装的类)

然后在构造函数时使用初始化列表给这个成员变量进行赋值。

简单应用

在Udpserver类中

声明一种新的类型,使用包装器,包装器包装的是一个返回值为string,参数为string的可调用对象

现在服务端已经收到了消息,收到的消息就在buff数组中。

下面就使用这个回调函数去处理收到的消息。

而服务端在start函数中不是会接收消息吗?我们将这个buff数组传递给回调函数让其去处理信息。

然后将处理后的信息发送给client端。

然后就在Main函数中写一个方法用于处理client端传递过来的信息。

然后运行尝试一下:

运行成功了。

那么这个发送过来的字符串只能是一个普通字符串吗?当然不是也可以是一个命令字符串。所以下面再写一个函数。这个函数要用于执行指令,要执行指令,就需要解析字符串,然后fork创建子进程,然后判断命令是否是内建命令,不是就直接程序替换,是就写内建命令。这是命令执行函数的实现逻辑。

但是这里也可以使用一个新的接口实现这个功能:

这个调用能够执行一个命令,命令字符串不用解析直接执行。

这个调用底层会做的第一件事情:底层创建管道,然后fork创建子进程,然后让子进程程序替换执行这个命令。然后这个命令的结果可以通过这个返回值来获取(以文件的方式获取返回值,然后这个文件是可读的还是可写的,都可以通过type参数实现)。

如果这个函数在/fork失败/管道失败,这里返回的就是一个null。

然后将这个函数传递过去。

运行成功了,当然也不是所有的指令都能运行的,为了防止问题,也可以设置一些黑名单指令。方法也很简单,查询字符串(find函数),如果存在黑名单中的指令不执行即可。

这里的逻辑就是遍历黑名单,如果在message中发现了这个指令就不能执行。

当然我写的这个代码还存在很多的问题,例如无法打印错误指令的信息。因为popen函数只会在fork和pipe创建失败的时候返回null,而在其它的时候是不会返回null的,所以如果你输入了一个错误的指令,我这个代码是不会做出任何处理的。

写这个代码只是为了说明,远程执行命令的底层逻辑就是这个。让机器远程执行命令,再将结果返回。只不过这里使用的协议是ssh协议。

实现简单的聊天室

首先来说明实现逻辑:

现在左端的很多client端一起去访问服务端。

现在如果一个人发送了消息,那么就可以在服务端将这个人的信息记录下来(记录用户的地址信息)。

现在如果其他人也发送信息,这些人的信息都被记录了下来,只要这个人历史上发送过消息,这个人就在线了。然后我们将一个消息,转发给所有人,此时就实现了一个简单的聊天室。

然后udp协议是支持双工通信的,那么就可以实现一个线程去读,一个线程专门去写。读的线程将信息读到了,就先将信息放到临时的区域中。

然后写线程就负责从这个区域中获取数据然后将这些信息发送出去。

对于读写线程的实现方式,可以使用线程池,也可以使用环形队列,或者阻塞队列。

这里选择使用了之前写的线程池来实现。

首先第一步是将之前写的线程池拿过来:

增加了我之间简单封装的线程,守护线程,和线程池。

现在我们要在服务器内部创建一个线程池,获取线程池则直接使用单例模式的函数即可获取.

这里需要修改一下服务端的代码,首先就是服务端不需要返回信息,并且执行函数了

那么声明和定义的回调函数自然也不需要了。

然后就是Threadpool中,线程池执行的任务,我们也需要单独设计所以暂时取消任务的执行。

最后Makefile文件中编译选项需要带上-lpthread。

运行一下:

可以看到确实创建除了出了5个线程。

现在初始化完成线程池启动。

然后服务器就可以接收信息了,只要发送消息的IP地址是一样的,那么我们就可以认为是同一个人发送的信息。

那么既然存在很多人发送消息,所以需要将这些信息管理起来,这也是为什么之前要写一个InetAddr类用于储存client端信息的。

这里可以使用unordered_map来保存,

然后写一个Add函数:

但是这个Add函数是不完整的,这个数组可能会有多个线程一起去访问所以需要加锁。

那么主线程要做什么呢?现在消息已经存在了,在线用户也已经有了。

下面要做的就是要将消息进行转发。

如何转发自然就是写一个函数了,函数的参数就是要转发的sock,以及需要转发的message

那么接下来就要让线程池去执行这个任务了,从这里我们也能知道。

所以线程池中也可以是上面的Rout任务

到这里代码逻辑就很清楚了,首先服务端会创建一个服务器,服务器就会创建一个线程池,这个线程池中存在很多的子线程去等待任务。然后一个客户端连接到服务器之后,会向服务器发送一个字符串,这个字符串被服务器接收以后会形成一个任务,将其push到线程池中,线程池中的线程接收到任务之后就会唤醒线程去执行这个任务,线程池执行的任务就是将这个信息发送到当前所有的已经上线的用户上。

因为服务器在形成任务的时候,使用了bind将任务直接绑定到了task任务上,所以我们需要修改一下udp_server端函数的参数。

之间这个函数是具有参数的。

同时这里为了方便我本地进行测试,我还需要修改一下InetAddr这个类的==函数

因为我是在同一台服务器上进行的测试,所以ip是相等的,就会导致==函数将我启动的两个用户当作了一个用户去处理:

这样就能够在同一台服务器上运行了,在同一台服务器上测试的时候,两个用户的ip是一样的,但是两个用户绑定的端口号是不一样的。

现在就能够运行了。

运行测试一下:

首先是服务端在将服务器启动之后,确实创建了5个子线程,还有一个主线程。

为了让这次的测试更加的准确,所以会添加一些Debug信息。

首先就是服务端每向一个在线客户端发送一条信息,使用log打印一条:

现在启动客户端测试一下:

现在启动了一个客户端,然后发下哦那个消息之后,服务端确实也接收到了这个消息,并且向客户端发送了消息你好。

现在在启动一个客户端尝试一下:

可以看到现在另外一个客户端打印了一条消息给服务端,然后服务端确实也接收到了这个消息,然后将这个消息转发给了两个用户,但是此时出现了问题,虽然服务器向两个客户端都转发了信息,但是左边那个客户端并没有显示任何的信息啊。

这里我们让左边的客户端在发下哦那个一个信息就会收到这个信息了。

导致这个现象的原因是什么呢?

原因很简单,因为我们的客户端是一个单线程的客户端,客户端在发完消息之后,再收到消息,一发一收,所以客户端就能够看到这个信息。但是可能会出现下面的情况,服务器在向客户端发送消息的时候,客户端可能正在sendto这里发送信息(被阻塞在sendto这里,无法去调用recvfrom),只有sendto函数执行完成之后,才会去执行接收信息的函数。单线程就会导致这样的问题举一个现实的例子,也就是现在其中一个客户端在上线之后就不再聊天了,但是其他的客户端还在聊天,客户端就需要一个单独的线程去专门用于接收信息,一个线程专门用于发送信息。所以要解决这个问题,就要让客户端也变成多线程版本的客户端。

现在就来修改代码让客户端也变成多线程版本的客户端,这里需要两个线程一个线程用于接收信息,一个线程用于客户端的发送信息。

首先是两个任务:

这个任务还只是一个简单的模板,参数和返回值都还没有确定。

然后是创建两个线程:

然后去看我们封装的这个线程的函数:

需要的是线程的名字,线程需要去执行的函数,以及要执行这个函数的参数,这里因为我们没有确定这两个函数的执行参数,所以直接写一个ThreadData结构体当作参数,之后再去考虑具体要填写什么值。

这里写错了应该是class ThreadDate

然后是创建两个线程。

然后就是启动这两个线程。

然后下面就是来完善这两个函数了。

首先是发送信息的函数,要发送信息肯定要有服务器的IP地址和Port端口。而这个都在InetAddr类中包含了服务端的IP地址,和port端口,以及会自动的将IP和port转化为struct sockaddr_in结构体。然后还需要一个sock用于发送信息的时候知道要往哪一个文件中发送。

这样当这个结构体传递给Thread之后,Thread在底层就能去调用sender函数和reciver函数了

下面来完成发送信息的功能,要发送信息首先要具有一个缓冲区。然后通过sento套接字来发

然后就是接收信息的任务了

这里即便我不进行聊天也要能够完成任务的接收。而这里为了方便测试选择了cerr输出。

下面就来测试一下,为了能够将接收消息的窗口和发送消息的窗口分开这里需要创建一个管道,然后将标准错误重定向到这个管道中,然后使用cat打印这个管道信息。也可以这个工作放到代码中,但是这里为了测试我就没写了。

建立两个管道文件便于测试

下面我们需要将标准错误流重定向到这两个管道中(一个终端重定向一个)。

此时就完成了上面的两个端口用于接收打印信息,而下面的两个端口用于,发送信息,而最右边的就是一个服务器。

也可以将这个信息打印到每一个终端对应的dev文件中。

但是现在还有一个问题那就是这个发消息的客户端的IP地址我们是不知道的。所以这里可以修改一下服务器创建任务时的发送字符串的逻辑:

这样每一个在线上的用户都能看到某一条信息是哪一个用户发送的了。

运行截图:

到这里一个最简单的基于UDP协议的网络聊天室就完成了。

希望这篇博客能对你有所帮助,写的不好请见谅,如若发现错误欢迎指出。


网站公告

今日签到

点亮在社区的每一天
去签到