来写一个udp的代码
1.socket编程接口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 #include <sys/types.h> #include <sys/socket.h> #include <netinet/in.h> #include <arpa/inet.h> int socket (int domain, int type, int protocol) ;int bind (int socket, const struct sockaddr *address, socklen_t address_len) ;int listen (int socket, int backlog) ;int accept (int socket, struct sockaddr* address, socklen_t * address_len) ;int connect (int sockfd, const struct sockaddr *addr, socklen_t addrlen) ;
linux下一切皆文件,socket接口也不例外。其返回值本质上就是一个fd文件描述符,这样我们对网络的发送/接收操作,就转换成了对文件的写入/读取操作了
在这里面有一个比较重要的结构sockaddr需要说明一番
1.1 sockaddr
socket是一层抽象的网络编程接口,适用于各种底层网络协议,如IPv4/IPv6。同时,这个接口还可以用于系统内部的通信。这就实现了用一个接口来干两件事。
为此,就必须要在传值中进行一些修改。该接口新增了一个sockaddr,用来接收目标信息。这个值的参数可以是sockaddr_in/scokaddr_un/sockadd_in6之中的任意一个(需要强转指针)
sockaddr本身不存放任何信息。
这个参数可接收的结构体中,固定前16位就是用于标识符的。传到处理函数中,就会判断前16位中的标识符的类型,以确定传入参数的类型,再执行不同的实现
比如传入的scokaddr_un,前16位是AF_UNIX,那么当前使用的就是本地通信
sockaddr_in是ipv4通信,sockaddr_in6是ipv6通信
你可能会有疑惑,既然sockaddr不存放信息,那为何不把这个参数设置为一个void*的指针?反正最后都是进了函数之后判断参数类型,void*指针也能达成目标呀🧐
这个问题的答案很简单:当初设计这套接口的时候,C语言还不支持void*😂
1.2 存放位置
因为sockaddr_in这类的结构体,最终都需要被操作系统载入并实现网络操作。所以它们肯定是需要载入内核中的
但这并不意味着这类结构体是存放在内核里面的,而是存放在用户栈 ,用户态和内核态交换的时候,通过接口传值载入到内核的空间进行使用
2.server
了解了上面的信息,接下来,认识一下如果想建立一个udpserver,需要怎么操作吧!
以下是一个server的类,包含了端口、ip、socker fd三个基本信息
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 class UdpServer { public : UdpServer (uint16_t port,const string& ip="" ) : _port((uint16_t )port), _ip(ip), _sockfd(-1 ) {} private : uint16_t _port; string _ip; int _sockfd; };
2.1 创建套接字
这里需要用的是下面这个接口
1 int socket (int domain, int type, int protocol) ;
可以设置为本地,也可以设置为网络。支持如下参数
1 2 3 4 5 6 7 8 9 10 11 Name Purpose Man page AF_UNIX, AF_LOCAL Local communication unix(7) AF_INET IPv4 Internet protocols ip(7) AF_INET6 IPv6 Internet protocols ipv6(7) AF_IPX IPX - Novell protocols AF_NETLINK Kernel user interface device netlink(7) AF_X25 ITU-T X.25 / ISO-8208 protocol x25(7) AF_AX25 Amateur radio AX.25 protocol AF_ATMPVC Access to raw ATM PVCs AF_APPLETALK Appletalk ddp(7) AF_PACKET Low level packet interface packet(7)
因为我们要创建的是一个网络服务器,所以这里设置为AF_INET,也就是IPV4的服务
第二个参数type指代套接字的类型,决定了通信时的报文类型
这里支持流式(TCP)或者用户数据报(UDP),以及RAW原始格式(能够直接访问协议,方便debug)
1 2 3 4 5 SOCK_STREAM Provides sequenced, reliable, two-way, connection-based byte streams. An out-of-band data transmission mechanism may be supported. SOCK_DGRAM Supports datagrams (connectionless, unreliable messages of a fixed maximum length). SOCK_RAW Provides raw network protocol access.
更多支持的参数参考man手册
返回值是一个linux系统的文件描述符
1 2 RETURN VALUE On success, a file descriptor for the new socket is returned. On error, -1 is returned, and errno is set appropriately.
这样,我们就能写出第一行代码,以及对这个代码的返回值判断
1 2 3 4 5 6 _sockfd = socket(AF_INET, SOCK_DGRAM, 0 ); if (_sockfd < 0 ){ logging(FATAL, "socket:%s:%d" , strerror(errno), _sockfd); exit (1 ); }
因为socket是文件描述符,为了规范,我们还可以在析构函数里面调用一下close
1 2 3 4 ~UdpServer () { close (_sockfd); }
2.2 配置sockaddr_in
1 2 3 struct sockaddr_in local ;memset (&local,0 ,sizeof (local));
因为用的是ipv4的网络通信,所以这里需要初始化一个sockaddr_in类型
此时在vscode的代码补全中,可以看到4个成员,需要对它们赋值以配置服务器信息
首先是把协议家族设置为IPV4,端口配置为函数参数中的端口
1 2 3 4 local.sin_family = AF_INET; local.sin_port = htons(_port);
随后配置ip
1 2 local.sin_addr.s_addr = _ip.empty() ? htonl(INADDR_ANY) : inet_addr(ip_.c_str());
这里采用了?:三目操作符,如果类构造的时候传入的ip是空(没有配置ip)那就直接设置为任意ip,否则传入成员变量;
这样对sockaddr_in的配置就完成了。
2.2.1 inet_addr
这里需要使用inet_addr函数对传入的字符串类型的ip(如192.168.0.1)进行转换
1 in_addr_t inet_addr (const char *cp) ;
因为对于网络来说,它并不认识字符串类型的ip,而是要用网络字节流规定的类型。
1 2 3 4 5 6 typedef uint32_t in_addr_t ;struct in_addr { in_addr_t s_addr; };
对于该接口的底层做一个简单的说明:其实就是利用位段,将数据转换为一个特殊的类型
1 2 3 4 5 6 7 8 struct ip { uint32_t part1:8 ; uint32_t part2:8 ; uint32_t part3:8 ; uint32_t part4:8 ; }
2.2.2 inet_ntoa
同样的,如果我们作为客户端接受到了网络请求中的ip,可以用inet_ntoa将其转换为字符串类型。
1 char *inet_ntoa (struct in_addr in) ;
这里就引申出了一个问题:返回值的char*是存在哪里的?是静态区还是malloc?
1 The inet_ntoa() function converts the Internet host address in, given in network byte order, to a string in IPv4 dotted-decimal notation. The string is returned in a statically allocated buffer, which subsequent calls will overwrite.
手册告诉我们,这个函数是维护了一个static变量来存放返回的ip的。
因此,该函数并不是一个线程安全 的函数,在APUE中明确标明了这一点;后续的调用会覆盖掉这个IP地址;
2023.09.15的面试问道了这个问题,感兴趣的可以看看我的面经 里面的题目和解释,这里把题目贴出来;如下的函数调用会不会有什么问题?
1 printf ("%s %s\n" ,inet_ntoa(ip1),inet_ntoa(ip2));
说结论:对于inet_aton函数而言,正确的调用 办法是每次调用后都立即取走返回的IP地址字符串,可以用std::string接收,也可以用strcmp拷贝到一个自己定义的字符串数组变量中;
具体的介绍请移步面经 哦!
2.3 bind绑定ip端口
1 2 3 4 5 #include <sys/types.h> #include <sys/socket.h> int bind (int sockfd, const struct sockaddr *addr, socklen_t addrlen) ;
这个接口的作用就是指定socket和sockaddr进行绑定。第三个参数是addr元素的大小(不是指针大小,别搞错了)
1 2 3 4 5 6 7 if (bind (_sockfd,(const struct sockaddr *)&local, sizeof (local)) == -1 ){ logging (FATAL, "bind: %s:%d" , strerror (errno), _sockfd); exit (2 ); } logging (DEBUG,"socket bind success: %d" , _sockfd);
绑定了之后,我们的服务器就配置成功了
测试一下,可以看到编译没有报错,也能正常运行!
1 2 3 4 5 [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ make udpServer g++ -o udpServer udpServer.cpp -std=c++11 [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ ./udpServer DEBUG | 1675326460 | muxue | socket create success: 3 DEBUG | 1675326460 | muxue | socket bind success: 3
在使用UDP进行通信的时候,我们不一定需要绑定具体的IP地址,可以用INADR_ANY来代替具体的本机IP地址,但是端口号一定要进行绑定 。(不然系统没办法让你的进程和某个端口关联来接收信息)
2.3.1 main
现在先来简单写一下main函数中启动服务的命令行参数吧
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 int main (int argc,char * argv[]) { if (argc!=2 && argc!=3 ) { cout << "Usage: " << argv[0 ] << " port [ip]" << endl ; return 1 ; } string ip; if (argc==3 ) { ip = argv[2 ]; } UdpServer s (atoi(argv[1 ]),ip) ; s.start(); return 0 ; }
为了测试,先把start()函数设置为一个死循环
1 2 3 4 5 6 7 8 void start () { while (1 ) { cout << "running " << getpid() << endl ; sleep(1 ); } }
编译运行,可以看到错误提示是可以用的。正确添加参数之后,也能绑定并开始运行
1 2 3 4 5 6 7 8 9 10 11 [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ make udpServer g++ -o udpServer udpServer.cpp -std=c++11 [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ ./udpServer Usage: ./udpServer port [ip] [muxue@bt-7274:~/git/linux/code/23-02-01 udp]$ ./udpServer 8080 DEBUG | 1675327610 | muxue | socket create success: 3 DEBUG | 1675327610 | muxue | socket bind success: 3 running 4467 running 4467 running 4467 ^C
注意,bind这个函数是不允许你绑定云服务器的公网ip的 。因为云服务器并不是直接暴露在公网上的,而是由提供商的入口服务器进入内网,在进入你的服务器。所以他不允许你绑定公网ip;
1 2 3 $ ./udpServer 8080 云服务器公网ip DEBUG | 1675327690 | muxue | socket create success: 3 FATAL | 1675327690 | muxue | bind: Cannot assign requested address:3
一般情况下,可以选择不绑定ip,或者绑定本地端口127.0.0.1
如果绑定了127.0.0.1,那么服务只有本地可以访问。不绑定端口,就会默认绑定成0.0.0.0,允许本地和远程端口连接
1 2 3 4 5 $ ./udpServer 8080 127.0.0.1 DEBUG | 1675327757 | muxue | socket create success: 3 DEBUG | 1675327757 | muxue | socket bind success: 3 running 5067 running 5067
2.3.2 netstat
可以用netstat -lnup命令查看当前开放的端口信息
可以看到,第一行就是我们的udp服务器,本地端口是我们绑定的127.0.0.1:8080,远程端口是0.0.0.0:*,代表允许任何远程ip的任何端口来访问
2.4 开始运行
上面的操作只是初始化了这个udp服务器的信息,并没有让它真正的运行起来;
接下来要做的就是让服务器开始接收信息,并在屏幕上打印出来
2.4.1 recvfrom
这个接口的作用是来接收信息
1 2 3 4 5 #include <sys/types.h> #include <sys/socket.h> ssize_t recvfrom (int sockfd, void *buf, size_t len, int flags, struct sockaddr *src_addr, socklen_t *addrlen) ;
第一个参数是前面创建的套接字
第二个参数是用来接收信息的缓冲区
第三个参数是缓冲区的大小
第四个参数是标识符,设置为0,代表阻塞等待
第五个参数,输出型参数,获取发送方的信息
第六个参数,输入输出型参数,需要初始化为sizeof(src_addr)
函数的返回值是接收到的数据的长度,没有接收到或者接受失败,则为-1
示例如下
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 char inBuf[BUF_SIZE];struct sockaddr_in peer ; socklen_t len = sizeof (peer); ssize_t s = recvfrom(_sockfd, inBuf, sizeof (inBuf)-1 ,0 ,(struct sockaddr *)&peer, &len);if (s > 0 ) { inBuf[s] = '\0' ; } else if (s == -1 ) { logging(WARINING, "recvfrom: %s:%d" , strerror(errno), _sockfd); continue ; }
这样就能在inBuf中直接获取到发送信息的内容
2.5 服务端start
以下是服务端运行的完整代码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 void start () { char inBuf[BUF_SIZE]; while (1 ) { struct sockaddr_in peer; socklen_t len = sizeof (peer); ssize_t s = recvfrom (_sockfd, inBuf, sizeof (inBuf)-1 ,0 ,(struct sockaddr *)&peer, &len); if (s > 0 ) { inBuf[s] = '\0' ; } else if (s == -1 ) { logging (WARINING, "recvfrom: %s:%d" , strerror (errno), _sockfd); continue ; } string senderIP = inet_ntoa (peer.sin_addr); uint16_t senderPort = ntohs (peer.sin_port); logging (NOTICE, "[%s:%d]# %s" , senderIP.c_str (),senderPort, inBuf); } }
如果你想让另外一台主机访问这个服务,则需要在云服务器控制台和linux系统 中同时开放对应的udp端口
参考 【Linux】设置系统防火墙
3.client
有了服务端,也要有对应的客户端来发送消息;除了发送消息的部分,其余操作和服务端基本一致。
3.1 sendto
1 2 3 4 5 6 7 8 9 #include <sys/types.h> #include <sys/socket.h> ssize_t send (int sockfd, const void *buf, size_t len, int flags) ;ssize_t sendto (int sockfd, const void *buf, size_t len, int flags, const struct sockaddr *dest_addr, socklen_t addrlen) ;ssize_t sendmsg (int sockfd, const struct msghdr *msg, int flags) ;
这里我们要用的是sendto接口
第一个参数是socket套接字
第二个参数是用于输入的缓冲区
第三个参数是缓冲区的类型
第四个参数是标识符,也设置为0
第五个参数和第六个参数与recvfrom一致,为目标服务器的信息
关于flag参数,man手册中有更多选项,这里我们依旧传入0采用默认策略
1 The flags argument is the bitwise OR of zero or more of the following flags.
3.2 客户端需不需要手动bind?
首先我们要明确一点,bind函数并没有规定一定要是服务端才能使用。也就是说,要不要使用bind是程序猿自己的选择。
答案其实很简单:那就是不需要手动bind
首先我们要知道一点:如果一个网络进程在启动的时候没有手动bind端口,系统是会自动分配一个未使用的端口给它的
对于服务器来说,IP:端口必须固定,否则没有办法给客户端提供稳定的服务。客户又不能拆了你的应用程序修改源码中的端口!
而对于客户端来说,端口应该让系统自动分配。因为这样能避免冲突问题。不然如果有另外一个应用占用了客户端bind的端口,那这个程序就会因为端口冲突 而一直打不开!
所以,客户端不需要我们调用bind函数,只需要配置好服务端的目标ip和目标端口就行了
3.3 代码示例
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 #include <iostream> #include <string> #include <cstdlib> #include <cassert> #include <unistd.h> #include <strings.h> #include <netinet/in.h> #include <arpa/inet.h> #include <sys/types.h> #include <sys/socket.h> using namespace std ; struct sockaddr_in server ;int main (int argc, char *argv[]) { if (argc != 3 ) { cout << "Usage:\n\t" << argv[0 ] << " server_ip server_port" << endl ; return 1 ; } string server_ip = argv[1 ]; uint16_t server_port = atoi(argv[2 ]); int sockfd = socket(AF_INET, SOCK_DGRAM, 0 ); if (sockfd<0 ) { cout << "socket 创建失败" << endl ; return 2 ; } bzero(&server, sizeof (server)); server.sin_family = AF_INET; server.sin_port = htons(server_port); server.sin_addr.s_addr = inet_addr(server_ip.c_str()); string buffer; while (true ) { cerr << "Please Enter# " ; getline(cin , buffer); sendto(sockfd, buffer.c_str(), buffer.size(), 0 , (const struct sockaddr *)&server, sizeof (server)); } close(sockfd); return 0 ; }
3.4 运行测试
这里提供一个makefile,来快速编译服务端/客户端的源码
1 2 3 4 5 6 7 8 9 10 11 .PHONY :allall:udpClient udpServer udpClient: udpClient.cpp g++ -o $@ $^ -std=c++11 -lpthread udpServer:udpServer.cpp g++ -o $@ $^ -std=c++11 .PHONY :cleanclean: rm -f udpClient udpServer
运行服务器,指定8080端口启动。再运行客户端,指定127.0.0.1本地ip和8080端口
可以看到,右侧我们收到的信息,都在左侧被打印了出来,同时显示了来源ip和端口
3.5 windows客户端
让我没想到的是,windows上网络的接口和linux很相似;这里提供一个windows下的udp客户端,向我们的云服务器发送信息
注:进行测试前,一定要在防火墙里面开放云服务器对应的udp端口
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 #define _WINSOCK_DEPRECATED_NO_WARNINGS 1 #include <winsock2.h> #include <stdio.h> #include <string.h> #include <string> #include <iostream> using namespace std;#pragma comment(lib,"ws2_32.lib" ) #define BUFFER_SIZE 1024 int main () { WSADATA WSAData; if (WSAStartup (MAKEWORD (2 , 2 ), &WSAData) != 0 ) { printf ("初始化失败!" ); return -1 ; } SOCKET sock_Client = socket (AF_INET, SOCK_DGRAM, IPPROTO_UDP); SOCKADDR_IN addr_server; addr_server.sin_family = AF_INET; addr_server.sin_port = htons (10000 ); addr_server.sin_addr.S_un.S_addr = inet_addr ("127.0.0.1" ); string sendBuf; while (true ) { cout << "请输入要传送的数据: " ; getline (cin,sendBuf); sendto (sock_Client, sendBuf.c_str (),sendBuf.size (), 0 , (const SOCKADDR*)&addr_server, sizeof (addr_server)); cout << sendBuf.size () << ": " << sendBuf << endl; } closesocket (sock_Client); WSACleanup (); return 0 ; }
测试一下,可以看到云服务器成功收到了信息,但因为windows和linux的文字编码问题,没能正确显示出中文
发送英文信息是没有问题的!
4.更进一步
4.1 记录用户
有用户给你发送信息,理论上来说,服务端应该记录下用户,以备debug;
这部分并不难,我们记录下用户的ip和端口,还有用户的peer结构体,在服务器里面维护一个map来存放就可以了
1 2 3 4 5 6 7 8 9 10 11 12 13 void CheckUser (struct sockaddr_in peer) { string tmp = inet_ntoa (peer.sin_addr); tmp += ':' ; tmp += to_string (ntohs (peer.sin_port)); auto it = _usrMap.find (tmp); if (it == _usrMap.end ()) { _usrMap.insert ({tmp,peer}); } }
4.2 客户端接收回信
客户端发送信息给服务器后,可以来接收一下服务器的回信。比如在日常生活中,我们发邮件的时候,需要等待对方回信,这才表明你的信对方确实收到了,而不是丢在半路上了
1 2 3 pthread_t t;pthread_create(&t, nullptr, recverAndPrint, (void *)&sockfd);
为了方便,这里采用多线程的方式来操作;客户端在接收到服务器的回信后,会打印出来
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 void *recverAndPrint (void *args) { while (true ) { int sockfd = *(int *)args; char buffer[1024 ]; struct sockaddr_in temp; socklen_t len = sizeof (temp); ssize_t s = recvfrom (sockfd, buffer, sizeof (buffer), 0 , (struct sockaddr *)&temp, &len); if (s > 0 ) { buffer[s] = 0 ; cout << "server echo# " << buffer << "\n" ; } } }
4.3 消息路由
所谓消息路由,就是把接收到的消息广播给所有用户。可以理解为一个简单的聊天室。
上面我们已经获取并记录了信息,下面要做的就是把信息重新发给其他用户;操作和客户端的发送是一样的
1 2 3 4 5 6 7 8 9 10 11 12 13 void MsgRoute (const char * inBuf,size_t len) { struct sockaddr_in user; for (auto e:_usrMap) { user.sin_family = AF_INET; user.sin_port = e.second.sin_port; user.sin_addr.s_addr = e.second.sin_addr.s_addr; sendto (_sockfd, inBuf, len, 0 , (const struct sockaddr *)&user, sizeof (user)); } }
测试,可以看到,服务端把收到的消息发送给了用户
再新增一个客户端进行测试,可以看到两个客户都收到了服务器的回信
这里对于聊天室来说还有一个小问题,那就是聊天框里面并不会二次出现你的消息。也就是服务器不会把你发送的消息再转发给你。
我们在消息路由函数里面进行判断即可!
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 void MsgRoute (struct sockaddr_in peer,const char * inBuf,size_t len) { struct sockaddr_in user; for (auto e:_usrMap) { if (e.second.sin_port != peer.sin_port || e.second.sin_addr.s_addr != peer.sin_addr.s_addr) { user.sin_family = AF_INET; user.sin_port = e.second.sin_port; user.sin_addr.s_addr = e.second.sin_addr.s_addr; sendto (_sockfd, inBuf, len, 0 , (const struct sockaddr *)&user, sizeof (user)); } } }
因为乱序打印的问题,所以看的可能不是很明显。但是我们的目的已经达到了!
这样打印看的不是很清楚,可以使用管道文件来实现输出重定向
运行客户端的时候,指定输出
1 ./udpClient 127.0.0.1 1000 > fifo
在另外一个bash里面,用cat来获取输出
这就不会出现乱序打印的问题了。
fifo是一个管道文件,需要执行cat后(读端),客户端(写端)才能运行
5.关于什么时候需要bind
本文以下内容建议学习了UDP底层报文和相关网络协议栈的知识后再来看,会方便理解一些。
参考文章:socket 通信关于bind IP地址
5.1 情况一
情况一:若有客户端和服务器 之分的程序,创建sock后即可在该socket上用recvfrom/sendto方法发送接受数据了。
因为客户端只需要用sendto发送数据到指定的地址,所以不需要bind本地的ip和端口。当然若是bind了,程序也没什么问题,区别就是不bind的时候,系统会自动bind()指定本机的socket参数地址来进行发送数据库。
而服务器因为必须要有一个显式的、固定的 IP端口供客户端连接,所以接收方 是必须要进行bind的。
那UDP服务器是怎么知道客户端的IP地址和UDP端口?一般来说有两种方式:
一种是客户端发消息显式地告诉服务器IP地址和端口,消息内容就包括IP地址和UDP端口。
另外一种就是隐式的,服务器从收到的包的报文头部中得到包的源IP地址和端口。
5.2 情况二
若是没有客户端和服务器之分的程序,即自己指定特定端口的UDP对等通信(双向对等通信),则客户端和服务器都需要bind() IP地址和端口了。因为双方都需要知道对方的IP和端口才能进行数据收发。
5.3 多播
但UDP中更常用的是广播分发 ,服务端socket设定一个X.X.X.255的广播地址并始终向它发送,每个客户端建立的socket只需要加入 这个广播地址便可以收到,这个行为称为加入多播组 。这便是多播的概念。
一个多播组可以被多个进程加入,加入了这个多播组后,所有进程将收到相同的消息 。这样就实现了一对多通信。
请注意,多播地址是不能进行bind的 ,我们需要用相关的接口将自己的fd加入 目标多播组,才能从这个多播组中收到信息。发送方是不需要加入多播组的,直接往多播组的ip地址里面发就行了。
接收方需要调用接口加入指定多播组
发送方不需要加入多播组
重点: 加入了多播组后,将无法收到 原本bind的ip地址的消息!比如一个udp的socket,原本bind了0.0.0.0:5000,在加入多播组之前 ,我能正常从5000端口中收到信息;但是加入了多播组后,我就只能从多播组里面收信息了。
接收方加入多播组的接口如下,在接收方的bind之后 调用就行了
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 #include <stdio.h> #include <stdlib.h> #include <string.h> #include <arpa/inet.h> #define MCAST_GROUP "224.0.0.1" #define MCAST_PORT 5007 int main () { int sockfd; struct sockaddr_in addr ; socklen_t addrlen = sizeof (addr); char buffer[1024 ]; if ((sockfd = socket(AF_INET, SOCK_DGRAM, 0 )) == -1 ) { perror("socket" ); exit (EXIT_FAILURE); } memset (&addr, 0 , sizeof (addr)); addr.sin_family = AF_INET; addr.sin_addr.s_addr = htonl(INADDR_ANY); addr.sin_port = htons(MCAST_PORT); if (bind(sockfd, (struct sockaddr*)&addr, sizeof (addr)) == -1 ) { perror("bind" ); exit (EXIT_FAILURE); } struct ip_mreq mreq ; mreq.imr_multiaddr.s_addr = inet_addr(MCAST_GROUP); mreq.imr_interface.s_addr = htonl(INADDR_ANY); if (setsockopt(sockfd, IPPROTO_IP, IP_ADD_MEMBERSHIP, &mreq, sizeof (mreq)) == -1 ) { perror("setsockopt" ); exit (EXIT_FAILURE); } if (recvfrom(sockfd, buffer, sizeof (buffer), 0 , (struct sockaddr*)&addr, &addrlen) == -1 ) { perror("recvfrom" ); exit (EXIT_FAILURE); } printf ("Received: %s\n" , buffer); close(sockfd); return 0 ; }
因为多播通常需要绑定特殊的IP地址(例如 224.0.0.0 到 239.255.255.255),这些IP地址是无法在公网 上使用的,所以多播是只存在于局域网中的概念。要想实现多播,发送端和接受端必须处于同一局域网 。
5.4 直播推流
以下都是我根据自己的理解瞎逼逼的,有问题还请指出。
公网上,也有使用UDP进行“多播”的例子,比如我们常用的直播就是使用的UDP(因为直播是一个视频流推送给N个用户,如果每个用户都建立一个tcp连接会有巨大的消耗,服务器压根顶不住。再加上tcp需要等待用户发回ack,在一对多的大型推流场景下,等待ack的消耗也不容忽视)
直播推流情况下,其实并不是用局域网内的多播实现的,而是用UDP模拟实现一个类似“多播”的一对多通信。
下面举个例子,我们暂时认为客户机也是拥有公网IP的 ,后续学习到IP层,会提到NAT技术,到时候就能理解家宽是怎么和服务器通信的了
客户机想看主播的直播,直播app会发送一个请求到特定的IP:端口,请求建立一个直播推流的连接。这个请求中就会包含客户机自己的IP地址和端口号
服务端接受请求,解析出客户机的IP和端口号,并将其加入到推流队列中
服务端每一次推流视频,都遍历全部连接到这个直播上的客户机的IP端口,通过UDP向他们发送视频数据。
这样就实现了一个服务端向多个客户机“广播”直播推流数据的通信。
需要注意的是,这和前文提到的局域网内的多播完全不同!这里我们依旧是用公网IP和端口进行一对一通信的,只不过服务器端进行了处理,会向所有客户机发送视频流数据罢了。
你可能会有疑惑,现在的直播那么多人看,一个热门的直播间几万人甚至上十几万人,服务器用遍历发送的方式来得及吗?如果遍历发送一次所有客户机的耗时超过了每次发送的数据大小,岂不是大家都卡顿了吗?
如果你了解UDP报文的结构的话,就知道UDP一次发送最多只能发送64KB的数据,对于直播推流/视频来说,64KB的数据恐怕只有1秒的视频。
其实我们直播出现卡顿,就是这么个原因。服务端没有办法推流那么多用户了,就会出现卡顿乃至中断的情况。而且,只用同一台服务器对所有用户进行广播肯定是不够的,这时候就会引入不同线路来进行优化:
主播连接一个主服务器,推流自己的直播视频数据
主服务器将该直播视频流数据发送给全国各地的子服务器
用户A进入直播间,将请求主服务器,发送自己的IP和端口号
主服务器将用户A的IP和端口号解析,根据IP属地,发送给一个距离用户A最近的子服务器B,让子服务器B来给用户A进行直播的推流
此时用户A收到的视频数据:主播->主服务器->子服务器B->用户A;
这样,推流的压力就能分散给不同的子服务器线路。
我们看直播的时候可以选择线路切换,其实也就是在不同的子服务器中进行切换,如果某个子服务器压力较低,此时在这个子服务器的线路上接收直播推流,用户的观看体验就比较流畅了。
more…
关于udp编程的操作到这里就Over啦,现在我们认识了大部分的网络接口,下一步的目标,就是实现tcp服务器啦!