目录
1 ip和端口号
要使用网络必须要有ip地址,那么就注定了通过网络发送的数据包中一定会包含 源ip 和 目的ip 。
但是有了IP就能直接通信了吗?中间可能还需要经过很多的路由器来进行数据的转发,但是这样也只是能够将数据发送到目的ip的主机上,并没有将数据交给具体的应用进程,但是就算我们将数据不断往上层协议传,最终在应用层将数据分离出来之后,我们该将数据交给哪个进程呢?
也就是说,目前我们能够使用ip(公网)来标识唯一一台主机。
那么数据包中的源ip和目的ip就能标识网络通信的主机,但是怎么标识这两台主机上通信的进程呢?
结论:为了更好地标识一台主机上服务进程的唯一性,我们采用 ip + 端口号port ,标识服务器进程和客户端进程的唯一性。
端口号是传输层协议的内容,可以供上层使用,利用系统调用,因为传输层和网络层都是在操作系统内的,可以提供给上层提供系统调用来供上层操作。 那么我们让应用进程和端口号关联,就能标识其唯一性。
端口号就是一个 2 字节的整数,uint16_t ,或者说 unsigned short
其实网络通信的本质就是两个进程之间在通信,不过这两个进程不在同一台主机,他们通过网络资源进行通信,而我们的通信过程其实就是在做IO,将数据发到网络中,以及从网络中拿数据。
目前的结论就是: ip+端口号标定全网唯一的一个进程。
通信的两个进程的端口号可以一样吗? 如果是不同的主机上的进程,是可以一样的。当然如果在同一台主机上,那端口号就必须不同。 一台主机上的一个端口号只能绑定一个进程,为了保证进程的唯一性。
但是一个进程可以绑定多个端口号。
那么我们又有一个问题了,在学习系统时,进程的唯一标识是 pid ,为什么还要搞一个端口号来标识一台确定主机的唯一进程呢?直接用pid不好吗?从技术上来说,如果想要这样做,肯定是能够做到的。
使用port的三个原因:
1 系统是系统,网络是网络,我们要完成它们之间的解耦。如果网络中标识进程也用pid的话,那么网络就和系统强耦合了,如果某一天系统中的这部分设计发生变化,那么就会牵连网络也要跟着做出调整。而单独设计出一个 port 来标识网络通信中的进程,让系统与网络之间解耦,就算系统的进程的内容如何变,也不影响网络的部分。
2 一般我们使用网络通信是通过客户端来向服务器发送请求,那么就需要客户端每次都能够找到服务器进程,也就是客户端需要知道服务器进程的 ip 和 port ,那么未来我们就需要保证服务器进程的ip和port不能轻易改变,如果改变,就会影响客户端进程的请求的递达,服务器的ip和端口号一般是固定的,编码在客户端的。使用 port 的话,我们就能通过代码,来保证服务器每一次启动都是使用该 port(不考虑被占用的情况) ,而如果使用 pid 的话,服务器进程的每一次启动他的pid我们是不能保证不变的。
3 不是所有的进程都需要进行网络通信的 port ,但是所有的进程都需要 pid 。
那么也就意味着,如果一个进程和端口号绑定了,他大概率是一个网络服务的进程。
操作系统是如何根据端口号找到指定的进程的呢? 也就是如何根据一个 uint16_t 找到一个task_struct,很简单, 维护一个类似哈希表的结构来建立映射关系就好了。
所以,我们通过网络发送的数据包,除了有源ip目的ip,源mac,目的mac之外,还要有源端口号和目的端口号。
那么在客户端向服务器发送请求时,需不需要将自己的 ip 和 port 发送给服务器呢?
当然需要,客户端发送请求给服务器之后,服务器执行对应的请求最终会有一个结果或者进行一些操作,最终要返回给 客户端的,那么就注定了服务器需要知道发送给请求的客户端的ip和端口号,当然这部分内容最终会以协议的报头的形式呈现。
2 tcp/udp
在这里简单认识一下tcp协议和udp协议的特点。
tcp(传输控制协议)的认识:
1 是传输层协议
2 有连接
3 可靠传输
4 面向字节流
有连接指的是在正式通信之前,通信双方需要先建立连接。
字节流怎么理解呢?流就是有序的连续的数据,而字节流我们可以理解为以字节为单位的有序连续数据。
udp(用户数据报协议)的认识:
1 是传输层协议
2 无连接
3 不可靠传输
4 面向数据报
可靠与不可靠描述的是一种特征,并不是说可靠就一定是好的,我们还要综合考虑效率和成本的问题,需要那种要看具体场景而定。
他是直接将应用层协议传过来的数据包添加报头后发送给下一层,而不会进行拆分或者合并。而tcp则可能会进行拆分或者合并再发送给网络层。
3 网络字节序
多字节的数据在内存中的存储是有一定的顺序的,比如小端机,高权值的字节放在低地址处,低权值的字节放在高地址处。 大端机则是高权值的字节放在高地址处,低权值的字节放在底地址处。
那么就有一个问题了?如果一个大端机和一个小端机进行通信,他们是怎么知道对方的存储结构呢?
这个问题在网络设计的时候就已经考虑到了,他的解决方案很简单,规定网络中的数据都是大端。
数据发送的时候,地址从低往高依次发出。
而数据接收的时候,是按照接收的顺序,依次将拿到的字节从低地址到高地址保存到内存中。
那么数据发送的时候字节在内存中的顺序是怎么样,那么收到数据的主机中也是按该顺序存储的。
而当我们规定了网络中的数据都是大端的时候,如果发送方主机是大端机,那么就不需要调整,可以直接发出去,而如果是小端机,那么就需要先将数据从小端转换为大端之后再发送到网络中。与此同时,接收消息的主机收到的都是大端的数据,那么如果该主机是大端机,那么就不需要任何转换,而如果是小端机,那么就需要将拿到的大端的数据转换为小端数据。
那么我们需要自己完成这些操作吗?我们需要知道自己的机器是小端还是大端吗?不需要,因为这些功能在网络中肯定会经常用到,为了方便以及保证正确率,我们的操作系统为我们提供了一套相关的接口。
两套接口,对应四字节和两字节的整数,函数名种 h 表示的是 host ,也就是主机,而 n 表示 net 网络,hton 就是主机序列转换为网络序列, ntoh 就是网络序列转主机序列,而最后的一个字符表示就是数据的大小或者类型, s 表示 short ,2 字节的整数, l 表示 long int ,四字节的整数。
他们的底层回去判断当前的环境是大端还是小端,hton 时如果是大端,也就不需要进行转换,将参数原封不动返回,如果是小端,那么就会将转换为大端之后的结果返回,ntoh 也是类似。
不过我们不需要关心他们的底层,我们未来的编码都是在应用层的。
4 网络套接字
常用的套接字有三种: 原始套接字,网络套接字和unix域间套接字。
网络套接字主要用于网络通信,也能用于本地通信,而域间套接字则只能用于本地通信,功能类似于命名管道,原始套接字而原始套接字则很复杂,我们一般用不上,所以我们也不做讲解。
那么按照道路来说,我们就需要设计三套接口来适应这三个套接字的功能,但是由于它们的功能都是类似的,用于通信,所以最终操作系统只给我们提供了一套接口,通过传参的不同来解决网络或者其他场景下的通信问题。
五个接口(先了解):
1 创建 socket 套接字、文件描述符
int socket ( int domain , int type , int protocol )
2 套接字绑定端口
int bind ( int socket , const struct sockaddr * address , socklen_t address_len )
3 监听socket(tcp服务器)
int listen ( int socket , int backlog )
4 接收连接请求(tcp服务器)
int accept ( int socket , struct sockaddr * address , socklen_t * address_len)
5 建立连接
int connect ( int sockfd , const struct sockaddr * addr , socklen_t addrlen )
我们发现这几个接口中有两个参数类型我们没见过, 一个是struct sockaddr ,一个是 socklen_t ,socklen_t 好理解就是长度,谁的长度呢?不就是前面的结构体指针指向的对象的长度吗?那么为什么一个确定的参数类型还要传他的长度呢?
我们的每一个套接字都有自己的结构体,来保存完成他的功能所需要的信息,我们就拿网络套接字和unix套接字来举例,他们的结构体的类型分别是 struct sockddr_in 和 struct sockaddr_un ,in和un分别表示 inet 和 unix ,他们的结构如图:
地址类型其实就是我们创建的套接字的功能,他们也就是结构体中保存的地址的类型,是网络地址(ip+port)还是文件的目录地址,而他们有一个共同点就是前十六位都是十六位类型,那么从这是六个比特位就能的值他们的整体的结构体的类型。
所以虽然我们看着函数中的参数类型都是 struct sockaddr* ,但是他们实际上是 struct sockaddr_in*和struct sock sockaddr_un* 强制转换而来,实际上在函数内部会取出前十六个位来判断具体是哪个类型,然后进行强制转换再使用。
所以不管是什么类型的套接字,他们的结构体都能通过转换成 struct sockaddr* 来传参,在函数中再将其转换为所需的或者说实际的类型。所以我们才能只用一套接口就实现三种类型的套接字的功能。
那么为什么不直接用 void* 来传参呢?因为涉及这一批接口时,C语言标准还不支持 void* ,网络设计是很早很早的。
而我们要学习的网络通信,要用到的就是 struct sockaddr_in 结构体,他最重要的内容就是端口号和ip。
5 udp demo程序
封装一个简单的udp的服务端和客户端,实现基本的通信功能。
我们将其都封装成类
首先我们关注服务器程序:
服务器的类需要的成员有哪些?目前我们知道的,必要的肯定有 ip ,端口号 和 socket ,因为构造的时候我们需要传入ip和端口号,而后再进行服务器的初始化的时候需要构造出一个套接字并保存起来。
要使用socket套接字,必须包含头文件
#include<iostream>
#include<sys/types.h>
#include<sys/socket.h>
#include<string>
namespace udpsever
{
using namespace std;
#define defaultip "0.0.0.0"
class sever
{
sever(uint16_t port , const string& ip = defaultip)
:_port(port),_ip(ip),_sock(-1)
{}
public:
private:
uint16_t _port;
string _ip; //我们接受点分十进制的字符串类型的ip
int _sock;
};
}
然后我们需要提供一个初始化的方法,来创建套接字以及为套接字绑定端口和ip地址。
首先是创建套接字
socket
int socket ( int domain , int type , int protocol )
socket 函数的功能是创建一个通信的端点并返回一个描述符。
这里的描述符其实本质上和文件描述符是一个东西,都是文件描述符表的下标。
为什么套接字返回的是文件描述符呢?我们说过Linux下一切皆文件,而网络通信的本质就是使用一个和网卡关联起来的文件,当网卡收到发送给他的消息之后,就写到该文件中。
本质上socket和open没什么区别,都是打开文件,文件中也都有对应的文件指针,我们的显示器,键盘等都可以用文件的形式来操作,那么网卡当然也能。网络通信使用的接口大部分就是操作系统的文件管理模块的操作。
理解套接字的含义之后,我们再来看该函数所需的参数。
首先,第一个是 domain ,domain被称为域,其实简单理解就是我们创建该套接字是用来本地通信还是网络通信 或者 说套接字所用到的协议家族?或者说要创建那种套接字,它有以下这些选项
我们常用的就两个: AF_INET ,就是创建网络套接字,AF_UNIX就是创建unix域间套接字。而我们这里学习的是网络,基本都是用AF_INET。
第二个参数是 type ,type就是套接字提供的功能的类型,比如说面向字节流的通信或者面向数据报的通信,它有以下的选项
常用的就是 SOCK_DGRAM(数据报服务) 和 SOCK_STREAM(有序,可靠,全双工,有连接的字节流服务) ,其实就是对应 udp 和 tcp
第三个参数则是指明该套接字要用的具体的协议
一般我们不需要指明协议,我们前面传的两个参数确定了,那么就能确定一个默认的协议,那么我们可以直接传 0 ,表示直接用默认的协议。
那么我们就可以将套接字创建出来了。
_sock = socket(AF_INET,SOCK_DGRAM,0);
//判断是否创建成功
if(_sock==-1)
{
cerr<<"socket error"<<endl;
exit(SOCKET_ERR);
}
为了表示我们的错误的类型,我们的退出码统一使用枚举来列出来,后续我们的exit中传的退出码都是我们自己定义的退出码。
上面的创建套接字,其实只是相当于创建了一个可以用于基于ipv4的 udp 通信的文件,他的重点是在系统中创建一个文件,但是我们并没有为其关联 ip 和端口号,我们必须要有这两个东西才能标识唯一性。
所以我们需要给套接字 绑定 端口号 和 ip(暂定) ,而这里要用到的接口就是:
bind
int bind(int sockfd, const struct sockaddr *addr,socklen_t addrlen);
bind用于为一个套接字起名字,其实就是让网络通信时能够找到该文件将数据写到该文件中,也就是标识他的唯一性。
它的第一个参数就是要绑定的套接字,第二个参数是sockaddr结构体,也就是类似于地址的标识唯一性的结构体,我们在网络套接字中传的就是 sockaddr_in ,第三个参数就是我们传的结构体的大小。
那么这里的重点就是如何定义或者填充我们的 sockaddr_in 结构体,我们可以转到定义看一下它的成员。 要注意的,要使用sockaddr_in 就必须要包含头文件 arpa/inet.h
它的成员如图:
重点就是三个成员,第一个字段其实就是利用宏将我们传进来的参数和family合并成一个新的标识符,定义为 sa_family_t 类型的成员变量,其实这个字段就是在指明协议家族,对应的就是结构体中的 16 位的地址类型,比如 AF_INET / AF_UNIX ,我们套接字是什么协议家族,他就传什么协议家族。如果传的和套接字的协议家族不一致,bind会报错。
第二个成员就是我们的端口号
第三个成员则是一个结构体,该结构体中只有一个成员,就是 s_addr ,也就是一个uint32_t 的整型,其实就是 ip 的大小。但是我们这里的 ip 是一个整形来表示,而我们常用的ip的表示是点分十进制的字符换来表示,因为可读性更好,所以我们需要对其做转换。而要转换其实也不难,可以利用strstr来切割字符串然后乘上他们的权重,转换成整型。
但是我们要注意的是,我们说过,未来发送数据的时候,要将端口号和ip 一并发出去,这是为了双向通信,让对方也能找到我们,那么既然端口号和ip也要被对方得到,那么他们也需要转换为网络序列。
而端口号是一个16位的短整型,我们使用 htons 就能转换出来 ,我们的字符串形式的ip怎么转换为呢?首先要将其转换为 4 字节的整型,再转为大端序列。
不过我们也不需要操心,库里面提供了相应的方法,
inet_addr系列
我们主要用的是第二个和第四个,分别用于设置ip和提取出点分十进制的ip。
同时由于我们的struct sockaddr_in 里面有 8 个字节的填充内容,为了避免出现随机值影响,我们可以将整个结构体初始化以下,每个字节都初始化为0,我们可以使用 memset ,也可以使用 bzero
他其实就是 memset的功能,只不过设置的值是确定的,0 ,而memset是需要我们自己指定设置的值。
那么 init 初始化服务器的整体代码如下:
void init()
{
_sock = socket(AF_INET,SOCK_DGRAM,0);
//判断是否创建成功
if(_sock==-1)
{
cerr<<"socket error"<<endl;
exit(SOCKET_ERR);
}
struct sockaddr_in addr;
//由于struct sockaddr_in中有8字节的填充,避免出现随机值影响结果,我们要先将所有的字节设为0
bzero(&addr,sizeof addr);
addr.sin_family=AF_INET;
addr.sin_port = htons(_port);
addr.sin_addr.s_addr = inet_addr(_ip.c_str());
//绑定
int n = bind(_sock,(struct sockaddr*)&addr,sizeof addr);
//成功返回0,失败返回-1
if(n==-1)
{
cerr<<"bind failed"<<endl;
exit(BIND_ERR);
}
(void)n;
}
我们可以先来测试以下我们的服务器能否初始化。
我们未来要如何启动我们的服务器呢?可以通过命令行参数在启动的时候指定我们要绑定的ip和端口号,这样做的优势就是未来我们需要修改 ip 和端口号的时候,我们不需要修改代码的任何逻辑,只需要在启动的时候命令行变一下就行就行了。
#include"udpsever.hpp"
using namespace std;
using namespace udpsever;
//使用手册
void Usage()
{
cout<<endl<<"Usage:"<<endl<< "./sever ip port"<<endl;
}
int main(int argc ,char* argv[])
{
// ./sever ip port
if(argc!=3)
{
Usage();
exit(USAGE_ERR);
}
uint16_t port=(uint16_t)atoi(argv[1]);
string ip = argv[2];
sever sv(port,ip);
sv.init();
return 0;
}
我们测试的时候会发现一些问题:我们命令行启动服务器的时候,ip地址是给哪个?我们的云服务器的公网ip吗?
我们发现绑定时会有报错。具体是什么报错呢?我们可以在bind后面打印一下报错信息。
int n = bind(_sock, (struct sockaddr *)&addr, sizeof addr);
// 成功返回0,失败返回-1
if (n == -1)
{
cerr << "bind failed" << endl;
cout<<strerror(errno)<<endl;
exit(BIND_ERR);
}
(void)n;
他的报错就是无法关联我们给的公网ip。 因为我们的云服务器是虚拟化的服务器,不能直接绑定公网ip,这是由他的安全组规则限制的。哪能不能绑定内网ip呢?
我们使用 ifconfig 能够看到我们云服务器的网络端口的信息
lo就是我们本地环回的ip,如果我们绑定127.0.0.1,那么未来发消息和杜歇息都只会在本主机的协议栈内进行流动,不会到达物理层。这个ip地址一般用于服务器的代码的测试,我们当然可以绑定这个ip。
如何看我们的服务器进程的状态呢?我们可以使用 netstat 命令,如果要查看 udp 的服务,我们可以加上选项 -naup ,n表示num,因为有些字段比如下面的 local address 的ip和端口号,默认不是用数字来显示的, a 表示all, u 表示udp ,p表示process ,就是把所有的开启udp服务的进程都显示出来。
这里我们也能看到有一列内容是 Foreign Address ,他是什么意思呢?他表示的是可以向该服务器进程发送消息的远端的客户端的ip和端口号,而上面的ip是 0.0.0.0 ,表示所有的可以接受所有的ip的消息,端口号为 * ,表示可以接受所有端口号的消息,那么就是可以接受所有的主机和端口的消息。
而 ifconfig 中显示的 eth0 表示 0 号网络端口,我们可以看到一个 ip 地址,那就是我们的内网ip。我们也可以试一下是否能够进行绑定。
还是不能,因为内网ip其实本质还是一层虚拟的ip。同时,如果使用内网ip,那么它就只在本局域网有效,那么就是只接受本地的消息,那么其实和本地环回没什么区别了。
但是有一个问题就是:我们的服务器可能不止一个 ip ,如果我们指明绑定某一个ip,那么就意味着,我们的服务器只接受发送给本主机的 该指定 ip 以及指定端口的消息。那么就会导致我们的发送给本主机的该端口的消息,会由于ip对不上,消息被丢弃。但是真正实际情况是,未来发送到当前主机的我们绑定的端口的消息都需要交给我们的服务器,不能由于绑定具体的ip而漏掉发给其他ip的消息。 怎么说呢?或者说我们的主机有多个 ip ,但是他的端口是绑定我们的服务进程的,只要是发给这个主机的这个端口的消息,就都是发给我们的服务器的消息。 而不是说,只有发送给主机的其中一个指定的ip 的消息才是发给服务进程的。
所有,我们的服务器其实不能够绑定具体的ip,而要接受发送给本主机的所有ip 的消息,真正要指定/绑定的是我们这个唯一主机的端口号。这就叫做任意地址绑定,我们可以绑定 INADDR_ANY,他其实就是0。
那么我们的代码就需要作出修改,主要修改的是我们的调用逻辑,也就是.cpp,我们不再需要从命令行接受 ip ,只需要在命令行指定端口就行了,而服务器绑定的ip就用我们给的缺省值"0.0.0.0",也就是任意地址绑定。
int main(int argc ,char* argv[])
{
// ./sever port
if(argc!=2)
{
Usage();
exit(USAGE_ERR);
}
uint16_t port=(uint16_t)atoi(argv[1]);
sever sv(port);
sv.init();
return 0;
}
那么目前我们的服务器的初始化工作就完成了,接下来就是服务器启动之后的逻辑。
服务器无非就是死循环接收消息和处理消息。
我们先来搞一个简单的 处理逻辑,直接接收到数据之后打印出来,然后在消息的基础上加一些字段再发回给客户端。
要接受消息我们可以使用如下接口
recvfrom
我们服务器收消息的同时,还要拿到客户端的ip和端口号,而要知道对方的ip和端口号,其实就是拿到对方发消息的时候使用的 sockaddr_in 结构体,所以说我们的recvfrom的第四个参数其实是一个输出型参数。 而最后一个参数 addrlen 他是以取地址的形式传进去的,他其实是一个输入输出性参数,做输入的时候,他要充当表示我们传进去的结构体的大小的功能,用于将 struct sockaddr* 转换为我们的实际指向的类型比如 struct sockaddr_in* ,而作输出时则表示填充后的 src_addr 指针实际指向的结构体的大小,用于用户来转换。其实我们不用很关心这个参数,最重要的还是数据 buf 和 客户端的 sockaddr_in ,因为我们双方都使用同一个协议家族通信。
其他的参数也很简单,第一个就是我们要读取的套接字,第二个就是我们要将数据读取到哪个缓冲区,第三个就是最多读取多少个字节的数据,第四个参数就是读取的方式,我们设置为 0 就行,表示阻塞式读取。
而他的返回值就表示读取到的字节个数
而上面的 recv 接口则只能用于建立连接的套接字的接收消息,其实也就是用于有链接的通信。
char buf[1024];
while(1)
{
bzero(buf,1024);
struct sockaddr_in from;
socklen_t fromlen = sizeof from;
int n = recvfrom(_sock,buf,sizeof buf -1,0,(struct sockaddr*)&from,&fromlen);
buf[n]=0; //我们把数据当成字符串来处理,所以最后要留一个位置放 \0
}
而拿到客户端的网络信息之后,我么需要将其提取出来,将网络字节序转换为主机字节序。
string message = buf ;//收到的消息
string ip = inet_ntoa(from.sin_addr); //将ip 转换成主机序列的点分十进制
uint16_t port = ntohs(from.sin_port);
拿到消息之后我们就要对消息进行处理,我们先写一个简单的处理函数。
void HandlerMessage(string& message,string&ip,uint16_t port)
{
cout<<ip<<" : "<<port<<"----"<<message<<endl; //打印收到的消息及相关信息.
}
处理完之后我们也需要发消息回给客户端,而我们已经知道了客户端的 ip 和 port ,发消息就很简单了。
我们要用的接口:
sendto
send也是用于建立了连接的套接字,而如果是无连接的,还是需要sendto来指明对方的ip和端口号,同时,我们也是写到套接字的文件中交给下层udp协议,很简单,跟我呢见操作差不多。
返回值就是发送的字符个数。
void HandlerMessage(int sock,string& message,string&ip,uint16_t port)
{
cout<<ip<<" : "<<port<<"----"<<message<<endl; //打印收到的消息及相关信息.
//加工一下
string response = "response : ";
response+=message;
struct sockaddr_in goal ;
goal.sin_family = AF_INET;
goal.sin_port=htons(port);
goal.sin_addr.s_addr = inet_addr(ip.c_str());
int n = sendto(sock,response.c_str(),response.size(),0,(struct sockaddr*)&goal,sizeof goal);
if(n==-1)
{
cerr<<"sendto call failed";
exit(SENDTO_ERR);
}
}
那么目前一个简单的服务器就已经写完了,先写一个简单的客户端来测试一下功能。
客户端的设计
还是一样的,构造析构init和start,同时我们要分析一下客户端需要知道什么数据。
首先,客户端既然要向服务发送请求,那么他需要知道服务器的 ip 和 端口号,而这些数据我们通过命令行获取。同时,他要网络通信,也需要创建和保存套接字。
注意,客户端要知道的ip,要么是服务器的公网 IP ,如果是本地通信,就是本地环回ip 127.0.0.1 或者对方的内网 ip 。
class client
{
public:
client(string ip, uint16_t port)
: _sock(-1), _ip(ip), _port(port)
{
}
~client()
{
close(_sock);
}
private:
int _sock;
uint16_t _port;
string _ip;
};
init还是一样的,创建套接字和绑定端口和ip。
但是我们仔细想一下,客户端的套接字需要绑定 ip 和端口吗?
必须要,只有绑定了ip和端口才能进行网络通信。
但是需要我们显式绑定具体的ip和port吗?
我们想一想服务端为什么需要我们显式绑定?服务端最重要的就是绑定端口,对于服务端来说,ip反而不重要。因为服务端未来要有明确的端口号,不能随意改变,所以必须在代码中明确显式绑定某个固定的端口。
而对于客户端而言,端口是多少重要吗?不重要,因为客户端是发送请求的一方,他需要知道服务器的IP和port这毋庸置疑,而客户端发送请求的时候,会将自己的端口和 ip 都带给服务端,那么对于客户端而言,他的ip和端口其实是多少其实不重要,只要能表明他的唯一性就行了,因为服务端能够根据客户端发送的消息提取出客户端的ip和端口号。
同时一台主机上可能启动了很多的客户端,如果我们让客户端绑定确定的端口的话,那么可能会出现端口被占用的情况,那么就会绑定失败。
所以客户端其实不需要我们显式绑定,他与服务器类似,主机也可能有多个 IP ,所以也不需要显式指明要绑定的ip ,他的端口也可以由操作系统在运行时随即指定一个空闲的端口,防止端口被占用。 而这些工作是可以有操作系统自动做的。
其实就算我们不为套接字绑定p和端口,操作系统也会自动为套接字绑定ip和端口。udp中操作系统在程序第一次 sendto 的时候如果发现套接字没有绑定,就会自动绑定。
void init()
{
_sock = socket(AF_INET, SOCK_DGRAM, 0);
if (_sock == -1)
{
cerr << "socket error" << endl;
exit(SOCKET_ERR);
}
// 客户端的套接字不需要我们显式绑定
}
void start()
{
// 构造服务端的网络地址
struct sockaddr_in sever;
sever.sin_addr.s_addr = inet_addr(_ip.c_str());
sever.sin_family = AF_INET;
sever.sin_port = htons(_port);
socklen_t severlen = sizeof sever;
while (1)
{
string message;
cout << "Please Enter:";
// cin>>message; //会被空格中断
getline(cin, message); // 读取一行
sendto(_sock, message.c_str(), message.size(), 0, (struct sockaddr *)&sever, severlen);
// 接收服务器的返回结果
char buf[1024];
bzero(buf, 1024);
struct sockaddr_in from;
socklen_t fromlen =sizeof from;
recvfrom(_sock, buf, sizeof buf - 1, 0, (struct sockaddr*)&from,&fromlen);
string response = buf;
string ip = inet_ntoa(from.sin_addr);
uint16_t port = ntohs(from.sin_port);
cout<<"response from " <<ip <<":"<<port <<"----"<<response<<endl;
}
}
客户端的调用逻辑:
void Usage()
{
cout<<"Usage:"<<endl<<"./client severip severport"<<endl;
}
int main(int argc ,char*args[])
{
if(argc!=3)
{
Usage();
exit(USAGE_ERR);
}
string severip = args[1];
uint16_t severport = atoi(args[2]);
client clt(severip,severport);
clt.init();
clt.start();
return 0;
}
由于是本地环回测试,所以ip都是127.0.0.1,主要是端口号在起作用。
当然我们也可以使用公网ip来通信,客户端指定服务端的ip可以是服务端公网ip,这时候其实就是本地环回ip变成了我们的服务器的公网ip,其他都是一样的。
也可以使用内网 ip 进行通信,前提是要在一个局域网内
由于我们都是在同一个云服务器上运行的客户端和服务器,所以他们的结果看起来没什么差别,因为发送方和接收方的ip都是一样的,没什么好玩的现象。
在我们的sever中,业务处理或者说对收到的消息的处理方式是单独用一个 handlermessage 来进行的,业务逻辑和服务器本身进行了解耦。未来我们需要修改业务逻辑时,只需要修改业务函数,而不需要改动sever的逻辑。业务端也不需要关心消息怎么来的,只需要处理完消息之后发出去就行了。
更简单的,我们可以将业务函数作为参数传给sever 来构造服务器,到时候服务器直接调用。
比如我们现在想要sever提供类似英汉互译的服务。客户端发送英语单词,而服务器则在本地的词典中查找该单词的中文翻译。
首先我们需要有一个文件存储单词和对应的中文翻译。
然后首先要有数据结构将文件中的数据保存到我们的程序中,可以用map来保存。 那么我们就需要一个初始化的函数。
#define DICTNAME "CH_ENG_dict"
static map<string,string> dict;
//初始化词典
void init_dict()
{
FILE* pf = fopen(DICTNAME,"r");
if(pf==nullptr)
{
cerr<<"open failed"<<endl;
exit(OPEN_ERR);
}
//读取一行数据
char buf[1024];
while(fgets(buf,sizeof buf,pf)!=0)
{
string str = buf;
int n = str.find(':');
string eng=str.substr(0,n);
string chn = str.substr(n);
dict.emplace(eng,chn);
}
fclose(pf);
}
//业务处理逻辑
void HandlerMessage(int sock, string &message, string &ip, uint16_t port) // 处理方法2
{
cout<<"receive : "<<message<<endl;
string response;
if(dict.find(message)==dict.end())
{
response="unknow";
}
else
response = dict[message];
struct sockaddr_in client;
client.sin_family=AF_INET;
client.sin_addr.s_addr=inet_addr(ip.c_str());
client.sin_port=htons(port);
sendto(sock,response.c_str(),response.size(),0,(struct sockaddr*)&client,sizeof client);
}
那么如此一来我们就可以实现一个翻译的功能了。
但是单纯这样写的话,我们的服务器有一个问题,不能热加载。因为我们的服务器是长时间启动的,中间没有特殊情况是不会退出的,但是假如我们想要更新一下我们的词典,那么按照上面的逻辑就必须重启服务器才能更新。
我们有没有办法不重启服务器更新数据呢? 可以,使用信号,自定义一个处理方法,因为我们的信号的处理方法是在程序运行时执行的,当我们的数据更新之后,我们就发送指定的信号让我们的程序去更新 dict 。
比如我们捕捉2号信号来作为更新词典的信号。
其实我们只需要修改一下上面的init_dict,加一个int参数就行了,虽然参数我们也不用,但是由于信号捕捉函数的要求必须参数为int
又比如说,我们想要实现远程的命令行操作,比如我们的客户端发送 touch ,服务端就执行touch命令,也就是实现一个远端的shell。
按照我们以前的实现 设立了的demo的经验,首先要解析命令行字符串,然后创建子进程,子进程进行程序替换执行这些解析出来的命令行操作。
但是这里我们就不需要这么复杂了,我们可以用一个接口:
popen
它的功能其实就相当于创建一个管道(写回结果结果),创建一个子进程,以及进程替换,一个函数就完成了我们上面所需的功能。
它的第一个参数就是我们的命令行字符串,第二个参数就是打开这个文件的方式,其实就跟 fopen 的mode差不多,a,w,r等。
我们就可以直接将字符串作为命令行传给该函数,他就会执行命令行操作。
但是为了确保安全问题,我们可以屏蔽掉几个命令,比如rm,mv ,rmdir 等。
//命令行业务逻辑
void HandlerMessage(int sock,string&message,string&ip,uint16_t port)
{
cout<<"receive a command :" <<message<<endl;
string response;
struct sockaddr_in client;
client.sin_addr.s_addr=inet_addr(ip.c_str());
client.sin_family=AF_INET;
client.sin_port=htons(port);
vector<string> ban ={"rm","mv"};
for(auto&str:ban)
{
if(message.find(str)!=string::npos)
{
response = "Unsafe operation";
sendto(sock,response.c_str(),response.size(),0,(struct sockaddr*)&client,sizeof client);
return;
}
}
FILE*pf=popen(message.c_str(),"r");
char result[1024];
while(fgets(result,sizeof result , pf)!=0)
{
response+= result;
}
sendto(sock,response.c_str(),response.size(),0,(struct sockaddr*)&client,sizeof client);
}
这其实就类似于我们使用的xshell连接云服务器的操作,只不过实际上为了可靠,一般是用tcp或者其他协议的,而不是使用udp通信。
最后,我们还可以使用一套业务逻辑,设计一个简易的聊天室。
简易聊天室怎么设计呢?首先他可能存在多个在线的用户,那么我们就需要维护一个数据结构来保存在线的用户的网络信息,以及id等,最简单的我们可以用一个map,或者我们维护一个在线用户的类。一个用户想要发消息,必须先检查是否属于在线状态。
class OnlineUser
{
public:
bool isonline(string ip,uint16_t port)
{
string id = ip+":";
id += to_string(port);
return _usrs.find(id) != _usrs.end(); //如果在线就返回true,不在线就返回flase
}
void addusr(string ip,uint16_t port)
{
string id = ip+":";
id += to_string(port);
_usrs.emplace(id,make_pair(ip,port));
}
void delusr(string ip ,uint16_t port)
{
string id = ip+":";
id += to_string(port);
_usrs.erase(id);
}
map<string,pair<string,uint16_t>> _usrs; //string 用来保存用户的id作为key值,id我们直接用 ip + port组合起来,
//pair<string,uint16_t>保存用户网络地址
};
而针对客户端的信息有以下几种处理方式:
1 如果发的是Online 上线请求,判断其是否已经在线,如果已经在线,就单独给他回消息,如果未上线的状态然后上线,那么就给他发"Onlien success",给在线的其他用户发我们的客户端上线的消息
2 如果发的是Offline 下线请求,那么就给该用户发下线成功,并将其从在线列表中删除。
3 在线,且不是上限和下线请求,那么就将它的消息转发给除他自己之外的其他的在线用户
static OnlineUser OnlineUSR;
void HandlerMessage(int sock,string& message ,string& ip ,uint16_t port)
{
string response;
if(message == "Online" && !OnlineUSR.isonline(ip,port)) //上线请求
{
OnlineUSR.addusr(ip,port);
//给其他客户端发送消息提示我们的用户上线
string toothers = ip+":"+to_string(port)+"has onlined";
for(const auto& usr:OnlineUSR._usrs)
{
struct sockaddr_in goal;
goal.sin_family=AF_INET;
goal.sin_port = htons((usr.second).second);
goal.sin_addr.s_addr=inet_addr((usr.second).first.c_str());
//发送当前用户上线的消息
sendto(sock,toothers.c_str(),toothers.size(),0,(struct sockaddr*)&goal,sizeof(goal));
}
//向上线的用户发送成功上线的提示
response = "Online Success";
struct sockaddr_in client;
client.sin_family=AF_INET;
client.sin_addr.s_addr=inet_addr(ip.c_str());
client.sin_port=htons(port);
sendto(sock,response.c_str(),response.size(),0,(struct sockaddr*)&client,sizeof client);
return;
}
else if(message == "Online" && OnlineUSR.isonline(ip,port)) //上线请求重复
{
response = "repeat Online";
struct sockaddr_in client;
client.sin_family=AF_INET;
client.sin_addr.s_addr=inet_addr(ip.c_str());
client.sin_port=htons(port);
sendto(sock,response.c_str(),response.size(),0,(struct sockaddr*)&client,sizeof client);
return;
}
if(message == "Offline") //下线请求,不考虑是否在线
{
response = "Offline success";
OnlineUSR.delusr(ip,port);
struct sockaddr_in client;
client.sin_family=AF_INET;
client.sin_addr.s_addr=inet_addr(ip.c_str());
client.sin_port=htons(port);
sendto(sock,response.c_str(),response.size(),0,(struct sockaddr*)&client,sizeof client);
}
if(OnlineUSR.isonline(ip,port)) //在线
{
//向除他之外的所有用户广播消息
for(const auto& usr:OnlineUSR._usrs)
{
string id =ip+":";
id+=to_string(port);
if(usr.first !=id)
{
struct sockaddr_in goal;
goal.sin_family=AF_INET;
goal.sin_port = htons((usr.second).second);
goal.sin_addr.s_addr=inet_addr((usr.second).first.c_str());
//发送当前用户上线的消息
sendto(sock,message.c_str(),message.size(),0,(struct sockaddr*)&goal,sizeof(goal));
}
}
}
else
{
//提示该用户未上线
response = "Please Online First";
struct sockaddr_in client;
client.sin_family=AF_INET;
client.sin_addr.s_addr=inet_addr(ip.c_str());
client.sin_port=htons(port);
sendto(sock,response.c_str(),response.size(),0,(struct sockaddr*)&client,sizeof client);
return;
}
}
这里的打印会出现问题,这是因为我们的客户端的逻辑是死循环发一次消息才能收消息,所以导致收到消息会有问题。要解决这个问题,我么需要将客户端写成多线程,一个线程用来read和发消息,一个线程用来获取消息以及打印收到的消息。
void start()
{
// 构造服务端的网络地址
struct sockaddr_in sever;
sever.sin_addr.s_addr = inet_addr(_ip.c_str());
sever.sin_family = AF_INET;
sever.sin_port = htons(_port);
socklen_t severlen = sizeof sever;
pthread_t tid;
pthread_create(&tid, nullptr, clientReceive, (void *)&_sock);
// 主线程读取和发送消息
while (1)
{
string message;
cout << "Please Enter:";
getline(cin, message); // 读取一行
sendto(_sock, message.c_str(), message.size(), 0, (struct sockaddr *)&sever, severlen);
usleep(1000);
}
pthread_join(tid, nullptr);
}
static void *clientReceive(void *arg)
{
int sock = *(static_cast<int *>(arg));
while (1)
{
char buf[1024];
struct sockaddr_in from;
socklen_t len = sizeof from;
int n = recvfrom(sock, buf, sizeof(buf) - 1, 0, (struct sockaddr *)&from, &len);
if (n > 0)
{
buf[n] = 0;
cout << buf << endl;
}
}
}
那么这样一来接受和发送就可以并行进行了。
目前还有一个小瑕疵就是我们的输入的提示和输出的消息可能会混在同一行,我们可也可以再加逻辑去控制,不过在这篇文章中我们就不过分追究细节了。
我们也可以在windows上写一个客户端,其实主逻辑都是一样的,只不过有些细节需要修改。
首先第一点区别就是头文件的不同,头文件中的2就是版本
#include<WinSock2.h>
由于这是第三方库,我们需要显式关联一下,使用以下的命令:
#pragma comment(lib,"ws2_32.lib")
其次,在main函数中使用 WinSock 库之前,我们还需要定义一个对象来表明你要申请的库的版
本。
int main()
{
WSAData wsd;
if (WSAStartup(MAKEWORD(2, 2), &wsd) != 0) //表示申请2,2版本的socket网络库 ,如果与我们的头文件对不上,就申请失败
{
cerr << "WSAStart Error" << endl;
exit(1);
}
//到这里才算成功将库关联成功
//......客户端逻辑
//最后还需要清理清理申请的库资源
WSACleanup();
return 0;
}
windows下的套接字类型是 SOCKET ,它的本质就是一个 uint64_t 类型的整型 ,然后需要用socket 函数来进行初始化。初始化之后我们需要判断它是否创建成功,如果创建失败,sock的值就是 SOCKET_ERROR ,它的值就是 -1 。
SOCKET sock = socket(AF_INET,SOCK_DGRAM,0);
if (sock == SOCKET_ERROR)
{
cerr << "socket create fail" << endl;
exit(2);
}
windows下客户端也不需要显式绑定端口号和ip。
然后就是发送消息,我们需要填充网络地址的结构体。windows下的结构体就是SOCKET_IN,当然我们也可以写成小写,他们是重命名的关系。同时它的成员和Linux下也是一样的。
sockaddr_in sever;
string ip = "43.143.238.233";
uint16_t port = 8080;
sever.sin_family = AF_INET;
sever.sin_port = htons(port);
sever.sin_addr.s_addr = inet_addr(ip.c_str());
//通信
string msg;
while (1)
{
getline(cin,msg);
int n =sendto(sock,msg.c_str(),msg.size(),0,(sockaddr*)&sever,sizeof sever);
if (n == 0) exit(3);
char response[1024];
sockaddr_in from;
int len = sizeof from;
int m = recvfrom(sock ,response,sizeof response -1,0,(sockaddr*)&from,&len);
if (m <= 0) exit(4);
response[m] = 0;
cout << "response:" << response << endl;
}
//关闭文件描述符/套接字
closesocket(sock);
上面的程序中,我们对结构体填充的时候,可能会报一个错,我们可以把这个报错屏蔽掉。
#define _WINSOCK_DEPRECATED_NO_WARNINGS 1
当然,我们上面的windows的客户端没有搞成多进程或者多线程的,所以可能输入一次消息之后就阻塞在了读取上,因为他的while循环中的逻辑就是必须发一次收一次。
5 tcp demo程序
首先还是分析服务器端,服务器端类成员需要哪些呢?套接字和端口号还是无法避免,其他的等用到了再往上加。
enum ERRNO
{
};
class sever
{
public:
sever(uint16_t port)
:_sock(-1),_port(port)
{}
private:
int _sock;
uint16_t _port;
};
然后还是一样的,封装 init 和 start 函数,先从最简单的服务器做起。
由于tcp是面向字节流的,所以我们的套接字就是流式套接字,第二个参数使用 SOCK_STREAM
tcp服务端 的init 还是需要创建套接字和绑定端口,这没什么好说的,但是由于 tcp 是面向连接的,所以我们还要做一步工作,就是将该套接字设置为监听状态,以便我们的服务器能够时时刻刻接收到客户端发来的连接请求。
设置监听状态我们需要用到以下接口:
listen
他需要两个参数,一个是要设置的套接字,另一个是 backlog ,backlog其实是设置底层连接队列的长度,实际上队列的长度是我们传的值再加一。 当然这个连接队列我们现在还无法讲,需要等到详细讲解tcp协议的时候再讲。 目前我们只需要把他设置为一个合适的值就行,不要太大也不要太小。
成功返回 0 ,失败返回 -1 。
监听状态设置完之后我们的init初始化工作就完成了
#define defaultip "0.0.0.0"
void init()
{
_sock = socket(AF_INET,SOCK_STREAM,0);
if(_sock==-1)
{
cerr <<"socket error"<<endl;
exit(SOCKET_ERR);
}
cout<<"socket create success"<<endl;
//绑定端口号和ip
struct sockaddr_in addr;
addr.sin_family = AF_INET;
addr.sin_port=htons(_port);
string ip = defaultip;
addr.sin_addr.s_addr=inet_addr(ip.c_str());
int n = bind(_sock,(struct sockaddr*)&addr,sizeof addr);
if(n==-1)
{
cerr<<"bind error"<<endl;
exit(BIND_ERR);
}
cout<<"bind success"<<endl;
#define defaultbacklog 10
n = listen(_sock,defaultbacklog);
if(n==-1)
{
cerr<<"listen err"<<endl;
exit(LISTEN_ERR);
}
cout<<"listen success"<<endl;
}
监听状态用来监听客户端发来的连接请求,而客户端的连接请求来了之后,后面我们会将tcp 建立连接需要三次握手的过程,这其中就是双方的操作系统在进行数据的保存和属性的设置。当然我们现在不关心这些。
那么连接创建好之后,我们怎么获取呢?
accept
他的参数第一个是设置好监听状态的套接字,后面的就是输出型参数,用来获取客户端的网络地址。
他的作用就是返回指定监听套接字的连接队列中的第一个连接的文件描述符。
他的返回值就很奇怪,是一个文件描述符,或者我们也可以称之为套接字,那么跟我们成员中设置为监听状态的套接字有什么区别吗?
我们的成员的套接字或者说我们初始化时创建好的套接字是专门用来监听客户端的连接请求的,不用于通信,因为我们的一个服务器可能同时链接多个客户端,也就是不止一个连接,而我们实际中通信使用的套接字是 accept 返回的那么套接字。
所以我们可以将成员变量的监听套接字的变量名改成 _listensock,便于表示他的功能,防止我们记忆混乱。
未来有多个客户端来连接的话,就会有多个用于通信的套接字。
在获取到通信的套接字之后,我们就可以调用业务逻辑来进行处理,为了解耦和更方便未来我们进行拓展,我们也可以将其设置为成员变量,在构造时传进来。
void start()
{
//死循环从监听套接字中获取新连接
while(true)
{
struct sockaddr_in client;
socklen_t len = sizeof client;
int sock = accept(_listensock,(struct sockaddr*)&client,&len);
if(sock==-1)
{
cerr<<"accept error"<<endl;
exit(ACCEPT_ERR);
}
cout<<"accept a new link , sock :" << sock <<endl;
_handler(sock,client);
}
}
而目前我们最简单的处理逻辑就是将发过来的消息添加一个字段之后直接发回去。
void handler(int sock,uint16_t port,string ip)
{
//简单添加字段就发回去
struct sockaddr_in client;
client.sin_family=AF_INET;
client.sin_addr.s_addr = inet_addr(ip.c_str());
client.sin_port=htons(port);
socklen_t clientlen=sizeof client;
//读取消息我们当前可以直接使用read ,后面会详细讲tcp的读取的问题
char buffer[1024];
while(1)
{
memset(buffer,0,sizeof buffer);
int n =read(sock,buffer,sizeof buffer -1); //还是读取进来当字符串处理
if(n>0) //说明读到了数据
{
buffer[n]=0;
string msg ="response : ";
msg+=buffer;
int n =sendto(sock,msg.c_str(),msg.size(),0,(struct sockaddr*)&client,sizeof client);
if(n==-1)
{
cerr<<"sendto error"<<endl;
exit(SEND_ERR);
}
}
else if(n==0) //说明对方关闭了文件描述符
break;
else
{
cerr<<"read error"<<endl;
exit(READ_ERR);
}
}
}
目前我们使用tcp通信就可以用文件或者管道通信的接口,因为他们其实已经间接的是一对一的向各自的文件描述符中写入了,同时文件操作也是字节流的,我们直接使用文件的接口没什么问题。
在编写客户端程序之前,我们首先对上面的代码进行一些小的优化,我们将日志的一些逻辑加进来,也就是前面的错误输出以及重要事件的成功我们都调用日志函数。
既然要封装成日志,那么就需要尽可能地规范,首先日志是有等级的,一般是五个等级: debug,normal,warning,error,fatal
debug就是一些用于调试的日志,normal就是程序正常向后运行的日志,warning是一些告警的日志,error是出错日志,但是不影响后续的运行,而fatal则是致命错误。但凡是fatal的,调用完日志函数都是要exit的。而 error 则不一定要退出服务器。
同时,日志还可能需要时间,时间的获取我们先不管,我们先完成一个最简单的日志的函数,他需要穿的参数就是一个 日志等级和日志的信息。
enum LogLevel
{
DEBUG, //0
NORMAL, //1
WARNING, //2
ERROR, //3
FATAL //4
};
static const char* LevelString[] = {"DEBUG","NORMAL","WARNING","ERROR","FATAL"}; //日志的等级的字符串表示
//最简单的日志函数,后续还会进行优化
void logmessage(LogLevel level , string message)
{
cout<<"["<<LevelString[level]<<"] : " << message <<endl;
}
比如我们的监听字的创建的打印信息就可以这样:
_listensock = socket(AF_INET,SOCK_STREAM,0);
if(_listensock==-1)
{
// cerr <<"socket error"<<endl;
logmessage(FATAL,"listen socket create fail"); //监听套接字创建失败就是致命错误了
exit(SOCKET_ERR);
}
else
logmessage(NORMAL,"socket create success");
有了简单的服务器逻辑之后,我们先搞一个简单的tcp客户端来测试一下我们的逻辑是否正确。
客户端这里的逻辑其实和 udp 的差别不是很大,也是需要套接字,服务器ip和port ,但是他中间可能会多一个建立连接的过程。
客户端也可以使用日志来打印信息,只不过没有必要,重要性没有服务器这么高
class client
{
public:
client(uint16_t port, string ip)
: _severport(port), _severip(ip)
{
}
void init()
{
_sock = socket(AF_INET, SOCK_STREAM, 0);
if (_sock == -1)
{
cout << "socket fail" << endl;
exit(SOCKET_ERR);
}
cout << "socket success" << endl;
//不需要显式bind
}
private:
int _sock;
string _severip;
uint16_t _severport;
};
客户端的start该做什么呢?客户端需不需要监听呢?不需要。
客户端是要主动去连接服务器的,是发起连接的一方,所以我们start要做的第一件事就是要和服务器建立连接
connect
他的参数也很简单,首先就是要建立连接的套接字,后两个参数就是指明目标服务器的网络地址。
成功建立连接就返回 0 ,失败就返回 -1。
void start()
{
struct sockaddr_in sever;
sever.sin_addr.s_addr = inet_addr(_severip.c_str());
sever.sin_port = htons(_severport);
sever.sin_family = AF_INET;
int n = connect(_sock,(struct sockaddr*)&sever,sizeof sever);
if(n!=0)
{
cout<<"connect fail"<<endl;
exit(CONNECT_ERR);
}
//连接成功之后就是客户端的逻辑
ClientHandler(_sock,_severip,_severport);
}
void ClientHandler(int sock,string severip,uint16_t severport)
{
struct sockaddr_in sever;
sever.sin_family=AF_INET;
sever.sin_addr.s_addr = inet_addr(severip.c_str());
sever.sin_port = htons(severport);
string message;
while(1)
{
getline(cin,message);
int n = write(sock,message.c_str(),message.size());
if(n==0)
break;
if(n==-1)
{
cout<<"write error"<<endl;
exit(WRITE_ERR);
}
//读取response
char resp[1024];
bzero(resp,sizeof resp);
read(sock,resp,sizeof resp);
string response = "sever response : ";
response += resp;
}
}
显而易见,tcp的客户端的sock的绑定就是在connect的时候自动完成的。
我们也可以使用 netstat 命令来查看网络服务的状态
那么我们第一个版本的 tcp 服务器和客户端就完成了,但是上面的tcp的客户端有一个最大的缺陷,我们可以看一下,当两个客户端都来连接服务器时会发生什么,为了方便看现象,我们可以在客户端的逻辑中,在连接成功之后打印一条提示消息
我们的服务端只能读取和回应第一个连接的客户端的消息。
我们再把第一个客户端关闭,
这时候我们会发现,服务器将2号客户端之前发送的消息依次接收和回应了。
上面的代码中有两个问题:
1 一次同时只能服务一个客户端
2 客户端连接断开之后文件描述符未释放,造成了文件描述符的泄露。
怎么解决这两个问题呢?
我们的tcp服务端注定未来要同时服务多个客户端,而服务的业务逻辑又必须是死循环,所以我们只有一个执行流是不行的,同时,我们的服务器在服务某一个客户端时,他必须能够同时accept 获取其他的客户端的连接,为其提供服务,这就注定了,必须要有一个执行流死循环的 accept 。
那么我们想到的第一种解决方案就是创建子进程,子进程用来服务客户端,而我们的父进程的功能就是用来获取新连接。
1 子进程版:
// version 2
pid_t id = fork();
if (id == 0) // 子进程
{
_handler(sock, clientport, clientip);
exit(0);
}
// 父进程要wait子进程
waitpid(id, nullptr, 0); //?
// 父进程继续while循环获取新连接
如果单纯这样写,还是有文件描述符泄露的问题,同时,我们的子进程也要注意关闭不需要的文件描述符,比如关闭监听描述符,防止误操作影响服务器接受新连接。
同时,由于子进程是拷贝了一份父进程的文件描述符表,也就是该网络文件被子进程和父进程都能找到,而我们的子进程退出的时候,它会自动释放他打开的文件描述符,但是不会对父进程的该文件描述符有影响,所以我们父进程还要手动关闭 accept 的用于跟客户端通信的文件描述符,可以在waitpid 前关闭,也可以在waitpid之后关闭,不会影响子进程,因为fork返回的时候子进程已经将资源拷贝好了。
// version 2.1
pid_t id = fork();
if (id == 0) // 子进程
{
close(_listensock);
_handler(sock, clientport, clientip);
//可以手动关闭也可以程序退出自动关闭
close(sock);
exit(0);
}
// 父进程要wait子进程
close(sock);
waitpid(id, nullptr, 0); //?
// 父进程继续while循环获取新连接
那么这样改完之后能不能行呢?
我们在来测试一下:
问题还是存在,那么我们在关闭第一个客户端试一下
我们发现,我们将文件描述符泄露的问题倒是解决了,但是为什么还是一次只能处理一个客户端呢?
其实很简单,因为我们使用 waitpid 的时候使用的是阻塞等待,那么他就会一直阻塞在waitpid,直到我们的服务子进程退出被他回收之后,我们的父进程才能够接着while循环去accept获取连接服务新的客户端。
那么怎么解决呢?
既然问题出现在了waitpid阻塞等待上,我们是否可以选择非阻塞等待呢? 当然可以,只不过采用非阻塞等待的话,我们就需要用一个vector 来保存创建的子进程的 pid ,那么父进程每次accept一个新连接,创建一个子进程之后再关闭文件描述符之后,父进程就需要对 vector 中保存的子进程的pid进行非阻塞回收。
// version 2.2
static vector<pid_t> ids;
pid_t id = fork();
if (id == 0) // 子进程
{
close(_listensock);
_handler(sock, clientport, clientip);
// 可以手动关闭也可以程序退出自动关闭
close(sock);
exit(0);
}
ids.push_back(id);
// 父进程要wait子进程
close(sock);
for (int i = 0; i < ids.size(); i++)
{
if(ids[i]==waitpid(ids[i],nullptr,WNOHANG)) //说明等待成功
{
ids.erase(ids.begin()+i);
--i;
}
}
实验下来确实能够同时对进行多个服务了,但是其实这种方案还是有一个很大的问题:
当我们接收到了多个客户端的请求之后,假如我们获取了1000个连接,且中途没有客户端断开连接,也就是说同时存在1000个子进程,但是,在这之后,不再有客户端发起连接请求了,而我们之前的客户端陆陆续续断开连接,但是由于父进程阻塞在 accept 不会返回,就会导致我们的服务器无法回收那些已经退出的子进程,那么就会造成资源的浪费。
所以这里的僵尸进程的问题其实和 waitpid 是否是阻塞的关系并不是很大,因为我们的主进程可能都走不到 waitpid 的逻辑中。那么我们就需要想一种办法,让我们的子进程和父进程分离,或者说想办法让子进程退出之后就直接被回收,而不是必须要父进程来回收。
我们以前学习过的孤儿进程就能够完成这样的目的。但是我们这里要怎么设计呢?
很简单,我们创建完子进程之后,不是让子进程执行业务逻辑,而是再创建一个子进程,也就是我们的主进程的孙子进程来执行业务逻辑,创建完孙子进程之后,子进程立马退出,而我们的主进程也就能够直接回收子进程。 与此同时,由于子进程退出,那么孙子进程就成了孤儿进程,它会自动被 1 好进程操作系统所领养,退出时会被自动回收,这就解决了我们的问题。
// version 2.3
static vector<pid_t> ids;
pid_t id = fork();
if (id == 0) // 子进程
{
if(fork()==0)
_handler(sock,clientport,clientip); // 孙子进程执行业务逻辑
exit(0); //子进程和孙子进程都会走这一个退出函数
}
// 父进程要wait子进程
close(sock);
waitpid(id,nullptr,0);
// 父进程继续while循环获取新连接
那么这就是多进程方案的最终解决方案了。
版本 3 多线程
既然可以用多进程,我们也可以通过创建线程的方式来解决。
既然要使用多线程,我们就需要业务逻辑函数改成多线程版本的,将多个参数整合在一起来传参。
//多线程准备工作
class ThreadData
{
public:
ThreadData(int sock,uint16_t clientport,string clientip)
:_sock(sock),_clientport(clientport),_clientip(clientip)
{}
public:
int _sock;
uint16_t _clientport;
string _clientip;
};
void* start_routine(void* arg)
{
ThreadData* pdata = static_cast<ThreadData*>(arg);
int sock = pdata->_sock;
uint16_t port = pdata->_clientport;
string ip = pdata->_clientip;
handler(sock,port,ip); //调用业务逻辑
//多线程版本只需要在新线程中关闭文件描述符就行了
close(sock);
return nullptr;
}
//version 3 多线程
pthread_t tid = 0 ;
ThreadData* pdata = new ThreadData(sock,clientport,clientip);
int n = pthread_create(&tid,nullptr,start_routine,pdata);
if(n!=0) //创建失败
{
logmessage(ERROR,"pthread_create fail"); // 一个连接的服务线程失败不影响整个服务器,所以他的级别是ERROR
}
(void)n;
注意,多线程版本和多进程不一样,多线程用的是同一张文件描述符表,或许可以称之为这个进程的文件描述符表,所以我们只需要新线程去关闭对应的文件描述符就行了,主线程继续去accept就行了。
版本 4 :线程池
线程池版本其实没什么好说的,我们只需要把以前写的线程池拿过来,然后将 Task 修改一下就行了,那么这里我们也就不实现了。主要还是因为我们上面的业务逻辑使用线程池的方案其实不好,线程池适用于 消耗时间很短的业务,而不适合这种死循环的业务逻辑,因为线程池毕竟里面的现场的数量是有限的,同时最好是要保证他有线程是空闲的。
6 日志的完善
//最简单的日志函数,后续还会进行优化
void logmessage(LogLevel level , string message)
{
cout<<"["<<LevelString[level]<<"] : " << message <<endl;
}
这是我们上面写的日志函数,十分简单,只包含日志等级和日志信息,但是其实有些时候日志可能需要更多的信息,比如我们获取到一个连接,我们可以把 获取的 sock 打印出来,同时,如果我们设置监听状态失败,我们也可以将该文件描述符打印出来,或者可能还需要更多的信息,总之,实际中日志函数的参数是可变的,我们要将日志函数写成可变参数的版本。
//可变参数版本
void logmessage(LogLevel level , const char* format , ...)
{
}
那么我们就需要能够将参数提取出来,而提取可变参数我们需要几个宏:
va_list ,va_start , va_end , va_arg
提取可变参数其实就是使用起始地址加偏移量的方式。
而 va_list 其实就是 char* ,va_start 能让我们的指针指向可变参数部分的起始地址,不过我们使用的时候必须要传一个参照的位置,也就是可变参数部分的前一个参数。 因为函数参数的压栈顺序就是从右往左的,也就是说可变参数压完栈之后才会压可变参数部分的前一个参数,那么该参数的位置加上他的大小就是可变参数部分的起始地址。 当然,所有的参数压完之后,可能会留一块空间用来临时保存该函数的返回值,在函数的栈帧开始之前会再压一个函数返回地址(也就是call指令的下一个指令的地址),这一点就与这里无关了。
而va_arg 就是让我们的指针向后移动指定类型的大小(提取当前位置开始的指定类型大小的空间并按该类型返回,同时指针向后移)。va_end 就是让指针指向 nullptr
那么怎么控制指针的向后移动呢?
其实也不难,我们只需要依据我们的格式控制的字符串,也就是我们的 format 参数,依次遍历该字符串,如果读到 %s ,%d ,%ld ... \r,\n,\t ... 这样的特殊符号,我们就进行特殊处理,该控制格式就控制格式,如果是占位符就去可变参数部分那参数来替换。
提取的逻辑其实就是一个 循环内嵌套一些 if ,switch case 等语句。
当然这里我们就不高这么复杂了,不用这套 C 提供的原始的接口来搞了,直接使用 vprintf 系列
我们可以使用 vsnprintf 将可变参数按照指定格式提取到我们的缓冲区中。
//可变参数版本
void logmessage(LogLevel level , const char* format , ...)
{
char buffer[1024];
bzero(buffer,sizeof buffer);
va_list start;
va_start(start,format);
vsnprintf(buffer,sizeof buffer,format,start); //提取可变参数
//再加上我们的日志等级的信息
string logmsg = "[";
logmsg += LevelString[level];
logmsg += "] : ";
logmsg+= buffer;
cout<<logmsg<<endl;
}
那么我们代码中有些地方的日志就可以修改一下,比如我们成功accept一个连接的时候:
logmessage(NORMAL,"accept a new link , sock :%d",sock);
当然如果我们还想要更多的信息,比如时间,代码的所在行数等,可以在网上再找一些日志的相关函数的编写。
7 守护进程
我们上面的服务器其实还有一个问题,就是只要我们将 xshell 关掉,那么我们的服务器进程就自动退出了,而这其实是不合理的,因为我们只是将 xshell 关闭了,也就是结束了本次会话,但是我们的云服务器并没有关机,那么服务器就没必要关闭。
服务器的特点就是:启动之后,不再受用户的登录和注销的影响,他可以自由运行,如果不再需要这个服务器,可以手动 kill 掉服务器进程
会话,作业,进程组
我们使用 xshell 工具连接到Linux云服务器之后,其实是给我们建立了一个会话,同时启动了一个bash程序,用来读取和执行我们的命令行操作。
同时,我们的进程也分为前台进程和后台进程,一个会话同一时刻有且只有一个前台任务,但是可以同时存在多个后台任务。
注意我们这里说的是任务,一个任务可能有多个进程。
我们在命令行启动的进程,默认都是在当前会话中启动的,那么当我们注销或者退出登录时,会话关闭了,会话中的进程自然也就被清理了。
如果我们想要在后台启动一个进程,我们可以在命令行操作后面加上一个 & ,就代表在后台启动该任务。
我们怎么查询后台进程呢?
jobs
jobs命令能够显示当前正在执行的作业,而我们完成同一个任务的这些后台进程称为一个作业。每个作业都有编号,作业编号我们也叫 作业号 。注意,只有在后台执行的任务才交作业。
我们可以使用管道来观察多个进程构成一个作业的情况。
当我们在后台启动这样一组进程后,我们发现他还额外打印了一些信息,我们能看出来它们分别是作业号以及这一组进程的最后一个进程的 pid 。
同时我们也能发现,他们的 ppid 是一样的,这很好理解,他们的父进程都是 bash , 而他们的 pid 是连续的,说明他们是一组兄弟进程,但是 PGID 和 SID 也是一样的,这又是什么呢?
首先,SID 就是 session id ,其实就是会话 id ,这一个作业中的进程都是在当前会话启动的,所以他们的会话 id 是相等的,同时,一个会话的 id 又是 该会话中的 bash 的 pid 。
PGID 其实是 组id ,一个进程组通常是由一个进程或者多个进程构成,他们之间的关系可能是完成同一个作业或者有其他的一些关联比如父子进程,简单理解就是一组相关的进程。注意,我们的作业中的进程创建的子进程不属于这个作业,但是属于这个进程组,我们可以理解为 作业是进程组的一个子集
组id就是组长进程的pid,而组长进程就是该组中第一个启动的进程。
jobs只能查看当前会话的作业
fg/bg
fg命令可以把一个指定编号的作业放到前台执行。
同时,我们也可以使用 ctrl + z 将一个前台任务暂停,而一个前台任务暂停之后就自动切换到后台了,但是他还是处于暂停状态,我们可以使用信号让他继续,也可以使用 bg 命令让一个作业继续。
同时我们也发现,前台任务切换到后台之后,我们的 bash 就自动切回前台了。
而当我们退出登陆的时候,会将该会话中的任务清理,那么我们在该会话启动的任务就都有可能被清理。
这样的任务会受到我们的登录和注销的影响。
那么我们想让一个进程不受登录和注销的影响,只是把它放到后台是不够的,我们要让这个进程独立于当前会话,让他自成会话,那么当然也就自成进程组。 让这个进程和我们的终端设备无关。
如果能创建出这样的进程,我们就称之为守护进程
那么我们要怎么让我们的服务器进程变成一个守护进程呢?
最简单的方式其实就是调用 daemon 系统接口,但是这样就不便于我们理解他的原理,所以我们这里会自己实现一个类似于 daemon 的守护进程化的函数。自己实现这个函数也有一个优势,就是我们能够自己对某些行为进行定义,提示或禁止等。
函数 setsid 能够创建一个会话,同时把调用这个函数的进程变为新会话的领头进程,就类似于我们平常的会话的 bash 进程,领头进程的 pid 就是该会话的 sid
但是这个函数不能随意调用,她有一个前提条件,就是调用的进程不能是 组长进程,只能是一个普通进程。
那么我们要守护进程化的步骤:
1 让调用的进程忽略掉异常的信号。
比如 SIGPIPE,为什么要屏蔽这些类似的信号呢? 因为在通信的过程中,我们的客户端如果因为一些特殊愿意异常崩溃了,那么对应的文件描述符就关闭了,我们服务端的通信线程再往套接字写数据其实就有点类似于管道的读端关闭,写端继续写,这时候操作系统会检测到这种情况,为了防止资源的浪费,OS会发送SIGPIPE信号给我们的服务器进程,那么就会导致我们的服务器进程终止。我们是不希望见到这样的事的,所以我们最好就屏蔽掉这些信号。
2 让进程不是组长 ,然后调用setsid
这个其实也很简单,我们可以模仿前面我们的多进程的通信方案,使用fork创建一个子进程,让子进程执行服务器的逻辑,我们知道子进程肯定不会是组长进程,这时候肯定能调用setsid成功,创建完完子进程之后,我们的父进程直接终止,那么与此同时,子进程就会被 1 号进程也就是操作系统领养。
守护进程也叫精灵进程,它的本质就是也就是孤儿进程的一种
3 守护进程是脱离终端的,所以我们要关闭或者重定向之前进程默认打开标准输入,标准输出,标准文件描述符
因为我们的守护进程是脱离终端的,那么它就和我们的显示器和键盘等终端设备没关系了,所以必须避免向显示器中写数据和从键盘读数据的。
那么我们上面写的日志的函数就没用了吗?我们直接关闭0,1,2吗? 不能,因为我们的日志函数还是用到了1号文件描述符的,如果粗暴的直接关闭,那么就会出现写入的文件描述符不存在的错误,写入就会报错,会导致我们的程序崩溃。
我们有两种可选方案,第一种就是保留日志,将日志写到我们的系统的文件中,也就是重定向 1 号文件描述符。
static const char* logfilename = "log.txt";
void mydaemon()
{
//1屏蔽某些信号
signal(SIGPIPE,SIG_IGN);
//2 创建子进程来执行服务器逻辑,父进程退出
if(fork()>0) exit(0);
int n = setsid();
if(n==-1)
{
logmessage(FATAL,"setsid error");
exit(SETSID_ERR);
}
//3 脱离终端
int fd = open(logfilename,O_WRONLY|O_RDONLY|O_CREAT,0666);
if(fd==-1)
{
//也要继续执行代码,那么就只能关闭了
close(0);
close(1);
close(2);
}
dup2(fd,0);
dup2(fd,1);
dup2(fd,2);
}
这种方案当然我们还可以在做一些优化,比如我们为区分一下日志的等级,把error和fatal的日志放在一个日志文件中,把其他的日志放在另一个日志文件中。
第二种方案就是不需要日志信息。 在我们的Linux中有一种特殊的字符文件,叫做 /dev/null,这个文件相当于Linux的文件黑洞,凡是向他里面写的内容,全部直接丢弃掉,如果我们要从该文件中读取数据,不会出错也不会阻塞,而是立马返回,但是什么也不会给你,相当于读取的时候什么都不做,但是写入的时候把你写的东西就丢弃了。
所以我们如果不需要日志的化,就直接将 0 1 2 重定向到该文件中。
daemon还有一些可选内容,比如更改我们的程序的执行路径。
可以通过传参的形式或者硬编码的形式,然后调用 chdir() 。我们不考虑这么多了。
我们的程序的启动逻辑就需要调用 daemon ,我们在 start 之前调用 daemon 就行了。 比如我们甚至可以直接在创建对象之前完成守护进程化。
int main(int argc, char*args[])
{
if(argc!=2)
{
Usage();
exit(USAGE_ERR);
}
uint16_t port = atoi(args[1]);
//守护进程
mydaemon();
sever sv(port,handler);
sv.init();
sv.start();
return 0;
}
守护进程化之后,进程的TPGID为 -1 表示该进程为守护进程。 而TTY字段表示是否关联终端设备。
8 tcp通信的简单理解
在之后详细学习tcp的时候我们会知道,使用 read 和 write 来发送和读取在tcp中是有问题的。因为tcp是面向字节流的,我们无法保证读到的数据是对方放的一个完整的数据,或者不止一个完整数据。tcp通信是基于字节流的,没有指定规则,所以读写是无序混乱的,当然其实管道也会存在这个问题。
各个接口的定位
我们的tcp客户端做的工作其实就是创建一个流式套接字,设置监听状态,然后就在死循环调用accept 获取 连接 ,注意,accept 是获取连接,而不是建立或者创建连接,而获取链接的前提则是我们的底层已经建立好了这个链接,我们才能谈获取。
同时,客户端调用 connect 函数其实也不是真正的建立连接, 而是发起连接请求。而tcp的连接过程我们称之为 三次握手,所以调用 connect 其实是发起三次握手的请求,但是其实真正去完成建立连接工作的还是双方的操作系统自动完成的,建立连接的过程并没有我们用户的参与,用户只是发起连接请求。
服务端的accept也并不会参与三次握手的细节,他只是在连接建立好之后获取该链接的文件描述符并返回。而如果底层并没有新的链接的话,那么accept也只是会阻塞,直到新连接的建立。
而相同的,我们客户端即便不去调用 accept 来获取链接,操作系统也是能够完成三次握手来和客户端建立连接的,即使我们不去获取,连接也是客观存在的。
怎么理解链接呢?
通信双方建立连接的本质就是双方保存对方的属性和信息,同时为其绑定某些标签,就比如我们的文件描述符,而后续我们只要拿着这个标签,就能找到这个链接。而三次握手则是建立连接的手段,使通信双方保存对方信息。 而建立链接的目的就是通信。
而服务器有可能会受到很多链接,也就是同时会存在多个连接,那么操作系统也需要管理这些链接,而要管理连接就要使用对应的数据结构。
所以其实连接的本质就是为该链接创建一个数据结构对象,保存连接的相关属性信息,然后将该对象放到对应的数据结构中保存起来,这就是一个先描述再组织的过程,也是连接的抽象到建模的过程。
所以维护连接也是需要成本的,对于服务器而言,连接的成本体现在占用内存资源。
同理,tcp断开连接的过程叫做四次挥手,断开连接的本质就是将曾经创建好的连接的数据结构对象释放,并且在对应的数据结构中拿走 。
那么为什么tcp断开连接需要四次挥手呢?
这就要说到tcp的可靠性,它的可靠性很大一部分是通过确认应答来保证的,也就是说,每一条历史消息,只有在收到对方的确认应答,才能确保该消息被对方完整收到。而断开连接需要通信双方都发送断开连接的请求,那么再加上各自的应答, 这里就会有四次的信息的传递。
当然这些所谓的确认应答甚至所谓的发送和接受消息其实都是由操作系统自主完成或者说是由内核的 tcp协议 完成的,为什么这样说呢?
我们调用 write 或者 send等接口发送消息,其实并不是直接将我们的数据从用户缓冲区发送到了网络中,我们不要忘了网络协议栈的不断向下交付的过程,我们其实是将数据交给了下一层协议,也就是 tcp协议,而 tcp 协议首先会将我们要发送的数据封装拷贝到他维护的发送缓冲区中,而我们的send和write等接口其实将数据交给下层协议之后就已经返回了,但是此时我们的数据可能并没有真正发送到网络中,而可能还保存在tcp的发送缓冲区。
而我们的服务端发送数据给客户端的话,首先是将数据写到服务端所对应的发送缓冲区,然后由tcp将数据交付给下层最终数据会流转到客户端的接收缓冲区。
同理,客户端要给服务端发送消息,也是先将数据写到客户端对应的发送缓冲区,然后由客户端的操作系统或者说tcp将数据交付给网络层,最终通过网络将数据转移到了我们的服务端的接收缓冲区。
我们的发送其实并不是真正发送,而是拷贝,将数据从应用层的用户缓冲区拷贝到了内核的tcp维护的发送缓冲区。而接受也不是从网络中读取数据,而是从tcp维护的接收缓冲区中拷贝数据到我们的用户缓冲区。当然这个过程我们后续还会再讲一遍。
同时我们也能发现,由于双方的通信,客户端发消息服务端收消息, 和 服务端发消息客户端收消息 其实用的是两对缓冲区,那么其实 tcp 可以在发送的同时接收数据,不会互相影响,也就是说,客户端在发数据的时候,服务端也可以同时发数据,我们称这种通信是全双工的。
tcp的通信的总流程:
tcp 和 udp :
tcp : 有连接 ,可靠 ,面向字节流
udp: 无连接 , 不可靠 , 面向数据报