网络套接字~
文章目录
前言
上一篇文章中我们讲到了很多的网络名词以及相关知识,下面我们就直接进入udp服务器的实现。
一、udp服务器的实现
首先我们需要创建五个文件(文件名可以自己命名也可以和我一样),分别是makefile,udpclient.cc,udpclient.hpp,udpserver.cc,udpserver.hpp,下面我们先进行makefile的编写,在makefile中我们要一次创建两个可执行程序:
cc=g++ .PHONY:all all:udpClient udpServer udpClient:udpClient.cc $(cc) -o $@ $^ -std=c++11 udpServer:udpServer.cc $(cc) -o $@ $^ -std=c++11 .PHONY:clean clean: rm -f udpClient udpServer
我们通过all就可以创建多个可执行程序了,对于cc这个变量我们设置为g++,以后如果想换其他的编译器就可以直接替换了。
在udpserver.hpp这个文件中我们先写出整体框架:
namespace Server { class udpServer { public: udpServer() { } void InitServer() { } void start() { } ~udpServer() { } private: //服务器一定要有自己的服务端口号(注意端口号是16位的) uint16_t _port; //端口号 //实际上一款服务器不建议指明一个IP string _ip; //ip }; }
那么我们现在服务器的ip填多少呢?实际上我们只是完成测试,所以ip就填0.0.0.0就好了,这样的话任意的ip都能访问我们的服务器,所以我们定义一个static变量来保存ip:
static const string defaultIp = "0.0.0.0";
有了ip和端口号后,我们就可以用构造函数初始化了:
udpServer(const uint16_t& port,const string ip = defaultIp) :_port(port) ,_ip(ip) { }
我们的服务器未来要启动的话就必须先初始化然后再启动,所以我们写了init和start接口,那么该如何初始化呢?实际上不管是udp还是tcp,我们初始化都是需要套接字的,下面我们看看套接字的接口:
如何理解套接字呢,我们都知道linux一切皆文件,所以未来的网络通信一定是在同一个文件中只要和网卡设备关联起来就实现了网络通信,所以套接字的目的实际上是创建一个文件,可以看到我们的套接字有三个参数,第一个参数的解释是域,实际上就是让我们选择是进行网络通信还是本地通信,这里我们一般选择AF_INET选项,代表使用IPV4协议的网络通信。第二个参数是type,表面套接字要向我们提供服务的类型,怎么理解呢,如下图:
我们现在所写的UDP服务器的特点是不可靠传输无连接,而这正是与SOCK_DGRAM这个选项所匹配的,我们查看这个选项的解释可以看到:DGRAM适用于不可靠传输,连接少
我们下一篇要实现的TCP服务器,就会用到SOCK_STREAM这个选项,因为这个选项的解释是面向流式服务,而我们TCP的特点就是面向字节流。
第三个参数我们一般缺省为0,因为这个参数代表我们未来要采用什么协议,如果我们写为0,那么这个接口会根据我们填的前两个参数来帮我们确定第三个参数是选择TCP协议还是UDP协议。
这个接口的返回值相信大家也看到了,没错!一旦创建套接字成功,那么就会给我们返回一个文件描述符,如果失败则会给我们返回-1并且提供错误码。
了解了socket这个接口,那么我们下一步就是增加一个私有变量来接收socket返回的文件描述符(注意:这个文件描述符会被后面的接口多次用到):
然后我们在构造函数中将这个文件描述符初始化为-1:
udpServer(const uint16_t& port,const string ip = defaultIp) :_port(port) ,_ip(ip) ,_sockfd(-1) { }
然后我们初始化第一步:使用套接字
void InitServer() { //UDP第一步:创建了一个套接字 _sockfd = socket(AF_INET,SOCK_DGRAM,0); if (_sockfd==-1) { cerr<<"socket error: "<<errno<<" : "<<strerror(errno)<<endl; exit(SOCKET_ERROR); } cout<<"server socket success: "<<" : "<<_sockfd<<endl; }
如果套接字创建失败,就算没有给我们的文件描述符返回-1,由于我们初始化的时候就初始化为-1,所以还是会报错,注意:一旦连套接字都没创建成功,那么就没有继续的必要了直接退出即可,这里我们直接用枚举列出所有的退出码然后在退出的时候使用:
enum { SOCKET_ERROR = 2 };
创建成功我们就直接打印一下文件描述符即可。
下面进入初始化第二步:绑定端口和ip
首先第一个参数就是我们使用socket接口给我们返回的文件描述符,第二个参数是什么呢?大家看到这个参数名struct sockaddr*是否感到熟悉呢?没错就是我们上一篇讲到的sockaddr结构:
注意我们用的IPV4协议要用sockaddr_in这个结构,但是接口参数是sockaddr*这个结构,所以我们用的时候要做一下强制类型转换。可以看到我们的这个结构有4个位置需要我们填充,第一个AF_INET代表协议家族,第二个是端口号,第三个是IP地址,第四个是这个结构体的大小。
第三个参数是这个结构体的长度。
对于bind这个接口,如果成功则返回0,如果失败则返回-1.
由于bind的第二个参数是结构体指针,所以我们需要先创建一个新的结构体,然后对这个结构体进行填充,填充后传入参数:
struct sockaddr_in local; //在栈(用户)上定义了一个结构体变量 bzero(&local,sizeof(local)); local.sin_family = AF_INET; local.sin_port = htons(_port); //给别人发消息要将port和ip发送给对方 htons主机转网络序列(port是short类型) local.sin_addr.s_addr = inet_addr(_ip.c_str()); //1.string->uint32_t 2.主机转网络,ip是四字节htonl
bzero这个接口可以将我们的结构体里面的内容初始化为0,然后我们进行填充首先协议家族填写AF_INET这里是固定写法,然后就是填写端口号和ip地址,对于端口号,在结构体中的类型是16字节的short短整型,而htons这个接口可以将主机字节序转化为网络字节序(还记得我们上一篇讲的内容吗?网络中所有字节序必须是大端存储,而主机中有可能大端有可能小端,所以hton这个接口就是将任意的主机字节序转换为网络字节序的接口),htons后面的s代表要转化为16字节的,如果你的port是32字节的,那么你就需要用htonl转换为long类型。
对于ip的填充,首先结构体中的ip的类型是32位的,而我们刚刚在类内定义的是一个字符串,所以我们需要先将字符串转换为32位整形,然后再将这个32位整形由主机字节序转化为网络字节序,所以正常的步骤是:1.string->uint32_t 2.htonl(uint32_t) 但是现在我们有一个很好用的接口,这个接口是inet_addr,下面我们看看这个接口:
我们可以看到inet_addr的参数是一个const char*类型,这是什么呢?实际上这个类型就是我们ip常用的点分十进制类型,这个函数的返回值是in_addr_t,也就是说这个函数可以直接将点分十进制类型转化为我们结构体中所需要的ip类型。
我们将这个结构体填充完毕后,下面就直接绑定端口号和ip:
int n = bind(_sockfd,(struct sockaddr*)&local,sizeof(local)); if (n==-1) { cerr<<"bind error: "<<errno<<" : "<<strerror(errno)<<endl; exit(BIND_ERR); }
前面我们说过,bind的参数与我们ipv4协议使用的结构体类型不一样需要强制转化。当我们绑定失败,我们就打印错误信息,然后加一个bind接口的错误码用于返回:
enum { SOCKET_ERROR = 2 ,BIND_ERR };
绑定结束后我们的服务器初始化接口就结束了,下面我们进入服务器启动的接口,在这里我们要注意,服务器启动的本质就是一个死循环,就比如我们的手机系统,如果不是主动的退出,我们的手机是不会关机的。
对于udp服务器的启动,我们先大概的思考一下:./udpserver ip port也就是说需要三个参数,所以我们可以先设计一下udpserver.cc:
首先对于不懂如何启动服务器的用户我们需要加一个使用手册,保证用户可以正常启动服务器:
static void Usage(string proc) { cout<<"Usage:\n\t"<<proc<<" local_ip local_port\n\n"; } int main(int argc,char* argv[]) { if (argc!=3) { Usage(argv[0]); exit(USAGE_ERR); } uint16_t port = atoi(argv[2]); string ip = argv[1]; unique_ptr<udpServer> usvr(new udpServer(port,ip)); usvr->InitServer(); usvr->start(); return 0; }
对于main函数的参数我们之前已经讲过,argc代表你传了几个参数,argv这个数组对应的下标就是我们的参数。我们的目的是:./udpserver ip port这样使用,所以一共有三个参数,如果用户没有传3个参数,那么我们就直接提示如何使用并且退出程序,这里我们也可以弄一个错误码写到枚举中:
enum { USAGE_ERR = 1 ,SOCKET_ERROR ,BIND_ERR };
如果用户输入成功,那么我们先获取用户输入的端口号,因为用户输入的是字符串,所以需要将字符串转化为整形,我们用uint16_t的类型来接收端口号,因为我们的server类中的ip是string的,所以可以直接用string变量获取ip地址。然后我们用一个智能指针来管理服务器,在服务器中使用端口号和ip构造服务器,然后对服务器进行初始化和启动即可。
下面我们讲解一个在绑定前填充结构体中ip地址的问题:实际上我们在正在做项目的时候,是不会直接像下面这样指明一个IP的:
真实的写法应该是下面这样:
local.sin_addr.s_addr = INADDR_ANY; //任意地址绑定才是服务器的真实写法
什么意思呢?实际上就是当我们将服务器的IP设为ANY(本质其实是0),就代表未来发给我的数据只要是绑定了我的端口那么就能与我通信,这样就不会漏掉没有我IP地址的服务器给我发的消息了。还记得我们刚开始写的IP是什么吗?没错就是全0,也就是说我们现在写的这个服务器是不需要我们具体的IP只需要通过端口号就可以启动台服务器,并且未来客户端访问我们的服务器的时候是不需要指明IP的,任意一个IP+特定的端口号都能访问我们这台服务器。既然不需要IP,下面我们就修改一下代码:
static void Usage(string proc) { cout<<"Usage:\n\t"<<proc<<" local_port\n\n"; } int main(int argc,char* argv[]) { if (argc!=2) { Usage(argv[0]); exit(USAGE_ERR); } uint16_t port = atoi(argv[1]); unique_ptr<udpServer> usvr(new udpServer(handerMessage,port)); usvr->InitServer(); usvr->start(); return 0; }
所以实际上一个服务器的IP不重要,只要我们有端口号就能启动这台服务器,并且客户端用任意的IP和我们服务器特定的端口号就可以和我们的服务器通信。
下面我们编写start接口的代码,一旦启动我们就要接受数据,所以我们先认识一个接口:
这个接口的第一个参数是我们创建套接字返回的文件描述符,意思就是我们从哪个套接字里读数据。第二个参数是一个缓冲区,第三个参数是这个缓冲区的长度,2和3这两个参数代表的是你读到的数据要放在哪个缓冲区里,第四个参数是读取方式,这里我们默认填0代表阻塞式读取,也就是说客户端不给我们服务端发消息时,我们就一直等待客户端发消息,这就叫阻塞式读取。第五个参数和第六个参数非常重要,这两个参数是输出型参数,也就是说未来客户端给我们发消息时,会将数据放到缓冲区中,然后会将客户端的端口号和IP放到struct sockaddr*这个结构体当中,第六个参数就是这个结构体的长度,我们可以理解为:我们只需要创建一个空的结构体,然后客户端发消息后这个接口就会将客户端的端口号和IP放到我们自己创建的结构体中。
对于这个接口的返回值,如果成功则会给我们返回读到数据的字节数,如果失败返回-1.
static const int gnum = 1024; void start() { //服务器的本质实际上就是一个死循环 char buffer[gnum]; for (;;) { struct sockaddr_in peer; socklen_t len = sizeof(peer); //必填 ssize_t s = recvfrom(_sockfd,buffer,sizeof(buffer)-1,0,(structsockaddr*)&peer,&len); //成功返回字节数 } }
我们在使用recvfrom接口的时候,对于缓冲区是不用考虑\0的存在的,所以长度是1024-1.然后我们的结构体类型在参数中需要做强制类型转换,理由与上面同理。下面我们思考读到数据该干什么?我们的目的是实现一个udp服务器用来进行简单的聊天,聊天的时候要显示出客户端的ip和端口号,所以我们这样设计:
void start() { //服务器的本质实际上就是一个死循环 char buffer[gnum]; for (;;) { struct sockaddr_in peer; socklen_t len = sizeof(peer); //必填 ssize_t s = recvfrom(_sockfd,buffer,sizeof(buffer)-1,0,(struct sockaddr*)&peer,&len); //成功返回字节数 //1.数据是什么? 2.谁发的 if (s>0) { buffer[s] = 0; string clientip = inet_ntoa(peer.sin_addr); //1.网络序列 2.int->点分十进制IP uint16_t clientport = ntohs(peer.sin_port); string message = buffer; cout<<clientip<<"["<<clientport<<"]#"<<message<<endl; } } }
如果读取数据成功,我们先将缓冲区中最后一个位置填上\0,这样我们就可以用string来接收这个缓冲区中的字符了,然后我们获取用户的IP,由于结构体中的类型是网络的,所以我们需要将网络字节序转回主机字节序,而这里有一个接口与我们那会用的inet_addr正好相反,那就是inet_ntoa接口:
这个接口可以为完成两步:1.ntol(struct in_addr) 2.ntol(struct in_addr)->char*
ntol就是hton相反的转换接口。
获取到string类型的ip后,我们再接收端口号,同样需要转换,然后我们就打印用户端ip[端口号]+用户端发的消息即可。这样服务端的代码就实现完成了。
下面我们开始完成客户端代码:
首先客户端必须要有的是服务端的IP和服务端的port,所以我们先写一个框架:
namespace Client { class udpClient { public: udpClient(const string& serverip,const uint16_t &serverport) :_serverip(serverip) ,_serverport(serverport) { } void InitClient() { } void run() { } ~udpClient() { } private: string _serverip; uint16_t _serverport; }; }
前面我们说过,对于服务器而言,ip地址是不重要的,只需要端口号就可以启动服务器,因为一般服务器的IP都是全0,代表任意IP都可以访问,所以我们的客户端只需要随便填一个IP加上特殊的端口号就可以通信了,那么客户端内部ip和port肯定是必须要有的,明白了这个知识我们就先实现一下client.cc的框架:
#include "udpClient.hpp" #include <memory> using namespace Client; static void Usage(string proc) { cout<<"Usage:\n\t"<<proc<<" server_ip server_port\n\n"; } //./udpClient server_ip server_port int main(int argc, char* argv[]) { if (argc!=3) { Usage(argv[0]); exit(1); } string serverip = argv[1]; uint16_t serverport = atoi(argv[2]); unique_ptr<udpClient> ucli(new udpClient(serverip,serverport)); ucli->InitClient(); ucli->run(); return 0; }
这里的原理和我们服务器写的一模一样,我们就直接编写客户端代码:
首先我们客户端的初始化一定也是需要创建套接字的,既然要创建套接字就必须要有一个变量接收套接字返回的文件描述符:
udpClient(const string& serverip,const uint16_t &serverport) :_sockfd(-1) ,_serverip(serverip) ,_serverport(serverport) { }
然后我们编写初始化函数:
void InitClient() { // 1.创建socket _sockfd = socket(AF_INET,SOCK_DGRAM,0); if (_sockfd==-1) { cerr<<"socket error: "<<errno<<" : "<<strerror(errno)<<endl; exit(2); } cout<<"client socket success: "<<" : "<<_sockfd<<endl; //2. client要不要bind(必须要),client要不要明确的bind(不需要,不需要程序员自己bind(由OS自动形成端口绑定)) }
我们客户端的代码很简单,相比服务端客户端是不需要明确的去bind的,这是因为服务端必须要有指定的不能随意改变的端口,这样我们的客户端才能找到服务端,就像110一样,110这个电话是不能随意更改的,但是对于用户端自己来讲,我自己是什么样的端口不重要,我只需要通过服务端的端口访问服务端。所以我们一定要注意:客户端需要bind,但是不需要程序员明确的bind,这里我们自己不bind,操作系统察觉到我们没有绑定后会自动帮我们绑定,并且每次绑定的端口号都是随机的。
下面我们编写客户端运行的函数,客户端运行很简单,我们只需要让客户端输入数据,这样的话我们服务端就可以接受到数据,因为我们的目的就是简单的网络通信。
对于客户端发消息,我们需要认识一个接口:
第一个参数是我们创建套接字返回的文件描述符,第二个参数和第三个参数是一起的,buf是我们发送的数据所在的缓冲区,第三个参数是缓冲区的长度,第四个参数是发送方式,我们还是默认填0表示阻塞发送,有数据就发,没数据就等。第五个参数和第六个参数同样是输入型参数,我们客户端需要提前创建一个结构体,向里面填充我们客户端的ip和端口号,然后通过sendto接口发送到服务端,然后服务端就会接收到我们的数据和ip和端口号。
void run() { struct sockaddr_in server; memset(&server,0,sizeof(server)); server.sin_family = AF_INET; server.sin_addr.s_addr = inet_addr(_serverip.c_str()); server.sin_port = htons(_serverport); string message; while (!_quit) { cout<<"Please Enter# "; cin>>message; sendto(_sockfd,message.c_str(),message.size(),0,(struct sockaddr*)&server,sizeof(server)); } }
这里我们的客户端要持续的输入所以设为死循环,quit是我们新增的一个成员变量:
以上就是我们客户端的代码了,实际上客户端的代码非常简单,下面我们运行起来:
运行起来后我们可以看到是没问题的,这里也解释了为什么说客户端端口不需要程序员绑定,我们可以看到每次客户端重新登录在服务端显示的端口号都是不一样的,因为这是操作系统自动指定的端口号,而我们的服务端的端口号是唯一的,我们客户端必须输入服务端正确的端口号才能访问服务端,当然小伙伴们也一样将服务端的可执行程序直接发给你们的小伙伴,然后让他们直接通过任意ip+ 你的服务器端口号来和你进行聊天,下面是多人通过网络聊天的界面:
总结
以上就是我们udp服务器的所有内容了,下一篇文章我们将会把这个服务器改造称为英汉互译,大型聊天室等好玩的工具。