要完成网络编程首先要理解原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协议的网络聊天室就完成了。
TCP协议
下面我们来学习一下TCP套接字的使用。
也就是使用一下基本的接口。首先TCP套接字的使用和UDP套接字的使用是大同小异的,但是多了一些步骤。
这里回顾一下:UDP是不可靠的,无连接的协议。而TCP则是可靠的,面向连接的协议。也就是说客户端和服务端要进行通信,首先要建立连接。
简单服务端
下面先来写一个TCP的框架,依旧是Main.cc,Log.hpp,tcpserver.hpp,Makefile文件。暂时是这些。将Log.hPP移动过来之后。
首先来写Main.cc将主逻辑写好:
初始化服务器和服务器的开始函数之后再去填写。和上面写的UDP服务器是一样的。作为一个服务器首先服务器的端口号和sock是需要的,但是在TCP这里如果使用和UDP一样的sock是会出现问题的,什么问题,我们下面会说明。至于IP地址在服务器的类中是不需要的,首先服务器是不允许绑定一个固定的IP端的,并且服务器是要做到接收多个客户端的请求的,所以在Tcpserver这个类中是不需要IP地址的。
所以在TcpServer这个类中只需要端口号和sock(存在问题)成员。然后我们需要完成一个Init初始化函数,构造函数,和Start函数,为了让这个TcpServer类不能被拷贝我还是将之前写的nocopy.hpp拿了下来,让TcpServer继承这个类,让TcpServer不能被拷贝。
而要启动这个TCP服务器的指令也很明显是./TcpServer <端口号>,所以也是需要读取bash的启动字符串获取端口号的。而在这里就会存在可能失败的情况,所以也需要将comm.hpp从UDP那里获取一下。
下面就来完成这些基本的工作。
然后是Main.cc函数
然后下面我们要编写的就是TcpServer类中的Init接口了。
对于这个接口首先要创建套接字。创建TCP套接字的接口和UDP是一样的:
只不过参数存在变化:
因为虽然这里的套接字虽然变化成了TCP但是还是需要进行网络通信的,所以第一个参数domain(域/协议家族)的选择还是不变的还是AF_INET/PF_INET,第二个参数type发生变化了,之前在写UDP套接字的时候选择的是
支持无连接的,不可靠的,有最大长度的通信方案。
而这里需要选择的是:
提供全双工的,基于连接的字节流服务。至于最后一个参数也可以设置为TCP对应的那个参数,也可以写成0,因为前面两个参数已经足够让os去判断这里使用的是什么协议了。
至于返回值则是一样的,错误返回-1并且错误码被设置,成功则返回一个文件描述符。
下面是第一步创建套接字的代码:
既然返回的是一个文件描述符,说明创建的这个套接字就是一个文件,而文件要进行网络通信,下面就是要对文件填充一下本地的网络信息。并bind,再次说明这里的bind和c++中的bind在用法和作用上是完全不同的。
bind
接口通常用于将一个套接字(socket)与一个特定的地址(包括 IP 地址【一般不写】和端口号)绑定在一起,使得该套接字可以在网络中唯一标识出来,以便其他网络节点可以与其通信。
这里稍微再提一下,在一个os中联网的进程是有很多的,所以创建的套接字也是有很多的,所以os也是需要将这些套接字管理起来的,如何管理先描述在组织。对于这部分更加详细的信息在之后的章节再去说明,这里专注于TCP接口的使用上。
下面继续填充网络信息。
到这里初始化的第二步填充网络信息就完成了。
但是到这里并没有将这些信息设置到内核空间中(还是在函数栈,也就是用户空间中),所以下面就是2.1步bind。
因为bind函数需要将struct sockaddr_in结构变成sock_addr结构至于原因上面已经说明过了。这里可以将这个强转写成一个宏来完成强转。
到目前为止,TCP套接字的使用和UDP是没有太大的差别的。
而下一个步骤就是TCP协议特有的动作了。
之前我们说过了TCP协议是面向连接的协议,在通信之前就需要建立连接,那么谁来建立连接呢?一般都是客户端来建立连接的。而server端一般都应该主动等待连接的到来。正如开餐馆的老板都是被动的等待客户的到来的。这就要求餐馆的老板一直在餐馆中,不然用户来了没有人为其提供服务。所以TCP服务器还需要做第三步叫做:设置套接字为监听状态(TCP特有)
由此就要知道TCP特有的接口:
这个函数的第一个参数就是要设置的套接字的文件描述符,而这个函数的第二个参数暂时无法解释,之后会说明,这个参数是全连接队列。暂时不解释。这个函数成功的时候0被返回失败的时候,-1被返回错误码被设置。
下面就来进行这一步的代码编写:
下面运行一下:
到这里TCP服务端的初始化函数就完成了。
为了表示服务器当前的运行状态使用一个bool类型的成员变量表示当前服务器的运行状态。
构造函数那里使用false表示一开始这个服务器处于的是非运行状态。
那么要完成Start函数,首先就要将服务器的运行状态进行修改。
监听机制
对于TCP而言,服务器现在已经完成了基本的初始化函数sock也处于了监听状态了,现在也就是需要等待客户端来连接了。那么服务端怎么知道一个客户端连接了字节呢?所以还需要让服务端获取连接。如何获取需要使用新的接口:
下面使用示例图说明一下:其中一条线是客户端,其中一条线是服务端,然后服务端有一个套接字是处于listen状态的(下面的3表示的就是这个sockfd是3处于监听状态)
然后客户端一定会来连接这个服务器。所以服务器一定要从这个listen套接字中将这个连接拿上来。
那么是谁来连接这个服务器呢?是客户端那么服务器如何得到这个客户端的信息呢?就是通过accept套接字,对于accept套接字来说,后面两个参数是一个输入输出型的参数。更强调的是输出。这也就是说当服务端通过accept获得连接之后,这两个参数中储存的信息就是客户端的套接字信息。这两个参数等同于在UDP套接字中的recvfrom套接字中的最后两个参数是一样的(作用)。
回到这个accept套接字的参数上,第一个参数sock是是什么呢?这个参数就是上面我们使用socket创建的文件fd(完成bind,和listen的sockfd)。而这个套接字最重要的就是返回值。下面我们来看一下这个套接字的返回值是什么
上图中说的accepted socket就是在accept套接字中的第一个参数sockfd。可以看到这个accept套接字在成功之后又返回了一个新的文件描述符(新的sockfd)
那么这个新的sockfd又有什么作用呢(如何理解这个返回值呢?)
新的sockfd
在UDP代码的编写中从头到尾都只有一个sockfd。那么TCP为什么要创建一个新的sockfd呢?之前我们说过在tcp中服务端要等待连接的到来,而为了获取新连接就需要使用accept套接字,而这里新增了一个sockfd,那么未来服务端和客户端进行通信的时候要使用哪一个sockfd呢?
下面举一个例子
下面是一家饭店,然后饭店外有一名招呼客人的人叫做张三,饭店外有路人路过的时候,张三一直在拉这些路人到饭店中去吃饭。这个饭店的名字叫做好再来鱼庄。
有一次张三招呼到了几个人,然后张三会跟着这些客人来到饭店门口,往饭店中说一声来客人了。来一个服务员,提供服务。此时这些被招呼的人就去到了一个位置上,一会之后就会有一个服务员(李四)来为这些人提供服务。而张三又返回到外面去拉客人。
画图表示:
过了一会张三又带领了一些客人进来了,然后张三又会通知饭店让其出来一个新的服务员去提供服务。这个新的服务员王五又会为这一批客户提供服务。
在鱼庄中有客人的时候,张三这个人从来都不会进饭店,也不会为里面的人提供服务。
张三的核心工作就是从路上抓人来饭店。抓完一波,再去路边抓另外一波。
如果我是这个鱼庄的老板,此时我的员工是分成两个类别的。一类是李四王五,这种向外提供服务的服务员。还有一类像张三这样的服务员(也是饭店的客户来源)。而这里我们将李四王五叫做,accept新返回的文件描述符。而张三就是listensock,主要作用就是获取连接。而未来服务端和客户端要进行通信使用的文件描述符则是accept新返回的这个文件描述符。
所以下面我们要修改一下TcpServer中sock这个变量的名字了,这个不是sock了应该是listensock
然后改变一下上面使用_sock的代码。
下面就是来写Start函数了。
很明显就是要获取连接了accept接口,从哪里获取呢?从_listensock中获取。
既然张三是拉客的人,如果某一次张三拉客失败了,自然是不会放弃拉客的,而是马上去拉下一波客人。这个也要影响到我们的代码,在之前的代码中如果创建sock失败了就直接,失败不玩了,而accept这里即使失败了,也不会有任何的影响。
对于提供服务的代码一共有四个版本,这里先完成一个版本之后去写客户端,让两个终端能够互相通信起来。
为了不让代码变得臃肿这里专门写一个提供服务的函数。
下面首先就是要从客户端中读取信息了,在UDP那里可以使用recvfrom,因为UDP套接字不是面向字节流的,是面向数据报的。所以这个UDP和文件本身的渐进性不强。但是如果要进行TCP的读写(面向连接,面向字节流,和之前学习的管道特性是一样的)。所以对于TCP的读取直接调用read去进行读取,使用write进行写入。
下面就来使用read进行读取。
读取既然是一个系统调用就一定有可能会读取失败,如果读取到的数据等于0要怎么去处理呢?如果最后的返回值小于0代表读取失败了,直接break即可
对于read成功读取的时候,会返回读取到多少字节,而0表示读到了文件的结尾,也就是说如果read的返回值是0,代表读到了文件结尾。在网络当中,如果reada返回了0,表示读到了文件结尾(表明对端关闭了连接),这个特点和当时的管道是一模一样的,管道的四种情况。
下面是代码:
读取完成之后再将信息写回去,这里对于写入的返回值(写入是否成功暂时不处理)。
后面会说明写入的返回值。
这里为了测试就一直让其进行IO的操作。在上面的代码中有一个小细节,在写的时候使用的是sockfd,而这里再读的时候使用的也是sockfd。这也就说明了,这个新的sockfd(tcp连接)是一个全双工的。
服务器暂时这么写。以上就是服务端的书写了,然后来运行一下。
在启动了服务端之后,使用下面的指令去查看信息:
netstat -nltp
其中的n代表将能够显示成数字的字段全部使用10进制数字显示
p代表显示进程相关的信息
t代表查看tcp
l代表只查看listen状态的TCP套接字
这就说明我们的服务器已经处于监听状态了,而这里为了完成测试下面就来编写客户端。
简单客户端
然后就是和UDP一样的操作了获取服务端的ip和端口号然后去创建套接字。
下一个步骤就是bind了,那么客户端一定要有自己的ip和端口,那么要不要bind呢?答案是不需要显示的bind,但是是需要bind的,因为客户端也是需要有自己的ip和端口的。如果bind了一个固定的端口,就可能出现问题(其它的线程占据了这个端口就会导致当前的客户端无法启动),也就是说一般的客户端都是随机端口的
而在TCP中在client发起连接的时候客户端会被os自动绑定本地端口号。
在UDP那里直接就可以让client去发和收取信息了。但是TCP是面向连接的协议。所以客户端的下一步就是建立连接。使用的接口:
这三个参数也就不详细解释了,第一个参数是client创建的sock,而后面的两个参数都是说明要连接的服务端的信息的。
这里会涉及到将字符串风格的地址转化为4字节整型的地址,使用的接口是inet_pton。这个接口能够完成两件事情:
第一个将点分十进制的ip转化为4字节的整型ip,第二个:将四字节ip转为了网络序列。
并且会将这个信息写到struct sockddr_in的结构体变量中去。
但是到这里了,这些信息也没有被设置到内核中去,因为这些结构体本质还是在栈中的,属于用户空间下面就是调用connect接口将这些信息写到内核中去了。
下面就是代码:
如果连接失败(connect)返回的是-1(一般情况连接失败:要么是网络的问题,要么就是服务端没有启动)。如果是网络问题,那么就要给客户端一个重新连接的机会这些功能的实现都会放到后面。这里默认连接失败就直接return。
connet之后客户端就会由os自动的去选择一个端口。也就是说未来进行通行的时候客户端和服务端各自都有自己的ip和port。
客户端这里没有产生新的sockfd,连接成功之后使用之前创建的sockfd就能完成和服务器的通信。
下面我们就让客户端去给服务端发送信息。同时客户端也要接收服务端发送的信息
这里发送信息失败以及收取信息失败的处理方式这里都没有做更加具体的处理。
下面就是测试了。
同时从这一个日志信息上可以看到在服务端accept之后确实形成了一个新的sockfd为4,那么为什么是4呢?因为这里是单进程。现在只有一个客户端连接过来所以是4,再有客户端来连就是5。
而如果这里将客户端关闭之后,会发生什么呢?
可以看到服务端也是能够知道的(原因就是在客户端关闭之后,客户端对应的套接字信息也就没有了,服务端就会读取到0,也就知道了客户端关闭了)。
而在服务端知道客户端关闭之后就会从server函数中退出,让4文件描述符不再使用,然后再次去等待,连接。所以当存在一个客户端再次连接到服务端之后,因为之前的4已经被释放了,所以再次获取会再次获取到4.
现在我们启动服务器和客户端使用指令去查看一些信息。
可以看到客户端到服务器以及从服务器到客户端互相建立了两条连接。
怎么会出现两个连接呢?这里的原因是我的客户端和服务器是在同一台主机上进行的。如果是在不同的主机上就只有一条连接。
现在我在启动一个客户端
可以看到我新启动的这个客户端怎么无法发送信息到服务端呢?另外有一个客户端又是可以发送的。
然后我将下面的这个客户端关闭。
服务器一瞬间就收到了这些信息,并且将这些信息发送回去了,造成这个的原因就是这个服务器代码是一个单进程的代码,一旦进入Server就进入死循环了。也就是当前的这个服务只能同时处理一个链接。原因也很简单,当服务端接收了一个链接之后,执行流就进入到了Server这个死循环中,自然就不能接收其它的客户端的链接了。
对于这个问题,暂时先放在这里,我们先去完成一个断线重连的服务。也就是让客户端能够支持断线重连。
断线重连
首先是定制重连的次数:也就是定义一个无法改变的常量/宏。很多游戏的客户端(QQ等)都会做这个操作的。
然后我们封装一下下面的这些操作。
我们之前在客户端所做的创建套接字,填充套接字,隐式bind,然后和服务端建立connect都是在访问服务器。我们就将这些操作进行一些封装。
既然封装了访问服务器的操作,那么如果创建sock失败自然就是访问服务器失败了,connect失败了也是一样的,最后如果你向服务器写入失败了,自然也可以认为是访问服务器失败了,直接返回。然后是读取服务器发送过来的信息(read的返回值),如果read的返回值 == 0表示服务器被关闭了,或者协议是这么规定的。直接break循环即可。但是如果读取失败了,也就是访问服务器失败了直接返回即可。然后就是代码了:
但是这样写有一些不太好,机会每一次异常的返回都要先关闭close(sock)然后才能去返回。这样就完成了队服务器访问操作的封装。
那么现在重连的思路也很明显了,每一次的重连都是在调用上面写的这个函数而已。
下面就来写代码:
如果重连次数大于限定次数说明服务器是离线的状态。
现在来测试一下:
这里我让服务器连接一个没有运行的客户端,确实是能够重连的。但是这里存在两个问题。
第一个:如果在重连的过程中有一次重连成功了,然后再次连接失败了,那么再次重连的时候又会重新拥有REY_TRY次的机会。所以visitserver这个函数必须让客户端在重连成功后,让count重新归为1。为了完成这一步骤,就需要将count的地址传递过去,同时在这个函数中的conect成功之后就直接让count设置为1。
然后我们继续去运行就会出现下一个问题:
这里我先让客户端连接成功之后,直接关闭了客户端。
然后在输入信息之后可以看到并没有触发重连而是整个进程都被杀死了。
原因就是当使用当我们将信息写到客户端中的缓冲区之后,这个缓冲区会将这个信息通过write写到服务器中,但是服务器关闭了,此时就会出现异常,然后os注意到这个异常直接就将客户端进程杀死了。
但是如果我没有输入任何的信息是可以进行重连的。
可以看到确实是触发了重连。
但是这也不能看到重连之后的count被重置啊,要做到这一点还有一个点需要处理,如果直接去做实验会导致下面的现象:
可以看到我左边在重连服务器的时候出现了一个Address already in use的错误。"Address already in use"错误通常是由于在服务器关闭后,之前使用的端口还处于TIME_WAIT状态,而客户端尝试重新连接时遇到了这个问题。
对于这个错误更加详细的原因我在后面的网络原理会说明。这里暂时只说明如何处理
使用下面的接口:
对于这个函数的解释也放在后面说明
在服务端的Init函数中写:
再去尝试一下原先的实验,看在重连之后count是否会被重新设置为1:
看到确实被设置了。
对于上面setsockopt函数后面讲解TCP原理的时候再去详细的说明。
但是到目前为止,服务端都是一个单进程版本的服务器,也只能支持处理一个客户端,下面就要将其变成一个多进程版本的服务器。
多进程版本服务器
要将我们的服务器改成多进程版本的,就要考虑下面的问题:
我们的每一个进程都会有自己的文件描述符表,这个表中012默认占据的是标准输入,标准输出,和标准错误。
然后我们每打开一个网络套接字在底层其实也是一个struct file对象。
我们的这个单进程的代码对于连接的第一个客户端,占据的文件描述符表中的第三位,此时再来一个客户端连接,就是第四个位置。
那么现在如果建立一个子进程。对于子进程来说哪些东西要被重新创建呢?首先就是进程的pcb肯定是要重新拷贝的,然后文件描述符表也要重新拷贝。
那么这些往后的(struct file对象等)用不用重新创建呢?肯定是不需要了。
子进程拷贝出来之后文件描述符中3号位置最后还是会指向和父进程一样的file对象,同样四号也是一样的。
由此我们就可以让父进程去获取新连接,而让子进程去处理这个新连接。
此时就需要让父子关闭不需要的文件描述符了
关掉之后就能让子进程去处理新的连接,而让父进程去获取新的连接。如果不关带来的结果就是:父进程获取的连接数一旦达到了文件描述符表的上限就无法再去获取新的连接了(这种情况也就是文件描述符泄漏)。对于子进程没有太大的影响。为了处理这种情况--父子进程推荐关闭自己不需要的fd。
下面就是写代码了:
这个代码是在tcp_server类中的Start函数中的。但是上面的代码还是存在问题的,这个问题就是如果是让子进程去进行server,因为父进程必须要等待子进程,此时就变得和单进程的服务器没有什么区别了,只不过是父子去串行处理的。那么能否让父进程在等待的时候不选择阻塞等待呢?也不行,想象一种场景,现在有100个连接过来了,然后父进程处理了这些连接,然后就再也没有连接过来了,此时就会出现父进程一直在等待获取连接的接口处等待。不会去执行waitpid函数,导致僵尸进程的出现。
对于这个问题有两种解决方法:
第一种:子进程在退出的时候会向父进程发送SIGCHLD 信号,也可以通过基于信号的方式来回收子进程,这种方式是可行的。
但是不够简单和优雅。
这里使用第二种方法:
让子进程继续去创建子进程,也就是创建一个孙子进程。在创建完成之后直接让子进程退出。因为子进程只执行了很少的代码,所以父进程也能很快的等待到子进程。也达成了让父进程去快速的处理其它的连接。回到子进程和孙子进程上,当子进程退出的时候孙子进程就变成了孤儿进程会被os接收。所以也不需要担心孙子进程的资源泄漏问题。
是否可行呢?测试一下:
同时去看一下进程的个数
可以看到确实是存在了三个进程,并且有两个进程的父进程是1,而1自然就是bash(可以认为就是os了)。我们去看server端的信息,可以看到每一个连接过来的客户端最后得到的sockfd都是4,为什么都是4呢?原因就是父进程每获取一个连接,就会创建子进程然后让子进程继承下去,然后父进程就会关闭这个文件描述符,所以这里获得的都是4。
保证了不会出现文件描述符泄漏。并且我们写的这个代码也是具有重连的功能的。
那么除了这种方案之外还有没有其它的方案呢?
多进程信号版本
答案是也有。下一个版本就是多进程的信号版本。
前面依旧是一样的去创建多进程,不一样的是现在父进程不想去等待子进程了。此时就让子进程直接去提供服务,然后让父进程不进行等待,直接去获取新的连接。此时没有进程去等待子进程就会出现系统资源越来越少的现象,为了处理这样的现象。
就需要处理子进程向父进程发送的信号了(每一次子进程退出的时候都会向父进程发送SIGCHLD信号)。其中的一种处理方法就是捕捉这个信号然后重写一个信号的处理方法,而这个信号的处理方法就是去wait子进程,但是在Linux中也可以选择直接忽略这个信号。
因为在Linux中如果对SIGCHLD信号进行忽略,子进程在退出的时候,就会自动的释放自己的资源。
下面是代码:
下面再来运行一下:
然后再退出几个客户端:
通过脚本可以看到没有出现僵尸进程的问题。如果没有使用忽略这个信号此时这里就会出现僵尸进程的问题。
以上就是多进程基于信号版本。
但是这个代码仍然是存在问题的,因为创建进程也是存在代价的,上面的代码都是在客户端连接到服务器之后才创建的进程。此时就将代价转到了客户端上(客户端需要等待进程的创建),那么能否引入进程池呢?当然可以。进程池的代码之前也是写过的。
这里的思路也很简单,将server包装成进程池中的进程能够执行的代码。每连接到一个客户但了就负 载均衡式的唤醒一个进程去执行server代码即可。但是这里就有一个问题。在进程池中的进程是提前创建的,而上面的代码都是来了链接之后才去创建的子进程也就是先有的链接,再创建的子进程。而在进程池中因为子进程是提前创建的,也就导致了子进程不能获得这个链接,所以我们就需要处理一下,让之前创建的子进程也能得到这个链接。
这里就需要使用一种技术了,也就是使用Unix域套接字。
因为笔者写这篇文章的时候没有写这个代码,所以就不写代码了。但是思路是这样的。
下一个版本:多线程
多线程版本服务器
现在解释原理,当父进程创建一个一个子线程之后,是会和主线程共用同一张文件描述符表的。所以主线程曾经打开的文件描述符,也能让子线程直接知道。这也就意味着在多线程中根本不需要进行父子间文件描述符的传递。所以多线程的服务比较简单。
除此之外主线程和新线程不需要关闭不需要的文件描述符。因为主线程和新线程共享一张文件描述符表。
这里使用原生的线程,不使用我之前封装的简单的线程库了。
但是使用线程的话需要考虑主线程需要join子线程,就会让主线程无法一直获取链接。所以当创建好一个子线程之后需要将这个子线程设置为分离状态。
那么现在要解决的问题就是要让Thread_Start函数拿到sockfd了。这里不能使用pthread_create(&id,NULL,Thread_Start,&sock)的方法,因为这里你不能确定主线程和子线程是谁先运行的,如果某一次主线程运行了两次,那么某一个子线程得到的sockfd就是错误的(原先正确的sockfd被覆盖了)。所以这里需要解决这个问题。
虽然完成了但是可以看到还是存在报错的,这个原因就是Thread_Start函数是在类内部的。所以其实这个函数是包含了一个this指针的隐藏参数的。
所以需要将Thread_Start方法设置为静态的。
但是这样做之后,又会导致编译器不知道Server方法是哪一个类对象在调用了。所以我们需要在ThreadData中在将一个this成员变量。
测试一下是否可以运行。
运行结果:
此时如果再次增加一个客户端就会发现,文件描述符一直在增加,原因很简单,当前只有一个进程,而多个线程是共享一张文件描述符表的。
但是这里还是有一个问题,客户端来了才创建多线程是存在代价的,所以这里的方法就是引入线程池,让线程提前创建好,并且因为多个线程公用同一张文件描 述符表所以不需要担心提前创建的线程得不到文件描述符的问题。
对于这个问题之后再去解决,现在的问题是服务端得到了客户端的信息,但是服务端并不知道客户端是谁啊。所以需要增加一些代码。
对于服务端而言有一个函数就会将客户端的信息返回过来。
accept函数,在成功的时候会返回客户端的信息。
而在之前我们已经写了一个InetAddr类能够完成将sockaddr*由网络序列转化为主机序列了。
然后让ThreadData中新增一个成员,也就是这个InetAddr类。
然后重写一下Server函数。
运行结果:
这样服务端就能得到客户端的信息了。
对于实现这个功能其实并不难。我们需要关注的是,将网络序列转化为主机序列。在InetAddr这个类中我们使用的接口是下面这两个:
inet_ntoa能够将4字节的网络序列转化为主机端的点分十进制的地址。当时我写这个接口也是因为这个接口便于理解。
可以看到这个接口返回的并不是这个字符串,而是这个字符串的地址。那么这个接口转换完成后的字符串在哪里呢?
这个问题真如之前学习的c接口fopen函数,这个接口最后返回的是一个FILE*的变量,那么FILE在哪里呢?这个FILE是fopen在内部malloc出的一个空间然后将值拷贝过去的。
而这个inet_ntoa采用了类似的方法,而这里是多线程的环境啊,这就可能会造成线程不安全的现象。
这个函数使用的是静态的缓冲区。
例如下面的测试:
两个地址在转化完成之后变成同一个地址了,说明这个缓冲区永远只保留一次的结果。
在APUE中明确指出了inet_ntoa是线程不安全的。但是我在centeros7中测试了没有出现这种问题,可能是centeros7内部使用了互斥锁。但是既然可能存在线程安全的问题,就需要将这个函数替换了。
之前我们使用过一个函数inet_pton,这个函数能够将一个字符串ip转化为4字节ip的网络序列。而这里依旧存在一个函数能够将一个4字节IP的网络序列转为位主机端点分十进制的IP地址。
第一个参数位协议家族,第二个参数就是四字节的IP地址(网络序列),第三个就是转化完成之后储存的位置。最后自然就是大小了。这个函数的返回值,成功返回的其实就是dst的地址,失败一般返回的都是NULL,也有将-1强转的。
这就意味着每一个想要调用这个函数的进程都必须自己维护一个缓冲区,和大小。此时就能有效的避免线程安全的问题。
然后我们就来修改InetAddr类中的代码:
线程池版本服务器
现在回归主题,让线程池加入到这个代码中。
依旧是先让线程池需要的组件拿过来。
因为我实现的这个线程池也使用了ThreadDate类,为了防止命名冲突的问题。我这里就使用命名空间了。
然后就是要将这个线程池引入到tcp服务器中了。
服务器在初始化的时候就可以将单例线程池创建完成了。也就是要将获取单例的函数放到服务器的初始化函数中:
然后尝试启动一下线程池。因为我将线程池中储存的类型设置为了int而int类型是没有()方法的,所以这里我就将线程池中的()方法注释了。
现在已经有了线程池之后在服务端的就变成了,服务端获取到了链接之后,将链接push到线程池中,线程池就会唤醒线程去处理这个任务。
那么此时自然也就不需要创建线程的步骤了,既然不需要创建线程的步骤,自然能ThreadData也就不需要了。
现在我们要完成的事情就是将线程池中线程要执行的任务传递过去,线程池就会唤醒线程去执行。
这里我就设定一个类型然后将这个类型填入到线程池中。
然后当服务端收到一个链接之后就构建一个task_t传递给线程池即可,这里可以使用c++中的bind函数,将Server函数绑定给task_t类型的函数。
需要注意一下我之前重载过Server任务,这里就会出现不知道绑定的哪一个版本的Server的问题,所以写到这里的时候需要将之前重载的Server给注释了。
然后测试运行一下:
这样就完成了。
但是这个代码还是存在问题的,是可能存在客户端无法链接的问题的。原因也很简单,这里是一个线程池,而线程数量是固定的。而这里提供的服务是一个死循环的服务,这种服务一旦一个线程进入了,就不会退出,这个线程就只能为一个客户端提供服务了。就会导致有的客户端无法获得服务。
而多线程一般很少让任务成为一个长任务长任务的处理(在多路转接会说明)。
也就是目前我们不能让我们的服务器提供长服务。
现在写的这个服务器只能提供基本的IO服务(服务器接收发送消息)。因为协议我还没学习,所以不能增加太复杂的服务
下面我们提供几个服务,要如何处理呢?
为了提供不同的服务我们需要一个哈希表。
然后再提供一个函数类型,这个函数是返回值为void,参数为int(sockfd)和InetAddr类型(客户端的信息)。
然后就是构建业务逻辑了。
这里的业务逻辑就是通过字符串选择不同的callback_t函数。
然后需要增加一个函数用于给func函数增加方法。
然后需要提供一个路由的方法,所谓的路由也就是判断用户需要的是什么服务,既然要了解用户需要的方法,自然要读取用户写的信息了。读取之后拿这个信息和哈希表中的key做比较然后选择正确的服务。
这样就完成了一个功能路由的方法。
现在已经有了一个功能路由的方法,当服务器接收到了客户端之后,线程为客户端现在不提供server服务,而是提供一个功能路由的服务。
当某一个客户端链接到服务器之后,经过路由就能够选择不同的服务了。
然后我们就需要去到服务器的main函数中去给这个服务器注册服务了。
在服务器初始化完成之前加载对应的服务。
需要将服务列表给客户端打印一下。
现在想要使用什么服务就由客户端自己去决定了。
先来修改一下客户端:
客户端需要让用户去选择自己需要的服务。
在选择了服务之后,下面的输入就是针对各个服务的输入了。
我们现在完成最简单的ping服务。
现在先测试一下所以这个ping服务我们就将之前的server代码拷贝过去。
测试结果:
成功了。然后就是其它的服务了。
现在这个服务器要提供什么服务,就由我们自己去编写了。
然后在路由的函数出也增加一个日志信息显示某一个线程选择了什么服务。
然后在每一个服务函数中也打印一下这个信息。
然后再去测试一下:
可以看到这个打印信息。说明现在已经能够跳转到各个服务函数了。
线程池ping服务编写
下面我们来实现第一个服务ping服务,这个ping服务其实就是一个IO服务。只不过当客户端选择了这个服务之后,服务端线程只会运行一次这个服务就会退出,不再是一个死循环式的服务了。
然后就是无论你客户端给服务端输入什么,服务端最后返回的都是一个pong。
然后对于路由函数也有需要修改的东西。当某一个线程执行完某一个客户端的任务之后也就代表着这个sockfd不再使用了,那么就可以关闭这个sockfd了。
所以在Routinue函数最后需要关闭sockfd。
运行截图:
可以看到当客户端选择了ping服务之后,除了第1次输入,服务器返回了一个pong,第二次输入之后客户端直接崩溃了。崩溃的原因也很简单,这里的ping服务只是一个短服务,也就意味着当服务器返回了pong之后,就直接将sockfd关闭了。当客户端再次输入信息的时候因为在向一个关闭的sockfd中发送信息就直接被os杀死了。
那么这个ping有什么用呢?这里需要知道的是未来的服务都是部署在云服务器上的,那么我如何知道我的服务在未来的某一个时刻是否是健康的呢?
那么这里我们就可以定期(30s)向我的服务器发送最小服务请求,如果得到了恢复,说明我们的服务是正常的。这种机制我们称之为心跳机制。
而这里的ping函数也就是对心跳机制的响应。
但是这样写的ping服务有一点点的重复。这里我们可以封装一个interact函数(交互函数),这个函数的功能就是将一个信息通过sockfd发送到某一个客户端。这里我们就可以认为是完成了一次交互。
然后我们来写下一个服务
线程池translate服务编写
首先明确这个服务的功能是什么这个服务的功能也就是你输入一个英语单词,而服务器将这个单词的汉语返回过来。
对于translate服务,有一种极其简单的方法,所谓的单词翻译无非就是你输入字符串,然后服务器直接返回一个新的字符串,那么可以选择使用一个unordered_map将将某一个单词作为key而汉语意思作为value。放到map中,然后通过客户端发送的英语返回value即可。但是这种方法储存的单词数量很少,map只能储存在内存中。而这里就选择了另外一种方法,将单词翻译作为一个简单的业务去写。也就是将这个业务和网络代码做一个解耦。
然后在这个构造函数中可以选择直接插入一些单词的信息到dict中,但是这里我们不这么做了。这样写就相当于将这个dict硬编码了,并且这样储存的单词数量也是很少的。
这里我们可以选择通过读取文件的方式来进行。
首先准备一个文本文档作为dict中信息的来源,然后运行的时候将这个文档的信息读取到_dict中即可。
这里我就准备了一个简单的单词表:
然后这里我们可以看到一个单词是具有单词本体,单词的音标,单词的词性。而单词的翻译也就是找到对应的单词本体然后返回单词的中文。也就是说这就是对单词的管理,既然是管理那么就可以使用先描述再组织。将单词写成一个类。然后在映射的时候使用单词和word之间进行映射。
这里为了简便一些我就不这么写了。
这里就直接客户端输入一个单词我这边把含义全部给你。
下面我们就需要去写一下加载这个txt文档的函数了。
对于读取到的信息,因为这里我的txt文档是一行一行的,所以先使用一个vector<string>储存起来。然后再对这些文档做字符串分析。
下面来测试一下读取是否成功了
注意这里我将.hpp修改为了.cc,并且将#pragma once去掉了,再测试完毕之后我会将其修改回来的。
测试结果证明读取文件成功了。
现在已经能够做到将文件读取出来了,但是我们需要的是翻译啊。
为了能够进行正确的返回这里将文件中的单词按照第一个空格作为分隔符,分成左半部分和右半部分。左半部分作为键值,右半部分作为value值。也就是对读取到的信息进行字符串分析。
当然这里也是可能存在问题的,例如如果某一个单词的空格是出现在了单词和英标的后面,或者再单词内部出现了空格的情况,这里我就不处理了,默认的就是符合规则的。当然你要处理也很好处理,无非就是读取字符串,然后对读取的字符串进行判断和修改的工作,例如如果是单词内部出现了空格你就使用一个指针如果某一个英文字符的后面出现了空格,但是这个空格后面又出现了一个英文字符则直接删除这个空格,多个空格也是一样的处理,这些情况都是需要进行处理的,但是这里我就直接默认没有错误的情况了。对于这些情况的处理可以写一个预处理函数,对数组中的每一个字符串先进行一次预处理。
这里我没有写,而是直接写了这个字符串处理函数。
这样每一个单词的意思就按照英文本体和中文被放到词典中了。
下面再测试一下词典中是否存在这些内容了,将这个函数放到构造函数中,然后调用debug打印一下
运行截图:
那么现在就能够使用这个类去向外提供服务了。
之后你想要加词的话,直接向这个文件中加就可以了。
我们还可以添加一个热加载的功能,这个热加载功能就是当这个类收到某个信号之后,回去进行加载文件和文件分析的工作,甚至于可以直接删除这个对象,重新创建一个对象,都是可行的方法。而这也是一种基于信号的热加载的方法。
下面我们将日志添加到这个类中,然后这个类就可以向外部提供服务了。
然后我们就可以去完成服务端的翻译函数了。
这样翻译的服务就完成了。
下面测试一下:
翻译服务就完成了。
写这个服务就是为了表示,现在业务已经能够拆出来了,那么就可以写一个聊天室服务,五子棋服务等都是可以写的。业务和网络代码的分离也就可以做到这一点,同时子复杂一点,我们现在写的ping服务也可以是一个登录服务,translate是一个注册服务等等。这些服务都是可以通过注册的方式写到这个服务器上的。
回到这个代码上,到这里还有一个小问题,再客户端连接的时候,服务端就向客户端发送信息表示能够提供的服务是什么。所以我们需要让服务端给每一个连接上的客户端发送服务列表。在server端的start函数中
然后修改一下客户端让其能够接收到这个信息,再去选择服务。
然后就是下面的translate和default服务。
线程池中的transform服务
这个服务也就是将你输入的字符串中的所有小写英文字符修改为大小字符。
这个服务就很简单了,我也就不和translate服务一样去写了。
最后是测试的截图:
线程池中的default服务
还有一个default服务,这个default服务我打算写成一个打印当前服务器中提供什么服务的函数。也就是将上面我们写的那个打印服务器列表的函数封装到这个服务中。也因此这个函数也就不打算暴露给外部了。而是将这个服务的实现放到TcpServer类中去。
然后这里我将之前在初始化函数那里的信息传递删除了。而是将这个信息传递写到了,路由函数中。当线程执行到到这里的时候会先向客户端发送服务列表的信息。
然后在服务器的初始化函数这里就将这个函数直接插入到funcs中去
然后这个服务器现在提供的是一次性的短服务,所以客户端也不应该一直在任务窗口而是在完成一次任务后就直接结束。
如果想要修改为客户端一直不退出的话,就需要让服务端的服务线程在路由函数一直不退出,这样就能让服务器一直为客户端提供这些服务。直到某一次线程收到客户端的退出信息再关闭sockfd再让线程退出。但是这里我就不修改了。
将客户端任务窗口的死循环删除,再将错误信息修改后:
主要就是将死循环删除。
运行测试:
测试完成之后我们的这个简单项目就已经完成了。
小节总结
通过上面的代码我们已经知道了客户端和服务端能够使用read和write从网络中获取信息。由此我们就能得到第一个信息:
IO类的函数,write/read在底层已经做了转网络序列的工作
下一个信息和UDP和TCP协议的特点有关。
首先UDP是用于数据报的,而TCP则是面向字节流的。那么这个用于数据报和面向字节流有什么不同呢?
到目前为止,这两个东西在编码上的区别是很小的,但是从底层上来说这两个协议的实现是具有很大的不同的。
这里我们在编码上的区别很小是因为TCP代码中,我们在编写IO代码的时候,尤其是网络IO的时候,使用过的read/write,目前的代码都是存在bug的。在服务端读取客户端的信息的时候,服务端向客户端发送信息的时候,都是存在bug的。在UDP中这一份代码是没有问题的,但是在TCP代码中是存在问题的。要解决这个问题,需要使用协议。但是目前无法解决。
现在回到UDP和TCP的特点上:UDP是面向数据报的也就意味着:在UDP中数据和数据之间是存在边界的。什么意思呢?在UDP协议中使用的接口就是:sendto和recvfrom接口,
这就意味着当我们在客户端sendto了一次信息之后,(不考虑数据丢失的情况),那么服务端一定会进行一个recvfrom,反过来也是一样的。
拿现实的例子说明,当一个人给你发送了5个包裹的时候,你一定会接收到5个包裹。并且包裹和包裹之间区别是非常明确的。回到UDP也就是报文之间是相互独立的。
而现在使用的TCP则是面向字节流的。也就是说在TCP这里write了不止一次(10次),而在read那里可以使用一次直接接收完成,也可以使用多次接收完成。这个接收和发方式没有联系的。
这个东西也就是面向字节流。
拿之前学习过的管道说明,写端可以往管道中写无数次的信息,而读端可能只使用一次读取就将这些信息全部读取上来了,而这也就是面向字节流。
这就有可能造成,有一方每写一次都写了一个完整的报文,但是另外一方就是不读,而另外一方就是不读。而是一直积压在管道中。而在写了10次之后,再一次性全部读取。这也就意味着某一方不能一次性处理所有的信息,而是需要一个报文一个报文的处理。由此就导致了数据的解析工作要有用户层来做。这也就是面向字节流。
使用现实举例子,UDP就相当于发邮件寄信。然后你收信的时候,无论你是一次性收取所有的信还是一封一封的拿取。每封信之间的区别是很明显的。
在UDP当中也就是用户将报文拿取上来之后,是不需要判断这个报文和下一个报文之间是否是存在关系的。每一个报文之间都是单独存在的。这是UDP。
但是TCP不一样。TCP是面向字节流的。
就拿接自来水为例子,在家中使用自来水的时候,我是不关心这个自来水来到我家的时候,是自来水厂压送了几次的。我只关心我打开水龙头之后有水,然后我使用杯子接水还是使用桶接水都是由自己决定的。
而我现在使用的水很可能就是自来水厂压送了10次才送上来的。也可能我接了1000次,10000次水而自来水厂只压送了1次就送上来了。
此时发送方和接收方是没有明确的联系的。
自来水在管道中都是水流,而到了我家之后要怎么使用都由我自己决定。
假设接了一杯水就相当于将一个报文读取上来了,之后还有多少个报文我是不知道的,我必须边接边处理。这种特点也就是面向字节流。
正如在进行文件操作的时候,往文件中写是最容易的。 但是从文件中读取的时候是恶心的
因为可能在写的时候,我是按照一个单词一个单词的写的,所以在读取的时候,我也希望一个一个读取单词。但是在写的时候我一行写了10多个单词(使用空格隔开)。由此就导致了我在读取的时候,要么直接将整个文件读取上来然后去寻找空格,要么就是一个字符一个字符的读取。直到遇到空格。由此文件本身也叫做字节流。这个也就是TCP的特点
到目前为止,上面写的代码都没有对字节流进行处理。就拿大小写转换的服务为例子,在这个例子中我们定义了一个1024字节的缓冲区大小,但是我怎么就确定用户输入的信息一定是1024字节大小的呢?如果用户传入了一篇文章这个文章是10000字节呢?
这样我是无法证明服务端将客户端的信息读取完成的。
而在TCP中要正确的处理信息的读取,必须要结合用户协议。
也就是要完成自定义的协议之后才能正确的读取。
就拿刚才的以空格为分隔符输入单词为例子,当在读取的时候以空格为分隔一个一个读取单词。这就是在文件和进程之间建立了一个协议。这个协议就是读取信息的一方如何判断一个单词呢?就是通过当读取到一个空格的时候就能够认为前面读取的信息就是一个单词了。
由此也就能够知道了TCP的代码是更难写的。
这里先暂时理解到这里。之后再去说明。
现在回到我们的代码上,难道当我们启动了服务器之后,我们的服务器就要一直以前台进程的方式在前台运行吗?当然不是这种模式最多在Debug的时候使用,真正的服务器(我们刚刚写的那种软件),必须在Linux后台,以守护进程(精灵进程)的方式进行运行。
那么什么是守护进程呢?
守护进程
首先守护进程并不算是网络的概念,这是一个系统的概念。
为了理解这个守护进程需要知道在进程中其实不仅存在进程的概念,还有进程组,作业,会话这样的概念。
下面我们来复习一下之前所说的前台和后台的概念:
这里我们先启动两个bash,然后在一个bash中使用sleep10000。
然后我们在另外一个bash中就能查询到我们刚刚启动的进程
这个进程的id是576 ppid为547。
当我们重新启动了一个sleep之后,pid变了,但是ppid依旧是不变的
下面我们来认识一下其它的id:
上图中的PGID就是进程组ID,SID也就是会话ID
而TPGID一般都是和PGID一样的。TTY是当前打开的终端设备是谁
stat表示当前进程的状态,而UID表示当前用户的身份,我这个用户在系统中的编号就是1000.
因为现在我只启动了一个sleep 10000的进程,所以这个进程的PID就等于它的组ID,所以当前这个进程就是自成进程组。但是无论怎么样,这个进程组一定是属于当前的会话的。(也即是这个进程组是在这个会话当中的) 。
下面我们再启动一批进程,然后再去看一下
通过管道我们知道这三个线程都是兄弟线程。
此时这三个进程的父进程都是同一个所以这三个进程是兄弟关系,所以能够使用匿名管道通信。然后我们还能看到这三个进程的PGID也是一样的。此时这三个进程就是一个进程组。
而这个组ID一般是多个进程中第一个启动的进程的PID。
然后PPID也就是bash的id。
然后我们就能够知道了:
当我们登录Linux的时候os会给用户提供bash和终端,用于给用户提供命令行解析服务,这两个事物结合起来就是一个会话,而在os中我们能够启动多个终端,所以就存在多个会话,所以os需要管理会话,如何管理先描述在组织。也就是使用一个结构体描述会话这个结构体中存在bash和终端的信息。而在命令行中启动的所有的进程都是默认在当前会话内部的一个进程组(单个进程可以自成进程组)。
使用一个图像表示一下:
然后我们能够得到一个信息:任何时刻在一个会话内部,可以存在多个进程组(用户级任务),但是默认任何时刻,只允许一个进程组在前台。
这也是为什么,当我们在启动了sleep 1000之后,在向终端中输入命令这个命令就没有作用了。因为bash也是一个进程组。那么什么是前台呢?
前台也就是和终端/键盘相关,可以接收IO的。所以当bash成为后台之后自然就不能为用户提供命令行解释的服务了
当这么启动就是将进程放到后台:
通过下面的jobs指令能够查看后台进程
如果我想将这个进程放到前台,使用fg 1(后台进程的编号使用jobs可以看到)。
如果现在sleep 1000已经是前台了使用ctrl+z能够自动将这个进程变成后台的进程
当sleep变成后台之后,前台必须存在一个进程,所以当sleep变成后台之后,bash自己就回来了。
jobs---查看后台进程,fg <task_number>将后台变成前台,ctrl+z将前台变成后台。bg<task_number> 也是一样的.
那么这些和进程组有什么关系呢?
首先我们来认识一下什么是用户级任务什么优势进程组。
进程组是一个技术方面的表述,任务是一个用户级的概念,这两个其实一体的。
任务是由用户提出来的,而进程组就是用来完成这些任务的。
这里我们已经知道了每当一个用户登录的时候,都会创建一个会话然后在这个会话中创建bash,以及其它的进程组,当再次登录的时候又会创建一个会话,然后创建bash和其它的进程组。由此我们就知道了会话和会话之间是具有隔离性的。
当我们登录了一个用户之后,就相当于创建了一个会话,然后在这个会话中启动了我们的服务。
当我们启动我们的tcp_server的时候,我们很容易就知道这个服务是启动在当前的会话当中的,这是单个进程所以自成了进程组。
因为这个服务是在用户登录创建的这个会话当中运行的,这也就意味着当用户退出的时候这个服务自然也就停止了。相当于用户退出的时候,这个会话当中的所有东西都会被释放,释放也就意味着,这个服务也就会停止。当然不同的系统对于这一行为的处理方式也是不同的,关键在于我启动的某一个服务是受到某一个用户的启动和关闭的影响的。
所以最后我们想让我们的服务器不受到用户登录和注销的影响。
这也就意味着我们要将我们的服务进程变成一个守护进程。
而在之前的概念中说的任务其实也就是守护进程概念中的作业。而会话则是我们在登录的时候bash给我们启动的一个会话。
在os中进程之间不止存在父子的关系还有同组的关系。
而守护进程一定是一个独立的会话。不隶属于任何一个bash的会话。
也就是说在之前我们启动这个我们写的这个服务的时候,无论你是在前台启动这个服务还是将其变成后台的执行,这个服务一定是在用户的这个会话当中的,现在我们要做的就是要将这个服务变成一个守护进程,也就是将这个服务变成一个单独的会话。
要怎么处理呢?
使用的接口如下:
当某一个进程调用了这个系统调用之后就会创建一个单独的会话。并且让这个进程成为这个会话的话首进程。
所以要让我们的这个服务变成守护进程必须要调用这个接口,但是要调用这个接口是存在要求的。
首先介绍一下这个接口的返回值,这个接口在调用成功的时候会返回这个调用成功的进程的pid。如果调用失败返回-1,并且错误码被设置。
所以要求调用这个接口的进程的PID PGID SID都是一样的
还有一个要求:
如果你要使用这个接口创建一个新的会话的话,这个进程不能是进程组中的组长。
因为我们不能是一个组长才能去调用这个接口,所以我们就需要要知道组长是谁。
组长一般都是多个进程中的第一个。
而我们今天写的这个服务,只有一个进程自成进程组,并且自己一定是组长,所以我们一般要创建子进程,让父进程直接退出。此时如果是子进程去执行后面的代码,那么子进程就不是组长了,所以就能够去调用setsid函数了。
所以要让一个进程能够调用这个接口必须要让这个进程不成为组长。
到这里守护进程的理论知识就完成了。
因为要调用上面的接口才能成为守护进程,而要成为守护进程一定需要满足自己不是组长的条件,而要满足这个条件就需要让子进程去执行后面的任务,而让父进程直接退出。由此我们就能知道了:守护进程一定是孤儿进程。所以守护进程的父进程一般都是1,也就是系统。
下面我们就来编写代码:
在编写这个代码的时候
第一步:忽略信号
我们需要忽略可能影响进程异常退出的信号。要忽略的信号由你的应用场景决定。
第二步:不要让自己成为组长
也就是创建进程然后直接让父进程直接退出。
这样这个进程就会被系统接收成为孤儿进程。
第三步就是设置让自己成为一个新的会话(setsid),此时的代码就是子进程再走
然后是第四步:
这里我们需要知道的是每一个进程都有自己的CWD(当前工作路径)守护进程也是有一个策略为是否将当前进程的CWD更改为/根目录。
为什么要将这个CWD变成根目录呢?因为修改之后未来这个服务就能够从根目录开始以绝对路径的方式找到Linux下的和这个服务相关的所有的日志文件,配置文件或者是资源文件。不然就要以当前路径开始寻找这个文件了。
修改路径可以使用chdir接口
然后是第五步
到达第五步的时候进程已经变成守护进程了,也就是已经是一个独立的会话了。也就不需要和用户的输入输出,错误进行关联了。
当然可以选择close(0),close(1),close(2)这样的代码去关闭。但是这种方案不友好。因为在主代码中可能真的存在printf/scanf这样的函数,此时向一个已经关闭了的文件描述符中写信息,就会直接异常让os直接将这个进程kill了。所以一般不使用这样的代码。
这里推荐的方式和一个文件有关这个文件在下面的路径:
任何一款Linux系统都会提供一个这样的字符设备。凡是往这个字符设备写的任何东西,全部自动会被丢弃。凡是想从这个文件中读取的进程,自动读到文件的结尾。也即是这个字符文件的特点就是抛弃一切,所以比较好的做法就是:
打开这个文件,然后dup替换012为这个文件,最后关闭fd即可。
这个dup2也就是将fd文件描述符中的内容覆盖拷贝到文件描述符表中012的内容就完成了重定向。到这里也就完成了守护进程化的最重要的5个步骤就完成了。当然也还有其它的方法去完成守护进程化,但是最重要的五个步骤就是上面说的这5个。
当然这里你也可以提供一个方式,这种方式就是直接关闭012,而不是进行重定向。
下面我们来测试一下:
需要注意守护进程化的形成的进程的名字一般都以d为结尾。、
当启动之后我们是感受不到的,但是使用ps去查看可以看到我们刚刚的服务进程,确实已经是一个孤儿进程了
同时pid pgid和sid确实也是一样的。并且当我重新登录的时候这个服务还是存在的。
至于如何关闭这个进程呢?直接使用kill -9指令即可。
现在如果我将这个代码修改为要改变CWD,再去编译一下查看是否修改成功呢?
可以看到这个守护线程的cwd确实被修改为了/目录。
那么系统有没有提供将进程守护进程化的方法呢?
我们刚刚写的TCPserver和UDPserver要如何守护进程化呢?
提供的接口:
至于这两个参数自然就是是否选择修改根目录,和是否重定向到/dev/null文件上。
这里将012重定向到null文件上是最好的选择。
下面来将我的服务守护进程化。
这里推荐守护进程放在创建服务器之前
然后就来测试一下:
当我启动服务器之后,可以看到我的bash依旧是在运行的。
同时这也是为什么我们写的这个日志要具有往文件中打印的能力。
这样就完成了将我们的服务守护进程化。并且这个进程也能正常的运行了。
并且日志也有了。
此时就算我将bash退出了这个服务也一直会在运行。
这也是为什么要存在云服务。有了云服务(24小时不关机),才能一直提供服务。
希望这篇文章能对您有所帮助,非常感谢您的阅读,如果发现了任何的错误欢迎指出,写的不好请见谅。如果您需要源码,请私信我。